本日程式碼: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 與對帳引擎本身一行都沒動,動的只有它們看到的 Observation 與 Transfer 是怎麼算出來的。
今天引的四份都是 TON 自己的文件,前兩份講失敗長什麼樣、第三份講不可逆、最後一份是站在收款方寫的:
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/chain 的 Example_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
pi_0002 被指名在 merchant 那一步因為 out of gas 失敗。真的鏈上每一步落在哪個 block 由 shard 的壅塞程度決定,不會這麼整齊pi_0003 那 100 已經離開我們的 jetton wallet、還沒進 merchant 的,此刻不在任何一個帳戶上check 三行是 listener 的 Check 印的,程式碼跟前幾天一模一樣。它處理的只是三個 Observation,鏈是不是 TON 對它沒有差別一筆 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」講的就是這件事,會晚到、不會不到。所以樹上「還沒」那兩個結果只有一種收法:等。
終點定了,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,因為從那一刻起這筆付款不會消失。Height 與 Final 看的是終點那筆交易被哪個 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 的終點在那裡,對帳引擎掃的也是那裡。它換來的是同一份 Observation 與 Transfer,listener 與對帳引擎在四條鏈上不用知道自己在看哪一條。冒的險是那份名單:每個 merchant 的 jetton wallet 在哪得先問過 jetton master,名單上少一個 merchant,那個 merchant 收到的錢對帳引擎就一筆都看不到。
還有一題我沒有答案:一則卡在路上的 message 要等多久才該轉人工介入。錢包收下之後這筆付款不會 lost,所以 LostAfter 管不到它,而「delayed to next blocks」沒有說會延遲幾個 block。今天的答案是永遠等,這在 shard 壅塞的時候會變成一筆停在 confirming 好幾個小時、沒有任何人被通知的付款。
明天輪到最後一條鏈。SUI 把錢做成一個一個的 object,連「誰能動這筆錢」都寫在 object 上,結算合約的形狀要重新想一次。
明天見。