一、背景

项目地址:https://github.com/chitian-victor/pumpkin_faucet

这是一个基于 Next.jsFoundry 构建代币水龙头项目,支持用户在一定的时间间隔内领取指定数量的代币,仅供学习使用

项目展示

后续可以自行拓展的功能:

  1. 支持修改领取间隔以及领取数量
  2. 支持捐赠代币
  3. 支持修改代币和水龙头地址

项目部署&体验

在进入开发之前一定要清楚,我们做的到底是个什么东西…..

1. 后端部署

1
2
3
4
5
cd backend
# 首先,单开一个 terminal 执行下面的指令启动 foundry 的测试区块节点,控制台会输出固定的 10 个公钥和私钥地址
anvil
# 之后再单开一个 terminal 部署上我们的合约,并进行初始化
make init

2. 前端部署

1
2
3
cd frontend
npm install
npm run dev

3. 修改水龙头地址

path: pumpkin_faucet/frontend/components/context.tsx

1
2
// 找到下面这个变量,修改为自己的水龙头地址即可
const pumpkinFaucetAddress = "水龙头合约地址";

4. metamask 配置

新版的 metamask 浏览器插件使用貌似都需要翻墙,可以安装旧版,比如11.16.15

  1. 首先第一步一定要先在metamask 上面添加自己的本地网络,并切换过来, ethers 组件的以当前插件所在区块链网络为主
  1. 添加 foundry 生成的账户到 metamask 里面,因为后面交易需要 gas, foundry 的账户是有余额的,这一步必不可少(要不就利用cast 指令给自己的账户铸币)

5. 连接钱包领取代币

可以在 metamask 上查看交易记录

二、开发

前置准备

  1. 安装 VSCode 以及相应的 solidity 插件,官网
  2. 安装 Foundry 框架的相关指令集,官网
  3. 安装 Node.js 相关指令集,官网

后端

初始化

  1. 进行Foundry框架初始化
1
forge init 
  1. 下载 99% 合约开发者都会用到的仓库OpenZeppelin
1
forge install OpenZeppelin/openzeppelin-contracts

安装好就可以直接以import {xx} from openzeppelin-contracts/xxx/xxx.sol这样用了,因为 foundry 自动生成好了文件映射关系,可以通过forge remappings查看
另外,某些情况下需要重新启动下 VSCode 才能支持点击路径定位到文件位置,快速浏览代码

合约编写

1. 代币合约

path: pumpkin_faucet/backend/src/PumpkinToken.sol

作为我们发行的主要货币,支持铸造、销毁和转账等一些列操作,遵从IERC20接口规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//SPDX-License-Identifier: MIT  
pragma solidity ^0.8.30;

import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract PumpkinToken is ERC20, Ownable {
// 因为ERC20里面没有这两个事件,这里定义一下,debug查看
event Mint(address indexed user, uint256 indexed amount);
event Burn(address indexed user, uint256 indexed amount);

// 为了方便,这里直接写死了 token 信息,其实重复定义了,造成了 gas 浪费
string private _name = "Pumpkin";
string private _symbol = "PK";

constructor(
address owner
) ERC20(_name, _symbol) Ownable(owner) {}

function mint(uint256 amount) public onlyOwner {
_mint(msg.sender, amount);
emit Mint(msg.sender, amount);
}

function burn(uint256 amount) public {
_burn(msg.sender, amount);
emit Burn(msg.sender, amount);
}
}

2. 水龙头合约

path: pumpkin_faucet/backend/src/PumpkinFaucet.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// SPDX-License-Identifier: MIT  

pragma solidity ^0.8.30;

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol";
import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";

// 控制台打印输出的包
// import "forge-std/console2.sol";
contract PumpkinFaucet is Ownable,ReentrancyGuard {
// 可以直接利用现成的 SafeERC20 库,避免手动检查和处理转账失败的情况
using SafeERC20 for IERC20;

// 这里变量定义为了 public,会自动生成 Getter 函数的,比如 dripInterval()uint256 IERC20 public token;
uint256 public dripInterval;
uint256 public dripLimit;
// public 关键字会自动生成 getter: function lastDripTime(address) external view returns (uint256) mapping(address => uint256) public lastDripTime;

error Faucet_TooFrequently();
error Faucet_ExceedLimit();
error Faucet_FaucetEmpty();
error Faucet_InvalidAmount();
error Faucet_InvalidAddress();
error Faucet_DepositFailed();

event Drip(address indexed receiver, uint256 indexed amount);
event Deposit(uint256 indexed amount);
event TokenUpdated(address indexed from, address indexed to);

// 定义一个事件,当合约所有者存入代币时触发,记录存入的代币数量。
constructor(
address _tokenAddress,
uint256 _dripInterval,
uint256 _dripLimit
) Ownable(_msgSender()) {
token = IERC20(_tokenAddress);
dripInterval = _dripInterval;
dripLimit = _dripLimit;
}

// 用户调用该函数领取代币
function drip(uint256 _amount) external nonReentrant {
uint256 targetAmount = _amount;
if (block.timestamp < lastDripTime[_msgSender()] + dripInterval) {
revert Faucet_TooFrequently();
}
if (targetAmount > dripLimit) {
revert Faucet_ExceedLimit();
}
if (token.balanceOf(address(this)) < targetAmount) {
revert Faucet_FaucetEmpty();
}
lastDripTime[_msgSender()] = block.timestamp;
token.safeTransfer(_msgSender(), targetAmount);
emit Drip(_msgSender(), targetAmount);
}

// 任何人都可以给水龙头捐赠代币,前提需要先调用 token 的 approve 方法,
// 因为传给 token 合约的 msg.sender 是 faucet 合约地址
function deposit(uint256 _amount) external {
token.safeTransferFrom(msg.sender, address(this), _amount);
emit Deposit(_amount);
}

/* 管理员功能 */
function setDripInterval(uint256 _newDripInterval) public onlyOwner {
dripInterval = _newDripInterval;
}

function setDripLimit(uint256 _newDripLimit) public onlyOwner {
dripLimit = _newDripLimit;
}

function setTokenAddress(address _newTokenAddress) external onlyOwner {
if (_newTokenAddress == address(0)) revert Faucet_InvalidAddress();
token = IERC20(_newTokenAddress);
emit TokenUpdated(address(token), _newTokenAddress);
}
// 紧急提款功能:防止合约 bug 导致资金被锁
function withdrawAssets(address _token, uint256 _amount) external onlyOwner {
IERC20(_token).safeTransfer(owner(), _amount);
}
}

补充一点,合约的单测也非常重要,因为合约一旦部署上去了就不能修改了,所以单测最好可以把合约的基础逻辑验证一遍

合约验证

环境变量配置

主要为了重复使用,path: pumpkin_faucet/backend/.env
这里面的公钥(地址)和私钥都是 foundry 自动生成的,都是固定的,合约地址需要自行修改。
注意⚠️:以上私钥仅为 Anvil 本地测试链生成的公开私钥,千万不要将包含真实私钥的 .env 提交到 GitHub

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
OWNER_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
OWNER_ADDRESS=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

USER_PRIVATE_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
USER_ADDRESS=0x70997970C51812dc3A010C7d01b50e0d17dc79C8

# 以下两个合约地址需要自行修改
TOKEN_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
FAUCET_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512


DRIP_AMOUNT=1000000000000000000 # 1
MINT_AMOUNT=1000000000000000000000 # 1000
DEPOSIT_AMOUNT=800000000000000000000 # 800
DRIP_LIMIT= 2000000000000000000 # 2
DRIP_INTERVAL=20 # 20 seconds

# https://eth-converter.com/ 转换wei、ETH单位

验证内容包括:

这些验证验证指令最好都写在一个 makefile 文件里面,方便下次重复执行
path: pumpkin_faucet/backend/Makefile

  1. 合约部署
1
forge create --broadcast src/PumpkinToken.sol:PumpkinToken --private-key $(OWNER_PRIVATE_KEY) --constructor-args $(OWNER_ADDRESS)
  1. 🎃币铸造
1
cast send $(TOKEN_ADDRESS) "mint(uint256)" $(MINT_AMOUNT) --private-key $(OWNER_PRIVATE_KEY)
  1. 查询owner余额
1
cast call $(TOKEN_ADDRESS) "balanceOf(address)" $(OWNER_ADDRESS) --private-key $(OWNER_PRIVATE_KEY)
  1. 给 faucet 授权额度
1
cast send $(TOKEN_ADDRESS) "approve(address,uint256)" $(FAUCET_ADDRESS) $(DEPOSIT_AMOUNT) --private-key $(OWNER_PRIVATE_KEY)
  1. 往 faucet 合约存入🎃币
1
cast send $(FAUCET_ADDRESS) "deposit(uint256)" $(DEPOSIT_AMOUNT) --private-key $(OWNER_PRIVATE_KEY)
  1. 尝试给 user 领取🎃币
1
cast send $(FAUCET_ADDRESS) "drip(uint)" $(DRIP_AMOUNT) --private-key $(USER_PRIVATE_KEY)
  1. 查询user余额
1
cast call $(TOKEN_ADDRESS) "balanceOf(address)" $(USER_ADDRESS) --private-key $(USER_PRIVATE_KEY)

前端

前置提醒:我对前端这块的掌握程度远不如后端,只能说看得懂也知道怎么使用,但是目前还达不到写出非常优秀的代码的程度,所以这块仅供参考。但是我都已经利用 gemini 优化过一遍了

初始化

1
2
npx create-next-app@latest
npm install ethers lucide-react clsx

代码编写

前端代码太长了,这部分还是自行去 github 仓库里面查看吧

简单说一下前端代码结构吧,主要创建了4 个组件

  • components/nav.tsx : 负责导航栏的展示和交互,包括钱包的连接与断开
  • components/main.tsx : 负责核心页面的展示和交互,包括领取信息的展示和领取代币
  • components/footer.tsx : 页脚信息展示(不重要)
  • components/context.tsx : 负责管理共用变量及方法

三、拓展

部署到测试网

再次提醒⚠️:千万不要把自己的私钥暴露出去

将 Foundry 本地合约部署到公开测试网(如 Sepolia)的流程与本地 Anvil 部署非常相似,核心区别在于需要真实的 RPC 节点、有测试币的钱包以及不同的命令参数。以下是部署指南:

1. 准备工作

  • 首先需要在Alchemy上创建你的 App,然后获取测试网的 HTTPS URL,比如 Sepolia
  • 之后在etherscan创建免费的 Api Key,部署合约的时候会上传到 etherscan,方便后面查看自己的链上合约
  • 最后可能稍微可能有点门槛的,获取一些 Sepolia 货币,这个其实有很多免费的水龙头上可以领取(比如免费水龙头地址),但是都需要账户上有点以太坊主网 ETH ,所以可以选择在闲鱼 APP 上购买,也很便宜

2. 部署

  • 环境变量配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# === 你的测试网配置 ===

# Alchemy 或 Infura 给你的 URL
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/xxxxxxxxx

# 你的钱包地址 (用于接收代币)
REAL_ADDRESS=
# 你的测试钱包私钥 (不要带 0x 前缀,除非 Foundry 报错)
REAL_PRIVATE_KEY=

# Etherscan 的 API Key
ETHERSCAN_API_KEY=

# https://sepolia.etherscan.io/address/0xA2eD67252991ca6Baa8c1f50c01C4a0b27104556
REAL_TOKEN_ADDRESS=0xA2eD67252991ca6Baa8c1f50c01C4a0b27104556

# 下面的南瓜币水龙头地址是我已经部署到Sepolia测试网上面的合约地址,可以直接拿来使用
# https://sepolia.etherscan.io/address/0x48C41c2b4Bc41f14764Ba19Fd4cA3a6c21e7B650
REAL_FAUCET_ADDRESS=0x48C41c2b4Bc41f14764Ba19Fd4cA3a6c21e7B650

REAL_DRIP_AMOUNT=1000000000000000000 # 1
REAL_MINT_AMOUNT=100000000000000000000000 # 100000
REAL_DEPOSIT_AMOUNT=80000000000000000000000 # 80000
REAL_DRIP_LIMIT=100000000000000000 # 0.1
REAL_DRIP_INTERVAL=3600 # 1 hour
  • Makefile

    和本地部署的指令差别不大,下面的指令依次执行即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
deploy_token_sepolia:  
@echo "deploy token contract to sepolia..."
forge create --broadcast \
--rpc-url $(SEPOLIA_RPC_URL) \
--private-key $(REAL_PRIVATE_KEY) \
--etherscan-api-key $(ETHERSCAN_API_KEY) \
--verify \
src/PumpkinToken.sol:PumpkinToken \
--constructor-args $(REAL_ADDRESS)

deploy_faucet_sepolia:
@echo "deploy faucet contract to sepolia..."
forge create --broadcast \
--rpc-url $(SEPOLIA_RPC_URL) \
--private-key $(REAL_PRIVATE_KEY) \
--etherscan-api-key $(ETHERSCAN_API_KEY) \
--verify \
src/PumpkinFaucet.sol:PumpkinFaucet \
--constructor-args $(REAL_TOKEN_ADDRESS) $(REAL_DRIP_INTERVAL) $(REAL_DRIP_LIMIT)

mint_sepolia:
@echo "mint tokens to sepolia..."
cast send $(REAL_TOKEN_ADDRESS) "mint(uint256)" $(REAL_MINT_AMOUNT) \
--rpc-url $(SEPOLIA_RPC_URL) \
--private-key $(REAL_PRIVATE_KEY)


approve_faucet_sepolia:
@echo "approve faucet contract to sepolia..."
cast send $(REAL_TOKEN_ADDRESS) "approve(address,uint256)" $(REAL_FAUCET_ADDRESS) $(REAL_DEPOSIT_AMOUNT) \
--rpc-url $(SEPOLIA_RPC_URL) \
--private-key $(REAL_PRIVATE_KEY)

owner_deposit_sepolia:
@echo "deposit tokens to sepolia..."
cast send $(REAL_FAUCET_ADDRESS) "deposit(uint256)" $(REAL_DEPOSIT_AMOUNT) \
--rpc-url $(SEPOLIA_RPC_URL) \
--private-key $(REAL_PRIVATE_KEY)

3. 验证

最后替换掉前端代码里面的本地的水龙头地址为测试网上的地址,然后在把metamask切换至Sepolia网络,之后就可以继续在南瓜币水龙头网站上进行交互了


Q&A

1. 重新执行 anvil 部署合约,发现前端展示的还是上一次的信息
这个也是我偶然遇到的,可以先在 metamask 的高级设置里面点击‘清除活动选项卡数据’,如果没有效果,就在浏览器插件管理那块对 metamask 进行刷新,这时候重新登录 metamask 一般就可以了
另外补充:当 Anvil 重启时,链的状态重置了(Nonce 归零),但 MetaMask 可能记住的还是之前的 Nonce(比如 10)。当你再次发起交易,MetaMask 发送 Nonce 11,但 Anvil 期望 Nonce 0,导致交易卡死或报错

2. 遇到读取到的地址非 metamask 当前地址
和当前网络断开连接,重连即可

参考