Перейти к основному содержимому

ТЗ: Жизненный цикл клейма и инвойсов рулетки

Метаданные

ПараметрЗначение
Дата создания2026-06-09
Дата последнего изменения2026-06-09
Статус апрува⏳ На рассмотрении
Дата апрува

Доработка модуля рулетки (apps/backend/src/roulette/) и связанных сервисов клейма (claimTokens) и оплат (web3-payments). Решаемые проблемы:

  1. Слишком короткий expiration клейм-подписи (15с). On-chain контракт MagnetTokensClaim.claimTokens требует block.timestamp <= expiration. 15 секунд часто не хватает на подтверждение в кошельке + майнинг → клейм ревертит как просроченный.
  2. Неинформативный кулдаун. При повторном запросе подписи бэкенд бросает захардкоженное "Signature is already in use. Wait 15 seconds" — фронт не знает, сколько реально осталось ждать.
  3. Защита от двойного клейма — нужно подтвердить и задокументировать, как именно гарантируется, что приз нельзя склеймить дважды.
  4. Висящие pending-инвойсы. Подпись на оплату инвойса колеса уходит в контракт с invoiceExpiration = 0 (бессрочно), просроченные/неоплаченные инвойсы остаются в статусе pending навсегда.

2.1. Пользовательские сценарии

  • Клейм приза. Пользователь выигрывает приз → видит его в «Доступно для клейма» → жмёт «Клейм» → бэкенд выдаёт подпись (живёт 60с) → фронт сразу шлёт claimTokens. Подписи хватает на подтверждение в кошельке и майнинг.
  • Повторный запрос подписи. Если пользователь жмёт «Клейм» снова, пока активна предыдущая подпись — получает понятное сообщение «Подождите N сек.» с реальным остатком.
  • Оплата спина колеса. Пользователь покупает спин → сначала проходит allowance (approve ERC20), затем запрашивается подпись на оплату и сразу pay(). Окно 60с покрывает только транзакцию pay().
  • Очистка инвойсов. Просроченные неоплаченные инвойсы автоматически удаляются при подтягивании оплат.

2.2. Бизнес-логика

Единое время жизни подписи

Новая константа ROULETTE_SIGNATURE_TTL_SECONDS = 60 в roulette.constants.ts. Используется в трёх местах:

  • on-chain expiration клейм-подписи рулетки;
  • кулдаун на повторный запрос клейм-подписи (== времени жизни подписи);
  • invoiceExpiration / expiredAt подписи на оплату инвойса колеса.

Общая academy-константа TIME_TO_LIFE_TRANSACTION_SIGNATURE (15с) не меняется — изменения аддитивны и затрагивают только путь рулетки.

Кулдаун и остаток секунд

Кулдаун-кэш (redis) хранит epoch истечения (мс) как значение вместо true. При попадании в кэш бэкенд считает retryAfterSeconds = ceil((expiresAt - now) / 1000) и бросает BadRequestException с телом { message, retryAfterSeconds }. Так как кулдаун == времени жизни подписи (60с), пока старая подпись валидна on-chain, новая не выдаётся; повторная отправка идёт той же ещё валидной подписью.

Защита от двойного клейма (Часть 3)

Гарантия — многоуровневая, главный гарант on-chain:

  1. On-chain (авторитетный уровень). MagnetTokensClaim.claimTokens:

    • usedObjectIds[id] → revert "Object id already claimed";
    • usedSignatures[sig] → revert "Signature already has been used".

    Средства физически нельзя получить дважды.

  2. Реконсиляция в бэкенде. Событие TokensClaimed (крон каждые 10с) → ClaimTokensService.markClaimTokenClaimed (MySQL claimed=true) + RouletteService.reconcileClaimedToken (спин WonPendingClaim → Claimed, приз reserved → claimed). Переход защищён findOneAndUpdate по статусу — идемпотентен.

  3. Гейт на повторную подпись. getAvailableClaims отдаёт только спины WonPendingClaim с claimExpiresAt > now; signMessageForClaimIds фильтрует claimed=false, invalidatedAt=null. После реконсиляции события новую подпись выдать нельзя.

  4. Окно гонки. Единственное окно — между отправкой транзакции и реконсиляцией события (крон 10с + лаг блоков). Кулдаун = времени жизни подписи (60с) перекрывает это окно; даже если бы вторая подпись выдалась после истечения кулдауна, on-chain usedObjectIds отклонит реальный двойной клейм.

Вывод: дополнительный лок в бэкенде не нужен (YAGNI). Кулдаун = времени жизни дополнительно сужает практическое окно.

Инвойсы (Часть 4)

  • В инвойс сохраняется expiredAt (epoch в секундах — для сравнения с timestamp блока).
  • expiredAt / invoiceExpiration устанавливаются только для инвойсов ROULETTE_SPIN_BUY. VIP/NFT остаются на invoiceExpiration = 0 (без изменений).
  • При подтягивании оплат удаляются все инвойсы по условию: delete * where expiredAt < last_block_timestamp AND status = 'pending'. last_block_timestamp — timestamp последнего блока сети. Так как expiredAt несут только инвойсы рулетки, VIP/NFT не затрагиваются.

2.3. UI/UX требования

  • В ClaimRouletteTokensButton.onClaim блок catch читает error.response.data.retryAfterSeconds (та же форма, что существующий прецедент retryAfterMs) и показывает тост «Подождите N сек.». Прочие ошибки — старый тост.
  • Во флоу оплаты спина (pages/wheel/[id]/index.tsx → handleInstantWinPurchase) поменять порядок: allowance → подпись → pay() (см. 3.2).

3.1. Архитектура

Затрагиваемые модули:

  • apps/backend/src/roulette/ — константа TTL, проброс TTL в подпись клейма.
  • apps/backend/src/claimTokens/claimTokens.service.ts — TTL подписи, кулдаун с остатком секунд.
  • apps/backend/src/web3-payments/ — поле expiredAt, установка expiration для инвойсов рулетки, удаление просроченных pending-инвойсов.
  • apps/frontend/src/entities/wheel/ui/ClaimRouletteTokensButton.tsx — тост с остатком секунд.
  • apps/frontend/src/pages/wheel/[id]/index.tsx — порядок allowance → подпись → pay.

3.2. Описание технической реализации

Backend — claimTokens.service.ts

  • createSignature(...): добавить параметр signatureTtlSeconds. Кэш-значение — expiresAtMs (число). При попадании: retryAfterSeconds = Math.ceil((expiresAtMs - Date.now()) / 1000), throw new BadRequestException({ message: '...', retryAfterSeconds }). TTL кэша = signatureTtlSeconds.
  • _signMessage(...): добавить опциональный signatureTtlSeconds (default TIME_TO_LIFE_TRANSACTION_SIGNATURE), использовать его и для expiration, и пробросить в createSignature.
  • signMessageForClaimIds(...) (путь рулетки): передать ROULETTE_SIGNATURE_TTL_SECONDS.
  • Academy-пути (signMessage, signMessageToClaimAll, signMessageWithAddress) не меняются — дефолт 15с сохраняется.

Backend — web3-payments

  • invoice.schema.ts: @Prop({ type: Number, default: null }) expiredAt?: number | null.
  • web3-payments.service.ts → getSignatureToPayInvoice: если invoice.action === InvoiceActions.ROULETTE_SPIN_BUY, то invoiceExpiration = Math.floor(Date.now() / 1000) + ROULETTE_SIGNATURE_TTL_SECONDS, сохранить expiredAt = invoiceExpiration в инвойс (findByIdAndUpdate). Иначе invoiceExpiration = 0 (как сейчас).
  • web3-payments.service.ts: новый метод deleteExpiredPendingInvoices(lastBlockTimestamp: number)invoiceModel.deleteMany({ status: 'pending', expiredAt: { $ne: null, $lt: lastBlockTimestamp } }).
  • check-payments.job.ts: после listenForEvents получить timestamp последнего блока (paymentsManagerContract.runner.provider.getBlock('latest').timestamp) и вызвать deleteExpiredPendingInvoices(blockTs).

Frontend

  • ClaimRouletteTokensButton.tsx: в catch читать error?.response?.data?.retryAfterSeconds; если есть — тост с остатком, иначе — существующий тост.
  • pages/wheel/[id]/index.tsx → handleInstantWinPurchase: поменять порядок:
    1. requestSpinPurchaseinvoiceId;
    2. получить amount/tokenAddress (через getSpinInvoice); resolveTokenContract
      • checkBalance (проходит allowance/approve и ждёт майнинга);
    3. затем getSignatureToPay (здесь стартует 60с окно и пишется expiredAt);
    4. сразу pay().

4. Проблемы и компромиссы

4.1. Известные ограничения

  1. Окно гонки клейма
    • Между отправкой tx и реконсиляцией события спин остаётся WonPendingClaim.
    • Влияние: минимально — двойной клейм средств невозможен on-chain; кулдаун = TTL перекрывает окно выдачи второй подписи.
  2. Кэш-ключ кулдауна userAddress:amount:tokenAddress
    • Завязан на сумму. При выигрыше нового приза в том же токене сумма меняется → ключ другой. Для одного и того же набора objectIds сумма совпадает → ключ блокирует.
    • Влияние: приемлемо, главный гарант — on-chain usedObjectIds.

4.2. Технический долг

  • Бэкенд без TypeORM-миграций (см. memory) — добавление колонок в MySQL делается вручную. Здесь новых MySQL-колонок нет (expiredAt — в Mongo-инвойсе), миграция не требуется.

4.3. Риски

  • 60с на оплату инвойса. Если allowance проходит ПОСЛЕ запроса подписи — окна может не хватить. Митигация: порядок allowance → подпись → pay (см. 3.2). Согласовано.

5. Вопросы на дополнительное обсуждение

  • Время жизни клейм-подписи — 60 секунд.
  • Кулдаун повторной подписи — = времени жизни подписи (60с).
  • Expiration инвойса — 60 секунд, allowance проходит до запроса подписи.

6. План реализации

6.1. Этапы разработки

  • Этап 1: Константа TTL + проброс в подпись клейма (TDD).
  • Этап 2: Кулдаун с остатком секунд + тест.
  • Этап 3: Фронт: тост с остатком секунд.
  • Этап 4: Инвойс expiredAt + установка expiration для рулетки.
  • Этап 5: Удаление просроченных pending-инвойсов в check-payments + тест.
  • Этап 6: Фронт: порядок allowance → подпись → pay.
  • Этап 7: Документация защиты от двойного клейма (этот ТЗ).

6.2. Критические зависимости

  • Нет внешних зависимостей; все изменения внутри монорепо.

8. Документация

  • Swagger: тело ошибки { message, retryAfterSeconds } для /claim/sign.
  • Engineering docs: раздел про защиту от двойного клейма рулетки.

9. Ссылки

  • apps/backend/src/roulette/ — модуль рулетки.
  • apps/contracts-v2/contracts/MagnetTokensClaim.sol — клейм-контракт.
  • apps/contracts-v2/contracts/MagnetPaymentsManager.sol — контракт оплат.