众筹合约的实现

实现一个众筹合约:投资、退款、提款、锁定期、Oracle喂价合约获取实时链下数据等操作

0. 众筹合约目标

  1. 实现创建收款的函数:由外部账户调用,存入一定数量的金额;并且在合约初始化时,指定合约的锁定期;
  2. 使用
    Oracle Aggregator
    实时获取链下数据,实现ETH/USD转换;
  3. 实现退款函数
  4. 实现提款函数:
    1. 合约的拥有者可以将合约内的资产提取到指定账户;
    2. 合约的拥有者可以转移合约的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. 获取链下数据

  1. 引入Oracle预言机接口合约,找到对应网络的预言机合约地址:Chainlink- Price Feed Contract Addresses
  2. 通过构造函数,合约部署时,将预言机喂价合约地址写入;
  3. 创建函数:
    getChainlinkDataFeedLatestAnswer()
    实时查询当前ETH/USD兑换比例;
  4. 创建一个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)

向合约内添加:

  1. owner转换函数:将owner移交给其他地址(只允许当前owner调用);
  2. 转账函数:将当前合约的资产转移给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. 部署到测试网络

要获取预言机的数据,必须部署到测试网络;

  1. 选择环境为:Injected Provider Metamask;(也可以选择WalletConnect链接钱包的Sepolia测试网络)
  2. 弹出Metamask插件,选择对应的账户和测试网络(已有Sepolia的测试Eth)
  3. 执行部署;

4. 测试

选择30Finney执行

Fund
函数,提示交易会失败,说明此时 30 Finney 小于100USD;(具体看执行时价格)

选择40 Finney 执行Fund函数,弹出钱包确认交易;

执行成功后,显示合约账户的余额: