本日程式碼:repo tag day-17
昨天的 Settlement 收工時帶著一個明講過的問題:搬錢那一行用標準 IERC20 介面呼叫 transferFrom,而主網 USDT 的 transferFrom 沒有回傳值,標準介面在解碼回傳值那一步就 revert,連成功的轉帳都過不了。也就是說,這份結算合約面對 USDT 時整個都不能用。今天我們就來透過 SafeTransfer 彌補這一部分。
業界的標準答案是 SafeERC20,我們則照它的行為實作最小子集(注意:正式專案請直接用 OpenZeppelin,這裡自己寫是為了把機制拆開)。這邊我們把 SafeTransfer 設計成一個包住 ERC-20 搬錢動作的 library,把 token 對一筆轉帳的三種真實回應(revert、回傳 false、什麼都不回)收斂成同一種語義:沒成功就 revert。這邊可以參考的公開設計有兩個:
transfer 與 transferFrom 兩個搬錢動作.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,又因為不看回傳值被幽靈支付搞死。
safeTransfer 與 safeTransferFrom 只負責把 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。
明天見。