本日程式碼:repo tag day-18
目前為止,一筆付款要兩筆鏈上交易:payer 先發一筆 approve 把額度授權給結算合約,relayer 才發第二筆把錢搬走。第一筆沒有搬到任何一塊錢,卻要 payer 自己備 gas、自己等它上鏈,而且授權出去的額度跟這一筆付款無關,付完之後還留在鏈上。今天我們就來把第一筆從每一筆付款裡拿掉。
我們今天要替 Settlement 開第三個入口 settleWithPermit():payer 不再對結算合約 approve,改成離線簽一份 EIP-712 資料,relayer 帶著那份簽名上鏈。我們直接來抄答案:
approve and the smart contract call which will internally call transferFrom). Even in the simple use case of paying another person, they need to hold ETH to pay for transaction gas costs」:多出來的那筆交易跟「payer 手上要有 ETH」是同一個問題的兩面permitWitnessTransferFrom 的 witness 是「arbitrary data passed through that was signed by the user」,我們實作上會把 PaymentRef 與 merchant 掛在這邊repo 中 contracts/evm/src/Settlement.sol 對外只多一個入口,另外 constructor 多吃一個參數,Permit2 的位址是部署時傳進去的:
function settleWithPermit(
address token, address payer, address merchant, uint256 amount,
bytes32 ref, uint256 deadline, bytes calldata signature
) external;
要拿掉那筆交易,得先知道它是哪裡來的。approve 加 transferFrom 是 EIP-20 在 2015 年定下的組合,規格形容它是「a withdraw workflow」,owner 先劃一塊額度給 spender,spender 之後自己來扣。token 進得了合約靠的就是這個組合,DEX、借貸、我們的 pull 支付流,全部蓋在它上面。
用了幾年之後,這個組合有兩個問題被反覆檢討:
額度跟實際用量不成比例:額度是照 spender 記的,payer 每接觸一個新的應用就要重新上鏈 approve 一次。為了不要一天到晚重簽,應用乾脆請使用者一次授權到最大值,Permit2 的發布文自己講得很白:「For convenience's sake, applications asked users to approve the maximum allowance, giving applications access to a wallet's entire token balance for an indefinite amount of time」。授權的範圍是整個錢包餘額、期限是無限期,跟使用者實際要做的那件事完全不成比例。
改額度必須本人上鏈:approve 認的是 msg.sender,想動自己的額度就得自己發一筆交易。這正是開場那筆帳裡怎麼省都省不掉的那一筆:後面的結算誰都能代發,第一筆永遠是 payer 自己的。
接下來幾年的演進都在修這兩件事。
修法是把「表達同意」跟「發交易」拆開:token 合約真正需要的是 payer 同意的證明,而一份離線簽名就足以當證明,交易誰發都行。
把這件事做上主網、後來被當成範本的是 MakerDAO:2019 年 11 月部署的 Dai 在 token 合約裡放了一個 permit 函式(dai.sol),payer 離線簽一份 EIP-712 資料,任何人都可以帶著這份簽名上鏈把額度改好,gas 由帶簽名的人出。這一版跟後來的標準長得不太一樣:簽名裡沒有金額,只有一個 bool allowed,簽下去就是全額授權或整個歸零。
2020 年 4 月,Martin Lundfall 把這個做法寫成了 EIP-2612,提案裡自己註明了出處:「There are already a couple of permit functions in token contracts implemented in contracts in the wild, most notably the one introduced in the dai.sol.」標準化之後的 permit 簽的是任意金額加一個 deadline,授權終於可以只簽這次要用的量。
但 permit 寫在 token 合約裡,要不要實作是每一顆 token 自己的事:USDC 在 FiatTokenV2 補上了它,USDT 則到今天都沒有。一份 ERC 逼不動已經部署的合約,也逼不動新合約採用,發布文自己也認了這件事:「While EIP-2612 made token approves safer with granular allowance approvals, tokens launched before EIP-2612 did not support the permit function and not all newer tokens have adopted it」。對一個 USDC 與 USDT 都要支援的支付系統來說,「有些 token 有」的功能等於沒有。
2022 年 11 月,Uniswap 發布 Permit2,切入點是不再等 token 升級:既然 permit 進不去每一顆已經部署的 token,那就把整套簽名授權的邏輯放進一份大家共用的外部合約。payer 對 Permit2 做一次傳統的 approve,之後每一個要動這顆 token 的應用,拿的都是 payer 的簽名,跟 token 合約有沒有實作 permit 再也沒有關係。授權也從「每個應用各收一份」變成「Permit2 收一份、大家分著用」。
Permit2 的文件把它拆成兩半:「Permit2 is a unification of 2 contracts, SignatureTransfer and AllowanceTransfer.」AllowanceTransfer 管的還是額度,只是額度多了金額與期限(「giving permissions to spenders on a specified amount for a specified duration of time」),SignatureTransfer 則整個繞過額度,就是開頭那份清單引的那一半。同一個 spender 會反覆動錢的場景適合前者(DEX 的 router 就是這樣用的);支付要的是授權剛好等於這一筆付款,所以我們只用 SignatureTransfer。
把三代授權並排,就看得出 SignatureTransfer 改掉的是什麼:
前兩代不管怎麼修,授權的本體都還是 token 合約裡的那份 allowance。SignatureTransfer 是三代裡第一個不留額度的做法,簽名用掉就沒了,沒有東西需要收回。
Permit2 在主網、Optimism、Arbitrum、Polygon、Celo 上都是同一個位址 0x000000000022D473030F116dDEE9F6B43aC78BA3,整合的人不用替每條鏈各查一份設定。
歷史講完,回到我們的系統。整條 pipeline 只有 payer 那一側換了:
左上角那一次,Uniswap 的整合指南寫的是「For each token, users have to submit a one-time traditional approval that sets Permit2 as an approved spender」,那筆 approve 於是從「每筆付款一次」收斂成「每顆 token 一次」。對一個會重複付款的 payer 來說,這是全部的差別。
relayer 那一側則什麼都沒變。交易還是我們簽的、gas 還是我們付的,呼叫的人一樣要在 relayer 名單上,所以前面幾天蓋的 nonce 序列化、卡住的交易加價救援、失敗分類與重試完全不用改動,只有 payer 授權的方式被換掉。
(題外話:這個系列的 actor 名一路用英文,所以泛指收款的那一方時寫 payee,講我們系統裡那個具體角色時一律寫 merchant,兩個詞不混用。)
payer 簽的那份結構裡沒有 payee 這個欄位:簽下去的是「哪一顆 token、最多多少、給哪個 spender 用、什麼時候之前有效」,錢最後進誰的口袋是 spender 呼叫時填的參數。
settleWithPermit 呼叫 Permit2 時因此多帶了兩樣東西:一個 witness 的 hash 以及一段描述它型別的字串。
permit2.permitWitnessTransferFrom(
ISignatureTransfer.PermitTransferFrom({
permitted: ISignatureTransfer.TokenPermissions({token: token, amount: amount}),
nonce: uint256(ref),
deadline: deadline
}),
ISignatureTransfer.SignatureTransferDetails({to: merchant, requestedAmount: amount}),
payer,
keccak256(abi.encode(PAYMENT_WITNESS_TYPEHASH, ref, merchant)),
PAYMENT_WITNESS_TYPE_STRING,
signature
);
那段型別字串不是寫給人看的註解:Permit2 拿它接在自己的前綴後面拼出完整的 typehash,再用這個 typehash 算 digest。它跟 PAYMENT_WITNESS_TYPEHASH 只要差一個字元,算出來的就是另一份 digest,payer 那份簽名當場就對不上。
to 填的還是 merchant,但 merchant 現在同時也躺在 payer 簽的那份資料裡。relayer 想把錢改送給自己,算出來的 digest 就不是 payer 簽過的那一份,Permit2 回 InvalidSigner。金額多請一塊錢也是同一個下場。連 spender 都綁著:那個欄位是 Permit2 拿 msg.sender 自己補進 digest 的,所以同一份簽名拿到另一份合約上也用不了。授權就這樣從「這個地址可以在額度內動我的錢」收窄成「這一筆付款可以成立」。
這裡的 nonce 不必連號,任何一個沒被用過的數字都可以,SignatureTransfer 參考頁講得很白:「Instead of using incrementing nonces, we introduce non-monotonic, or unordered nonces with a nonceBitmap」。鏈上帳戶那條 nonce 剛好相反,它非得連號不可,因為它的工作是排序。
「任何數字都可以」是靠記法做到的。同一頁說「Users will sign a uint256 nonce value where the first 248 bits correspond to the word position of the bitmap to dirty and the last 8 bits correspond to the actual bit position being flipped on」:每個 payer 名下是一整片 bitmap,一個 nonce 對到其中一個 bit,用掉就把那個 bit 翻起來,一個 storage slot 剛好記得下 256 份簽名的紀錄。計數器回答的是「下一個號碼是多少」,bitmap 回答的是「這個號碼用過了沒」,後者天生不在乎順序。
既然任何數字都行,就不必再發一個新的識別碼了:我們直接把 PaymentRef 當 nonce 用。同一筆 intent 不管重簽幾次,nonce 都是同一個,relayer 重送吃掉的也是同一個 bit(deadline 換過的話簽名本身會不一樣,但那個 bit 不會變)。同一筆付款於是在鏈上有兩道各自獨立的 replay 防線。把它們放進一筆 settleWithPermit 的檢查順序裡:
replay 那兩道裡先講話的是我們自己那一道,理由是 paid 的檢查排在呼叫 Permit2 之前。這個順序是刻意的:paid 對三個入口一起生效,Permit2 的 nonce 只管得到簽名版的那一個,兩者都在的時候,回答「這筆付款走完了沒有」的還是我們自己那一份標記。
授權收窄的缺點有三個:
依然需要一次性的 approve:payer 對某顆 token 從來沒授權過 Permit2 的話,簽名再漂亮也搬不動錢。第一次付某顆 token 依然是兩筆交易,只是之後每一筆都省下來。而且那一筆實務上簽的就是無限額度,前面罵過的「整個錢包餘額、無限期」並沒有消失,只是從每個應用各拿一份,變成 Permit2 拿一份大家分著用。
金流中多了一份外部合約:Permit2 是 Uniswap 部署的單一合約,它終究不是我們寫的,payer 的錢從此要經過它。多出來的那次外部呼叫、一次 ecrecover、一個 bitmap 的 storage 寫入,gas 都記在 relayer 頭上:payer 省掉的是一整筆交易,換成我們這一筆變貴一點。合約裡它的位址由 constructor 傳進來、沒有寫死成常數,也是為了這件事:本地 devnet 上沒有人部署過 Permit2,測試得換成自己的替身才跑得動。
簽名會過期、鏈下要有辦法 handle:deadline 一到就要回頭跟 payer 再要一份,於是「什麼時候該重簽」「重簽期間那筆 intent 停在哪一個狀態」變成新的題目,今天鏈下還沒有人負責它們。
前兩點算是取捨而已,所以可以接受,但第三點我們目前還處理不了,留著技術債之後再說。
今天我們討論的是怎麼讓一份簽名剛好等於一筆付款。settleWithPermit 把 pull 支付流的授權從「一份跟付款無關的額度」換成「一份只買得到這一筆付款的簽名」,payer 每筆付款發零筆交易、付零 gas。業界從 2015 年的 approve 一路修到 2022 年的 Permit2,落到我們合約上只是多一個入口,paid 的 replay 防護三個入口共用。
錢到目前為止都是一進一出、當場結清。明天來實作要把錢先留在合約裡的「託管」與「手續費」拆帳(終於要幫系統賺錢了嗎...)。
明天見。