iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0

本日程式碼:repo tag day-16

一筆 100 USDC 的付款,最直接的走法是 payer 對 USDC 合約發一筆 transfer 給 merchant。雖然會成功,但這筆轉帳無法證明自己是為了哪一筆付款:ERC-20 的 transfer 只有收款人與金額兩個參數,沒有 memo 欄位,ref 沒有地方可以寫。昨天的對帳引擎只能把這種轉帳列成 unreferenced,等人工介入把錢找回來。今天開始往鏈上走,先來開發結算合約吧,我們來讓每一筆結算的錢都帶著 ref 上鏈。

今天的目標

我們今天只要做 EVM 上的結算合約 Settlement:一條搬錢路徑、兩種支付入口(pull 與 push 各一個),加上「同一個 ref 只結算一次」的 replay 防護。

老樣子,我們可以參考人家大廠穩定使用的公開設計(很怕被):

  1. Stripe 的 A2A 支付入門對兩種支付流的定義:push 是「The payer initiates the transaction」、pull 是「The recipient initiates the payment, often with the payer's prior consent」,所以可知分界在誰發起,而 pull 的前提是付款方的事前授權;我們的事前授權就是 ERC-20 的 allowance,發起的人則是 relayer
  2. Request Network 的 ERC20FeeProxy:「The proxy contract does the fungible token transfer on behalf of the user」,付款人自己呼叫代理合約、payment reference 寫進 event,是 push 支付流的公開前例;我們的 pay() 照這個形狀做,ref 沿用自己的 32 bytes commitment

repo 中 contracts/evmSettlement.sol 做完之後,對外的形狀長這樣(完整檔案在 repo):

function pay(address token, address merchant, uint256 amount, bytes32 ref) external;                   // push
function settle(address token, address payer, address merchant, uint256 amount, bytes32 ref) external; // pull
event Paid(bytes32 indexed ref, address indexed payer, address indexed merchant,
           address token, uint256 amount, address executor);

兩個入口最後走進同一條私有的 _settle,event 也只有一種。event 刻意不叫 Settled:intent 的 settled 要等 finality 與金額核對,只有 listener 能宣告;合約能作證的只有「錢在這筆交易裡曾經動過」,所以叫 Paid

pull 與 push 的差異

Day 2 我講過 token 層的 push 與 pull:transfer 是自己 push,approvetransferFrom 是被 pull。而到了結算這一層,pull 與 push 其實差別在於誰簽那筆交易。

我們的 pipeline 走 pull,而且幾乎沒得選:簽名迴圈的設計就是要讓 payer 不碰 gas,payer 只負責授權,交易由 relayer 簽名、代付 gas 送上鏈;而前幾天蓋的重試、fee 加價救援、nonce 序列化,也全部建立在「交易是我們簽的」這個前提上。換到 push 支付流,這些事全落回 payer 頭上,得自己盯交易、自己救卡住的 tx。

那為什麼還留一個 pay()?因為不走 relayer 的付款人本來就存在,而他們直接對 token 合約 transfer 的結果就是開頭那種 unreferencedpay() 就是留給這種人的入口:多繞一步合約,ref 就上得了鏈。至於鏈下要怎麼接住 push 進來的錢(intent 要停在哪一格等 payer 自己上鏈),之後再討論。

圖上兩條支付流的第一步都一樣:payer 得先發一筆 approve,因為錢要繞經合約,合約就得是 allowance 的 spender。一筆付款至少兩筆交易,這是 ref 只能靠合約上鏈的代價;讓 approve 這筆交易消失的簽名方案,之後再談。

同一個 ref 只結算一次

replay 的防線鏈下已經有三道:idpk 擋 API 的重送、queue 對同 ID 的 job 去重、intent 靠 CAS 擋兩個 worker 搶同一筆。但它們擋的都是鏈下的重複,一筆簽好的交易離開系統之後,鏈下沒有任何機制攔得住它。所以合約上這一格 paid mapping 是整條 pipeline 的最後一道防線,關鍵的就是 _settle 裡這幾行:

    require(!paid[ref], "Settlement: ref already paid");
    paid[ref] = true;

    bool ok = IERC20(token).transferFrom(payer, merchant, amount);
    require(ok, "Settlement: transferFrom returned false");

    emit Paid(ref, payer, merchant, token, amount, msg.sender);

_settle 只有一個順序上的堅持:ref 的標記寫在搬錢之前。

方向跟鏈下的「先記 settling 再廣播」一樣,理由卻不一樣:這裡防的是重入(reentrancy,這個風險我寫合約常常都會沒注意到)。transferFrom 是外部呼叫,token 的程式碼不歸我們管,一顆惡意 token 可以在標記寫下去之前重入 _settle,同一筆付款就走完兩次;先標記,重入進來撞到的就是拒絕。失敗那一側反而不用另外設計:鏈上一筆交易本來就是原子的,一次失敗的嘗試不會把 ref 燒掉,這是整條 pipeline 裡難得白拿的保證。

(還記得昨天的 paid_twice 是事後偵測嗎?這邊合約就能事前直接擋下來了。)

把這份合約帶入 token 的四類例外

如果要把這份合約帶入 token 的四類例外,只要準備一行:檢查 transferFrom 的回傳值。EIP-20 那句「Callers MUST handle false」,今天輪到我們自己當那個 caller。

例外 穿過 _settle 的結果
會 revert 的 整筆回滾,ref 不留標記,失敗是安全的
不會 revert 的 require(ok) 把回傳 false 轉成明確的 revert
金額對不上的 放行,event 記請款金額,實收短少留給鏈下對帳
永遠失敗的 revert(黑名單與 pause 都在 token 裡擋),怎麼重試都一樣

第三個是刻意的:合約不量實收。轉帳前後各查一次餘額就量得到,但金額核對已經有 listener 在做,合約再量一次就是第二份判斷,兩邊的容差規則遲早對不上,而鏈上那份部署之後還改不了。

其實 USDT 也是一個超麻煩的例外,那就是它的 transferFrom 沒有回傳值,標準介面在解碼回傳值那一步就 revert,連成功的轉帳都過不了。也就是說,今天這份合約對市值最大的穩定幣是整個不能用的。

approve 給出去的比一筆付款多

pull 支付流真正要想清楚的是信任邊界。payer 簽 approve 的那一刻,授權的對象是合約整體,跟哪一筆付款無關;合約再用名單把「誰能發起」收到 relayer,但名單上的 relayer 在額度內還是可以發起任何付款。這個授權比「payer 同意過的那一筆」寬得多:

可以從圖得知,這份信任的另一頭有人看著:每一筆 Paid 都帶著 ref,一筆對不回任何 intent 的結算會被對帳引擎列成 unknown_ref 叫人來看。但稽核是事後的,擋不住已經送出的那筆;把授權收窄到「payer 對單筆付款簽名」的方案,之後再討論。

小結

我們來看看結算合約最終的形狀:錢通過合約,ref 帶入 EVM 上的 memo 欄位,最後以 event 上鏈;pull 與 push 只差在誰簽那筆讓錢移動的交易,搬錢路徑只有一條,paid 那一格的 replay 防護對兩條支付流一起生效;四類例外裡它只需要親手擋「不會 revert 的」,金額的判斷留在鏈下。信任模型今天停在 allowance 加 relayer 名單,這是之後要收窄的地方。明天來處理 USDT 連標準介面都過不了的問題:把轉帳封裝成一層 SafeTransfer

明天見。


上一篇
Day 15 | 鏈上鏈下對齊:Reconciliation Engine 實作
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言