本日程式碼:repo tag day-19
我們的結算合約目前只會當場結清:交易上鏈的那一刻,錢已經從 payer 直達 merchant,事後發現問題想把錢要回來,只能指望 merchant 願意自己轉回去。但不少支付業務要的順序是反過來的:先確定錢收得到,等貨出了、服務做完了,錢才真的付出去,中間出事就原路退回。傳統支付把這叫「授權」與「請款」,Web3 世界的合約則叫它「託管」。今天順便處理另一件拖了很久的事:這套系統替人搬了這麼多天的錢,手續費一毛都還沒收過。
我們今天給 Settlement 加上第二條搬錢的路:hold() 把 payer 的錢先收進合約,release() 照講好的條件拆帳(amount 減 fee 給 merchant、fee 給 feeRecipient),refund() 全額退回。動工之前先看這幾個公開設計:
feeAmount amount of the token transfer will be forwarded to the feeAddress」,而 feeAmount 與 feeAddress 都是函式參數,所以可知這個公開前例把手續費的金額與去向交給呼叫端決定,費率的計算不進合約做完之後 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」。
我們來看看合約設計細節:
hold() 是唯一要檢查實收的入口hold() 的錢要留在合約裡,之後 release/refund 動用的是合約自己的餘額,記錄寫多少就得真收到多少,不然之後付不出去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 都得跟著改,改動範圍太大,先不動它們。
錢進了合約之後,只有三條出路:
由圖可知:
_reserve 的「先占 ref、再搬錢」是同一條規則,重入殺回來撞到的是「no hold for this ref」。「誰能把錢拿出合約」是信任邊界的問題:那時 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 認得 Held、Released、Refunded 這三個新 event」等等,都是鏈下的工作,今天先不談。
今天我們討論的是「錢住在合約裡的那段時間」所發生的事還有各種措施:
hold() 收錢時把 ref 先占用、檢查實收、把 fee 與 refundAfter 一次約定好release() 照記錄拆帳,手續費在這一刻才真的收到refund() 全額退回,ref 不解鎖到目前為止,一筆結算的收款人都只有一個 merchant。明天來研究一次要付給一大群地址的「Bulk Transfer」,比較逐筆 batch 與 Merkle airdrop 兩條路。
明天見。