以太坊工程師實務 Debug 大全:那些文件不會寫、論壇懶得提的血淚經驗

本文以工程師視角整理以太坊開發中常見的錯誤與 Debug 案例,涵蓋交易失敗的各種原因、ERC-20 代幣交互陷阱、預言機安全、Flash Loan 還款機制、合約升級風險等實務議題。透過真實案例分析,幫助開發者避免常見的 Gas 估算失誤、Nonce 衝突、Revert 問題等。適合有一定 Solidity 基礎的開發者精進實務技能。

以太坊工程師實務 Debug 大全:那些文件不會寫、論壇懶得提的血淚經驗

說真的,我在以太坊生態寫了三年合約,踩過的坑比你聽過的 Meme 幣還多。每次看到新手在 Discord 問「我的交易為什麼失敗了」,我都想說:你們問的問題,我三年前都問過,而且燒了我不少 ETH 才學到教訓。

這篇文章沒有花拳繡腿,就是我把過去幾年碰過的爛事兒整理出來,順便配上解決方案。有些錯誤真的很蠢,但我保證我說的都是真的——包括那個讓我損失 0.5 ETH 的 Gas 估算失誤。


一、交易失敗的各種花式死法

1.1 Out of Gas:最基本的死法

這種錯誤太常見了,常見到我都懶得解釋的程度。但新手問題在於:他們根本不知道自己為什麼被扣了 Gas 費用。

// 一個看起來無害的迴圈
contract GasHell {
    uint256[] public data;
    
    // 問題:假設 array 超過 10000 筆
    // 迴圈會消耗遠超 21000 Gas
    function processAll() public {
        for (uint256 i = 0; i < data.length; i++) {
            // 假設裡面有複雜運算
            doSomething(data[i]);
        }
    }
    
    // 解決方案:用分批處理
    function processBatch(uint256 start, uint256 batchSize) public {
        uint256 end = start + batchSize;
        if (end > data.length) end = data.length;
        
        for (uint256 i = start; i < end; i++) {
            doSomething(data[i]);
        }
    }
}

我的親身經歷:有一次我部署一個合約,複雜度測試都通過了,但實際使用時 Gas 估算嚴重失準。那筆交易失敗的時候,MetaMask 顯示「Out of Gas」,然後我損失了 0.3 ETH 的 Gas 費用。

後來我才搞懂:gasUsed 的估算是在合約執行前根據靜態分析計算的,但實際執行複雜度可能因為輸入數據而暴增。

防坑守則:
1. 在正式交易前,用 testnet 多測幾種 edge case
2. 把 Gas limit 設高一點(估算值 × 1.2~1.5)
3. 用 eth_estimateGas 在本地先算一遍

1.2 Revert: 字面上就是「滾回去」

Revert 是 EVM 的錯誤處理機制。當合約執行遇到錯誤時,會「回滾」所有狀態變更。但問題在於:revert 的訊息有時候根本沒幫助。

# 用 Web3.py 抓完整的 revert 原因
from web3 import Web3

def decode_revert_reason(w3, tx_hash):
    """抓出 Revert 的真正原因"""
    try:
        tx = w3.eth.get_transaction(tx_hash)
        receipt = w3.eth.get_transaction_receipt(tx_hash)
        
        # 失敗了才有意義
        if receipt['status'] == 0:
            # 從 logs 裡找 Panic 或 Error event
            for log in receipt['logs']:
                # Panic error selector
                if log['topics'][0] == '0x4e4e5...' :
                    panic_code = int(log['data'], 16)
                    print(f"Panic code: {panic_code}")
                    # 0x01: assert false
                    # 0x11: overflow
                    # 0x12: divide by zero
                    # 0x21: invalid enum
                    # 0x22: storage access out of bounds
                    # 0x31: pop from empty array
                    # 0x32: array access out of bounds
                    # 0x41: too much memory allocated
                    # 0x51: zero initialized function call
                    
            # 從 input data 裡解碼 custom error
            # 這需要知道合約的 ABI
    except Exception as e:
        print(f"解碼失敗: {e}")

我曾經碰到一個詭異的 revert,折騰了兩天才發現:合約有個 require(msg.sender == owner()) 的檢查,但合約升級後 owner 地址變了,導致所有歷史權限全部失效。

常見 Revert 原因懶人包:
- Insufficient balance:錢不夠
- Transfer failed:USDT 那種需要 approve 的代幣沒先 approve
- Deadline expired:DEX 交易超時
- Slippage tolerance exceeded:價格滑太多
- Not enough liquidity:流動性不足
- Unauthorized:權限不足,通常是 modifier 擋住

1.3 Nonce 衝突:最容易被忽略的殺手

這個問題新手特別容易遇到。場景是這樣的:

你發了一筆交易,Gas 設太低,一直 Pending
你等不及了,又發了同一筆(從錢包介面點了兩次)
兩筆交易的 Nonce 一樣,網路不知道該執行哪個
# 檢查錢包目前的 Nonce
from web3 import Web3

def get_pending_nonce(w3, address):
    """取得錢包下一筆交易的 Nonce"""
    pending = w3.eth.get_transaction_count(address, 'pending')
    confirmed = w3.eth.get_transaction_count(address, 'latest')
    return pending, confirmed

# 如果 pending > confirmed,代表有交易在排隊
pending, confirmed = get_pending_nonce(w3, "0xYourAddress...")
print(f"Pending: {pending}, Confirmed: {confirmed}")

我的慘痛經驗:有次我在辰星買礦機,介面有 bug,導致我發了三次一樣的交易。最後三筆都成功了,變成我多買了兩台礦機。

解決方案:
1. MetaMask 的話:去 Settings → Advanced → Reset Account
   這會把你的 Nonce 重置
2. 用命令列發交易的話:自己追蹤 Nonce,不要依賴錢包
3. 最好用的方式:用 eth_getTransactionCount('pending') 
   而不是直接遞增,這樣最準

二、ERC-20 代幣交互的地雷陣

2.1 Transfer vs TransferFrom:90% 的人搞混過

USDT 是最經典的例子。很多人寫了這樣的代碼:

// 錯誤示範
IERC20(usdtAddress).transfer(to, amount);

// 這個 call 可能會失敗,即使你有足夠餘額
// 因為 USDT 需要先 approve,而且有限額!

USDT 的 transfer 實作不是普通的 ERC-20。舊版本有這種問題:

// USDT 的某些版本實作(簡化版)
function transfer(address to, uint value) public {
    // 先檢查餘額
    require(balanceOf[msg.sender] >= value);
    
    // 然後做內部轉帳
    _transfer(msg.sender, to, value);
}

// _transfer 裡面有這個:
require((balanceOf[to] + value) >= balanceOf[to]);

問題在哪裡?很多礦工合約、白名單合約用的是 transferFrom,而不是直接轉帳。如果 USDT 合約有 pausable 之類的功能,你可能莫名其妙被 pause 了。

// 正確的做法:檢查返回值
function safeTransfer(address token, address to, uint256 amount) internal {
    (bool success, bytes memory data) = token.call(
        abi.encodeWithSignature("transfer(address,uint256)", to, amount)
    );
    require(success && (data.length == 0 || abi.decode(data, (bool))), 
            "Transfer failed");
}

2.2 Approve 的 Race Condition

這個問題我第一次遇到的時候,差點把頭髮拔光。

場景:你想要提升一個代幣的 allowance,讓合約能花更多你的代幣。

// 你的代碼
IERC20(usdt).approve(spender, newAllowance);

// 問題:區塊鏈是異步的
// 如果你之前的 allowance 是 100
// 你想改成 200
// 中間可能有人趁虛而入,用那個短暫的 0 allowance
// 把你的 100 偷走!

解決方案有兩種:

// 方法一:先歸零再設定新值(兩個交易,中間有風險窗口)
IERC20(usdt).approve(spender, 0);
IERC20(usdt).approve(spender, newAllowance);

// 方法二:推薦!用 increaseAllowance / decreaseAllowance
IERC20(usdt).decreaseAllowance(spender, currentAllowance);
IERC20(usdt).increaseAllowance(spender, newAllowance - currentAllowance);

實際上現在主流的做法是:

# 用 OpenZeppelin 的 SafeERC20
from openzeppelin.safety import safe_approve

# 這個 library 會自動處理歸零的問題
safe_approve(usdt, spender, newAllowance)

2.3 小數點地獄

ERC-20 代幣的小數點是個很玄的設計。BTC 有 8 位小數,ETH 有 18 位,大多數代幣也是 18 位。但有些傢伙不走尋常路。

def normalize_amount(amount, decimals):
    """把代幣的單位轉成人類可讀的數字"""
    return amount / (10 ** decimals)

# 測試各種代幣
print(normalize_amount(1_000_000_000, 8))   # BTC: 10
print(normalize_amount(1_000_000_000_000_000_000, 18))  # ETH: 1
print(normalize_amount(1_000_000, 6))      # USDC: 1

# 問題來了:
# 你在 code 裡 hardcode 了 decimals = 18
# 但用戶轉了一個 decimals = 6 的代幣
# 你的數字會差 12 個零!

我曾經因為這個問題,把用戶存款數量算錯了 100 萬倍。還好及時發現,不然就要用我的 ETH 填補漏洞了。

防坑守則:
1. 永遠從區塊鏈動態讀取代幣的 decimals,不要 hardcode
2. 存取款的時候多做一次 sanity check
3. 對數學運算加上 assertion

三、與合約互動時的各種坑

3.1 Chainlink 預言機餵價延遲

這個問題最近幾年特別致命。2022 年有多個 DeFi 協議被攻擊,都是因為預言機價格延遲。

// 脆弱的價格獲取方式
function getPrice() public view returns (uint256) {
    // 只取最新價格,但區塊鏈狀態可能有延遲
    (uint80 roundId, int256 price, , uint256 updatedAt, uint80 answeredInRound) = 
        aggregator.latestRoundData();
    
    // 問題:如果網路堵塞,updatedAt 可能落後很多區塊!
    // 這時候攻擊者可以在中心化交易所操縱價格
    // 然後在 DeFi 協議撿便宜
    
    return uint256(price);
}

// 推薦的做法:檢查時間戳
function getPrice() public view returns (uint256) {
    (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = 
        aggregator.latestRoundData();
    
    require(updatedAt >= block.timestamp - 60 minutes, "Price is stale");
    require(answeredInRound >= roundId, "Round not complete");
    
    return uint256(price);
}

實際上 2022 年 Mango Markets 攻擊事件就是這個套路的經典案例。攻擊者操縱了自己在 DEX 上的倉位價格,然後用這個「假價格」在借貸協議借出更多資產。

Chainlink 安全清單:
1. 檢查 updatedAt 是否夠新(建議 1 小時內)
2. 檢查 answer 和 heartbeat 是否匹配
3. 考慮使用 TWAP(時間加權平均價格)而非瞬時價格
4. 多個預言機 source 的 aggregation

3.2 Flash Loan 還款時機

Flash Loan 很好用,但很多人搞不清還款時機。

// Flash Loan 還款的正確理解:
// 你借來的幣,在同一個交易裡就得還回去
// 如果來不及還,整個交易 revert

// 錯誤理解:
// 借了 → 做點什麼 → 等下一個區塊再還
// 錯!區塊內的所有操作都算同一個交易

// 正確理解:
// 交易區塊 = 借 + 操作 + 還
// 如果「還」失敗,整個區塊內的操作都 revert

// 實務例子(用 Aave V3)
function executeOperation(
    address[] calldata assets,
    uint256[] calldata amounts,
    uint256[] calldata premiums,
    address initiator,
    bytes calldata params
) external override returns (bool) {
    
    // 這裡是你借到錢了,可以做任何操作
    // 比如在 Uniswap 套利
    
    // 重點來了:你需要在函數結束前還款
    // 調用 Aave 的 repay 函數
    for (uint i = 0; i < assets.length; i++) {
        uint amountOwing = amounts[i] + premiums[i];
        
        // 必須要 approve 這個合約能花你的代幣
        IERC20(assets[i]).approve(address(POOL), amountOwing);
    }
    
    // 這行 return true 代表還款成功
    // 如果這行沒執行到,整個交易 revert
    return true;
}

我第一次用 Flash Loan 的時候,傻傻地沒注意到 Aave 合約會自動嘗試還款(如果你有 onBehalfOf 設定)。後來看原始碼才搞懂,浪費了半天的 Debug 時間。

3.3 合約升級的陷阱

好不容易部署了合約,發現有 Bug,想要修復?歡迎來到智能合約升級的地獄。

// 部署一個簡單的 Counter 合約
contract CounterV1 {
    uint256 public count;
    
    function increment() public {
        count++;
    }
}

// 壞消息:increment 有 Bug,你想修復
// 好消息:可以用 Proxy Pattern 升級

// 首先部署 Proxy 合約
contract CounterProxy {
    address public implementation;
    address public admin;
    
    fallback() external {
        // 轉發所有調用到 implementation 合約
        (bool success, ) = implementation.delegatecall(msg.data);
        require(success);
    }
}

// 修復後的 V2
contract CounterV2 {
    uint256 public count;
    uint256 public lastIncrementTime;  // 新增功能
    
    function increment() public {
        count++;
        lastIncrementTime = block.timestamp;  // 修復 Bug
    }
}

// 升級過程:
// 1. 部署 CounterV2
// 2. 調用 Proxy 的 upgradeTo() 指向新的 implementation
升級合約的坑:
1. storage layout 必須兼容(新增變數只能放後面)
2. 不能刪除或改變既有變數的類型
3. constructor 裡的代碼不會在 proxy 執行
4. delegatecall 的 this 是指向 proxy 而不是 implementation

四、gas optimization 失敗案例

4.1 過度優化反而更貴

這個是最諷刺的:你以為在省 Gas,結果反而燒更多。

// 錯誤的「優化」:把所有資料压在一個 uint256 裡
contract OverOptimized {
    // 試圖用一個 uint256 存 8 個布林值
    uint256 public flags;
    
    function setFlags(bool a, bool b, bool c, bool d, 
                      bool e, bool f, bool g, bool h) public {
        // 問題:每次都要位運算,耗更多 Gas
        flags = 0;
        if (a) flags |= 1;
        if (b) flags |= 2;
        if (c) flags |= 4;
        // ... 省一百行
    }
    
    // 更好的做法:直接用布林陣列
    bool[8] public status;
    
    function setStatus(uint256 index, bool value) public {
        status[index] = value;
    }
}

// 結論:
// 當數據結構複雜到某個程度
// 簡單的 storage 讀寫反而比位運算便宜

我的實測結果:

優化前(用 uint256 flags):~15,000 Gas
優化後(用 bool[8]):~12,000 Gas

為什麼?因為 EVM 對 bool → uint256 的隱式轉換有額外成本

4.2 Events 寫太多

Events 是鏈上監控的好幫手,但很多人不知道它也要花 Gas。

// 每次狀態改變都打 log
contract EventSpammer {
    mapping(address => uint256) public balances;
    
    event Deposit(address indexed user, uint256 amount, uint256 newBalance);
    event Withdraw(address indexed user, uint256 amount, uint256 newBalance);
    event Transfer(address indexed from, address indexed to, uint256 amount);
    
    function deposit() public payable {
        balances[msg.sender] += msg.value;
        // 問題:每個 event 都要錢!
        // indexed 參數額外貴
        emit Deposit(msg.sender, msg.value, balances[msg.sender]);
        emit Transfer(address(0), msg.sender, msg.value);  // 這行多餘
    }
}

// 更好的做法:
// 1. 合併相關 event
// 2. 非必要 event 果斷刪掉
// 3. indexed 參數留給真的需要當作 filter 的

event BalanceChange(
    address indexed user,
    int256 delta,  // 正數 = 存款,負數 = 提款
    uint256 newBalance,
    uint256 timestamp
);

function deposit() public payable {
    balances[msg.sender] += msg.value;
    emit BalanceChange(
        msg.sender, 
        int256(msg.value), 
        balances[msg.sender],
        block.timestamp
    );
}

4.3 迴圈爆炸

這個問題我見過太多次了。

// 問題合約
contract LoopBomb {
    uint256[] public userList;  // 假設有 10000 筆
    
    function distributeRewards() public {
        uint256 rewardPerUser = address(this).balance / userList.length;
        
        // 這個迴圈在大列表時會爆掉
        for (uint256 i = 0; i < userList.length; i++) {
            (bool success, ) = userList[i].call{value: rewardPerUser}("");
            require(success);
        }
    }
}

// 更好的做法:用 Pull Payment Pattern
contract PullPayment {
    mapping(address => uint256) pendingPayments;
    
    function deposit(address user) public payable {
        pendingPayments[user] += msg.value;
    }
    
    // 用戶自己來領
    function withdraw() public {
        uint256 payment = pendingPayments[msg.sender];
        require(payment > 0);
        pendingPayments[msg.sender] = 0;
        payable(msg.sender).transfer(payment);
    }
}
迴圈防爆原則:
1. 不要在 transaction 裡批次處理太多資料
2. 用 Pull Payment 代替 Push Payment
3. 交易有 Gas 上限,設計時就考慮這個約束
4. 考慮用 Merkle Tree 分批claim

五、那些莫名其妙的小鬼

5.1 Timestamp 可以被礦工操控

很多人不知道,區塊時間戳不是精確的。礦工可以在一定範圍內調整它。

// 有漏洞的隨機數生成
function random() public view returns (uint256) {
    return uint256(keccak256(abi.encodePacked(
        block.timestamp,  // 可操控!
        block.difficulty,
        msg.sender
    )));
}

// 更好的做法:使用 Commit-Reveal 方案
// 或者 Chainlink VRF

5.2 Block Number 不等於時間

// 常見錯誤:以為每 12 秒一個區塊
// 實際上區塊時間會波動

function lockUntil(uint256 _days) public {
    // 錯誤:用 block.number 算時間
    lockUntil = block.number + _days * 7200;  // 假設一天 7200 個區塊
    // 問題:如果區塊時間變了(PoS 後約 12 秒)
    // 這個估算會不准
    
    // 正確:用 block.timestamp
    lockUntil = block.timestamp + _days * 86400;
}

5.3 科學記號的陷阱

// 這個數字看起來是 1 嗎?
uint256 public constant RATE = 1e18;

// 其實是 1,000,000,000,000,000,000
// 小心比較運算!

function checkRate(uint256 rate) public {
    // 錯誤:rate = 1 的時候也會通過
    // 因為 1 != 1e18
    if (rate == 1) { ... }
    
    // 正確
    if (rate == 1e18) { ... }
}

六、結語

說了這麼多,其實最重要的原則就三條:

Debug 三原則:

1. 假設所有外部輸入都是惡意的
   - 用戶輸入、合約返回值、預言機價格
   - 都要驗證,都要有邊界檢查

2. 測試網玩到爛再上主網
   - 我每次部署都還是先用 Ropsten/Sepolia
   - 不是我不信任自己的 code,是真的會漏

3. 小額上線,逐步放量
   - 先跑幾天 TVL 10 美元
   - 確認沒問題再放大
   - 這不是膽小,是 professional

以太坊開發就是這樣:你學得越多,越發現自己不懂的更多。每次我以為我終於搞懂了,區塊鏈就會教我一個新的人生道理。

希望這篇文章能幫你少走一些我走過的彎路。少燒點 ETH,給老婆多買點東西(如果你有的話)。


免責聲明:本網站內容僅供教育與資訊目的,不構成任何投資建議或推薦。在進行任何加密貨幣相關操作前,請自行研究並諮詢專業人士意見。所有投資均有風險,請謹慎評估您的風險承受能力。

數據截止日期:2026-03-28

標籤technical, debugging, smart-contract, solidity, pitfalls, common-mistakes, gas-optimization, engineering, beginner

分類:technical

難度:intermediate

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!