iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Software Development

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

Day 7 | 財務底線:Double-entry Ledger 與 Append-only 稽核日誌

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-07

昨天 Payment Intent pi_0001 已經跑到 settled,而 History 記著它走過的所有軌跡和兩個 tx hash。但 History 能夠記的是「這筆付款進度到哪」,沒辦法知道「目前錢在哪」。比如 merchant 這個月收了多少 USDC、還有幾筆在路上、USDT 抽走的轉帳稅加起來多少等等的資訊,都要把 intent store 全部掃過一輪才算得出來;而且 intent 身上甚至只有一個請款金額 Amount,沒有實收金額和抽稅金額等等的資訊。

所以今天我們要開發另一個帳本叫做 Double-entry Ledger,也就是我們 Day 1 那時說「狀態 SSOT 儲存在鏈下 ledger」的那個 ledger。錢在鏈上移動的時候,這個 ledger 也會去判定錢增減了沒、增減了多少,而且只能新增紀錄、不能修改紀錄。

今天的目標

複式記帳本身不是什麼新東西,義大利數學家 aka 會計學之父 Luca Pacioli 1494 年就寫下來了。工程上的形式我沿用 Martin Fowler 的 Accounting Patterns(entry 帶正負號、一筆 transaction 裡的 entries 加總為零),然後兩段式轉帳模式沿用 TigerBeetle 的 two-phase transferModern Treasury 的 pending / posted 餘額 的實作方法。

repo 中 internal/ledgerExample_holdPostVoid 會記三筆付款:pi_0001 付 100 USDC 原封不動到帳,pi_0002 付 100 USDT 被 token 抽了 0.1 的稅,pi_0003 付 50 USDC 但收款人在黑名單上。輸出大概長這樣:

#1  hold 0xb02f8d29…  by relayer   payer:0x7099…79C8 -100000000  merchant:0x3C44…93BC +100000000
#2  hold 0xecb961c3…  by relayer   payer:0x7099…79C8 -100000000  merchant:0x3C44…93BC +100000000
#3  hold 0x7d7c1422…  by relayer   payer:0x7099…79C8 -50000000  merchant:0x3C44…93BC +50000000
#4  post 0xb02f8d29…  by listener  payer:0x7099…79C8 -100000000  merchant:0x3C44…93BC +100000000  tx 0xbb
#5  post 0xecb961c3…  by operator  payer:0x7099…79C8 -100000000  merchant:0x3C44…93BC +99900000  fee:0xe7f1…0512 +100000  tx 0xcc
pi_0001/post     no-op (already there)
#6  void 0x7d7c1422…  by relayer   (blacklisted, nothing moved)
pi_0003/post     REJECTED: ledger: hold already resolved: pi_0003/hold
balance merchant USDC  pending 0  posted 100000000
balance merchant USDT  pending 0  posted 99900000
balance payer    USDT  pending 0  posted -100000000
balance fee      USDT  pending 0  posted 100000
verify: ok (6 entries, chain intact)

每一列開頭是 ref 的前十碼(0xb02f8d29… 就是 Day 6 那筆 pi_0001 的 PaymentRef),接著是誰記的、動了哪幾個科目、各動多少。六列裡沒有一列會再被改:hold 是錢動之前先占位,post 是鏈上確認後照實收金額收尾,void 是確定沒動錢就放掉。listener 把 pi_0001/post 多送一次是 no-op,有人想把已經 void 掉的 pi_0003 再 post 一次會被拒絕。最後那行 verify 是把整本帳的 hash 鏈重算一遍。

為什麼每一筆都要有兩條腿

會計的「兩條腿(two-legged)」指的是借方(Debit,左腿)和貸方(Credit,右腿)。所以支付這邊也一樣,一筆 entry 至少兩條腿、每條腿代表一項科目(account)動了多少錢,同一筆只能有一種 asset,所有腿的金額加總必須是零(收支平衡)。

所以為什麼每一筆交易記錄都至少有兩條腿?回想一下,我們幾天前提過的 ERC-20 token 實作有四類例外,其中兩類是「幽靈支付(失敗也不會 revert 的交易)」和「轉帳稅」:

  1. 幽靈支付會發生「交易成功、實際上錢沒移動」的狀況,所以如果帳本允許只記錄單邊、listener 看到收據就替 merchant 加一筆,錢就在帳上憑空出現了;故一定要規定有另一條腿、也就是得先回答「這錢是從誰那裡出來的」才能把它記進帳本。
  2. 轉帳稅(金額對不上的)是「payer 出 100、merchant 只進 99.9」,兩條腿加起來不是零,ledger 直接拒收。唯一解法是加第三條腿 fee:<token> 額外記錄那 0.1。所以只要 fee 科目有餘額,就代表交易的 token 在抽稅,不用等對帳才知道。

實作上,我們這邊腿用正負號而不用會計師的借方貸方兩欄,是因為這裡不做財報,正負號對工程師比較不會搞反;而且這個系統我們預設是 non-custodial 的,科目也不用事先開戶、我們自己沒有資產負債可記;payer:<address>merchant:<address>fee:<token> 第一次出現在某條腿上就存在,且科目那邊記錄的是「這筆支付讓誰的錢增加或減少了」。

四類例外對到帳本上:

例外 intent 停在哪 帳本上長什麼樣
會 revert 的(重試有救) settling hold 留在 pending,relayer 重送幾次帳都不動
永遠失敗的 failed void,pending 歸零,沒有 posted
不會 revert 的 needs_review 沒有 post。錢沒動就湊不出第二條腿,listener 只能送審,hold 留在 pending 等人
金額對不上的 needs_review operator 判定已付才 post,三條腿,差額落在 fee;容差內要不要自動放行之後會討論

如何在錢移動之前先記帳

我們定義三種 entry:

  • hold:「錢還沒移動,先在帳上卡位」,記的是請款金額,餘額進 pending
  • post:「確認錢真的在鏈上移動了,再把剛剛卡位正式寫入」,記的是實收金額,金額從 pending 搬到 posted
  • void:「確定錢不會移動了,放棄帳上的卡位」,pending 歸零、什麼都不進 posted

一筆支付請求在帳本上最多兩列(hold,加上 post 或 void 其中之一),三種 entry 各對到狀態機的一個情境:

三種 entry 之間有一條硬規則:一筆 hold 只能被 post 或 void 收尾一次(擇一),第二次一律回傳錯誤 ErrAlreadyResolved

餘額分兩個數字:pending 是「帳上占著、鏈上還沒確認」,posted 是「鏈上已確認」。所以如果 merchant 問「我收了多少錢」要看 posted;對帳引擎問「還有多少錢在路上」要看 pending,這部分要搞清楚。

稽核日誌(Journal)介紹

餘額只是 journal 的投影:任何人拿匯出的完整 entries 自己重新 fold 一遍,都會得到同一份餘額。

實作上我們 Journal 介面上只有 Append,沒有 update 或 delete:

// Journal 只能 Append,而且 Append 對同一個 ID 是冪等的。沒有 Update、沒有 Delete,介面上就沒有這兩個字。
type Journal interface {
	Append(ctx context.Context, e Entry) (stored Entry, applied bool, err error)
	Get(ctx context.Context, id string) (Entry, error)
	ByRef(ctx context.Context, ref paymentref.Ref) ([]Entry, error)
	Balance(ctx context.Context, acct Account, asset Asset) (Balance, error)
	Scan(ctx context.Context, fn func(Entry) error) error
}

可以想像,即使不小心 post 錯了也不會去動那一則記錄,只會開一筆反向的 intent 走一次新的 hold 與 post。

這邊我們畫出防止餘額被竄改的解決方案演進:

最終演變出來的解決方案是 journal 加上 hash 鏈。每一列 Append 時拿到一個 Seq 與一個 HashHash 是 SHA-256 過「上一列的 Hash 加這一列的正規編碼」(編碼跟 PaymentRef 的 Preimage 同一套:長度前綴、帶版本號的前綴),跟 git 的 commit 一樣:任何一列被改過,從那一列起整條鏈都對不上,Verify 會指出第一個壞掉的 Seq。AWS 那個已經停掉QLDB journal 就是這個結構(「append-only ... sequenced and hash-chained set of blocks」)。

說真的,這解決方案也無法阻止餘額被竄改,因為改得動的人還是改得動,只是改完藏不住。要真的阻止,得定期把鏈頭存到寫入權不同的地方,有效但有點麻煩就是。

Append 的檢查順序也是刻意排的:

重放擺在 hold 檢查之前,是因為 listener 把同一個 post 送兩次時,第二次不該撞到 ErrAlreadyResolved

小結

今天都在定義帳怎麼記:每一筆至少兩條腿、加總為零,所以幽靈支付與轉帳稅的問題可以靠記帳來及早發現;錢動之前先 hold、鏈上確認後照實收 post、確定沒動就 void,一筆 hold 只能收尾一次;journal 只加不改,餘額是 fold 出來的投影,每一列去 hash 前一列,改過就會被發現。到這裡鏈下的核心就齊了:狀態機負責注意「付款走到哪」、雙鍵負責管理「付款是哪一筆」、ledger 則負責記錄「錢現在在哪」。

明天開始進 relayer:intent 到了 authorized 之後,誰負責處理它、怎麼排隊、系統掛了怎麼辦。

明天見。


上一篇
Day 6 | 雙鍵設計 (下):以 PaymentRef 作為 Audit Tracking 的識別碼
下一篇
Day 8 | Relayer 架構概觀:Job Queue 驅動的設計
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言