基于 Duel 开发
Duel 的池子是带有钩子的标准 Uniswap V4 池子,应用显示的每一个数字都来自公开的视图和事件。机器人、交易终端、数据面板和聚合器都可以直接在它之上开发,无需任何许可。
读取对决
DuelLens 通过一次调用汇总一场对决的全部信息:
function getPair(uint256 pairId) external view returns (PairView memory);
function getPairs(uint256 offset, uint256 limit) external view returns (PairView[] memory);
PairView 包含对决的 ID、创建者和元数据 URI、两个阵营(代币、名称、符号、供应量、池子价格和 tick、每枚代币的 ETH 价格、池中的实际 ETH)、三个池子的键、实时占比(gaugeBps,A 阵营的份额,以基点表示)、本回合至今的平均值(roundGaugeBps)、奖池、创建者手续费、累计交易量和回购、当前的防狙击税、回合的序号、开始和结束时间以及是否有过交易(roundTraded)、发射时间和发射时的 tick(launchTime、startTick)、是否为支持者对决、类别(category,按应用的编号,无类别为 0)、手续费条款(fees),以及协议目前从其份额中收取的部分(protocolRateBps,全部为 10,000):协议获得兑换手续费的 fees.protocolShareBps × protocolRateBps / 10,000,该份额的其余部分进入奖池。在合约及其 ABI 中,B 阵营写作 D:d、tokenD、keyD。
钩子还提供更小的视图: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 |
钩子通过 return delta 收取手续费,所以请用 V4 Quoter 获取报价:它的结果包含了所有手续费和税。经 ETH 的切换,沿着“代币 → ETH → 另一个代币”的路径调用它的 quoteExactInput。
钩子在执行前收取手续费的兑换(精确输入的买入或切换、精确输出的卖出)必须完全成交:会因价格限制或 ETH 不足的 ETH 池而提前停下的兑换,会以 PartialFill 回滚。PoolManager 会包装钩子的错误:PartialFill 和 ArbitrageStarved 会装在 WrappedError(address target, bytes4 selector, bytes reason, bytes details) 中返回;由 V4 Quoter 报告时,则装在 UnexpectedRevertBytes(bytes revertData) 中。解码 reason 即可读到钩子的错误。
每笔交易都以钩子的再平衡结束,再平衡需要自己的 gas:留给它的 gas 太少的兑换会以 ArbitrageStarved 回滚,而不是跳过它。发送兑换时请使用估算得到的 gas,绝不更少,并留出余量:回合结束后的第一笔兑换还会结算该回合(先回购,再做一次再平衡,约多耗 170,000 gas),而结束前所做的估算不会计入这部分。应用会在每次估算上加 500,000 gas。
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);
当卖入 Duel ETH 池子的交易因其 ETH 耗尽而停在池子的发射价格时,在钩子的再平衡从池子买回两个阵营之间的池子所能承接的部分之后,剩余部分会在同一次调用中再次发送:最多 SALE_PASSES()(8)次兑换,只要池子的价格仍低于兑换的限价。每次兑换都按自己的 ETH 支付钩子的手续费,minAmountOut 将它们全部计入。Uniswap 的 V4 Quoter 只为一次兑换报价:超出它成交的部分,它会返回 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 像 swapExactIn 一样卖入 keyIn,再把剩余部分经 keyCross(它会全部承接,其切换费与任何切换一样被销毁)卖入 keyOther,同样分多次进行。minAmountOut 计入两个池子的 ETH。keyOther 也无法买回的部分,会以该池子的代币支付给 recipient,不计入最小输出。当 V4 Quoter 拒绝为一笔卖出报价,而 DuelSimulator.simulateSale 显示 sell 能把全部代币卖成 ETH 时,应用就通过 sell 卖出。
其他 V4 路由器只做一次兑换。钩子不会拒绝超出池中 ETH 支付能力的精确输入卖出(它的手续费在兑换之后、按付出的 ETH 收取):卖出只部分成交,直到池子的发射价格为止,其余部分留在卖方手中。请按能够成交的部分设置最小输出,剩下的在之后的交易中卖出,卖给再平衡买回的那部分。
买入时随 swapExactIn 发送 ETH。卖出或切换时,为该代币授权路由器,或在同一笔交易中用许可(EIP-2612 permit)完成授权:swapExactInWithPermit、swapThroughEthWithPermit 和 sellWithPermit 接受相同的参数,外加调用者为路由器签署的、恰好为 amountIn 的许可:
struct Permit {
uint256 deadline;
uint8 v;
bytes32 r;
bytes32 s;
}
用户的钱包使用各 Duel 代币的地址,在该代币名为 Duel、版本为 1 的 EIP-712 域中签署许可(其 ERC-5267 的 eip712Domain() 会给出这些信息)。路由器先使用许可,并忽略其失败:任何人已提前发送的许可已经设好了授权;失败的许可不会改变任何东西,兑换只会动用已有授权所覆盖的数量,不足时回滚。只部分成交的兑换会保留未使用的授权额度。许可的截止时间只限制何时可以提交签名,并不会使已授予的授权到期。它只会从调用者那里取走代币。切换中的 ETH 留在 PoolManager 中供第二个池子使用;Duel 池会把它全部花掉,否则钩子会回滚这笔兑换(PartialFill);在其他任何池子上,花不完的部分会退还给调用者。
发射对决
DuelFactory.createPair(CreateParams) 发射一场对决;随调用一并发送发射费和发射买入的 ETH,多余部分会退还。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) | 其池子在同一笔交易中注册到钩子;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) | 每一笔交易,包括钩子自己的再平衡(其 sender 是钩子) |
交易列表读取全部三个池子的兑换;价格及占比图表只读取两个 ETH 池。持有者列表使用代币 Transfer 事件。子图索引兑换、余额、持有者和已结算回合,让应用无需从发射时重新读取整条链即可加载历史。
结算与领取
settle(pairId)结算已结束的回合,任何人都可调用。claimCreatorFees(pairId)只支付给对决的创建者(发射它的钱包,或接受其角色转让的地址);claimProtocolFees()支付给领取时配置的金库。任何人都可触发领取。金库可在工厂的 48 小时延迟后更改,但调用者无法选择其他收款方。- 工厂的
transferCreator(pairId, newCreator)提议转让对决的创建者角色,由newCreator调用acceptCreator(pairId)接受;pendingCreator(pairId)可读取该提议。只有创建者能提议,也只有被提议的地址能接受。 - 在钩子上调用
giveUpCreatorFees(pairId),会将未来手续费中的创建者份额永久转入奖池。只有当前创建者可以调用。已累积的手续费仍可由创建者领取,创建者角色也仍可转让。