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

Флоу покупки земли и вышки

Пошагово: что делает фронт, что проверяет бэкенд при выдаче подписи, что проверяет контракт и как покупка попадает в граф. Модель данных — в Землях и вышках, деньги — в партнёрке.

Где в интерфейсе​

  • Маршрут /finances/liquidity (apps/frontend/src/pages/finances/liquidity/index.tsx) закрыт NftLevelGate requiredLevel={3} с пометкой «Temporary threshold for test rollout»: страница доступна с уровнем главного NFT от 3.
  • В preprod-start-v2 пункт меню скрыт на боевых стендах (production, preprod-start): SECTION_VISIBILITY.liquidity = !isLiveStand (apps/frontend/src/shared/constants/sections.ts:15). По прямой ссылке маршрут открывается. На сборке productionPolygon middleware.ts перенаправляет его на /career.
  • Вкладка «general» страницы: секции NFT-позиций, пулов и «Grounds и Towers». Покупка земли открывается из секции кросс-чейн свопа («Открыть») — это CrosschainSwapModal. Покупка вышки — кнопка «Купить NFT позиции».

Покупка земли​

 Фронт                  Polygon             backend              p2-backend / Neo4j          BSC
CrosschainSwapModal Diamond
│ 1. уровень L, план
│ 2. кросс-чейн обмены 0x ───────────────────────────────────────────────────────────▶
│ 3. deposit(L, L) ────▶ BuyP1Level,
│ BuyP2Level ─────────────────────▶ TreeEventService:
│ program1, MySQL lp1/lp2,
│ PartnerChainBridge ─────────▶ мост
│ 4. ждёт /buy-wait ◀──────────────────────────────────── обработано
│ 5. signatureNonce(uid) ◀─────────────────────────────────────────────────────────── Ground
│ 6. POST /liquidity/ground/sign ──▶ проверки, лок
│ GET /service/ground/free ──▶ родитель, inviterChain,
│ кешбэк
│ 7. buyGround(...) ─────────────────────────────────────────────────────────────────▶ Ground
│ LiquidityGroundEventsJob ◀── GroundPurchased
│ → узел Ground в графе

1. План (фронт)​

apps/frontend/src/widgets/liquidityManagement/ui/modals/CrosschainSwapModal.tsx.

  • Пользователь вводит только уровень L. Модалка показывает цену программ (DAI + POL), цену земли, балансы в обеих сетях, план обменов и газ.
  • Цена земли — 0. Константы LAND_PRICE_USDT = '0', LAND_PRICE_BNB = '0' (CrosschainSwapModal.tsx:50-51); токен оплаты — USDT BSC 0x55d3…7955 (GROUND_TOKEN_ADDRESS, :47-48).
  • Цена программ: MagnetBonuses.previewDeposit(wallet, L, L, PROGRAM_TOKEN_POLYGON) — оба уровня программ целевые L (:911-955). Если depositEnabled() ложно, покупка земли целиком блокируется ошибкой «Покупка программ сейчас недоступна».
  • Покупать ли программы решает getShouldBuyPrograms (:957-983): программы покупаются, только если у пользователя уже есть земля уровня L − 1 или выше (GET /liquidity/ground/max-level) и предпросмотр показывает, что платить есть за что.
Порядок уровней земли

Явного правила «сначала землю L − 1» нет ни на фронте, ни в бэкенде, ни в контракте. Но если земли L − 1 нет, фронт молча не покупает программы, а бэкенд выдаст подпись только при lp1 >= L и lp2 >= L. В итоге землю уровня L без земли L − 1 купит только тот, у кого уровни программ уже есть.

2. Обмены и уровни программ​

  • Недостающие токены в одной сети покрываются излишком в другой кросс-чейн обменом: BNB ↔ POL на газ и ethFee, USDT ↔ DAI на оплату программ. В preprod-start-v2 обмен идёт через 0x Cross-Chain, шаги на каждый обмен — «Approve», «Отправить транзакцию обмена», «Дождаться моста»; механика описана в engineering/Сервисы/backend/ZeroEx-CrossChain.md этой ветки. В preprod фронт ещё на 1inch Fusion+ (шаги «Approve», «Подписать сообщение», «Отправить заявку», «Отправить транзакцию»).
  • В Polygon: MagnetBonuses.deposit(L, L, token, referrer, { value: ethFee }), затем POST /save-hash в p2-backend и ожидание, пока p2-backend обработает BuyP1Level/BuyP2Level (GET /buy-wait, опрос раз в 5 с).
  • Обработка событий в p2-backend делает три вещи, нужные земле: узлы пользователя в program1, уровни lp1/lp2 в MySQL основного бэкенда и запись uid ↔ адрес в PartnerChainBridge в BSC (см. мост).

3. Подпись: POST /liquidity/ground/sign​

Основной бэкенд, createGroundBuySignature (apps/backend/src/liquidity/liquidity.service.ts:980-1176). Тело — LiquidityGroundSignatureDto: network? (по умолчанию BSC), level, token, amount, signatureNonce (фронт читает его из контракта signatureNonce(uid)).

  1. У пользователя должен быть uid, и lp1 >= L && lp2 >= L — иначе 400 User does not have required level.
  2. Запрос в p2-backend GET /service/ground/free?uid&level → родитель (groundId = parentId), индекс новой земли, inviterChain, groundCashback (см. Neo4j).
  3. Лок на родителя. Redlock плюс ключ в кеше liquidity:ground:buy-parent-lock:<parentId> на 140 секунд (время жизни подписи 120 с + 20 с запаса). Если ключ уже есть — 409 c { code: 'PARENT_ID_BUSY', parentId, retryAfterSec, retryAfterMs }. После выдачи подписи ключ не снимается и живёт до истечения TTL: следующий покупатель под тем же родителем ждёт до 140 с, даже если транзакция уже прошла.
  4. Проценты: getAdjustedPartnerPercents([5, 5, 5, 5], groundCashback), цепочка — inviterChain (см. проценты).
  5. Подпись EIP-191 (wallet.signMessage) ключом LIQUIDITY_GROUND_SIGNATURE_PRIVATE_KEY, срок — 120 секунд.

Подписываемое сообщение — keccak256(abi.encode(...)) полей:

ПолеТипЗначение
uiduint32uid покупателя
uplineGroundIduint256родитель
uplineGroundChainIduint256сеть родителя, всегда равна network
leveluint8уровень
groundIndexuint32индекс новой земли
tokenaddressтокен оплаты из запроса
amountuint256сумма из запроса
expirationuint256сейчас + 120 с
nonceuint256signatureNonce из запроса
chainIduint256network
partnersHashbytes32keccak256(abi.encode(uint32[] partnersChain, uint16[] partnerPercents))
Цена земли не проверяется

token и amount бэкенд берёт из тела запроса и подписывает как есть, контракт их тоже не сверяет (в коде контракта — TODO про управление ценой). Любой пользователь может получить подпись на покупку земли за любую сумму в любом токене. Пока фронт продаёт землю за 0, это незаметно, но до введения цены проверку нужно добавить. См. известные проблемы.

Ответ: signature, expiration, groundId (= parentId), parentId, groundIndex, uplineGroundChainId, partnersChain, partnerPercents, inviterChain.

Фронт на 409 с retryAfterMs показывает «Пожалуйста, подождите…», ждёт и повторяет запрос — без ограничения числа попыток (CrosschainSwapModal.tsx:1796-1839).

4. Контракт: buyGround​

function buyGround(
uint uplineGroundId, uint uplineGroundChainId, uint8 level, uint32 groundIndex,
address token, uint32[] partnersChain, uint16[] partnerPercents,
uint256 amount, uint256 expiration, bytes signature
) external nonReentrant

MagnetLiquidityGround.sol:123-189, проверки по порядку:

ПроверкаОшибка
Длины partnersChain и partnerPercents равныInvalidPartnerData
Срок подписи не истёкSignatureExpired
Верификатор задан'Verifier not set'
Подпись верификатора над полями выше с signatureNonce[uid] и block.chainid; uid — PartnerChainBridge.userIds(msg.sender)InvalidSignature
У uid ещё нет земли (level, groundIndex)GroundAlreadyExists
Если родитель в этой же сети — его уровень равен levelInvalidUpline

Затем: signatureNonce[uid]++, перевод amount токена token на контракт, запись долей партнёров по линиям, минт NFT покупателю, запись Ground и groundMatrix, событие:

event GroundPurchased(
uint indexed groundId, uint32 groundIndex, uint32 indexed uidFromDiamond, uint8 indexed level,
uint uplineGroundId, uint uplineGroundChainId, address token, uint256 amount
);

5. Индексация​

LiquidityGroundEventsJob раз в 5 секунд забирает GroundPurchased и создаёт узел Ground под родителем (см. Neo4j). Пока событие не обработано, новой земли не видно: на неё нельзя купить вышку, и она не учитывается в max-level.

Покупка вышки​

 Фронт (buyNftFx)          backend                      p2-backend / Neo4j          BSC (Shared)
│ 1. approve payToken ─────────────────────────────────────────────────────────────▶
│ 2. POST /liquidity/buy ×2 ──▶ 0x calldata
│ 3. userNonces(account) ◀────────────────────────────────────────────────────────── Shared
│ 4. POST /liquidity/shared/buy/sign ──▶ GET /service/ground/tower/free ──▶ земля-хозяин,
│ кулдаун, actionId inviterChain, кешбэк
│ 5. buyNft({...}) ──────────────────────────────────────────────────────────────────▶ NftPurchased
│ LiquidityTowerEventsJob ◀──────────┘
│ узел Tower, places −1 ×2
│ ◀── POST /liquidity/shared/operation/event (Completed)

1. Фронт​

Кнопка «Купить NFT позиции» → BuyNftPositionModal (apps/frontend/src/widgets/liquidityManagement/ui/modals/BuyNftPositionModal.tsx): пул, сумма в payToken, «Уровень» (по умолчанию 1) и «Ground index» (по умолчанию 0). Уровень и индекс своей земли пользователь вводит руками, подсказок из секции земель нет.

buyNftFx (apps/frontend/src/widgets/liquidityManagement/model/liquidityManagement.effects.ts:371-540):

  1. Апрув payAmount контракту MagnetLiquidityShared.
  2. Две котировки 0x на payAmount / 2 — в token0 и token1 пула (POST /liquidity/buy, taker — контракт).
  3. nonce = shared.userNonces(account).
  4. POST /liquidity/shared/buy/sign с { poolId, level, groundIndex, chainId: 56, expiration, nonce }; на 409 — ожидание retryAfterMs и повтор, как у земли.
  5. shared.buyNft({...}) с подписью и полями из ответа; minShares, minAmt0, minAmt1 — нули.

2. Подпись: POST /liquidity/shared/buy/sign​

createBuyNftSignature (apps/backend/src/liquidity/liquidity.service.ts:411-550):

  1. Запрос в p2-backend GET /service/ground/tower/free?uid&level&groundIndex: проверка своей земли и её свободных мест, выбор земли-хозяина (см. выбор земли-хозяина). Ошибка p2-backend прерывает выдачу подписи.
  2. Сверка выданных подписей (syncIssuedBuyNftOperations, :715-799). Для каждой операции на этом месте в статусе issued бэкенд ищет её NftPurchased по actionId в последних 50 блоках. Нашлась — значит транзакция прошла, а граф её ещё не учёл: 409 EVENT_PENDING, повтор через 60 с. Не нашлась и подпись истекла — операция переводится в expired.
  3. Кулдаун (enforceBuyNftSignatureCooldown, :810-875). Если на этом месте есть живая issued-подпись, новая выдаётся не раньше чем через 142 с после неё: 409 SIGNATURE_COOLDOWN с retryAfterMs.
  4. Проценты: getAdjustedPartnerPercents([5, 5, 5, 5], towerCashback), цепочка — inviterChain от земли-хозяина.
  5. actionId — случайные 32 байта. Подпись EIP-191 ключом LIQUIDITY_SIGNATURE_VERIFIER_PRIVATE_KEY, срок — 120 секунд (поле expiration из запроса не используется).
  6. Операция сохраняется в кеш: liquidity:shared:buy-operation:<actionId> на 24 часа, статус issued, и добавляется в индекс места liquidity:shared:buy-operations:place:<groundId>:<level>:<groundIndex>.

«Место» в ключах — земля-хозяин (groundId) плюс уровень и индекс. Кулдаун и сверка нужны, потому что places в графе уменьшается только после индексации: без них две подписи подряд могли бы занять одно последнее место.

Подписываемое сообщение — keccak256(abi.encode(...)):

ПолеТипЗначение
buyeraddressадрес пользователя из БД
poolIduint256пул
groundIduint256земля-хозяин
groundChainIduint256её сеть
ownerGroundIndexuint256индекс своей земли (из запроса)
uplineGroundIndexuint256индекс земли-хозяина
expirationuint256сейчас + 120 с
nonceuint256userNonces из запроса
chainIduint256из запроса
actionIdbytes32ID операции
partnersHashbytes32как у земли

Сумма покупки в подпись не входит — доля всё равно пропорциональна вложенному.

3. Контракт: buyNft​

MagnetLiquiditySharedOps.buyNft (apps/contracts-v2/contracts/MagnetLiquidity/MagnetLiquiditySharedOps.sol:76-182), вызывается через delegatecall из фасада MagnetLiquidityShared:

  1. deadline не истёк; подпись: срок, верификатор, userNonces[msg.sender] == nonce, подписант — signatureVerifier. userNonces[msg.sender]++.
  2. Пул включён; перевод payAmount; распределение (партнёры + buyDistributionTargets).
  3. Сбор комиссий пула, свопы (0x или Uniswap), добавление ликвидности, проверка minShares.
  4. Минт NFT вышки, доля и rewardDebt; возврат остатков свопа.
  5. Событие:
event NftPurchased(
uint256 indexed nftId, uint256 poolId, address indexed buyer, bytes32 indexed actionId,
uint256 shares, uint256 payAmount,
uint256 groundId, uint256 ownerGroundIndex, uint256 uplineGroundIndex, uint256 groundChainId
);

Контракт не знает про земли: существование земли и свободные места он не проверяет, доверяя подписи.

Общий nonce

userNonces — один счётчик на адрес для покупки, клейма, вывода и реинвеста. Если между выдачей подписи на покупку и самой покупкой пользователь сделает клейм, подпись покупки станет недействительной (OPS_InvalidNonce).

4. Индексация и завершение операции​

LiquidityTowerEventsJob создаёт Tower, уменьшает places на своей земле и на земле-хозяине и вызывает POST /liquidity/shared/operation/event основного бэкенда — операция переходит в completed (handleBuyNftOperationEvent, liquidity.service.ts:954-978). Детали и подводные камни — в Neo4j.

Статусы операции:

СтатусКогда
issuedПодпись выдана
completedИндексатор обработал NftPurchased
expiredПодпись истекла, события нет

Кешбэк, доход, партнёрка​

  • Кешбэк — p2-backend POST /liquidity/ground/cashback, без транзакции, только владелец земли (см. кешбэк).
  • Доход вышки — обычный клейм позиции MagnetLiquidityShared (claim/claimBatch с подписью POST /liquidity/signature[/batch]). Земля на доход не влияет.
  • Партнёрка — UI нет (см. клейм партнёрки).

API основного бэкенда​

Контроллер — apps/backend/src/liquidity/liquidity.controller.ts, все маршруты под JwtAuthGuard.

МетодМаршрутНазначение
POST/liquidity/ground/signПодпись buyGround
POST/liquidity/shared/buy/signПодпись buyNft (вышка)
POST/liquidity/shared/operation/eventРоль Service: p2-backend отмечает операцию завершённой
POST/liquidity/buyCalldata 0x для swapData0/swapData1
POST/liquidity/signature, /liquidity/signature/batchПодписи клейма и вывода вышки

Маршруты p2-backend — в Neo4j.

Переменные окружения​

ПеременнаяГдеЗачем
MAGNET_LIQUIDITY_GROUND_CONTRACT_ADDRESSbackend, p2-backend, frontend (NEXT_PUBLIC_…)Контракт земли
MAGNET_LIQUIDITY_SHARED_CONTRACT_ADDRESSbackend, p2-backend, frontend (NEXT_PUBLIC_…)Контракт вышек
LIQUIDITY_RPC_URL_BSCbackend, p2-backendRPC BSC для чтения и индексации
LIQUIDITY_GROUND_SIGNATURE_PRIVATE_KEYbackendКлюч подписи buyGround (должен совпадать с signatureVerifier контракта земли)
LIQUIDITY_SIGNATURE_VERIFIER_PRIVATE_KEYbackendКлюч подписи buyNft, клейма и вывода
PROGRAMS_API_URLbackendАдрес p2-backend для /service/ground/*
PARTNER_CHAIN_BRIDGE_CONTRACT_ADDRESSp2-backend, frontendМост uid ↔ адрес
LIQUIDITY_CHAIN_BRIDGE_OPERATOR_PRIVATE_KEYp2-backendОператор моста

Адреса сети BSC собраны в LIQUIDITY_NETWORKS_CONFIG (packages/reusable-magnet-common/src/constants/liquidity.constants.ts); сеть в LiquidityNetwork пока одна — BSC (56). В CI переменные прописывает scripts/ci-initialize-env.sh.