实现一个众筹合约:投资、退款、提款、锁定期、Oracle喂价合约获取实时链下数据等操作
0. 众筹合约目标
- 实现创建收款的函数:由外部账户调用,存入一定数量的金额;并且在合约初始化时,指定合约的锁定期;
- 使用Oracle Aggregator实时获取链下数据,实现ETH/USD转换;
- 实现退款函数
- 实现提款函数:
- 合约的拥有者可以将合约内的资产提取到指定账户;
- 合约的拥有者可以转移合约的owner;
1. 收款和查询
创建合约:
- 使用mapping存储投资地址和金额;
- 创建合约时,指定合约的owner、合约部署时间、锁定期时间
- 创建fund函数,可以进行众筹,并校验众筹最小金额和锁定期;
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract FundMe { // 投资人地址 -> 投资金额,public可以直接查询 mapping(address => uint256) public funderToAmountMapping; // 合约的owner address public owner; // 合约部署时间 uint256 public startTimestamp; // 精度为妙,如 10 min = 60 * 10 uint256 public lockTime; // 单次投资最小金额:10 USD uint256 constant MINIMUM_USD = 10 * 10 ** 18; // 众筹目标:100 USD uint256 constant TARGET = 100 * 10 ** 18; /** * Network: Sepolia Testnet * Aggregator: ETH/USD * Address: 0x694AA1769357215DE4FAC081bf1f309aDC325306 */ constructor(uint256 _lockTime) { // 首次部署初始化数据:owner、startTimestamp、lockTime owner = msg.sender; startTimestamp = block.timestamp; // 当前所部署时的区块的时间戳 lockTime = _lockTime; } function fund() external payable { require(convertEthToUSD(msg.value) >= MINIMUM_USD, "Send more USD"); // 校验是否超出锁定期,此时的block是当前执行此函数时所在的区块(非部署时的block) require( block.timestamp < startTimestamp + lockTime, "lock winodw is closed" ); funderToAmountMapping[msg.sender] = msg.value; } }
2. 获取链下数据
- 引入Oracle预言机接口合约,找到对应网络的预言机合约地址:Chainlink- Price Feed Contract Addresses
- 通过构造函数,合约部署时,将预言机喂价合约地址写入;
- 创建函数:getChainlinkDataFeedLatestAnswer()实时查询当前ETH/USD兑换比例;
- 创建一个ETH->USD转换函数convertEthToUSD()。注意:兑换比例和精度(precision);每一个代币都有精度的机制,以太坊中是没有小数的概念的,要表示一个完整的代币,实际上存储的值是:1 * precision,如果精度是18位小数,就对应:1 * 10 ** 18
这里的聚合函数查询到的USD的精度为8位,即
1 USD
就需要用1 * 10 ** 8
来表示;
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract FundMe { // 投资人地址 -> 投资金额,public可以直接查询 mapping(address => uint256) public funderToAmountMapping; // 合约的owner address public owner; // 合约部署时间 uint256 public startTimestamp; // 精度为妙,如 10 min = 60 * 10 uint256 public lockTime; // 单次投资最小金额:10 USD uint256 constant MINIMUM_USD = 10 * 10 ** 18; // 众筹目标:100 USD uint256 constant TARGET = 100 * 10 ** 18; // 预言机合约 AggregatorV3Interface internal dataFeed; /** * Network: Sepolia Testnet * Aggregator: ETH/USD * Address: 0x694AA1769357215DE4FAC081bf1f309aDC325306 */ constructor(uint256 _lockTime) { dataFeed = AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306); // 首次部署初始化数据:owner、startTimestamp、lockTime owner = msg.sender; startTimestamp = block.timestamp; // 当前所部署时的区块的时间戳 lockTime = _lockTime; } function fund() external payable { require(convertEthToUSD(msg.value) >= MINIMUM_USD, "Send more USD"); // 校验是否超出锁定期,此时的block是当前执行此函数时所在的区块(非部署时的block) require( block.timestamp < startTimestamp + lockTime, "lock winodw is closed" ); funderToAmountMapping[msg.sender] = msg.value; } /** * Eth -> USD */ function convertEthToUSD(uint256 ethAmount) internal view returns (uint256) { uint256 price = uint(getChainlinkDataFeedLatestAnswer()); // 精度,具体看预言机函数的介绍 return (ethAmount * price) / (10 ** 8); } /** * Returns the latest answer. */ function getChainlinkDataFeedLatestAnswer() public view returns (int) { // prettier-ignore ( /* uint80 roundID */, int answer, /*uint startedAt*/, /*uint timeStamp*/, /*uint80 answeredInRound*/ ) = dataFeed.latestRoundData(); return answer; } }
3. 使用modifier创建复用逻辑
创建
modifier
校验:操作时间在锁定期之后,后续退款和提款,都需要在锁定期之后执行:
// 使用modifier增加复用、可读性 modifier lockWindowCheck() { require(block.timestamp >= startTimestamp + lockTime, "lock winodw is not closed"); _; }
4. 退款函数
// 投资人退款,使用modifier function refund() external lockWindowCheck { // 查看当前的合约的账户的资产,没有达到目标值 require(convertEthToUSD(address(this).balance) < TARGET, "Target overflow"); // 查看当前退款的账户的余额是不是存在 uint256 fundBalance = funderToAmountMapping[msg.sender]; require(fundBalance >= 0, "You have no fund"); // 执行退款 (bool success,) = payable(msg.sender).call{value: fundBalance}(""); require(success, "transfer failed"); }
上述代码存在漏洞,取款之后,并没有清空对应账户的余额
并且:清空余额的操作应该放在转账之前,防止重入攻击;
重入攻击:收款方是一个合约账户,当发生转账调用,此合约账户可以执行一个
fallback
函数,对refund
进行重入,重入时此账户的余额仍然是原余额,可以触发再次转账。(The Dao事件)
// 投资人退款,使用modifier function refund() external lockWindowCheck { // 查看当前的合约的账户的资产,没有达到目标值 require(convertEthToUSD(address(this).balance) < TARGET, "Target overflow"); // 查看当前退款的账户的余额是不是存在 uint256 fundBalance = funderToAmountMapping[msg.sender]; require(fundBalance >= 0, "no fund"); // 防止重入 funderToAmountMapping[msg.sender] = 0; // 执行退款 (bool success,) = payable(msg.sender).call{value: fundBalance}(""); require(success, "transfer failed"); }
5. 提款函数
转账函数:
transfer
:transfer ETH and revert if tx failed; 当转账失败不会损失转账金额,仅损失gas
payable(msg.sender).transfer(value)
向合约内添加:
- owner转换函数:将owner移交给其他地址(只允许当前owner调用);
- 转账函数:将当前合约的资产转移给owner;(只有资产达到TARGET才可提款)
// 提款转账函数 function getFund() external lockWindowCheck { // 仅允许owner提款 require(msg.sender == owner, "This function can only be called by owner!"); // balance 单位为:Wei require(convertEthToUSD(address(this).balance) >= TARGET, "Target is not reached!"); // 转账 transfer:将address的余额,转账给msg.sender payable(msg.sender).transfer(address(this).balance); } // owner转换函数 function transferOwnership(address newOwner) public { require(msg.sender == owner, "This function can only be called by owner!"); owner = newOwner; }
6. 部署到测试网络
要获取预言机的数据,必须部署到测试网络;
- 选择环境为:Injected Provider Metamask;(也可以选择WalletConnect链接钱包的Sepolia测试网络)
- 弹出Metamask插件,选择对应的账户和测试网络(已有Sepolia的测试Eth)
- 执行部署;
4. 测试
选择30Finney执行
Fund
函数,提示交易会失败,说明此时 30 Finney 小于100USD;(具体看执行时价格)

选择40 Finney 执行Fund函数,弹出钱包确认交易;
执行成功后,显示合约账户的余额: