iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Software Development

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

Day 3 | 穩定幣實作 (下):Local Devnet 部署與無痛的自動化狀態注入

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-03

昨天的測試成果無法輕易 reproduce

昨天用四隻 mock 把四類例外都列舉了,但那些 token 只存在每次執行 forge test 時:每個測試在 setUp 裡部署一遍,斷言完就丟掉。測合約的時候勉強堪用,但對接下來要實作的完整支付系統來說是不完整的,畢竟 Payment Intent 狀態機、ledger、之後的 Go relayer 等等,它們需要一條可以重現某個狀態的本地端區塊鏈,上面已經有穩定幣、幾個有錢的 payer 錢包、被列入黑名單的地址,而且每次都能把這些東西 reproduce。

今天就是把這整條鏈搭建起來,然後把「狀態注入」做成一個指令。

為什麼不直接用 testnet

很多測試鏈都拿得到 USDC(Circle 有官方 faucet),但拿不到帶著特殊設計的 USDT,也拿不到會回傳 false 的 token 或常駐轉帳稅的 token。就算拿得到,狀態也不受我們控制:我們沒辦法把某個地址列入黑名單來測「永遠失敗」那一類、沒辦法打開轉帳稅來測「金額對不上」、沒辦法在 CI 裡每次都從同一個乾淨狀態開始測試。

所以我們希望測試環境會分兩層:本地 devnet 負責「我要的狀態隨時能造出來、造出來的東西可重現」,testnet 留給之後跟真正外部系統(錢包、瀏覽器、別人的合約)整合的階段。今天就是要來幹出本地 devnet。

工具是 Anvil,Foundry 內建的本地節點。起得很快,預設產生 10 個帳號、每個 10000 ETH,chain id 31337,交易一進來就出區塊。它的助記詞 test test … junk 全世界一樣,Foundry 文件也提醒接公開 RPC 時別用預設帳號,所以今天所有腳本只能對著本地跑。

做完大概會長這樣

指令:

make evm-test    # cd contracts/evm && forge test(離線,30 個測試)
make devnet      # 起 anvil、部署 Token Zoo、注入狀態;重複執行不會重做

結果:

[devnet] anvil: running (pid 6005) at http://127.0.0.1:8545, block 8
[devnet] USDC 0x5FbDB2315678afecb367f032d93F642f64180aa3  USDT 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
[devnet] payer       0x70997970C51812dc3A010C7d01b50e0d17dc79C8  USDC 1000000  USDT 1000000
[devnet] merchant    0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC  USDC 1000000  USDT 1000000
[devnet] blacklisted 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720  USDT 1000000 (frozen)

我們可以去看 repo 多了三樣東西:根目錄的 Makefilescripts/devnet.sh(管 anvil 的起停)、contracts/evm/script/ 底下的部署與注入腳本、以及腳本產出的 contracts/evm/deployments/31337.json

幫狀態把關的三種 gate

這邊比較純技術,如果只對業務流程有興趣可以跳過。

把一條空鏈變成「有 USDT、有錢的付款人、有黑名單」的鏈,有不同 gate 可以走。三種 gate 都會用到,用的地方不同。

main gate:發交易

最基本的方法就是當成一般交易送上鏈:部署合約、mintaddBlackList,一筆一筆交易送上去。Foundry 的部署腳本用 Solidity 寫,vm.startBroadcast(key)vm.stopBroadcast() 之間的外部呼叫會被收集起來,模擬過一遍才真的送出(官方指南)。

這裡有一個結構上的決定:部署與注入的邏輯放在 TokenZooBase.sol,它是純 Solidity、沒有 run(),只有 deploy()seed() 兩個函式。DeployTokenZoo.s.solSeedDevnet.s.sol 各自只包一層薄薄的 run(),負責拿金鑰、廣播、讀寫檔案。為什麼要拆?因為拆了之後,測試可以直接繼承 TokenZooBase,在 setUp 裡呼叫同一份 deploy()seed(),然後對種子狀態下斷言:付款人有 100 萬 USDT、黑名單地址手上有錢卻轉不出去、relayer 一毛都沒有。這組測試過了,make devnet 在 Anvil 上做出來的就是同一個世界,差別只在誰簽名。部署腳本本身也在測試覆蓋範圍內,不再是一段「應該沒問題」的膠水。

角色名單 DevnetAccounts.sol 是從那組公開助記詞推導地址:index 0 是 deployer(兼 USDT 發行商)、1 是 payer、2 是 merchant、3 是 relayer、9 是 blacklisted。不寫死 hex 的好處是 Solidity 測試、shell 腳本、之後的 Go 後端算出來的都是同一批人,之後每一篇提到這五個名字,指的都是同一個地址。

腳本跑完會寫一個交接檔 deployments/31337.json

{
  "chainId": 31337,
  "deployer": "0xf39F…2266",
  "accounts": { "payer": "0x7099…", "merchant": "0x3C44…", "relayer": "0x90F7…", "blacklisted": "0xa0Ee…" },
  "tokens":   { "USDC": { "address": "0x5FbD…", "decimals": 6, "kind": "ERC20Mock" }, "USDT": { … }, … }
}

它是合約層與鏈下層的交接點:後端不需要知道 Foundry 怎麼部署,只要讀這個檔。順帶一提,main gate 走出來的地址其實是可預測的(帳號 0 的第一筆部署永遠是 0x5FbD…0aa3,因為 CREATE 地址只看 deployer 與 nonce),但我不打算讓任何程式碼依賴它,地址一律從 JSON 讀,哪天 seed 前多送了一筆交易也不會全錯。寫檔需要在 foundry.tomlfs_permissions,我只開 ./deployments 這一個目錄。

寫腳本時踩到一個值得記的坑:在 script 裡,msg.sender 是 forge 的預設 sender0x1804c8AB…),跟你正在廣播的那把金鑰無關。fee-on-transfer mock 的抽稅收款人如果用 msg.sender 帶進去,會變成一個誰也不認識的地址。所以 deploy() 把收款人當參數傳,而 USDTMock 的 owner 沒事,因為它是在自己的建構子裡讀 msg.sender,那一格已經是廣播出去的交易了。

secondary gate:直接改狀態

走 main gate 的前提是「你有權限」。這在自己的 mock 上當然成立,但換到 fork 主網、面對真 USDT,你既不是 owner 也沒有餘額。這時候 Anvil 有一組 anvil_* RPC 讓你直接寫鏈的狀態,三個最常用的:

cast rpc anvil_dealERC20 $RELAYER $USDT 0xee6b280            # 憑空給 relayer 250 USDT
cast rpc anvil_impersonateAccount $BLACKLISTED               # 之後可以用這個地址發交易,不需要私鑰
cast rpc anvil_setStorageAt $USDT $(cast index address $BLACKLISTED 6) $(cast to-uint256 0)   # 把黑名單旗標翻掉

anvil_dealERC20v1.3.0 才加的,測試裡對應的是 forge-std 的 deal,原理相同:先錄下 balanceOf(who) 讀了哪些 slot,再逐一改值、看回傳值有沒有跟著變,變了就是它。所以你不必知道 balance 存在第幾個 slot,也不必管 token 是不是 proxy。

不過其實 secondary gate 有一個比較大的代價是「deal 只改 balanceOftotalSupply 不動、Transfer 事件不會發送」。這段話的意思就是「餘額加總從此不等於總供給,靠事件對帳的 listener 也看不到這筆錢」,而這對結算系統來說是蠻大的問題,因為之後對帳引擎認的就是事件與餘額。所以我們在測試時的原則是:種子狀態一律走 main gate,但臨時要一筆錢或者 fork 模式下我們根本沒有 main gate 的 key 時才使用 secondary gate。

side gate:把狀態存成 snapshot

main gate 走過一次之後,不想每次啟動測試時都再走一次。Anvil 的 --state <path> 同時是 --load-state--dump-state:檔案存在就從它恢復,程式退出時把整條鏈寫回去。scripts/devnet.sh 就靠這一個旗標決定要不要重新部署:狀態檔和交接檔都在,就直接恢復;少任何一個,就重新走 main gate。

make devnet-down 送 SIGINT 讓 Anvil 有機會 dump,make devnet-reset 把兩個檔一起刪掉,下次從創世區塊重來。

同一件事在測試層級的版本是 evm_snapshot / evm_revert:把狀態存成 snapshot、做一堆鳥事、結束後回到那個 snapshot(之後測 relayer 的失敗重試會大量用到)。

用 fork test 檢查 mock 的可靠性

昨天的 mock 是照著 Etherscan 上的原始碼寫的,但「照著寫」和「行為一致」是兩回事。所以 test/fork/USDTMainnet.t.solvm.createSelectFork 把主網拉進來,缺的餘額用 deal 補(這就是 secondary gate 的其中一種正當用途),然後把昨天那組斷言對真實的 USDT 再跑一遍:低階 call 成功但 returndata 是 0 bytes、標準介面呼叫會 revert、approve 有歸零鎖、basisPointsRatemaximumFeesetParams 的上限內。只斷言上限不斷言現值,因為 Tether 隨時可以改。沒設 ETH_RPC_URL 就整組 skip,forge test 預設離線;make devnet 也刻意不走 fork,因為我希望它能夠完全離線執行。

另外三條鏈

同樣三個 gate 的概念在其他鏈上都有對應的東西:Solana 的 solana-test-validator 可以用 --clone 把主網帳號複製進本地、TON 有 @ton/sandbox 直接在 Node 裡模擬整條鏈、SUI 的 sui start --with-faucet 起 localnet 並附 faucet。寫到那幾條鏈時再一併蓋,一樣掛在 make devnet 底下。

小結

今天沒有新的合約邏輯,全部還在處理測試基礎框架:一條可中斷和恢復、狀態不丟失的本地鏈;一份可以被測試繼承的部署與注入腳本;一個 deployments/31337.json 當合約層與鏈下層的交接檔;一組對真實世界 USDT 跑的測試。這三個 gate 之後會反覆出現,尤其是用到 secondary gate 時其實會有點麻煩,它跳過的 totalSupplyTransfer 事件對帳時得自己補回來。

明天開始處理鏈下的東西:Payment Intent 狀態機、看一筆付款從 API 進來到鏈上結束中間可以停在哪幾個狀態,以及誰有權讓它往前走。

明天見。


上一篇
Day 2 | 穩定幣實作 (上):ERC-20 代幣在現實世界中的各種陷阱
下一篇
Day 4 | 核心狀態機:支付意圖 (Payment Intent) 的生命週期流轉
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言