iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰系列 第 21

Day 21 | Solana 批量分發的三條路線:PDA Vault、Merkle Distributor 與圈存簽 root

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-21

一份 300 筆的撥款名單,在 EVM 上塞得進一筆交易。同一份名單搬到 Solana,切成幾十筆交易是逃不掉的,因為一筆交易的上限是 1,232 bytes(9/9 更新成 4,096 bytes),而一筆付款光收款帳戶加金額加 ref 就要七十幾 bytes。而交易一多,一個 EVM 上不存在的問題就冒出來了:這幾十筆交易,payer 要簽幾次?今天要做的設計答案是一次。payer 簽一筆交易,把整輪的錢與整份名單一起鎖上鏈,之後的每一筆都由 relayer 代簽代付。

今天的目標

業界的標準答案是用 Anchor 寫,或直接用審計過的 merkle distributor,Saber 與 Jito 的都是公開的。我們照它們的行為手寫最小子集(注意,正式專案請直接用現成的,這裡自己寫是為了把機制拆開)。今天要做的東西橫跨兩邊。鏈上是 contracts/solana/payout-run,一個只依賴 solana-program 的原生程式,三個指令 init_runpay_batchclawback。鏈下是 internal/merkle(把名單收成一個 root、替對齊的區塊開證明)與照新的批形狀重寫的 internal/bulk。該讀的東西,兩份是規格、兩份是實作:

  1. Solana 文件的交易結構那頁寫「The total serialized size of a transaction must not exceed PACKET_DATA_SIZE (1,232 bytes).」,而 1,232 是 IPv6 的最小 MTU 1,280 減掉 48 bytes 的表頭:一筆交易裝得下多少東西是網路層決定的。同一頁還有一句得一起讀:「The upcoming v1 format raises the size limit to 4,096 bytes and uses a different wire layout.」,今天算出來的每一個數字都有保存期限
  2. 同一套文件裡的交易頁寫「A transaction's recent blockhash is valid for 150 slots.」:一筆簽好的交易放不了多久,這件事會直接決定 payer 能簽什麼
  3. Saber 的 merkle-distributor 開頭那段 Rationale 講得最直接:「Although Solana has low fees for executing transactions, it requires staking tokens to pay for storage costs, also known as "rent". These rent costs can add up when sending tokens to thousands or tens of thousands of wallets, making it economically unreasonable to distribute tokens to everyone.」,下一段接著寫「This puts the gas cost on the claimer.」:claim 模型的存在理由是把 rent 與交易費推給領錢的人
  4. Jito 的 distributor 把同一條路拿去發真錢,它的 state 裡有三個欄位管 clawback:clawback_start_tsclawback_receiverclawed_back。錢發出去之前,「什麼時候拿得回來、拿回到哪裡」就寫死在帳戶裡

做完之後 repo 中 internal/bulkExample_packAPayoutRun 會拿同一份 300 筆的名單,分別照兩條鏈的限制切一次,輸出長這樣:

run     300 payouts, 100 USDC each
plan    evm      300 payouts  1 batch  0 new accounts  rent 0 wei
batch   #1       300 items  gas 16,003,310/30,000,000
check   12 of the 300 merchants have no token account yet
plan    solana   300 payouts  38 batches  12 new accounts  rent 24,471,360 lamports
tree    512 leaves  depth 9  proof 6 hashes per batch
prep    #1       12 accounts  bytes 1,150/1,232  accounts 29/64
batch   #1       8 items  bytes 1,056/1,232  accounts 13/64
batch   #38      4 items  bytes 764/1,232  accounts 9/64
  • EVM 那一行照舊:gas 16,003,310 是拿結算合約 batch 入口的兩個實測點(一項 90,530、兩百項 10,681,008)反推斜率再外推的,決定「要不要切」夠用,當帳單不行。但分母那個 30,000,000 是 block 的 target,Fusaka 之後它不再是先撞到的那一條,EIP-7825 替單筆交易另外釘死了 2^24(16,777,216)。300 筆貼著這條線只剩不到 5% 的餘裕,limits.goCap 要換,而這一批該當成剛好塞得下,不是塞得很鬆
  • Solana 的 bytes 是照 pay_batch 交易的格式逐欄位加出來的估算,每個加項都寫在 limits.go 的註解裡,而且刻意寧可高估:真正的長度要等交易組出來才知道,這裡只保證「不會先組出一批一定送不出去的名單」
  • 那個 check 的 12 是假的:Example 直接假設每 25 個 merchant 有一個還沒開帳戶。真的要送之前這一欄得逐個去鏈上查,查出來的數字會連帶改變 prep 有幾批、rent 要備多少
  • rent 的算式沿用(一個 SPL token 帳戶是 165 bytes,rent 的算法(165 + 128) x 3,480 x 2,一個帳戶 2,039,280 lamports),這筆錢鎖在新帳戶裡,帳戶關掉才拿得回來

payer 只簽一次,簽的不能是交易

最直覺的做法活不過兩分鐘。讓 payer 把 38 筆轉帳交易全部預簽好、交給 relayer 慢慢送,而交易帶的是 recent blockhash,照上面那句規則只有 150 個 slot 的壽命,官方 guide 換算大約 80 到 90 秒。第一批還沒送完,後面的就過期了。

有一條看起來更短的路是 durable nonce:同一份 guide 講的做法,開一個 nonce account 代替 recent blockhash,簽好的交易就不會過期。但一個 nonce account 一次只裝得下一個 nonce,38 筆平行的交易要開 38 個帳戶、各墊一份 rent。而且交易一旦簽死,批的形狀也跟著簽死,哪一批壞掉要重組,就得回頭再麻煩 payer 一次。這條路解掉了過期,但「payer 得一直在場」的問題原封不動。

所以 payer 簽的不能是交易,得是一個不會過期的授權。SPL Token 現成的答案是 delegate,payer 發一筆 Approve,把一部分餘額的動用權交給我們程式的 PDA。它有兩個問題。第一個寫在 token 帳戶的結構裡。Account 上放 delegate 的只有一個欄位(delegate: COption<Pubkey>),一個帳戶同一時間只認得一個 delegate,payer 哪天要授權第二個服務,我們的就被蓋掉。第二個更根本:delegate 授權的是一個額度,額度內要付給誰、付多少,全看拿到授權的人,這跟 allowance 那種「額度內任意付款」是同一種寬度的信任。名單有 300 行,payer 同意的是這 300 行,簽出去的授權卻管不到任何一行。

把「同意的就是這份名單」直接簽出去的辦法,昨天已經拆過機制了:整份名單收成一個 32 bytes 的 merkle root,改一個字就是另一個值。payer 簽 root,relayer 之後帶名單的內容回來對,這一次授權的寬度剛好等於名單本身。

兩條公開路線各借一半

Solana 上把「一次付一大群人」做成公開程式的有兩條路線,第一步都是把錢先收進程式的 PDA,差別在錢怎麼出去:

merkle 那條的缺點昨天收過一次:merchant 要的是入帳。它把成本推給領錢的人,是因為 airdrop 的名單大到 rent 付不完,Saber 那段 Rationale 講的就是這件事;撥款的名單是幾百筆,rent 我們自己付得起,「等人來領」就沒有理由留著。vault 那條反過來:錢由程式付出去、merchant 當場入帳,形狀是對的,但名單只活在呼叫端手上,程式收到什麼付什麼。

我們的第三條路是各借一半:跟 vault 那條借「程式簽名、分批付出去」,跟 distributor 那條借「root 簽住名單」與 Jito 那組 clawback 欄位,唯獨不借「等人來領」。傳統支付把「先把一筆錢鎖起來、之後再慢慢請款」叫「圈存」,這條路做的就是鏈上版的圈存:錢先鎖進 vault,名單同時簽死,付款由 relayer 分批執行,期限一到沒付完的原路退回。

一輪撥款收成一個 run

payer 唯一要簽的那筆交易是 init_run:把整輪的總額從自己的 token 帳戶付進 vault、把名單的 root 與 clawback 的期限寫進 run 帳戶,三件事在同一筆交易裡完成。之後的一切都不再需要 payer:

pay_batch 帶一個對齊區塊的內容回來:8 筆的金額與 ref、一份「區塊走回 root」的證明。程式重算葉子的時候,merchant 的地址取自交易實際帶進來的帳戶,指令資料裡沒有這一欄:

    for i in 0..BLOCK {
        if i < count {
            let at = 3 + i * 40;
            let amount = u64::from_le_bytes(data[at..at + 8].try_into().unwrap());
            let index = (block * BLOCK + i) as u16;
            level[i] = hashv(&[
                &[0u8],
                &index.to_le_bytes(),
                merchants[i].key.as_ref(),
                &amount.to_le_bytes(),
                &data[at + 8..at + 40],
            ])
            .to_bytes();
            amounts[i] = amount;
        } else {
            level[i] = [0u8; 32]; // 墊出來的葉子:全零,跟鏈下的 PadLeaf 同一個值。
        }
    }

relayer 想把錢改送給自己,就得把帳戶清單裡的收款帳戶換掉,而換掉的那一刻葉子就是另一片,重算出來的 root 對不上 payer 簽的那一個,整批 revert。金額多一個 lamport、順序對調兩筆、拿第 3 批的證明來付第 4 批,全部同一個下場。付款的順序則是先翻 bit 再付出去。每片葉子在 run 帳戶裡有一個 bit,程式先把整個區塊的 bit 翻起來,再逐筆轉帳,所以重入的呼叫會先撞到已經翻起的那一個。交易本身是原子的。轉帳失敗的話,翻過的 bit 跟著整筆回滾,沒有「翻了沒付」這種狀態。

過了 clawback_start_tspay_batch 一律拒絕,clawback 才開得動。線前只有付款,線後只有退回,兩邊不會在期限邊上賽跑,因為 clawback 跟付款共用的是同一條時間線。退回的去向在 init_run 就寫死是 payer 自己,退完把 vault 與 run 兩個帳戶關掉,rent 的 lamports 也回到 payer 手上。「隨時把錢提走」這件事刻意不存在:圈存對 merchant 的意義就是這段期間錢動不了,提走的自由留給期限後。

一批 8 筆,證明跟著名單長

批為什麼固定 8 筆、還要切在 8 的倍數邊界上?因為證明是整批共用的。樹的機制昨天拆過:驗一片葉子要沿路的兄弟節點,一片一份證明就是 6 個雜湊 192 bytes,8 筆帶 8 份光證明就一千五百多 bytes,交易直接爆掉。但 8 片連續、而且對齊在區塊邊界上的葉子,可以先收成區塊自己的根,再共用一份「區塊走回 root」的證明。貪心切出來的邊界跟樹的區塊對不齊,這份共用就不成立。一批的帳這樣算:

證明的長度由整份名單決定。名單墊到 2 的冪次,每翻一倍樹就高一層,每一批就多帶 32 bytes。這給了這個切法一個自己的天花板:一輪到 16,384 筆還裝得下(滿批 1,216 bytes),再多一筆,樹墊到 32,768 片、證明變 12 層,滿批 1,248 bytes 就超過 1,232 了。bulk 遇到這種名單會直接回 ErrBlockTooLarge,因為再切細也救不了滿批,要嘛拆成兩輪、要嘛把 Align 降到 4。

開帳戶整個抽出來,排進送錢之前的 prep batch(輸出畫面的 prep #1,一筆交易開得了 13 個帳戶),rent 由 relayer 的錢包先墊。要開帳戶的那幾個 merchant 於是不再讓某一批變貴。這樣付款批的價錢只跟項數有關,每一批的證明一樣長,「一批裝多少」才是一個算得出來的數字。

小結

今天把一輪撥款收成鏈上的一個 run。payer 簽一筆 init_run,換到的是之後 38 筆交易都不用再簽,而賭上的是名單開跑之後改不了:root 把 300 行整份簽死,名單漏了一個 merchant,這一輪沒有任何補救的入口,只能等期限到、clawback 退回、重開一輪。這個賭注靠的前提是「名單在 init_run 之前已經驗證定稿」,而那份驗證今天還不存在。

至於數字的保存期限:一批 8 筆是照 1,232 算出來的。v1 格式的 4,096 bytes 上線那天,Align 可以升到 32、同一份名單從 38 批掉到 10 批。wire layout 既然換了,BaseItem 那些逐欄位加出來的數字也得重算一遍,但要動的就只是 Limits 這張表裡的幾個欄位,切批的程式一行都不用改,這也是當初把批的形狀做成資料而不是常數的原因。

明天輪到那份名單本身:從 CSV 進來到簽進 root 之間,壞掉的那幾行要在哪一關被攔下來。

明天見。


上一篇
Day 20 | Bulk Transfer 設計:逐筆 Batch vs Merkle Airdrop
下一篇
Day 22 | Partial Failure:CSV 驗證到上鏈的容錯處理
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言