通过前文的介绍跑断腿的“旧商栈”大改造:UniswapV4如何用一块黑板,开启DEX的颠覆性优化,我们对Uniswapv4有了直观的理解。本篇开始正式进行合约分析。v4非常灵活,单例状态机提供的函数可以在外围通过“指令集”的组合和解析进行调用,从而完成特定场景的交互。
<!--StartFragment-->
通过前文的介绍跑断腿的“旧商栈”大改造: Uniswap V4 如何用一块黑板,开启 DEX 的颠覆性优化,我们对Uniswap v4有了直观的理解。本篇开始正式进行合约分析。

v4非常灵活,单例状态机提供的函数可以在外围通过“指令集”的组合和解析进行调用,从而完成特定场景的交互。我们可以从官方提供的各种外围“样板”(包括外围合约,测试case等)开始理解流程,逐步进入核心层。
本篇会以定输出的单跳兑换场景为例,带大家完整的了解外围调用链路与指令编排,核心合约架构与重要函数。
我们可以通过universal-router仓库中官方的测试案例来进行学习。以定输出单跳兑换为例,在UniswapV4.test.ts这个文件里,我们可以找到如下片段:
it('completes a v4 exactOutSingle swap', async () => {
v4Planner.addAction(Actions.SWAP_EXACT_OUT_SINGLE, [
{
poolKey: USDC_WETH.poolKey,
zeroForOne: true,
amountOut: amountOutNative,
amountInMaximum: maxAmountInUSDC,
hookData: '0x',
},
])
v4Planner.addAction(Actions.SETTLE_ALL, [usdcContract.address, MAX_UINT])
v4Planner.addAction(Actions.TAKE_ALL, [wethContract.address, 0])
planner.addCommand(CommandType.V4_SWAP, [v4Planner.actions, v4Planner.params])
const { usdcBalanceBefore, usdcBalanceAfter, wethBalanceBefore, wethBalanceAfter } = await executeRouter(
planner,
bob,
router,
wethContract,
daiContract,
usdcContract
)
expect(wethBalanceAfter.sub(wethBalanceBefore)).to.be.eq(amountOutNative)
expect(usdcBalanceBefore.sub(usdcBalanceAfter)).to.be.lte(maxAmountInUSDC)
})
可以看到,概况来说CommandType.V4_SWAP是通过router按序execute了三个“Action”:SWAP_EXACT_OUT_SINGLE、SETTLE_ALL、TAKE_ALL
V4的路由合约入口在UniversalRouter,我们从execute函数开始:
function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline)
external
payable
checkDeadline(deadline)
{
execute(commands, inputs);
}
function execute(bytes calldata commands, bytes[] calldata inputs) public payable override isNotLocked {
bool success;
bytes memory output;
uint256 numCommands = commands.length;
if (inputs.length != numCommands) revert LengthMismatch();
// loop through all given commands, execute them and pass along outputs as defined
for (uint256 commandIndex = 0; commandIndex < numCommands; commandIndex++) {
bytes1 command = commands[commandIndex];
bytes calldata input = inputs[commandIndex];
(success, output) = dispatch(command, input);
if (!success && successRequired(command)) {
revert ExecutionFailed({commandIndex: commandIndex, message: output});
}
}
}
可以看到,循环对每个Command调用dispatch函数,这个函数继承自Dispatcher,我们可以在其中找到如下if 分支
if (command == Commands.V4_SWAP) {
_executeActions(inputs); //批量执行Action
}
我们去找_executeActions函数,顺着继承往上一层来到V4SwapRouter,
abstract contract V4SwapRouter is V4Router, Permit2Payments {
constructor(address _poolManager) V4Router(IPoolManager(_poolManager)) {}
function _pay(Currency token, address payer, uint256 amount) internal override {
payOrPermit2Transfer(Currency.unwrap(token), payer, address(poolManager), amount);
}
}
看到其继承V4Router,我们要从universal-router仓库来到v4-periphery仓库了,_executeActions函数在V4Router的父合约BaseActionsRouter里:
function _executeActions(bytes calldata unlockData) internal {
poolManager.unlock(unlockData);
}
这次来到了v4-core仓库,PoolManager合约的unlock函数:
function unlock(bytes calldata data) external override returns (bytes memory result) {
//防重入校验:确保一个交易内不会嵌套开启多个记账窗口
if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();
Lock.unlock(); //1.打开记账窗口
//2.执行外围逻辑:回调 msg.sender(如路由合约),由其编排具体的业务指令(Actions)
// 在此期间产生的代币欠款/结余将实时记录在 Transient Storage 中
result = IUnlockCallback(msg.sender).unlockCallback(data);
//3. 检查所有代币的 Delta 是否都已归零。若有任何代币未结清(多给或少给),整个交易回滚
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
Lock.lock(); //4.关闭记账窗口
}
可以看到,unlock中承接_executeActions批量执行Actions,用防重入锁打开了一个记账窗口,然后回调msg.sender即UniversalRouter的unlockCallback(data)接口函数,在这个接口实现里路由合约自己处理Actions。之后执行了一个很重要的NonzeroDeltaCount.read() != 0检查,最后关闭记账窗口。
我们一步步来,先去回调函数里看看如何对Actions进行处理,V4Router, BaseActionsRouter,SafeCallback都在UniversalRouter继承链上,我们可以找到如下链路:
//SafeCallback.sol
function unlockCallback(bytes calldata data) external onlyPoolManager returns (bytes memory) {
return _unlockCallback(data);
}
//BaseActionsRouter.sol
function _unlockCallback(bytes calldata data) internal override returns (bytes memory) {
// abi.decode(data, (bytes, bytes[]));
(bytes calldata actions, bytes[] calldata params) = data.decodeActionsRouterParams();
_executeActionsWithoutUnlock(actions, params);
return "";
}
function _executeActionsWithoutUnlock(bytes calldata actions, bytes[] calldata params) internal {
uint256 numActions = actions.length;
if (numActions != params.length) revert InputLengthMismatch();
for (uint256 actionIndex = 0; actionIndex < numActions; actionIndex++) {
uint256 action = uint8(actions[actionIndex]);
_handleAction(action, params[actionIndex]);
}
}
//V4Router.sol
function _handleAction(uint256 action, bytes calldata params) internal override {
/// ...
else if (action == Actions.SWAP_EXACT_OUT_SINGLE) {
IV4Router.ExactOutputSingleParams calldata swapParams = params.decodeSwapExactOutSingleParams();
_swapExactOutputSingle(swapParams);
return;
}
/// ...
if (action == Actions.SETTLE_ALL) {
(Currency currency, uint256 maxAmount) = params.decodeCurrencyAndUint256();
uint256 amount = _getFullDebt(currency); //计算路由欠池子多少delta
if (amount > maxAmount) revert V4TooMuchRequested(maxAmount, amount);
_settle(currency, msgSender(), amount); //结算,用户需要在这里转钱给池子
return;
} else if (action == Actions.TAKE_ALL) {
(Currency currency, uint256 minAmount) = params.decodeCurrencyAndUint256();
uint256 amount = _getFullCredit(currency); //计算路由可以从池子提走多少delta
if (amount < minAmount) revert V4TooLittleReceived(minAmount, amount);
_take(currency, msgSender(), amount); //提取,提币到msgSender()
return;
}
/// ...
revert UnsupportedAction(action);
}
//V4Router.sol
function _swapExactOutputSingle(IV4Router.ExactOutputSingleParams calldata params) private {
uint128 amountOut = params.amountOut;
/**
amountOut == ActionConstants.OPEN_DELTA 意思是用当前未结算债务作为 amountOut
比如多跳的时候,A->B->C, ExactOutput方式下,先计算B->C需要多少B,也就是路由欠池子多少B (delta为负)
之后再计算A->B,这时候的amountOut就直接用上一步的B的-delta就行了。
*/
if (amountOut == ActionConstants.OPEN_DELTA) {
amountOut =
_getFullDebt(params.zeroForOne ? params.poolKey.currency1 : params.poolKey.currency0).toUint128(); //这里是计算路由欠池子多少outToken
}
//根据amountOut计算需要多少amountIn,这里负值表示欠池子的
uint128 amountIn = (uint256(
-int256(_swap(params.poolKey, params.zeroForOne, int256(uint256(amountOut)), params.hookData))
))
.toUint128();
//滑点控制,amountIn不可以大于amountInMax
if (amountIn > params.amountInMaximum) revert V4TooMuchRequested(params.amountInMaximum, amountIn);
}
从上面的BaseActionsRouter._executeActionsWithoutUnlock函数可以看出,这个回调逻辑实现主要就是使用_handleAction去逐个处理Command里的多个Action,而每个Action都对应一个if else分支去调用一个内部函数,我们这个例子里有三个Action,也就是调用三次_handleAction、每次走不同的分支。
小结一下,涉及的文件比较多,但我们把universal-router,v4-periphery仓库里这些通过继承、实现关联在一起的部分称为外围,而把PoolManager及其继承关联的部分通过称为核心,那么我们暂时可知有如下关系:
外围接收Commands和其对应的Actions作为入参,每个Command对应的是外围去调用核心的unlock函数,打开了一个记账窗口。在这个记账窗口里核心会回调外围的unlockCallback接口,外围自己实现这个接口去批处理这个Command的多个Action。而这个接口的实现逻辑,以我们的场景为例,是使用外围的三个if分支调用对应的内部方法来处理对应的Action的。回调结束后核心会做一次特殊校验并关闭记账窗口。
我们接着看这三个处理Action的分支。三个用到了_getFullDebt和_getFullCredit函数,这个函数的意思是路由合约欠池子多少钱,以及路由可以从池子支取多少钱:
//DeltaResolver
function _getFullDebt(Currency currency) internal view returns (uint256 amount) {
int256 _amount = poolManager.currencyDelta(address(this), currency); //取得路由在该币种下的delta
// If the amount is positive, it should be taken not settled.
if (_amount > 0) revert DeltaNotNegative(currency); //如果delta是正的,那么说明该take提取,而不是settle结算。revert
// Casting is safe due to limits on the total supply of a pool
amount = uint256(-_amount); //把delta改成正数返回
}
function _getFullCredit(Currency currency) internal view returns (uint256 amount) {
int256 _amount = poolManager.currencyDelta(address(this), currency); //取得路由在该币种下的delta
// If the amount is negative, it should be settled not taken.
if (_amount < 0) revert DeltaNotPositive(currency); //delta必须是正数
amount = uint256(_amount);
}
有了这些铺垫,我们来看这三个内部函数:
//V4Router.sol
function _swap(PoolKey memory poolKey, bool zeroForOne, int256 amountSpecified, bytes calldata hookData)
private
returns (int128 reciprocalAmount)
{
// for protection of exactOut swaps, sqrtPriceLimit is not exposed as a feature in this contract
unchecked {
//调用pm.swap计算本次兑换的内部delta
BalanceDelta delta = poolManager.swap(
poolKey,
SwapParams(
zeroForOne, amountSpecified, zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1
),
hookData
);
//确定返回delta.amount1() 还是返回 delta.amount0()
reciprocalAmount = (zeroForOne == amountSpecified < 0) ? delta.amount1() : delta.amount0();
}
}
//DeltaResolver
function _settle(Currency currency, address payer, uint256 amount) internal {
if (amount == 0) return;
poolManager.sync(currency); //pm里先同步一下最新的代币余额。
if (currency.isAddressZero()) { //原生代币
poolManager.settle{value: amount}(); //调用settle的同时直接向pm发送原生代币
} else { //ERC20
_pay(currency, payer, amount);//转入池子erc20代币。payer这里可以进行分析最后是取的Locker,我们这个场景里是测试用例中的bob地址。
poolManager.settle(); //调用pm.settle()
}
}
function _take(Currency currency, address recipient, uint256 amount) internal {
if (amount == 0) return;
poolManager.take(currency, recipient, amount); //调用pm.take提取代币到recipient
}
//V4SwapRouter
function _pay(Currency token, address payer, uint256 amount) internal override {
payOrPermit2Transfer(Currency.unwrap(token), payer, address(poolManager), amount);
}
settle的时候资金是bob approve 使用Permit2签名授权UniversalRouter路由合约向PoolManager进行转账。之后PoolManager内部的delta记账记在UniversalRouter上。take的时候,pm.take提取代币到bob地址。
settle/take可以概括为“用bob的资金进出来销UniversalRouter在PM的内部delta账”。
至此,调用链路的部分我们整体就讲完了。
我们完善我们的总结:
外围接收Commands和其对应的Actions作为入参,每个Command对应的是外围去调用核心的unlock函数,打开了一个记账窗口。在这个记账窗口里核心会回调外围的unlockCallback接口,外围自己实现这个接口去批处理这个Command的多个Action。而这个接口的实现逻辑,以我们的场景为例,是使用外围的三个if分支调用对应的内部方法来处理对应的Action的:
SWAP_EXACT_OUT_SINGLE
根据amountOut计算amountIn,以负 delta 表示 Router 欠 PoolManager 的 token,以正 delta 表示 Router 可以从 PoolManager 提取的 token。
SETTLE_ALL
根据负delta了解路由“欠”池子多钱,然后用户转钱给池子,平了路由的负delta(PoolManager 通过 sync + settle 计算实际转入金额,然后更新 Router 的 delta)
TAKE_ALL
根据正 delta,Router 调用 poolManager.take,由 PoolManager 直接把 token 转给用户,同时减少 Router 的正 delta,即平了路由的delta。
回调结束后核心会做一次特殊校验,检查正负delta是否都被平了。最后,关闭记账窗口。
接下来我们会深入到PoolManager合约里边,看看前文我们这个场景下,三个Action对应的poolManager.swap,poolManager.settle(), poolManager.take的底层工作细节,以及闪电记账的真面目。
但在这之前,我们必须先搞清楚delta是怎么回事,也即闪电记账的底层实现。
关键的代码如下:
//PoolManager.sol
//核心记账函数
function _accountDelta(Currency currency, int128 delta, address target) internal {
if (delta == 0) return; //如果delta变化是0,直接返回。
//把delta变化累加到slot,并返回之前和最新的delta值
(int256 previous, int256 next) = currency.applyDelta(target, delta);
if (next == 0) { //最新delta是0、且delta变化不是0,说明平了账
NonzeroDeltaCount.decrement(); //NonzeroDeltaCount扣减
} else if (previous == 0) {//前一次是0,且delta变化不是0,说明多了一个待平的账
NonzeroDeltaCount.increment(); //NonzeroDeltaCount累加
}
}
//CurrencyDelta.sol
function applyDelta(Currency currency, address target, int128 delta)
internal
returns (int256 previous, int256 next)
{
//计算记账存储的slot,对应的关系是 (address, currency) -> slot, 即:“谁的、什么币种的账”记在特定的slot
bytes32 hashSlot = _computeSlot(target, currency);
assembly ("memory-safe") {
previous := tload(hashSlot) //之前的delta
}
next = previous + delta; //最新的delta
assembly ("memory-safe") {
tstore(hashSlot, next) //把最新的delta存进slot
}
}
//使用的是特定的PoolManager的slot,由记账地址和币种决定
function _computeSlot(address target, Currency currency) internal pure returns (bytes32 hashSlot) {
assembly ("memory-safe") {
mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
hashSlot := keccak256(0, 64)
}
}
说明一下。
我们应该有印象,delta就是我们之前故事中的“小黑板记账”,类似会计中的借贷记账,正delta表示可以从池子提取,负delta表示需要向池子转移。比如一个负delta,用户向池子转了钱,我们说就把这个负delta的账平了。
delta的存储简单来说的结构是(tagetAddress, currency) -> slot,存储在PoolManager的特定的slot里,读写这个slot使用tstore/tload,这种slot存储即所谓的“瞬态存储”,只存在于内存而不是磁盘,它的有效生命周期只是在一次交易当中。
NonzeroDeltaCount是一个计数器,表示当前交易有多少“待平”的账。在PoolManager.unlock框架中最后Lock.lock()关闭记账窗口之前做的那个校验:
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
就是要保证当前这个交易记账窗口内,不管中途做了哪些Action指令组合,最后要保证所有的delta账都平了。
有了对闪电记账的理解,我们继续分析PoolManager中的函数。
PoolManager中的重要函数除了前文我们介绍过的执行框架unlock函数之外,还有一种我称为功能函数,一共有9个,特点是都需要modifier onlyWhenUnlocked限定调用条件,即由外围合约在unlockCallback接口的实现中、根据具体业务场景进行编排调用。我们在上一篇文章中分析过单跳定输出的兑换场景下编排调用的链路,忘记的同学可以去翻翻看。这里我们接着来看涉及到的PoolManager的swap、settle、take函数的内部实现。
function swap(PoolKey memory key, SwapParams memory params, bytes calldata hookData)
external
onlyWhenUnlocked
noDelegateCall
returns (BalanceDelta swapDelta)
{
if (params.amountSpecified == 0) SwapAmountCannotBeZero.selector.revertWith();
PoolId id = key.toId();
Pool.State storage pool = _getPool(id);
pool.checkPoolInitialized();
BeforeSwapDelta beforeSwapDelta;
{
int256 amountToSwap;
uint24 lpFeeOverride;
// 1. AOP 前置拦截:Hook 可以截留资金或修改交易参数。
// beforeSwapDelta 记录了 Hook 在交易前产生的债务/债权
(amountToSwap, beforeSwapDelta, lpFeeOverride) = key.hooks.beforeSwap(key, params, hookData);
// 2. 核心数学计算:按照集中流动性算法计算价格移动。
// swapDelta中,需要转入池子的delta为负,需要从池子支取的delta记为正
swapDelta = _swap(
pool,
id,
Pool.SwapParams({
tickSpacing: key.tickSpacing,
zeroForOne: params.zeroForOne,
amountSpecified: amountToSwap,
sqrtPriceLimitX96: params.sqrtPriceLimitX96,
lpFeeOverride: lpFeeOverride
}),
params.zeroForOne ? key.currency0 : key.currency1
);
}
BalanceDelta hookDelta;
// 3. AOP 后置处理:Hook 可以根据交易结果再次调整结算金额。
// 这里会返回最终用户该付/该得的 swapDelta,以及 Hook 自己要结算的 hookDelta
(swapDelta, hookDelta) = key.hooks.afterSwap(key, params, swapDelta, hookData, beforeSwapDelta);
// 4. 记账(写入瞬态存储):把 Hook 的那份账记在 Hook 地址下
if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
// 5. 记账(写入瞬态存储):兑换产生一正一负两笔delta
_accountPoolBalanceDelta(key, swapDelta, msg.sender);
}
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta, address target) internal {
_accountDelta(key.currency0, delta.amount0(), target);
_accountDelta(key.currency1, delta.amount1(), target);
}
function _swap(Pool.State storage pool, PoolId id, Pool.SwapParams memory params, Currency inputCurrency)
internal
returns (BalanceDelta)
{
(BalanceDelta delta, uint256 amountToProtocol, uint24 swapFee, Pool.SwapResult memory result) =
pool.swap(params);
// the fee is on the input currency
if (amountToProtocol > 0) _updateProtocolFees(inputCurrency, amountToProtocol);
// event is emitted before the afterSwap call to ensure events are always emitted in order
emit Swap(
id,
msg.sender,
delta.amount0(),
delta.amount1(),
result.sqrtPriceX96,
result.liquidity,
result.tick,
swapFee
);
return delta;
}
可以看到,swap函数内主要的逻辑是,调用内部函数_swap按照集中流动性算法计算价格移动,并计算swapDelta。 swapDelta中,需要转入池子的delta为负,需要从池子支取的delta记为正,一次swap会产生一正一负两个delta。 以及在调用_swap之前和之后,分别使用Hook的beforeSwap和afterSwap计算Hook合约逻辑所产生的delta,_accountPoolBalanceDelta记账delta,且为hook记录一次,为msg.sender(路由合约)记录一次。
最后,pool.swap里的计算逻辑(Tick 移动、集中流动性消耗、价格区间跨越)与v3相比几乎没变,这里不再赘述,可以参考笔者之前V3的文章。变化的实际上是“结算方式”:
接着再看下settle和take:
function settle() external payable onlyWhenUnlocked returns (uint256) {
return _settle(msg.sender);
}
function _settle(address recipient) internal returns (uint256 paid) {
Currency currency = CurrencyReserves.getSyncedCurrency();
// if not previously synced, or the syncedCurrency slot has been reset, expects native currency to be settled
if (currency.isAddressZero()) {//原生代币
paid = msg.value;
} else {
if (msg.value > 0) NonzeroNativeValue.selector.revertWith();
// Reserves are guaranteed to be set because currency and reserves are always set together
uint256 reservesBefore = CurrencyReserves.getSyncedReserves();
uint256 reservesNow = currency.balanceOfSelf();
paid = reservesNow - reservesBefore; //储备变化差值,就是需要本次结算需要转移到池子的代币数量
CurrencyReserves.resetCurrency();
}
_accountDelta(currency, paid.toInt128(), recipient); //记账delta,注意这里paid是正,表示去平负delta
}
function take(Currency currency, address to, uint256 amount) external onlyWhenUnlocked {
unchecked {
// negation must be safe as amount is not negative
_accountDelta(currency, -(amount.toInt128()), msg.sender);//-amout,去平正delta
currency.transfer(to, amount); //转钱给to地址
}
}
settle那里说明一下,外围先调用poolManager.sync同步代币、记录储备,然后pay,最后poolManager.settle。所以上面代码两次储备变化差值就是外围pay进来的金额,大家忘记了的话可以去翻一下前一篇文章。
而_accountDelta(currency, paid.toInt128(), recipient),paid是正的,表示本次settle是为了结算去平“负delta”账,大家不要绕不过来。
take中_accountDelta传入的amount是负的,表示去平池子中“正delta”的账。这样swap产生的一正一负的delta就都被平掉了。
通过对 Uniswap v4 核心源码的拆解,我们不难发现,v4 实际上完成了一场 DeFi 架构的“范式转移”:
PoolManager 不再关心你是谁,它只关心在它的记账窗口(unlock)结束时,账目是否归零。这种设计将极大的灵活性释放给了外围合约(如 Universal Router)和插件(Hooks),使得 Uniswap 真正从一个“自动做市工具”进化成了“流动性操作系统”。BalanceDelta 进行精准的账目平衡,以及如何在 Hooks 的切面(AOP)中注入创新的流动性逻辑。Uniswap v4 的单例和闪电记账,标志着链上交易进入了“意图(Intent)与编排”的时代。 至此,我们已经完成了对 v4 调用链路与记账逻辑的深度硬核拆解。
本系列文章主要探讨了Uniswap v4合约最主要的本体核心架构和功能。但v4还有扩展功能,即Hook这一重要新特性,后续我考虑大家介绍如何利用这一机制来实现诸如“限价单”或“动态滑点防护”等高级功能。
喜欢本文请点点关注,谢谢大家!

<!--EndFragment-->
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!