本日程式碼:repo tag day-20
一個平台月底要同時撥款給 300 個 merchant,照現在的做法就是排 300 份 job、送 300 筆 settle()。這條路走得通,nonce 有人排,卡住有人救,失敗有人分類。問題出在成本。每一筆交易光是「作為一筆交易」就要先付 21,000 gas 的基本開銷,300 筆就是 630 萬 gas 花在搬錢以外的地方。而這 300 筆都要從同一個發送帳戶出去,就得在那個帳戶的 nonce 序列上排 300 格,中間任何一格卡住,後面的全部留在 mempool 裡進不了 pending。今天來把整批撥款收進同一筆交易。
我們今天要給 Settlement 開第四個當場結清的入口 settleBatch(),它做的事是同一個 payer、同一顆 token,一筆交易對一批 merchant 各結一筆付款。外面把「一次付一大群地址」做成公開產品的,主要是兩條路線。
做完之後 repo 中的 Settlement.sol 會多出一個 struct 與一個入口:
struct Payout {
address merchant;
uint256 amount;
bytes32 ref;
}
function settleBatch(address token, address payer, Payout[] calldata items) external;
兩條路線都能把「一次付很多人」搬上鏈,分開它們的是兩個問題:每一個收款人的 gas 誰出?錢什麼時候動?
| 逐筆 batch | Merkle airdrop | |
|---|---|---|
| 誰發起轉帳 | relayer 送一筆交易,裡面 N 筆轉帳 | 收款人各自帶 proof 呼叫 claim |
| 錢什麼時候動 | 當場到帳,每筆各帶自己的 ref | 先停進合約,等人來領 |
| gas 誰出 | 全由發放方出 | 領錢的人自己出 |
| 天生的限制 | 一批塞不過一個區塊 | 領不領、何時領都不可控 |
merkle root 那條把發放方的成本壓到一筆交易,名單一萬人跟十萬人都一樣。代價是 claim(index, account, amount, merkleProof) 由收款人自己呼叫,每領一次就是一筆獨立的鏈上交易。
這條路線的成名作是 2020 年 9 月的 UNI 空投。Uniswap 宣布「400 UNI are claimable by each address that has ever called the Uniswap v1 or v2 contracts」,名單是所有用過 v1 或 v2 的地址,大到不可能整份放上鏈,而當年領 UNI 的那份合約在 Etherscan 上掛的名字就是 MerkleDistributor,它上鏈的只有一個 32 bytes 的 root。那份合約到今天還躺著一千兩百多萬顆沒被領走的 UNI,「領不領隨緣」這件事在 Etherscan 上是一筆看得到的餘額。
下面引用的是 repo 上今天的版本(Solidity 0.8.17,用 custom error)。2020 年部署的那一份還是 0.6 時代的寫法,點進 Etherscan 看到的錯誤訊息會是 MerkleDistributor: Invalid proof. 這種字串。
root 是這樣長出來的。名單的每一行先各自 hash 成一個 leaf,MerkleDistributor.sol 裡那行是 bytes32 node = keccak256(abi.encodePacked(index, account, amount));,然後兩兩再 hash、一層一層往上收,收到只剩一個值為止,那個值就是 root。名單任何一行改一個字,root 就是另一個值。claim 帶的那份 proof,是從自己的 leaf 走回 root 的路上、每一層缺的那另一半。
驗一個 leaf 不用碰其他 leaf,只要沿路的兄弟節點。名單 300 筆,樹高 9 層就蓋得過(2 的 9 次方是 512),一份 proof 就是 9 個 hash 上下,名單漲到一萬筆也只長到 14 個。發放方的成本固定在一筆交易,領的人的成本跟著樹高走。
合約端收到 claim 之後做的事因此很少。把呼叫者報上來的 index、account、amount 重新 hash 成 leaf,拿 proof 一層一層算回去,對照的是 if (!MerkleProof.verify(merkleProof, merkleRoot, node)) revert InvalidProof(); 這一行,算出來的 root 跟鏈上那個對不上就整筆 revert。合約不用存名單、也不用查名單,名單的完整性由 root 一個值保證,查證的工作攤給每一個來領的人。領過沒領過記在 mapping(uint256 => uint256) private claimedBitMap; 裡,一個 index 一個 bit,領過就翻起來,再領就撞上 AlreadyClaimed(),跟 Permit2 記 nonce 的手法是同一種。把名單變成樹、替每個地址算出自己那份 proof,全發生在鏈下,repo 裡八成以上的程式碼是 TypeScript,做的就是這件事,合約只負責驗。
對 airdrop 來說上面這一切全是特性:名單大、領不領隨緣、成本攤給有動機來領的人。但撥款的 merchant 等的是入帳,不會自己來 claim。發放方當然可以自己把 300 個 claim 逐一代呼叫,那句「Allows anyone to claim」寫的就是 caller 不限,可是每一個 claim 都是一筆獨立的交易,開場那 630 萬 gas 的基本開銷原封不動回到自己頭上,還要再加上 proof 驗證與 bitmap 寫入。而且錢得先離開 payer、停進合約,撞上我們「合約持有的每一塊錢都要對得回一筆 hold 記錄」的規矩。所以我選逐筆 batch,claim 模型今天不實作,它答的是另一個業務的題目。
界線也要劃清楚。被拒絕的是「等人來領」,不是 merkle。把整份名單簽成一個 32 bytes 承諾,跟誰來發起轉帳是兩件獨立的事。名單塞得進一筆交易的 EVM 用不到前者,之後換到塞不進的鏈上,它會用另一種身分回來。
路線定了,還剩一個決定:錢要不要先進合約過一手?Disperse 的合約裡兩種寫法都有。
先集中再分發(disperseToken) |
逐筆直達(disperseTokenSimple) |
|
|---|---|---|
| 錢怎麼走 | transferFrom 一次收進合約,再逐筆 transfer 分出去 |
每一項各自 transferFrom,payer 直達收款人 |
| allowance | 一整批只改一次 | 每一項改一次 |
| 代價 | 錢中途住進合約,fee-on-transfer 一抽稅就得檢查實收 | gas 比集中版貴一點 |
集中版省的就是 allowance 那一筆,transferFrom 平常每次都要改一次 payer 給合約的額度,集中版一整批只改一次。缺點是踩到兩條既有的限制。
逐筆直達不用管這兩條。缺錢只發生在 payer 到 merchant 這一段,單筆 settle() 本來就會遇到這種情況,可以留給對帳引擎收,而合約自始至終沒有經手一毛錢。
路選完之後,合約裡剩下的東西很少:
function settleBatch(address token, address payer, Payout[] calldata items) external {
require(isRelayer[msg.sender], "Settlement: caller is not a relayer");
require(items.length > 0, "Settlement: the batch is empty");
for (uint256 i = 0; i < items.length; i++) {
_settle(token, payer, items[i].merchant, items[i].amount, items[i].ref);
}
}
兩個 require 之後的那個迴圈就是今天全部的機制。每一項各走一次完整的 _settle,提前取用自己的 ref、搬自己的錢、發自己的 Paid。所以「batch」只存在於交易這一層。listener 收到的是 N 個各自帶 ref 的 Paid,對帳引擎照舊拿 ref 一筆一筆對回 intent,鏈下完全不用改動,連「batch」這個詞都不用學。這也是 Payout 只有三個欄位的原因,一項就是一筆付款,有自己的 intent 與自己的 ref,batch 不給付款第二種身分。順帶多拿到一層保護:batch 裡兩項帶同一個 ref,第二項要取用 ref 時撞到的就是那句「ref already paid」,鏈下組 batch 時漏做去重也擋得下。
實際跑過的數字這樣量:solc 0.8.26、optimizer 開著跑 200 runs、token 是測試用的 mock,看的是 forge test --gas-report 裡 settleBatch 那一列。兩百項的批次是 10,681,008 gas,平均每一項 53,405;只放一項的批次是 90,530。這個口徑只算合約裡的執行,不含開場講的那 21,000 交易基本開銷,也不含 calldata(一項 Payout 佔 96 bytes),要跟 300 筆單獨交易的帳相比得自己補回來。
不過一批裡的每一項並不等價。把三項的批次用 -vvvv 打開,同一筆交易裡三次 transferFrom 分別是 35,384、25,784、25,784 gas。差的那 9,600 是冷熱的差別:token 合約、payer 的餘額那一格、allowance 那一格,都只有第一次碰要付冷的價錢,同一筆交易裡的第二項之後全是熱的(EIP-2929)。省不掉的是每一項自己的 paid[ref] 與各自 merchant 的餘額,那兩格每一項都得冷寫一次,五萬三的主體就是它們(測試裡的 merchant 餘額從零開始,正式環境上已經有餘額的 merchant,這一格會便宜很多)。所以批次真正省下來的有兩份,一份是每筆 21,000 的入場費,一份是只付一次的暖機錢。只放一項的批次兩份都沾不到邊,這就是那 90,530 看起來偏貴的原因。
至於一批能塞多長,ethereum.org 的區塊文件寫「Each block has a target size of 30 million gas but the size of blocks will increase or decrease in accordance with network demands, up until the block limit of 60 million gas (2x target block size)」,一筆交易再大也塞不過一個區塊。對照 60M 這個數字,一批幾百項很從容,幾千項就該拆成幾批送。名單大到連拆成幾批送都不划算的時候,才輪得到 merkle root 出場。
300 項裡只要有一個 merchant 收不了錢,鏈上與鏈下就各有一件事要接:
另一種寫法是逐筆容錯,迴圈裡包 try/catch,壞的跳過、好的照結。看起來體貼,但它會生出 partial success 這種狀態,哪幾項成功要逐項對 event 才知道,鏈下得多養一種「batch 結了一半」的收拾流程,而那正是 batch 最難收拾的形狀。整個 batch 回滾把結果收成全成或全敗兩種,重試整個 batch 跟重試一筆一樣安全,batch 只是把重試的單位變大了。真正的容錯在上鏈之前。一份 300 行的撥款名單,壞的那幾行本來就該在鏈下驗證時被剔掉,名單怎麼驗、被剔掉的那幾筆怎麼收場,之後再討論。
今天我們討論的是怎麼把一批付款收進一筆交易。settleBatch() 對一批 merchant 各結一筆付款,每一項保有單筆付款的完整身分,鏈下的 listener 與對帳引擎完全不用改動。錢逐筆從 payer 直達 merchant,合約照舊一毛不留,一項失敗整個 batch 回滾,不留半個被取用的 ref。Merkle airdrop 那條路先放著,它要的是名單大、gas 由領的人出、錢停在合約裡等人來領,那是 airdrop 的形狀,而撥款要的是入帳。
明天會深入討論 Solana 上的兩種批量分發解決方案。
明天見。