iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰系列 第 2

Day 2 | 穩定幣實作 (上):ERC-20 代幣在現實世界中的各種陷阱

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-02

一顆 ERC-20 穩定幣在鏈上長怎樣

一顆 ERC-20 穩定幣,本質上是一份住在合約裡的帳本。你「持有 100 USDC」的意思,是 USDC 合約的儲存空間裡有一行寫著「你的地址 → 100000000」(USDC 與 USDT 都是小數後 6 位)。錢不在你的錢包裡,錢包只負責用私鑰簽署「請改帳本」的請求。這也是為什麼發行商有辦法凍結或銷毀你的餘額,因為帳本就是他們的合約,他們當然改得動(但當然不會隨便更改啦...)。

mapping(address => uint256) balanceOf;                       // 誰有多少錢
mapping(address => mapping(address => uint256)) allowance;   // 誰允許誰替他花多少

要在這份帳本上搬錢,只有兩種方法:

  1. transfer(to, amount):你自己簽一筆交易,合約檢查你的餘額夠不夠,然後直接改帳本。這是所謂「push」:錢從你手上主動推出去。
  2. approvetransferFrom:你先呼叫 approve(spender, amount),然後登記「我允許 spender 替我花最多 amount」的這個額度就叫 allowance。之後 spender 可以在額度內呼叫 transferFrom(payer, to, amount) 替你搬錢,每搬一次額度就扣一次。這是所謂「pull」:錢是被對方拉走的。

push 很好懂,那為什麼需要 pull?因為合約沒有私鑰,不能去動你的餘額,可是結算、託管、兌換等等的操作都需要「合約收下你的錢,然後在同一筆交易裡做點什麼」。如果只有 transfer,你得先把錢推給合約、再另外通知它這筆錢是誰的、要幹嘛...不僅沒有原子性,而且也會提升整個交易的不穩定性。有了 allowance,合約可以在一筆交易裡先 transferFrom 把錢拉進來、接著執行邏輯,要嘛全部成功、要嘛全部 revert。結算合約要替客戶搬錢,用這種方法最實際。

但使用 pull 的代價是使用者要多發一筆 approve 交易、多付一次 gas,體驗不好,這件事有別的解法,之後會討論。現在只要記住:transferbalanceOfapproveallowancetransferFrom 兩個都改。接下來讀到的每一個特殊設計,都是針對這兩個 mapping 的某個操作。

EIP-20 規格不等於實作

昨天提到的藍圖:「API 收單、intent 落地、job 進 queue、relayer 組好交易,最後由結算合約呼叫 IERC20(token).transferFrom(payer, to, amount) 把錢送出去」在 USDC 會成功,但換成 USDT 的話應該會 revert,而且 revert 得莫名其妙:餘額足夠、合約沒暫停、地址沒問題,錯誤訊息一片空白。

也許你決定把呼叫改成低階的 call、並不去解碼回傳值,然後 USDT 就能成功支付了!但一段時間後,其他有些 token 出現新問題:交易成功上鏈、gas 也燒了、intent 在後端被推進到 settled,但收款人餘額一毛都沒增加。

會發生這些問題其實就是因為「ERC-20 只是一份規格,他沒有實作上的強制力」(就像每家銀行都說自己支援轉帳,但有的轉完不給你收據、有的偷偷扣手續費、有的轉帳失敗也不通知你,你要自己去查餘額才知道)。

USDT 合約實際上的問題

USDT 是市值最大的穩定幣,以太坊主網上那份 TetherToken 合約在 2017 年 11 月部署,此後沒有升級過。它有四個對支付系統影響最大的特殊設計。

1. transfer 沒有回傳值

EIP-20 原文中,transfertransferFrom 的簽名是:

function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)

後面其實接著一段很重要的話:

Callers MUST handle false from returns (bool success). Callers MUST NOT assume that false is never returned!

所以可知「token 在轉帳失敗時可以選擇回傳 false 而不 revert,所以處理 false 的責任在呼叫方」。

可是 USDT 的合約裡,這三個核心函式都沒有 returns (bool)

function transfer(address _to, uint _value) public whenNotPaused { ... }
function transferFrom(address _from, address _to, uint _value) public whenNotPaused { ... }
function approve(address _spender, uint _value) public onlyPayloadSize(2 * 32) { ... }

...放到 2017 年的脈絡看,當時不少 token 合約都省略了回傳值,所以應該不是漏寫,而且編譯器也會正常通過編譯。

2. approve 有歸零鎖

EIP-20 的 approvetransferFrom 是兩個獨立的交易,沒有任何機制保證「先授權、再轉帳」的流程不會被搶跑:你把某人的額度從 100 改成 50,對方若搶在你的交易前先花掉 100,再等你的交易生效後又花 50,總共花了 150。

其實 EIP-20 的建議是「先把額度設成 0,再設成新值」,但有明確說到「合約本身不應強制執行,以維持向後相容」。然而 USDT 偏偏強制了,approve 裡有一行:

require(!((_value != 0) && (allowed[msg.sender][_spender] != 0)));

allowance 不為 0 時,不能直接改成另一個非零值,必須先歸零再設定。EIP-20 說「合約不應強制」,USDT 偏偏強制了。任何想要「改額度一步到位」的授權流程,遇到 USDT 都會在第二次 approve 時 revert。

3. 轉帳稅

function setParams(uint newBasisPoints, uint newMaxFee) public onlyOwner {
    require(newBasisPoints < 20);
    require(newMaxFee < 50);
    basisPointsRate = newBasisPoints;
    maximumFee = newMaxFee.mul(10**decimals);
    ...
}

transfer 內部會依 basisPointsRate 抽一筆 fee 給 owner,超過 maximumFee 就封頂。目前兩個參數都是 0,但 owner 隨時可以打開:費率上限 19 個基點(不到 0.2%),單筆封頂不到 50 USDT。換句話說,USDT 從第一天起就是一顆隨時可以變成 fee-on-transfer 的 token(後端賺服務費不夠還想直接從合約端賺流水的概念...)。「轉出金額等於入帳金額」這個假設,建立在 Tether 沒有動這兩個參數的前提上。

4. 黑名單、暫停、整顆換掉

addBlackList 讓 owner 凍結任何地址、destroyBlackFunds 直接銷毀黑名單地址的餘額、pause 讓所有轉帳停擺、deprecate 可以把合約邏輯導向另一個地址。這些是發行商刻意保留的合規權力,不是漏洞。對結算系統的意思是:一筆昨天還能成功的付款,今天可能因為收款地址被列入黑名單而永遠失敗,而這種失敗重試幾次都沒用。

合規穩定幣的共同前提

稍微看一下乖寶寶 USDC 的公開原始碼:它乖乖回傳 bool、approve 沒有歸零鎖、沒有轉帳稅,介面層面確實是模範生。可是它同樣有 blacklist(被列入的地址不能轉帳也不能收款)、同樣有 pause,而且整份合約跑在可升級的 proxy 後面,實作隨時可以換。

這很正常,因為合規穩定幣的發行商永遠保留凍結、暫停、升級的權力。雖然喪失了一部分的 web3 風味,但這是監管下的折衷選擇,所以結算系統要把它當成環境的一部分來設計,不能當成特例來處理。

為什麼「沒回傳值」以前沒事,後來爆開?

回到開頭講到的問題:用標準 IERC20 介面呼叫 USDT,為什麼會 revert?

Solidity 0.4.22 之前,編譯器不檢查外部呼叫回傳資料的長度。介面說有一個 bool,實際回來 0 bytes,編譯器就從記憶體裡撈殘值來解碼,大多時候剛好非零,被當成 true。一切看起來正常了很多年。

0.4.22 開始,編譯器插入了 returndata 長度檢查:預期 32 bytes、實際 0 bytes,整筆交易 revert,連 USDT 內部已經更新的帳一起回滾。2018 年有一份統計找出至少 130 個有同樣問題的 token,USDT、OMG、BNB 都在名單上。用新版編譯器部署的合約,就這樣突然無法跟這些 token 互動。

那為什麼有些 token 會失敗得更莫名其妙?因為他們失敗時不 revert,只回傳 false。如果你為了相容 USDT 改用低階 call、又沒有檢查回傳值,就會得到一筆「上鏈成功、gas 正常消耗、錢卻完全沒動」的幽靈支付,這對支付系統來說這就是大事故了。

支付系統眼中的四類例外

以上這些行為,開源社群有一份完整目錄:weird-erc20,收錄了二十多種偏離標準的模式,建議整份讀過一遍。但對結算系統來說,逐一記住每一種沒有意義,重要的是按「支付鏈會怎麼應付它」來分類。我把它們分成四類:

1. 會 revert 的:無回傳值 token 經標準介面呼叫、approve 歸零鎖、pause 期間的轉帳。這類最友善,因為失敗是明確的:交易不會上鏈,或上鏈後狀態明確為失敗。relayer 在送出時就能知道,intent 不會被錯標。

2. 不會 revert 的:回傳 false 的 token。這類最危險,因為支付鏈上每一層看到的都是「成功」,唯一的異狀是餘額沒變。任何只看交易狀態就推進 intent 的設計都會中招;要靠讀回傳值、比對餘額、比對事件才能發現。

3. 金額對不上的:轉帳稅、以及像 USDT 這樣隨時可能開稅的 token。交易成功、錢也動了,但入帳金額小於請款金額。這類要靠對帳才會發現,帳本設計必須容許「請款金額」與「實收金額」是兩個欄位。

4. 永遠失敗的:黑名單、凍結。交易失敗,但重試多少次結果都一樣。這類的重點是要能被辨識出來、從重試佇列拿掉、轉給人工處理,否則會無限重試到 relayer 的資源被吃光。

這四類會貫穿整個系列。之後在設計轉帳封裝、對帳邏輯、失敗任務處理時,會再回到這裡。

另外三條鏈:例外從介面搬進執行模型

那 Solana、TON、SUI 上的穩定幣就乾淨嗎?介面確實整齊得多,但四類例外沒有消失,只是從合約介面搬進了鏈的執行模型。

Solana 上的 USDC 與 USDT 是 SPL Token。USDC 的 mint 保留 freeze authority,發行商可以凍結任何 token account,屬於「永遠失敗」類。指令失敗會讓整筆交易原子性地失敗,所以「不會 revert」這一類在 Solana 幾乎不存在;但多了一個 EVM 沒有的前置條件,收款人的 token account 可能還沒建立,付款前得先幫對方開戶並付租金。新一代的 Token-2022 更把 transfer fee、transfer hook、permanent delegate 做成官方擴充,「金額對不上」在這裡是標準功能。

TON 上的 USDT 是 jetton,TON 官方釋出的 stablecoin-contract 就是為這類中心化穩定幣寫的範本:admin 可以鎖定任何人的 jetton wallet(還細分成不能轉出、不能轉入、全鎖)、強制銷毀、強制轉帳、整顆換 code。這些是「永遠失敗」類。但 TON 真正顛覆的是分類本身:jetton 轉帳是一串非同步訊息,你的錢包發訊息給你的 jetton wallet,它再發給對方的 jetton wallet,對方可能再回一個通知。沒有回傳值、沒有同步的成功或失敗,「會不會 revert」這個問題在 TON 上要換一種問法。之後會專門討論。

SUI 上的 USDC 是 Circle 原生發行的 regulated coin。Sui 把黑名單做進了鏈本身的 deny list:被列入的地址當下就不能轉出,下個 epoch 起連收都收不到,發行商還有 global pause 可以一鍵停掉整顆幣。「永遠失敗」類在這裡由鏈來執行,而不是合約。另一個差異在餘額本身:它不是 mapping 裡的一個數字,而是你名下的一堆 Coin<USDC> 物件,「餘額不足」會變成「手上的物件要先合併」。這也之後會討論。

四條鏈放在一起看,會發現同一件事:發行商需要的管控權在哪條鏈上都存在,差別只是藏在合約裡還是藏在鏈裡。結算系統不能假設任何一條鏈上的穩定幣是「純粹的錢」(唉,這就是為什麼有一陣子流行的 buzz word 是「Programmable Money」)。

今天的程式碼只蓋 EVM 這一部分。其他三條鏈的 mock 與本地測試環境要用 Rust、FunC、Move 各寫一套,會在寫到那幾條鏈時補上。

Token Zoo:四種 mock 與它們各自模仿的特例

今天在 repo 的 contracts/evm/Foundry 起了專案,測試直接用 Solidity 寫;mock 放在 src/mocks/、測試在 test/TokenZoo.t.sol。專案結構與怎麼跑,明天講本地測試環境時一起說。今天的重點是把前面讀到的特殊設計,逐一實作到測試環境裡:

Mock 模仿的行為 原型 對應的類別
ERC20Mock 完全遵守 EIP-20 教科書行為 對照組
USDTMock 無回傳值、approve 歸零鎖、休眠轉帳稅、黑名單、pause TetherToken 會 revert/金額對不上/永遠失敗
NoRevertERC20Mock 失敗回傳 false、不 revert 規格書允許的合法行為 不會 revert
FeeOnTransferERC20Mock 常駐轉帳稅 STA、PAXG 金額對不上

USDTMock 的關鍵片段(完整版在 repo):

// 注意:沒有 returns (bool),這是整件事的重點
function transfer(address to, uint256 value) public whenNotPaused {
    require(!isBlackListed[msg.sender], "blacklisted");   // 原版只擋轉出方
    uint256 fee = (value * basisPointsRate) / 10_000;
    if (fee > maximumFee) fee = maximumFee;
    _move(msg.sender, to, value - fee);
    if (fee > 0) _move(msg.sender, owner, fee);
}

然後用測試把每一類釘死。挑三個代表(測試用 transfer 示範,transferFrom 同理;vm.prankvm.expectRevert 是 Foundry 的 cheatcode,分別用來切換呼叫者與宣告「下一個呼叫應該 revert」):

// 會 revert:用標準介面呼叫無回傳值的 token
function test_usdtStyle_revertsThroughStandardInterface() public {
    vm.expectRevert();
    IERC20(address(usdt)).transfer(alice, 100e6);
}

// 不會 revert:餘額不足,token 回傳 false,呼叫端若不看回傳值就是幽靈支付
function test_noRevertToken_silentFailureWhenReturnIgnored() public {
    (bool success, ) = address(badToken).call(
        abi.encodeCall(IERC20.transfer, (alice, 1_000_000e18))
    );
    assertTrue(success);                     // 呼叫「成功」
    assertEq(badToken.balanceOf(alice), 0);  // 但錢沒動
}

// 金額對不上:開稅後任意金額轉帳,三方餘額總和不變,但入帳 < 請款
function testFuzz_usdtStyle_amountIsConserved(uint256 amount) public {
    amount = bound(amount, 0, usdt.balanceOf(bob));
    vm.prank(owner);
    usdt.setParams(10, 20);
    uint256 before = usdt.balanceOf(bob) + usdt.balanceOf(alice) + usdt.balanceOf(owner);
    vm.prank(bob);
    usdt.transfer(alice, amount);
    uint256 after_ = usdt.balanceOf(bob) + usdt.balanceOf(alice) + usdt.balanceOf(owner);
    assertEq(before, after_);
}

其中第二個測試的 successtrue、沒有 revert、gas 照燒,唯一的問題是餘額沒變。如果結算系統只看「交易有沒有成功上鏈」就把 intent 推進到 settled,這筆錢就在帳本上憑空出現了。之後設計對帳時,最終認的會是餘額與事件,交易狀態只是參考。

小結

ERC-20 名義上是標準,實際上是一份大家各自表述的君子協定:市值最大的穩定幣公然偏離規格,偏離的方式還寫在一份 2017 年部署、再也沒改過的合約裡。改不了,只能適應。而換到另外三條鏈,例外不會消失,只是從合約介面搬進了鏈的執行模型。

今天把 EVM 的測試基礎建設搭起來、把四類例外用 mock 來完整代替。明天繼續把本地測試環境的下半部蓋完。

明天見。


上一篇
Day 1 | 導讀:在不可靠又非同步的世界裡,保證錢只動一次
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰2
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言