iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

本日程式碼:repo tag day-19

我們的結算合約目前只會當場結清:交易上鏈的那一刻,錢已經從 payer 直達 merchant,事後發現問題想把錢要回來,只能指望 merchant 願意自己轉回去。但不少支付業務要的順序是反過來的:先確定錢收得到,等貨出了、服務做完了,錢才真的付出去,中間出事就原路退回。傳統支付把這叫「授權」與「請款」,Web3 世界的合約則叫它「託管」。今天順便處理另一件拖了很久的事:這套系統替人搬了這麼多天的錢,手續費一毛都還沒收過。

今天的目標

我們今天給 Settlement 加上第二條搬錢的路:hold() 把 payer 的錢先收進合約,release() 照講好的條件拆帳(amount 減 fee 給 merchant、fee 給 feeRecipient),refund() 全額退回。動工之前先看這幾個公開設計:

  1. Stripe 的 Place a hold on a payment method 開頭是「When you create a payment, you can place a hold on an eligible payment method to reserve funds that you can capture later」,所以可知「先保留、後請款」是傳統支付本來就有的動作,文件自己舉的例子是飯店入住前先授權、退房時才請款
  2. OpenZeppelin 4.x 的 RefundEscrow(5.x 的 API 裡已經沒有這個合約)把權限寫成「The owner account (that is, the contract that instantiates this contract) may deposit, close the deposit period, and allow for either withdrawal by the beneficiary, or refunds to the depositors」,所以可知一份託管合約的骨架只有三件事:收進來、放給收款人、退回付款人
  3. Request Network ERC20FeeProxy 講手續費的那句是「The contract also ensures that the feeAmount amount of the token transfer will be forwarded to the feeAddress」,而 feeAmountfeeAddress 都是函式參數,所以可知這個公開前例把手續費的金額與去向交給呼叫端決定,費率的計算不進合約

做完之後 repo 中的 Settlement.sol 會多出三個函式:

function hold(
    address token, address payer, address merchant, uint256 amount,
    uint256 fee, address feeRecipient, bytes32 ref, uint64 refundAfter
) external;
function release(bytes32 ref) external;
function refund(bytes32 ref) external;

託管在結算合約裡

先把兩條路擺在一起看:

你可能會問:託管為什麼不另立一份 Escrow 合約?這是因為 replay 防護沒辦法拆出去獨立運作。要是託管自己一份合約,paid 就會有兩份,同一筆付款可以在結算合約結一次、再到託管合約託管一次,鏈上最後一道防線就裂成兩半。所以 hold() 跟其他入口一樣先走 _reserve:ref 在錢進來之前就被占走,之後不管誰拿同一個 ref 走哪個入口,撞到的都是同一句「ref already paid」。

入金要一毛不差才行

我們來看看合約設計細節:

  1. 合約中 hold() 是唯一要檢查實收的入口
  2. 為什麼?因為當場結清錢一次到位、合約自己不留錢,短少算鏈下的事;但 hold() 的錢要留在合約裡,之後 release/refund 動用的是合約自己的餘額,記錄寫多少就得真收到多少,不然之後付不出去
  3. 具體錯誤的情形會長怎樣?fee-on-transfer token 入金抽水 1% → 記錄寫 100、帳上只有 99 → release/refund 想搬「記錄上的 100」時餘額不夠 → 卡死,要人工救
  4. 所以依據上面的情形,我們有兩種因應方式:放行 or 拒收,其中拒收比較好收拾殘局
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(payer, address(this), amount);
require(IERC20(token).balanceOf(address(this)) - balanceBefore == amount, "Settlement: deposit arrived short");

holds[ref] = Hold({
    token: token,
    payer: payer,
    merchant: merchant,
    amount: amount,
    fee: fee,
    feeRecipient: feeRecipient,
    refundAfter: refundAfter
});

hold 記錄刻意晚一步寫,順序是先卡一個 ref、再搬錢、最後才寫記錄。卡位 ref 這一步跟之前一樣走在搬錢之前(重入進來照樣被 _reserve 擋下),真正延後的只有記錄本身。這樣一來,「記錄存在」就永遠等於「錢已入帳」,release 與 refund 看到記錄就能放心付錢,不必再檢查餘額。

手續費只在結清時收

fee 與 feeRecipient 都在 hold() 那一刻講定並寫進記錄:費率怎麼算(抽百分比、收固定費、大客戶打折)全是鏈下的判斷,合約只保證照著拆,事後沒有任何入口能改條件,owner 也不行。這跟 Day 16 把金額核對留在鏈下是同一個習慣:判斷住在鏈下,合約只負責照著執行。

至於當場結清的三個入口,今天維持一進一出、不收費:拆帳要有一個「錢重新分配」的時刻才成立,release 正好是這個時刻——錢已經躺在合約裡,一次分給兩個地址是順理成章的事;settle() 那種一筆直達的路沒有這個時刻,硬塞進去,三個入口的簽章與 event 都得跟著改,改動範圍太大,先不動它們。

錢進了合約之後,只有三條出路:

由圖可知:

  1. release 的順序照舊:先刪 hold 記錄、再搬錢,跟 _reserve 的「先占 ref、再搬錢」是同一條規則,重入殺回來撞到的是「no hold for this ref」。
  2. refund 這裡我選全額退,包括本來要收的 fee:手續費是結清的對價,一筆沒走完的付款不該讓 payer 出錢。
  3. 退款之後 ref 也不會解鎖,重新收款走新的 intent 與新的 ref,也就是狀態機那條「修正靠新 intent」。

誰能把錢拿出合約

「誰能把錢拿出合約」是信任邊界的問題:那時 payer 交出去的只是 allowance,錢至少還留在自己口袋;現在錢真的住進了我們的合約裡(即便只是暫時)。其實 hold() 那一刻就寫死了金流只能往哪裡跑:release 只會給 merchant 與 feeRecipient,refund 只會回到 payer,名單上的 relayer 也改不了收款人。

圖上唯一不需要名單的那條路留給 payer 自己:refundAfter 一過,拿回自己的錢不必等任何人點頭。

Stripe 講授權過期時提到「If the authorization expires before you capture the funds, the funds are released and the payment status changes to canceled」,所以可知「放著不管,錢要回到付款人身上」本來就是這個機制的另一半,差別在發卡組織(Visa 或 Mastercard 等等)會自動退,我們的合約不會:鏈上沒有排程這種東西,每一筆退款都要有人發交易,refundAfter 只是把「這個人可以是 payer 自己」的門打開。至於「到期該不該主動退、誰去提醒 payer、讓 listener 認得 HeldReleasedRefunded 這三個新 event」等等,都是鏈下的工作,今天先不談。

小結

今天我們討論的是「錢住在合約裡的那段時間」所發生的事還有各種措施:

  • hold() 收錢時把 ref 先占用、檢查實收、把 fee 與 refundAfter 一次約定好
  • release() 照記錄拆帳,手續費在這一刻才真的收到
  • refund() 全額退回,ref 不解鎖
  • 合約持有的錢都要存在一筆對應的 hold 記錄,且錢離開合約時只有拆帳與退回兩條路

到目前為止,一筆結算的收款人都只有一個 merchant。明天來研究一次要付給一大群地址的「Bulk Transfer」,比較逐筆 batch 與 Merkle airdrop 兩條路。

明天見。


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

尚未有邦友留言

立即登入留言