Разработка на 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):
| Пул | currency0 | currency1 | fee | tickSpacing |
|---|---|---|---|---|
| ETH / A | ETH (address(0)) | токен A | 0 | 200 |
| ETH / B | ETH (address(0)) | токен B | 0 | 200 |
| A / B | меньший адрес токена | больший | 0 | 60 |
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). Существующие дуэли продолжают торговаться.
События
| Контракт | Событие | Когда |
|---|---|---|
| DuelFactory | PairCreated(pairId, creator, tokenA, tokenD) | Запущена дуэль |
| DuelHook | PairRegistered(pairId, tokenA, tokenD, creator, category) | Её пулы зарегистрированы в hook, в той же транзакции; category — её тема, по нумерации категорий в приложении (0 — без категории) |
| DuelHook | FeeTaken(pairId, fee, snipeTax) | Сделка в паре с ETH заплатила комиссию |
| DuelHook | SwitchFeeBurned(pairId, token, amount) | Переход сжёг свою комиссию |
| DuelHook | ArbitrageCaptured(pairId, profit) | Выравнивание пополнило банк |
| DuelHook | RoundSettled(pairId, roundIndex, averageTick, winner, ethSpent, tokensBurned) | Раунд закрыт |
| DuelHook | CreatorFeesClaimed(pairId, to, amount) | Создателю выплачено |
| DuelFactory | CreatorTransferStarted(pairId, creator, newCreator) | Создатель предложил свою роль (нулевому адресу — предложение отозвано) |
| DuelHook | CreatorChanged(pairId, from, to) | Роль создателя дуэли перешла к другому |
| DuelHook | CreatorFeesGivenUp(pairId, creator) | Создатель навсегда направил свою долю будущих комиссий в банки |
| DuelHook | ProtocolFeesClaimed(to, amount) | Казначейству выплачено |
| DuelHook | ProtocolRateSet(rateBps) | Доля протокола в комиссии за своп всех дуэлей сразу изменилась: он берёт из неё rateBps (10 000 — всю), остальное идёт в банки |
| DuelFactory | ConfigProposed(config, eta), ConfigApplied(config), ConfigCancelled() | Условия запуска предложены на 48 часов вперёд, применены, отозваны |
| DuelFactory | PausedSet(paused) | Запуски приостановлены или возобновлены |
| PoolManager | Swap(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 навсегда направляет долю создателя в будущих комиссиях в банки. Вызвать функцию может только текущий создатель. Уже накопленные комиссии по-прежнему доступны создателю, а роль создателя по-прежнему можно передать.