本日程式碼:repo tag day-04
我們現在 devnet 上有 USDC 和 USDT、有一個有錢的 payer、有動不了錢的黑名單地址,所以可以來打一筆錢了。還記得第一天的圖裡面有一個「Payment Intent」的東西嗎?那個就是「錢在鏈上開始移動之前,鏈下先產生的代表物件」:它從 API 收到請求的那一刻產生、穿過 queue + relayer + listener 後最後停在某個 terminal state。
今天我們就是要來實作出 Payment Intent 狀態機。完成這個實作,主要有三個點要考慮:
基於這三個點,其實我們可以用一個 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、沒有鏈:它們是之後提出請求的人,先把「誰能提什麼請求」定下來。
圖上最重要的東西是那幾個框:每個非 terminal 狀態都剛好落在一個 actor 的地盤裡。created 與 authorized 是 API 的(簽名迴圈在這裡跑)、settling 是 relayer 的、confirming 是 listener 的、needs_review 是人的。狀態名同時就是責任歸屬,看到一筆 intent 停在哪,就知道該去找誰。
如果你拿最初我們設計的狀態機來比較(Day 1 的那個),你會發現有一些不一樣的地方:
funded 改叫 authorized:簽名迴圈的第三步是付款人把簽好的 payload 送回來,那代表「他授權了」,不代表「錢在」。錢在不在、allowance 夠不夠、地址有沒有被列入黑名單,要到鏈上才知道,鏈下這一格叫 funded 的話會讓人以為已經查過了。
多了 confirming:交易進了區塊不等於結束:以太坊在 finality 之前的區塊是可以被換掉的,reorg 會把已進區塊的交易吐回來。但它也不能當成沒送過,再送一次錢就多動一次。所以需要一個「有一筆在鏈上、但還不能對商家說完成」的停靠點。它歸 listener 管,因為只有 listener 從鏈上讀事實;relayer 看到的是「我送出去了」,不是「錢動了」。
多了 needs_review:金額對不上、交易成功但餘額沒變、交易送出去之後下落不明,這些情況系統不該自己下判斷。它不是 terminal,但從這裡出去只有兩條路:settled 或 failed,都要人來走。沒有「退回 settling 重送」這條路,因為交易下落不明時再送一次就可能付兩次;人只能判定「已付」或「未付」,想再付就開一筆新的 intent。
順帶一提,Stripe 的 PaymentIntent 是反過來的:付款嘗試失敗會退回 requires_payment_method 讓你換張卡重試。那是因為卡片被拒是一個乾淨的失敗,Stripe 確定錢沒動;鏈上的「不知道有沒有成功」不是。同一個 intent 能不能重試,取決於你有多確定上一次沒動到錢。
前面那張表的 BY 那一欄是需要花最多時間想的部分:
只有 listener(和人類)能把狀態推到 settled。API 與 relayer 都不行,因為它們手上的資訊最多是「交易送出去了」「交易進區塊了」。Day 2 的幽靈支付就是這樣來的:交易成功、gas 燒了、餘額沒變。如果 relayer 看到收據就能推 settled,這筆錢就在帳上憑空出現了。
唯一的回頭路 confirming → settling 只有 listener 能走,而且要留理由。這條路是給 reorg 用的,走一次就要在 History 留一筆,因為「一筆 settled 的 intent 有沒有經歷過 reorg」光看狀態看不出來,只有歷程知道。
進「有一筆交易在鏈上」的狀態一定要帶 tx hash,走任何非正常路(取消、失敗、退回、送審)一定要留一句 reason。證據是轉移的一部分,不是可選的欄位。宣告 settled 的 hash 還必須就是 confirming 時記下的那一筆,不一樣代表 listener 看到的跟 relayer 送的不是同一筆交易,這種事不能靜靜地過。
進入 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)
拒絕的理由也刻意分開列。ErrIllegalTransition 與 ErrForbiddenActor 是 bug,代表某個 actor 在做不該做的事,要發出告警;而 ErrTerminal 與 ErrVersionConflict 是正常的競爭結果,輸的一方放手就好。呼叫端靠 errors.Is 分辨這兩類,決定要叫人類介入還是要系統閉嘴(題外話,想到這類支付系統開發人員還要 oncall 就真的很躁)。
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,笑死)。
最後還有一個問題: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 → canceled 與 authorized → settling 都在表上),誰先寫回誰贏,最後讓 relayer 先記著再廣播:
「先記再廣播」的代價是多了一種「卡在 settling 卻沒有交易」的情況要收拾,但至少那是可以被發現並且處理的。這也是 settling → needs_review 這條路存在的理由之一,畢竟不只鏈上的交易會下落不明,系統自己也會掛在半路。relayer 實際怎麼收拾這種情況,之後會專門討論。
今天沒有碰鏈,全部在定義「一筆付款可以停在哪、誰能推、推要拿什麼證據」:每個非 terminal 狀態各有負責的 actor,重放是 no-op、terminal 狀態沒辦法更改、needs_review 不能重送、只有 listener 能宣告錢的動向,存檔靠版本號做 CAS。這些規則單獨看每一條都很小,但合起來真的就是「錢只動一次」在鏈下的重要骨架了。剩下實作就靠各自工程師功力,但處理支付請求的重要流程設計就差不多是這樣。
明天會回到 created 之前那一格:API 收到同一筆請求三次,怎麼讓它只長出一筆 intent(老熟的面試題哈哈哈)。
明天見。