Solidity 第一周(下):ETH交互、安全模式与DApp实战

40 阅读6分钟

Solidity 第一周(下):ETH交互、安全模式与DApp实战

在第一周《上》中,我们已经为自己打下了坚实的Solidity基础,掌握了变量、函数和基础数据结构。现在,是时候将这些知识投入实战,构建能够处理真实以太币(ETH)、具备严谨安全逻辑的去中心化应用(DApp)了。

本文将以AuctionHouseEtherPiggyBankSimpleIOU等更复杂的合约为蓝本,带你深入理解ETH交互、合约安全设计模式以及如何将所有知识点融会贯通,构建一个功能完整的链上应用。

一、 让合约“收钱”:payablemsg.value 与 ETH 转账

到目前为止,我们操作的都只是合约内部的uint记账。EtherPiggyBank合约首次让我们接触到了真正的链上价值转移。

1. 接收ETH:payablemsg.value

要让一个函数能够接收ETH,必须用payable关键字进行修饰。

// 一个可以接收ETH的存款函数
function depositIntoWallet() public payable onlyRegistered {
    require(msg.value > 0, "Must send ETH");
    balances[msg.sender] += msg.value;
}
  • payable: 告诉以太坊虚拟机(EVM),这个函数可以伴随着ETH转账被调用。如果没有它,任何向该函数发送ETH的交易都会被拒绝。
  • msg.value: 这是一个全局变量,它存储了随函数调用一同发送的ETH数量(单位是wei,1 ether = 10^18 wei)。

当用户调用depositIntoWallet并发送0.1 ETH时,msg.value的值就是100000000000000000 wei,这笔ETH会被锁定在合约地址中,同时我们在balances映射中为用户记上这笔账。

2. 发送ETH:transfer vs call

SimpleIOU 合约中,我们遇到了两种从合约向外部地址发送ETH的方式。

  • transfer (已不推荐)

    function transferEther(address payable _to, uint256 _amount) public onlyRegistered {
        // ... 省略检查 ...
        _to.transfer(_amount); // 从合约向 _to 地址发送ETH
    }
    

    transfer方法简单直接,如果发送失败会自动revert(回滚交易)。但它的致命缺点是只转发2300 Gas。这个Gas量仅够接收方(一个普通钱包地址)接收ETH,但如果接收方是一个需要执行更多逻辑的智能合约,2300 Gas很可能不够用,导致转账失败。

  • call (现代推荐)

    function withdraw(uint256 _amount) public onlyRegistered {
        // ... 省略检查 ...
        (bool success, ) = payable(msg.sender).call{value: _amount}("");
        require(success, "Withdrawal failed");
    }
    

    call是当前推荐的发送ETH的低级方法。

    • {value: _amount}: 这是指定发送ETH数量的语法。
    • "": 表示我们不调用对方合约的任何函数,只是单纯转账。
    • 它会返回一个布尔值success来表示转账是否成功。我们必须显式地用require(success, ...)来检查结果
    • 转发所有可用的Gas,这意味着即使接收方是复杂的智能合约,也有足够的Gas来执行其接收逻辑。

安全警告call的灵活性也带来了风险。由于它不限制Gas,如果使用不当,可能会引发重入攻击 (Re-entrancy Attack)。一个最佳实践是遵循**“检查-生效-交互” (Checks-Effects-Interactions)** 模式:即在调用call之前,先完成所有内部状态的修改(如扣减余额)。


二、 合约的“安全卫士”:require 与设计模式

智能合约一旦部署便不可更改,“Code is Law”。因此,编写健壮、安全的逻辑至关重要。

1. 使用 require 进行条件检查

require()是我们代码中最忠实的守卫。它用于在函数执行前验证条件是否满足,如果不满足,则立即停止执行并回滚所有状态更改。

AuctionHouse合约的bid函数中,我们看到了它的经典用法:

function bid(uint amount) external {
    // 检查1: 拍卖是否已结束?
    require(block.timestamp < auctionEndTime, "Auction has already ended.");
    // 检查2: 出价是否有效?
    require(amount > 0, "Bid amount must be greater than zero.");
    // 检查3: 新出价是否高于当前出价?
    require(amount > bids[msg.sender], "New bid must be higher than your current bid.");

    // ... 后续逻辑 ...
}
```在函数入口处进行充分的`require`检查,是防止无效操作和潜在攻击的第一道防线。

**2. 防止重复操作的状态标志**

在`AuctionHouse`的`endAuction`函数中,我们学习了如何使用一个`bool`标志来确保一个关键操作只能被执行一次。

```solidity
bool public ended;

function endAuction() external {
    require(block.timestamp >= auctionEndTime, "Auction hasn't ended yet.");
    // 关键检查:确保此函数之前没有被调用过
    require(!ended, "Auction end already called.");
    
    // 生效 (Effect):立即修改状态
    ended = true;
    
    // ... 后续可能存在的交互 (Interaction) ...
}

这种“状态锁”模式非常普遍,可用于防止重复提款、重复领取奖励等场景。


三、 综合实战:构建 SimpleIOU 合约的逻辑闭环

SimpleIOU合约是所有知识点的集大成者。它不仅是一个简单的记账本,更是一个具备完整逻辑闭环的迷你金融应用。让我们回顾一下它的核心流程:

  1. 权限管理 (Owner & Friends):

    • 通过constructor设立owner
    • owner使用addFriend函数,通过onlyOwner修饰符保护,构建一个私有的朋友圈。
    • 所有核心功能都由onlyRegistered修饰符保护,确保只有圈内好友可以操作。
  2. 资金池管理 (Balance):

    • 朋友们通过payabledepositIntoWallet函数向合约存入ETH,资金由合约统一保管,个人余额记录在balances映射中。
  3. 核心业务逻辑 (Debt):

    • recordDebt函数用于在链上记录一笔债务,它只修改debts这个嵌套映射,不移动任何资金。
    • payFromWallet函数用于清偿债务。这是一个纯内部的账本操作:从债务人balances中扣款,加到债权人balances中,同时减少debts记录。整个过程没有ETH离开合约。
  4. 与外部交互 (Withdraw & Transfer):

    • withdrawtransferEtherViaCall函数是资金离开合约的出口。它们都遵循了“检查-生效-交互”的最佳实践:
      1. 检查 (Checks): 验证权限、余额是否充足。
      2. 生效 (Effects): 扣除内部balances映射中的余额。
      3. 交互 (Interactions): 最后才使用.call向外部地址发送ETH。

这个顺序至关重要,它能有效防止上文提到的重入攻击。


回顾:

  • 我们从基础语法和数据结构起步。
  • 学会了如何通过modifierrequire构建坚固的访问控制和业务逻辑。
  • 掌握了使用payablecall来处理真实ETH的核心技巧。
  • 最终,通过综合实战,理解了如何将所有模块组合成一个逻辑自洽、遵循安全模式的去中心化应用。