iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

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

Day 25 | TON 的極端案例 (上):Async Message 所帶來的 Paradigm Shift

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-25

在 EVM 上把 100 USDC 付給一個 merchant 是一筆交易的事:relayer 簽一筆交易,合約在那筆交易裡把錢搬過去,成功還是 revert,同一筆交易裡就有答案。同一筆付款搬到 TON 上,relayer 簽的東西到了錢包合約就停了。錢包那筆交易只做一件事,送一則 message 給我們自己的 jetton wallet。jetton wallet 收到之後自己跑一筆交易,扣掉餘額,再送一則 message 給 merchant 的 jetton wallet。那邊再跑一筆交易才把餘額加上去,然後又送出兩則 message,一則通知 merchant,一則把花剩的 TON 退回來。錢包那筆之後還有四則 message、四筆交易、四個帳戶,可能落在不同的 shard 與不同的區塊裡,而 relayer 手上唯一的憑證是第一則 message 的雜湊。今天先把這則 message 組出來,順便看清楚「一批付款」在這條鏈上還剩下什麼。

今天的目標

今天把 TON 接進 chainton 這個 adapter 答完四題、bulk 多一條鏈的上限、TransferRequest 把一批付款組成 relayer 要簽的那一則 external message。組 message 要先有 cell,所以另外多一個小 package boc,只用標準函式庫把 TON 的 cell、雜湊與序列化做出來,跟 @ton/core 逐 byte 對過。錢包用的是 W5,官方的對照表寫「It is recommended to use the v5 wallet standard, since it's the latest and most powerful implementation to date.」,而且一則請求能帶 255 則 message,v4 只有 4 則。

動工之前先看這幾份公開文件:

  1. TON 文件的 core concepts 對 asynchronous execution 的說法是「When some interaction with the blockchain involves multiple contracts, their code may not be executed in the same block. Contracts have to exchange messages with each other, and two such concurrent traces may interleave their messages.」,同一頁對 message 的定義更直接:「Formally, a message is only an intent: it has a destination, possibly some Gram, and data.」。一則 message 只是一個意圖,收到它的合約在自己的交易裡決定要不要照做
  2. TEP-74 是 jetton 的標準,transfer 那一節寫 jetton wallet 收到 transfer 之後要做的第一件事是「decrease jetton amount on sender wallet by amount and send message which increase jetton amount on receiver wallet (and optionally deploy it).」,扣餘額與加餘額是兩個合約各自的交易。拒收的第一條理由是「message is not from the owner.」:這份標準裡沒有 approve,jetton 只能由持有它的錢包簽走
  3. 錢包的上限有兩層。對照表裡「Messages throughput」那一項,v4 是「Up to 4 per request」、v5 是「Up to 255 per request」。255 這個數字來自 TVM 本身,exit code 的文件寫「If there are more than 255 actions queued for execution, the action phase will throw an error with an exit code 33」。另外一層是 external message 本身的大小,limits 那頁列的是「Maximum external message size in bytes | 65535」與「Maximum external message depth | 512」

做完之後 repo 中 internal/chainExample_buildATONRequest 會拿同一份 12 筆的名單組成一則請求,再把一份 300 筆的名單照 TON 的上限切一次,輸出長這樣:

run     12 payouts, 100 USDC each
ton     seqno 41  valid until 1800000300  12 messages behind one signature  signing hash ea0b3151…
ton     request 2,318 bytes  50 cells  15 deep  refs 12/12 aboard
ton     each message carries 0.05 TON, forwards 1 nanoton; the wallet fronts 0.6 TON until the excesses come back
ton     one payout is 4 hops: wallet -transfer-> our jetton wallet; our jetton wallet -internal_transfer-> merchant's jetton wallet; merchant's jetton wallet -transfer_notification-> merchant; merchant's jetton wallet -excesses-> wallet
plan    ton      300 payouts  2 batches  0 new accounts  rent 0 nanoton
batch   #1       255 items  messages 255/255  bytes 49,612/65,535  depth 258/512
ton     batch #1 built: 49,513 bytes before the signature, 258 deep  (bulk estimated 49,612 for the signed message)
batch   #2       45 items  messages 45/255  bytes 8,872/65,535  depth 48/512
ton     batch #2 built: 8,588 bytes before the signature, 48 deep  (bulk estimated 8,872 for the signed message)
  • 第二行那個 signing hash 是 relayer 唯一要簽的東西。它是實跑的,2,318 bytes、50 個 cell、15 層深也是,而且同一份名單用 @ton/toncreateWalletTransferV5R1 組一次、去掉尾端的簽名再算雜湊,得到的是同一個值,1 筆、12 筆、255 筆三個規模都對過(注意版本 @ton/ton >= 16.x,因為 @ton/ton <= 15.x 會把 action list 單反著包)。錢包合約驗的就是這個雜湊,版面錯一個 bit,整批一則都送不出去
  • 0.05 TON 與 1 nanoton 是設定值。下限是參考實作 jetton-wallet.fc 裡那條檢查算出來的:附上的 TON 要大於 forward 的金額加兩份 forward fee 加兩份 gas(各 0.015 TON)加儲存費 0.01 TON,也就是 0.04 TON 再加兩筆 forward fee。0.05 是留了一點餘裕的數字,12 筆要先墊 0.6 TON、255 筆要先墊 12.75 TON,這兩個是乘出來的。大部分會以 excesses 退回來,退多少要看每一步真的花了多少
  • 第一批的 49,612 是 bulk 估的,49,513 是組出來的。兩者差的 99 bytes 是簽名(64)與 external message 的表頭(35):builder 組的是還沒簽名的內容,bulk 估的是簽好、包好、送到節點手上的那一則,所以估計刻意把這兩段加回去。255 則剛好落在 cell 超過 255 個、每個 ref 的索引占兩個 byte 的那一邊,估計等於真值加 99。45 則那一批的索引只占一個 byte,估計比真值加 99 還多了 185 bytes,來自每一則四個 ref 各省下的一個 byte 與短一點的表頭,這是 bulk 那條規則唯一會高估的地方,方向跟 Solana 那邊一樣只准高、不准低
  • 300 筆切成 255 加 45,切的依據是 messages 那一條。bytes 與 depth 兩條離上限都還遠,但它們是三條各自獨立的規則,哪一條先撞到就是哪一條說了算,跟 Solana 上 bytes 與 accounts 的關係同一種

一筆付款是四則 message、五筆交易

TON 上沒有合約呼叫合約這種事。一個合約能做的只有送 message,收到 message 的合約在自己的交易裡處理它,處理完再送 message 給下一個。所以一筆 jetton 付款從 relayer 手上出發之後要經過五筆交易:

圖上每一個方框都是一筆獨立的交易,各自有自己的 gas、自己的成功或失敗,而且沒有一筆看得到另一筆。交易 1 成功的意思只有「seqno 用掉了、message 送出去了」,錢在交易 2 才離開我們的 jetton wallet,在交易 3 才進 merchant 的。三筆交易之間只有先後,什麼時候發生沒有保證,core concepts 那句「their code may not be executed in the same block」講的就是這件事:每一則 message 從一個 shard 送到另一個 shard 要時間,同一則請求裡的 12 筆付款,最後可能散在好幾個區塊裡。

失敗的形狀也跟著變。EVM 上一筆交易 revert,狀態整個退回;TON 上交易 3 失敗的時候,交易 2 早就結束了,我們的 jetton wallet 已經把餘額扣掉。這條鏈的解法是 bounce:一則 message 帶著 bounceable 旗標,收件的合約處理失敗,失敗那筆交易的 bounce phase 會把它退回給寄件人,寄件人再跑一筆交易處理退回來的那一則。參考實作的 jetton wallet 對退回來的 internal_transfer 只做一件事,balance += jetton_amount,把扣掉的餘額加回去。所以圖上前兩步都開著 bounce,後兩步刻意不開,原始碼裡那行註解寫得很白:「we should not bounce here cause receiver can have uninitialized contract」,merchant 本人可能是一個還沒部署的地址,通知送不到也不該影響已經入帳的 jetton。

每一則 message 還得自己帶錢。core concepts 那頁寫「Every internal message should have some Gram attached to it so that it can pay for the cost of handling it.」,交易 2 到交易 5 的 gas、可能要替 merchant 部署 jetton wallet 的儲存費、forward 出去的那一點點,全部從第一則 message 附上的 TON 裡出,用不完的由 merchant 的 jetton wallet 用 excesses 退回我們的錢包。這就是輸出畫面上那 0.05 TON 的用途:它是這筆付款接下來四筆交易的預付款,先由我們的錢包墊。

jetton 只有 owner 簽得動,所以請求由 payer 的錢包簽

TEP-74 裡沒有 approve。jetton wallet 對 transfer 的第一條拒收理由是「message is not from the owner.」,也就是說一顆 jetton 只能由持有它的錢包簽走,沒有 allowance、沒有 delegate、沒有簽一個 root 讓別人代付這種東西。這改變的是 relayer 拿哪一把 key 簽名。EVM 上 payer 給合約一份 allowance,relayer 用自己的錢包簽交易。Solana 上 payer 簽一個 root,relayer 用自己的錢包簽每一批。TON 上簽這則請求的錢包就得是 jetton 的 owner,也就是 payer 的錢包。撥款這條線上 payer 是平台自己,所以那把 key 同時是 relayer 的 key。換成要從別人的錢包收錢的那一類付款,在 TEP-74 這一層只有 push 沒有 pull,payer 得自己簽每一則 transfer。

relayer 簽的是一個 cell 的雜湊,那個 cell 裡是 opcode、wallet_id、valid_until、seqno,再加一條動作清單:

動作清單是一條鏈結串列,一則 message 一個節點,每一個節點 ref 到前一個,所以請求的 cell 樹深度跟則數一起長:12 則 15 層,255 則 258 層,limits 那頁的 512 層上限管的就是它。seqno 與 valid_until 簽在裡面,投遞資料簽在付款裡,跟 Solana 的 blockhash 是同一種形狀:一則簽好的請求在 valid_until 之前可以原封不動重送,過期就重簽一則新的,錢包的文件寫「a client may re-send the same signed message until the deadline; after it expires, the client must re-sign with a new valid_until.」。

seqno 那一題的答案跟 EVM 一樣是我們自己發號,錢包「accepts only the exact current seqno and increments it on success」,所以 ton 的 adapter 交出的是一個真的 Counter。差別在跳號沒有成本:對不上的請求錢包直接不收,不會像 EVM 那樣把後面的交易全部卡在 mempool 裡。它也不實作 Replacer:external message 沒有出價可以加,卡住只能重送同一段 bytes,跟 Solana 同一個答案。

每一則 message 的 body 是 TEP-74 的 transfer,組出來就是一串欄位:

    return boc.NewBuilder().
        Uint(TONOpTransfer, 32).
        Uint(binary.BigEndian.Uint64(it.Ref[:8]), 64).
        Coins(it.Amount).
        Address(to).
        Address(acc.Wallet).
        MaybeRef(nil).
        Coins(big.NewInt(TONForward)).
        Bit(true).Ref(payload).
        Build()

我們那把 32 bytes 的 ref 放在最後的 forward_payload 裡,前面掛一個我們自己的 op。這是 ref 上鏈唯一的位置,而 TEP-74 規定 merchant 收到的 transfer_notification 裡「forward_payload should be equal with request's forward_payload」,所以同一把 32 bytes 會原封不動出現在 merchant 收到的那則通知裡。第二個欄位 query_id 放的是 ref 的前 8 個 bytes:transfer_notification 與 excesses 都會把 query_id 原樣帶回來,之後從鏈上撈到的每一則帶著 query_id 回來的 message 都對得回是哪一筆付款。destination 是 merchant 本人,merchant 的 jetton wallet 由我們的 jetton wallet 在鏈上自己算出來,必要時順便部署,這也是 TEP-74 那句「and optionally deploy it」的意思。

跟 Solana 相反,鏈下組 message 的時候不需要知道 merchant 有沒有帳戶,也就沒有 prepare batch。

batch 只剩運輸單位

前兩條鏈的 batch 都是一筆交易:EVM 一筆 settleBatch 裝 300 項,一項失敗整個 batch 回滾。Solana 一筆 pay_batch 裝 8 項,同樣是原子的。TON 的 batch 是一則請求裝 255 則 message,錢包送出去之後各走各的,沒有任何一筆交易同時碰得到 255 個 merchant 的 jetton wallet,也就要不到「整批一起成功或失敗」這種東西:

這件事連 send mode 都寫進去了。每一個動作帶一個 mode,我們填的是 3:

    // tonSendMode 是每一則 message 的 send mode:1 是手續費從錢包餘額另外付、不從附上的 value 扣,
    // 2 是這一則送不出去時略過它、不要讓整筆錢包交易失敗。W5 對 external message 強制要 +2,
    // 理由跟這裡的設計一致:一則壞掉的 message 不該拖住同一批的其他 254 則。
    tonSendMode = 1 + 2

+2 那個 bit 是 W5 對 external message 的硬性要求,理由跟這條鏈的規則一致:一則送不出去的 message 只該影響它自己。partial failure 的單位於是從一批縮成一筆。之前那條規則「鏈上唯一會出現的 partial failure,單位就是一批」在 TON 上換一個說法,鏈上每一筆付款都是自己的 partial failure,一則請求裡 255 筆的結局要一筆一筆各自去追。

bulk 對這條鏈的三條規則因此管的都是那則請求:messages 上限 255、bytes 上限 65,535、depth 上限 512,沒有一條看得到單獨一筆付款。三條各自獨立,在我們的 payload 下先撞到的永遠是 255,另外兩條留在那裡是因為 payload 哪天變長,先撞到的可能是另一條。

這條鏈也沒有 rent 這種先墊的錢:merchant 沒有 jetton wallet 的話,我們的 jetton wallet 會在交易 2 順便部署它,儲存費從那 0.05 TON 裡出,有沒有帳戶每一則都一樣貴,所以 plan 那一行的 new accounts 與 rent 永遠是 0。錢還是要先墊的,只是換了地方:255 則要先墊 12.75 TON 在錢包裡,等 excesses 一則一則退回來。

小結

今天最沒把握的是那 0.05 TON。它是照參考實作的下限加一點餘裕設的,沒有在真的鏈上跑過:設太少,交易 2 那條檢查會逐則拒收、每一則都退回錢包;設太多,255 則就是十幾個 TON 先付出去、等 excesses 慢慢退回來,而 excesses 什麼時候回來、回來多少,要看每一步真的花了多少 gas。這個數字該調成多少,只有把一則請求送上 testnet 才知道。

有把握的是那個雜湊。signing cell 跟 @ton/ton 對過 1、12、255 三個規模,版面對不對,今天就答得出來。錢包收了之後那 255 筆各自走到哪,今天的程式碼一個字都答不出來,因為 relayer 手上只有一則 external message 的雜湊,而那則 message 在交易 1 就結束了。

明天來把「這筆付款成功了嗎」在這條鏈上重新問一次:錢包交易成功不算數,那什麼算數,對帳引擎又該掃哪個帳戶。

明天見。


上一篇
Day 24 | 抽象的邊界:什麼該共用 (Intent/Ref),什麼不該 (Tx 構造/簽名)
下一篇
Day 26 | TON 的極端案例 (下):模糊的 Tx 完成判定與對帳挑戰
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言