一、背景
项目地址:https://github.com/chitian-victor/pumpkin_faucet
这是一个基于 Next.js 和 Foundry 构建代币水龙头项目,支持用户在一定的时间间隔内领取指定数量的代币,仅供学习使用
项目展示
后续可以自行拓展的功能:
- 支持修改领取间隔以及领取数量
- 支持捐赠代币
- 支持修改代币和水龙头地址
项目部署&体验
在进入开发之前一定要清楚,我们做的到底是个什么东西…..
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 = "水龙头合约地址";
|
新版的 metamask 浏览器插件使用貌似都需要翻墙,可以安装旧版,比如11.16.15
- 首先第一步一定要先在metamask 上面添加自己的本地网络,并切换过来, ethers 组件的以当前插件所在区块链网络为主
- 添加 foundry 生成的账户到 metamask 里面,因为后面交易需要 gas, foundry 的账户是有余额的,这一步必不可少(要不就利用cast 指令给自己的账户铸币)
5. 连接钱包领取代币
可以在 metamask 上查看交易记录
二、开发
前置准备
- 安装 VSCode 以及相应的 solidity 插件,官网
- 安装 Foundry 框架的相关指令集,官网
- 安装 Node.js 相关指令集,官网
后端
初始化
- 进行Foundry框架初始化
- 下载 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
| forge create --broadcast src/PumpkinToken.sol:PumpkinToken --private-key $(OWNER_PRIVATE_KEY) --constructor-args $(OWNER_ADDRESS)
|
- 🎃币铸造
1
| cast send $(TOKEN_ADDRESS) "mint(uint256)" $(MINT_AMOUNT) --private-key $(OWNER_PRIVATE_KEY)
|
- 查询owner余额
1
| cast call $(TOKEN_ADDRESS) "balanceOf(address)" $(OWNER_ADDRESS) --private-key $(OWNER_PRIVATE_KEY)
|
- 给 faucet 授权额度
1
| cast send $(TOKEN_ADDRESS) "approve(address,uint256)" $(FAUCET_ADDRESS) $(DEPOSIT_AMOUNT) --private-key $(OWNER_PRIVATE_KEY)
|
- 往 faucet 合约存入🎃币
1
| cast send $(FAUCET_ADDRESS) "deposit(uint256)" $(DEPOSIT_AMOUNT) --private-key $(OWNER_PRIVATE_KEY)
|
- 尝试给 user 领取🎃币
1
| cast send $(FAUCET_ADDRESS) "drip(uint)" $(DRIP_AMOUNT) --private-key $(USER_PRIVATE_KEY)
|
- 查询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 当前地址
和当前网络断开连接,重连即可

参考