iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

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

Day 27 | SUI 的衝擊 (上):Object Ownership 對結算合約設計的顛覆

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-27

其實歸納起來,EVM、Solana 和 TON 有一個共同的前提:錢是某個帳戶名下的一個數字,而改那個數字的是合約。EVM 的結算合約拿 payer 給的 allowance 呼叫 transferFrom,Solana 的 program 用 PDA 的簽名動 vault 那個 token 帳戶,TON 的 jetton wallet 收到 message 之後把自己的餘額減掉。SUI 上這個前提不成立。錢是一顆一顆的 Coin<T> object,每一顆都有 owner,而「這筆交易帶的 object 是不是簽名的人的」這件事,節點在跑任何一行 Move 之前就先檢查完了。今天要看的是這件事怎麼把結算合約的形狀整個換掉:四個入口少掉兩個,而留下來的每一個入口,都得先處理一個前三條鏈從來不用問的問題:它的狀態要放在哪一個 object 上,那個 object 歸誰。

今天的目標

今天做的是 contracts/sui/settlement,一個只依賴 Sui framework 的 Move 模組。規則跟前三條鏈同一組:一把 ref 只付一次、託管先鎖後放、手續費在結清那一刻才收。變的是這些規則的狀態住在哪裡。EVM 上它們都住在合約的 storage 裡,不用選。這裡每一條都得放進某一個 object,而每一個 object 都得有 owner,今天所有的決定都是從這裡長出來的。

說真的,Sui 自己的文件就講得夠完整了:

  1. Types of Object Ownership 那頁有一張表,一種 ownership 一列,我們用得到前兩列:address-owned 是「A single address can use the object.」,走的是 Fastpath;shared 是「Any address can use the object, subject to Move checks.」,走 Consensus。同一頁最後那段警告才是重點:「Anyone can submit a transaction that references a shared object. Shared-object access is not restricted by ownership, so secure it in Move with explicit authorization checks (such as a capability argument, a tx_context::sender() check, or validating object ownership).」今天會用到括號裡三種寫法中的其中兩種
  2. Address-Owned Objects 把「只有 owner 動得了」講到最白:「An address-owned object is only accessible to its owner.」,然後補一句「Other addresses cannot access owned objects in any way.」
  3. capability 模式:「A capability is a pattern that allows authorizing actions with an object.」權限是一個傳得進函式的 object,誰拿得出那個 object,誰就有這個權限

做完之後 repo 中 contracts/sui/settlement 的對外形狀是這六個函式:

public fun open_book(ctx: &mut TxContext)
public fun pay<T>(book: &mut Book, coin: Coin<T>, merchant: address, ref: vector<u8>, ctx: &mut TxContext)
public fun hold<T>(
    book: &mut Book, coin: Coin<T>, merchant: address, fee: u64, fee_recipient: address,
    ref: vector<u8>, refund_after_ms: u64, clock: &Clock, ctx: &mut TxContext,
)
public fun release<T>(_: &OperatorCap, hold: Hold<T>, ctx: &mut TxContext)
public fun refund<T>(_: &OperatorCap, hold: Hold<T>, ctx: &mut TxContext)
public fun refund_expired<T>(hold: Hold<T>, clock: &Clock, ctx: &mut TxContext)
  • 少掉的是 settle()settleWithPermit(),也就是 EVM 那兩個靠 allowance 從 payer 帳上拉錢的入口。這條鏈上沒有東西能替代它們
  • 多出來的是 open_book()。EVM 上記著「哪些 ref 付過了」的 paid 是合約 storage 裡的一張表,合約部署好就在那裡,所有人共用。這裡沒有合約 storage 這種東西,這張表得做成一個 object,而 object 一定有 owner,所以要先有人開它、也要決定它歸誰。這個決定是今天篇幅最長的一段
  • release 的第一個參數 _: &OperatorCap 就是權限本身,它取代 EVM 的 require(isRelayer[msg.sender], ...)。底線代表函式根本不讀它的內容,呼叫端拿得出這個 object 就算過關
  • 金額不在參數裡:pay 收的是一整顆 Coin<T>,要付多少由呼叫端先切好。切的方法是明天的事

沒有 allowance,四個入口就剩兩個

EVM 的四個入口裡有兩個是 pull:settle() 靠 payer 事先給的 allowance,settleWithPermit() 靠一份簽名換來的一次性授權,兩個都由 relayer 發起、從 payer 帳上把錢拉走。這兩個入口在 SUI 上做不出來。payer 的 Coin<T> 是一枚 address-owned object,而「Other addresses cannot access owned objects in any way.」這條規則由節點在驗證交易的時候套用,合約還沒開始跑就套用了:relayer 簽的交易只要帶著別人的 object,節點當場退回,模組裡寫什麼都沒差。

留下來的是 push:payer 自己把一顆 coin 交進來。pay 於是收 Coin<T> 而不是金額加地址,模組拿到那顆 coin 就是拿到了那筆錢,不必再去查任何授權。這其實是 allowance 那個老問題(授權的範圍比一筆付款寬)的極端版本:EVM 上我們後來用 Permit2 的簽名把授權收窄到一筆,這裡的授權物直接就是那筆錢本身,範圍不可能再寬。

代價是 payer 得簽每一筆交易。前三條鏈上 relayer 可以拿自己的錢包簽、payer 只出授權,這條鏈上簽名的必須是那顆 coin 的 owner。撥款這條線的 payer 就是平台自己,所以這件事不痛。換成要從別人的錢包收錢,payer 就得自己來簽每一筆。

paid 那張表要開在誰名下

EVM 的 replay 防護是一行:mapping(bytes32 => bool) public paid。它住在合約的 storage 裡,而合約只有一份 storage,所以不用決定放在哪、也不用決定歸誰。這裡它必須是一個 object,而一個 object 要嘛歸某個地址、要嘛 shared,於是「歸誰」變成一個要自己處理的問題,而兩個答案擋的東西不一樣:

全網共用一本 shared Book 的問題不只是慢。所有 payer 擠同一個 object,文件自己講了會怎樣:「Contention on a single shared object can reduce transaction throughput」。更難處理的是另一件事:shared object 誰都能塞進交易裡,所以任何人都可以拿自己的一顆 coin、帶著我們還沒付的那把 ref,先呼叫一次 pay,把那把 ref 記進 Book。等我們的付款走到鏈上,撞到的就是「這把 ref 付過了」,而剛剛動的錢是陌生人自己的,我們的 merchant 一毛都沒收到。花一點手續費就能讓別人一整份撥款名單全部卡住,這種入口不能留。

我選每個 payer 一本。它擋的是這個系統真正在防的那件事:同一個 payer 把同一把 ref 付兩次,來源是 relayer 重試、job 重送、API 端重放。至於陌生人帶著我們的 ref 付一筆錢,那從來就不是我們的錢動了第二次,對帳引擎照舊把它列成 unexpected,四條鏈的處理方式都一樣。

public struct Book has key {
    id: UID,
    payer: address,
    paid: Table<vector<u8>, bool>,
}

這三個欄位每一個都是一個決定。has key 而沒有 store,意思是模組外面沒有任何辦法把這個 object 轉給別人:一本 Book 開在哪個地址就永遠在那個地址,付過的 ref 不會跟著一次轉帳消失在另一個帳戶裡。payer 那一欄記著這本是誰的,因為 pay 發出去的 event 要說得出這筆錢是誰付的,而這條鏈上沒有 msg.sender 可以問。paid 是一張 Table,每一把 ref 進去就是一個 dynamic field,也就是鏈上一顆獨立的小 object。

這一本會一直長大,而長大是要付錢的。SUI 上每寫一個 object 都要付 storage 費用,這筆費用退得回來,但條件是東西被刪掉:「Sui storage mechanics provide storage fee rebates whenever a transaction deletes previously stored objects.」,退的比例則是「Initially, the rebateable amount equals 99% of the storage fees, while the non-rebateable amount equals the remaining 1%.」(gas 的文件)。付過的 ref 永遠不能刪,所以這筆費用永遠不會退,而且跟付款筆數等比例地長。

還有一個缺點來自 owned object 本身的規矩:一顆 owned object 一次只能被一筆交易動。同一個 payer 兩筆交易同時在路上、兩筆都指名 Book 的同一個版本,那就是 equivocation,而官方 guide 講得很直接:「The effects of this type of equivocation can lock the objects your code interacts with until the end of the current epoch.」被鎖住的是 Book,也就是那個 payer 接下來一整個 epoch 都付不了錢。撥款這條線的交易是一筆接一筆送的,下一筆用的是上一筆改完之後的新版本,所以踩不到。但要同時開幾條並行的付款線,就得一條線一本 Book,而這件事在開 Book 的時候就要決定。

託管一定要共用,所以權限得是一個 object

託管的答案剛好相反:它必須共用。錢鎖起來之後有兩個人可能來收尾,operator 來 release,或者期限過了 payer 自己來 refund_expired。兩個不同的地址都要碰得到同一筆託管,owned object 立刻出局,Hold<T> 只能做成 shared object:

transfer::share_object原始碼註解第一句就先講這一步不能反悔:「Turn the given object into a mutable shared object that everyone can access and mutate. This is irreversible, i.e. once an object is shared, it will stay shared forever.」而 everyone can access 是字面意思,所以擋人的工作全部落在 Move 這一層。決策樹最下面那兩條是我們用到的兩種寫法,差別在要擋的人是用什麼身分來的:一個可以換人的角色,還是這筆資料自己記著的當事人。

operator 是一個角色,換人是常態,所以它的身分是一顆 OperatorCap

public fun release<T>(_: &OperatorCap, hold: Hold<T>, ctx: &mut TxContext) {
    let Hold { id, ref, payer: _, merchant, mut funds, fee, fee_recipient, refund_after_ms: _ } = hold;
    let hold_id = id.to_inner();
    id.delete();
    let amount = funds.value();
    if (fee > 0) {
        transfer::public_transfer(coin::take(&mut funds, fee, ctx), fee_recipient);
    };
    event::emit(Released<T> { hold: hold_id, ref, merchant, amount: amount - fee, fee, fee_recipient });
    transfer::public_transfer(funds.into_coin(ctx), merchant);
}

第一個參數就是全部的權限檢查,函式裡一行 assert 都沒有:拿不出那顆 cap 的人根本組不出這個呼叫。跟 EVM 那份 isRelayer 名單比,差別在名單住哪裡。那份名單住在合約的 storage 裡,改它要一筆交易、要 owner 權限。這裡的名單就是「誰手上有那顆 cap」,換 operator 是把它轉走,而 OperatorCap 帶著 store 就是為了讓它轉得動。

第二行還有一個效果。let Hold { ... } = hold 把那顆 shared object 拆成一個一個欄位,Move 的規矩是拆出來的每一個欄位都要交代去處,id 的去處就是 id.delete(),從這一行起那筆託管在鏈上不存在了。EVM 的 delete holds[ref] 做的是同一件事,但那只是把一張表裡的一列清成零;這裡消失的是一整個 object,連 storage 費用都退得回來。

refund_expired 用的是另一種檢查。它問的是「你是不是這筆託管的 payer」,而那是這筆資料自己記著的當事人,拿 ctx.sender() 比對就好,不必另外發一顆 cap 給每個 payer。這個入口是 payer 的保底:錢鎖在那個 object 裡的期間,payer 不必相信 operator 總有一天會來處理,期限一到自己就拿得回來。

payer 手上多了一本永遠轉不走的帳,operator 手上多了一顆隨時可以轉走的 cap,而託管期間的錢不歸任何地址:

圖上的 Book 一條線都沒有連出去,這是刻意的:託管裡的錢由 operator 手上那顆 cap 動得了,但「這筆付款有沒有付過」只有 payer 自己的交易改得動,operator 一輩子碰不到它。

小結

今天的每一個決定的目的都是處理同一個問題:這個狀態要放在哪一個 object 上,那個 object 歸誰。記著哪些 ref 付過的 Book 每個 payer 一本、歸 payer 自己,它只擋同一個 payer 把同一把 ref 付兩次,換來的是付款走 fastpath、不用排共識。鎖著託管款的 Hold<T> 做成 shared object,operator 與 payer 都碰得到,擋人的工作交給 Move:operator 的身分是一顆轉得走的 OperatorCap,payer 的身分是 Hold 裡記著的那個地址。

今天刻意沒選的作法是「只開一本 shared Book,所有 payer 的付款都記進同一本」。這個做法多擋得住一件事,陌生人拿我們的 ref 付一筆錢,鏈上當場就會被「這把 ref 付過了」攔下,不用等對帳引擎把它列成 unexpected。但每一筆付款都要經過共識排序、所有 payer 擠同一個 object,還有更麻煩的一件事:shared object 誰都能塞進交易,任何人都可以拿一把我們還沒付的 ref 先呼叫一次 pay,我們整份撥款名單就付不出去。用「鏈上多攔一種對不上的帳」去換「任何人花一點手續費就能讓我們的名單卡住」,我不接受。

今天的模組只有單筆的 pay,一次要付 300 個 merchant 顯然不能送 300 筆交易。明天輪到一次付一批:這條鏈的交易本身就裝得下 N 筆付款,那 relayer 在這條鏈上還剩下什麼工作。

明天見。


上一篇
Day 26 | TON 的極端案例 (下):模糊的 Tx 完成判定與對帳挑戰
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言