一名門口的保鑣,與一本登記簿
想像一間會員制俱樂部。門口站著一名保鑣,他用一條規則檢查每個來客——你在名單上嗎?——不在的就一律擋下。裡頭有一本登記簿,記下每一件有意義的事:誰進來了、誰付了錢、誰被升為經理。保鑣決定誰可以行動;登記簿則是「做了什麼」這件事永久而公開的紀錄。
一份 Solidity 合約兩者都需要。到這裡,你已經會宣告狀態、寫函式、選擇它們的可見性;也會用映射與結構塑造資料。但一個誰都能呼叫、毫無檢查也毫無紀錄的開放函式,正是合約被掏空的途徑。本篇要補上這些護欄。四個工具:修飾器(可重複使用的保鑣)、require/revert(擋下壞的呼叫)、自訂錯誤(便宜地說明原因),以及事件(外界讀取的登記簿)。
修飾器:可重複使用的把關,與那個神奇的底線
函式修飾器是一小段程式碼,你可以把它掛到許多函式上,在函式本體之前(或之後)執行檢查。函式本體會被插入到你寫 `_;` 佔位符的地方。把它想成一層包裝:`_;` 之前的部分先跑,接著是真正的函式,然後才是 `_;` 之後的部分。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
address public owner;
constructor() {
owner = msg.sender; // whoever deploys becomes the owner
}
// The bouncer: run this check, then (at _;) the function body
modifier onlyOwner() {
require(msg.sender == owner, "Not the owner");
_;
}
uint256 public threshold;
// Reuse the same guard on any privileged function
function setThreshold(uint256 v) external onlyOwner {
threshold = v;
}
}有兩個細節很重要。第一,順序由左到右:`function f() onlyOwner whenNotPaused {}` 會先跑 `onlyOwner` 的檢查、再跑 `whenNotPaused` 的,最後才是本體——每個修飾器的 `_;` 會展開成下一個。第二,修飾器是語法糖:它會被內聯,所以一個用在十個函式上的修飾器,會把它的位元組碼複製十份。修飾器要小;若邏輯很大,就讓修飾器去呼叫一個 `internal` 函式,這樣被複製的只是一個極小的呼叫。
require、revert、assert:擋下壞的呼叫
在保鑣裡我們用了 `require`。當它的條件為假時,會觸發還原(revert):呼叫中止,至今所做的每一項狀態變更都會被回滾,未用完的 gas 退還給呼叫者(已燒掉的那部分不退)。還原是 EVM「全有或全無」的保證——一筆交易要嘛完全成功,要嘛讓整個世界維持它原本的樣子。
// require: validate inputs / external conditions (the common case)
require(amount > 0, "amount must be positive");
require(msg.sender == owner, "Not the owner");
// revert: identical effect, handy with branching or complex conditions
if (recipient == address(0)) {
revert("zero address");
}
// assert: check an invariant that should NEVER be false.
// A failing assert means a BUG in your code, not bad user input.
assert(totalSupply == sumOfAllBalances);`require` 與 `revert` 會丟出標準的 `Error(string)`。`assert` 不一樣:自 Solidity 0.8.0 起,失敗的 `assert`(或溢位、或除以零)會丟出帶有數字代碼的 `Panic(uint256)`,它一樣會還原並退還剩餘 gas。判斷準則是:**`require` 防的是外部世界;`assert` 記錄的是一個假設——它若為假,代表你自己有 bug。** 絕不要拿 `assert` 做一般的輸入驗證——panic 的意思是「這理論上不可能發生」。
自訂錯誤:說明原因,幾乎不花 gas
像 `"Not the owner"` 這樣的字串原因雖然友善卻昂貴:這些字面位元組會被烤進合約的位元組碼(你得付費部署它們),而且每次還原時都會被 ABI 編碼進回傳資料(執行時又得付一次)。自 Solidity 0.8.4 起,自訂錯誤解決了這點。你像宣告函式一樣宣告一個錯誤,然後 `revert` 它。
// Declared once, can carry typed data about what went wrong
error NotOwner(address caller);
error InsufficientBalance(uint256 requested, uint256 available);
function withdraw(uint256 amount) external {
if (msg.sender != owner) {
revert NotOwner(msg.sender);
}
uint256 bal = address(this).balance;
if (amount > bal) {
revert InsufficientBalance(amount, bal); // tells the caller the numbers
}
// ... proceed
}在傳輸層面,`revert NotOwner(0xAbc...)` 會編碼成 4 位元組選擇器 `bytes4(keccak256("NotOwner(address)"))`,後面接著 ABI 編碼的位址——和一次函式呼叫一模一樣。這正是它便宜的原因:位元組碼裡沒有字串,還原的負載也極小。額外好處是引數是結構化的:前端或稽核工具能把 `InsufficientBalance(100, 40)` 解碼出來、直接把真實數字顯示給使用者,而不必去解析英文句子。自 Solidity 0.8.26+ 起,你甚至可以寫 `require(cond, CustomError(args))`;在那之前,通用寫法是 `if (!cond) revert CustomError(args);`。
事件:鏈上的公開登記簿
修飾器與錯誤負責控制與擋下呼叫。事件做的是相反的工作:它宣告發生了某件事,好讓外界能做出反應。一個事件會把一筆紀錄寫進交易的日誌(log)——那是收據的一部分、卻不是合約儲存的特殊區域。日誌比儲存便宜得多,但帶著一條硬性規則:合約永遠無法讀取自己(或任何人)的日誌。 日誌純粹是給鏈下消費者用的——錢包、索引器與儀表板。
// Up to 3 parameters may be 'indexed' (searchable); the rest go in 'data'
event Transfer(address indexed from, address indexed to, uint256 value);
function _transfer(address from, address to, uint256 value) internal {
balanceOf[from] -= value;
balanceOf[to] += value;
emit Transfer(from, to, value); // writes a log entry, not storage
}底層上,EVM 有 `LOG0`–`LOG4` 這幾個操作碼。每筆日誌最多帶 4 個主題外加一個 data 區塊。對一個一般(非匿名)事件來說,主題 0 是固定的:它就是 `keccak256("Transfer(address,address,uint256)")`——事件簽章的雜湊。你標上 `indexed` 的每個參數會成為其餘主題之一——這正是讓用戶端能篩選「所有 to == 我的位址的 Transfer」、而不必下載整條鏈的關鍵。未加 indexed 的參數會被 ABI 編碼進 data 區塊:較便宜,但只能讀取、無法篩選。注意:對動態型別(string、bytes、陣列)加索引,存進主題的是它的 keccak 雜湊而非原值,所以你能比對它、卻無法還原它。
{
"jsonrpc": "2.0",
"method": "eth_getLogs",
"params": [{
"address": "0xTokenContractAddress",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
null,
"0x000000000000000000000000YourAddressPaddedTo32Bytes"
],
"fromBlock": "0x0",
"toBlock": "latest"
}],
"id": 1
}這正是區塊鏈瀏覽器顯示某代幣轉帳歷史的方式、錢包察覺你收到款項的方式,也是 The Graph 這類索引器建立可查詢資料庫的方式——全都靠透過 JSON-RPC 讀取日誌。由於儲存的讀取看不見日誌,正確的紀律是:合約邏輯需要的就存進儲存,盡量少;外界需要追蹤的,就用事件發出來。
把它們組起來:一份 Ownable 金庫
下面是一份小合約,它用上全部四個工具,方式就如真實程式碼一樣:用修飾器把關、用自訂錯誤做便宜且具型別的拒絕,並在每個特權動作上發出事件,好讓全世界都能稽核它。這正是 OpenZeppelin `Ownable` 的雛形——正式環境中最常見的存取控制基底。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Treasury {
address public owner;
uint256 public feeBps; // fee in basis points: 100 = 1%
// ---- Events: the public logbook ----
event OwnershipTransferred(address indexed from, address indexed to);
event FeeUpdated(uint256 oldFeeBps, uint256 newFeeBps);
// ---- Custom errors: cheap, typed revert reasons ----
error NotOwner(address caller);
error ZeroAddress();
error FeeTooHigh(uint256 requested, uint256 maxBps);
constructor() {
owner = msg.sender;
emit OwnershipTransferred(address(0), msg.sender); // mint of ownership
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner(msg.sender);
_;
}
function setFee(uint256 newFeeBps) external onlyOwner {
if (newFeeBps > 1000) revert FeeTooHigh(newFeeBps, 1000); // cap at 10%
emit FeeUpdated(feeBps, newFeeBps); // emit BEFORE overwriting state
feeBps = newFeeBps;
}
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
emit OwnershipTransferred(owner, newOwner); // owner is still the old one
owner = newOwner;
}
}追蹤一個非擁有者呼叫 `transferOwnership` 的情形:修飾器執行 `msg.sender != owner`、命中 `revert NotOwner(msg.sender)`,整個呼叫退回,什麼都沒變。當擁有者帶著一個真實位址呼叫時,零位址防護通過,`OwnershipTransferred(old, new)` 在 *`owner` 還是舊值時*就被記入日誌(所以日誌讀起來是對的),之後狀態才改變。鏈下任何盯著 `OwnershipTransferred` 的人,都會即刻得知控制權移轉了——不必去輪詢 `owner` 那個槽位。
幾處利刃,以及接下來
- 事件是告知性的,不是權威。任何合約都能發出簽章相同的 `Transfer` 事件,所以鏈下消費者必須以合約位址篩選,絕不能只因主題 0 相符就信任一筆日誌。
- 少了 `_;` 的修飾器會悄悄地讓函式永遠無法執行;有兩個 `_;` 的修飾器會把本體跑兩遍。這個佔位符是承重的——要刻意地寫對它。
- 能避免就別在修飾器裡放會改變狀態的副作用或外部呼叫——那會弄亂執行順序,可能引出還原或重入形狀的意外。修飾器只做純粹的檢查。
- 在設定擁有者/收款人的函式裡,永遠要檢查零位址。把擁有權交給 `address(0)`(且無法復原)是一個永久性的自傷——除非你是刻意要放棄擁有權。
這四個工具是安全 Solidity 的文法,但它們是積木,不是完工的防禦。單一 `owner` 是單點故障;真實系統需要以角色為基礎的存取控制(多種角色,而非一把鑰匙),也需要謹慎安排狀態變更與外部呼叫的順序以避免重入。那套排序紀律——檢查—生效—互動——加上提領式付款與可升級代理,正是本階的下一篇。再之後,你會寫出一份完整的 ERC-20:`Transfer` 與 `Approval` 事件、`onlyOwner` 式的把關,以及自訂錯誤,全部匯聚進一個整個生態系都讀得懂的標準裡。