边界函数处理
前面我们已经拆完了 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 就是边界保护和状态修复工具。
平时不显眼,出事时很关键。
这一篇绕不开一个核心概念:
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() 要处理的典型场景。
因为 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 承认这笔余额
这两个函数就是一对。
先看 lock。
源码:
uint private unlocked = 1;
modifier lock() {
require(unlocked == 1, 'UniswapV2: LOCKED');
unlocked = 0;
_;
unlocked = 1;
}
它用一个状态变量 unlocked 做互斥锁。
进入函数时:
要求 unlocked == 1
然后设置 unlocked = 0
函数执行结束后:
恢复 unlocked = 1
如果执行过程中有人试图再次进入带 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
只要有外部调用,就存在重入风险。
以 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 是必要的。
注意一个细节:
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 的状态一致性。
现在看第一个边界函数:
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 且存在多余余额的场景。
假设当前 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 被“扫走”了。
因为 skim() 的目标不是承认新余额。
它的目标是:
把多出来的余额拿走,让 balance 回到 reserve。
所以它不需要 _update()。
如果它调用 _update(),就会把 reserve 更新成 balance,那就不是 skim 了。
对比一下:
skim:balance 往 reserve 靠
sync:reserve 往 balance 靠
这是这两个函数最关键的区别。
skim() 是 external,没有权限限制。
任何人都可以调用:
pair.skim(to);
这听起来好像有点吓人:
那别人不是可以把 Pair 里的钱扫走?
注意,只能扫走:
balance - reserve
也就是多出来的部分。
不能扫走 reserve 内的正常池子资产。
如果当前:
balance0 == reserve0
balance1 == reserve1
那调用 skim() 没有可拿的多余余额。
如果有人真的直接给 Pair 转了 token,多出来的部分从协议语义上并没有被计入 reserve。
它不属于某个新增 LP 的份额,也不是通过 swap 规则进来的。
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()。
skim() 对一些特殊 token 也有意义。
比如某些 token 有反射、rebasing、空投、自动增发等机制,可能导致 Pair 的 balanceOf(pair) 自动增加,而 reserve 没变。
这时候:
balance > reserve
skim() 可以把多出来的部分转走。
不过对特殊 token 的支持是一个坑很多的领域。
Uniswap V2 原始 Pair 逻辑对标准 ERC20 最友好。
Router02 只是额外支持 fee-on-transfer swap 的部分场景,但并不意味着所有奇怪 token 都完美兼容。
现在看另一个函数:
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);
假设当前:
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() 不只是一个“整理余额”的函数。
它会影响池子的报价状态。
因为 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、余额恢复、协议维护、套利边界来说,它有存在意义。
这两个函数可以这样记:
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
这是最核心的区别。
要看具体函数。
mint() 看:
amount0 = balance0 - reserve0;
amount1 = balance1 - reserve1;
它把多出来的 balance 当成新增流动性。
所以如果你直接转 token 进去,然后调用 mint(),这些多出来的 token 会用于计算 LP Token。
burn() 用:
amount0 = liquidity * balance0 / totalSupply;
amount1 = liquidity * balance1 / totalSupply;
它按当前真实 balance 给 LP 分资产。
所以如果 balance 多了,退出 LP 可以按份额分到。
swap() 最后看 balance,通过 balance 和 reserve 反推 amountIn,并检查 K。
所以 balance 决定最终交易是否合法。
skim() 把:
balance - reserve
转走。
sync() 把 reserve 改成 balance。
所以没有一句简单的“谁说了算”。
Pair 的设计是:
reserve 是协议记账基准
balance 是真实资产事实
不同函数用不同方式协调二者
这就是 V2 代码读起来很有意思的地方。
假设有人直接给 Pair 转了 100 token0。
状态:
balance0 = reserve0 + 100
接下来可能发生几种事。
多出来的 100 token0 被转走
reserve 不变
reserve0 增加 100
池子价格改变
多出来的 token 被 Pair 正式纳入储备
如果同时也转了 token1,并且比例合适:
Pair 会把 balance - reserve 当作新增流动性
给 to mint LP Token
如果只转了 token0,没有 token1,调用 mint() 可能因为另一边新增量为 0 而无法 mint 有效 LP。
如果有人 burn LP,使用当前 balance 计算返还金额。
多出来的 token 会按 LP 份额分给退出者。
Pair 会把多出来的 token 视作可能的输入,最终由 K 检查决定交易是否合法。
因为它们不应该需要管理员。
Uniswap V2 Pair 是无需许可的合约。
任何人都可以:
交易
添加流动性
移除流动性
同步余额
扫出多余余额
如果 sync() 需要管理员,那么当 Pair 遇到某些特殊 token 或余额异常时,普通用户无法恢复状态。
如果 skim() 需要管理员,那么误转或额外余额可能永远卡在 Pair 里。
所以它们是 public/external permissionless。
当然,permissionless 的代价是:
直接转进 Pair 的多余资产不受保护
任何人都可能 skim
所以用户必须通过正确路径交互。
_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 的地方都尽量走这个逻辑。
会。
因为 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 被更新,价格基础就变了。
skim() 不调用 _update()。
所以它不会直接更新:
price0CumulativeLast
price1CumulativeLast
blockTimestampLast
reserve0
reserve1
它只是转走多余的 balance。
如果 skim() 把 balance 拉回 reserve,那么 Pair 的后续价格状态也不会因为那笔多余余额改变。
所以:
sync 会认账并更新 Oracle 状态
skim 不认账,只扫掉多余余额
有必要。
虽然 skim() 和 sync() 看起来简单,但它们也涉及外部调用或状态更新。
skim() 会 _safeTransfer,这是外部 token 调用。
如果没有 lock,恶意 token 或接收方相关逻辑可能造成重入。
sync() 会更新 reserve 和 Oracle 状态。
如果在其他关键函数执行中被重入调用,可能破坏 reserve 和 balance 的预期关系。
所以它们都加 lock。
这是一种简单一致的保护策略:
凡是会改变 Pair 状态,或会转出 token 的关键入口,都加锁。
真实世界的 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 结合时,需要专门设计。
前面大多讲的是:
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。
因为 Pair 没有办法自动知道 token 余额什么时候变化。
ERC20 转账不会通知接收方合约。
也就是说,当有人直接转 token 到 Pair 时,Pair 的代码不会被执行。
Pair 只有在有人调用它的函数时,才有机会读取 balance。
所以不能做到:
余额一变,reserve 自动更新
只能在这些函数里处理:
mint
burn
swap
sync
skim
这就是 ERC20 模型决定的。
正常 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:按各自规则处理
这三个小东西体现了 Uniswap V2 的几个设计原则。
lock 保证关键函数不能重入同一个 Pair。
这对 flash swap、外部 token 调用尤其重要。
reserve 是记账,balance 是事实。
V2 不是盲目相信其中一个,而是在不同函数里有明确协调方式。
直接转进 Pair 的 token 不会自动给你 LP Token。
要么被 mint() 正确吸收,要么可能被 skim() 扫走,要么被 sync() 纳入 reserve。
任何人都能 skim() 或 sync(),因为 Pair 不依赖管理员维护。
Pair 不为每种特殊 token 写复杂逻辑,只提供基础边界处理。
这也是 V2 的风格:
小而硬。
这一篇我们拆了 lock、skim() 和 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 的核心几乎全都拆完了。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码