Uniswap V2 源码学习第十二篇:skim、sync 和 lock,Pair 的边界处理函数

边界函数处理

前面我们已经拆完了 Uniswap V2 Pair 的大主线:

mint:添加流动性
burn:移除流动性
swap:交易兑换
_mintFee:协议手续费
_update:reserve 同步 + TWAP 累计
flash swap:乐观转账 + 回调 + K 检查

这一篇看几个看起来“不起眼”,但实际上非常重要的边界函数:

modifier lock()

function skim(address to) external lock

function sync() external lock

它们不直接负责交易,也不直接负责添加/移除流动性。

但它们解决的是 Pair 在真实链上环境里一定会遇到的问题:

1. 外部调用带来的重入风险
2. reserve 和 balance 不一致
3. 用户或合约直接给 Pair 转 token
4. token 行为不标准
5. 余额异常时如何恢复状态

如果说 mint / burn / swap 是主路,那 lock / skim / sync 就是边界保护和状态修复工具。

平时不显眼,出事时很关键。


1. 先回顾 reserve 和 balance 的区别

这一篇绕不开一个核心概念:

reserve:Pair 自己记录的储备量
balance:ERC20 token 合约里查到的真实余额

Pair 里有:

uint112 private reserve0;
uint112 private reserve1;

而真实余额来自:

IERC20(token0).balanceOf(address(this));
IERC20(token1).balanceOf(address(this));

正常情况下,很多操作结束后都会调用 _update(),让二者同步:

reserve0 = balance0
reserve1 = balance1

比如:

mint 结束后同步
burn 结束后同步
swap 结束后同步
sync 主动同步

但在链上,任何人都可以直接给 Pair 转 token。

比如:

IERC20(token0).transfer(pair, amount);

这不会自动调用 Pair 的任何函数。

于是就会出现:

balance0 > reserve0

也就是 Pair 真实收到的 token 比自己记账的 reserve 多。

这就是 skim()sync() 要处理的典型场景。


2. 为什么任何人都能直接给 Pair 转 token?

因为 ERC20 的转账模型就是这样。

如果你知道一个地址,就可以把 token 转过去。

Pair 合约没有办法阻止别人调用 token 合约的:

transfer(pair, amount)

这不是 Pair 主动收款。

这是 token 合约把 Pair 的余额加了。

所以即使 Pair 的核心函数都很严格,外部仍然可以制造这种状态:

Pair.reserve0 = 1000
token0.balanceOf(Pair) = 1100

这多出来的 100 token,Pair 没有“自动归类”。

它只是在真实余额里。

这时候就有两个处理方向:

skim:把多出来的 token 转走,让 balance 回到 reserve
sync:把 reserve 更新成 balance,让 Pair 承认这笔余额

这两个函数就是一对。


3. lock:最朴素的防重入

先看 lock

源码:

uint private unlocked = 1;

modifier lock() {
    require(unlocked == 1, 'UniswapV2: LOCKED');
    unlocked = 0;
    _;
    unlocked = 1;
}

它用一个状态变量 unlocked 做互斥锁。

进入函数时:

要求 unlocked == 1
然后设置 unlocked = 0

函数执行结束后:

恢复 unlocked = 1

如果执行过程中有人试图再次进入带 lock 的函数,就会失败。


4. 哪些函数加了 lock?

Pair 里的关键外部函数都会加:

function mint(address to) external lock returns (uint liquidity)

function burn(address to)
    external
    lock
    returns (uint amount0, uint amount1)

function swap(
    uint amount0Out,
    uint amount1Out,
    address to,
    bytes calldata data
) external lock

function skim(address to) external lock

function sync() external lock

也就是说,以下操作都不能重入同一个 Pair:

添加流动性
移除流动性
交易
skim
sync

为什么这么重要?

因为 Pair 在执行过程中经常会和外部合约交互。

比如:

swap 会向 to 转 token
swap 可能调用 uniswapV2Call
burn 会向用户转 token
skim 会向 to 转 token

只要有外部调用,就存在重入风险。


5. 重入风险从哪里来?

swap() 为例。

它会先转出 token:

if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);

如果 to 是一个合约,或者 token 本身有奇怪逻辑,就可能触发外部代码。

flash swap 更明显:

if (data.length > 0) {
    IUniswapV2Callee(to).uniswapV2Call(
        msg.sender,
        amount0Out,
        amount1Out,
        data
    );
}

这就是主动调用外部合约。

如果没有 lock,外部合约可以在回调里再次调用同一个 Pair 的:

swap
mint
burn
skim
sync

在 Pair 状态还没更新完成时重入,会让 reserve、balance、LP 供应量、K 检查等逻辑进入危险状态。

所以 lock 是必要的。


6. lock 锁的是“同一个 Pair”

注意一个细节:

lock 只锁当前 Pair 合约。

比如你在 DAI/USDC Pair 的 flash swap 回调中,不能重入 DAI/USDC Pair 的 swap()

但你可以调用另一个 Pair:

WETH/USDC Pair
DAI/WETH Pair
WBTC/WETH Pair

这是允许的。

这也正是套利、多跳交易、flash swap 组合调用能成立的原因。

lock 不是全局锁。

它只是保护当前 Pair 的状态一致性。


7. skim:把多余余额扫出去

现在看第一个边界函数:

function skim(address to) external lock {
    address _token0 = token0;
    address _token1 = token1;

    _safeTransfer(
        _token0,
        to,
        IERC20(_token0).balanceOf(address(this)).sub(reserve0)
    );

    _safeTransfer(
        _token1,
        to,
        IERC20(_token1).balanceOf(address(this)).sub(reserve1)
    );
}

skim() 的作用是:

把 Pair 当前 balance 中超过 reserve 的部分转给 to。

公式是:

extra0 = balance0 - reserve0
extra1 = balance1 - reserve1

然后:

把 extra0 / extra1 转给 to

它不会更新 reserve。

它只是把真实余额拉回 reserve 附近。

注意,如果某个 token 的 balance < reserve,这里的 .sub(reserve) 会因为 SafeMath 下溢而 revert。skim() 只适合处理 balance >= reserve 且存在多余余额的场景。


8. skim 的直观例子

假设当前 Pair 状态:

reserve0 = 1000
reserve1 = 1000

balance0 = 1000
balance1 = 1000

有人直接转了 100 个 token0 给 Pair:

reserve0 = 1000
balance0 = 1100

这时候调用:

pair.skim(alice);

Pair 会计算:

balance0 - reserve0 = 1100 - 1000 = 100
balance1 - reserve1 = 1000 - 1000 = 0

然后把:

100 token0

转给 Alice。

调用后:

reserve0 = 1000
balance0 = 1000

也就是多出来的 token 被“扫走”了。


9. skim 为什么不更新 reserve?

因为 skim() 的目标不是承认新余额。

它的目标是:

把多出来的余额拿走,让 balance 回到 reserve。

所以它不需要 _update()

如果它调用 _update(),就会把 reserve 更新成 balance,那就不是 skim 了。

对比一下:

skim:balance 往 reserve 靠
sync:reserve 往 balance 靠

这是这两个函数最关键的区别。


10. 谁可以调用 skim?

skim() 是 external,没有权限限制。

任何人都可以调用:

pair.skim(to);

这听起来好像有点吓人:

那别人不是可以把 Pair 里的钱扫走?

注意,只能扫走:

balance - reserve

也就是多出来的部分。

不能扫走 reserve 内的正常池子资产。

如果当前:

balance0 == reserve0
balance1 == reserve1

那调用 skim() 没有可拿的多余余额。

如果有人真的直接给 Pair 转了 token,多出来的部分从协议语义上并没有被计入 reserve。

它不属于某个新增 LP 的份额,也不是通过 swap 规则进来的。

skim() 允许把这部分“游离余额”移走。


11. 直接转 token 给 Pair,然后被 skim,算谁的?

这就很链上了。

如果你直接给 Pair 转 token,但没有调用 mint(),那 Pair 不会给你 LP Token。

这笔 token 只是让:

balance > reserve

在有人调用 skim() 前,这些多余 token 会暂时待在 Pair。

谁都可以调用 skim(to) 把它们扫走。

所以从用户角度:

不要直接给 Pair 转 token,除非你知道自己在干什么。

正常添加流动性要通过 Router,或者至少自己手动:

1. 转 token0/token1 到 Pair
2. 立刻调用 Pair.mint(to)

如果只转不 mint,那就是把资产放在一个很尴尬的位置。

链上没有“误转找回客服”。直接转给 Pair 的多余资产可能被任何人 skim()


12. skim 和 fee-on-transfer token 的微妙关系

skim() 对一些特殊 token 也有意义。

比如某些 token 有反射、rebasing、空投、自动增发等机制,可能导致 Pair 的 balanceOf(pair) 自动增加,而 reserve 没变。

这时候:

balance > reserve

skim() 可以把多出来的部分转走。

不过对特殊 token 的支持是一个坑很多的领域。

Uniswap V2 原始 Pair 逻辑对标准 ERC20 最友好。

Router02 只是额外支持 fee-on-transfer swap 的部分场景,但并不意味着所有奇怪 token 都完美兼容。


13. sync:让 reserve 承认当前 balance

现在看另一个函数:

function sync() external lock {
    _update(
        IERC20(token0).balanceOf(address(this)),
        IERC20(token1).balanceOf(address(this)),
        reserve0,
        reserve1
    );
}

sync() 的作用是:

把 Pair 的 reserve 更新为当前真实 balance。

也就是:

reserve0 = balance0
reserve1 = balance1

同时,因为它调用 _update(),还会更新:

price0CumulativeLast
price1CumulativeLast
blockTimestampLast

并发出:

emit Sync(reserve0, reserve1);

14. sync 的直观例子

假设当前:

reserve0 = 1000
reserve1 = 1000

balance0 = 1100
balance1 = 1000

调用:

pair.sync();

会把 reserve 更新成:

reserve0 = 1100
reserve1 = 1000

这表示 Pair 正式承认了多出来的 100 token0。

此后价格也会发生变化:

旧比例:1000 / 1000
新比例:1100 / 1000

如果 token0 是输入侧资产,那么 token0 相对变多,token0 的价格会下降,token1 相对更贵。

所以 sync() 不只是一个“整理余额”的函数。

它会影响池子的报价状态。


15. sync 为什么会影响价格?

因为 Uniswap V2 的价格来自 reserve 比例。

price0 ≈ reserve1 / reserve0
price1 ≈ reserve0 / reserve1

如果你调用 sync() 把 reserve 改了,价格就改了。

例如:

sync 前:
reserve0 = 1000
reserve1 = 1000
price0 = 1 token1/token0

sync 后:
reserve0 = 1100
reserve1 = 1000
price0 = 1000 / 1100 ≈ 0.909 token1/token0

也就是说,直接转 token0 给 Pair,然后 sync(),等于把池子价格往 token0 变便宜的方向推。

当然,你没有拿到 LP Token,也没有通过 swap 拿走 token1。

所以这通常不是一个正常用户会主动做的事。

但对于一些特殊 token、余额恢复、协议维护、套利边界来说,它有存在意义。


16. skim 和 sync 的核心对比

这两个函数可以这样记:

skim:
    多余 balance 被转走
    reserve 不变
    目标是 balance 回到 reserve

sync:
    reserve 更新为当前 balance
    balance 不变
    目标是 reserve 跟上 balance

更短:

skim:扫余额
sync:认余额

再配一张表:

场景:balance0 = 1100, reserve0 = 1000

skim(to):
    转走 100 token0 给 to
    reserve0 仍是 1000
    balance0 回到 1000

sync():
    不转 token
    reserve0 更新为 1100
    balance0 仍是 1100

这是最核心的区别。


17. reserve 和 balance 不一致时,谁说了算?

要看具体函数。

mint

mint() 看:

amount0 = balance0 - reserve0;
amount1 = balance1 - reserve1;

它把多出来的 balance 当成新增流动性。

所以如果你直接转 token 进去,然后调用 mint(),这些多出来的 token 会用于计算 LP Token。

burn

burn() 用:

amount0 = liquidity * balance0 / totalSupply;
amount1 = liquidity * balance1 / totalSupply;

它按当前真实 balance 给 LP 分资产。

所以如果 balance 多了,退出 LP 可以按份额分到。

swap

swap() 最后看 balance,通过 balance 和 reserve 反推 amountIn,并检查 K。

所以 balance 决定最终交易是否合法。

skim

skim() 把:

balance - reserve

转走。

sync

sync() 把 reserve 改成 balance。

所以没有一句简单的“谁说了算”。

Pair 的设计是:

reserve 是协议记账基准
balance 是真实资产事实
不同函数用不同方式协调二者

这就是 V2 代码读起来很有意思的地方。


18. 直接转 token 后,有几种可能路径?

假设有人直接给 Pair 转了 100 token0。

状态:

balance0 = reserve0 + 100

接下来可能发生几种事。

路径一:调用 skim

多出来的 100 token0 被转走
reserve 不变

路径二:调用 sync

reserve0 增加 100
池子价格改变
多出来的 token 被 Pair 正式纳入储备

路径三:调用 mint

如果同时也转了 token1,并且比例合适:

Pair 会把 balance - reserve 当作新增流动性
给 to mint LP Token

如果只转了 token0,没有 token1,调用 mint() 可能因为另一边新增量为 0 而无法 mint 有效 LP。

路径四:调用 burn

如果有人 burn LP,使用当前 balance 计算返还金额。

多出来的 token 会按 LP 份额分给退出者。

路径五:调用 swap

Pair 会把多出来的 token 视作可能的输入,最终由 K 检查决定交易是否合法。


19. skim 和 sync 为什么没有权限控制?

因为它们不应该需要管理员。

Uniswap V2 Pair 是无需许可的合约。

任何人都可以:

交易
添加流动性
移除流动性
同步余额
扫出多余余额

如果 sync() 需要管理员,那么当 Pair 遇到某些特殊 token 或余额异常时,普通用户无法恢复状态。

如果 skim() 需要管理员,那么误转或额外余额可能永远卡在 Pair 里。

所以它们是 public/external permissionless。

当然,permissionless 的代价是:

直接转进 Pair 的多余资产不受保护
任何人都可能 skim

所以用户必须通过正确路径交互。


20. _safeTransfer 再看一眼

skim() 里转 token 用的是:

_safeTransfer(_token0, to, amount);
_safeTransfer(_token1, to, amount);

源码:

function _safeTransfer(address token, address to, uint value) private {
    (bool success, bytes memory data) =
        token.call(abi.encodeWithSelector(SELECTOR, to, value));

    require(
        success && (data.length == 0 || abi.decode(data, (bool))),
        'UniswapV2: TRANSFER_FAILED'
    );
}

这和前面讲的一样:

兼容不返回 bool 的 ERC20
兼容标准返回 true 的 ERC20
失败时 revert

Pair 所有向外转 token 的地方都尽量走这个逻辑。


21. sync 会影响 TWAP Oracle 吗?

会。

因为 sync() 调用了 _update()

_update() 会:

1. 根据旧 reserve 和 timeElapsed 更新 cumulative price
2. 把 reserve 更新为当前 balance
3. 更新 blockTimestampLast

所以 sync() 会让 Pair 的 Oracle 状态向前推进。

如果有人直接转 token 改变 balance,然后调用 sync(),新的 reserve 会成为之后时间段的价格基础。

这意味着:

sync 可以把非 swap 造成的余额变化纳入价格。

这也是为什么 Oracle 使用者不能天真地认为价格只会被正常 swap 改变。

只要 reserve 被更新,价格基础就变了。


22. skim 会影响 TWAP Oracle 吗?

skim() 不调用 _update()

所以它不会直接更新:

price0CumulativeLast
price1CumulativeLast
blockTimestampLast
reserve0
reserve1

它只是转走多余的 balance。

如果 skim() 把 balance 拉回 reserve,那么 Pair 的后续价格状态也不会因为那笔多余余额改变。

所以:

sync 会认账并更新 Oracle 状态
skim 不认账,只扫掉多余余额

23. lock 对 skim/sync 也有必要吗?

有必要。

虽然 skim()sync() 看起来简单,但它们也涉及外部调用或状态更新。

skim()_safeTransfer,这是外部 token 调用。

如果没有 lock,恶意 token 或接收方相关逻辑可能造成重入。

sync() 会更新 reserve 和 Oracle 状态。

如果在其他关键函数执行中被重入调用,可能破坏 reserve 和 balance 的预期关系。

所以它们都加 lock

这是一种简单一致的保护策略:

凡是会改变 Pair 状态,或会转出 token 的关键入口,都加锁。

24. skim/sync 和特殊 ERC20 的边界

真实世界的 ERC20 很多并不标准。

比如:

fee-on-transfer:转账时扣手续费
rebasing token:余额会自动变化
reflection token:持有人余额会被动增加
blacklist token:某些地址不能转账
返回值不规范 token:transfer 不返回 bool

Uniswap V2 core 不是为所有怪 token 完美设计的。

skim()sync() 提供了一些余额修正手段,但不代表所有 token 都安全兼容。

比如 rebasing token 会让 Pair 的 balance 自动改变。

这时 reserve 可能经常和 balance 不一致。

不同 rebasing 方向会带来不同问题:

positive rebase:
    balance 增加,可以 skim 或 sync。

negative rebase:
    balance 减少,可能导致 reserve 大于 balance。
    这种情况下 swap 可能异常,因为 Pair 以为自己有更多储备。

所以很多特殊 token 和 AMM 结合时,需要专门设计。


25. reserve 大于 balance 会怎样?

前面大多讲的是:

balance > reserve

但还有一种危险情况:

balance < reserve

这可能来自:

1. negative rebase
2. token 强制扣余额
3. 非标准 token 行为
4. 某些极端转账手续费模型

如果 Pair 记录:

reserve0 = 1000

但真实:

balance0 = 900

那 Pair 的记账就高估了资产。

这会影响 swap、burn 等操作。

调用 sync() 可以把 reserve 降到 balance:

reserve0 = 900

但这意味着池子承认损失,LP 承担损失。

skim() 无法处理这种情况,因为没有多余余额可扫;如果直接调用,差值计算还可能下溢并 revert。

这也是为什么一些特殊 token 会要求在每次转账后调用 sync(),或者根本不适合标准 V2 Pair。


26. 为什么 Pair 不自动处理所有异常余额?

因为 Pair 没有办法自动知道 token 余额什么时候变化。

ERC20 转账不会通知接收方合约。

也就是说,当有人直接转 token 到 Pair 时,Pair 的代码不会被执行。

Pair 只有在有人调用它的函数时,才有机会读取 balance。

所以不能做到:

余额一变,reserve 自动更新

只能在这些函数里处理:

mint
burn
swap
sync
skim

这就是 ERC20 模型决定的。


27. 用一段流程理解 balance/reserve 的生命周期

正常 swap:

交易前:
    reserve = balance

Router 转入 tokenIn:
    balance > reserve

Pair.swap 转出 tokenOut:
    balance 发生变化

Pair 反推 amountIn:
    用 balance 和 reserve 比较

K 检查通过:
    说明最终 balance 合法

_update:
    reserve = balance

正常 mint:

转入 token0/token1:
    balance > reserve

Pair.mint:
    amount = balance - reserve
    mint LP

_update:
    reserve = balance

正常 burn:

转入 LP Token 到 Pair:
    LP balanceOf[pair] 增加

Pair.burn:
    用 token balance 按份额返还资产
    burn LP
    转出 token

重新读取 balance

_update:
    reserve = balance

异常直接转账:

有人直接转 token:
    balance > reserve

后续选择:
    skim:把多余的扫走
    sync:承认多余的为 reserve
    mint/burn/swap:按各自规则处理

28. lock、skim、sync 的设计哲学

这三个小东西体现了 Uniswap V2 的几个设计原则。

第一,状态变化要有互斥保护

lock 保证关键函数不能重入同一个 Pair。

这对 flash swap、外部 token 调用尤其重要。

第二,Pair 区分记账状态和真实余额

reserve 是记账,balance 是事实。

V2 不是盲目相信其中一个,而是在不同函数里有明确协调方式。

第三,异常余额不自动归属

直接转进 Pair 的 token 不会自动给你 LP Token。

要么被 mint() 正确吸收,要么可能被 skim() 扫走,要么被 sync() 纳入 reserve。

第四,修复工具是 permissionless 的

任何人都能 skim()sync(),因为 Pair 不依赖管理员维护。

第五,核心函数保持简单

Pair 不为每种特殊 token 写复杂逻辑,只提供基础边界处理。

这也是 V2 的风格:

小而硬。

小结

这一篇我们拆了 lockskim()sync()

你现在应该能回答:

1. lock 是干什么的?
   防止同一个 Pair 的关键函数被重入。

2. 为什么 Pair 需要 lock?
   因为 swap、burn、skim、flash swap 都会外部调用 token 或合约。

3. skim 是干什么的?
   把 balance 超过 reserve 的多余 token 转给指定地址。

4. sync 是干什么的?
   把 reserve 更新为当前真实 balance,并更新 Oracle 累计状态。

5. skim 和 sync 的区别是什么?
   skim 是 balance 向 reserve 靠;sync 是 reserve 向 balance 靠。

6. 如果有人直接给 Pair 转 token,会怎样?
   balance 会变大,但 reserve 不自动变。后续可能被 mint、skim、sync、burn 或 swap 处理。

7. reserve 和 balance 不一致时谁说了算?
   balance 是真实资产,reserve 是协议记账基准;不同函数用不同方式协调二者。

8. sync 会影响价格吗?
   会。因为 reserve 变了,后续价格和 TWAP 基础都会变。

9. skim 会影响 Oracle 吗?
   不直接影响。它不调用 _update,不更新 reserve 和 cumulative price。

10. 特殊 ERC20 会带来什么问题?
    可能导致 balance 和 reserve 异常偏离,尤其 negative rebase 会让 reserve 高估真实余额。

到这里,UniswapV2Pair.sol 的核心几乎全都拆完了。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
tomenengr
tomenengr
区块链工程专业学生一枚,喜欢倒腾ai应用,努力学习区块链ing