一.智能合约基础

智能合约是运行在区块链(以太坊)上的程序,部署后代码不可修改,调用即交易,需要支付 Gas。Solidity 是编写以太坊智能合约的主流语言,语法类似 JavaScript + C++,编译后在 EVM(以太坊虚拟机) 上执行。

开发环境推荐 Remix IDE(浏览器直接用,无需安装):编写 -> 编译 -> 部署 -> 调用。

// SPDX-License-Identifier: MIT  // 开源协议声明
pragma solidity ^0.8.0;          // 编译器版本

contract HelloWorld {
	string public message; // public 状态变量,自动生成 getter

	// 构造函数:部署时执行一次
	constructor() {
		message = "Hello, Solidity!";
	}

	// 修改状态:会发起交易,消耗 Gas
	function updateMessage(string memory newMessage) public {
		message = newMessage;
	}

	// 只读:不消耗 Gas(外部调用时)
	function getMessage() public view returns (string memory) {
		return message;
	}
}

Gas 简单理解:每个操作都有固定成本,写存储最贵,读最便宜;Gas 不足交易回滚。


二.EVM存储结构

数据的三种存储位置:

位置特点Gas
storage永久存储在链上,状态变量默认最贵
memory函数执行期间的临时内存,可修改中
calldata外部调用传入的只读参数最省
contract Storage {
	uint[] public nums; // storage

	// calldata:只读,外部函数参数首选
	function sum(uint[] calldata arr) external pure returns (uint total) {
		for (uint i = 0; i < arr.length; i++) {
			total += arr[i];
		}
	}

	// memory:可修改的临时数组
	function createArr() public pure returns (uint[] memory) {
		uint[] memory tmp = new uint[](3);
		tmp[0] = 1;
		return tmp;
	}
}

选择原则:外部函数的数组/字符串参数用 calldata;需要修改的临时数据用 memory;需要永久保存的才用 storage。


三.数据类型基础

值类型:bool、int/uint、address、bytes、enum。引用类型:数组、struct、mapping。

1.布尔和整数

bool public flag = true;
uint256 public a = 10;  // 无符号,uint 即 uint256
int256 public b = -5;   // 有符号
uint256 public c = 10 / 3;  // 3,整数除法没有小数

0.8.0 之后整数溢出会自动 revert,需要跳过检查时用 unchecked。

uint8 x = 255;
// x + 1;  // 报错回滚
unchecked { x = x + 1; } // 0,不检查溢出

2.地址类型

address public owner = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
address payable public wallet; // 可以接收 ETH
uint256 public bal = owner.balance; // 查询余额

3.字节和字符串

bytes32 public b32 = "abc";      // 定长字节,省 Gas
bytes public bs = "abc";          // 动态字节
string public str = "Hello";      // 字符串,不能直接比较、不能直接取长度
// 比较字符串
function eq(string memory x, string memory y) public pure returns (bool) {
	return keccak256(bytes(x)) == keccak256(bytes(y));
}

4.枚举和类型转换

enum Status { Pending, Done } // 底层是 uint8,从 0 开始
Status public s = Status.Pending;

uint8 small = 100;
uint256 big = small;        // 隐式转换:小 -> 大
uint8 s2 = uint8(big);      // 显式转换:大 -> 小可能截断
address addr = address(uint160(123));

5.运算符:算术 + - * / % **、比较 == != > < >= <=、逻辑 && || !(短路求值)、位运算 & | ^ << >>。


四.数组

uint[3] public fixedArr = [1, 2, 3]; // 定长数组,长度固定
uint[] public dynArr;                // 动态数组,长度可变

基本操作:

uint[] public arr;

function add(uint v) public { arr.push(v); }
function remove() public { arr.pop(); }          // 删除最后一个
function len() public view returns (uint) { return arr.length; }
function get(uint i) public view returns (uint) { return arr[i]; }

// delete 只是把值重置为 0,长度不变
function clear(uint i) public { delete arr[i]; }

删除中间元素:

// 方式一:保持顺序(循环移动,Gas 高)
function removeOrdered(uint i) public {
	for (uint j = i; j < arr.length - 1; j++) {
		arr[j] = arr[j + 1];
	}
	arr.pop();
}

// 方式二:不保序(用最后一个覆盖,Gas 低,推荐)
function removeFast(uint i) public {
	arr[i] = arr[arr.length - 1];
	arr.pop();
}

二维数组:uint[3][4] 表示 4 个长度为 3 的数组。memory 数组必须定长创建,不能 push:

uint[] memory tmp = new uint[](5);

Gas 注意:不要遍历无限增长的数组;缓存 length;限制数组最大长度;大批量分批处理。


五.映射和结构体

1.mapping

// mapping(键类型 => 值类型)
mapping(address => uint) public balances;

function set() public {
	balances[msg.sender] = 100;
}

特点:不能遍历、没有 length、不存在的 key 返回默认值 0、不保存 key。需要遍历时配合数组使用。

2.嵌套 mapping(ERC20 授权)

// 所有者 => 被授权人 => 额度
mapping(address => mapping(address => uint)) public allowance;

allowance[msg.sender][spender] = 100;

3.struct

struct User {
	string name;
	uint age;
	bool exists; // 判断用户是否存在
}

mapping(address => User) public users;

function register(string memory name, uint age) public {
	require(!users[msg.sender].exists, "exists");
	users[msg.sender] = User(name, age, true);
}

function grow() public {
	User storage u = users[msg.sender]; // storage 引用,直接修改链上数据
	u.age += 1;
}

4.mapping + array 组合(可遍历)

mapping(address => bool) public isUser;
address[] public userList;

function add() public {
	require(!isUser[msg.sender], "exists");
	isUser[msg.sender] = true;
	userList.push(msg.sender);
}

常见模式:用户管理、ID 自增、双向映射、一对多关系、计数器。


六.函数与修饰符

function 函数名(参数) 可见性 状态修饰符 returns (返回值) {
	函数体
}

1.可见性

关键字说明
public内外部都可调用
external只能外部调用(参数用 calldata 更省 Gas)
internal当前合约和子合约可调用
private仅当前合约可调用(链上数据依然可被读取,不是隐私)

2.状态修饰符

uint public x;

function read() public view returns (uint) { return x; }       // view:只读状态
function add(uint a, uint b) public pure returns (uint) {       // pure:不读不写状态
	return a + b;
}
function deposit() public payable {}                            // payable:可接收 ETH

3.自定义 modifier

address public owner;
constructor() { owner = msg.sender; }

modifier onlyOwner() {
	require(msg.sender == owner, "Not owner");
	_; // 被修饰函数的代码在这里执行
}

function setValue(uint v) public onlyOwner {}
// 多个 modifier 从左到右依次执行

4.函数重载(同名不同参数)

function show(uint a) public pure returns (uint) { return a; }
function show(string memory s) public pure returns (string memory) { return s; }

5.receive 和 fallback

receive() external payable {}   // 收到纯 ETH 转账时触发
fallback() external payable {}  // 调用不存在的函数时触发

七.控制流语句

1.if else 和三元运算符

function grade(uint score) public pure returns (string memory) {
	if (score >= 90) {
		return "A";
	} else if (score >= 60) {
		return "B";
	}
	return "C";
}

uint max = a > b ? a : b;

2.循环(注意 Gas)

// for
for (uint i = 0; i < 10; i++) {
	if (i == 3) continue; // 跳过
	if (i == 8) break;    // 跳出
}

// while
uint n = 0;
while (n < 10) { n++; }

// do-while 至少执行一次
do { n--; } while (n > 0);

链上循环每一步都消耗 Gas,循环次数不能无限增长,否则交易会因 Gas 超限失败(DoS)。

3.require / assert / revert

function withdraw(uint amount) public {
	require(amount > 0, "Zero amount");        // 校验输入,失败回退并返还剩余 Gas
	if (balance < amount) revert("No balance"); // 灵活抛错
	assert(balance >= 0);                       // 校验内部不变量,失败说明有 Bug
}

4.状态机模式

enum State { Active, Success, Failed }
State public state = State.Active;

modifier inState(State s) {
	require(state == s, "Wrong state");
	_;
}

八.特殊类型与全局变量

1.address 与三种转账方式

address payable to = payable(msg.sender);

to.transfer(1 ether);                   // 失败自动回滚,限 2300 Gas
bool ok = to.send(1 ether);             // 失败返回 false,需要手动检查
(bool success, ) = to.call{value: 1 ether}(""); // 最灵活,推荐
require(success, "Failed");

推荐使用 call,并配合 require 检查返回值。

2.全局变量

msg.sender      // 调用者地址
msg.value       // 随调用发送的 ETH(wei)
block.timestamp // 当前区块时间戳(秒)
block.number    // 当前区块号
tx.origin       // 交易发起者(⚠️ 不要用于权限判断,会被钓鱼攻击)

单位:1 ether = 1e18 wei,时间 1 days = 86400 秒。

3.enum 枚举(见第三节):用有名字的状态代替魔法数字,uint(status) 可转成整数。

4.constant 和 immutable

uint public constant MAX = 1000;    // 编译时确定,写死在代码里,最省 Gas
address public immutable owner;     // 部署时(构造函数)确定,之后不可改

constructor() {
	owner = msg.sender;
}

九.简单代币合约 ERC20

ERC20 是可互换代币的标准接口:

name / symbol / decimals / totalSupply     // 基本信息
balanceOf(owner)                           // 查询余额
transfer(to, amount)                       // 转账
approve(spender, amount)                   // 授权
allowance(owner, spender)                  // 查询授权额度
transferFrom(from, to, amount)             // 代为转账
事件:Transfer、Approval

最简实现:

contract MyToken {
	string public name = "MyToken";
	string public symbol = "MT";
	uint8 public decimals = 18;
	uint256 public totalSupply;
	address public owner;

	mapping(address => uint256) public balanceOf;
	mapping(address => mapping(address => uint256)) public allowance;

	event Transfer(address indexed from, address indexed to, uint256 value);
	event Approval(address indexed owner, address indexed spender, uint256 value);

	constructor(uint256 supply) {
		owner = msg.sender;
		totalSupply = supply * 10**decimals; // 乘以 10 的 decimals 次方
		balanceOf[msg.sender] = totalSupply;
		emit Transfer(address(0), msg.sender, totalSupply);
	}

	function transfer(address to, uint256 amount) public returns (bool) {
		require(balanceOf[msg.sender] >= amount, "Insufficient balance");
		balanceOf[msg.sender] -= amount;
		balanceOf[to] += amount;
		emit Transfer(msg.sender, to, amount);
		return true;
	}

	function approve(address spender, uint256 amount) public returns (bool) {
		allowance[msg.sender][spender] = amount;
		emit Approval(msg.sender, spender, amount);
		return true;
	}

	function transferFrom(address from, address to, uint256 amount) public returns (bool) {
		require(balanceOf[from] >= amount, "Insufficient balance");
		require(allowance[from][msg.sender] >= amount, "Insufficient allowance");
		allowance[from][msg.sender] -= amount;
		balanceOf[from] -= amount;
		balanceOf[to] += amount;
		emit Transfer(from, to, amount);
		return true;
	}

	// 铸造:只有 owner 可以
	function mint(address to, uint256 amount) public {
		require(msg.sender == owner, "Only owner");
		totalSupply += amount;
		balanceOf[to] += amount;
		emit Transfer(address(0), to, amount);
	}

	// 销毁:销毁自己的代币
	function burn(uint256 amount) public {
		require(balanceOf[msg.sender] >= amount, "Insufficient balance");
		totalSupply -= amount;
		balanceOf[msg.sender] -= amount;
		emit Transfer(msg.sender, address(0), amount);
	}
}

要点:mint 的 from 是零地址,burn 的 to 是零地址;approve 会覆盖旧额度;transferFrom 要扣减授权额度。生产环境直接使用 OpenZeppelin 的 ERC20。


十.合约继承

contract Parent {
	address public owner;
	constructor() { owner = msg.sender; }

	function hello() public virtual returns (string memory) { // virtual:允许被重写
		return "parent";
	}
}

contract Child is Parent { // is 表示继承
	function hello() public override returns (string memory) { // override:重写
		return "child";
	}
}

1.多重继承:contract C is A, B,顺序从最基础到最派生,按 C3 线性化确定调用顺序。

2.super:调用父合约的函数

function hello() public override returns (string memory) {
	return super.hello();
}

3.构造函数传参

contract Parent {
	string public name;
	constructor(string memory _name) { name = _name; }
}

contract Child is Parent("ww") {}                    // 方式一:固定参数
contract Child2 is Parent {
	constructor() Parent("zz") {}                    // 方式二:构造时传参
}

4.抽象合约和接口

// 抽象合约:有未实现的函数,不能直接部署
abstract contract Animal {
	function speak() public virtual returns (string memory);
}

// 接口:只有函数声明,全部 external
interface IERC20 {
	function balanceOf(address account) external view returns (uint256);
}

可见性提示:private 子合约不可访问,internal 子合约可访问。


十一.库合约 Library

库是可复用的无状态代码,不能有状态变量、不能接收 ETH、不能继承。

library MathLib {
	function add(uint a, uint b) internal pure returns (uint) {
		return a + b;
	}
}

contract Test {
	using MathLib for uint; // 给 uint 类型绑定库函数

	function calc(uint x, uint y) public pure returns (uint) {
		return x.add(y);       // 等价于 MathLib.add(x, y)
	}
}
  • 内部库(internal):代码被编译进调用合约,最常用。
  • 外部库(public/external):单独部署,通过 delegatecall 调用,使用前需要链接库地址。

OpenZeppelin 是最常用的开源库:Ownable、ERC20、ERC721、ReentrancyGuard、Pausable、AccessControl 等。

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20 {
	constructor() ERC20("MyToken", "MT") {
		_mint(msg.sender, 1000 * 10**decimals());
	}
}

十二.事件 Events

事件把数据写入交易日志,比存储便宜得多,合约内部读不到,主要给前端/链下服务监听。

// indexed 最多 3 个,可用来按条件过滤查询
event Transfer(address indexed from, address indexed to, uint value);

function send(address to, uint v) public {
	emit Transfer(msg.sender, to, v); // 触发事件
}
  • indexed 参数放入 topics,方便过滤;不加 indexed 的放在 data 里。
  • 匿名事件 event Log(uint a) anonymous;:不记录事件签名,最多 4 个 indexed。
  • 不要在事件里写敏感信息,日志是公开的。
  • 常用于:转账记录、NFT 交易、投票、多签操作。

十三.错误处理和自定义错误

1.三种基础机制

关键字用途失败时
require校验输入和外部条件回退,返还剩余 Gas
assert校验内部不变量回退(Panic 错误)
revert灵活抛错回退

2.自定义错误(0.8.4+),比字符串更省 Gas,还能带参数

error InsufficientBalance(uint available, uint required);
error Unauthorized();

function withdraw(uint amount) public {
	if (msg.sender != owner) revert Unauthorized();
	if (balance < amount) revert InsufficientBalance(balance, amount);
	balance -= amount;
}

3.try-catch:只能捕获外部调用和合约创建的失败

function safeCall(address target) public returns (bool) {
	try ITarget(target).doSomething() returns (uint v) {
		return v > 0;
	} catch Error(string memory reason) {  // require/revert 带字符串
		return false;
	} catch (bytes memory) {               // 其他所有错误
		return false;
	}
}

十四.合约间调用

1.接口调用(最常用、最安全)

interface ICounter {
	function count() external view returns (uint);
	function inc() external;
}

contract Caller {
	function callInc(address counter) public {
		ICounter(counter).inc();
	}
}

2.底层调用 call / delegatecall / staticcall

// call:在目标合约上下文中执行,修改目标合约的存储
(bool ok, bytes memory data) = target.call(abi.encodeWithSignature("setValue(uint256)", 1));

// delegatecall:借用目标合约的代码,在“自己”的存储里执行(代理合约的基础)
(bool ok2, ) = target.delegatecall(abi.encodeWithSignature("setValue(uint256)", 1));

// staticcall:只读调用,不允许修改状态
(bool ok3, bytes memory ret) = target.staticcall(abi.encodeWithSignature("getValue()"));

3.创建合约

Child c = new Child();                      // new:地址由部署者和 nonce 决定
Child c2 = new Child{salt: bytes32(uint(1))}(); // create2:地址可预测

4.外部调用要防重入(见下一节),并检查返回值。


十五.智能合约安全基础

1.重入攻击:先转账后改状态,被攻击合约在 receive 里反复调用 withdraw

// ❌ 不安全
function withdraw() public {
	uint bal = balances[msg.sender];
	(bool ok, ) = msg.sender.call{value: bal}(""); // 先转账
	require(ok);
	balances[msg.sender] = 0;                       // 后清零(太晚了)
}

// ✅ CEI:Checks -> Effects -> Interactions
function withdraw() public {
	uint bal = balances[msg.sender];
	require(bal > 0, "No balance");   // Checks
	balances[msg.sender] = 0;          // Effects
	(bool ok, ) = msg.sender.call{value: bal}(""); // Interactions
	require(ok, "Failed");
}

也可以使用 OpenZeppelin 的 ReentrancyGuard:

contract Vault is ReentrancyGuard {
	function withdraw() public nonReentrant {}
}

2.整数溢出:0.8.0 之后默认检查,用 unchecked 时要自己保证安全。

3.权限控制:敏感函数必须加 onlyOwner 等限制,不要用 tx.origin 验权。

4.DoS 攻击:不要循环给大量用户转账;使用拉取模式,让用户自己提款。

5.前端运行(抢跑):用 Commit-Reveal(先提交哈希,再揭示)防御。


十六.Gas优化技巧

1.存储槽打包:一个槽 32 字节,小类型相邻放一起

// ❌ 占 3 个槽
uint128 a; uint256 b; uint128 c;

// ✅ 占 2 个槽
uint128 a; uint128 c; uint256 b;

2.局部存储指针,避免重复读取 storage

User storage u = users[msg.sender];
u.age += 1;
u.score += 1;

3.常用小技巧:

// external + calldata
function f(uint[] calldata arr) external {}

// 缓存数组长度
uint len = arr.length;
for (uint i = 0; i < len; ) {
	unchecked { i++; } // 确定不会溢出时才使用
}

// 能用 constant / immutable 就用
uint public constant MAX = 100;

// 自定义错误代替字符串
error NotOwner();

// 批量操作,减少交易次数
function batchTransfer(address[] calldata tos, uint amount) external {}

// 只给前端看的数据用 event,不存 storage
emit Log(msg.sender, amount);

工具:Hardhat Gas Reporter、forge snapshot、forge test --gas-report。


十七.智能合约设计模式

1.访问控制:Ownable(单管理员)/ RBAC(多角色)

mapping(address => bool) public admins;

modifier onlyAdmin() {
	require(admins[msg.sender], "Not admin");
	_;
}

2.提现模式(Pull over Push):让用户自己来取钱

mapping(address => uint) public pending;

function withdraw() public {
	uint amount = pending[msg.sender];
	pending[msg.sender] = 0;
	(bool ok, ) = msg.sender.call{value: amount}("");
	require(ok);
}

3.状态机模式:见第七节。

4.代理模式:用户调用代理合约,代理用 delegatecall 转发给逻辑合约,实现可升级;注意存储布局要兼容。

contract Proxy {
	address public implementation; // 逻辑合约地址,可更换

	fallback() external payable {
		(bool ok, ) = implementation.delegatecall(msg.data);
		require(ok);
	}
}

5.工厂模式:用一个合约批量创建其他合约

contract TokenFactory {
	address[] public tokens;

	function create() public {
		Child c = new Child();
		tokens.push(address(c));
	}
}

6.紧急停止模式(Pausable)

bool public paused;

modifier whenNotPaused() {
	require(!paused, "Paused");
	_;
}

function pause() public onlyOwner { paused = true; }

十八.NFT市场

NFT 使用 ERC721 标准,每个 tokenId 唯一(ERC20 是可互换的)。

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MyNFT is ERC721 {
	uint public nextId;

	constructor() ERC721("MyNFT", "MNFT") {}

	function mint() public {
		_safeMint(msg.sender, nextId++);
	}
}

核心函数:ownerOf(id)、balanceOf(owner)、approve、setApprovalForAll、transferFrom、tokenURI(id)(元数据地址)。

NFT 市场最简逻辑:上架(挂单)-> 购买 -> 下架,市场合约需要用户先授权。

struct Listing {
	address seller;
	uint price;
}
mapping(address => mapping(uint => Listing)) public listings; // NFT合约 => tokenId => 挂单

function list(address nft, uint id, uint price) public {
	require(IERC721(nft).ownerOf(id) == msg.sender, "Not owner");
	listings[nft][id] = Listing(msg.sender, price);
}

function buy(address nft, uint id) public payable {
	Listing memory l = listings[nft][id];
	require(msg.value == l.price, "Wrong price");
	delete listings[nft][id];                                    // 先改状态
	IERC721(nft).transferFrom(l.seller, msg.sender, id);          // 转 NFT
	(bool ok, ) = l.seller.call{value: msg.value}("");            // 付款给卖家
	require(ok);
}

扩展:版税(ERC2981)、拍卖、平台手续费。


十九.Hardhat 3 环境搭建与合约部署

Hardhat 是 JavaScript/TypeScript 生态的合约开发框架:编译、测试、部署一体。

// 常用命令
npx hardhat compile          // 编译
npx hardhat test             // 测试
npx hardhat node             // 启动本地链
npx hardhat clean            // 清理缓存

目录结构:

project
    |- contracts       合约
    |- test            测试
    |- ignition
        |- modules     部署模块
    |- hardhat.config.ts   配置(编译器版本、网络)
    |- .env            私钥、RPC(不要提交)

Ignition 部署模块:

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

export default buildModule("CounterModule", (m) => {
	const counter = m.contract("Counter");
	return { counter };
});
// 部署到本地 / 测试网
npx hardhat ignition deploy ignition/modules/Counter.ts
npx hardhat ignition deploy ignition/modules/Counter.ts --network sepolia

二十.Hardhat 3 单元测试

测试使用 Mocha(describe / it 组织)+ Chai(expect 断言)。

import { expect } from "chai";
import { network } from "hardhat";

const { ethers, networkHelpers } = await network.connect();

// Fixture:部署一次,每个用例回到同一快照
async function deployFixture() {
	const [owner, addr1] = await ethers.getSigners();
	const counter = await ethers.deployContract("Counter");
	return { counter, owner, addr1 };
}

describe("Counter", function () {
	it("初始值为 0", async function () {
		const { counter } = await networkHelpers.loadFixture(deployFixture);
		expect(await counter.x()).to.equal(0n);
	});

	it("inc 后加 1", async function () {
		const { counter } = await networkHelpers.loadFixture(deployFixture);
		await counter.inc();
		expect(await counter.x()).to.equal(1n);
	});
});

常见测试点:

// 事件
await expect(token.transfer(addr1, 10n)).to.emit(token, "Transfer");

// 回滚
await expect(token.transfer(addr1, 999999n)).to.be.revertedWith("Insufficient balance");
await expect(token.withdraw(1n)).to.be.revertedWithCustomError(token, "InsufficientBalance");

// 切换调用者
await token.connect(addr1).transfer(owner, 1n);

还包括:时间旅行(快进区块时间)、快照恢复、覆盖率、Gas 报告。


二十一.Foundry

Foundry 用 Rust 编写,测试直接用 Solidity 写,速度快。工具链:

工具作用
forge编译、测试、部署脚本
cast和链交互(查询、发交易)
anvil本地测试链
chiselSolidity 交互式 REPL
forge init demo       // 初始化项目
forge build           // 编译
forge test            // 测试
forge test -vvv       // 显示详细日志
forge script script/Counter.s.sol --rpc-url <url> --broadcast  // 部署
forge install openzeppelin/openzeppelin-contracts              // 安装依赖
anvil                 // 启动本地链

测试合约(以 test 开头的函数是用例):

import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";

contract CounterTest is Test {
	Counter public counter;

	function setUp() public {          // 每个用例前执行
		counter = new Counter();
	}

	function test_Increment() public {
		counter.increment();
		assertEq(counter.number(), 1);
	}

	// 模糊测试:参数 x 由 Foundry 随机生成
	function testFuzz_SetNumber(uint256 x) public {
		counter.setNumber(x);
		assertEq(counter.number(), x);
	}

	// 期望回滚
	function test_RevertWhen_NotOwner() public {
		vm.prank(address(0xBEEF));      // 伪造调用者
		vm.expectRevert();
		counter.adminOnly();
	}
}

常用作弊码:vm.prank(改调用者)、vm.deal(设置余额)、vm.warp(改时间)、vm.expectRevert、vm.expectEmit。

配置文件 foundry.toml:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
optimizer = true
optimizer_runs = 200

二十二.Ethers.js 与 MetaMask 交互

前端通过 Ethers.js (v6) 连接钱包、读写合约。

import { ethers } from "ethers";

// 1.连接 MetaMask
const provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []); // 请求连接账户
const signer = await provider.getSigner();       // 签名者(能发交易)
console.log(await signer.getAddress());

// 2.读取链上数据
const balance = await provider.getBalance(await signer.getAddress());
console.log(ethers.formatEther(balance));        // wei -> ETH

// 3.读取合约(只读用 provider)
const counter = new ethers.Contract(address, abi, provider);
console.log(await counter.x());

// 4.写入合约(发交易用 signer)
const counterW = new ethers.Contract(address, abi, signer);
const tx = await counterW.inc();
await tx.wait();                                 // 等待上链确认

// 5.发送 ETH
await signer.sendTransaction({ to: addr, value: ethers.parseEther("0.01") });

// 6.监听事件
counter.on("Transfer", (from, to, value) => console.log(from, to, value));

// 7.签名消息
const sig = await signer.signMessage("hello");

要点:Provider 只读,Signer 可写;金额用 parseEther / formatEther 转换;发交易后要 wait()。


二十三.项目实战

1.众筹平台:工厂模式 + 状态机 + CEI

enum State { Active, Success, Failed, Closed }

// 工厂:每个众筹活动是一个独立合约
function createCampaign(uint goal, uint days_) external returns (address) {
	Campaign c = new Campaign(msg.sender, goal, days_);
	return address(c);
}

// 捐款:达到目标自动变成功
function contribute() external payable inState(State.Active) {
	require(block.timestamp < deadline, "Ended");
	contributions[msg.sender] += msg.value;
	totalRaised += msg.value;
	if (totalRaised >= goal) state = State.Success;
}

失败时捐款人自行退款(Pull 模式),成功时发起人提现(先改状态再转账)。

2.多签钱包:多个 owner,达到 N 个确认才能执行交易

struct Transaction {
	address to;
	uint value;
	bool executed;
	uint confirmations;
}
Transaction[] public txs;
mapping(uint => mapping(address => bool)) public confirmed; // 交易ID => owner => 是否确认

function submit(address to, uint value) public onlyOwner {
	txs.push(Transaction(to, value, false, 0));      // 提交
}

function confirm(uint id) public onlyOwner {
	require(!confirmed[id][msg.sender], "Done");
	confirmed[id][msg.sender] = true;                 // 确认
	txs[id].confirmations++;
}

function execute(uint id) public onlyOwner {
	Transaction storage t = txs[id];
	require(t.confirmations >= required, "Not enough");
	require(!t.executed, "Executed");
	t.executed = true;                                // 先改状态
	(bool ok, ) = t.to.call{value: t.value}("");      // 再转账
	require(ok);
}

流程:提交 -> 确认 -> 执行(可撤销确认);进阶版本使用代理模式支持升级。

3.NFT 盲盒:ERC721 + Chainlink VRF 随机数 + 白名单分阶段销售

流程:
1. 用户付费购买 -> 铸造盲盒 NFT(未揭示)
2. 请求 Chainlink VRF 随机数(链上不能自己生成安全随机数)
3. VRF 回调 -> 根据随机数分配稀有度
4. 揭示 -> tokenURI 指向 IPFS 上真实元数据

要点:随机数不能用 block.timestamp 等可预测的值;元数据放 IPFS;销售分阶段(未开始 / 白名单 / 公售)。


二十四.知识速查表

存储位置    storage 永久 / memory 临时 / calldata 只读参数
可见性      public / external / internal / private
状态修饰    view 只读 / pure 纯计算 / payable 收款
错误处理    require 校验 / assert 不变量 / revert 抛错 / 自定义 error
转账        call{value: x}("") + require 检查
全局变量    msg.sender / msg.value / block.timestamp / block.number
安全三件套  CEI 模式 / ReentrancyGuard / 权限控制
Gas 优化    打包存储槽 / calldata / 缓存 length / 自定义错误 / 事件代替存储
开发工具    Remix 入门 -> Hardhat(JS 测试) / Foundry(Solidity 测试) -> Ethers.js 前端