大数跨境
0
0

UniswapV3 技术学习系列(九):合约部署与本地测试

UniswapV3 技术学习系列(九):合约部署与本地测试 跨境电商Lily
2025-10-13
2
导读:本文将介绍如何选择和配置本地区块链网络、编写部署脚本、与已部署合约交互,以及理解 ABI(应用程序二进制接口)的作用。通过本文,你将掌握使用 Foundry 工具链进行智能合约部署和测试的完整流程。

 

UniswapV3 技术学习系列(九):合约部署与本地测试

系列文章导航

本文是 UniswapV3 技术学习系列的第九篇,属于"里程碑 1:第一次Swap"模块的最后一篇。在前面的文章中,我们已经完成了核心池合约和管理合约的实现,现在是时候将这些合约部署到本地以太坊网络进行测试了。本文将介绍如何选择和配置本地区块链网络、编写部署脚本、与已部署合约交互,以及理解 ABI(应用程序二进制接口)的作用。通过本文,你将掌握使用 Foundry 工具链进行智能合约部署和测试的完整流程。

原文链接: Deployment - Uniswap V3 Development Book


一、选择本地区块链网络

1.1 本地开发网络的必要性

智能合约开发需要一个本地区块链环境,让我们能够在开发和测试阶段快速迭代。一个理想的本地开发网络应该具备以下特性:

🔗 真实的区块链环境

  • • 必须是真正的以太坊网络实现,而不是模拟器
  • • 确保合约在本地网络的行为与主网完全一致
  • • 兼容以太坊的所有特性和规范

⚡ 极快的交易速度

  • • 交易立即被打包,无需等待区块确认
  • • 支持快速迭代开发
  • • 提升开发效率

💰 无限的测试以太币

  • • 可以生成任意数量的 ETH 用于支付 Gas 费用
  • • 无需从水龙头获取测试币
  • • 多个账户预充值,方便测试

🧪 强大的测试作弊码(Cheat Codes)

  • • 支持在任意地址部署合约
  • • 可以模拟任意地址发送交易(地址伪装)
  • • 能够直接修改合约状态
  • • 支持时间旅行、快照回滚等高级功能

1.2 主流解决方案对比

目前有三种主流的本地开发网络解决方案:

工具
开发团队
语言
测试框架
部署脚本
特点
Ganache
Truffle Suite
JavaScript
JavaScript
JavaScript
最早的解决方案,图形化界面
Hardhat
Nomic Foundation
JavaScript
JavaScript
JavaScript
当前最流行,生态完善
Anvil
Foundry
Rust
Solidity
Solidity
新一代工具,纯 Solidity 开发

1.3 为什么选择 Anvil?

我们选择 Anvil(Foundry 的一部分) 作为本地开发网络,原因如下:

  1. 1. 技术栈统一:测试和部署脚本都使用 Solidity 编写,无需切换到 JavaScript
  2. 2. 性能卓越:基于 Rust 构建,编译和执行速度极快
  3. 3. 现代化设计:原生支持 Solidity 0.8+ 的所有特性
  4. 4. 强大的作弊码:提供最丰富的测试辅助功能
  5. 5. 无缝集成:与 Forge 测试框架完美配合

💡 开发趋势:Solidity 开发者社区正在从 Ganache → Hardhat → Foundry 逐步迁移,Foundry 代表了智能合约开发工具的未来方向。


二、启动本地区块链节点

2.1 运行 Anvil

Anvil 无需任何配置即可运行,只需一条命令:

$ anvil --code-size-limit 50000

输出示例:

                             _   _
                            (_) | |
      __ _   _ __   __   __  _  | |
     / _` | | '_ \  \ \ / / | | | |
    | (_| | | | | |  \ V /  | | | |
     \__,_| |_| |_|   \_/   |_| |_|

    0.1.0 (d89f6af 2022-06-24T00:15:17.897682Z)
    https://github.com/foundry-rs/foundry

Available Accounts
==================
(0) 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH)
(1) 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 (10000 ETH)
...

Private Keys
==================
(0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
(1) 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
...

Listening on 127.0.0.1:8545

2.2 理解启动参数

--code-size-limit 50000

这个参数非常重要,它允许部署超过以太坊标准大小限制的合约。

  • • 以太坊合约大小限制:24,576 字节(24 KB)
  • • 为什么需要提高限制:在开发阶段,合约通常包含调试信息和未优化的代码
  • • 生产环境注意:主网部署前必须将合约优化到标准大小以内

2.3 Anvil 的默认配置

配置项
说明
账户数量
10 个
预先创建的测试账户
初始余额
每个 10,000 ETH
足够支付所有测试 Gas
网络 ID
31337
本地开发网络的链 ID
RPC 地址
127.0.0.1:8545
JSON-RPC API 接口
区块时间
即时出块
交易立即被打包

⚠️ 注意:Anvil 只运行单个节点,不是完整的网络,但这对开发测试来说完全足够。

2.4 测试 JSON-RPC 接口

Anvil 提供标准的以太坊 JSON-RPC API 接口,我们可以通过多种方式与其交互。

方式 1:使用 curl(底层方式)

# 查询链 ID
$ curl -X POST -H 'Content-Type: application/json' \
  --data '{"id":1,"jsonrpc":"2.0","method":"eth_chainId"}' \
  http://127.0.0.1:8545

# 返回:{"jsonrpc":"2.0","id":1,"result":"0x7a69"}
# 0x7a69 = 31337(十进制)

# 查询账户余额
$ curl -X POST -H 'Content-Type: application/json' \
  --data '{"id":1,"jsonrpc":"2.0","method":"eth_getBalance","params":["0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","latest"]}' \
  http://127.0.0.1:8545

# 返回:{"jsonrpc":"2.0","id":1,"result":"0x21e19e0c9bab2400000"}
# 0x21e19e0c9bab2400000 = 10000 ETH

方式 2:使用 cast(推荐方式)

Cast 是 Foundry 提供的命令行工具,封装了 JSON-RPC 调用:

# 查询链 ID
$ cast chain-id
31337

# 查询账户余额(返回 Wei)
$ cast balance 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
10000000000000000000000

# 转换为 ETH 单位
$ cast balance 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 --ether
10000.000000000000000000

💡 提示:在实际开发中,优先使用 cast 命令,它更简洁、易读,并且提供了丰富的辅助功能。


三、编写部署脚本

3.1 理解合约部署流程

合约部署本质上是三个步骤:

1. 编译 Solidity 源码
   ↓
2. 将字节码打包成交易并广播
   ↓
3. 节点执行构造函数并存储合约代码

在实际项目中,部署通常涉及多个步骤:

  • • 📋 准备部署参数
  • • 🔧 部署辅助合约(如代币合约)
  • • 🏗️ 部署主合约
  • • ⚙️ 初始化合约状态
  • • 🔗 建立合约间的关联

3.2 使用 Solidity 编写部署脚本

Foundry 的一大特色是可以用 Solidity 编写部署脚本,保持技术栈统一。

创建部署脚本文件

在项目根目录创建 scripts/DeployDevelopment.s.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.14;

import "forge-std/Script.sol";
import "../src/UniswapV3Pool.sol";
import "../src/UniswapV3Manager.sol";
import "../test/ERC20Mintable.sol";

/**
 * @title DeployDevelopment
 * @notice 本地开发环境部署脚本
 * @dev 部署池合约、管理合约和测试代币
 */
contract DeployDevelopment is Script {
    
    /**
     * @notice 部署脚本的主入口函数
     * @dev Foundry 会自动调用这个 run() 函数
     */
    function run() public {
        // 部署参数定义
        uint256 wethBalance = 1 ether;      // 部署者的 WETH 初始余额
        uint256 usdcBalance = 5042 ether;   // 部署者的 USDC 初始余额
        int24 currentTick = 85176;          // 初始价格 Tick
        uint160 currentSqrtP = 5602277097478614198912276234240; // 初始平方根价格
        
        // 开始广播交易
        vm.startBroadcast();
        
        // 步骤 1: 部署 ERC20 代币
        ERC20Mintable token0 = new ERC20Mintable("Wrapped Ether", "WETH", 18);
        ERC20Mintable token1 = new ERC20Mintable("USD Coin", "USDC", 18);
        
        // 步骤 2: 部署池合约
        UniswapV3Pool pool = new UniswapV3Pool(
            address(token0),
            address(token1),
            currentSqrtP,
            currentTick
        );
        
        // 步骤 3: 部署管理合约
        UniswapV3Manager manager = new UniswapV3Manager();
        
        // 步骤 4: 为部署者铸造测试代币
        // msg.sender 是发起广播交易的地址
        token0.mint(msg.sender, wethBalance);
        token1.mint(msg.sender, usdcBalance);
        
        // 停止广播
        vm.stopBroadcast();
        
        // 打印部署结果
        console.log("=== 部署成功 ===");
        console.log("WETH 地址:", address(token0));
        console.log("USDC 地址:", address(token1));
        console.log("Pool 地址:", address(pool));
        console.log("Manager 地址:", address(manager));
    }
}

3.3 理解脚本的关键要素

Script 合约

contract DeployDevelopment is Script {
  • • 继承自 forge-std/Script.sol
  • • 获得 Foundry 提供的部署能力
  • • 可以使用 vm 作弊码

run() 函数

function run() public {
  • • 这是部署脚本的约定入口函数
  • • Foundry 会自动识别并执行它
  • • 函数名必须是 run

广播机制

vm.startBroadcast();
// ... 部署操作 ...
vm.stopBroadcast();

作用

  • • startBroadcast() 和 stopBroadcast() 之间的操作会被转换为真实交易
  • • 每个合约部署或函数调用都是一笔独立的交易
  • • 如果不使用广播,代码只会在模拟环境中执行

msg.sender 的含义

  • • 在广播块内,msg.sender 是发起交易的账户地址
  • • 通过 --private-key 参数指定
  • • 所有交易的 Gas 费用由该账户支付

3.4 部署参数说明

uint256 wethBalance = 1 ether;      // 1 WETH
uint256 usdcBalance = 5042 ether;   // 5042 USDC

为什么是 5042 USDC?

  • • 5000 USDC:作为流动性提供到池子
  • • 42 USDC:用于测试 Swap 交易
  • • 这与我们之前的测试场景保持一致
int24 currentTick = 85176;
uint160 currentSqrtP = 5602277097478614198912276234240;

初始价格设置

  • • 这些参数决定了池子的初始价格
  • • 对应的价格约为:1 WETH ≈ 5000 USDC
  • • 与真实市场价格相近,便于理解

💡 本地 vs 主网部署:在本地网络,我们需要自己部署代币合约;在主网或测试网,代币已存在,只需要使用其地址。


四、执行部署

4.1 部署命令详解

确保 Anvil 在另一个终端窗口中运行,然后执行:

$ forge script scripts/DeployDevelopment.s.sol \
  --broadcast \
  --fork-url http://localhost:8545 \
  --private-key $PRIVATE_KEY \
  --code-size-limit 50000

参数说明

参数
作用
说明
--broadcast
启用交易广播
不加此参数时,脚本只会模拟执行
--fork-url
指定节点地址
本地 Anvil 的 RPC 接口
--private-key
设置部署账户
用于签名交易的私钥
--code-size-limit
提高合约大小限制
与 Anvil 保持一致

获取私钥

从 Anvil 启动时打印的私钥列表中选择一个,例如第一个账户:

export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

⚠️ 安全警告:这是测试私钥,永远不要在主网使用!主网部署应使用硬件钱包或环境变量管理私钥。

4.2 部署过程详解

执行部署命令后,Foundry 会经历以下步骤:

1. 编译合约
   ├─ 编译 UniswapV3Pool.sol
   ├─ 编译 UniswapV3Manager.sol
   ├─ 编译 ERC20Mintable.sol
   └─ 生成字节码和 ABI
   
2. 运行部署脚本
   ├─ 执行 run() 函数
   ├─ 模拟所有交易
   └─ 估算 Gas 费用
   
3. 广播交易(因为有 --broadcast)
   ├─ 发送交易 1: 部署 token0
   ├─ 发送交易 2: 部署 token1
   ├─ 发送交易 3: 部署 pool
   ├─ 发送交易 4: 部署 manager
   ├─ 发送交易 5: mint WETH
   └─ 发送交易 6: mint USDC
   
4. 等待交易确认
   └─ 获取所有交易回执
   
5. 保存部署记录
   └─ 写入 broadcast/ 目录

4.3 部署输出示例

[⠊] Compiling...
[⠒] Compiling 15 files with 0.8.30
[⠑] Solc 0.8.30 finished in 3.42s
Compiler run successful!

Script ran successfully.
Gas used: 3847592

== Logs ==
  === 部署成功 ===
  WETH 地址: 0x5FbDB2315678afecb367f032d93F642f64180aa3
  USDC 地址: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
  Pool 地址: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
  Manager 地址: 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9

## Setting up 1 EVM.
==========================

Chain 31337

Estimated gas price: 1 gwei

Estimated total gas used for script: 4256203

Estimated amount required: 0.004256203 ETH

==========================

##### anvil-hardhat
✅  [Success] Hash: 0x7a8c...1f2b
Contract Address: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Block: 1
Paid: 0.000847293 ETH (847293 gas * 1 gwei)

[... 更多交易记录 ...]

==========================

ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Total Paid: 0.003847592 ETH (3847592 gas * avg 1 gwei)

4.4 查看部署记录

部署成功后,交易回执会保存在 broadcast/ 目录:

broadcast/
└── DeployDevelopment.s.sol/
    └── 31337/
        ├── run-latest.json      # 最新部署记录
        └── run-1234567890.json  # 带时间戳的历史记录

这些文件包含:

  • • 所有交易的哈希和回执
  • • 部署的合约地址
  • • Gas 消耗统计
  • • 完整的部署流程

💡 版本控制:建议将 broadcast/ 目录加入 .gitignore,避免提交本地部署记录。

4.5 在 Anvil 中查看交易

切换到运行 Anvil 的终端窗口,你会看到大量日志输出:

eth_sendRawTransaction
eth_getTransactionByHash
eth_getTransactionReceipt
eth_call
...

这些是 Forge 和 Anvil 之间的 RPC 通信:

  • • eth_sendRawTransaction:发送签名后的交易
  • • eth_getTransactionByHash:查询交易状态
  • • eth_getTransactionReceipt:获取交易回执
  • • eth_call:执行只读调用(不产生交易)

五、与已部署合约交互

部署完成后,我们可以通过多种方式与合约交互。让我们学习如何调用合约函数、读取状态。

5.1 理解函数选择器(Function Selector)

为什么需要函数选择器?

在以太坊中:

  • • 合约被编译为字节码存储在链上
  • • 函数名在编译后会丢失,不存储在区块链上
  • • 需要用一个固定长度的标识符来识别函数

函数选择器的计算方式

函数选择器 = keccak256(函数签名)[0:4]

示例 1:transfer 函数

// 函数签名(不包含参数名,只有类型)
transfer(address,uint256)

// 计算过程
keccak256("transfer(address,uint256)") = 0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b

// 取前 4 字节
函数选择器 = 0xa9059cbb

示例 2:balanceOf 函数

$ cast keccak "balanceOf(address)" | cut -b 1-10
0x70a08231

💡 注意事项

  • • 函数签名中不能有空格
  • • 参数只写类型,不写变量名
  • • 返回值不包含在签名中

为什么是 4 字节?

  • • 4 字节 = 32 位 = 2³² = 4,294,967,296 种可能
  • • 碰撞概率极低(生日攻击概率约为 2⁻¹⁶)
  • • 平衡了安全性和 Gas 成本

5.2 方法一:使用 curl 进行底层调用

这是最底层的合约交互方式,帮助理解 EVM 的工作原理。

场景:查询 USDC 余额

步骤 1:计算函数选择器

$ cast keccak "balanceOf(address)" | cut -b 1-10
0x70a08231

步骤 2:编码参数

查询地址:0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266

calldata = 函数选择器 + 参数(左填充到 32 字节)

0x70a08231
  000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266

完整的 calldata:

0x70a08231000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266

步骤 3:发送 eth_call 请求

$ params='{"from":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","to":"0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512","data":"0x70a08231000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"}'

$ curl -X POST -H 'Content-Type: application/json' \
  --data '{"id":1,"jsonrpc":"2.0","method":"eth_call","params":['"$params"',"latest"]}' \
  http://127.0.0.1:8545

返回结果:

{
  "jsonrpc":"2.0",
  "id":1,
  "result":"0x00000000000000000000000000000000000000000000011153ce5e56cf880000"
}

⚠️ 注意:这里的 "to" 地址(0xe7f1...)是 USDC 代币合约地址,从部署输出中获取。

步骤 4:解析返回值

# 转换为十进制
$ cast --to-dec 0x00000000000000000000000000000000000000000000011153ce5e56cf880000
5042000000000000000000

# 转换为 ETH 单位(实际上是 USDC,但单位相同)
$ cast --from-wei 5042000000000000000000
5042.000000000000000000

✅ 结果正确!我们铸造了 5042 USDC。

eth_call vs eth_sendTransaction

方法
用途
是否改变状态
是否消耗 Gas
是否需要签名
eth_call
读取数据
❌ 否
❌ 否(模拟执行)
❌ 否
eth_sendTransaction
修改状态
✅ 是
✅ 是
✅ 是

5.3 方法二:使用 cast 进行高级调用(推荐)

Cast 封装了底层细节,提供更友好的接口。

查询当前价格和 Tick

$ cast call 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 "slot0()" | \
  xargs cast --abi-decode "a()(uint160,int24)"

5602277097478614198912276234240
85176

输出解释:

  • • 第一个值:当前的 sqrtPriceX96 = 5602277097478614198912276234240
  • • 第二个值:当前的 tick = 85176

💡 技巧--abi-decode 需要完整的函数签名,即使我们只关心返回值。这里用 "a()" 作为占位符。

查询流动性仓位

# 查询特定 Tick 范围的仓位信息
$ cast call 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 \
  "positions(bytes32)" \
  0x0000000000000000000000000000000000000000000000000000000000000001 | \
  xargs cast --abi-decode "a()(uint128)"

查询代币余额(更简洁)

# 直接查询,自动解码
$ cast call 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \
  "balanceOf(address)" \
  0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266

0x00000000000000000000000000000000000000000000011153ce5e56cf880000

发送交易(修改状态)

如果要发送真正的交易(而非只读调用),使用 cast send

$ cast send 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \
  "transfer(address,uint256)" \
  0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \
  1000000000000000000 \
  --private-key $PRIVATE_KEY

六、理解 ABI(应用程序二进制接口)

6.1 什么是 ABI?

ABI(Application Binary Interface) 是合约的"使用说明书",它是一个 JSON 文件,描述了:

  • • 📋 所有公共函数的签名
  • • 📤 函数的输入参数(名称、类型)
  • • 📥 函数的返回值(类型)
  • • 📡 所有事件的定义
  • • ❌ 所有自定义错误的定义

6.2 为什么需要 ABI?

问题:字节码难以理解

部署的合约字节码:
0x608060405234801561001057600080fd5b506040516108...

没有 ABI:

  • • ❌ 不知道有哪些函数
  • • ❌ 不知道函数参数类型
  • • ❌ 不知道如何编码调用数据
  • • ❌ 不知道如何解码返回值

有了 ABI:

  • • ✅ 自动生成函数选择器
  • • ✅ 自动编码参数
  • • ✅ 自动解码返回值
  • • ✅ 类型安全的合约调用

6.3 生成 ABI

使用 Forge 生成合约 ABI:

$ forge inspect UniswapV3Pool abi

输出示例(节选):

[
  {
    "type": "function",
    "name": "mint",
    "inputs": [
      {
        "name": "owner",
        "type": "address",
        "internalType": "address"
      },
      {
        "name": "lowerTick",
        "type": "int24",
        "internalType": "int24"
      },
      {
        "name": "upperTick",
        "type": "int24",
        "internalType": "int24"
      },
      {
        "name": "amount",
        "type": "uint128",
        "internalType": "uint128"
      },
      {
        "name": "data",
        "type": "bytes",
        "internalType": "bytes"
      }
    ],
    "outputs": [
      {
        "name": "amount0",
        "type": "uint256",
        "internalType": "uint256"
      },
      {
        "name": "amount1",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "stateMutability": "nonpayable"
  },
  {
    "type": "function",
    "name": "swap",
    "inputs": [
      {
        "name": "recipient",
        "type": "address",
        "internalType": "address"
      },
      {
        "name": "zeroForOne",
        "type": "bool",
        "internalType": "bool"
      },
      {
        "name": "amountSpecified",
        "type": "int256",
        "internalType": "int256"
      },
      {
        "name": "sqrtPriceLimitX96",
        "type": "uint160",
        "internalType": "uint160"
      },
      {
        "name": "data",
        "type": "bytes",
        "internalType": "bytes"
      }
    ],
    "outputs": [
      {
        "name": "amount0",
        "type": "int256",
        "internalType": "int256"
      },
      {
        "name": "amount1",
        "type": "int256",
        "internalType": "int256"
      }
    ],
    "stateMutability": "nonpayable"
  },
  {
    "type": "event",
    "name": "Mint",
    "inputs": [
      {
        "name": "sender",
        "type": "address",
        "indexed": true
      },
      {
        "name": "owner",
        "type": "address",
        "indexed": true
      },
      {
        "name": "tickLower",
        "type": "int24",
        "indexed": true
      },
      {
        "name": "tickUpper",
        "type": "int24",
        "indexed": false
      },
      {
        "name": "amount",
        "type": "uint128",
        "indexed": false
      },
      {
        "name": "amount0",
        "type": "uint256",
        "indexed": false
      },
      {
        "name": "amount1",
        "type": "uint256",
        "indexed": false
      }
    ]
  }
]

6.4 ABI 的组成部分

函数条目

{
  "type": "function",
  "name": "mint",                    // 函数名
  "inputs": [...],                   // 输入参数
  "outputs": [...],                  // 返回值
  "stateMutability": "nonpayable"    // 状态可变性
}

状态可变性(stateMutability)类型:

类型
含义
是否可以修改状态
是否可以读取状态
是否需要 Gas
pure
纯函数
❌ 否
❌ 否
❌ 否(只读调用时)
view
视图函数
❌ 否
✅ 是
❌ 否(只读调用时)
nonpayable
不接受 ETH
✅ 是
✅ 是
✅ 是
payable
接受 ETH
✅ 是
✅ 是
✅ 是

事件条目

{
  "type": "event",
  "name": "Mint",
  "inputs": [
    {
      "name": "sender",
      "type": "address",
      "indexed": true      // 索引字段,可用于过滤
    },
    ...
  ]
}

indexed 的作用:

  • • 最多 3 个参数可以标记为 indexed
  • • 索引参数存储在日志的 topics 中,便于快速检索
  • • 非索引参数存储在日志的 data 中

错误条目(Solidity 0.8.4+)

{
  "type": "error",
  "name": "InsufficientLiquidity",
  "inputs": [
    {
      "name": "available",
      "type": "uint256"
    },
    {
      "name": "required",
      "type": "uint256"
    }
  ]
}

6.5 ABI 的使用场景

1. 前端应用

使用 ethers.js 或 web3.js 与合约交互:

import { ethers } from 'ethers';
import UniswapV3PoolABI from './UniswapV3Pool.json';

const provider = new ethers.providers.JsonRpcProvider('http://localhost:8545');
const pool = new ethers.Contract(poolAddress, UniswapV3PoolABI, provider);

// 调用 slot0() 函数
const [sqrtPriceX96, tick] = await pool.slot0();
console.log('Current price:', sqrtPriceX96.toString());
console.log('Current tick:', tick);

// 调用 mint() 函数
const tx = await pool.mint(owner, lowerTick, upperTick, amount, data);
await tx.wait();

2. 其他合约调用

在 Solidity 中导入接口:

import "./interfaces/IUniswapV3Pool.sol";

contract MyContract {
    function getPoolPrice(address poolAddress) public view returns (uint160, int24) {
        IUniswapV3Pool pool = IUniswapV3Pool(poolAddress);
        return pool.slot0();
    }
}

3. 工具和脚本

使用 cast 或其他工具自动解析:

# cast 会自动读取 ABI 文件(如果在 out/ 目录中)
$ cast call $POOL_ADDRESS "slot0()"

6.6 保存 ABI 到文件

# 保存为 JSON 文件
$ forge inspect UniswapV3Pool abi > abi/UniswapV3Pool.json

# 或者使用 build 命令,ABI 会自动生成到 out/ 目录
$ forge build

生成的文件结构:

out/
└── UniswapV3Pool.sol/
    └── UniswapV3Pool.json
        ├── abi          # ABI 定义
        ├── bytecode     # 部署字节码
        ├── deployedBytecode  # 运行时字节码
        └── metadata     # 元数据

七、完整的部署与测试流程

现在让我们把所有步骤串联起来,完成一个完整的部署和测试流程。

7.1 准备工作

# 1. 确保在项目根目录
cd /path/to/uniswapv3_tech

# 2. 编译合约
forge build

# 3. 运行测试(可选,确保合约正确)
forge test

7.2 启动本地节点

在一个终端窗口中:

$ anvil --code-size-limit 50000

保持这个窗口打开,记下第一个账户的私钥。

7.3 设置环境变量

在另一个终端窗口中:

# 设置私钥(使用 Anvil 提供的测试私钥)
export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

# 设置 RPC URL(可选,默认就是这个)
export RPC_URL=http://localhost:8545

7.4 执行部署

$ forge script scripts/DeployDevelopment.s.sol \
  --broadcast \
  --fork-url $RPC_URL \
  --private-key $PRIVATE_KEY \
  --code-size-limit 50000 \
  -vvv

添加 -vvv 可以看到详细的执行日志。

7.5 记录部署地址

从部署输出中复制合约地址并保存:

export WETH=0x5FbDB2315678afecb367f032d93F642f64180aa3
export USDC=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
export POOL=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
export MANAGER=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
export USER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

7.6 验证部署

# 检查代币余额
$ cast call $WETH "balanceOf(address)" $USER | cast --from-wei
1.0

$ cast call $USDC "balanceOf(address)" $USER | cast --from-wei
5042.0

# 检查池子状态
$ cast call $POOL "slot0()" | xargs cast --abi-decode "a()(uint160,int24)"
5602277097478614198912276234240 [5.602e30]
85176 [8.517e4]

# 检查池子的代币地址
$ cast call $POOL "token0()"
0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3  # WETH

$ cast call $POOL "token1()"
0x000000000000000000000000e7f1725e7734ce288f8367e1bb143e90bb3f0512  # USDC

7.7 测试提供流动性

现在我们可以通过 Manager 合约提供流动性:

# 首先授权 Manager 使用我们的代币
$ cast send $WETH "approve(address,uint256)" $MANAGER $(cast --to-wei 1) \
  --private-key $PRIVATE_KEY

$ cast send $USDC "approve(address,uint256)" $MANAGER $(cast --to-wei 5000) \
  --private-key $PRIVATE_KEY

# 编码回调数据(非常重要!)
# Manager 需要这些信息来转移代币
$ DATA=$(cast abi-encode "f(address,address,address)" $WETH $USDC $USER)

# 通过 Manager 提供流动性
# 注意:使用正确的流动性值(通过 unimath.py 计算得出)
$ cast send $MANAGER "mint(address,int24,int24,uint128,bytes)" \
  $POOL \
  84222 \
  86129 \
  1517882343751509868544 \
  $DATA \
  --private-key $PRIVATE_KEY

⚠️ 重要提示

  1. 1. bytes 参数不能为空的 0x,必须编码正确的回调数据
  2. 2. 回调数据包含:token0 地址、token1 地址、支付者地址
  3. 3. 流动性值需要使用 scripts/unimath.py 计算得出,不能随意填写
  4. 4. 授权的代币额度会被本次交易消耗,Swap 前需要重新授权

7.8 测试 Swap

# 重要:重新授权 USDC(之前的额度已被 mint 消耗)
$ cast send $USDC "approve(address,uint256)" $MANAGER $(cast --to-wei 42) \
  --private-key $PRIVATE_KEY

# 执行 Swap:用 42 USDC 买入 WETH
# 注意:Manager 的 swap 函数只有两个参数
$ cast send $MANAGER "swap(address,bytes)" \
  $POOL \
  $DATA \
  --private-key $PRIVATE_KEY

# 检查余额变化(注意:cast call 不支持 --ether,需要用管道)
$ cast call $WETH "balanceOf(address)" $USER | cast --from-wei
0.009420095894737164
$ cast call $USDC "balanceOf(address)" $USER | cast --from-wei
0.000000000000000000

7.9 监听事件

查看池子发出的事件:

# 获取最新区块号
$ cast block-number

# 查询 Mint 事件
$ cast logs --from-block 0 --to-block latest \
  --address $POOL \
  "Mint(address indexed,address indexed,int24 indexed,int24,uint128,uint256,uint256)"

# 查询 Swap 事件
$ cast logs --from-block 0 --to-block latest \
  --address $POOL \
  "Swap(address indexed,address indexed,int256,int256,uint160,uint128,int24)"

八、常见问题与调试

8.1 部署失败的常见原因

问题 1:合约大小超限

错误信息:

Error: contract size exceeds 24576 bytes

解决方案:

# 在部署命令中添加 --code-size-limit
forge script ... --code-size-limit 50000

# 同时确保 Anvil 也设置了相同的限制
anvil --code-size-limit 50000

问题 2:Gas 不足

错误信息:

Error: insufficient funds for gas * price + value

解决方案:

  • • 确保使用的账户有足够的 ETH
  • • Anvil 默认每个账户有 10,000 ETH,应该足够
  • • 检查是否使用了错误的私钥

问题 3:Nonce 不匹配

错误信息:

Error: nonce too low

解决方案:

# 重置 Anvil(重启节点)
# Ctrl+C 停止 Anvil,然后重新启动
anvil --code-size-limit 50000

8.2 交互失败的常见原因

问题 1:地址不正确

症状:

  • • 调用返回空数据
  • • 交易失败但没有明确错误

解决方案:

  • • 仔细核对合约地址
  • • 确保使用部署脚本输出的地址
  • • 使用环境变量避免复制粘贴错误

问题 2:函数签名错误

症状:

Error: function selector was not recognized

解决方案:

  • • 检查函数名拼写
  • • 确认参数类型正确
  • • 不要在函数签名中添加空格
  • • 使用 forge inspect 查看准确的函数签名

问题 3:参数类型不匹配

症状:

  • • 交易 revert
  • • 返回数据解码失败

解决方案:

# 使用 cast 的类型转换功能
$ cast --to-wei 1      # 转换为 wei
$ cast --to-uint256 42 # 转换为 uint256
$ cast --to-bytes32 "0x123..." # 转换为 bytes32

问题 4:环境变量未设置

症状:

error: invalid value 'balanceOf(address)' for '[TO]': invalid string length

原因:

  • • 环境变量 $WETH$USDC 等为空
  • • cast 命令无法识别地址

解决方案:

# 检查环境变量
echo "WETH: $WETH"
echo "USDC: $USDC"

# 如果为空,需要从部署记录中提取地址
cat broadcast/DeployDevelopment.s.sol/31337/run-latest.json | \
  python3 -c "import sys, json; data = json.load(sys.stdin); \
  [print(f\"{tx.get('contractName', 'Unknown'):20s} {tx.get('contractAddress', 'N/A')}\") \
  for tx in data.get('transactions', []) if tx.get('transactionType') == 'CREATE']"


# 然后设置环境变量
export WETH=0x5fbdb2315678afecb367f032d93f642f64180aa3
export USDC=0xe7f1725e7734ce288f8367e1bb143e90bb3f0512
# ... 设置其他变量

问题 5:cast call 使用 --ether 参数报错

症状:

error: unexpected argument '--ether' found

原因:

  • • cast call 命令不支持 --ether 参数
  • • --ether 只能用于 cast balance 等命令

解决方案:

# 错误用法
$ cast call $WETH "balanceOf(address)" $USER --ether

# 正确用法:使用管道传递给 cast --from-wei
$ cast call $WETH "balanceOf(address)" $USER | cast --from-wei

问题 6:mint/swap 执行失败,提示 execution reverted

症状:

Error: Failed to estimate gas: server returned an error response: 
error code 3: execution reverted, data: "0x"

常见原因及解决方案:

原因 1:未授权代币

# 检查授权额度
$ cast call $WETH "allowance(address,address)" $USER $MANAGER | cast --from-wei

# 如果为 0,需要授权
$ cast send $WETH "approve(address,uint256)" $MANAGER $(cast --to-wei 1) \
  --private-key $PRIVATE_KEY

原因 2:授权额度已用完

# mint 操作会消耗授权额度
# swap 前需要重新授权
$ cast send $USDC "approve(address,uint256)" $MANAGER $(cast --to-wei 42) \
  --private-key $PRIVATE_KEY

原因 3:回调数据编码错误

# 错误:使用空的 bytes
$ cast send $MANAGER "mint(...)" ... 0x ...

# 正确:编码回调数据
$ DATA=$(cast abi-encode "f(address,address,address)" $WETH $USDC $USER)
$ cast send $MANAGER "mint(...)" ... $DATA ...

原因 4:流动性值太小或不正确

# 错误:随意使用小数值
$ cast send $MANAGER "mint(...)" ... 1000000000000000000 ...

# 正确:使用计算得出的精确值
$ python3 scripts/unimath.py  # 获取正确的流动性值
$ cast send $MANAGER "mint(...)" ... 1517882343751509868544 ...

8.3 调试技巧

使用详细输出

# -v: 基本输出
# -vv: 更详细
# -vvv: 非常详细(包括调用栈)
# -vvvv: 最详细(包括操作码)
$ forge script ... -vvvv

使用 Foundry 的调试器

# 调试特定交易
$ cast run 0x交易哈希 --debug

检查交易回执

$ cast receipt 0x交易哈希

模拟调用(不发送交易)

# 使用 cast call 模拟执行
$ cast call $POOL "mint(...)" [参数]

九、下一步:前端集成

完成本地部署后,下一步是开发前端应用与合约交互。我们将:

  1. 1. 搭建 React 前端
    • • 使用 ethers.js 连接到 Anvil
    • • 读取合约状态并显示
    • • 提供用户友好的交互界面
  2. 2. 实现核心功能
    • • 连接 MetaMask 钱包
    • • 提供流动性功能
    • • 执行 Swap 功能
    • • 实时显示价格和流动性
  3. 3. 优化用户体验
    • • 交易确认提示
    • • 加载状态显示
    • • 错误处理和提示
    • • Gas 费用估算

十、本章小结

在本章中,我们完成了以下内容:

核心知识点

✅ 本地开发网络的选择和配置

  • • 了解 Ganache、Hardhat、Anvil 的优劣
  • • 掌握 Anvil 的启动和配置
  • • 理解本地网络的特性和限制

✅ 使用 Solidity 编写部署脚本

  • • 创建 Script 合约
  • • 使用广播机制发送交易
  • • 编写可复用的部署逻辑

✅ 执行合约部署

  • • 使用 forge script 命令部署
  • • 理解部署参数的含义
  • • 查看和保存部署记录

✅ 与已部署合约交互

  • • 理解函数选择器的原理
  • • 使用 curl 进行底层调用
  • • 使用 cast 进行高级调用
  • • 区分只读调用和状态修改

✅ 理解和使用 ABI

  • • 掌握 ABI 的结构和作用
  • • 生成和导出 ABI 文件
  • • 在前端和合约中使用 ABI

技能提升

🚀 开发工具链熟练度

  • • Foundry 生态系统的完整使用
  • • cast 工具的各种用法
  • • JSON-RPC 接口的理解

🧪 本地测试能力

  • • 快速部署和迭代
  • • 交易和状态的验证
  • • 问题诊断和调试

📚 智能合约工程化

  • • 合约部署的最佳实践
  • • 网络特定的部署策略
  • • 版本管理和记录

关键要点

💡 部署脚本的重要性

使用 Solidity 编写部署脚本的优势:

  • • 与合约代码使用相同语言
  • • 可以复用测试中的代码
  • • 更容易维护和版本控制
  • • 支持复杂的部署逻辑

⚠️ 本地 vs 生产环境

本地部署和生产部署的区别:

  • • 本地:快速迭代,无限 ETH,可以调试
  • • 测试网:真实网络,有限资源,公开可访问
  • • 主网:真实资产,高昂 Gas,不可逆操作

🔐 安全注意事项

  • • 测试私钥永远不要用于主网
  • • 使用环境变量管理敏感信息
  • • 在主网部署前进行充分测试
  • • 考虑使用多签钱包部署关键合约

实践建议

  1. 1. 建立部署流程文档
    • • 记录所有部署步骤
    • • 记录合约地址和交易哈希
    • • 建立网络特定的配置文件
  2. 2. 自动化测试流程
    # 创建一键部署和测试脚本
    forge build && \
    forge test && \
    forge script scripts/DeployDevelopment.s.sol --broadcast
  3. 3. 使用多网络配置
    # 在 foundry.toml 中配置不同网络
    [rpc_endpoints]
    local = "http://localhost:8545"
    goerli = "${GOERLI_RPC_URL}"
    mainnet = "${MAINNET_RPC_URL}"
  4. 4. 集成 CI/CD
    • • 在 GitHub Actions 中自动运行测试
    • • 自动化部署到测试网络
    • • 生成部署报告

通过本章的学习,我们完成了 "里程碑 1:第一次Swap" 的所有内容。我们从零开始构建了一个可工作的 UniswapV3 池合约,实现了流动性提供和代币交换功能,并成功部署到本地网络。

在下一个里程碑中,我们将增强合约功能,支持更复杂的场景:计算输出金额、使用 Tick Bitmap 优化查找、实现通用化的 Minting 和 Swapping 等。


项目仓库

本系列教程的完整代码已开源在 GitHub:

UniswapV3 技术学习项目:
https://github.com/RyanWeb31110/uniswapv3_tech

系列学习项目:

  • • UniswapV1 技术学习:https://github.com/RyanWeb31110/uniswapv1_tech
  • • UniswapV2 技术学习:https://github.com/RyanWeb31110/uniswapv2_tech
  • • UniswapV3 技术学习:https://github.com/RyanWeb31110/uniswapv3_tech

欢迎 Star、Fork 和提交 Issue!让我们一起深入理解 DeFi 的核心技术。

 


【声明】内容源于网络
0
0
跨境电商Lily
跨境分享家 | 每天记录跨境思考
内容 44559
粉丝 2
跨境电商Lily 跨境分享家 | 每天记录跨境思考
总阅读312.5k
粉丝2
内容44.6k