本日程式碼:repo tag day-06
Day 4 的 Example 裡,Payment Intent pi_0001 從狀態 created 走到 settled,中間經過 0xaa 與 0xbb 兩個 tx hash,但只動了一次錢。現在把問題反過來:稽核拿著鏈上一筆 0xbb,要怎麼知道它是哪一筆付款、是不是重送、跟 0xaa 是不是同一件事?昨天的 idpk 幫不上忙,因為它在 API 邊界就結束工作了、鏈上從頭到尾不知道它存在,而 intent id 也不在鏈上。所以這時我們就要掏出 PaymentRef 來回答這個問題。
我們今天要把它實作出來:後端拿到一筆支付請求後算出一個 32 bytes 的 ref,之後每一層帶著它走,最後寫進鏈上交易,再由 listener 撈回來換回 intent。
SWIFT 畢竟還是結算界的權威,所以我們可以參考 SWIFT gpi 的 UETR 概念來設計 PaymentRef。UETR 是一串 36 字元的追蹤碼,由發起交易的機構產生,沿途每一家中轉機構只能直接轉傳、不能自己再生一個或者加料,所以隨時可以拿它查詢交易情形。當然,Defi 世界也有人做過類似的:Request Network 把 8 bytes 的 paymentReference 當參數傳進代理合約、寫進 event,讓鏈上的轉帳能對回鏈下的收據等等紀錄。
我們版本的 PaymentRef 會是一個 32 bytes 的雜湊(用 intent id 和付款條件湊出來的)、從 API 進來那一刻就產生、隨著交易上鏈、且任何人隨時都能拿著它去查詢交易情形。
今天的 repo 中 internal/api 的 Example_traceByRef 會走一遍這流程:建立一筆 intent 然後拿到 ref,後端會幫 relayer 和 listener 跑完這個 intent(經歷一次 reorg,得到兩個不同的 tx hash),最後只拿 ref 給 API,詢問交易結果。輸出大概長這樣:
POST -> 201 id=pi_0001
ref=0xb02f8d2972380c471030066cf638083d0d6e1674d250a38f2347c28fc5783c47
GET -> 200 pi_0001 settled v7 100000000 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
created -> authorized by api
authorized -> settling by relayer
settling -> confirming by relayer tx 0xaa
confirming -> settling by listener (reorg at block 12)
settling -> confirming by relayer tx 0xbb
confirming -> settled by listener tx 0xbb
GET /v1/payment_refs/{ref} 回來的是 intent id、目前狀態、以及 History 裡的每一步與每個 tx hash。這就是「任何一筆錢都能從鏈上反查回最初那個 API request」的最後一哩,稽核與對帳工具走這條路,客戶端還是拿 id 走 GET /v1/payment_intents/{id}。
昨天講完 idpk 之後我們手上其實有三把 key,先把分工列清楚:
| idpk | intent id | PaymentRef | |
|---|---|---|---|
| 誰取的 | 客戶端 | 伺服器(亂數) | 從 intent id 與付款條件算出來 |
| 唯一範圍 | scope 內 | 全系統 | 全系統 |
| 壽命 | 24 小時 | 永遠 | 永遠 |
| 長什麼樣 | 客戶端決定 | pi_ 加 24 個 hex |
32 bytes,文字形式 0x 加 64 個 hex |
| 存在哪 | record store | intent store,之後 ledger 與 queue 也拿它 | intent store,加上鏈上交易 |
三把 key 各自的用處用畫的比較清楚:
由此可知 idpk 用來確保同一個請求只長出一筆 intent,PaymentRef 用來確保鏈上任何一筆錢都對得回一筆 intent,然後 intent id 是用來連結兩者的唯一 key。
聰明的你可能會問:為什麼不用 tx hash 或者 intent id 就好?畢竟把 ref 寫進鏈上交易也是有成本的(要透過結算合約或塞進 memo 才行,這篇下面會講到)。
我們可以討論常見的另外兩種解法:
由上圖,我們會發現 tx hash 的問題:
pi_0001 就有兩個 hash,甚至只要持續換 gas 重送、reorg 吐回來再送,都會再繼續得到不同的 hash。然後我們也會發現 intent id 直接上鏈的問題更多:
所以就成本來說,多一個 PaymentRef 還是最划算:
// Derive 從付款條件算出 PaymentRef。同樣的 Terms 永遠算出同樣的 Ref,這是整個 package 唯一的承諾。
// Terms 只放建立之後就不會再變的欄位:IntentID、Chain、Token、Payer、Merchant、Amount。
// State、Version、TxHash 會變,時間欄位是簽名迴圈的事,都不放。
func Derive(t Terms) Ref {
return Ref(sha256.Sum256(Preimage(t)))
}
// Preimage 是被雜湊的原始位元組:DomainV1 前綴加六個欄位,每個欄位前面帶 uvarint 長度。
// 帶長度而不用分隔符,("ab","c") 與 ("a","bc") 才不會拼成同一串。
func Preimage(t Terms) []byte
stablecoin-settlement-engine/payment-ref/v1 讓它跟任何其他系統對同一組欄位算出來的 SHA-256 不會撞在一起,也預留了版本號的位置。如果你去 repo 中 internal/paymentref 的 Example_derive 觀察輸出,可以發現支付條件中的金額改變後就是另一個 ref:
amount=100000000 0xb02f8d2972380c471030066cf638083d0d6e1674d250a38f2347c28fc5783c47
amount=100000000 0xb02f8d2972380c471030066cf638083d0d6e1674d250a38f2347c28fc5783c47 (again)
amount=100000001 0xd694564081abc8e053640301fd99658865c74a0328bac8b664268eafc21c16fe
選 32 bytes 而不是 Request Network 的 8 bytes,一半是因為雜湊本人為了防碰撞要夠長,另一半是因為它剛好是四條鏈都塞得下、EVM 上又不用轉換的長度。文字形式只有一種:0x 加 64 個小寫 hex,Parse 只收這一種寫法,一把追蹤鍵如果有兩種以上的寫法,遲早會有兩個系統對同一筆付款算出不同的字串。
| 鏈 | ref 放哪裡 | listener 怎麼撈 |
|---|---|---|
| EVM | 結算合約的 bytes32 參數,同時 emit 成 indexed event topic |
eth_getLogs 依 topic 過濾 |
| Solana | 同一筆交易裡的 SPL Memo instruction,memo 是 UTF-8 字串,放 66 字元的文字形式 | 解析交易裡的 memo instruction |
| TON | jetton transfer 的 forward_payload,TEP-74 規定以 32 個零 bit 開頭的就是文字 comment |
解 transfer message 的 body |
| SUI | Move event 的欄位,sui::event::emit 出來的 |
依 event type 查詢 |
注意 EVM 那一格:EIP-20 的 transfer(to, value) 沒有任何可以帶資料的欄位,所以付款人的錢包直接對 USDC 合約 transfer,是沒有地方放 ref 的。ref 要上鏈,就得經過一個會把它當參數、寫進 event 的結算合約。這是「為什麼需要結算合約」的理由之一,合約本身之後會專門討論。今天四條鏈都還沒動,這張表是 ref 的規格,不是實作。
(其實上面那張表只是目前想法,可能之後會修改,大致看看就好)
Store 介面今天多了 GetByRef:鏈下的 API 與 queue 拿 id 來查,從鏈上回來的 listener 與對帳引擎手上只有 ref,就拿 ref 來查。Save 則是先做 CAS、再用 intent 自己的條件重算一次 ref,對不上就回 ErrRefMismatch 拒絕寫入,因為那已經不是競爭,是 bug 或竄改。
今天在聊 PaymentRef:它是對「intent id 加付款條件」做的 SHA-256 commitment,建立 intent 就產生、上鏈不變、任何人拿著 intent 都能重算,store 每次寫入都重算一次核對,listener 拿它換回 intent 與整段 History。到這裡雙鍵設計就完整了:idpk 把三次請求收成一筆 intent,PaymentRef 讓鏈上任何一筆錢都對得回那筆 intent,中間靠 intent id 串起來。
明天講帳本:錢在鏈下怎麼記,為什麼每一筆都要有兩條腿,以及為什麼稽核日誌只能加、不能改。
明天見。