教程区块链区块链基础知识chunk_58_ch19_dex_pt419.8 测试套件与无常损失计算

本页目录

从19.1的数学建模到19.7的链上部署,我们已经完成了一个简易DEX的全链路工程。然而,合约一旦部署即不可更改,任何经济逻辑上的漏洞都将造成真实的资产损失。19.8节将回到测试的视角,用Hardhat测试套件从数学上严格验证三项核心假设:恒定乘积是否成立、Swap输出是否精确、无常损失是否可被公式量化。这是整个第19章的最后一道质量关卡。

19.8.1 为什么要写测试套件

智能合约的测试不仅是"代码是否能跑通",更是"经济学假设是否成立"。在传统Web开发中,我们可以通过热补丁(hotfix)修复线上bug;在区块链上,合约状态不可篡改,任何数学漏洞都会导致真实资金损失。

测试分为三个层次:

  • 单元测试(Unit Test):验证单个函数的边界行为,如 getAmountOut(0) 应返回0,addLiquidity 在首次注入时LP Token总量是否正确。
  • 集成测试(Integration Test):验证多个合约之间的交互,如Factory创建Pair后Router是否能正确路由。
  • 端到端测试(E2E Test):模拟真实用户场景,从部署到Swap到移除流动性的完整资产流动闭环。

Hardhat测试框架提供 loadFixtureevm_snapshot / evm_revert,使我们能在每个测试用例之间快速重置链状态,同时又能构造复杂的时间序列场景。

19.8.2 测试用例一:初始流动性后k值验证

场景:向空池子注入10,000 TTA + 10,000 TTB,验证储备量与LP Token总量。

javascript
const { expect } = require("chai");
const { ethers } = require("hardhat");

it("初始流动性注入后储备与LP总量验证", async () => {
  const [owner] = await ethers.getSigners();
  const amount = ethers.parseUnits("10000", 18);

  // 授权并添加流动性
  await tokenA.approve(router.target, amount);
  await tokenB.approve(router.target, amount);
  await router.addLiquidity(tokenA.target, tokenB.target, amount, amount, 0, 0, owner.address, deadline);

  const [reserve0, reserve1] = await pair.getReserves();
  expect(reserve0).to.equal(amount);
  expect(reserve1).to.equal(amount);

  const totalSupply = await pair.totalSupply();
  const expectedLP = ethers.parseUnits("10000", 18); // sqrt(10^22 * 10^22) / 10^18 = 10^22 / 10^18 = 10^4 (scaled by 10^18)
  expect(totalSupply).to.equal(expectedLP);

  const k = reserve0 * reserve1;
  const expectedK = amount * amount;
  expect(k).to.equal(expectedK);
});

要点:首次注入时LP Token按 Linitial=x0×y0L_{\text{initial}} = \sqrt{x_0 \times y_0} 铸造。当 x0=y0=1022x_0 = y_0 = 10^{22}(含18位精度)时,L=1022L = 10^{22},以18位精度表示即为 104×1018=102210^{4} \times 10^{18} = 10^{22}

19.8.3 测试用例二:精确Swap计算验证

场景:向已有池子(10000, 10000)输入1,000 TTA,验证链上输出与数学公式是否一致。

含手续费的精确输出公式:

Δy=ybeforexbefore×ybeforexbefore+Δx×0.997\Delta y = y_{\text{before}} - \frac{x_{\text{before}} \times y_{\text{before}}}{x_{\text{before}} + \Delta x \times 0.997}

代入数值:

Δy=1000010000×1000010000+1000×0.997906.61\Delta y = 10000 - \frac{10000 \times 10000}{10000 + 1000 \times 0.997} \approx 906.61
javascript
it("Swap输出与数学公式一致", async () => {
  // ... 已添加初始流动性

  const amountIn = ethers.parseUnits("1000", 18);
  const reserveBefore = await pair.getReserves();

  // 链上执行swap(无滑点保护,仅用于验证)
  await tokenA.approve(router.target, amountIn);
  await router.swapExactTokensForTokens(
    amountIn, 0, [tokenA.target, tokenB.target], owner.address, deadline
  );

  const reserveAfter = await pair.getReserves();
  const actualOut = reserveBefore[1] - reserveAfter[1];

  // 数学计算(BigInt整数运算,允许1 wei舍入误差)
  const amountInWithFee = amountIn * 997n / 1000n;
  const numerator = reserveBefore[0] * reserveBefore[1];
  const denominator = reserveBefore[0] + amountInWithFee;
  const expectedOut = reserveBefore[1] - numerator / denominator;

  // 允许最多2 wei的舍入差异
  const diff = actualOut > expectedOut ? actualOut - expectedOut : expectedOut - actualOut;
  expect(diff).to.be.lte(2n);
});

这一测试的意义在于:合约的Solidity实现与我们的数学模型至少在数值上是等价的。如果测试失败,说明合约中的fee处理或除法舍入策略存在问题。

19.8.4 测试用例三:无常损失量化测试

场景:LP以1:1价格注入流动性(10,000 TTA + 10,000 TTB),随后大额交易将池内价格从1:1推至1:2,LP移除全部流动性并计算损失。

无常损失公式推导

当价格从 P0P_0 变为 P1P_1 时,定义价格比率 ρ=P1/P0\rho = P_1 / P_0。LP提取时的资产数量为:

x=Lρ,y=L/ρx' = L \cdot \sqrt{\rho}, \quad y' = L / \sqrt{\rho}

其中 LL 为LP Token总量(保持守恒)。无常损失定义为LP资产价值与HODL策略的价值差异百分比:

IL=2ρ1+ρ1IL = \frac{2\sqrt{\rho}}{1 + \rho} - 1

ρ=2\rho = 2(价格翻倍)时:

IL=221+21=2×1.4142310.0571=5.71%IL = \frac{2\sqrt{2}}{1 + 2} - 1 = \frac{2 \times 1.4142}{3} - 1 \approx -0.0571 = -5.71\%

这意味着:如果LP单纯"持有不动",资产价值会比HODL策略低约5.71%。注意这是"相对于HODL的机会成本",而非绝对亏损。如果手续费收益超过5.71%,LP仍然是盈利的。

Hardhat 测试脚本

javascript
it("无常损失量化:价格1→2时损失约5.71%", async () => {
  const [owner, trader] = await ethers.getSigners();
  const tenK = ethers.parseUnits("10000", 18);

  // 1. LP注入流动性(1:1)
  await tokenA.approve(router.target, tenK);
  await tokenB.approve(router.target, tenK);
  await router.addLiquidity(tokenA.target, tokenB.target, tenK, tenK, 0, 0, owner.address, deadline);

  // 2. 交易者用大量TTA买入TTB,将价格从1:1推至1:2
  // 根据 AMM 公式,要使价格变为1:2,需要在池中有大约7071 TTA和14142 TTB(含手续费修正)
  // 这里通过反复swap使池到达目标比例,实际数值由脚本迭代确定
  const largeAmount = ethers.parseUnits("100000", 18);
  await tokenA.connect(trader).approve(router.target, largeAmount);
  // 实际测试中会找到使价格比恰好达到1:2的输入量,此处示意
  // ...

  // 3. LP移除流动性
  const lpBalance = await pair.balanceOf(owner.address);
  await pair.approve(router.target, lpBalance);
  const beforeA = await tokenA.balanceOf(owner.address);
  const beforeB = await tokenB.balanceOf(owner.address);
  await router.removeLiquidity(tokenA.target, tokenB.target, lpBalance, 0, 0, owner.address, deadline);
  const afterA = await tokenA.balanceOf(owner.address);
  const afterB = await tokenB.balanceOf(owner.address);

  const retrievedA = afterA - beforeA;
  const retrievedB = afterB - beforeB;

  // 4. 计算价值(假设外部市场仍为1:2,以TTB为计价单位)
  const lpValue = retrievedA * 2n + retrievedB; // 1 TTA = 2 TTB
  const hodlValue = tenK * 2n + tenK; // 如果LP从未提供流动性,仍持有10kTTA+10kTTB

  // 允许1%容差
  const ilPercent = Number(lpValue - hodlValue) / Number(hodlValue);
  expect(Math.abs(ilPercent + 0.0571)).to.be.lessThan(0.01);
});

19.8.5 Python/JS 计算脚本:IL公式数值验证

为了直观理解无常损失的严重程度,我们用Python绘制不同价格比率下的IL曲线:

python
import numpy as np
import matplotlib.pyplot as plt

def impermanent_loss(rho):
    """rho = P1 / P0,价格比率"""
    return 2 * np.sqrt(rho) / (1 + rho) - 1

rho = np.linspace(0.1, 10, 500)
il = impermanent_loss(rho) * 100  # 转换为百分比

plt.figure(figsize=(10, 6))
plt.plot(rho, il, 'b-', linewidth=2)
plt.axhline(0, color='gray', linestyle='--', alpha=0.5)
plt.axvline(1, color='gray', linestyle='--', alpha=0.5)
plt.xlabel('Price Ratio (ρ = P₁ / P₀)')
plt.ylabel('Impermanent Loss (%)')
plt.title('Impermanent Loss vs Price Ratio')
plt.grid(True, alpha=0.3)

# 标注几个关键点
for r in [0.5, 1, 2, 4]:
    loss = impermanent_loss(r) * 100
    plt.annotate(f'ρ={r}: {loss:.1f}%', xy=(r, loss), xytext=(r, loss-3),
                 arrowprops=dict(arrowstyle='->', lw=1, color='red'))

plt.tight_layout()
plt.savefig('impermanent_loss.png', dpi=150)
plt.show()

关键数值速查

价格比率 ρ无常损失
0.5 (跌50%)-5.72%
1.0 (不变)0%
2.0 (涨100%)-5.71%
4.0 (涨300%)-20.0%
10.0 (涨900%)-41.9%

无常损失曲线关于 ρ=1\rho = 1 对称。价格偏离越远,损失增速递减但绝对值快速扩大。对于流动性提供者而言,理解IL是风险管理的第一步。

19.8.6 第19章完整工程闭环回顾

让我们站到一个更高的视角,回顾从19.1到19.8的全过程:

flowchart LR
    A[19.1-19.2<br/>AMM数学模型<br/>恒定乘积公式] --> B[19.3<br/>Swap手续费<br/>滑点保护]
    B --> C[19.4<br/>价格预言机<br/>闪电贷防御]
    C --> D[19.5<br/>前端UI<br/>钱包+交易面板]
    D --> E[19.6<br/>端到端测试<br/>全流程验证]
    E --> F[19.7<br/>合约部署<br/>源码验证]
    F --> G[19.8<br/>数学验证<br/>无常损失测试]
    G --> A

这七个小节构成了一个完整的DEX开发闭环

  1. 数学模型定义了系统的不变式(x×y=kx \times y = k)。
  2. 合约实现将数学转化为链上可执行代码。
  3. 前端产品让普通用户无需理解Solidity即可交互。
  4. 测试验证确保代码实现与数学模型等价。
  5. 部署运维将产品推向真实环境。

一个成功的DEX项目,绝不仅是几行Solidity代码——它是数学、工程、产品与安全的系统工程。

要点总结

  • 智能合约测试不仅是验证代码正确性,更是验证经济学假设的数值准确性。
  • Swap输出测试应精确到 wei 级别,确保合约实现与数学公式等价。
  • 无常损失公式 IL=2ρ1+ρ1IL = \frac{2\sqrt{\rho}}{1+\rho} - 1 是LP风险管理的核心工具,必须通过单元测试在代码层面验证。
  • DEX开发的闭环是:数学模型 → 合约实现 → 前端产品 → 测试验证 → 部署运维。

本章小结:带走的3个关键认知

  1. 测试是合约安全的最后防线:在不可篡改的链上环境中,Hardhat测试框架提供的快照、时间操纵与精确数值断言能力,是发现和预防经济漏洞的唯一手段。
  2. 数学公式必须在代码中验证:恒定乘积、手续费处理、无常损失——所有这些经济模型的正确性,最终都应体现为通过/失败的测试用例,而非仅停留在纸面推导。
  3. DEX是一个系统工程的闭环:从数学直觉到Solidity合约,从React前端到Hardhat测试,从本地开发到链上部署——每一步都不可或缺。理解这个闭环,是走向Web3产品开发者的必经之路。

评论

0

评论加载中…

发表评论

0/2000