
這篇文章將深入探討 Foundry 開發框架的運作機制如何影響 Gas 消耗的計算,以及為什麼 Foundry 本地測試的 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 中找到。
在本章節中,我們將實際測試以下三種在 Foundry 中常見的測量工具 / 方式,並將測試結果與鏈上實際值進行對比:
本章節會先聚焦於 Foundry 中常見 Gas 測量工具的實測結果,並將其與鏈上實際交易的 Gas 消耗進行對比。討論重點僅限於各工具測量結果與鏈上實際值之間的落差,以便讀者能直觀觀察這些方法在實務上的可靠性。
需要注意的是,本章不會深入探討造成落差的具體原因。相關分析將在後續完整拆解一筆 EVM 交易的 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、用 begin 與 end 變數紀錄當前 gasleft() 及計算 begin 與 end 兩者間的差都會額外消耗掉一些 gas。
但照理而言,即使它無法提供正確的絕對值,只要所有測試案例都使用相同方式量測,多出的額外消耗應該是固定且相同的,測量結果仍應具備一定的參考價值,不過我們從實測結果看來並非如此。
另一個觀察 Gas 消耗的方式,是利用 Foundry 強大的 Trace 功能:
forge test --mt test_gasLeft -vvvv
在 Trace 的輸出結果中,我們可以直接看到目標函式名稱旁的中括號 [],裡面標註了該次呼叫所對應的 Gas 消耗值:

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

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


從表格結果可以看出,加上 --isolate 參數後,透過 Foundry 執行測試所得的目標函式 Gas 消耗,與鏈上真實數據完全一致。
在深入了解 --isolate 參數如何改變 Foundry 本地測試的 Gas 消耗計算之前,我們需要先掌握一筆鏈上交易的 Gas 消耗究竟是如何構成的。
從前面的實測結果可以看到,即便使用相同的範例合約,不同的測量工具給出的數據仍有顯著差異。為了理解這些落差從何而來,我們必須先釐清:鏈上的 Gas 消耗究竟是如何計算的?
若要詳細觀察交易執行過程中每一條 Opcode 的 Gas 花費,我們可以使用 Foundry 提供的 cast run 指令協助分析。
cast run 能將鏈上已完成的交易在本地環境中「完整重播」,並逐步呈現執行過程:
cast run <TX_HASH> -t -r <URL>
其中-t 表示啟用交易執行的 trace 模式
透過 cast run,我們能精準觀察到一筆交易 Opcode 的執行順序、每個指令消耗的 Gas、Stack 的變化,以及最重要的——當前剩餘的 Gas。
我們首先分析最簡單的 doNothing() 交易:
cast run 0x5a121e8aa259b527f7d209fb073cc9aa6b20f638ee9f35c5e1d3ede53b8e98e4 -t -r $SEPOLIA_RPC_URL
💡 請把 $SEPOLIA_RPC_URL 參數換成自己的 Sepolia RPC URL。

在 cast run 的輸出結果中,有幾個關鍵欄位值得特別注意:
從輸出結果可以看到,第一條 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 的組成部分:
此外,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 的部分:
接著是 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 消耗。
在去年五月的 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 成本的下限。
也就是說,會在以下兩者之間取較大的值:
其中相關參數定義如下:
接續對範例一的 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 使用量。
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 一併納入計算:
因此,可得出範例二當前的 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 所對應的三個值分別為:
根據 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
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 Gas 與 Refund Gas。
同樣地,方法一雖然因測量本身的開銷導致結果略高於方法二,但從數據結構來看,它同樣缺少了 Intrinsic Gas 與 Refund Gas,而這兩部分都有一個共同的特性:
它們並非是在交易執行的過程中計算,而是分別在交易開始前與交易結束後才會被計入。
那麼,為什麼 Foundry 在執行測試時,無法捕捉到這兩部分的 Gas 消耗?這與 Foundry 測試框架的底層運作方式有關。
由於 Foundry 的測試函式本身是以 Solidity 撰寫,這代表「測試程式碼」與「被測試對象」實際上是在同一個 EVM Context(執行環境) 中運行。換句話說,當被測試對象開始執行時,測試函式早已在運行中,因此測試函式無法觀測到在交易開始執行前就已扣除的 Intrinsic Gas;同樣地,交易結束後才會結算的 Gas Refund,也因為測試函式仍在同一環境內而無法捕捉。
相較之下,Hardhat 的測試是以 JavaScript/TypeScript 撰寫。測試程式碼本身並不在 EVM Context 下執行,而是透過發送交易的方式呼叫合約。這種運行方式讓測試程式能夠掌握被測對象送入 EVM 執行的前、中、後三個階段,不會因為測試程式與合約共享同一個 EVM 執行環境,而導致無法觀測到交易前後完整的 Gas 消耗情況。
為了解決上述因測試架構而導致無法觀測完整 Gas 消耗的問題,Foundry 提供了 Isolation Mode(隔離模式)。
顧名思義,這個模式的核心機制在於「隔離」。當我們在測試中啟用 Isolation Mode 後,Foundry 不再將該次合約呼叫視為當前測試環境(Context)中的一部分,而是將其模擬為一筆完整且獨立的外部交易。
在一般的測試環境下,測試函式在測試合約中被呼叫屬於「合約對合約」的內部呼叫(Internal Call),因此無法觸發交易層級的計費邏輯。但在 Isolation Mode 下,被測試對象的呼叫具備了完整的交易生命週期。
這使得 EVM 能夠如同處理真實鏈上交易一般,執行以下標準流程:
正是因為 Isolation Mode 將測試呼叫從共用的 EVM Context 中抽離出來,使其行為符合標準交易的執行路徑,我們才能在測試報告中得到包含 Intrinsic Gas 與 Refund Gas 在內的精準數據,從而與鏈上真實消耗完全吻合。

雖然 Foundry 的 Isolation Mode 解決了 Gas 消耗測試不準確的問題,但在進行合約優化時,若單靠人工比對 Gas Report 上的數字,不僅效率低落,也無法透過 Git 有效追蹤每次進行 Gas 優化後造成的具體差異。
為了解決此一痛點,我們可以使用 Foundry 作弊碼:snapshotGasLastCall。
此作弊碼能精確捕捉「上一次外部合約呼叫」的 Gas 消耗,並將其記錄至快照資料夾下,讓開發者能將 Gas 數據納入版本控制,直觀地監測每一版本的優化成果。
接下來,我們將透過範例一來示範如何使用 snapshotGasLastCall 協助我們追蹤 Gas 優化的成果。
首先,我們需要在 foundry.toml 中加上 isolate = true,以確保在本地執行測試時能精準捕捉完整的 Gas 消耗。

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

snapshotGasLastCall 接受兩個參數:
執行測試後,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.
【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。
