---
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`