--- lang: zh-tw tags: Notes, Cryptocurrency date: 20220319 robots: noindex, nofollow license: GPL-3.0 --- 關於「smart contract 的函數回傳值」 === Introduction --- 建構 smart contract 的過程不只有實作合約函數內的邏輯,很多時候我們會希望透過呼叫一系列的函數,並在結束運行時回傳一個數值(return value),讓我們的其他程式能夠使用該值來接續整個工作流程[^1]。 假設我們寫了一個這樣的合約,那麼我們要怎麼做才能獲得 `get()` 和 `set()` 的回傳值呢? ```solidity=0.8.1 // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.1; contract Foo { uint256 public bar; function set(uint256 _value) public returns (uint256) { bar += _value; return bar; } function get() public view returns (uint256) { return bar; } } ``` Read-Only Method --- 由於 `get()` 函數是 view function,所以它不會造成合約的狀態(state)有任何改變;因此,我們能夠在不發出 transaction (txn)的情況下[^2],就促使 EVM 執行這個函數並取得相對應的回傳值。 使用以下 JavaScript 腳本,我們可以透過呼叫 `get()` 來直接印出 `bar` 數值。 ```javascript async function main() { /* Assume that contract Foo has been deployed. */ const contract = /* Ethers.js Contract object */; const signer = /* Ethers.js Signer object */; var txn; contract = contract.connect(signer); txn = await contract.get(); // Expected return: 0 console.log(JSON.stringify(txn, null, 2)); } ``` 在 terminal 印出數值為零[^3]的 BigNumber[^4] 物件 ```shell { "type": "BigNumber", "hex": "0x00" } ``` 真是相當地的輕鬆寫意,就如同一般 javascript 套件流程那樣呼叫函數、取得回傳值;然而,呼叫 `set()` 就沒有這麼容易了。 Write Method --- 熟悉 solidity 的讀者可以知道 `set()` 函數沒辦法加上 view 關鍵字,因為它會修改 state variable `bar` 數值,進而更改 contract state。對於這類的函數,我們沒辦法在不發出 txn 的情況下[^5],就完成 `set()` 函數的呼叫;不過 Ethers.js 並不是未提供發交易的 API,因此到目前為止仍然沒有特別困難。 使用以下 JavaScript 腳本,我們看看透過發出 txn 來呼叫 `set()` 會得到什麼東西呢? ```javascript async function main() { /* Assume that contract Foo has been deployed. */ const contract = /* Ethers.js Contract object */; const signer = /* Ethers.js Signer object */; var txn; contract = contract.connect(signer); txn = await contract.set(5); // Expected return: 5 txn = await txn.wait(); console.log(JSON.stringify(txn, null, 2)); } ``` 以下是在 terminal 可以看到的東西 ```shell { "to": "0xD014...", "from": "0xf39F...", "contractAddress": null, "transactionIndex": 0, "gasUsed": {...}, "logsBloom": "0x0000...", "blockHash": "0xa84e...", "transactionHash": "0xcb7e...", "logs": [], "blockNumber": 1391..., "confirmations": 1, "cumulativeGasUsed": {...}, "effectiveGasPrice": {...}, "status": 1, "type": 2, "byzantium": true, "events": [] } ``` 顯然印出的東西當中[^6]沒有發現任何和「5」有關的 property 存在,但是這並非 Ethers.js 套件的問題,也不是使用錯誤的 API 所導致。 為什麼? --- 首先,我們可以看看 Ethers.js 的說明[文件](https://docs.ethers.io/v5/api/contract/contract/#Contract--write)怎麼敘述這件事情。 > Write Methods (non-constant) > > A non-constant method requires a transaction to be signed... > ... > **It cannot return a result.** If a result is required, it should be logged using a Solidity event (or EVM log), which can then be queried from the transaction receipt. 說明文件直白地告訴我們呼叫 write method 就是不能獲得回傳值,這意味著本文開頭「想要取得回傳值」的這個想法,似乎不適用在 blockchain 開發環境當中。另外,我們也可以從 yellow paper[^7] 當中找到同樣的說法。 > In the case of executing a message call,... > ... > Aside from evaluating to... > ... > **This is ignored when executing transactions**, however message calls can be initiated due to VM-code execution and in this case this information is used. 這段文字清楚的敘述到我們看到的現象:已被礦工驗證完成且納入 block 當中的 txn 不會攜帶關於 write method 的回傳值,因為它被直接丟掉了;但是,合約之間的互相呼叫(message call)則會保留回傳值。 由於 blockchain 世界極為 asynchronous,此點可由 chain reorganisation 等現象輕易得知,因此開發者不應該預期發出 txn 之後,可以在固定的時間內(甚至無法預期時間長短)獲得 txn 已被納入 block 的訊息;除此之外,txn 也可能因為種種因素[^8],得到 reverted txn 或未被執行過,這樣的情況更不可能獲得回傳值。 基於 blockchain 這樣的特性,我們應該使用別種方式(就是 event)來記錄、取得函數運行過程當中,有興趣的關鍵變數的狀態。 使用 Event --- 在 solidity 說明[文件](https://docs.soliditylang.org/en/v0.8.13/structure-of-a-contract.html#events)當中是這麼敘述 event: > Events are convenience interfaces with the EVM logging facilities. 就像我們經常在 python 腳本使用 `print()` 或 `logging.info()`,在 javascript 腳本使用 `console.log()`,如果開發者希望在 contract 的執行過程中,EVM 可以給出一些 log 文字,那當然非使用 `event` 莫屬。特別注意: `event` 噴出的文字不能被 contract 在稍後存取使用,它只能由外部世界(Ethereum 以外的真實世界)的人爬梳、查詢。 應用這樣子的概念,我們把一開始的 `Foo` contract 作一點小小的修改 ```solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.1; contract FooV2 { uint256 public bar; event ChangeValue(uint256 new_bar); function set(uint256 _value) public returns (uint256) { bar += _value; emit ChangeValue(bar); return bar; } function get() public view returns (uint256) { return bar; } } ``` 同樣地,使用以下 JavaScript 腳本,我們看看這次能不能找到想要的數值呢? ```javascript async function main() { /* Assume that contract Foo has been deployed. */ const contract = /* Ethers.js Contract object */; const signer = /* Ethers.js Signer object */; var txn; contract = contract.connect(signer); txn = await contract.set(5); // Expected return: 5 txn = await txn.wait(); console.log(JSON.stringify(txn, null, 2)); } ``` 以下是在 terminal 可以看到的東西[^10] ```shell { "to": "0xD014...", "from": "0xf39F...", "contractAddress": null, "transactionIndex": 0, "gasUsed": {...}, "logsBloom": "0x0000...", "blockHash": ""0x25b0...", "transactionHash": "0xb242...", "logs": [ { "transactionIndex": 0, "blockNumber": 13914058, "transactionHash": "0xb2420...", "address": "0xD014...", "topics": [ "0x817b1c06cdeffca4116d87d0b3b928661475824ccecc5d542300e4f06d89f253" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000005", "logIndex": 0, "blockHash": "0x25b0..." } ], "blockNumber": 1391..., "confirmations": 1, "cumulativeGasUsed": {...}, "effectiveGasPrice": {...}, "status": 1, "type": 2, "byzantium": true, "events": [ { "transactionIndex": 0, "blockNumber": 1391..., "transactionHash": "0xb2420...", "address": "0xD014...", "topics": [ "0x817b1c06cdeffca4116d87d0b3b928661475824ccecc5d542300e4f06d89f253" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000005", "logIndex": 0, "blockHash": "0x25b0...", "args": [ { "type": "BigNumber", "hex": "0x05" } ], "event": "ChangeValue", "eventSignature": "ChangeValue(uint256)" } ] } ``` 哇啊,看起來比之前豐富許多!尤其在 `events.args` attribute 當中可以看到我們最想要得到的更新後數值![^11]。可能會有讀者好奇 `topics` 是什麼東西?這個詞就與 `indexed` 這個關鍵字有關聯了。 `topics`編號 0 (或使用 array 的觀點就是 index 0)的東西,永遠是 event signature 的 hash value。以這邊的例子來說,就是把「`ChangeValue(uint256)`」這串文字拿去給 keccak256 計算。 0. 假設 event 長這樣:`event ChangeValue(uint256 new_bar);` 1. 我們把 solidity code 裡面 event 的關鍵字、變數名稱都去掉 - 得到 `ChangeValue(uint256)` 2. 把得到的東西轉成 UTF-8 byte array 3. 丟給 keccak256 作雜湊計算 以下是使用 Ethers.js API 獲得 event signature 的方法[^14] ```javascript var name = ethers.utils.toUtf8Bytes("ChangeValue(uint256)"); var event_signature = ethers.utils.keccak256(name); console.log(event_signature); ``` `topics`編號 1~3 (或使用 array 的觀點就是 index 1~3)的東西,則是有添加 `indexed` 關鍵字的 arguments。`indexed` 最多只能有三個,但是 `event` 可以容納的總 arguments 則無限制。有添加 `indexed` 關鍵字的 arguments 會包含在 `topics` attribute 當中,沒有的則會通通包在 `events.data` attribute 當中,可能需要自行解碼(不過 Ethers.js 會盡量把能解碼的東西都解碼)。 特別注意:添加 `indexed` 關鍵字的 input argument 們,每個限制長度為 256 bits,**超過會被使用 keccak256 強制縮短**[^13];因此,`string` 型別的關鍵字應視情況[^12]避免加上 `indexed` 關鍵字。 假設我們把原本的 `event` 改寫成這樣 ```solidity // SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.1; contract FooV2 { uint256 public bar; event ChangeValue(uint256 indexed new_bar, string indexed log1, string log2); function set(uint256 _value) public returns (uint256) { bar += _value; emit ChangeValue(bar, "Hello World!", "Hello World!"); return bar; } function get() public view returns (uint256) { return bar; } } ``` 以下是在 terminal 可以看到的東西,大致上跟之前差不多 ```shell { ... "events": [ { "transactionIndex": 0, "blockNumber": 1391..., "transactionHash": "0x6d4a...", "address": "0xD014..", "topics": [ "0xaaef25bbee5e5fdfd20f17f7cfeeb90a41a514ef8b062ec99d35891429e7bdb6", "0x0000000000000000000000000000000000000000000000000000000000000005", "0x3ea2f1d0abf3fc66cf29eebb70cbd4e7fe762ef8a09bcc06c8edf641230afec0" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000c48656c6c6f20576f726c64210000000000000000000000000000000000000000", "logIndex": 0, "blockHash": "0x40b1...", "args": [ { "type": "BigNumber", "hex": "0x05" }, { "_isIndexed": true, "hash": "0x3ea2f1d0abf3fc66cf29eebb70cbd4e7fe762ef8a09bcc06c8edf641230afec0" }, "Hello World!" ], "event": "ChangeValue", "eventSignature": "ChangeValue(uint256,string,string)" } ] } ``` 我們可以發現 `"topics"` attribute 多了兩行東西:被添加 `indexed` 關鍵字的兩個 input arguments `new_bar` 和 `log1`。另外,我們也可以發現同樣都是塞 `Hello World!` 字串,但是有加 `indexed` 關鍵字 `log1` 只剩一串 256-bit hash value;反觀,另一個 `log2` 則能被解碼回 `Hello World!` 字串。 最後,event log 的功能不只是拿來給開發者看看 contract 運行過程當中發生了什麼事,它能拿來「追蹤」特定事件的發生,例如:追蹤特定 ERC-20 token 的 `Transfer()` 事件。如此一來再也不必針對所有新出現的 txn 一個一個剖析是否有我們感興趣的 ERC-20 token 被轉移,只需要尋找特定 event log 就好。 後續補充小筆記:https://discord.com/channels/938615061549842432/948252983492218880/1000058476401270784 查詢特定合約的 Event --- 承接前述內容,如果我們今天想要搜尋特定一個合約的特定一些 event,那要怎麼辦呢? 假設我們對 WrappedEther 合約特定幾個地址的轉帳行為感到有興趣,則只需要使用以下方法,就能針對特定 block number 區段撈取符合指定條件的 event。 ```javascript async function main() { /* Assume that contract Foo has been deployed. */ const contract = /* Ethers.js Contract object */; const from = 14680707; const to = 14687734; const address0 = "0xa69606e95a3358907bf861fd1602f3c49a267987"; // Randomly choosing const address1 = "0x1d88001a3661c7e2b886deceabdf67e4c8d89d4b"; // Randomly choosing const filter = { address: null, topics: [ [ // OR-Conditions of "Topics[0]" ethers.utils.id("Transfer(address,address,uint256)") ], [ // OR-Conditions of "Topics[1]" ethers.utils.hexZeroPad(address0, 32), ethers.utils.hexZeroPad(address1, 32) ] ] }; var results = await contract.queryFilter(filter, from, to); console.log(JSON.stringify(results, null, 2)); } ``` 以下是在 terminal 可以看到的東西 ```shell [ { "blockNumber": 14680707, "blockHash": "0x8ff1...", "transactionIndex": 132, "removed": false, "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "data": "0x0000000000000000000000000000000000000000000000000494654067e10000", "topics": [ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", "0x0000000000000000000000001d88001a3661c7e2b886deceabdf67e4c8d89d4b", "0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" ], "transactionHash": "0xf7e1...", "logIndex": 195 }, { "blockNumber": 14687289, ... }, { "blockNumber": 14687734, ... } ] ``` 各位讀者需要特別注意到以下幾點 1. 查詢特定 event 的過濾條件 `filter` 變數,只有 `address` 和 `topics` 兩個屬性;換句話說,在原本 solidity code 裡面,**未使用** `indexed` 關鍵字標註的參數皆**沒辦法**當成過濾條件。 2. 有些 Ethereum 節點供應商會限制查找的範圍或符合條件的結果總量,因此盡量以精準定義「過濾條件」為佳;當然,自架節點則無此限制。 3. `ethers.utils.id("")` 是用來求解某字串 KECCAK256 hash value 的[函數](https://docs.ethers.io/v5/api/utils/hashing/#utils-id),也可以使用 `ethers.utils.keccak256(ethers.utils.toUtf8Bytes(""))` 來表達,當然直接塞 "0xddf252..." 進去也是行的通。 4. 透過調整 `filter` 變數的 `topics` 屬性,可以得到不同結果;最外層陣列代表 Topics[0]、Topics[1]、Topics[2]、Topics[3] 過濾條件,次一層的陣列則是以「OR」運算串聯在一起的過濾條件,詳細可見這邊[說明](https://docs.ethers.io/v5/concepts/events/#events--filters)。 5. 同樣的 `filter` 變數也可以塞給 Ethers.js provider events [API](https://docs.ethers.io/v5/api/providers/provider/#Provider--event-methods)。 附錄:`callStatic` --- 雖然上面的介紹已經給出 yellow paper 的說明,原則上就是代表不能那樣子操做 write method,不過 Ethers.js 仍有提供一個「近似」的 API[^9] 給我們使用。 > *contract* . *callStatic* . **METHOD_NAME**( ...args [ , overrides ] ) ⇒ *Promise< any >* > > Rather than executing the state-change of a transaction, **it is possible to ask a node to pretend** that a call is not state-changing and return the result. > > **This does not actually change any state**, but is free. This in some cases can be used to determine if a transaction will fail or succeed. 類似 `contract.populateTransaction.METHOD_NAME()` 的概念,在 Ethers.js 當中使用此 API 的確可以獲得如同呼叫 read-only method 時的輕鬆寫意,但是這並不會發送任何 txn 給礦工;因此,只能稱得上「模擬」該筆 txn 的執行結果,相當可能隨著時間的遞移、Ethereum world state 的轉換,在給出相同 input arguments 卻獲得不同的結果。 ```javascript async function main() { /* Assume that contract Foo has been deployed. */ const contract = /* Ethers.js Contract object */; const signer = /* Ethers.js Signer object */; var txn; contract = contract.connect(signer); txn = await contract.callStatic.set(5); // Expected return: 5 console.log(JSON.stringify(txn, null, 2)); } ``` 以下是在 terminal 可以看到的東西 ```shell { "type": "BigNumber", "hex": "0x05" } ``` 另外,也許有讀者好奇 Remix 是怎麼做到 decoded output 功能的呢?由於筆者尚未釐清開發團隊的實作方法,因此無法詳述給讀者知道;然而,透過找到 yellow paper 當中的相關文字敘述,就有高度可能性亦為透過類似 `callStatic` 的機制實作出來;因此,筆者建議需要開發複雜合約的讀者,能夠善用熱門框架來進行開發,而非只使用 Remix 卻忽略了許多必須熟悉透徹的執行細節。 以下是 Remix txn 截圖(部屬相同合約並選擇 JavaScript VM (London)) <img src="https://hackmd.io/_uploads/rkTld8HE3.png" width="700"/> [^1]: 筆者使用 Ethers.js 套件,至於習慣用 web3.js 的讀者,應可從網路上搜尋到對應語法的文章 [^2]: Ethers.js 文件當中稱為 read-only method 或 constant method [^3]: Solidity 預設所有變數的初始化數值都是 binary representation 的零 [^4]: https://docs.ethers.io/v5/api/utils/bignumber/#BigNumber [^5]: Ethers.js 文件當中稱為 write method 或 non-constant method [^6]: 有省略部分欄位的數值 [^7]: https://ethereum.github.io/yellowpaper/paper.pdf 的「8. Message Call」段落 [^8]: 例如:gas price 太低、gas limit 給不夠、txn 包含 invalid message、使用重複 nonce 發出 txn、礦工的 mempool 遺失你的 txn 但你沒有重發送、、、 [^9]: https://docs.ethers.io/v5/api/contract/contract/#contract-callStatic [^10]: 有省略部分欄位的數值 [^11]: 眼尖的讀者可能會發現 `logs` attribute 也可以發現相同的東西,不過那是原始 txn 裡面尚未解碼的 event log;當然可以自行透過簡單的方法去解碼它,不過 Ethers.js 已經幫我們把解碼完的東西都包在 `events` 裡面囉,就不用這麼麻煩了 [^12]: Event log 越長所需的 gas 越貴,雖然 gas 不會到指數成長的那種可怕貴,不過以能省則省的角度來思考,仍然是盡量避免把長篇累牘的 `string` 塞進 event log [^13]: https://docs.soliditylang.org/en/v0.8.13/abi-spec.html#encoding-of-indexed-event-parameters [^14]: 求得 function selector 的方法也一樣,差別在 function selector 只取 keccak256 吐出的結果的前 4 bytes (32 bits);在 solidity 的語法則是 `<contract_instance>.<function_name>.selector`