部分內容與前文互補。
文章目錄
- 一個簡單的智能合約
- 子貨幣(Subcurrency)示例
- 區塊鏈基礎
- 交易
- 區塊
- 預編譯合約
一個簡單的智能合約
我們從一個基礎示例開始,該示例用于設置變量的值,并允許其他合約訪問它。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;contract SimpleStorage {uint storedData;function set(uint x) public {storedData = x;}function get() public view returns (uint) {return storedData;}
}
代碼的第一行表明,該源代碼采用 GPL 3.0 許可證進行授權。在以源代碼公開為默認規則的環境中,使用機器可讀的許可證標識符是非常重要的。
接下來一行 pragma solidity >=0.4.16 <0.9.0;
指定了該合約適用于 Solidity 0.4.16 及以上版本,但不包括 0.9.0。這是為了確保合約不會在未來的破壞性更新(Breaking Changes)中出現兼容性問題。Pragma 語句是編譯器的指令,類似于 C/C++ 語言中的 pragma once,用于指定源代碼的編譯方式。
在 Solidity 語言中,合約(contract) 本質上是一個代碼(函數)和數據(狀態)的集合,它們駐留在以太坊區塊鏈上的特定地址處。
contract SimpleStorage {uint storedData;function set(uint x) public {storedData = x;}function get() public view returns (uint) {return storedData;}
}
在合約 SimpleStorage 中,uint storedData;
聲明了一個狀態變量 storedData,其類型為 uint(無符號整數,默認為 256 位)。你可以把它看作數據庫中的一個單一存儲槽位,可以通過調用合約中的函數來查詢和修改它。在這個示例中,合約提供了 set 和 get 兩個函數,分別用于修改和獲取 storedData 的值。
在 Solidity 中,訪問當前合約的成員變量(如 storedData),通常無需使用 this. 前綴,直接使用變量名即可。這不僅僅是代碼風格的問題,而是影響訪問方式的關鍵區別(后續會詳細講解)。
這個合約本身功能還比較簡單,但得益于以太坊的基礎架構,它允許任何人存儲一個數值,并讓全球范圍內的任何人訪問。理論上,沒有任何方法可以阻止你發布這個數值。但需要注意,任何人都可以再次調用 set 方法,修改存儲的值,并覆蓋之前的數據。不過,之前存儲的數據仍然會保留在區塊鏈的歷史記錄中。
后續會介紹如何實現訪問權限控制,以便只有你自己才能修改這個值。
警告:使用 Unicode 文本時需要小心,因為一些看起來相似甚至完全相同的字符,可能具有不同的代碼點(Code Point),因此它們的字節編碼可能不同,從而引發安全或兼容性問題。
注意:所有標識符(包括合約名、函數名和變量名)都必須使用 ASCII 字符集。不過,你仍然可以在 string 類型的變量中存儲 UTF-8 編碼的數據。
子貨幣(Subcurrency)示例
以下合約實現了最簡單形式的加密貨幣。該合約僅允許其創建者鑄造新幣。任何人都可以在沒有用戶名和密碼的情況下相互轉賬,所需的只是一個以太坊密鑰對。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.26;// 該合約只能通過 IR 編譯
contract Coin {// 關鍵字 "public" 使變量可被其他合約訪問// 相當于所有人可見創建者的合約地址address public minter;mapping(address => uint) public balances;// 事件允許客戶端對你聲明的特定合約更改做出反應event Sent(address from, address to, uint amount);// 構造函數代碼僅在合約創建時運行constructor() {minter = msg.sender;}// 向指定地址鑄造一定數量的新幣// 僅合約創建者可以調用// 相當于只有合約創建者可以向別人發送新幣function mint(address receiver, uint amount) public {require(msg.sender == minter);balances[receiver] += amount;}// 錯誤(Errors)允許提供有關操作失敗原因的信息// 這些信息會返回給調用該函數的用戶error InsufficientBalance(uint requested, uint available);// 發送一定數量的現有幣// 任何人都可以調用,將代幣發送至指定地址function send(address receiver, uint amount) public {// 發送的數量必須小于等于自己擁有的數量require(amount <= balances[msg.sender], InsufficientBalance(amount, balances[msg.sender]));// 發送者減少balances[msg.sender] -= amount;// 接收者增加balances[receiver] += amount;emit Sent(msg.sender, receiver, amount);}
}
代碼 address public minter;
聲明了一個 address 類型的狀態變量。
address 類型是一個 160 位的值,不允許執行任何算術運算。它適用于存儲合約地址,或者存儲外部賬戶(EOA)公鑰哈希的一部分。
關鍵字 public 會自動生成一個函數,使外部可以訪問當前合約的狀態變量。如果沒有 public,其他合約將無法訪問該變量。
編譯器生成的代碼等效于以下函數(暫時忽略 external 和 view 關鍵字):
function minter() external view returns (address) { return minter;
}
下一行代碼:
mapping(address => uint) public balances;
這行代碼同樣定義了一個 public 狀態變量,但它的類型比 address 更復雜。mapping 是 Solidity 提供的一種映射類型,它將地址映射到 uint(無符號整數),即每個地址對應一個余額。
mapping 的特性:
-
mapping 類似于哈希表,所有可能的鍵在初始化時就已經存在,并默認映射到 0(即字節表示全為零)。
-
無法獲取 mapping 的所有鍵或所有值,因此如果你需要跟蹤存儲在 mapping 中的數據,最好自己維護一個列表,或者使用更合適的數據結構。
使用 mapping 是因為它提供了一種高效且簡潔的方式來關聯每個地址與其余額,且適應了區塊鏈中分布式賬本的特點。
由于 balances 變量是 public,編譯器會自動生成以下 getter 函數:
function balances(address account) external view returns (uint) {return balances[account];
}
這個函數可以用于查詢某個賬戶的余額,例如:
uint myBalance = contract.balances(myAddress);
這樣,你就可以直接在外部訪問某個地址的 balance,而無需手動編寫 getter 方法。
這一行代碼:
event Sent(address from, address to, uint amount);
聲明了一個 事件(event),它在 send 函數的最后一行被觸發(emit)。像 Web 應用程序這樣的以太坊客戶端可以監聽這些事件,而不會產生太多成本。
當事件被觸發后,監聽器會立即收到 from、to 和 amount 這三個參數,從而能夠跟蹤交易。
剛才提到的以太坊客戶端使用以下 JavaScript 代碼(web3.js)監聽 Sent 事件,并調用 balances 函數來更新用戶界面:
Coin.Sent().watch({}, '', function(error, result) {if (!error) {console.log("Coin transfer: " + result.args.amount +" coins were sent from " + result.args.from +" to " + result.args.to + ".");console.log("Balances now:\n" +"Sender: " + Coin.balances.call(result.args.from) +"Receiver: " + Coin.balances.call(result.args.to));}
});
構造函數是一種特殊的函數,在合約創建時執行,且無法在之后被調用。
在這個合約中,構造函數會永久存儲創建合約的人的地址:
constructor() {minter = msg.sender;
}
其中,msg 是 Solidity 提供的全局變量,它包含了一些區塊鏈相關的屬性,比如msg.sender為當前調用該函數的外部賬戶(EOA)或合約地址。
這個合約有兩個主要的用戶調用函數:
-
mint —— 鑄造新幣
-
send —— 發送已存在的幣
mint(鑄造新幣)
function mint(address receiver, uint amount) public {require(msg.sender == minter);balances[receiver] += amount;
}
只有合約的創建者(minter)可以調用 mint,因為:
require(msg.sender == minter);
如果 msg.sender 不是 minter,則交易會被回滾(revert)。
balances[receiver] += amount; 為接收者賬戶增加一定數量的新幣。
注意: 雖然 minter 可以無限制鑄造代幣,但如果 balances[receiver] + amount 超過 uint 類型的最大值 2的256次方 - 1,就會導致溢出(overflow)。然而,Solidity 默認啟用了 Checked arithmetic(溢出檢查),所以如果溢出發生,交易會自動回滾。
send(發送幣)
function send(address receiver, uint amount) public {require(amount <= balances[msg.sender], InsufficientBalance(amount, balances[msg.sender]));balances[msg.sender] -= amount;balances[receiver] += amount;emit Sent(msg.sender, receiver, amount);
}
任何人(已經擁有幣的人)都可以調用 send,將幣發送給其他人。
如果 msg.sender 的余額不足:
require(amount <= balances[msg.sender], InsufficientBalance(amount, balances[msg.sender]));
交易會回滾(revert),并返回 InsufficientBalance 錯誤,錯誤信息會提供給調用者,以便前端應用或區塊瀏覽器能夠顯示失敗的具體原因。
Solidity 允許在交易失敗時提供更多的錯誤信息,以便前端應用可以更容易地調試或做出反應。
錯誤信息通過 revert 語句觸發:
error InsufficientBalance(uint requested, uint available);
當 require 失敗時,它會返回 InsufficientBalance,并提供請求的金額 requested 和可用余額 available。
注意,在這個例子中,所有的代幣操作(如鑄造、轉賬)都在合約內部完成,余額和交易信息是局部的,僅存儲在合約的 balances 映射中。
普通區塊鏈瀏覽器(如 Etherscan)只能顯示以太坊全局賬戶余額,你不會在普通的區塊瀏覽器中看到余額變化。
解決方案:監聽 Sent 事件,并創建自己的區塊鏈瀏覽器來跟蹤交易記錄和余額變化,但你查詢合約地址(通過合約內部的查詢函數),而不是代幣持有人的地址。
區塊鏈基礎
區塊鏈作為一個概念對于程序員來說并不難理解。大多數復雜性(如哈希、橢圓曲線加密、對等網絡等)只是為了為平臺提供一組特定的功能和承諾。一旦你接受了這些特性作為前提,你就不必擔心底層技術——就像你不需要知道亞馬遜的 AWS 是如何在內部工作的。
交易
區塊鏈是一個全球共享的事務性數據庫。這意味著每個人都可以通過參與網絡來讀取數據庫中的條目。如果你想更改數據庫中的內容,你必須創建一個所謂的“交易”,并且這個交易必須被所有其他參與者接受。
“交易”一詞意味著你想要進行的更改(假設你同時想更改兩個值)要么完全不做,要么完全應用。此外,在你的交易被應用到數據庫時,其他交易不能修改它。
例如,假設有一個表格列出了所有賬戶的余額。如果請求從一個賬戶轉賬到另一個賬戶,數據庫的事務性特征確保如果從一個賬戶扣除金額,這個金額始終會被加到另一個賬戶上。如果由于某種原因無法將金額添加到目標賬戶,源賬戶也不會被修改。
此外,交易總是由發送者(創建者)進行加密簽名。這使得保護對數據庫特定修改的訪問變得簡單。舉個例子,只有持有賬戶密鑰的人可以從中轉移一定的貨幣。
區塊
需要克服的一個主要問題是雙重支付攻擊:“如果在網絡中有兩個交易都想清空一個賬戶,該怎么辦?”
解決方案是:只有其中一個交易可以是有效的,通常是先被接受的那個。
問題在于,“先”在對等網絡中并不是一個客觀的術語。
對此的抽象回答是:你不需要擔心。一個全球公認的交易順序會為你選定,從而解決沖突。這些交易會被打包成一個叫做“區塊”的內容,然后被執行并在所有參與節點之間分發。如果兩個交易互相矛盾,第二個交易會被拒絕,并不會成為區塊的一部分。
這些區塊形成了一個線性時間序列,這也是“區塊鏈”這一術語的來源。區塊會在定期的間隔時間內添加到鏈中,盡管這些間隔時間將來可能會發生變化。為了獲取最新的信息,建議監控網絡,例如通過 Etherscan。
可能會發生區塊偶爾被回滾的情況,但僅限于“鏈頂”部分。這是因為越多的區塊添加到某個區塊上時,這個區塊被回滾的可能性就越小。所以,可能會出現你的交易被回滾甚至從區塊鏈中移除的情況,但等待的時間越長,這種情況發生的可能性就越小。
注意
交易并不能保證會包含在下一個區塊或任何特定的未來區塊中,因為是否將交易包含在區塊中并不是由交易提交者決定的,而是由礦工決定交易被包含在哪個區塊中。
如果我們想安排未來的智能合約調用,可以使用智能合約自動化工具(比如定時觸發某個操作,或者基于某個事件觸發合約的函數調用)或預言機服務。
預編譯合約
在以太坊中,智能合約通常用 Solidity 編寫,并轉換為 EVM 字節碼執行。但一些計算(例如橢圓曲線加密、哈希計算)如果用 Solidity 實現,會消耗大量 Gas,甚至無法在區塊 Gas 限制內完成。因此,以太坊提供了一組內置的預編譯合約。
地址范圍 0x01 到 0x0a(包含 0x0a) 屬于預編譯合約(Precompiled Contracts)。這些合約可以像普通合約一樣被調用,但它們的行為(包括 Gas 消耗)并不是由存儲在這些地址上的 EVM 代碼決定的。這些合約直接在 EVM 層面執行,比普通智能合約運行更高效,并且Gas 消耗更少。
這些合約特別適用于密碼學、哈希計算、零知識證明等高計算量的任務。