本日程式碼:repo tag day-24
昨天的 Adapter 四題都答得乾淨,很自然會想給它加第五個方法:Build(batch),把一批付款變成一筆交易,一個介面、每條鏈一個實作。今天把兩條鏈的交易內容實際組出來之後,這個方法沒有出現。不只參數的形狀不一樣,兩條鏈連「一次組多少」都沒有共識。EVM 一批就是一筆交易,兩個地址加一份名單就組得出來。Solana 得先把整輪名單收成一棵樹,payer 簽走 root,然後才輪得到「一個區塊一筆交易」的部分。硬要收進同一個方法,參數只能退化成 map[string]any 這種誰都不知道該放什麼的東西。
今天在 chain 裡把兩條鏈組交易的那一半寫出來,全部只用標準函式庫。EVM 的 SettleBatchCalldata 照 ABI 規格手工編碼,一批就是一筆。Solana 的入口是 NewRun,把整份名單逐筆算成葉子、建成樹,Root() 回報 payer 在 init_run 要簽的那 32 bytes,PayBatchMessage() 再把樹上一個對齊區塊組成一份 relayer 要簽的訊息,帶著整批共用的證明。組出來的都是未簽名的內容。誰簽、用什麼曲線簽,刻意留在邊界外面,理由後面講。
三份規格,兩份講編碼、一份只是型別宣告:
state.rs 裡,token 帳戶餘額的宣告是「pub amount: u64,」:帳戶只裝得下 u64,任何轉帳金額自然也只有 u64做完之後 repo 中 internal/chain 的 Example_buildTheSameRunTwice 會拿同一份 12 筆的名單組兩次,輸出長這樣:
run 12 payouts, 100 USDC each
evm calldata 1,284 bytes selector 0xd0e1d648 refs 12/12 aboard
solana root 64146f265ad5bb94… 16 leaves depth 4 2 blocks
solana block 0 signed tx 896 bytes (bulk estimated 896) refs 8/12 aboard
solana block 1 signed tx 604 bytes (bulk estimated 604) refs 4/12 aboard
solana a 1-payout run: signed tx 352 bytes (bulk estimated 353)
bulk 拿固定 280、每筆 73、證明每層 32 的線性公式算出來。今天它第一次不是估的:block 0 的八筆序列化出來就是 896 bytes,跟 bulk.Pack 記在那一批上的用量一模一樣。300 筆那輪也對過,38 批、滿批 1,056、最後一批 764,每一批都逐 byte 相等,兩邊從此釘在一起兩個 builder 吃同一份輸入,bulk.Payout 的清單:merchant、金額、ref,一筆付款的身分。它們也背同一條義務,每把 ref 都要一字節不差地在輸出裡出現一次。這條義務是整個系統跨鏈的支點,listener 與對帳引擎在哪條鏈上都拿同一把 ref 對回同一筆 intent。ref 是唯一原封不動走出共用世界的東西。
連 Payout 裡的 merchant 都過不了這條線。EVM 的 builder 拿它當地址解析,解析不動就整批拒絕。Solana 的 builder 根本不看它,看的是呼叫端另外交進來的 token 帳戶清單,因為「這個 merchant 用哪個帳戶收這顆 token」要先去鏈上查,名單自己說了不算。而在新設計裡,這份帳戶清單的地位又比從前重了一層:token 帳戶會被編進葉子,也就是被編進 payer 簽的 root 裡。程式在鏈上重算葉子時,拿的是交易裡「實際要被轉帳的那個帳戶」的地址,relayer 想把收款帳戶掉包,葉子就對不上 root,整批被拒。名單保護自己的方式,是把每一個會動到錢的地址都簽進承諾裡。
EVM 那半的入口是 SettleBatchCalldata(token, payer, items):兩個地址、一份名單,呼叫一次得到一筆交易的內容。Solana 那半的入口 NewRun(items, tokenAccounts) 一口氣收下整輪名單,回傳一個抱著整棵樹的 PayoutRun,之後 PayBatchMessage(accounts, blockhash, block) 才一個區塊一份地把訊息組出來。入口的差距直接來自兩條鏈對「付這份名單要幾筆交易」的答案。
葉子的編碼是兩種語言各寫一次、錯一個 byte 就付不出錢的東西:編號(u16 LE)、merchant 的 token 帳戶(32)、金額(u64 LE)、ref(32),掛上 merkle 的 domain byte 進 SHA-256。鏈上程式在 pay_batch 裡用一模一樣的順序重算,所以 Go 這邊多墊了一個規矩,名單不足一個區塊時先墊滿 8 片空葉子再建樹。程式那一側的樹最小就是一個區塊寬,少墊這一下,兩邊的 root 就是兩個值。這種「兩邊各寫一次」的東西照系列的慣例釘 golden,同一份固定輸入,Go 的測試釘 4e6fa44a…,Rust 的測試釘同一個常數,哪天有人在任何一邊動了編碼,測試會先於 payer 發現。
同一筆金額在兩邊也裝進不一樣的容器。calldata 給它一整個 word 的 uint256,Solana 的指令資料只給 8 個 bytes,因為 token 帳戶的餘額本身就是 u64。裝不下的金額直接拒絕,不悄悄截斷。至於「merchant 還沒有 token 帳戶」那件事,新設計把它整個搬到付款之前,prepare batch 先開好帳戶,付款批裡每一項一樣貴。bulk 那組常數今天一個都沒改,變的是其中一半終於有了對照組:pay_batch 的 280、73、32 從此每一批都跟序列化出來的長度對得上,prepare 的 262 與 74 還是只有估算撐著,開帳戶那段訊息今天沒組,等它真的存在才有得對。
EVM 這邊最能代表邊界的是 calldata 的前四個 bytes:
// settleBatchSelector 是 settleBatch(address,address,(address,uint256,bytes32)[]) 的函式選擇子:
// ABI 規定 calldata 的前四個 bytes 是函式簽章 keccak256 的前四個 bytes
// (https://docs.soliditylang.org/en/v0.8.26/abi-spec.html)。
// keccak256 不在 Go 標準函式庫裡,所以這四個 bytes 是用 Foundry 的
// `cast sig "settleBatch(address,address,(address,uint256,bytes32)[])"` 算好釘死的:
// 選擇子是資料:跟著函式簽章走,簽章不變它就不變。
const settleBatchSelector = "\xd0\xe1\xd6\x48"
這個 repo 的 Go 側到今天為止零外部依賴,而讓它破功的第一個候選人居然只是四個 bytes?不值得。選擇子跟著簽章走,而 settleBatch 的簽章早就定下來了。把它當資料寫下來,整段輸出再拿 cast calldata 對過一次,比把 keccak 搬進來便宜得多。
讓估計差一個 byte 的元兇在 Solana 這邊:
// appendCompactU16 是 Solana 的 compact-u16 長度編碼:7 個 bit 一組、little-endian,
// 最高位表示後面還有;127 以內一個 byte、16383 以內兩個。這就是「128 以下省一個 byte」
// 的那個編碼,bulk 的線性估算在資料長度跨過 128 的那一刻會多算一個 byte。
func appendCompactU16(b []byte, v int) []byte {
for {
c := byte(v & 0x7f)
v >>= 7
if v == 0 {
return append(b, c)
}
b = append(b, c|0x80)
}
}
這一個 byte 的誤差方向剛好是安全的那一邊:估計拿來決定要不要切,寧可高估;序列化的結果拿來上鏈,錯一個 byte 都送不出去。測試裡最像鏈上程式的是這一條:它把組好的訊息當成程式收到的東西,從 bytes 尾端切出證明、照文件把葉子重算一遍,再用 merkle.VerifyBlock 走回 root。訊息自己帶著被驗證需要的一切,所以 relayer 重組一筆交易時不需要記得任何事。
nonce 與出價這類「怎麼送」的資料,跟「付給誰、付多少」不是同一種東西,而兩條鏈把它們放在不一樣的位置:
替換在 EVM 上可行、在 Solana 上不存在,之前是當成兩條鏈各自的規矩背下來的。從 bytes 的版面看,它只是「blockhash 有沒有被簽進去」的直接後果。實際對過:同一個區塊換一個 blockhash,兩份 message 只差那 32 個 bytes,而那 32 個 bytes 在簽名蓋住的範圍裡。
有趣的是同一個事實在這個設計裡被判了兩次,判決相反。「blockhash 會過期」在三天前是 payer 預簽整輪交易的死刑:150 個 slot 之後全部作廢。今天它出現在每一份 pay_batch 訊息裡,卻無關痛癢,因為簽這份訊息的是 relayer。訊息過期了?重抓一個 blockhash,重組、重簽、重送,材料(名單、樹、證明)全在鏈下手上,不用驚動任何人。payer 簽的 root 從頭到尾一個 byte 都不用動,因為 root 裡從一開始就什麼會過期的東西都沒有:沒有 blockhash、沒有 nonce、沒有期限以外的任何時間。把「會過期的簽名」分給重簽不花錢的那一方,把「不過期的簽名」留給簽一次很貴的那一方,這一刀就是整個 payout run 設計的分工。
簽名本身也停在邊界外。Solana 的 message 拿 ed25519 直接簽,標準函式庫就有;EVM 要先把信封做 RLP 編碼、keccak 雜湊,再用 secp256k1 簽,三樣沒有一樣在標準函式庫裡。一個共用的簽名器介面,要嘛逼整個 repo 吃下外部依賴,要嘛逼 Solana 陪 EVM 走一段它不需要的路。誰簽、簽什麼,每條鏈自己說了算。
昨天的介面留了一句話:payer 的授權長什麼樣,刻意不在必答題名單上。EVM 的授權物是一份 allowance,活在 token 合約的狀態裡,builder 組 calldata 時根本感覺不到它。Solana 的授權物是一個 root,活在 run PDA 裡,builder 的半數工作都在服務它。同一題的兩個答案連生命週期都不一樣,allowance 授權一個額度、跨越多輪,root 授權一份名單、隨這一輪的 clawback 一起死。硬把「取得授權物」收進共用介面,得到的會是一個 EVM 回空字串、Solana 回 32 bytes 的方法,誰都要繞過它。
反方向也走得通:EVM 那半其實走得出跟 root 同一種收窄,把整批 items 的雜湊簽進 Permit2 的 witness,payer 就從「授權一個額度」變成「授權這份名單」。我們沒走。EVM 上一批本來就是一筆原子交易,allowance 加上合約裡逐筆的 ref 查重,爆炸半徑已經鎖在名單之內,多簽這一次換到的收窄很薄,換掉的卻是 payer「授權一次、之後不用出現」的體驗。Solana 簽 root 是不得已(分批揭露的名單需要一個蓋住全部的承諾),EVM 沒有這個不得已,就不必進口這個解法。
沒做的是 Build 介面本身:兩個 builder 各自公開、各自帶著自己形狀的入口,共用的只有輸入的名單與「每把 ref 都在」這條義務。省下一個介面,代價是呼叫端得知道自己在跟哪條鏈說話。而它本來就知道,intent 身上寫著鏈名。
回頭看這四天:先是把 payer 的簽名收成一個 root,再把 CSV 名單凍成葉子的順序,昨天把四條鏈的規則收進一個索引,今天把名單真的變成兩條鏈的 bytes。撥款這條線的鏈下半場到此接完,剩下的是把 Sender 與 Watcher 接上真的 RPC。
明天輪到四條鏈裡最極端的那一條:TON 的 async message,連「一筆交易」這個詞在那裡都得重新定義。
明天見。