iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

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

Day 6 | 雙鍵設計 (下):以 PaymentRef 作為 Audit Tracking 的識別碼

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-06

Day 4 的 Example 裡,Payment Intent pi_0001 從狀態 created 走到 settled,中間經過 0xaa0xbb 兩個 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/apiExample_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、intent id 與 PaymentRef

昨天講完 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。

為什麼一定要用 PaymentRef

聰明的你可能會問:為什麼不用 tx hash 或者 intent id 就好?畢竟把 ref 寫進鏈上交易也是有成本的(要透過結算合約或塞進 memo 才行,這篇下面會講到)。

我們可以討論常見的另外兩種解法:

  1. 什麼都不放,交易送出去之後把 tx hash 記回後端 intent 紀錄
  2. 把 intent id 直接塞進交易

由上圖,我們會發現 tx hash 的問題:

  1. 它是用來識別「一次嘗試」,不是用來識別「一筆付款」。開頭那筆 pi_0001 就有兩個 hash,甚至只要持續換 gas 重送、reorg 吐回來再送,都會再繼續得到不同的 hash。
  2. 交易成功送出去之前它根本不存在,ledger 想在錢動之前先記這筆帳根本沒有東西可以記。
  3. 不要忘了之後還要處理 TON 鏈,那邊更麻煩,一次轉帳是一串非同步的 message,沒有單一個 hash 可以拿。

然後我們也會發現 intent id 直接上鏈的問題更多:

  1. 通常它是一組長字串,所以四條鏈各有各的塞法;比如 EVM 那邊合約要拿它當 mapping 的 key 或 event 的 topic 的話都得先 hash 一次。
  2. 它把 API 用的識別碼公開出來,看到鏈上那筆的人就拿到了真正後端系統用來查詢用的 id。
  3. 它只是一個標籤,假如鏈下那一列的金額或收款人被改了,鏈上的字串還是同一個,對不出來。

所以就成本來說,多一個 PaymentRef 還是最划算:

  1. PaymentRef 是從 intent id 與付款條件推導出來的 32 bytes,建立交易時就產生、上鏈不變、任何人拿著 intent 都能重算
  2. 支付條件改變的話 ref 也會變,所以想要變更 intent 內容的話只能建立新的 intent 把舊的覆蓋,不能修改。這也符合我們「修正靠新 intent」的原則。

如何正確湊出 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
  • 用 SHA-256 而不是 keccak256:Go 標準函式庫就有,這個 module 到今天還是零外部依賴;之後四條鏈的合約與程式也都算得動。雖然 keccak256 在 EVM 上比較便宜,但那要等到有合約需要在鏈上重算 ref 的那天才有差,今天先不為了那個多拉一個依賴。
  • 雜湊中要加入支付條件:把 chain、token、payer、merchant、amount 一起 hash 進去之後,ref 變成對「這筆付款是什麼」的 commitment:拿著 intent 的任何人自己算一次都能驗證,不用信任我們的資料庫。
  • 不做正規化、直接 hash 字串:大小寫、checksum、地址格式,每條鏈規矩都不同,跟 fingerprint 不解析 JSON 的理由一樣:正規化是 API 收請求時的事,PaymentRef 只保證「跟存下來的東西一模一樣」。
  • 加入額外編碼:前綴 stablecoin-settlement-engine/payment-ref/v1 讓它跟任何其他系統對同一組欄位算出來的 SHA-256 不會撞在一起,也預留了版本號的位置。

如果你去 repo 中 internal/paymentrefExample_derive 觀察輸出,可以發現支付條件中的金額改變後就是另一個 ref:

amount=100000000  0xb02f8d2972380c471030066cf638083d0d6e1674d250a38f2347c28fc5783c47
amount=100000000  0xb02f8d2972380c471030066cf638083d0d6e1674d250a38f2347c28fc5783c47 (again)
amount=100000001  0xd694564081abc8e053640301fd99658865c74a0328bac8b664268eafc21c16fe

如何把 32 bytes 塞到鏈上

選 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_payloadTEP-74 規定以 32 個零 bit 開頭的就是文字 comment 解 transfer message 的 body
SUI Move event 的欄位,sui::event::emit 出來的 依 event type 查詢

注意 EVM 那一格:EIP-20transfer(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 串起來。

明天講帳本:錢在鏈下怎麼記,為什麼每一筆都要有兩條腿,以及為什麼稽核日誌只能加、不能改。

明天見。


上一篇
Day 5 | 雙鍵設計 (上):idpk 如何守住 API 層的重試攻擊
下一篇
Day 7 | 財務底線:Double-entry Ledger 與 Append-only 稽核日誌
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言