iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0

本日程式碼:repo tag day-17

昨天的 Settlement 收工時帶著一個明講過的問題:搬錢那一行用標準 IERC20 介面呼叫 transferFrom,而主網 USDT 的 transferFrom 沒有回傳值,標準介面在解碼回傳值那一步就 revert,連成功的轉帳都過不了。也就是說,這份結算合約面對 USDT 時整個都不能用。今天我們就來透過 SafeTransfer 彌補這一部分。

今天的目標

業界的標準答案是 SafeERC20,我們則照它的行為實作最小子集(注意:正式專案請直接用 OpenZeppelin,這裡自己寫是為了把機制拆開)。這邊我們把 SafeTransfer 設計成一個包住 ERC-20 搬錢動作的 library,把 token 對一筆轉帳的三種真實回應(revert、回傳 false、什麼都不回)收斂成同一種語義:沒成功就 revert。這邊可以參考的公開設計有兩個:

  1. OpenZeppelin 的 SafeERC20 是業界的標準答案,行為寫在文件第一段:「Tokens that return no value (and instead revert or throw on failure) are also supported, non-reverting calls are assumed to be successful」,所以可知 0 bytes 的 returndata 要當成功收下;我們照這個行為從零實作最小子集,只包 transfertransferFrom 兩個搬錢動作
  2. Solidity 文件在 address 型別成員那節的警告:「The low-level calls which operate on addresses rather than contract instances (i.e. .call(), .delegatecall(), .staticcall(), .send() and .transfer()) do not include this check, which makes them cheaper in terms of gas but also less safe」,這裡的 this check 指編譯器在外部呼叫前用 extcodesize 確認對方有程式碼;低階 call 沒有這層保護,所以我們的封裝要自己補一次

repo 中 contracts/evm/src/libraries/SafeTransfer.sol 做完之後,對外的形狀只有兩個函式(完整檔案在 repo):

function safeTransfer(IERC20 token, address to, uint256 value) internal;
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal;

Settlement._settle 裡「呼叫再檢查回傳值」的兩行換成一行 safeTransferFrom,昨天過不了的 USDT,同一筆 settle() 現在直接走得完。

為什麼標準介面沒辦法正確完成交易

問題卡在宣告本身。介面宣告了 returns (bool),Solidity 就會替你解碼 returndata,USDT 這種 0 bytes 的直接死在解碼。那把介面宣告成沒有回傳值呢?換另一邊死:EIP-20 允許 token 用回傳 false 代替 revert,宣告裡沒有 bool,這個失敗訊號就被整個丟掉了。兩種宣告各救一半,所以只剩一條路:不讓編譯器自動解碼,用低階 call 呼叫,returndata 自己讀、每一種長度自己決定是什麼意思。這邊我們畫出呼叫方式的演進:

前兩種就是 Day 2 講過的那個迴圈:為了 USDT 改用低階 call,又因為不看回傳值被幽靈支付搞死。

三種回應收斂成一種語義

safeTransfersafeTransferFrom 只負責把 calldata 編好,判讀集中在私有的 _mustSucceed 上,它對 returndata 只問三個問題:

repo 裡的實作就是把這張圖直譯下來:

    function _mustSucceed(address token, bytes memory data) private {
        (bool success, bytes memory returndata) = token.call(data);
        if (!success) {
            // 轉發 returndata:token 帶什麼理由就丟回什麼理由;長度為 0 時等同不帶理由的 revert
            assembly ("memory-safe") {
                revert(add(returndata, 0x20), mload(returndata))
            }
        }
        if (returndata.length == 0) {
            require(token.code.length > 0, "SafeTransfer: token has no code");
            return;
        }
        require(abi.decode(returndata, (bool)), "SafeTransfer: transfer returned false");
    }

圖上沒寫的是兩個為什麼:

  • token 自己的 revert 原因原封轉發:失敗的理由是給鏈下讀的。relayer 把失敗分成 retryable 與 poison 的時候,「餘額不足」跟「被列入黑名單」要分得出來,前者等等再試、後者是永遠失敗。封裝一蓋成自己的字串,所有失敗就長得一樣了。

  • code 檢查只放在 0 bytes 那條路:回得出資料的地址一定有程式碼,只有 0 bytes 分不出「USDT 型的成功」與「打錯了 token 地址」。少了這行檢查,一個打錯的地址會變成一筆「成功」卻什麼都沒發生的結算,比 revert 難查得多;放到每次呼叫都做,又是多花一次沒必要的 gas。

四類例外重新驗收

昨天那張結果表今天重跑一遍,變化集中在第一項:

例外 經過 SafeTransfer 的結果
會 revert 的 無回傳值的 token 從這項除名了:0 bytes 當成功收下,USDT 走得完。真正失敗的照樣 revert,ref 不留標記
不會 revert 的 false 照樣轉成明確的 revert,只是這行檢查從 _settle 搬進了封裝
金額對不上的 照樣放行,實收短少留給鏈下對帳,封裝不量實收
永遠失敗的 照樣 revert,而且現在鏈下看得到 token 給的理由,分類失敗時多一條線索

再次強調,業界都用 SafeERC20,而我們的版本叫 SafeTransfer,因為它只包搬錢:OpenZeppelin 的版本還帶著一整組 approve 的封裝(forceApprove 對付的就是 USDT 那個歸零鎖),但這個 repo 的合約不對任何人 approve。

小結

今天的 SafeTransfer 把 token 對一筆轉帳的三種回應收斂成「沒成功就 revert」:USDT 從整個不能用變成正常結算,回傳 false 的幽靈支付在合約層就變回一筆看得見的失敗,token 自己的 revert 原因原封不動留給鏈下分類用。Settlement 只改了搬錢那一行,之後每一個要搬錢的合約都走這一層。

今天還留著一筆付款要發兩筆交易的問題:明天來看看讓 payer 那筆 approve 消失的方法、也就是 Permit2 的 signature-based approval。

明天見。


上一篇
Day 16 | 結算合約概觀:Pull vs Push 支付流
下一篇
Day 18 | Permit2 深度解析:Signature-based Approval 如何改變支付體驗
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言