iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Software Development

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

Day 4 | 核心狀態機:支付意圖 (Payment Intent) 的生命週期流轉

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-04

我們現在 devnet 上有 USDC 和 USDT、有一個有錢的 payer、有動不了錢的黑名單地址,所以可以來打一筆錢了。還記得第一天的圖裡面有一個「Payment Intent」的東西嗎?那個就是「錢在鏈上開始移動之前,鏈下先產生的代表物件」:它從 API 收到請求的那一刻產生、穿過 queue + relayer + listener 後最後停在某個 terminal state。

今天我們就是要來實作出 Payment Intent 狀態機。完成這個實作,主要有三個點要考慮:

  1. 一筆付款中間可以停在哪幾個狀態
  2. 每一步誰能夠繼續讓它往前走
  3. 往前走要有什麼條件

基於這三個點,其實我們可以用一個 transition table 來表示。

狀態機的 transition table

狀態機大家應該都熟悉,初始狀態我們稱作 initial state、最終狀態稱作 terminal state、驅動狀態改變的行為我們稱作 transition,驅動的人叫做 actor。這次設計的狀態機 transition table 大概長這樣(它同時是 repo 裡的一個檔案 backend/internal/intent/testdata/transitions.golden):

FROM          TO            BY                       NEEDS
created       authorized    api                      -
created       canceled      api,operator,system      reason
authorized    settling      relayer                  -
authorized    canceled      api,operator             reason
settling      confirming    relayer,listener         tx_hash
settling      failed        relayer                  reason
settling      needs_review  relayer                  reason
confirming    settled       listener                 tx_hash
confirming    settling      listener                 reason
confirming    needs_review  listener                 reason
needs_review  settled       operator                 tx_hash,reason
needs_review  failed        operator                 reason
 
terminal: settled, failed, canceled

然後是一筆 intent 走完一生的樣子(go test ./internal/intent -run Example -v 可以印出來的):

created      -> authorized   by api
authorized   -> settled      by api       REJECTED: intent: transition not in table: authorized -> settled
authorized   -> settling     by relayer
settling     -> settling     by relayer   no-op (already there)
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
settled      -> failed       by operator  REJECTED: intent: state is terminal: pi_0001 is settled
final: settled v7 tx=0xbb

今天的程式碼全部在 repo 的 backend/internal/intent,Go 寫的、只用標準函式庫,make go-test 跑 25 個測試。只有狀態機本體與一個記憶體 store,沒有 API、沒有 queue、沒有鏈:它們是之後提出請求的人,先把「誰能提什麼請求」定下來。

狀態以及對應的 actor

圖上最重要的東西是那幾個框:每個非 terminal 狀態都剛好落在一個 actor 的地盤裡。createdauthorized 是 API 的(簽名迴圈在這裡跑)、settling 是 relayer 的、confirming 是 listener 的、needs_review 是人的。狀態名同時就是責任歸屬,看到一筆 intent 停在哪,就知道該去找誰。

如果你拿最初我們設計的狀態機來比較(Day 1 的那個),你會發現有一些不一樣的地方:

  • funded 改叫 authorized:簽名迴圈的第三步是付款人把簽好的 payload 送回來,那代表「他授權了」,不代表「錢在」。錢在不在、allowance 夠不夠、地址有沒有被列入黑名單,要到鏈上才知道,鏈下這一格叫 funded 的話會讓人以為已經查過了。

  • 多了 confirming:交易進了區塊不等於結束:以太坊在 finality 之前的區塊是可以被換掉的,reorg 會把已進區塊的交易吐回來。但它也不能當成沒送過,再送一次錢就多動一次。所以需要一個「有一筆在鏈上、但還不能對商家說完成」的停靠點。它歸 listener 管,因為只有 listener 從鏈上讀事實;relayer 看到的是「我送出去了」,不是「錢動了」。

  • 多了 needs_review:金額對不上、交易成功但餘額沒變、交易送出去之後下落不明,這些情況系統不該自己下判斷。它不是 terminal,但從這裡出去只有兩條路:settledfailed,都要人來走。沒有「退回 settling 重送」這條路,因為交易下落不明時再送一次就可能付兩次;人只能判定「已付」或「未付」,想再付就開一筆新的 intent。

順帶一提,Stripe 的 PaymentIntent 是反過來的:付款嘗試失敗會退回 requires_payment_method 讓你換張卡重試。那是因為卡片被拒是一個乾淨的失敗,Stripe 確定錢沒動;鏈上的「不知道有沒有成功」不是。同一個 intent 能不能重試,取決於你有多確定上一次沒動到錢。

誰能夠繼續 transition 這個 intent 的狀態

前面那張表的 BY 那一欄是需要花最多時間想的部分:

  1. 只有 listener(和人類)能把狀態推到 settled。API 與 relayer 都不行,因為它們手上的資訊最多是「交易送出去了」「交易進區塊了」。Day 2 的幽靈支付就是這樣來的:交易成功、gas 燒了、餘額沒變。如果 relayer 看到收據就能推 settled,這筆錢就在帳上憑空出現了。

  2. 唯一的回頭路 confirming → settling 只有 listener 能走,而且要留理由。這條路是給 reorg 用的,走一次就要在 History 留一筆,因為「一筆 settled 的 intent 有沒有經歷過 reorg」光看狀態看不出來,只有歷程知道。

  3. 進「有一筆交易在鏈上」的狀態一定要帶 tx hash,走任何非正常路(取消、失敗、退回、送審)一定要留一句 reason。證據是轉移的一部分,不是可選的欄位。宣告 settled 的 hash 還必須就是 confirming 時記下的那一筆,不一樣代表 listener 看到的跟 relayer 送的不是同一筆交易,這種事不能靜靜地過。

  4. 進入 terminal 狀態後 intent 的狀態就固定了,連 operator 也無法更改(總會有人想要用管理員權限直接改狀態xdd)。修正靠新的 intent,沖正就是一筆反向的新 intent,不會靠改舊的 intent 來修正結算金額。

Apply 判「合不合法」的順序是刻意排的:

「已經在目標狀態」擺在第一格、而且不算錯,是整個順序裡最重要的決定。queue 是 at-least-once 的(SQS 的文件直接寫「more than one copy of a message might be delivered」),listener 重掃區塊也很正常,同一個事件送到兩次以上是支付系統的日常,反正重放的事件要能安靜地通過。所以 Apply 有三種結果,不是兩種:

// applied=true:真的推進了
// applied=false, err=nil:已經在那裡了,重放,不算錯
// err != nil:拒絕,intent 一個欄位都沒動
func Apply(it *Intent, req Request) (applied bool, err error)

拒絕的理由也刻意分開列。ErrIllegalTransitionErrForbiddenActor 是 bug,代表某個 actor 在做不該做的事,要發出告警;而 ErrTerminalErrVersionConflict 是正常的競爭結果,輸的一方放手就好。呼叫端靠 errors.Is 分辨這兩類,決定要叫人類介入還是要系統閉嘴(題外話,想到這類支付系統開發人員還要 oncall 就真的很躁)。

Day 2 的四類例外落在哪一格

Day 2 提到的四類例外,今天可以對到表上了:

例外 停在哪 誰推的 為什麼
會 revert 的(重試有救) 留在 settling relayer 自己處理 重送是「嘗試」不是「狀態」,狀態機不知道也不需要知道試了幾次
永遠失敗的 settling → failed relayer 黑名單、凍結,重試多少次都一樣,確定沒動錢
不會 revert 的 confirming → needs_review listener 交易成功但事件與餘額都沒變,這是幽靈支付,不能 settled
金額對不上的 confirming → needs_review listener 實收小於請款。要不要在容差內自動放行是商業決定,今天不做,之後會討論

注意,狀態機本身不看金額、不看事件、不看 revert 原因,它只判斷「這個角色從這一格走到那一格、帶著這些證據,合不合法」。「誰去看鏈上的事件、比對餘額、判斷 revert 有沒有救」是 relayer 與 listener 的事,之後會專門討論。狀態機的邊界刻意收在這裡,因為它是唯一每一層都會經過的東西,塞進去的每一條規則都會變成所有 actor 的負擔(然後某天就會有人在裡面寫 if token == USDT,笑死)。

兩個 worker 同時搶同一筆 intent 的競爭問題

最後還有一個問題:Apply 是純函式,它不知道有沒有別人也在動同一筆 intent。兩個 relayer worker 各自讀到 authorized、各自推到 settling、各自廣播,錢就被動了兩次。所以 Intent 帶一個 Version,每轉移一次加一,存檔時做 compare-and-swap(大師 Martin Fowler 叫它 Optimistic Offline Lock):

// Save 只在存的那份 Version 等於 expectedVersion 時寫入。
// 兩個 worker 各自讀到版本 2、各自 Apply,只有第一個 Save(it, 2) 會成功,
// 第二個拿到 ErrVersionConflict,重讀之後發現已經是 settling,於是放手。
func (s *MemoryStore) Save(ctx context.Context, it *Intent, expectedVersion uint64) error

但 CAS 只保護「寫回」這一步,順序才是重點。同一筆 intent,API 想取消、relayer 想拿去送,兩邊都合法(authorized → canceledauthorized → settling 都在表上),誰先寫回誰贏,最後讓 relayer 先記著再廣播:

「先記再廣播」的代價是多了一種「卡在 settling 卻沒有交易」的情況要收拾,但至少那是可以被發現並且處理的。這也是 settling → needs_review 這條路存在的理由之一,畢竟不只鏈上的交易會下落不明,系統自己也會掛在半路。relayer 實際怎麼收拾這種情況,之後會專門討論。

小結

今天沒有碰鏈,全部在定義「一筆付款可以停在哪、誰能推、推要拿什麼證據」:每個非 terminal 狀態各有負責的 actor,重放是 no-op、terminal 狀態沒辦法更改、needs_review 不能重送、只有 listener 能宣告錢的動向,存檔靠版本號做 CAS。這些規則單獨看每一條都很小,但合起來真的就是「錢只動一次」在鏈下的重要骨架了。剩下實作就靠各自工程師功力,但處理支付請求的重要流程設計就差不多是這樣。

明天會回到 created 之前那一格:API 收到同一筆請求三次,怎麼讓它只長出一筆 intent(老熟的面試題哈哈哈)。

明天見。


上一篇
Day 3 | 穩定幣實作 (下):Local Devnet 部署與無痛的自動化狀態注入
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言