教程区块链区块链基础知识chunk_57_ch19_dex_pt319.5 前端UI:钱包连接、交易面板、流动性管理

本页目录

完成了19.1至19.4的合约层逻辑后,我们已掌握了AMM的经济内核。然而,仅有Solidity合约并不能让用户直接使用DEX。去中心化应用(DApp,Decentralized Application)的精髓在于:将链上逻辑通过前端界面呈现给真实用户。一个完整的DEX前端需要负责三件事:连接钱包、展示状态、构造并发送交易。本节将拆解DEX前端的核心组件,编写一个可运行的React + ethers.js交互面板。

19.5.1 架构概览与组件拆解

一个DEX前端可抽象为三层架构:

  • 钱包层(Wallet Provider):负责用户授权、账户管理、链切换。MetaMask通过浏览器注入 window.ethereum 对象,是最常见的钱包入口。
  • 合约交互层(ethers.js/viem):负责链上读取(call)与链上写入(sendTransaction)。ethers.js v6 是目前主流选择,提供 BrowserProviderContract 抽象。
  • UI状态层(React/Vue):负责界面渲染、用户输入校验、交易状态反馈。

核心组件清单如下:

  • WalletConnector:检测MetaMask存在,请求账户授权,监听 accountsChanged / chainChanged / disconnect 事件,并在状态变化时清理缓存。
  • TokenSelector:下拉选择代币,联动查询 balanceOfdecimals,支持自定义代币地址输入。
  • SwapPanel:输入/输出金额、实时兑换率、滑点容差(Slippage Tolerance)设置、价格影响(Price Impact)估算。
  • LiquidityPanel:添加与移除LP的成对输入框,展示当前LP Token余额与池子总流动性。
  • PriceChart(可选):展示基于池子储备变化的价格走势。

安全原则:前端仅仅是展示层,核心资产操作全部通过链上合约执行,私钥始终保存在MetaMask内部,绝不进入JavaScript内存。

19.5.2 钱包连接:MetaMask集成与事件处理

ethers.js v6 使用 ethers.BrowserProvider 替代了 v5 的 Web3Provider,调用方式如下:

javascript
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();

关键事件监听不可忽略:

  • accountsChanged:用户切换账户时,前端需重置所有余额与授权状态。
  • chainChanged:用户切换网络时,前端需校验链ID是否匹配合约部署网络(如Hardhat本地链ID 31337 或 Sepolia 11155111)。若不匹配,应提示用户切换或调用 wallet_switchEthereumChain
  • disconnect:钱包断开连接时清理状态,避免用户误以为仍在连接状态。

前端只需处理EOA(外部拥有账户,Externally Owned Account)的签名交互;合约账户(如多签钱包、智能合约钱包)的复杂逻辑通常由Router合约自动路由,前端无需特别处理。

19.5.3 交易面板Swap:实时计算、授权与交易构造

Swap是DEX最核心的用户交互。前端需要根据当前池子储备实时计算输出金额。

兑换率计算

不含手续费时,恒定乘积公式给出输出:

Δy=ykx+Δx\Delta y = y - \frac{k}{x + \Delta x}

其中 xxyy 为池子中两种代币的储备量,k=x×yk = x \times y 为常数。

包含0.3%手续费后,实际交互公式变为:

Δy=ykx+Δx×0.997\Delta y = y - \frac{k}{x + \Delta x \times 0.997}

这意味着用户实际支付的输入金额中,0.3%作为手续费计入池子,剩余的99.7%参与恒定乘积计算。

价格影响与滑点保护

价格影响(Price Impact)衡量用户交易额占池子深度的比例。若用户交易1,000 TTA,而池子仅有10,000 TTA储备,则价格影响约为10%,导致显著滑点。前端应在Swap面板实时显示这一指标,提示用户风险。

最小输出保护(Minimum Amount Out)是防止MEV夹心攻击的关键参数:

minAmountOut=expectedAmountOut×(1slippage)\text{minAmountOut} = \text{expectedAmountOut} \times (1 - \text{slippage})

前端通常允许用户设置0.5%或1%的滑点容差。若链上实际输出低于 minAmountOut,交易将revert(回滚),保护用户免受价格突变损失。

两步交易流程

由于ERC-20采用授权-转移(approve-transfer)模型,Swap需要两步:

  1. 授权(approve):用户先调用Token合约的 approve(routerAddress, amountIn),授权Router代为转移代币。前端需监听 Approval 事件确认授权生效。
  2. 执行Swap:用户调用Router的 swapExactTokensForTokens,传入 amountInminAmountOutpathtodeadline 等参数。

无限approve(type(uint256).max) vs 精确approve的争议:无限approve减少用户每次交易前的额外签名步骤,但一旦Router合约被攻击,用户全部余额将面临风险。精确approve更安全,但用户体验略差。前端应提供两种选项并明示风险。

19.5.4 流动性管理面板:添加与移除LP

添加流动性时,用户输入两种代币的数量。若用户输入的比例偏离当前池子储备比例,合约会自动取较小值配平,并退还多余的另一种代币。前端应实时查询 getReserves 并提示用户最优配平比例。

对于首次创建新交易对的用户(First LP),前端应特别提示:“你正在创建新交易对,你的输入将决定初始价格比率。” 此时LP Token总量按 Linitial=x0×y0L_{\text{initial}} = \sqrt{x_0 \times y_0} 铸造。

移除流动性时,用户输入LP Token数量,前端预估可赎回的两种代币数量:

可赎回A=reserveA×lpAmounttotalSupply\text{可赎回A} = \text{reserveA} \times \frac{lpAmount}{totalSupply}

状态刷新策略:每次交易后或每轮新区块产生时,重新调用 getReservesbalanceOf,确保展示数据与链上最新状态一致。

19.5.5 代码示例:React + ethers.js Hook封装

以下是一个可复用的自定义Hook useSwap,封装了核心交互逻辑:

javascript
import { useState, useCallback } from 'react';
import { ethers } from 'ethers';

const ROUTER_ABI = [
  "function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external",
];

const ERC20_ABI = [
  "function approve(address spender, uint256 amount) external",
  "function balanceOf(address account) external view returns (uint256)",
];

export function useSwap(routerAddress, tokenA, tokenB, reserveA, reserveB) {
  const [pending, setPending] = useState(false);

  const getAmountOut = useCallback((amountIn) => {
    const k = reserveA * reserveB;
    return reserveB - k / (reserveA + amountIn * 997n / 1000n);
  }, [reserveA, reserveB]);

  const executeSwap = useCallback(async (signer, amountIn, slippage = 0.005) => {
    setPending(true);
    try {
      const expectedOut = getAmountOut(amountIn);
      const minOut = expectedOut * BigInt(Math.floor((1 - slippage) * 1000)) / 1000n;
      const tokenContract = new ethers.Contract(tokenA, ERC20_ABI, signer);
      const router = new ethers.Contract(routerAddress, ROUTER_ABI, signer);
      const approveTx = await tokenContract.approve(routerAddress, amountIn);
      await approveTx.wait();
      const swapTx = await router.swapExactTokensForTokens(
        amountIn, minOut, [tokenA, tokenB], await signer.getAddress(),
        Math.floor(Date.now() / 1000) + 300
      );
      await swapTx.wait();
      return swapTx;
    } finally {
      setPending(false);
    }
  }, [getAmountOut, routerAddress, tokenA, tokenB]);

  return { getAmountOut, executeSwap, pending };
}

要点总结

  • DApp前端是钱包层、合约层与UI层的三层架构。
  • Swap输出通过恒定乘积公式实时计算,前端必须包含0.3%手续费的修正。
  • 最小输出保护(minAmountOut)是抵御MEV攻击的必备机制。
  • 流动性管理中,首次创建交易对的用户决定了初始价格比率。
flowchart TB
    A[连接钱包] --> B[选择代币A与B]
    B --> C[输入金额]
    C --> D[实时计算输出与价格影响]
    D --> E[授权Router转移代币]
    E --> F[调用swapExactTokensForTokens]
    F --> G[等待链上确认]
    G --> H[刷新余额与池子储备]

19.6 集成测试代币与端到端流程

前端开发需要可预测的链上环境。使用真实主网代币不仅昂贵,且无法精确控制初始储备量。本节将部署两个测试代币,通过完整的「添加流动性 → 执行Swap → 移除流动性」流程,验证整个DEX的经济逻辑是否自洽。

19.6.1 测试代币合约:TestTokenA 与 TestTokenB

借助OpenZeppelin的 ERC20PresetMinterPauser,我们可以在Hardhat网络中快速部署测试代币:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";

contract TestTokenA is ERC20PresetMinterPauser {
    constructor() ERC20PresetMinterPauser("TestTokenA", "TTA") {
        mint(msg.sender, 100000 * 10 ** decimals());
    }
}

contract TestTokenB is ERC20PresetMinterPauser {
    constructor() ERC20PresetMinterPauser("TestTokenB", "TTB") {
        mint(msg.sender, 100000 * 10 ** decimals());
    }
}

每个合约在部署时自动铸造100,000枚代币(精度18位),供测试使用。测试代币无价格风险,可无限重置,是本地开发与集成测试的黄金标准。

19.6.2 完整流程第一步:添加初始流动性

场景设定:用户A持有各10,000枚TTA与TTB,通过Router向Pair合约注入初始流动性。

javascript
// 部署后的交互流程(Hardhat脚本简化版)
await router.addLiquidity(
  tokenA.address, tokenB.address,
  ethers.parseUnits("10000", 18), ethers.parseUnits("10000", 18),
  0, 0, owner.address, deadline
);

验证要点:

  1. 调用 pair.getReserves(),池子储备应变为 (10000×1018,10000×1018)(10000 \times 10^{18}, 10000 \times 10^{18})
  2. LP Token总供应量等于 Linitial=x0×y0=1022×1022=1022L_{\text{initial}} = \sqrt{x_0 \times y_0} = \sqrt{10^{22} \times 10^{22}} = 10^{22}
  3. 用户A的LP Token余额等于总供应量,即拥有100%池子份额。

19.6.3 完整流程第二步:执行Swap并观察价格变化

场景:用户B用1,000 TTA兑换TTB,设定1%滑点容差。

含手续费后的精确输出计算:

Δy=ybeforexbefore×ybeforexbefore+Δx×(10.003)\Delta y = y_{\text{before}} - \frac{x_{\text{before}} \times y_{\text{before}}}{x_{\text{before}} + \Delta x \times (1 - 0.003)}

代入数值:

Δy=1000010000×1000010000+1000×0.997906.61\Delta y = 10000 - \frac{10000 \times 10000}{10000 + 1000 \times 0.997} \approx 906.61

Swap后池子储备变为 (11000,9093.39)(11000, 9093.39),新的价格比变为:

Pnew=110009093.391.21P_{\text{new}} = \frac{11000}{9093.39} \approx 1.21

这意味着TTA相对于TTB贬值了约21%。这是恒定乘积做市商的核心特征:大额交易会沿着价格曲线滑动,导致价格显著偏离。

验证 x×yx \times y:由于手续费0.3%计入了池子,新的乘积 11000×9093.39100,027,29011000 \times 9093.39 \approx 100,027,290,略大于原始 k=108k = 10^8。这部分增量即为LP赚取的手续费,使 kk 随时间缓慢增长。

19.6.4 完整流程第三步:移除流动性并暗示无常损失

用户A决定移除全部LP Token。根据当前储备比例,可赎回:

TTA=11000,TTB=9093.39\text{TTA} = 11000, \quad \text{TTB} = 9093.39

对比两种策略:

  • 做市结果(LP):持有11,000 TTA + 9,093.39 TTB
  • 单纯HODL:如果用户A从未提供流动性,仍持有10,000 TTA + 10,000 TTB

假设外部市场价格仍为1:1,则LP持仓总价值为 11000+9093.39=20093.3911000 + 9093.39 = 20093.39,而HODL总价值为 2000020000。表面上LP"赚"了手续费,但如果外部价格未变,LP实际上承受了无常损失(Impermanent Loss):其资产价值因池内价格偏离外部市场而下降。

19.8节将用精确公式 IL=2ρ/(1+ρ)1IL = 2\sqrt{\rho}/(1+\rho) - 1 量化这一损失。本节先建立直觉:无常损失不是"本金亏损",而是"相对于HODL的机会成本"。

19.6.5 Hardhat端到端测试脚本

以下脚本完整覆盖从部署到移除流动性的全流程,每个步骤附带断言与日志输出:

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

describe("DEX E2E Flow", function () {
  it("deploy → addLiquidity → swap → removeLiquidity", async () => {
    const [owner, userB] = await ethers.getSigners();
    
    // 1. 部署Factory、TokenA、TokenB、Pair、Router(示意)
    const Factory = await ethers.getContractFactory("Factory");
    const factory = await Factory.deploy();
    const TokenA = await ethers.getContractFactory("TestTokenA");
    const tokenA = await TokenA.deploy();
    const TokenB = await ethers.getContractFactory("TestTokenB");
    const tokenB = await TokenB.deploy();
    // ... 部署Router并创建Pair ...

    // 2. 添加初始流动性
    await tokenA.approve(router.target, ethers.parseUnits("10000", 18));
    await tokenB.approve(router.target, ethers.parseUnits("10000", 18));
    await router.addLiquidity(tokenA.target, tokenB.target, ...);
    console.log("Reserves after add:", await pair.getReserves());

    // 3. 用户B执行swap
    const amountIn = ethers.parseUnits("1000", 18);
    await tokenA.connect(userB).approve(router.target, amountIn);
    await router.connect(userB).swapExactTokensForTokens(
      amountIn, 0, [tokenA.target, tokenB.target], userB.address, deadline
    );
    console.log("UserB TTB balance:", await tokenB.balanceOf(userB.address));

    // 4. 移除流动性
    const lpBalance = await pair.balanceOf(owner.address);
    await pair.approve(router.target, lpBalance);
    await router.removeLiquidity(tokenA.target, tokenB.target, lpBalance, 0, 0, owner.address, deadline);
    console.log("Owner balances after remove:", await tokenA.balanceOf(owner.address), await tokenB.balanceOf(owner.address));
  });
});
sequenceDiagram
    actor UserA
    actor UserB
    participant Router
    participant Pair
    participant TokenA
    participant TokenB
    UserA->>Router: addLiquidity(10000A, 10000B)
    Router->>Pair: mint LP token
    Pair->>TokenA: transferFrom UserA
    Pair->>TokenB: transferFrom UserA
    UserB->>TokenA: approve(Router, 1000)
    UserB->>Router: swapExactTokensForTokens(1000A → ?B)
    Router->>Pair: swap(1000A, minB)
    Pair->>TokenA: transferFrom UserB
    Pair->>TokenB: transfer to UserB
    UserA->>Router: removeLiquidity(all LP)
    Router->>Pair: burn LP
    Pair->>TokenA: transfer to UserA
    Pair->>TokenB: transfer to UserA

要点总结

  • 测试代币(Mock Token)是本地开发DEX前端的必需品,OpenZeppelin的预设合约可快速部署。
  • 初始流动性注入后,LP Token按几何平均数 L=x0×y0L = \sqrt{x_0 \times y_0} 铸造。
  • Swap会改变池子储备比例,导致池内价格沿价格曲线滑动。
  • 无常损失的本质是「LP做市收益 vs 单纯HODL的机会成本」,与手续费收益相互抵消。

19.7 部署与验证全套合约

本地Hardhat网络完成了逻辑验证后,下一步是将合约部署到测试网乃至主网。本节介绍Factory + Pair + Router的最小部署方案,以及合约验证、前端地址配置的标准化流程。

19.7.1 Uniswap V2 最小三件套部署

Uniswap V2的核心由三件套组成:

  • Factory:管理所有交易对的创建,记录 tokenA → tokenB → pairAddress 映射。通过 createPair(address, address) 创建新交易对。
  • Pair:每个交易对对应一个Pair合约实例,持有双币储备、执行swap、发行LP Token。Pair代码通过CREATE2确定性部署,使得给定两个代币地址即可预测Pair地址。
  • Router:前端唯一交互入口,封装了复杂的参数计算(如配平amounts、最小数量保护、期限deadline)和重入保护。本章Router简化为仅支持单池swap和流动性管理,多hop路由可作为扩展练习。

部署顺序至关重要:Factory → 两个ERC-20 → 用Factory创建Pair → Router(传入Factory地址作为构造参数)。

19.7.2 Hardhat部署脚本与网络配置

以下是一个标准的 deploy.js

javascript
// scripts/deploy.js
const { ethers } = require("hardhat");
const fs = require("fs");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with:", deployer.address);

  const Factory = await ethers.getContractFactory("Factory");
  const factory = await Factory.deploy();
  await factory.waitForDeployment();
  console.log("Factory:", factory.target);

  const TokenA = await ethers.getContractFactory("TestTokenA");
  const tokenA = await TokenA.deploy();
  await tokenA.waitForDeployment();
  console.log("TokenA:", tokenA.target);

  const TokenB = await ethers.getContractFactory("TestTokenB");
  const tokenB = await TokenB.deploy();
  await tokenB.waitForDeployment();
  console.log("TokenB:", tokenB.target);

  const tx = await factory.createPair(tokenA.target, tokenB.target);
  await tx.wait();
  const pairAddress = await factory.getPair(tokenA.target, tokenB.target);
  console.log("Pair:", pairAddress);

  const Router = await ethers.getContractFactory("Router");
  const router = await Router.deploy(factory.target);
  await router.waitForDeployment();
  console.log("Router:", router.target);

  const addresses = {
    factory: factory.target,
    tokenA: tokenA.target,
    tokenB: tokenB.target,
    pair: pairAddress,
    router: router.target,
  };
  fs.writeFileSync("deployments.json", JSON.stringify(addresses, null, 2));
}

main().catch(console.error);

运行方式:

bash
npx hardhat run scripts/deploy.js --network sepolia

hardhat.config.js 的网络配置:

javascript
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-verify");

module.exports = {
  solidity: "0.8.20",
  networks: {
    hardhat: { chainId: 31337 },
    sepolia: {
      url: process.env.SEPOLIA_RPC,
      accounts: [process.env.PRIVATE_KEY],
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

安全提醒:部署私钥必须与前端交互账号分离。生产环境禁止在代码中硬编码私钥,应使用环境变量或专用部署CI/CD流水线。

19.7.3 合约验证与Etherscan源码验证

源码验证的意义在于:任何人可在Etherscan上直接读取合约状态、调用只读函数、查看ABI,从而极大增强信任度。Hardhat的Etherscan插件使验证自动化:

bash
npx hardhat verify --network sepolia <CONTRACT_ADDRESS> <CONSTRUCTOR_ARG1> <CONSTRUCTOR_ARG2>

常见验证失败原因:

  • 编译器版本与部署时不一致。
  • 优化设置(runs参数)不匹配。
  • 构造函数参数ABI编码错误。
  • 合约地址已被其他代码占用(常见于测试网地址复用)。

多链验证需切换API Key:主网Etherscan、Sepolia、BscScan、PolygonScan等扫描器各自独立。

19.7.4 前端配置与合约地址管理

部署完成后,前端需要一份多网络地址映射表:

javascript
// src/config/contracts.js
export const CONTRACTS = {
  31337: {  // Hardhat local
    factory: "0x...",
    router: "0x...",
    pair: "0x...",
    tokenA: "0x...",
    tokenB: "0x...",
  },
  11155111: {  // Sepolia
    factory: "0x...",
    router: "0x...",
    pair: "0x...",
    tokenA: "0x...",
    tokenB: "0x...",
  },
};

ABI文件从 artifacts/contracts/ 提取所需接口,放入前端 abis/ 目录。建议仅保留function和event定义,去除bytecode字段以减小打包体积。

合约重部署后必须同步更新前端地址映射,避免用户调用旧合约导致失败。建议将部署地址归档至版本控制(如 deployments/.json)。

flowchart LR
    A[编译合约] --> B[部署Factory/ERC-20/Pair/Router]
    B --> C[写入deployments.json]
    C --> D[Etherscan源码验证]
    D --> E[提取ABI至前端]
    E --> F[配置多网络地址映射]
    F --> G[前端连接测试]

要点总结

  • Factory + Pair + Router 的最小部署顺序不可颠倒。
  • Hardhat部署脚本应自动将地址导出为JSON,供前端和测试复用。
  • Etherscan源码验证是增强合约可信度的必要步骤。
  • 多网络地址映射(按chainId组织)是保障用户体验的基础设施。

19.8节衔接预告

19.1至19.4节构建了AMM的数学内核与合约骨架。19.5节将其转化为用户可交互的前端界面;19.6节通过端到端测试验证了从流动性注入到Swap执行再到移除的完整资产闭环;19.7节则将一切从本地开发环境推向了可公开访问的链上环境。

然而,作为一个LP,仅知道"如何添加流动性"是不够的——你还必须理解无常损失(Impermanent Loss)的精确量化。19.8节将回到数学,用测试节点验证IL公式,为DEX做市风险提供可度量的标尺。

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

  1. 前端是合约与用户的桥梁:即使合约层逻辑完美,没有前端UI的DEX无法触达真实用户。React + ethers.js 的三层架构(钱包层、合约层、UI层)是DApp开发的标准范式。
  2. 端到端测试是闭环验证的唯一方式:从部署测试代币、添加初始流动性、执行Swap、观察价格滑动,到移除流动性并感受无常损失——只有走完这一整圈,才能确信合约、前端、数学公式三者自洽。
  3. 部署是开发的终点,也是运营的起点:合约源码验证、多网络地址管理、前端ABI同步,这些看似琐碎的工程细节,决定了你的DEX能否被真实用户安全、可靠地使用。

评论

0

评论加载中…

发表评论

0/2000