Foundry 進階指南:如何精準測量 Gas 消耗?
2026-03-2809:31
Taipei Ethereum Meetup
2026-03-28 09:31

本篇重點 (Takeaways)

這篇文章將深入探討 Foundry 開發框架的運作機制如何影響 Gas 消耗的計算,以及為什麼 Foundry 本地測試的 Gas 消耗與鏈上結果會存在誤差。

讀完後你將掌握:

  1. 一筆交易的 Gas 消耗如何計算?
  2. 「本地測試」「鏈上執行」的誤差從何而來?
  3. 如何在「本地測試」獲取與「鏈上執行」相同的 Gas 消耗?
  4. 什麼是「Isolation Mode」?
  5. 如何運用 snapshotGasLastCall cheatcode 優雅地測量 Gas?

背景介紹

對於合約開發者而言,Gas 優化一直是開發週期中無法忽視的核心課題。

如果把智能合約的執行比喻為開車出門,Gas Price 就像是每公升的油價,而 Gas Usage 則是這趟行程實際耗費的油量。兩者相乘,才是交易執行最終所需的礦工費(Transaction Fee)。身為開發者,我們無法控制浮動的油價,但我們能控制車子的「油耗」 — — 也就是如何透過合約優化來降低 Gas 消耗。

然而,「如何在本地環境精準測量 Gas 消耗」本身就是一個複雜的問題,且測量結果深受開發框架的影響。以 Foundry 為例,受限於框架執行測試的方式,在某些情況下測試結果不僅會偏離鏈上的實際值,甚至可能會讓開發者誤判 Gas 優化的有效性。

為了釐清 Foundry 本地測試的誤差來源,本文將從三個範例合約出發,解析 Gas 消耗的構成與計算方式;接著比較三種常見的測量方法,說明哪些工具能提供真實的參考數據;最後,我們將討論如何使用 Isolation Mode 進行精準測量,並在開發流程中運用 snapshotGasLastCall cheatcode 建立一套便於追蹤 Gas 變化的管理系統。

範例合約介紹

在開始測量方法的比較之前,我們準備以下三組合約作為測試對象。

//SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

// Example 1
contract DoNothing {
function doNothing() external payable {}
}

// Example 2
contract SetToZero {
uint256 num = 1;

function setToZero() external payable {
num = 0;
}
}

// Example 3
contract SetNumber {
uint256 num = 1;

function setNumber(uint256 _num) external payable {
num = _num;
}
}

我們將上述三組合約部署至 Sepolia 測試鏈,並將其鏈上執行後的 Gas 消耗紀錄到下表中,作為後續評估本地工具準確性的「真實值」(Ground Truth)

💡 本文使用的範例合約、測試合約及詳細的測試數據,皆可在此 Repo 中找到。

本地 Gas 測量工具介紹

在本章節中,我們將實際測試以下三種在 Foundry 中常見的測量工具 / 方式,並將測試結果與鏈上實際值進行對比:

  1. Solidity 內建語法: gasleft()
  2. forge test CLI 指令
  3. forge test --isolate CLI 指令

本章節會先聚焦於 Foundry 中常見 Gas 測量工具的實測結果,並將其與鏈上實際交易的 Gas 消耗進行對比。討論重點僅限於各工具測量結果與鏈上實際值之間的落差,以便讀者能直觀觀察這些方法在實務上的可靠性。

需要注意的是,本章不會深入探討造成落差的具體原因。相關分析將在後續完整拆解一筆 EVM 交易的 Gas 消耗結構之後,再回過頭進行說明。

方法一:使用 `gasleft()` 測量 Gas

gasleft() 是 Solidity 原生提供的語法,用於取得當前執行環境中剩餘可用的 Gas 量。常見測量 Gas 消耗的方式是在目標程式碼片段執行前、後各呼叫一次 gasleft(),並計算兩者差值作為該段程式的 Gas 消耗。

在 Foundry 測試中,我們可以直接在測試函式內將目標函數的呼叫用 gasleft()「包起來」以推算目標函數的 Gas 消耗:

測試合約

function test_gasLeft_doNothing() public {
uint256 begin = gasleft();
doNothingC.doNothing();
uint256 end = gasleft();

emit log_named_uint("gas usage", begin - end);
}

function test_gasLeft_setToZero() public {
uint256 begin = gasleft();
setToZeroC.setToZero();
uint256 end = gasleft();

emit log_named_uint("gas usage", begin - end);
}

function test_gasLeft_setNumber() public {
uint256 begin = gasleft();
setNumberC.setNumber(0);
uint256 end = gasleft();

emit log_named_uint("gas usage", begin - end);
}
「測量結果」「實際消耗」的差距來看,三個案例間的差距完全不成比例。這代表 gasleft() 不僅無法提供正確數值,也無法提供可靠的相對差異,因此在測試中幾乎沒有參考價值。

從字面上來看,我們本來就不期待透過 gasleft() 得出目標函數精準的 Gas 消耗,因為在計算過程中會參雜很多不必要的計算,例如:gasleft() 本身會消耗掉 2 gas、用 beginend 變數紀錄當前 gasleft() 及計算 beginend 兩者間的差都會額外消耗掉一些 gas。

但照理而言,即使它無法提供正確的絕對值,只要所有測試案例都使用相同方式量測,多出的額外消耗應該是固定且相同的,測量結果仍應具備一定的參考價值,不過我們從實測結果看來並非如此。

方法二:使用 `forge test` CLI 指令

另一個觀察 Gas 消耗的方式,是利用 Foundry 強大的 Trace 功能:

forge test --mt test_gasLeft -vvvv

在 Trace 的輸出結果中,我們可以直接看到目標函式名稱旁的中括號 [],裡面標註了該次呼叫所對應的 Gas 消耗值:

我們將 Trace 中記錄的數據整理至下表,並與 Sepolia 鏈上的真實數據對比如下:

觀察上表可以發現,即便我們在 forge test 的輸出結果中,已經將焦點鎖定在目標函式本身的 Gas 消耗,所得數據依然無法準確反映鏈上的真實消耗。這意味著在一般預設情況下,Foundry 執行測試所得的 Gas 數值,對於精準的優化判斷並不具備參考價值。

方法三:使用 `forge test --isolate` CLI 指令

我們同樣透過 forge test 來觀察 Gas 消耗,但這次我們加上 --isolate 參數:

forge test --mt test_gasLeft -vvvv --isolate

從表格結果可以看出,加上 --isolate 參數後,透過 Foundry 執行測試所得的目標函式 Gas 消耗,與鏈上真實數據完全一致。

在深入了解 --isolate 參數如何改變 Foundry 本地測試的 Gas 消耗計算之前,我們需要先掌握一筆鏈上交易的 Gas 消耗究竟是如何構成的。

分析鏈上交易的 Gas 消耗

從前面的實測結果可以看到,即便使用相同的範例合約,不同的測量工具給出的數據仍有顯著差異。為了理解這些落差從何而來,我們必須先釐清:鏈上的 Gas 消耗究竟是如何計算的?

若要詳細觀察交易執行過程中每一條 Opcode 的 Gas 花費,我們可以使用 Foundry 提供的 cast run 指令協助分析。

分析工具:cast run 指令

cast run 能將鏈上已完成的交易在本地環境中「完整重播」,並逐步呈現執行過程:

cast run <TX_HASH> -t -r <URL>

其中-t 表示啟用交易執行的 trace 模式

透過 cast run,我們能精準觀察到一筆交易 Opcode 的執行順序、每個指令消耗的 Gas、Stack 的變化,以及最重要的——當前剩餘的 Gas

範例一:doNothing()

我們首先分析最簡單的 doNothing() 交易:

cast run 0x5a121e8aa259b527f7d209fb073cc9aa6b20f638ee9f35c5e1d3ede53b8e98e4 -t -r $SEPOLIA_RPC_URL
💡 請把 $SEPOLIA_RPC_URL 參數換成自己的 Sepolia RPC URL

cast run 的輸出結果中,有幾個關鍵欄位值得特別注意:

  1. gas:指令執行「前」剩餘的 Gas。這個數字會隨著每條 Opcode 的執行持續遞減,因此透過觀察 gas 欄位的變化,就能計算出 Opcode 的實際 Gas 消耗量。
  2. OPCODE:當前執行的 EVM 指令(如 PUSH1, SSTORE)。
  3. refund:當前累積的 Gas 返還數值。部分 Opcode 在特定條件下會退回 Gas 作為「獎勵」,而這欄位則負責記錄交易執行過程已累計的 Gas 返還值(後續在範例二會再進一步說明)。

從輸出結果可以看到,第一條 Opcode PUSH1 執行前剩餘 Gas 是 10,925,而最後一條 Opcode STOP 執行前剩餘 Gas 則是 10,852。兩者相差 73,代表執行doNothing() 函式過程中,所有 Opcode 合計消耗 73 Gas

💡 由於 STOP Opcode 本身不消耗 Gas,因此執行 STOP 前顯示的剩餘 Gas 等同於整筆交易完
成後剩餘的 Gas。

然而,這與鏈上實際消耗的 21,160 Gas 相差甚遠。除此之外,你可能也注意到另一個疑問:我們給這筆交易可用的 Gas 上限(Gas Limit)設為 31,989,但為何在執行第一條 Opcode 時僅剩下 10,925 Gas

這中間的巨大差異是如何產生的?要解釋這些疑問,就必須談到 Gas 消耗中一個非常重要的概念:Intrinsic Gas

Intrinsic Gas

顧名思義,Intrinsic Gas 指的是一筆交易「固有的 」Gas 成本,可以將它類比為計程車的起步價 — — 不論目的地遠近,只要上車就必須支付的固定費用。在 EVM 的世界中也是如此:不論交易實際執行過程中會涉及哪些 Opcode,每一筆交易在開始執行前,都必須先支付一筆固定的 Gas 開銷

以下是以太坊黃皮書中對 Intrinsic Gas 的原文定義:

Intrinsic Gas: the amount of gas this transaction requires to be paid prior to execution

換句話說,Intrinsic Gas 指的是一筆交易在正式開始執行之前,就必須預先支付的 Gas 成本。這部分的 Gas 與合約內部實際執行的 opcode 無關,而是針對「交易本身」所收取的必要費用。

在 EVM 中,下列項目都屬於 Intrinsic Gas 的組成部分:

  • 基本交易成本(Base Transaction Cost):固定收取 21,000 Gas
  • 交易資料(calldata)成本:分為 (1) 每個零值位元組(例如 0x00):4 Gas (2)每個非零值位元組(例如 0x01):16 Gas
  • 合約創建成本:固定收取 32,000 Gas
此外,Type1 與 Type2 類型的交易可能還會包含額外的 Intrinsic Gas 成本。不過這類交易相對少見,也屬於較進階的主題,因此本文不進一步展開說明。

Intrinsic Gas 的計算可用下列方式來表達:

intrinsic_gas = 21000

// contract creation
if txn.to == null:
intrinsic_gas += 32000

intrinsic gas += 4 * the number of zero bytes in msg.data
intrinsic gas += 16 * the number of non-zero bytes in msg.data.

有了上述 Intrinsic Gas 的計算規則後,我們就可以回到範例一:doNothing(),實際計算這筆交易的 Gas 消耗。

首先來看 Intrinsic Gas 的部分:

  • 基本交易成本21,000
  • 交易資料(calldata)成本doNothing() 的 calldata 為 0x2f576f20,總共包含 4 個非零位元組,因此 calldata 成本為 16 × 4 = 64

接著是 Execution Gas,也就是所有 Opcode 的執行成本。根據前面 cast run 的分析結果,doNothing() 中的 Execution Gas 73 Gas

因此,範例一 doNothing() 目前的 Gas 總消耗為:21,000+64+73=21,137

這個數字已經很接近鏈上實際消耗的 21,160 Gas。事實上,在 Pectra 升級之前,Gas 消耗的計算到這裡就已經結束了,但在 Pectra 升級後,以太坊引入了 EIP-7623,進一步調整了與 calldata 相關的 Gas 計算方式,使得實際鏈上觀察到的 Gas 消耗高於上述結果。

接下來,我們就以範例一為例,說明 EIP-7623 是如何影響最終的 Gas 消耗。

EIP-7623: Increase calldata cost

在去年五月的 Pectra 升級後,以太坊引入了 EIP-7623,針對交易資料(calldata)的 Gas 計算方式進行了調整。其核心改動在於:提高 calldata 的最低 Gas 成本,避免大量 calldata、但幾乎不執行 Opcode 的交易佔用過多區塊資源。

在引入 EIP-7623 後,交易最終的 Gas 消耗計算方式如下:

tx.gasUsed = (
21000 +
max(
STANDARD_TOKEN_COST * tokens_in_calldata +
execution_gas_used +
isContractCreation * (32000 + INITCODE_WORD_COST * words(calldata)),
TOTAL_COST_FLOOR_PER_TOKEN * tokens_in_calldata
)
)

與先前的計算方式相比,EIP-7623 的關鍵在於引入了 max() 函式,用來動態決定 calldata 相關 Gas 成本的下限。

也就是說,會在以下兩者之間取較大的值:

  1. 傳統的 Intrinsic Gas + Execution Gas(排除 21,000 的基本交易成本)
  2. 由 calldata 數量所計算出的最低 Gas floor

其中相關參數定義如下:

  • TOTAL_COST_FLOOR_PER_TOKEN = 10
  • tokens_in_calldata = zero_bytes_in_calldata + nonzero_bytes_in_calldata × 4

接續對範例一的 Gas 消耗計算,在引入 EIP-7623 後,其 gas 消耗計算公式如下:21,000+max(64+73+0, 10*(0+4*4))=21,160

到這裡為止,我們已經能完整推導出 範例一 的 Gas 消耗是如何一步一步計算而來的。事實上,大多數交易的 Gas 計算流程與範例一並沒有本質上的差異。

不過,還有一個在範例一中尚未涵蓋的情況。回顧先前使用 cast run 分析交易執行流程時,我們曾注意到輸出結果中有一個名為 refund 的欄位。這個欄位所代表的 Gas 回退(Gas refund)機制,同樣會影響交易最終顯示的 Gas 消耗。

接下來,我們將透過 範例二,專注說明 Gas refund 是如何運作的,以及它會如何改變一筆交易最終的 Gas 使用量。

範例二:setToZero()

cast run 0x470947c429a338c2f7460b3c08f05a8f0931437522bace94c2d072e5118b7016 -t -r $SEPOLIA_RPC_URL

首先,我們同樣可以從 cast run 的輸出結果,計算範例二中所有 Opcode 的實際 Gas 消耗。做法與範例一相同:將第一條 Opcode PUSH1 執行前的剩餘 Gas(10,165),減去最後一條 Opcode STOP 執行前的剩餘 Gas(5,075),即可得到所有 Opcode 合計消耗的 Gas 為 5,090

這個數值也能直接從 trace 輸出中的 [] 框框中得知。

接著,將 Intrinsic Gas 一併納入計算:

  • 基本交易成本:21,000
  • calldata 成本:16 × 4 = 64

因此,可得出範例二當前的 Gas 消耗為 21,000+64+5,090=26,154。然而,從 cast run 的輸出結果可以看到,這筆交易最終顯示的 Gas 消耗卻只有 21,354 Gas,明顯低於上述計算結果。這是因為在目前的計算過程中,我們尚未考慮到 Gas 回退(Gas Refund)機制

Gas Refund

在目前的 EVM 版本(Fusaka)中,只有 SSTORE Opcode 能夠觸發 Gas 回退。當交易在執行過程中滿足特定條件時,部分已消耗的 Gas 會在交易成功執行完成後被退回。

注意Gas 回退並不是在交易執行過程中即時發生,而是在交易成功之後才會觸發。因此即使範例二最終顯示只消耗 21,354 Gas,若我們在送出交易時僅提供 21,354 Gas,交易仍會因為在執行途中 Gas 不足而失敗,進而無法取得任何退款。

SSTORE 在執行過程中會退回多少 Gas,與當前儲存槽(Storage Slot)原始值original_value)、當前值current_value)、即將寫入的值value)之間的關係有關,其規則表示如下:

if value != current_value
if current_value == original_value
if original_value != 0 and value == 0
gas_refunds += 4800
else
if original_value != 0
if current_value == 0
gas_refunds -= 4800
else if value == 0
gas_refunds += 4800
if value == original_value
if original_value == 0
gas_refunds += 20000 - 100
else
gas_refunds += 5000 - 2100 - 100
EVM 在執行過程中會用一個 gas_refunds 變數來計算累計可退回的 Gas,等交易執行成功後再把這些值算進最終的 gasUsed

以範例二為例,合約中的儲存槽 num,在部署時被初始化為 1。接著,我們呼叫 setToZero(),將該儲存槽的值從 1改為 0

在這個執行過程中,SSTORE 所對應的三個值分別為:

  • original_value 為 1
  • current_value1 (在將 num 修改為 0 之前為 1,修改為 0 之後則為 0)
  • value 為 0

根據 SSTORE Gas 回退規則,範例二在執行過程中會累積 4,800 的 Refund Gas。這也與 cast run 輸出結果中觀察到的 refund 數值完全一致。

綜合以上,我們現在已經能夠完整計算出範例二在鏈上實際消耗的 Gas。將 Opcode 執行成本、Intrinsic Gas、Gas Refund,帶入 EIP-7623 的計算公式後得到最終 Gas 消耗為:21000+max(64+5090-4800, 10*(0+4*4))=21,354

範例三:setNumber(uint256)

cast run 0xe2953525c4b5036763fb4d41f653efe48a57b87d1fe5704963f10172582f3027 -t -r $SEPOLIA_RPC_URL

範例三與範例二本質上是相同的情境,只是改為透過 setNumber(uint256) 傳入參數 0,將儲存槽的值寫為零值。由於涉及的 opcode、Intrinsic Gas、Gas Refund,以及 EIP-7623 的計算邏輯皆與範例二一致,實際的 Gas 消耗推導方式也完全雷同。

因此,這裡不再逐步展開計算,讀者可以參考前一個範例,嘗試自行推導範例三的 Gas 消耗,作為一次練習。

測量工具為何有時會失效?

在分析完三個範例的鏈上 Gas 消耗是如何組成之後,我們先回顧一下先前的測量數據:

細心的讀者可能會發現,方法二的測量結果,其實剛好對應了各自範例的 Execution Gas。這意味著方法二在測量過程中,僅計算了 Opcode 的執行成本,而未包含 Intrinsic GasRefund Gas

同樣地,方法一雖然因測量本身的開銷導致結果略高於方法二,但從數據結構來看,它同樣缺少了 Intrinsic GasRefund Gas,而這兩部分都有一個共同的特性:

它們並非是在交易執行的過程中計算,而是分別在交易開始前與交易結束後才會被計入。

那麼,為什麼 Foundry 在執行測試時,無法捕捉到這兩部分的 Gas 消耗?這與 Foundry 測試框架的底層運作方式有關。

由於 Foundry 的測試函式本身是以 Solidity 撰寫,這代表「測試程式碼」「被測試對象」實際上是在同一個 EVM Context(執行環境) 中運行。換句話說,當被測試對象開始執行時,測試函式早已在運行中,因此測試函式無法觀測到在交易開始執行前就已扣除的 Intrinsic Gas;同樣地,交易結束後才會結算的 Gas Refund,也因為測試函式仍在同一環境內而無法捕捉。

相較之下,Hardhat 的測試是以 JavaScript/TypeScript 撰寫。測試程式碼本身並不在 EVM Context 下執行,而是透過發送交易的方式呼叫合約。這種運行方式讓測試程式能夠掌握被測對象送入 EVM 執行的前、中、後三個階段,不會因為測試程式與合約共享同一個 EVM 執行環境,而導致無法觀測到交易前後完整的 Gas 消耗情況。

何謂 Isolation Mode?

為了解決上述因測試架構而導致無法觀測完整 Gas 消耗的問題,Foundry 提供了 Isolation Mode(隔離模式)

顧名思義,這個模式的核心機制在於「隔離」。當我們在測試中啟用 Isolation Mode 後,Foundry 不再將該次合約呼叫視為當前測試環境(Context)中的一部分,而是將其模擬為一筆完整且獨立的外部交易。

在一般的測試環境下,測試函式在測試合約中被呼叫屬於「合約對合約」內部呼叫(Internal Call),因此無法觸發交易層級的計費邏輯。但在 Isolation Mode 下,被測試對象的呼叫具備了完整的交易生命週期。

這使得 EVM 能夠如同處理真實鏈上交易一般,執行以下標準流程:

  1. 在執行前,先計算並扣除 Intrinsic Gas
  2. 執行合約邏輯,計算 Execution Gas
  3. 在執行結束後,結算 Gas Refund

正是因為 Isolation Mode 將測試呼叫從共用的 EVM Context 中抽離出來,使其行為符合標準交易的執行路徑,我們才能在測試報告中得到包含 Intrinsic GasRefund Gas 在內的精準數據,從而與鏈上真實消耗完全吻合。

示意圖

測試用作弊碼:snapshotGasLastCall

雖然 Foundry 的 Isolation Mode 解決了 Gas 消耗測試不準確的問題,但在進行合約優化時,若單靠人工比對 Gas Report 上的數字,不僅效率低落,也無法透過 Git 有效追蹤每次進行 Gas 優化後造成的具體差異。

為了解決此一痛點,我們可以使用 Foundry 作弊碼:snapshotGasLastCall。

此作弊碼能精確捕捉「上一次外部合約呼叫」的 Gas 消耗,並將其記錄至快照資料夾下,讓開發者能將 Gas 數據納入版本控制,直觀地監測每一版本的優化成果。

接下來,我們將透過範例一來示範如何使用 snapshotGasLastCall 協助我們追蹤 Gas 優化的成果。

1. 配置設定

首先,我們需要在 foundry.toml 中加上 isolate = true,以確保在本地執行測試時能精準捕捉完整的 Gas 消耗。

2. 插入作弊碼

接著,在想要測量的目標函式呼叫後,使用 snapshotGasLastCall

snapshotGasLastCall 接受兩個參數:

  • 第一個參數決定 JSON 檔案的名稱
  • 第二個參數則作為該筆數據的 Key 值

3. 執行測試

執行測試後,Foundry 會在 snapshots 資料夾下生成對應的檔案(例如 doNothingC.json)。在檔案中,我們可以看到 Key 值正是我們指定的 doNothing() function,以及其對應的 Gas 消耗數值。

透過這種方式,我們就能利用 snapshots 資料夾下的檔案,直觀地觀察優化前後的數值變化,並讓 Git 完整記錄每一次的效能演進。

結語

這其實是我 2023 年剛進 imToken,在用 Foundry 幫線上合約做 Gas 優化時發現的問題。一直隱約知道 Foundry 測量 Gas 不太準確,但深入研究後才發現,這其實是 Foundry 用 Solidity 執行測試的原罪,一個埋得出奇深的坑。其實也不難想像,同樣的原因也讓 selfdestruct 在 Foundry 測試環境裡無法正常運作,雖然現在已經用不太到了,但當時在解 Ethernaut 時也是結結實實被坑過一次。

這個研究雖然早就做完也有了結論,但一直沒準備好把它整理成文章。熬到了有人提出用 Inner EVM 解決 Gas 測不準的問題,熬到了 snapshotGasLastCall cheatcode 出現、愈來愈多項目開始用 Foundry 做 Gas Profiling,心裡還是一直覺得這主題太小眾,加上 AI 大幅改變了大家獲取新知的方式,所以遲遲沒跨出這一步。直到後來前 Mentor Charles 跑來詢問我相關問題,才燃起了把這份研究寫成文章的念頭。真的非常感謝 Charles 推了我一把,不然這篇研究很可能就這樣悄悄埋沒了。另外,也很感謝 Nic Lin 過去也一直鼓勵我寫寫文章,間接促成這篇文章的誕生。


Foundry 進階指南:如何精準測量 Gas 消耗? was originally published in Taipei Ethereum Meetup on Medium, where people are continuing the conversation by highlighting and responding to this story.

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。

专栏文章
查看更多
数据请求中

推荐专栏

数据请求中

一起「遇见」未来

DOWNLOAD FORESIGHT NEWS APP

Download QR Code