• Duel открыт в Robinhood Chain
  • Запустите дуэль два токена, три пула, одна транзакция
  • Раунд закроется через --:--:--
  • Раунды по четыре часа выкуп победителя ограничен по цене
  • Ликвидность заблокирована навсегда, токенов команды нет

Разработка на Duel

Пулы Duel — это стандартные пулы Uniswap V4 с hook, а каждое число, которое показывает приложение, берётся из публичных представлений и событий. Боты, терминалы, дашборды и агрегаторы могут строить на нём, ни у кого не спрашивая разрешения.

Чтение дуэли

DuelLens собирает всё о дуэли одним вызовом:

function getPair(uint256 pairId) external view returns (PairView memory);
function getPairs(uint256 offset, uint256 limit) external view returns (PairView[] memory);

PairView содержит идентификатор дуэли, её создателя и URI метаданных, обе стороны (токен, название, символ, эмиссию, цену и тик пула, ETH за токен, реальный ETH в пуле), три ключа пулов, соотношение в реальном времени (gaugeBps, доля стороны A в базисных пунктах), среднее раунда на текущий момент (roundGaugeBps), банк, комиссии создателя, объём и выкупы за всё время, текущий антиснайп-налог, номер, начало и конец раунда и то, были ли в нём сделки (roundTraded), время и тик запуска (launchTime, startTick), то, является ли она дуэлью поддержки, её категорию (category, по нумерации приложения, 0 — без категории), условия её комиссий (fees) и ту часть своей доли, которую протокол берёт сейчас (protocolRateBps, из 10 000): протокол получает fees.protocolShareBps × protocolRateBps / 10 000 комиссии свопа, а банк — остаток этой доли. В контрактах и их ABI сторона B называется D: d, tokenD, keyD.

У hook есть и представления поменьше: pairCount(), getPair(pairId), poolKeys(pairId), gaugeBps(pairId), snipeTaxBps(pairId), protocolRateBps(), protocolFees() и firstRoundEnd(launchTime).

Торговля

Любой роутер Uniswap V4 может торговать в пулах дуэли. Соберите ключи пулов из poolKeys(pairId):

Пулcurrency0currency1feetickSpacing
ETH / AETH (address(0))токен A0200
ETH / BETH (address(0))токен B0200
A / Bменьший адрес токенабольший060

Hook взимает комиссии через return deltas, поэтому запрашивайте котировки через V4 Quoter: его ответы учитывают все комиссии и налоги. Переход через ETH — это его quoteExactInput по пути токен → ETH → другой токен.

Своп, комиссию которого hook взимает до исполнения (покупка или переход с точным входом, продажа с точным выходом), должен исполниться полностью: своп, который его ценовой лимит или пул ETH с нехваткой ETH остановили бы раньше, откатывается с PartialFill. PoolManager оборачивает ошибку hook: PartialFill и ArbitrageStarved приходят внутри WrappedError(address target, bytes4 selector, bytes reason, bytes details), а когда о них сообщает V4 Quoter — внутри UnexpectedRevertBytes(bytes revertData). Декодируйте reason, чтобы прочитать ошибку hook.

Каждая сделка завершается выравниванием, которое выполняет hook и которому нужен собственный газ: своп, оставляющий ему слишком мало газа, откатывается с ArbitrageStarved, а не пропускает его. Отправляйте свопы с газом из оценки, никак не меньше, и с запасом: первый своп после закрытия раунда заодно рассчитывает этот раунд (его выкуп, затем выравнивание — примерно на 170 000 газа больше), чего оценка, сделанная до закрытия, не учитывает. Приложение добавляет к каждой оценке 500 000 газа.

DuelRouter предлагает один вызов для свопа в одном пуле:

function swapExactIn(
    PoolKey calldata key,
    bool zeroForOne,
    uint256 amountIn,
    uint256 minAmountOut,
    address recipient,
    uint256 deadline
) external payable returns (uint256 amountOut);

и один для перехода через ETH: покидаемая сторона продаётся в своём пуле ETH, а весь полученный ETH тратится в пуле другой:

function swapThroughEth(
    PoolKey calldata keyIn,
    PoolKey calldata keyOut,
    uint256 amountIn,
    uint256 minAmountOut,
    address recipient,
    uint256 deadline
) external returns (uint256 amountOut);

Продажа в пул ETH Duel, которая останавливается на цене запуска пула, когда его ETH исчерпан, отправляется снова на остаток в том же вызове, как только выравнивание, которое выполняет hook, выкупит у пула то, что принимает пул между сторонами: до SALE_PASSES() (8) свопов, пока цена пула ниже лимита свопов. Каждый платит комиссию hook со своего ETH, а minAmountOut учитывает их все. V4 Quoter Uniswap котирует один своп: сверх того, что он исполняет, он отвечает NotEnoughLiquidity. Приложение котирует такую продажу, выполняя роутер от имени трейдера (eth_call с переопределением состояния, DuelSimulator).

То, что выравнивание уже не может выкупить, sell проводит дальше через пул между сторонами в пул ETH другой стороны, куда выравнивания перенесли ETH, который за это платит:

function sell(
    PoolKey calldata keyIn,
    PoolKey calldata keyCross,
    PoolKey calldata keyOther,
    uint256 amountIn,
    uint256 minAmountOut,
    address recipient,
    uint256 deadline
) external returns (uint256 amountOut);

sell продаёт в keyIn так же, как swapExactIn, затем остаток — через keyCross, который принимает всё (его комиссия перехода сжигается, как при любом переходе), и в keyOther, тоже проход за проходом. minAmountOut учитывает ETH обоих пулов. То, что не может выкупить и keyOther, выплачивается recipient в токене этого пула и не учитывается в минимальном выходе. Приложение продаёт через sell, когда V4 Quoter отклоняет продажу, а DuelSimulator.simulateSale показывает, что sell продаст всё за ETH.

Другой роутер V4 делает один своп. Продажу с точным входом сверх того, что может оплатить ETH пула, hook не отклоняет (его комиссия берётся после свопа, с выплаченного ETH): продажа исполняется частично, до цены запуска пула, а остаток остаётся у продавца. Задайте минимальный выход под то, что может исполниться, а остаток продайте следующей транзакцией — в то, что выкупило выравнивание.

Чтобы купить, отправьте ETH вместе с swapExactIn. Чтобы продать или перейти, одобрите роутеру токен или позвольте permit (EIP-2612) сделать это в той же транзакции: swapExactInWithPermit, swapThroughEthWithPermit и sellWithPermit принимают те же аргументы плюс permit вызывающего ровно на amountIn, подписанный для роутера:

struct Permit {
    uint256 deadline;
    uint8 v;
    bytes32 r;
    bytes32 s;
}

Для каждого токена Duel владелец кошелька подписывает permit в домене EIP-712 с именем Duel, версией 1, привязанном к адресу этого токена (об этом сообщает его eip712Domain(), ERC-5267). Роутер сначала использует permit и игнорирует его неудачу: permit, уже отправленный кем угодно, установил разрешение, а неудачный ничего не меняет; тогда своп берёт то, что покрывает уже действующее разрешение, и откатывается, если его не хватает. После частично исполненного свопа неиспользованная часть разрешения остаётся действующей: срок permit ограничивает, когда можно использовать подпись, но не прекращает уже выданное разрешение. Роутер забирает токены только у вызывающего. ETH перехода остаётся в PoolManager, чтобы его потратил второй пул; пул Duel тратит его целиком, иначе hook откатывает своп (PartialFill), а на любом другом пуле то, что он не может потратить, возвращается вызывающему.

Запуск дуэли

DuelFactory.createPair(CreateParams) запускает дуэль; отправьте вместе с вызовом комиссию запуска и покупки при запуске, излишек возвращается. CreateParams — это nameA, symbolA, nameD, symbolD (сторона B), metadataURI, buyA, buyD (ETH, потраченный на каждую сторону при запуске, без антиснайп-налога), supporter, category (по нумерации категорий в приложении, 0 — без категории) и config — configHash() фабрики в том виде, в каком вы его прочитали: запуск откатывается с ConfigChanged, если условия с тех пор изменились. Название занимает от 1 до 128 байт, тикер — от 1 до 40, а URI метаданных — не более 4 096 (InvalidMetadata); покупка при запуске свыше 5 % эмиссии стороны откатывается (CreatorBuyTooLarge).

Фабрика начинает работу с приостановленными запусками. Пока paused() равно true, запустить дуэль может только owner(); вызовы остальных откатываются с CreationPaused. Владелец открывает запуски через setPaused(false). Существующие дуэли продолжают торговаться.

События

КонтрактСобытиеКогда
DuelFactoryPairCreated(pairId, creator, tokenA, tokenD)Запущена дуэль
DuelHookPairRegistered(pairId, tokenA, tokenD, creator, category)Её пулы зарегистрированы в hook, в той же транзакции; category — её тема, по нумерации категорий в приложении (0 — без категории)
DuelHookFeeTaken(pairId, fee, snipeTax)Сделка в паре с ETH заплатила комиссию
DuelHookSwitchFeeBurned(pairId, token, amount)Переход сжёг свою комиссию
DuelHookArbitrageCaptured(pairId, profit)Выравнивание пополнило банк
DuelHookRoundSettled(pairId, roundIndex, averageTick, winner, ethSpent, tokensBurned)Раунд закрыт
DuelHookCreatorFeesClaimed(pairId, to, amount)Создателю выплачено
DuelFactoryCreatorTransferStarted(pairId, creator, newCreator)Создатель предложил свою роль (нулевому адресу — предложение отозвано)
DuelHookCreatorChanged(pairId, from, to)Роль создателя дуэли перешла к другому
DuelHookCreatorFeesGivenUp(pairId, creator)Создатель навсегда направил свою долю будущих комиссий в банки
DuelHookProtocolFeesClaimed(to, amount)Казначейству выплачено
DuelHookProtocolRateSet(rateBps)Доля протокола в комиссии за своп всех дуэлей сразу изменилась: он берёт из неё rateBps (10 000 — всю), остальное идёт в банки
DuelFactoryConfigProposed(config, eta), ConfigApplied(config), ConfigCancelled()Условия запуска предложены на 48 часов вперёд, применены, отозваны
DuelFactoryPausedSet(paused)Запуски приостановлены или возобновлены
PoolManagerSwap(id, sender, amount0, amount1, sqrtPriceX96, liquidity, tick, fee)Каждая сделка, включая выравнивание самого hook (его sender — это hook)

Списки сделок читают свопы из всех трёх пулов; графики цены и соотношения читают только два пула ETH. Списки холдеров используют события Transfer токенов. Subgraph индексирует свопы, балансы, холдеров и рассчитанные раунды, чтобы приложение могло загрузить историю, не проигрывая сеть заново с момента запуска.

Расчёт и выплаты

  • settle(pairId) рассчитывает завершённый раунд. Вызвать её может кто угодно.
  • claimCreatorFees(pairId) платит только создателю дуэли — кошельку, который её запустил, или адресу, которому он передал роль; claimProtocolFees() платит казначейству, указанному на момент выплаты. Запустить любую из этих выплат может кто угодно. Казначейство может смениться после 48-часовой задержки фабрики; вызывающий не может выбрать другого получателя.
  • transferCreator(pairId, newCreator) на фабрике предлагает роль создателя дуэли, которую принимает acceptCreator(pairId), вызванная с newCreator; pendingCreator(pairId) читает предложение. Предлагает роль только создатель, а принимает только адрес, которому она предложена.
  • giveUpCreatorFees(pairId) в hook навсегда направляет долю создателя в будущих комиссиях в банки. Вызвать функцию может только текущий создатель. Уже накопленные комиссии по-прежнему доступны создателю, а роль создателя по-прежнему можно передать.