Solidity 第一周(下):ETH交互、安全模式与DApp实战
在第一周《上》中,我们已经为自己打下了坚实的Solidity基础,掌握了变量、函数和基础数据结构。现在,是时候将这些知识投入实战,构建能够处理真实以太币(ETH)、具备严谨安全逻辑的去中心化应用(DApp)了。
本文将以AuctionHouse、EtherPiggyBank和SimpleIOU等更复杂的合约为蓝本,带你深入理解ETH交互、合约安全设计模式以及如何将所有知识点融会贯通,构建一个功能完整的链上应用。
一、 让合约“收钱”:payable、msg.value 与 ETH 转账
到目前为止,我们操作的都只是合约内部的uint记账。EtherPiggyBank合约首次让我们接触到了真正的链上价值转移。
1. 接收ETH:payable 与 msg.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合约是所有知识点的集大成者。它不仅是一个简单的记账本,更是一个具备完整逻辑闭环的迷你金融应用。让我们回顾一下它的核心流程:
-
权限管理 (Owner & Friends):
- 通过
constructor设立owner。 owner使用addFriend函数,通过onlyOwner修饰符保护,构建一个私有的朋友圈。- 所有核心功能都由
onlyRegistered修饰符保护,确保只有圈内好友可以操作。
- 通过
-
资金池管理 (Balance):
- 朋友们通过
payable的depositIntoWallet函数向合约存入ETH,资金由合约统一保管,个人余额记录在balances映射中。
- 朋友们通过
-
核心业务逻辑 (Debt):
recordDebt函数用于在链上记录一笔债务,它只修改debts这个嵌套映射,不移动任何资金。payFromWallet函数用于清偿债务。这是一个纯内部的账本操作:从债务人balances中扣款,加到债权人balances中,同时减少debts记录。整个过程没有ETH离开合约。
-
与外部交互 (Withdraw & Transfer):
withdraw和transferEtherViaCall函数是资金离开合约的出口。它们都遵循了“检查-生效-交互”的最佳实践:- 检查 (Checks): 验证权限、余额是否充足。
- 生效 (Effects): 先扣除内部
balances映射中的余额。 - 交互 (Interactions): 最后才使用
.call向外部地址发送ETH。
这个顺序至关重要,它能有效防止上文提到的重入攻击。
回顾:
- 我们从基础语法和数据结构起步。
- 学会了如何通过
modifier和require构建坚固的访问控制和业务逻辑。 - 掌握了使用
payable和call来处理真实ETH的核心技巧。 - 最终,通过综合实战,理解了如何将所有模块组合成一个逻辑自洽、遵循安全模式的去中心化应用。