iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

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

Day 26 | TON 的極端案例 (下):模糊的 Tx 完成判定與對帳挑戰

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-26

昨天那則請求送出去之後,relayer 手上多了一個雜湊。在 EVM 上這個雜湊叫 tx hash,拿它去問節點,收據上寫著 status 是 1 還是 0,錢動了沒一眼看得出來。TON 上這個雜湊是一則 external message 的雜湊,拿它去問節點,找到的是錢包那筆交易,而那筆交易「成功」的意思只有:seqno 用掉了、三則 message 送出去了。錢在後面幾筆交易裡:我們的 jetton wallet 一筆、merchant 的 jetton wallet 一筆,各自落在不同的帳戶、不同的 masterchain block 裡,而後面那一筆還可能根本沒發生。今天要回答的是「這筆付款成功了嗎」在這條鏈上該怎麼問,以及對帳引擎該掃哪一個帳戶。

今天的目標

今天做的是 ton adapter 讀鏈的那一半:TONReader 同時實作 listener 的 Watcher 與對帳引擎的 Source,中間靠一個 Trace 把一筆付款從錢包那筆交易一路追到它的終點。鏈還是沒接,節點由測試裡的一個 fake 扮演,它照參考實作 jetton-wallet.fc 的規則處理每一則 message:扣餘額、加餘額、失敗就退回、退回來就加回去。listener 與對帳引擎本身一行都沒動,動的只有它們看到的 ObservationTransfer 是怎麼算出來的。

今天引的四份都是 TON 自己的文件,前兩份講失敗長什麼樣、第三份講不可逆、最後一份是站在收款方寫的:

  1. internal message 那頁講 bounce:「A bounce message is used to inform the sender that handling of their message failed. It is sent automatically to a sender when」有足夠的 TON、message 是 bounceable、而且「either a contract throws an error from the compute phase during message processing, and the state was not committed by COMMIT TVM instruction, or an action fails in the action phase」。退回來的 message 長什麼樣也寫死了:「body is replaced with the concatenation of 32 bits equal to one (0xffffffff) and the first 256 bits of the old body.」
  2. exit code 那頁把失敗與 bounce 接起來:「If the compute phase fails (the resulting exit code is neither 0 nor 1), the transaction skips the action phase and proceeds to the bounce phase.」,表上的 13 是「Out of gas error」,今天的劇本讓 merchant 那一步就停在這個 exit code 上
  3. 不可逆那一句沒有變:「Once a transaction from a shardchain appears in a masterchain block, it becomes irreversible.」(payments overview)。變的是「a transaction」指哪一筆。另外 shards 那頁講 message 在 shard 之間的遞送:「During congestion, handling of messages that do not fit into current block is delayed to next blocks.」,晚到,但不會不到
  4. 官方的 jetton 收款指南是站在收款方寫的:「Check the opcode (first 32 bits of tx.in_msg.body) equals 0x7362d09c (transfer_notification)」,而且入帳前「Always perform these checks: 1. Get the jetton master address from the allowlist 2. Call jetton_master.get_wallet_address(owner_address) 3. Verify the returned address matches the jetton wallet that sent the notification」。我們站在付款方,看的帳戶跟它不一樣

做完之後 repo 中 internal/chainExample_readATONPayout 會送一則裝了三筆付款的請求,讓三筆各走各的路,輸出長這樣:

send    seqno 41  3 payouts in one request  external 68c24be3…  the wallet took it at masterchain 101
trace   pi_0001  wallet 101 -> our jetton wallet 102 -> merchant's jetton wallet 103   delivered 100000000
trace   pi_0002  wallet 101 -> our jetton wallet 102 -> merchant's jetton wallet 103 aborted (exit 13) -> our jetton wallet 104   bounced
trace   pi_0003  wallet 101 -> our jetton wallet 102 -> merchant's jetton wallet …   in flight
check   pi_0001  settled      (masterchain at 103, 2 deep)
check   pi_0002  needs_review (masterchain at 104, 1 deep but the execution failed; gas burned, nothing moved)
check   pi_0003  wait         (included at 102, 3 deep, not yet masterchain)
window  masterchain 101..104  1 credit at the merchants' jetton wallets
credit  pi_0001  100000000  to 0:0000…0a0001  from 0:1111…111111  at 103
balance our jetton wallet 800000000: 300000000 sent, 100000000 bounced back, 100000000 still on the road
  • 三筆掛在同一則請求、同一個 seqno 上,錢包那筆交易在 masterchain 101 就不可逆了。三筆的結局卻分別落在 103、104 與「還不知道」
  • 101 到 104 這四個數字與 exit 13 是劇本編的:fake 一次處理一輪 message,每一輪結束把這一輪的交易標成被下一個 masterchain block 引用,而 pi_0002 被指名在 merchant 那一步因為 out of gas 失敗。真的鏈上每一步落在哪個 block 由 shard 的壅塞程度決定,不會這麼整齊
  • 餘額那一行照參考實作的規則走出來:三筆各扣 100,退回來的那筆在第四步那筆交易加回去,所以是 1,000 減 200。pi_0003 那 100 已經離開我們的 jetton wallet、還沒進 merchant 的,此刻不在任何一個帳戶上
  • check 三行是 listener 的 Check 印的,程式碼跟前幾天一模一樣。它處理的只是三個 Observation,鏈是不是 TON 對它沒有差別

錢包交易成功,只代表 seqno 用掉了

一筆 jetton 付款在鏈上留下五筆交易,而每一筆「成功」的意思都不一樣:

交易 在哪個帳戶上 成功代表什麼 失敗長什麼樣
1 我們的錢包 seqno 用掉、N 則 transfer 送出去 沒有這筆交易:錢包不收,過了 valid_until 就沒了
2 我們的 jetton wallet 餘額扣掉、internal_transfer 送出去 aborted,transfer 退回錢包,jetton 沒有離開
3 merchant 的 jetton wallet 餘額加上、notification 與 excesses 送出去 aborted,internal_transfer 退回,交易 2 的帳戶再跑一筆交易把餘額加回去
4 merchant 本人 收到通知 不 bounce,失敗不影響已經入帳的 jetton
5 我們的錢包 收到 excesses,花剩的 TON 回來 不 bounce,同上

relayer 手上那個雜湊只找得到第一筆。所以 Trace 從那裡出發,每一步做同一件事:拿上一筆交易送出去的那則 message 的雜湊,問節點「誰處理了它」,找到就往下走,找不到就是這一步還沒發生。找到的交易要是 aborted,那則 message 已經變成一則 bounced message 正在退回,於是改追退回來的那一則落在哪一筆交易,那一筆就是這筆付款的終點:

樹上沒有交易 4 與 5。通知送不送得到跟錢無關,那一步不 bounce,失敗也不會有任何東西退回來,excesses 同理。所以「一筆付款成功」在這條鏈上的定義收成一句話:merchant 的 jetton wallet 把 internal_transfer 執行成功。它前面的兩筆交易成功只是必要條件,它後面的兩筆交易則完全不在定義裡。

樹上也刻意沒有「lost」這個結果。錢包沒收下的請求走的是轉移表上那條回頭路,過了 LostAfter 退回 settling,跟 EVM 上一筆從 mempool 消失的交易同一種收法;但錢包一旦收下,接下來每一則 message 都在某個 shard 的 block 裡排隊,shards 那頁那句「delayed to next blocks」講的就是這件事,會晚到、不會不到。所以樹上「還沒」那兩個結果只有一種收法:等。

終點是 merchant 的 jetton wallet 那筆交易

終點定了,listener 要改的只剩一個函式。它認的還是那個 Observation,欄位一個沒加,變的是每一個欄位從哪一筆交易讀:

    s.Included = true
    // Height 是「目前追到的最後一步」被引用的 masterchain seqno;還在路上就先報最後一筆看得到的交易。
    for _, st := range tr.Steps {
        if st.Tx != nil && st.Tx.Masterchain > s.Height {
            s.Height = st.Tx.Masterchain
        }
    }
    if tr.Terminal == nil {
        return s, nil
    }
    s.Height = tr.Terminal.Masterchain
    s.Final = tr.Terminal.Masterchain != 0
    s.Succeeded = tr.Outcome == TONDelivered

Included 在錢包收下請求那一刻就是 true,因為從那一刻起這筆付款不會消失。HeightFinal 看的是終點那筆交易被哪個 masterchain block 引用,不看錢包那筆:pi_0001 的錢包交易在 101 就不可逆了,錢在 103 才進 merchant 的帳戶,在 103 被引用之前 Final 是 false,listener 只能等。引用之後 Height 是 103,錢包那筆的 101 不會出現在 Observation 裡。

Succeeded 只有終點是 delivered 才是 true,bounce 回來的那一筆終點是我們的 jetton wallet 把餘額加回去的那筆交易,那筆交易本身執行得好好的,但對這筆付款來說它是失敗的證據,所以 Succeeded 是 false,listener 照 revert 那條路把它送審。

三筆付款在時間上於是各走各的:

同一則請求裡的三筆付款,最後不可逆的時間點各不相同,而且沒有一個等於請求本身不可逆的時間點。這是「Tx 成功」在這條鏈上模糊掉的地方:問「那筆交易成功了嗎」永遠有答案,只是那個答案跟錢無關。要問錢,得先決定「那筆交易」是五筆裡的哪一筆,而答案是第三筆。

對帳要掃收款的那個帳戶

對帳引擎問的是另一個方向的問題:這段 masterchain seqno 裡所有跟我們有關的轉帳,每一筆是否都對得回 intent。EVM 上它掃的是結算合約的 event,一筆付款一個 event;TON 上沒有結算合約,jetton 在兩個 jetton wallet 之間直接搬,掃哪個帳戶是要選的:

我選掃 merchant 的 jetton wallet,理由跟 listener 的終點是同一個:只有那筆交易會加餘額。掃我們自己帳戶的問題不只 bounce。同一把 ref 在一條 trace 上會出現三次,transfer 帶著它、internal_transfer 帶著它、transfer_notification 也帶著它(TEP-74 規定 forward_payload 要原樣轉進通知),掃我們自己送出去的 message 會把一筆付款數成三筆。pi_0002 那則 internal_transfer 從我們的帳戶看是一則帶著 ref 的合法轉帳,它退回來的證據在另一筆交易上。

換成掃收款的帳戶,這些全部消失:一筆付款在那裡剛好留下一筆交易,退回來的那筆什麼都沒留。

Transfers 因此只做過濾,條件寫在一起就是這一節的全部:

    if tx.Aborted || tx.In == nil || tx.In.Bounced || tx.In.Body == nil || tx.Masterchain == 0 {
        continue
    }
    if op := tx.In.Body.Begin().Uint(32); op != TONOpInternalTransfer {
        continue
    }

aborted 的不算,bounced 的不算,還沒被 masterchain 引用的不算,op 不是 internal_transfer 的不算。ref 從 forward_payload 讀,讀不到就是一筆沒帶 ref 的入帳,交給對帳引擎當 unreferenced 收。付給誰由帳戶決定:這個 jetton wallet 是哪個 merchant 的,是 Watch 的時候登記的,而那份名單就是這條路的缺點。merchant 的 jetton wallet 地址鏈下算不出來,要拿 merchant 的地址去問 jetton master 的 get_wallet_address,這跟收款指南裡那三條檢查做的其實是同一件事,只是方向相反:他們驗「送通知來的是不是真的 jetton wallet」,我們驗「我們掃的是不是真的 jetton wallet」。掃對了帳戶,上面每一筆成功的 internal_transfer 都可信,因為參考實作的 jetton wallet 會拒收不是同一個 jetton master 底下的 jetton wallet 送來的 internal_transfer,冒牌的 jetton 進不了這個帳戶。

掃收款的帳戶還多看到一種東西:別人送的錢。拿一個陌生的 jetton wallet 帶著 pi_0001 的 ref 再付一次,Transfers 照樣回報它,from 是陌生人,height 是 105。對帳引擎拿到第二筆同 ref 的轉帳,列出來的就是 paid_twice。至於 excesses,它帶回來的是 TON,落地的帳戶是我們的錢包,對帳引擎掃的帳戶上根本不會有它。

小結

今天的決定是把「一筆付款的交易」定成 merchant 的 jetton wallet 那一筆:listener 的終點在那裡,對帳引擎掃的也是那裡。它換來的是同一份 ObservationTransfer,listener 與對帳引擎在四條鏈上不用知道自己在看哪一條。冒的險是那份名單:每個 merchant 的 jetton wallet 在哪得先問過 jetton master,名單上少一個 merchant,那個 merchant 收到的錢對帳引擎就一筆都看不到。

還有一題我沒有答案:一則卡在路上的 message 要等多久才該轉人工介入。錢包收下之後這筆付款不會 lost,所以 LostAfter 管不到它,而「delayed to next blocks」沒有說會延遲幾個 block。今天的答案是永遠等,這在 shard 壅塞的時候會變成一筆停在 confirming 好幾個小時、沒有任何人被通知的付款。

明天輪到最後一條鏈。SUI 把錢做成一個一個的 object,連「誰能動這筆錢」都寫在 object 上,結算合約的形狀要重新想一次。

明天見。


上一篇
Day 25 | TON 的極端案例 (上):Async Message 所帶來的 Paradigm Shift
下一篇
Day 27 | SUI 的衝擊 (上):Object Ownership 對結算合約設計的顛覆
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言