From f0d30fa070f3b636646c636d56d3c010bd3836fe Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Wed, 27 May 2026 16:37:59 +0200 Subject: [PATCH 01/14] feat: sui ntt pauser --- .claude/tasks/sui-wtt-pauser.md | 380 +++++++++++++ sui/token_bridge/Move.toml | 2 +- sui/token_bridge/sources/attest_token.move | 1 + .../sources/complete_transfer.move | 1 + .../complete_transfer_with_payload.move | 1 + sui/token_bridge/sources/create_wrapped.move | 6 +- .../governance/set_pauser_addresses.move | 131 +++++ sui/token_bridge/sources/migrate.move | 2 +- sui/token_bridge/sources/pause.move | 75 +++ sui/token_bridge/sources/state.move | 140 ++++- .../sources/test/coin_wrapped_12.move | 2 +- .../sources/test/coin_wrapped_7.move | 2 +- .../sources/test/pause_tests.move | 511 ++++++++++++++++++ sui/token_bridge/sources/transfer_tokens.move | 1 + .../sources/transfer_tokens_with_payload.move | 1 + sui/token_bridge/sources/version_control.move | 17 +- 16 files changed, 1259 insertions(+), 14 deletions(-) create mode 100644 .claude/tasks/sui-wtt-pauser.md create mode 100644 sui/token_bridge/sources/governance/set_pauser_addresses.move create mode 100644 sui/token_bridge/sources/pause.move create mode 100644 sui/token_bridge/sources/test/pause_tests.move diff --git a/.claude/tasks/sui-wtt-pauser.md b/.claude/tasks/sui-wtt-pauser.md new file mode 100644 index 00000000000..f19a3b7acd1 --- /dev/null +++ b/.claude/tasks/sui-wtt-pauser.md @@ -0,0 +1,380 @@ +# SUI Token Bridge Pauser — Implementation Plan + +## Context + +The Wormhole Token Bridge is adding emergency pause support across all chains. +PRs already exist for: + +- **Design** — [#4809](https://github.com/wormhole-foundation/wormhole/pull/4809) (whitepaper 0003 update) +- **Guardian** — [#4810](https://github.com/wormhole-foundation/wormhole/pull/4810) (node: VAA generation for `SetPauserAddresses`) +- **EVM** — [#4801](https://github.com/wormhole-foundation/wormhole/pull/4801) (action 4, fixed 20-byte addresses) +- **SVM** — [#4802](https://github.com/wormhole-foundation/wormhole/pull/4802) (action 5, fixed 32-byte pubkeys) + +This plan covers the **SUI** implementation. + +--- + +## Design Summary + +Two roles — **pauser** and **unpauser** — are configured via a `SetPauserAddresses` governance VAA +(module `"TokenBridge"`, new action ID). When `paused == true`, all user-facing entry points revert. +Governance handlers + `pause()`/`unpause()` remain callable. + +### Wire Format (from whitepaper) + +``` +Module(32) "TokenBridge" left-padded +Action(1) new action ID for SUI (see "Action ID" section below) +ChainID(2) 21 (SUI) +Payload SUI-specific: pauser(32) + unpauser(32) +``` + +### Action ID Decision + +The guardian Go code uses a **single action 4** with length-prefixed addresses on the wire. +However, each runtime interprets a fixed layout: + +| Runtime | Action | Address Format | +|---------|--------|----------------| +| EVM | 4 | Fixed 20 bytes (no length prefix) | +| SVM | 5 | Fixed 32 bytes (no length prefix) | +| **SUI** | **?** | Fixed 32 bytes (Sui addresses are 32 bytes) | + +**Options:** + +1. **Reuse action 5** — SUI addresses are 32 bytes like SVM, same wire layout. + The SVM contract already uses action 5 with `MODULE = "TokenBridge"`. + But SUI governance uses `authorize_verify_local()` which checks `chain == 21`, + so SVM (chain=1) and SUI (chain=21) VAAs cannot collide even with the same action ID. + **This is safe and mirrors how register_chain (action 1) and upgrade_contract (action 2) + are shared across all chains with chain-ID differentiation.** + +2. **Use action 6** — New SUI-specific action. Cleanest separation but burns an action ID + for no added security (chain-ID already prevents cross-chain replay). + +**Recommendation: Use action 4 (the canonical wire action).** The guardian serializes action 4 +with length-prefixed addresses. SUI should parse the length-prefixed format directly, matching +the whitepaper spec. This is what the EVM/SVM PRs *should* be doing too (there's a discrepancy +in those PRs where they parse fixed-layout but the guardian emits length-prefixed). Confirm with +the team which approach wins. If the team prefers per-runtime action IDs, use **action 6**. + +**For this plan, we will use action 4 with length-prefixed parsing**, since that matches +the whitepaper and the guardian serialization. If the team decides otherwise, the only change +is the action constant and the deserialization logic. + +--- + +## Architecture + +### Approach: Modify Token Bridge State Directly (Not a Companion Package) + +Unlike NTT SUI governance (which uses a separate companion package because NTT is parameterized +by `CoinType` and SUI lacks dynamic dispatch), the Token Bridge has a **single shared `State` +object**. We can add pause fields directly to `State` using **dynamic fields** — same pattern +the Token Bridge already uses for version info. + +This approach: +- Avoids deploying a separate governance package +- Keeps the pause check inside the existing version-controlled module +- Is delivered as a contract **upgrade** (new version V__0_3_0) +- Uses the existing `DecreeTicket`/`DecreeReceipt` pattern that `register_chain` and + `upgrade_contract` already use + +### State Changes (Dynamic Fields) + +We store pause state as dynamic fields on `State.id` to avoid breaking the existing +struct layout: + +```move +struct PauserKey has copy, drop, store {} // → address (32 bytes), 0x0 = unassigned +struct UnpauserKey has copy, drop, store {} // → address (32 bytes), 0x0 = unassigned +struct PausedKey has copy, drop, store {} // → bool +``` + +On first `SetPauserAddresses` governance VAA (or during `migrate`), these fields are +initialized. Before they exist, `is_paused()` returns `false` (backwards compatible). + +### Entry Points Requiring `notPaused` Guard + +All version-controlled user-facing functions: + +| Module | Function | Direction | +|--------|----------|-----------| +| `transfer_tokens` | `transfer_tokens()` | Outbound | +| `transfer_tokens_with_payload` | `transfer_tokens_with_payload()` | Outbound | +| `attest_token` | `attest_token()` | Outbound | +| `complete_transfer` | `authorize_transfer()` | Inbound | +| `complete_transfer_with_payload` | `authorize_transfer()` | Inbound | +| `create_wrapped` | `complete_registration()` | Asset mgmt | +| `create_wrapped` | `update_attestation()` | Asset mgmt | + +### Entry Points Exempt from Pause + +| Module | Function | Reason | +|--------|----------|--------| +| `register_chain` | `register_chain()` | Governance — must remain callable | +| `upgrade_contract` | `authorize_upgrade()` | Governance — must remain callable | +| `upgrade_contract` | `commit_upgrade()` | Governance — must remain callable | +| `migrate` | `migrate()` | Upgrade — must remain callable | +| `set_pauser_addresses` | (new) | Governance — must remain callable | +| `pause` | (new entry function) | Must be callable to trigger pause | +| `unpause` | (new entry function) | Must be callable when paused | + +--- + +## Implementation Steps + +### Phase 0: Tests First (TDD Red) + +Write tests before implementation. Test files go in `sui/token_bridge/sources/test/` or alongside +governance modules. + +**Test cases for `set_pauser_addresses`:** +1. Success — sets pauser and unpauser via governance VAA +2. Rejects wrong module +3. Rejects wrong chain (not SUI) +4. Rejects replayed VAA (consumed) +5. Can rotate — second VAA overwrites previous addresses +6. Zero address sets role as unassigned + +**Test cases for `pause`/`unpause`:** +7. Pauser can pause — `is_paused()` becomes true +8. Non-pauser cannot pause — aborts +9. Unpauser can unpause — `is_paused()` becomes false +10. Non-unpauser cannot unpause — aborts +11. Pause when pauser is unassigned (zero addr) — aborts +12. Unpause when unpauser is unassigned — aborts +13. Pause is idempotent (calling pause when already paused is OK) +14. Unpause is idempotent + +**Test cases for `notPaused` guard:** +15. `transfer_tokens` reverts when paused +16. `complete_transfer::authorize_transfer` reverts when paused +17. `attest_token` reverts when paused +18. `create_wrapped::complete_registration` reverts when paused +19. Governance handlers still work when paused (register_chain, upgrade, set_pauser_addresses) +20. Legacy state (no dynamic fields yet) — all operations work as before (unpaused by default) + +### Phase 1: State Module Changes (`state.move`) + +1. Add dynamic field key structs: + ```move + struct PausedKey has copy, drop, store {} + struct PauserKey has copy, drop, store {} + struct UnpauserKey has copy, drop, store {} + ``` + +2. Add public getter functions: + ```move + public fun is_paused(self: &State): bool + public fun pauser(self: &State): address + public fun unpauser(self: &State): address + ``` + - `is_paused()` returns `false` if `PausedKey` dynamic field doesn't exist (backwards compat) + - `pauser()`/`unpauser()` return `@0x0` if field doesn't exist + +3. Add friend-only mutation functions: + ```move + public(friend) fun set_paused(_: &LatestOnly, self: &mut State, paused: bool) + public(friend) fun set_pauser_address(_: &LatestOnly, self: &mut State, pauser: address) + public(friend) fun set_unpauser_address(_: &LatestOnly, self: &mut State, unpauser: address) + ``` + +4. Add `assert_not_paused` helper: + ```move + public(friend) fun assert_not_paused(self: &State) + ``` + Aborts with `E_PAUSED` if `is_paused()` returns true. + +5. Add new friends: + ```move + friend token_bridge::set_pauser_addresses; + friend token_bridge::pause; + ``` + +### Phase 2: Version Control (`version_control.move`) + +1. Add new version `V__0_3_0`: + ```move + struct V__0_3_0 has store, drop, copy {} + ``` + +2. Update `current_version()` to return `V__0_3_0`. +3. Set `previous_version()` to `V__0_2_0`. + +### Phase 3: Governance — `set_pauser_addresses.move` (New File) + +New module: `token_bridge::set_pauser_addresses` + +``` +sui/token_bridge/sources/governance/set_pauser_addresses.move +``` + +**Constants:** +```move +const ACTION_SET_PAUSER_ADDRESSES: u8 = 4; +``` + +**Structs:** +```move +struct GovernanceWitness has drop {} +``` + +**Functions:** + +```move +/// Create DecreeTicket for SetPauserAddresses governance VAA. +/// Uses authorize_verify_local (chain-specific, chain == 21). +public fun authorize_governance( + token_bridge_state: &State +): DecreeTicket + +/// Execute the SetPauserAddresses governance action. +/// Consumes the DecreeReceipt, parses the payload, and updates state. +public fun set_pauser_addresses( + token_bridge_state: &mut State, + receipt: DecreeReceipt +) +``` + +**Payload Parsing (action 4, length-prefixed per whitepaper):** +``` +PauserLen(1) | Pauser(PauserLen) | UnpauserLen(1) | Unpauser(UnpauserLen) +``` + +Validation: +- If PauserLen > 0: must be exactly 32 bytes (SUI address size), else abort +- If PauserLen == 0: set pauser to `@0x0` (unassigned) +- Same for UnpauserLen/Unpauser +- All-zero 32-byte address treated same as unassigned (set to `@0x0`) +- No trailing bytes allowed (cursor must be fully consumed) + +### Phase 4: Pause/Unpause Entry Functions (New File) + +New module: `token_bridge::pause` + +``` +sui/token_bridge/sources/pause.move +``` + +**Entry functions:** + +```move +/// Pause the token bridge. Only callable by the configured pauser. +/// Aborts if pauser is unassigned (@0x0). +public entry fun pause( + token_bridge_state: &mut State, + ctx: &TxContext +) + +/// Unpause the token bridge. Only callable by the configured unpauser. +/// Aborts if unpauser is unassigned (@0x0). +/// NOT guarded by assert_not_paused (must be callable when paused). +public entry fun unpause( + token_bridge_state: &mut State, + ctx: &TxContext +) +``` + +**Logic:** +1. `assert_latest_only(state)` — version check +2. Read configured pauser/unpauser from state +3. Assert configured address != `@0x0` (`E_PAUSER_NOT_CONFIGURED`) +4. Assert `tx_context::sender(ctx) == configured_address` (`E_NOT_PAUSER` / `E_NOT_UNPAUSER`) +5. Call `state::set_paused(latest_only, state, true/false)` + +### Phase 5: Add Pause Guards to Existing Modules + +In each guarded module, add `state::assert_not_paused(&state)` right after +`state::assert_latest_only(&state)`. This ensures the pause check happens at the +same point as the version check — early, before any state mutation. + +Files to modify: +1. `transfer_tokens.move` — in `transfer_tokens()` +2. `transfer_tokens_with_payload.move` — in `transfer_tokens_with_payload()` +3. `attest_token.move` — in `attest_token()` +4. `complete_transfer.move` — in `authorize_transfer()` +5. `complete_transfer_with_payload.move` — in `authorize_transfer()` +6. `create_wrapped.move` — in `complete_registration()` and `update_attestation()` + +Each change is a single line addition: +```move +let latest_only = state::assert_latest_only(&token_bridge_state); +state::assert_not_paused(token_bridge_state); // <-- NEW +``` + +### Phase 6: Migration (`migrate.move`) + +Update `migrate__v__0_3_0()` to initialize dynamic fields: + +```move +public(friend) fun migrate__v__0_3_0(state: &mut State) { + // Initialize pause dynamic fields with defaults. + // paused = false, pauser = @0x0, unpauser = @0x0 + state::init_pause_state(state); +} +``` + +This ensures the dynamic fields exist after upgrade, even before any governance +VAA is submitted. The `is_paused()` getter already handles the case where fields +don't exist (returns false), but initializing them during migration is cleaner. + +### Phase 7: Guardian Node Changes (if needed) + +The guardian Go code already serializes action 4 with length-prefixed addresses. +If we use action 4 on SUI, no guardian changes are needed — the same VAA +targeting chain 21 (SUI) will work. + +If the team decides to use a SUI-specific action ID (e.g., 6), we need to: +1. Add `ActionTokenBridgeSetPauserAddressesSui GovernanceAction = 6` in `sdk/vaa/payloads.go` +2. Add a SUI serialization path in `adminserver.go` + +### Phase 8: Run Tests (TDD Green) + +1. `cd sui/token_bridge && sui move test` +2. All Phase 0 tests should pass +3. Existing tests must continue passing (backwards compatibility) + +### Phase 9: Code Review + +Run code-reviewer agent on all changed files. + +--- + +## File Change Summary + +| File | Change Type | Description | +|------|-------------|-------------| +| `sui/token_bridge/sources/state.move` | Modify | Add pause dynamic fields, getters, setters, `assert_not_paused` | +| `sui/token_bridge/sources/version_control.move` | Modify | Add V__0_3_0, update current/previous | +| `sui/token_bridge/sources/governance/set_pauser_addresses.move` | **New** | Governance action handler | +| `sui/token_bridge/sources/pause.move` | **New** | `pause()`/`unpause()` entry functions | +| `sui/token_bridge/sources/migrate.move` | Modify | Add `migrate__v__0_3_0()` | +| `sui/token_bridge/sources/transfer_tokens.move` | Modify | Add `assert_not_paused` (1 line) | +| `sui/token_bridge/sources/transfer_tokens_with_payload.move` | Modify | Add `assert_not_paused` (1 line) | +| `sui/token_bridge/sources/attest_token.move` | Modify | Add `assert_not_paused` (1 line) | +| `sui/token_bridge/sources/complete_transfer.move` | Modify | Add `assert_not_paused` (1 line) | +| `sui/token_bridge/sources/complete_transfer_with_payload.move` | Modify | Add `assert_not_paused` (1 line) | +| `sui/token_bridge/sources/create_wrapped.move` | Modify | Add `assert_not_paused` (2 lines) | + +--- + +## Open Questions for Team + +1. **Action ID**: Use action 4 (whitepaper canonical, length-prefixed) or a SUI-specific + action ID (5 or 6)? This plan assumes action 4. The EVM/SVM PRs have a discrepancy + where the guardian emits length-prefixed action 4 but the contracts parse fixed-layout + with action 4 (EVM) / action 5 (SVM). Need team alignment on the intended approach. + +2. **Events**: Should we emit Move events for `Paused`, `Unpaused`, `PauserAddressesSet`? + SUI supports events via `sui::event::emit`. The EVM PR emits events; the SVM PR doesn't + (Solana doesn't have events in the same sense). Recommendation: yes, emit events. + +3. **Dynamic fields vs struct fields**: This plan uses dynamic fields to avoid breaking + the existing `State` struct. Alternative: modify the struct directly in the migration. + Dynamic fields are safer for upgrades (no storage layout assumptions). + +4. **Backwards compatibility**: The `is_paused()` getter handles missing dynamic fields + gracefully (returns false). Do we also want to handle the case where `set_pauser_addresses` + is called before migration? This plan says no — the governance module requires + `LatestOnly` which requires V__0_3_0, so migration must happen first. diff --git a/sui/token_bridge/Move.toml b/sui/token_bridge/Move.toml index a8c3f69fe4a..6c12255000a 100644 --- a/sui/token_bridge/Move.toml +++ b/sui/token_bridge/Move.toml @@ -1,6 +1,6 @@ [package] name = "token_bridge" -version = "0.2.0" +version = "0.3.0" [dependencies] wormhole = { local = "../wormhole" } diff --git a/sui/token_bridge/sources/attest_token.move b/sui/token_bridge/sources/attest_token.move index c5e67d1670d..fc9e4ad6f63 100644 --- a/sui/token_bridge/sources/attest_token.move +++ b/sui/token_bridge/sources/attest_token.move @@ -37,6 +37,7 @@ module token_bridge::attest_token { ): MessageTicket { // This capability ensures that the current build version is used. let latest_only = state::assert_latest_only(token_bridge_state); + state::assert_not_paused(token_bridge_state); // Encode Wormhole message payload. let encoded_asset_meta = diff --git a/sui/token_bridge/sources/complete_transfer.move b/sui/token_bridge/sources/complete_transfer.move index c9086e7dbc8..db180fd0a41 100644 --- a/sui/token_bridge/sources/complete_transfer.move +++ b/sui/token_bridge/sources/complete_transfer.move @@ -93,6 +93,7 @@ module token_bridge::complete_transfer { ): RelayerReceipt { // This capability ensures that the current build version is used. let latest_only = state::assert_latest_only(token_bridge_state); + state::assert_not_paused(token_bridge_state); // Emitting the transfer being redeemed (and disregard return value). emit_transfer_redeemed(&msg); diff --git a/sui/token_bridge/sources/complete_transfer_with_payload.move b/sui/token_bridge/sources/complete_transfer_with_payload.move index ba35a7a9e81..d4fb7925fff 100644 --- a/sui/token_bridge/sources/complete_transfer_with_payload.move +++ b/sui/token_bridge/sources/complete_transfer_with_payload.move @@ -88,6 +88,7 @@ module token_bridge::complete_transfer_with_payload { ): RedeemerReceipt { // This capability ensures that the current build version is used. let latest_only = state::assert_latest_only(token_bridge_state); + state::assert_not_paused(token_bridge_state); // Emitting the transfer being redeemed. // diff --git a/sui/token_bridge/sources/create_wrapped.move b/sui/token_bridge/sources/create_wrapped.move index f52de72dd7a..79505ec643b 100644 --- a/sui/token_bridge/sources/create_wrapped.move +++ b/sui/token_bridge/sources/create_wrapped.move @@ -46,7 +46,7 @@ module token_bridge::create_wrapped { use token_bridge::wrapped_asset::{Self}; #[test_only] - use token_bridge::version_control::{Self, V__0_2_0 as V__CURRENT}; + use token_bridge::version_control::{Self, V__0_3_0 as V__CURRENT}; /// Failed one-time witness verification. const E_BAD_WITNESS: u64 = 0; @@ -160,6 +160,7 @@ module token_bridge::create_wrapped { // created using the current package. let latest_only = state::assert_latest_only_specified(token_bridge_state); + state::assert_not_paused(token_bridge_state); let WrappedAssetSetup { id, @@ -196,6 +197,7 @@ module token_bridge::create_wrapped { ) { // This capability ensures that the current build version is used. let latest_only = state::assert_latest_only(token_bridge_state); + state::assert_not_paused(token_bridge_state); // Deserialize to `AssetMeta`. let token_meta = asset_meta::deserialize(vaa::take_payload(msg)); @@ -300,7 +302,7 @@ module token_bridge::create_wrapped_tests { }; use token_bridge::token_registry::{Self}; use token_bridge::vaa::{Self}; - use token_bridge::version_control::{V__0_2_0 as V__CURRENT}; + use token_bridge::version_control::{V__0_3_0 as V__CURRENT}; use token_bridge::wrapped_asset::{Self}; struct NOT_A_WITNESS has drop {} diff --git a/sui/token_bridge/sources/governance/set_pauser_addresses.move b/sui/token_bridge/sources/governance/set_pauser_addresses.move new file mode 100644 index 00000000000..ca2e771676e --- /dev/null +++ b/sui/token_bridge/sources/governance/set_pauser_addresses.move @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache 2 + +/// This module implements handling a governance VAA to set the pauser and +/// unpauser addresses on the Token Bridge. These addresses control the +/// emergency pause mechanism. +/// +/// Wire format (action 4, per whitepaper 0003): +/// ``` +/// PauserLen(1) | Pauser(PauserLen) | UnpauserLen(1) | Unpauser(UnpauserLen) +/// ``` +/// +/// Validation: +/// - PauserLen must be 0 (unassigned) or 32 (Sui address size). +/// - UnpauserLen must be 0 (unassigned) or 32 (Sui address size). +/// - An all-zero 32-byte address is treated as unassigned (@0x0). +/// - No trailing bytes allowed (cursor must be fully consumed). +module token_bridge::set_pauser_addresses { + use wormhole::bytes::{Self}; + use wormhole::cursor::{Self}; + use wormhole::governance_message::{Self, DecreeTicket, DecreeReceipt}; + + use token_bridge::state::{Self, State}; + + /// Address length is not 0 or 32. + const E_INVALID_ADDRESS_LENGTH: u64 = 0; + + /// Governance action ID for SetPauserAddresses (canonical, per whitepaper). + const ACTION_SET_PAUSER_ADDRESSES: u8 = 4; + + /// Expected address size for Sui (32 bytes). + const SUI_ADDRESS_SIZE: u8 = 32; + + struct GovernanceWitness has drop {} + + /// Event emitted when pauser addresses are updated via governance. + struct PauserAddressesSet has drop, copy { + pauser: address, + unpauser: address + } + + /// Create `DecreeTicket` for `SetPauserAddresses` governance VAA. + /// Uses `authorize_verify_local` (chain-specific, chain == 21 for Sui). + public fun authorize_governance( + token_bridge_state: &State + ): DecreeTicket { + governance_message::authorize_verify_local( + GovernanceWitness {}, + state::governance_chain(token_bridge_state), + state::governance_contract(token_bridge_state), + state::governance_module(), + ACTION_SET_PAUSER_ADDRESSES + ) + } + + /// Execute the `SetPauserAddresses` governance action. + /// Consumes the `DecreeReceipt`, parses the length-prefixed payload, + /// and updates state. + public fun set_pauser_addresses( + token_bridge_state: &mut State, + receipt: DecreeReceipt + ) { + // This capability ensures that the current build version is used. + let latest_only = state::assert_latest_only(token_bridge_state); + + let payload = + governance_message::take_payload( + state::borrow_mut_consumed_vaas( + &latest_only, + token_bridge_state + ), + receipt + ); + + // Parse the length-prefixed payload. + let cur = cursor::new(payload); + + let pauser = take_address_length_prefixed(&mut cur); + let unpauser = take_address_length_prefixed(&mut cur); + + // No trailing bytes allowed. + cursor::destroy_empty(cur); + + // Update state. + state::set_pauser_address(&latest_only, token_bridge_state, pauser); + state::set_unpauser_address( + &latest_only, + token_bridge_state, + unpauser + ); + + // Emit event. + sui::event::emit(PauserAddressesSet { pauser, unpauser }); + } + + /// Parse a length-prefixed address from the cursor. + /// Length must be 0 (returns @0x0) or SUI_ADDRESS_SIZE (32). + /// An all-zero 32-byte address is also treated as @0x0. + fun take_address_length_prefixed(cur: &mut cursor::Cursor): address { + let len = bytes::take_u8(cur); + if (len == 0) { + return @0x0 + }; + assert!((len as u64) == (SUI_ADDRESS_SIZE as u64), E_INVALID_ADDRESS_LENGTH); + + let addr_bytes = bytes::take_bytes(cur, (SUI_ADDRESS_SIZE as u64)); + + // Convert to address. + sui::address::from_bytes(addr_bytes) + } + + #[test_only] + public fun action(): u8 { + ACTION_SET_PAUSER_ADDRESSES + } + + #[test_only] + /// Directly set pauser addresses for tests, bypassing governance VAA. + public fun set_pauser_addresses_test_only( + token_bridge_state: &mut State, + pauser: address, + unpauser: address + ) { + let latest_only = state::assert_latest_only(token_bridge_state); + state::set_pauser_address(&latest_only, token_bridge_state, pauser); + state::set_unpauser_address( + &latest_only, + token_bridge_state, + unpauser + ); + } +} diff --git a/sui/token_bridge/sources/migrate.move b/sui/token_bridge/sources/migrate.move index e3559de48a4..0e87d3f4d8c 100644 --- a/sui/token_bridge/sources/migrate.move +++ b/sui/token_bridge/sources/migrate.move @@ -25,7 +25,7 @@ module token_bridge::migrate { token_bridge_state: &mut State, receipt: DecreeReceipt ) { - state::migrate__v__0_2_0(token_bridge_state); + state::migrate__v__0_3_0(token_bridge_state); // Perform standard migrate. handle_migrate(token_bridge_state, receipt); diff --git a/sui/token_bridge/sources/pause.move b/sui/token_bridge/sources/pause.move new file mode 100644 index 00000000000..8e03ac9b319 --- /dev/null +++ b/sui/token_bridge/sources/pause.move @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache 2 + +/// This module implements the `pause` and `unpause` entry functions for the +/// Token Bridge emergency pause mechanism. +/// +/// - `pause` can only be called by the configured pauser address. +/// - `unpause` can only be called by the configured unpauser address. +/// - Neither function is guarded by `assert_not_paused` (both must be callable +/// regardless of pause state — `pause` is a no-op when already paused, +/// `unpause` must obviously work when paused). +module token_bridge::pause { + use sui::tx_context::{Self, TxContext}; + + use token_bridge::state::{Self, State}; + + /// The configured pauser is the zero address (unassigned). + const E_PAUSER_NOT_CONFIGURED: u64 = 0; + /// The configured unpauser is the zero address (unassigned). + const E_UNPAUSER_NOT_CONFIGURED: u64 = 1; + /// Caller is not the configured pauser. + const E_NOT_PAUSER: u64 = 2; + /// Caller is not the configured unpauser. + const E_NOT_UNPAUSER: u64 = 3; + + /// Event emitted when the bridge is paused. + struct Paused has drop, copy { + sender: address + } + + /// Event emitted when the bridge is unpaused. + struct Unpaused has drop, copy { + sender: address + } + + /// Pause the token bridge. Only callable by the configured pauser. + /// Aborts if pauser is unassigned (@0x0). + public fun pause( + token_bridge_state: &mut State, + ctx: &TxContext + ) { + // Version check. + let latest_only = state::assert_latest_only(token_bridge_state); + + let configured_pauser = state::pauser(token_bridge_state); + assert!(configured_pauser != @0x0, E_PAUSER_NOT_CONFIGURED); + + let sender = tx_context::sender(ctx); + assert!(sender == configured_pauser, E_NOT_PAUSER); + + state::set_paused(&latest_only, token_bridge_state, true); + + sui::event::emit(Paused { sender }); + } + + /// Unpause the token bridge. Only callable by the configured unpauser. + /// Aborts if unpauser is unassigned (@0x0). + /// NOT guarded by assert_not_paused (must be callable when paused). + public fun unpause( + token_bridge_state: &mut State, + ctx: &TxContext + ) { + // Version check. + let latest_only = state::assert_latest_only(token_bridge_state); + + let configured_unpauser = state::unpauser(token_bridge_state); + assert!(configured_unpauser != @0x0, E_UNPAUSER_NOT_CONFIGURED); + + let sender = tx_context::sender(ctx); + assert!(sender == configured_unpauser, E_NOT_UNPAUSER); + + state::set_paused(&latest_only, token_bridge_state, false); + + sui::event::emit(Unpaused { sender }); + } +} diff --git a/sui/token_bridge/sources/state.move b/sui/token_bridge/sources/state.move index a0c424ae5fe..dac53f269ff 100644 --- a/sui/token_bridge/sources/state.move +++ b/sui/token_bridge/sources/state.move @@ -6,6 +6,7 @@ /// accessing registered assets and verifying `VAA` intended for Token Bridge by /// checking the emitter against its own registered emitters. module token_bridge::state { + use sui::dynamic_field::{Self as field}; use sui::object::{Self, ID, UID}; use sui::package::{UpgradeCap, UpgradeReceipt, UpgradeTicket}; use sui::table::{Self, Table}; @@ -26,13 +27,17 @@ module token_bridge::state { const E_VERSION_MISMATCH: u64 = 1; /// Emitter has already been used to emit Wormhole messages. const E_USED_EMITTER: u64 = 2; + /// The token bridge is paused. + const E_PAUSED: u64 = 3; friend token_bridge::attest_token; friend token_bridge::complete_transfer; friend token_bridge::complete_transfer_with_payload; friend token_bridge::create_wrapped; friend token_bridge::migrate; + friend token_bridge::pause; friend token_bridge::register_chain; + friend token_bridge::set_pauser_addresses; friend token_bridge::setup; friend token_bridge::transfer_tokens; friend token_bridge::transfer_tokens_with_payload; @@ -43,6 +48,13 @@ module token_bridge::state { /// state methods. struct LatestOnly has drop {} + /// Dynamic field key for the `paused` boolean. + struct PausedKey has copy, drop, store {} + /// Dynamic field key for the pauser address. + struct PauserKey has copy, drop, store {} + /// Dynamic field key for the unpauser address. + struct UnpauserKey has copy, drop, store {} + /// Container for all state variables for Token Bridge. struct State has key, store { id: UID, @@ -149,6 +161,35 @@ module token_bridge::state { token_registry::verified_asset(&self.token_registry) } + /// Returns `true` if the token bridge is paused. Returns `false` if + /// the `PausedKey` dynamic field has not been initialized yet (backwards + /// compatible with pre-pause state). + public fun is_paused(self: &State): bool { + if (field::exists_(&self.id, PausedKey {})) { + *field::borrow(&self.id, PausedKey {}) + } else { + false + } + } + + /// Returns the configured pauser address. Returns `@0x0` if unset. + public fun pauser(self: &State): address { + if (field::exists_(&self.id, PauserKey {})) { + *field::borrow(&self.id, PauserKey {}) + } else { + @0x0 + } + } + + /// Returns the configured unpauser address. Returns `@0x0` if unset. + public fun unpauser(self: &State): address { + if (field::exists_(&self.id, UnpauserKey {})) { + *field::borrow(&self.id, UnpauserKey {}) + } else { + @0x0 + } + } + #[test_only] public fun borrow_mut_token_registry_test_only( self: &mut State @@ -290,6 +331,65 @@ module token_bridge::state { package_utils::current_package(&self.id) } + //////////////////////////////////////////////////////////////////////////// + // + // Pause + // + // Methods to manage the pause state. Setters require `LatestOnly` to + // ensure only the current build can modify pause state. + // + //////////////////////////////////////////////////////////////////////////// + + /// Abort if the token bridge is paused. + public(friend) fun assert_not_paused(self: &State) { + assert!(!is_paused(self), E_PAUSED); + } + + /// Set the paused flag. Requires `LatestOnly`. + public(friend) fun set_paused( + _: &LatestOnly, + self: &mut State, + paused: bool + ) { + if (field::exists_(&self.id, PausedKey {})) { + *field::borrow_mut(&mut self.id, PausedKey {}) = paused; + } else { + field::add(&mut self.id, PausedKey {}, paused); + } + } + + /// Set the pauser address. Requires `LatestOnly`. + public(friend) fun set_pauser_address( + _: &LatestOnly, + self: &mut State, + new_pauser: address + ) { + if (field::exists_(&self.id, PauserKey {})) { + *field::borrow_mut( + &mut self.id, + PauserKey {} + ) = new_pauser; + } else { + field::add(&mut self.id, PauserKey {}, new_pauser); + } + } + + /// Set the unpauser address. Requires `LatestOnly`. + public(friend) fun set_unpauser_address( + _: &LatestOnly, + self: &mut State, + new_unpauser: address + ) { + if (field::exists_(&self.id, UnpauserKey {})) { + *field::borrow_mut( + &mut self.id, + UnpauserKey {} + ) = new_unpauser; + } else { + field::add(&mut self.id, UnpauserKey {}, new_unpauser); + } + } + //////////////////////////////////////////////////////////////////////////// // // Upgradability @@ -371,8 +471,16 @@ module token_bridge::state { /// /// NOTE: Please keep this method as public(friend) because we never want /// to expose this method as a public method. - public(friend) fun migrate__v__0_2_0(_self: &mut State) { - // Intentionally do nothing. + public(friend) fun migrate__v__0_3_0(self: &mut State) { + // Initialize pause dynamic fields with defaults. + // paused = false, pauser = @0x0, unpauser = @0x0 + // Guarded with exists_ check for resilience against partial migration + // retry scenarios. + if (!field::exists_(&self.id, PausedKey {})) { + field::add(&mut self.id, PausedKey {}, false); + field::add(&mut self.id, PauserKey {}, @0x0); + field::add(&mut self.id, UnpauserKey {}, @0x0); + }; } #[test_only] @@ -385,6 +493,30 @@ module token_bridge::state { // Intentionally do nothing. } + #[test_only] + /// Initialize pause state for tests. Call this in test setup so that + /// pause-related getters/setters work without running migrate. + public fun init_pause_state_test_only(self: &mut State) { + if (!field::exists_(&self.id, PausedKey {})) { + field::add(&mut self.id, PausedKey {}, false); + field::add(&mut self.id, PauserKey {}, @0x0); + field::add(&mut self.id, UnpauserKey {}, @0x0); + } + } + + #[test_only] + /// Set the paused flag directly in tests (bypasses LatestOnly). + public fun set_paused_test_only(self: &mut State, paused: bool) { + let latest_only = assert_latest_only(self); + set_paused(&latest_only, self, paused); + } + + #[test_only] + /// Call assert_not_paused from test context. + public fun assert_not_paused_test_only(self: &State) { + assert_not_paused(self); + } + //////////////////////////////////////////////////////////////////////////// // // Deprecated @@ -393,4 +525,8 @@ module token_bridge::state { // be used in future builds. // //////////////////////////////////////////////////////////////////////////// + + public(friend) fun migrate__v__0_2_0(_self: &mut State) { + // Intentionally do nothing. + } } diff --git a/sui/token_bridge/sources/test/coin_wrapped_12.move b/sui/token_bridge/sources/test/coin_wrapped_12.move index 74932765ef7..b9442db2b6a 100644 --- a/sui/token_bridge/sources/test/coin_wrapped_12.move +++ b/sui/token_bridge/sources/test/coin_wrapped_12.move @@ -15,7 +15,7 @@ module token_bridge::coin_wrapped_12 { use token_bridge::token_registry::{Self}; use token_bridge::wrapped_asset::{Self}; - use token_bridge::version_control::{V__0_2_0 as V__CURRENT}; + use token_bridge::version_control::{V__0_3_0 as V__CURRENT}; struct COIN_WRAPPED_12 has drop {} diff --git a/sui/token_bridge/sources/test/coin_wrapped_7.move b/sui/token_bridge/sources/test/coin_wrapped_7.move index fa09e1b4445..69af9336197 100644 --- a/sui/token_bridge/sources/test/coin_wrapped_7.move +++ b/sui/token_bridge/sources/test/coin_wrapped_7.move @@ -15,7 +15,7 @@ module token_bridge::coin_wrapped_7 { use token_bridge::token_registry::{Self}; use token_bridge::wrapped_asset::{Self}; - use token_bridge::version_control::{V__0_2_0 as V__CURRENT}; + use token_bridge::version_control::{V__0_3_0 as V__CURRENT}; struct COIN_WRAPPED_7 has drop {} diff --git a/sui/token_bridge/sources/test/pause_tests.move b/sui/token_bridge/sources/test/pause_tests.move new file mode 100644 index 00000000000..36e7a6546d8 --- /dev/null +++ b/sui/token_bridge/sources/test/pause_tests.move @@ -0,0 +1,511 @@ +// SPDX-License-Identifier: Apache 2 + +#[test_only] +module token_bridge::pause_tests { + use sui::test_scenario::{Self}; + + use token_bridge::pause::{Self}; + use token_bridge::set_pauser_addresses::{Self}; + use token_bridge::state::{Self}; + use token_bridge::token_bridge_scenario::{ + person, + return_state, + set_up_wormhole_and_token_bridge, + take_state, + three_people + }; + + // ======================================================================== + // Pause State Initialization + // ======================================================================== + + #[test] + fun test_default_pause_state() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + + // Initialize pause state (simulating migration). + state::init_pause_state_test_only(&mut token_bridge_state); + + // Default state: not paused, pauser/unpauser = @0x0. + assert!(!state::is_paused(&token_bridge_state), 0); + assert!(state::pauser(&token_bridge_state) == @0x0, 0); + assert!(state::unpauser(&token_bridge_state) == @0x0, 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + #[test] + fun test_is_paused_returns_false_before_init() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + + // Before pause state init, is_paused returns false (backwards compat). + assert!(!state::is_paused(&token_bridge_state), 0); + assert!(state::pauser(&token_bridge_state) == @0x0, 0); + assert!(state::unpauser(&token_bridge_state) == @0x0, 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // Set Pauser Addresses (via test helper) + // ======================================================================== + + #[test] + fun test_set_pauser_addresses() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + // Set pauser and unpauser. + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + assert!(state::pauser(&token_bridge_state) == pauser_addr, 0); + assert!(state::unpauser(&token_bridge_state) == unpauser_addr, 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + #[test] + fun test_rotate_pauser_addresses() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + // Set initial addresses. + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + // Rotate to new addresses (swap them). + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + unpauser_addr, + pauser_addr + ); + + assert!(state::pauser(&token_bridge_state) == unpauser_addr, 0); + assert!(state::unpauser(&token_bridge_state) == pauser_addr, 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + #[test] + fun test_set_pauser_to_zero_unassigns() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + // Set addresses. + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + // Unassign by setting to @0x0. + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + @0x0, + @0x0 + ); + + assert!(state::pauser(&token_bridge_state) == @0x0, 0); + assert!(state::unpauser(&token_bridge_state) == @0x0, 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // Pause / Unpause + // ======================================================================== + + #[test] + fun test_pauser_can_pause() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + return_state(token_bridge_state); + + // Pause as the configured pauser. + test_scenario::next_tx(scenario, pauser_addr); + let token_bridge_state = take_state(scenario); + + pause::pause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + + assert!(state::is_paused(&token_bridge_state), 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + #[test] + fun test_unpauser_can_unpause() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + // Set paused directly for this test. + state::set_paused_test_only(&mut token_bridge_state, true); + assert!(state::is_paused(&token_bridge_state), 0); + + return_state(token_bridge_state); + + // Unpause as the configured unpauser. + test_scenario::next_tx(scenario, unpauser_addr); + let token_bridge_state = take_state(scenario); + + pause::unpause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + + assert!(!state::is_paused(&token_bridge_state), 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + #[test] + fun test_pause_is_idempotent() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + return_state(token_bridge_state); + + // Pause twice — should not revert. + test_scenario::next_tx(scenario, pauser_addr); + let token_bridge_state = take_state(scenario); + + pause::pause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + assert!(state::is_paused(&token_bridge_state), 0); + + return_state(token_bridge_state); + + test_scenario::next_tx(scenario, pauser_addr); + let token_bridge_state = take_state(scenario); + + pause::pause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + assert!(state::is_paused(&token_bridge_state), 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + #[test] + fun test_unpause_is_idempotent() { + let (caller, _pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + caller, + unpauser_addr + ); + + return_state(token_bridge_state); + + // Unpause twice — should not revert (already unpaused). + test_scenario::next_tx(scenario, unpauser_addr); + let token_bridge_state = take_state(scenario); + + pause::unpause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + assert!(!state::is_paused(&token_bridge_state), 0); + + return_state(token_bridge_state); + + test_scenario::next_tx(scenario, unpauser_addr); + let token_bridge_state = take_state(scenario); + + pause::unpause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + assert!(!state::is_paused(&token_bridge_state), 0); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // Access Control — Negative Tests + // ======================================================================== + + #[test] + #[expected_failure(abort_code = pause::E_NOT_PAUSER)] + fun test_non_pauser_cannot_pause() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + return_state(token_bridge_state); + + // Try to pause as a random (non-pauser) address. + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + + // You shall not pass! + pause::pause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_NOT_UNPAUSER)] + fun test_non_unpauser_cannot_unpause() { + let (caller, pauser_addr, unpauser_addr) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_addr, + unpauser_addr + ); + + state::set_paused_test_only(&mut token_bridge_state, true); + + return_state(token_bridge_state); + + // Try to unpause as a random (non-unpauser) address. + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + + // You shall not pass! + pause::unpause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_PAUSER_NOT_CONFIGURED)] + fun test_cannot_pause_when_pauser_unassigned() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + // Pauser is @0x0 (default / unassigned). + + // You shall not pass! + pause::pause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_UNPAUSER_NOT_CONFIGURED)] + fun test_cannot_unpause_when_unpauser_unassigned() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + state::set_paused_test_only(&mut token_bridge_state, true); + + // Unpauser is @0x0 (default / unassigned). + + // You shall not pass! + pause::unpause( + &mut token_bridge_state, + test_scenario::ctx(scenario) + ); + + abort 42 + } + + // ======================================================================== + // assert_not_paused Guard + // ======================================================================== + + #[test] + #[expected_failure(abort_code = state::E_PAUSED)] + fun test_assert_not_paused_reverts_when_paused() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + state::set_paused_test_only(&mut token_bridge_state, true); + + // You shall not pass! + state::assert_not_paused_test_only(&token_bridge_state); + + abort 42 + } + + #[test] + fun test_assert_not_paused_passes_when_not_paused() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + // Should not revert. + state::assert_not_paused_test_only(&token_bridge_state); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } +} diff --git a/sui/token_bridge/sources/transfer_tokens.move b/sui/token_bridge/sources/transfer_tokens.move index fd3ec9a5b23..58dae35a9fb 100644 --- a/sui/token_bridge/sources/transfer_tokens.move +++ b/sui/token_bridge/sources/transfer_tokens.move @@ -135,6 +135,7 @@ module token_bridge::transfer_tokens { ): MessageTicket { // This capability ensures that the current build version is used. let latest_only = state::assert_latest_only(token_bridge_state); + state::assert_not_paused(token_bridge_state); let ( nonce, diff --git a/sui/token_bridge/sources/transfer_tokens_with_payload.move b/sui/token_bridge/sources/transfer_tokens_with_payload.move index 0ed204bfb7d..64ecd9787de 100644 --- a/sui/token_bridge/sources/transfer_tokens_with_payload.move +++ b/sui/token_bridge/sources/transfer_tokens_with_payload.move @@ -136,6 +136,7 @@ module token_bridge::transfer_tokens_with_payload { ): MessageTicket { // This capability ensures that the current build version is used. let latest_only = state::assert_latest_only(token_bridge_state); + state::assert_not_paused(token_bridge_state); // Encode Wormhole message payload. let ( diff --git a/sui/token_bridge/sources/version_control.move b/sui/token_bridge/sources/version_control.move index caee75794c2..1ccb42659d0 100644 --- a/sui/token_bridge/sources/version_control.move +++ b/sui/token_bridge/sources/version_control.move @@ -16,21 +16,21 @@ module token_bridge::version_control { // //////////////////////////////////////////////////////////////////////////// - public(friend) fun current_version(): V__0_2_0 { - V__0_2_0 {} + public(friend) fun current_version(): V__0_3_0 { + V__0_3_0 {} } #[test_only] - public fun current_version_test_only(): V__0_2_0 { + public fun current_version_test_only(): V__0_3_0 { current_version() } - public(friend) fun previous_version(): V__DUMMY { - V__DUMMY {} + public(friend) fun previous_version(): V__0_2_0 { + V__0_2_0 {} } #[test_only] - public fun previous_version_test_only(): V__DUMMY { + public fun previous_version_test_only(): V__0_2_0 { previous_version() } @@ -46,6 +46,11 @@ module token_bridge::version_control { /// First published package on Sui mainnet. struct V__0_2_0 has store, drop, copy {} + /// Added emergency pause support (SetPauserAddresses governance action, + /// pause/unpause entry functions, notPaused guards on user-facing + /// entry points). + struct V__0_3_0 has store, drop, copy {} + // Dummy. struct V__DUMMY has store, drop, copy {} From d71d8b66e529765d2283a67d75a42def32669bda Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 28 May 2026 19:00:39 +0200 Subject: [PATCH 02/14] fix: coin example versioning --- .claude/tasks/sui-wtt-pauser.md | 380 --------------------------- sui/examples/coins/sources/coin.move | 4 +- 2 files changed, 2 insertions(+), 382 deletions(-) delete mode 100644 .claude/tasks/sui-wtt-pauser.md diff --git a/.claude/tasks/sui-wtt-pauser.md b/.claude/tasks/sui-wtt-pauser.md deleted file mode 100644 index f19a3b7acd1..00000000000 --- a/.claude/tasks/sui-wtt-pauser.md +++ /dev/null @@ -1,380 +0,0 @@ -# SUI Token Bridge Pauser — Implementation Plan - -## Context - -The Wormhole Token Bridge is adding emergency pause support across all chains. -PRs already exist for: - -- **Design** — [#4809](https://github.com/wormhole-foundation/wormhole/pull/4809) (whitepaper 0003 update) -- **Guardian** — [#4810](https://github.com/wormhole-foundation/wormhole/pull/4810) (node: VAA generation for `SetPauserAddresses`) -- **EVM** — [#4801](https://github.com/wormhole-foundation/wormhole/pull/4801) (action 4, fixed 20-byte addresses) -- **SVM** — [#4802](https://github.com/wormhole-foundation/wormhole/pull/4802) (action 5, fixed 32-byte pubkeys) - -This plan covers the **SUI** implementation. - ---- - -## Design Summary - -Two roles — **pauser** and **unpauser** — are configured via a `SetPauserAddresses` governance VAA -(module `"TokenBridge"`, new action ID). When `paused == true`, all user-facing entry points revert. -Governance handlers + `pause()`/`unpause()` remain callable. - -### Wire Format (from whitepaper) - -``` -Module(32) "TokenBridge" left-padded -Action(1) new action ID for SUI (see "Action ID" section below) -ChainID(2) 21 (SUI) -Payload SUI-specific: pauser(32) + unpauser(32) -``` - -### Action ID Decision - -The guardian Go code uses a **single action 4** with length-prefixed addresses on the wire. -However, each runtime interprets a fixed layout: - -| Runtime | Action | Address Format | -|---------|--------|----------------| -| EVM | 4 | Fixed 20 bytes (no length prefix) | -| SVM | 5 | Fixed 32 bytes (no length prefix) | -| **SUI** | **?** | Fixed 32 bytes (Sui addresses are 32 bytes) | - -**Options:** - -1. **Reuse action 5** — SUI addresses are 32 bytes like SVM, same wire layout. - The SVM contract already uses action 5 with `MODULE = "TokenBridge"`. - But SUI governance uses `authorize_verify_local()` which checks `chain == 21`, - so SVM (chain=1) and SUI (chain=21) VAAs cannot collide even with the same action ID. - **This is safe and mirrors how register_chain (action 1) and upgrade_contract (action 2) - are shared across all chains with chain-ID differentiation.** - -2. **Use action 6** — New SUI-specific action. Cleanest separation but burns an action ID - for no added security (chain-ID already prevents cross-chain replay). - -**Recommendation: Use action 4 (the canonical wire action).** The guardian serializes action 4 -with length-prefixed addresses. SUI should parse the length-prefixed format directly, matching -the whitepaper spec. This is what the EVM/SVM PRs *should* be doing too (there's a discrepancy -in those PRs where they parse fixed-layout but the guardian emits length-prefixed). Confirm with -the team which approach wins. If the team prefers per-runtime action IDs, use **action 6**. - -**For this plan, we will use action 4 with length-prefixed parsing**, since that matches -the whitepaper and the guardian serialization. If the team decides otherwise, the only change -is the action constant and the deserialization logic. - ---- - -## Architecture - -### Approach: Modify Token Bridge State Directly (Not a Companion Package) - -Unlike NTT SUI governance (which uses a separate companion package because NTT is parameterized -by `CoinType` and SUI lacks dynamic dispatch), the Token Bridge has a **single shared `State` -object**. We can add pause fields directly to `State` using **dynamic fields** — same pattern -the Token Bridge already uses for version info. - -This approach: -- Avoids deploying a separate governance package -- Keeps the pause check inside the existing version-controlled module -- Is delivered as a contract **upgrade** (new version V__0_3_0) -- Uses the existing `DecreeTicket`/`DecreeReceipt` pattern that `register_chain` and - `upgrade_contract` already use - -### State Changes (Dynamic Fields) - -We store pause state as dynamic fields on `State.id` to avoid breaking the existing -struct layout: - -```move -struct PauserKey has copy, drop, store {} // → address (32 bytes), 0x0 = unassigned -struct UnpauserKey has copy, drop, store {} // → address (32 bytes), 0x0 = unassigned -struct PausedKey has copy, drop, store {} // → bool -``` - -On first `SetPauserAddresses` governance VAA (or during `migrate`), these fields are -initialized. Before they exist, `is_paused()` returns `false` (backwards compatible). - -### Entry Points Requiring `notPaused` Guard - -All version-controlled user-facing functions: - -| Module | Function | Direction | -|--------|----------|-----------| -| `transfer_tokens` | `transfer_tokens()` | Outbound | -| `transfer_tokens_with_payload` | `transfer_tokens_with_payload()` | Outbound | -| `attest_token` | `attest_token()` | Outbound | -| `complete_transfer` | `authorize_transfer()` | Inbound | -| `complete_transfer_with_payload` | `authorize_transfer()` | Inbound | -| `create_wrapped` | `complete_registration()` | Asset mgmt | -| `create_wrapped` | `update_attestation()` | Asset mgmt | - -### Entry Points Exempt from Pause - -| Module | Function | Reason | -|--------|----------|--------| -| `register_chain` | `register_chain()` | Governance — must remain callable | -| `upgrade_contract` | `authorize_upgrade()` | Governance — must remain callable | -| `upgrade_contract` | `commit_upgrade()` | Governance — must remain callable | -| `migrate` | `migrate()` | Upgrade — must remain callable | -| `set_pauser_addresses` | (new) | Governance — must remain callable | -| `pause` | (new entry function) | Must be callable to trigger pause | -| `unpause` | (new entry function) | Must be callable when paused | - ---- - -## Implementation Steps - -### Phase 0: Tests First (TDD Red) - -Write tests before implementation. Test files go in `sui/token_bridge/sources/test/` or alongside -governance modules. - -**Test cases for `set_pauser_addresses`:** -1. Success — sets pauser and unpauser via governance VAA -2. Rejects wrong module -3. Rejects wrong chain (not SUI) -4. Rejects replayed VAA (consumed) -5. Can rotate — second VAA overwrites previous addresses -6. Zero address sets role as unassigned - -**Test cases for `pause`/`unpause`:** -7. Pauser can pause — `is_paused()` becomes true -8. Non-pauser cannot pause — aborts -9. Unpauser can unpause — `is_paused()` becomes false -10. Non-unpauser cannot unpause — aborts -11. Pause when pauser is unassigned (zero addr) — aborts -12. Unpause when unpauser is unassigned — aborts -13. Pause is idempotent (calling pause when already paused is OK) -14. Unpause is idempotent - -**Test cases for `notPaused` guard:** -15. `transfer_tokens` reverts when paused -16. `complete_transfer::authorize_transfer` reverts when paused -17. `attest_token` reverts when paused -18. `create_wrapped::complete_registration` reverts when paused -19. Governance handlers still work when paused (register_chain, upgrade, set_pauser_addresses) -20. Legacy state (no dynamic fields yet) — all operations work as before (unpaused by default) - -### Phase 1: State Module Changes (`state.move`) - -1. Add dynamic field key structs: - ```move - struct PausedKey has copy, drop, store {} - struct PauserKey has copy, drop, store {} - struct UnpauserKey has copy, drop, store {} - ``` - -2. Add public getter functions: - ```move - public fun is_paused(self: &State): bool - public fun pauser(self: &State): address - public fun unpauser(self: &State): address - ``` - - `is_paused()` returns `false` if `PausedKey` dynamic field doesn't exist (backwards compat) - - `pauser()`/`unpauser()` return `@0x0` if field doesn't exist - -3. Add friend-only mutation functions: - ```move - public(friend) fun set_paused(_: &LatestOnly, self: &mut State, paused: bool) - public(friend) fun set_pauser_address(_: &LatestOnly, self: &mut State, pauser: address) - public(friend) fun set_unpauser_address(_: &LatestOnly, self: &mut State, unpauser: address) - ``` - -4. Add `assert_not_paused` helper: - ```move - public(friend) fun assert_not_paused(self: &State) - ``` - Aborts with `E_PAUSED` if `is_paused()` returns true. - -5. Add new friends: - ```move - friend token_bridge::set_pauser_addresses; - friend token_bridge::pause; - ``` - -### Phase 2: Version Control (`version_control.move`) - -1. Add new version `V__0_3_0`: - ```move - struct V__0_3_0 has store, drop, copy {} - ``` - -2. Update `current_version()` to return `V__0_3_0`. -3. Set `previous_version()` to `V__0_2_0`. - -### Phase 3: Governance — `set_pauser_addresses.move` (New File) - -New module: `token_bridge::set_pauser_addresses` - -``` -sui/token_bridge/sources/governance/set_pauser_addresses.move -``` - -**Constants:** -```move -const ACTION_SET_PAUSER_ADDRESSES: u8 = 4; -``` - -**Structs:** -```move -struct GovernanceWitness has drop {} -``` - -**Functions:** - -```move -/// Create DecreeTicket for SetPauserAddresses governance VAA. -/// Uses authorize_verify_local (chain-specific, chain == 21). -public fun authorize_governance( - token_bridge_state: &State -): DecreeTicket - -/// Execute the SetPauserAddresses governance action. -/// Consumes the DecreeReceipt, parses the payload, and updates state. -public fun set_pauser_addresses( - token_bridge_state: &mut State, - receipt: DecreeReceipt -) -``` - -**Payload Parsing (action 4, length-prefixed per whitepaper):** -``` -PauserLen(1) | Pauser(PauserLen) | UnpauserLen(1) | Unpauser(UnpauserLen) -``` - -Validation: -- If PauserLen > 0: must be exactly 32 bytes (SUI address size), else abort -- If PauserLen == 0: set pauser to `@0x0` (unassigned) -- Same for UnpauserLen/Unpauser -- All-zero 32-byte address treated same as unassigned (set to `@0x0`) -- No trailing bytes allowed (cursor must be fully consumed) - -### Phase 4: Pause/Unpause Entry Functions (New File) - -New module: `token_bridge::pause` - -``` -sui/token_bridge/sources/pause.move -``` - -**Entry functions:** - -```move -/// Pause the token bridge. Only callable by the configured pauser. -/// Aborts if pauser is unassigned (@0x0). -public entry fun pause( - token_bridge_state: &mut State, - ctx: &TxContext -) - -/// Unpause the token bridge. Only callable by the configured unpauser. -/// Aborts if unpauser is unassigned (@0x0). -/// NOT guarded by assert_not_paused (must be callable when paused). -public entry fun unpause( - token_bridge_state: &mut State, - ctx: &TxContext -) -``` - -**Logic:** -1. `assert_latest_only(state)` — version check -2. Read configured pauser/unpauser from state -3. Assert configured address != `@0x0` (`E_PAUSER_NOT_CONFIGURED`) -4. Assert `tx_context::sender(ctx) == configured_address` (`E_NOT_PAUSER` / `E_NOT_UNPAUSER`) -5. Call `state::set_paused(latest_only, state, true/false)` - -### Phase 5: Add Pause Guards to Existing Modules - -In each guarded module, add `state::assert_not_paused(&state)` right after -`state::assert_latest_only(&state)`. This ensures the pause check happens at the -same point as the version check — early, before any state mutation. - -Files to modify: -1. `transfer_tokens.move` — in `transfer_tokens()` -2. `transfer_tokens_with_payload.move` — in `transfer_tokens_with_payload()` -3. `attest_token.move` — in `attest_token()` -4. `complete_transfer.move` — in `authorize_transfer()` -5. `complete_transfer_with_payload.move` — in `authorize_transfer()` -6. `create_wrapped.move` — in `complete_registration()` and `update_attestation()` - -Each change is a single line addition: -```move -let latest_only = state::assert_latest_only(&token_bridge_state); -state::assert_not_paused(token_bridge_state); // <-- NEW -``` - -### Phase 6: Migration (`migrate.move`) - -Update `migrate__v__0_3_0()` to initialize dynamic fields: - -```move -public(friend) fun migrate__v__0_3_0(state: &mut State) { - // Initialize pause dynamic fields with defaults. - // paused = false, pauser = @0x0, unpauser = @0x0 - state::init_pause_state(state); -} -``` - -This ensures the dynamic fields exist after upgrade, even before any governance -VAA is submitted. The `is_paused()` getter already handles the case where fields -don't exist (returns false), but initializing them during migration is cleaner. - -### Phase 7: Guardian Node Changes (if needed) - -The guardian Go code already serializes action 4 with length-prefixed addresses. -If we use action 4 on SUI, no guardian changes are needed — the same VAA -targeting chain 21 (SUI) will work. - -If the team decides to use a SUI-specific action ID (e.g., 6), we need to: -1. Add `ActionTokenBridgeSetPauserAddressesSui GovernanceAction = 6` in `sdk/vaa/payloads.go` -2. Add a SUI serialization path in `adminserver.go` - -### Phase 8: Run Tests (TDD Green) - -1. `cd sui/token_bridge && sui move test` -2. All Phase 0 tests should pass -3. Existing tests must continue passing (backwards compatibility) - -### Phase 9: Code Review - -Run code-reviewer agent on all changed files. - ---- - -## File Change Summary - -| File | Change Type | Description | -|------|-------------|-------------| -| `sui/token_bridge/sources/state.move` | Modify | Add pause dynamic fields, getters, setters, `assert_not_paused` | -| `sui/token_bridge/sources/version_control.move` | Modify | Add V__0_3_0, update current/previous | -| `sui/token_bridge/sources/governance/set_pauser_addresses.move` | **New** | Governance action handler | -| `sui/token_bridge/sources/pause.move` | **New** | `pause()`/`unpause()` entry functions | -| `sui/token_bridge/sources/migrate.move` | Modify | Add `migrate__v__0_3_0()` | -| `sui/token_bridge/sources/transfer_tokens.move` | Modify | Add `assert_not_paused` (1 line) | -| `sui/token_bridge/sources/transfer_tokens_with_payload.move` | Modify | Add `assert_not_paused` (1 line) | -| `sui/token_bridge/sources/attest_token.move` | Modify | Add `assert_not_paused` (1 line) | -| `sui/token_bridge/sources/complete_transfer.move` | Modify | Add `assert_not_paused` (1 line) | -| `sui/token_bridge/sources/complete_transfer_with_payload.move` | Modify | Add `assert_not_paused` (1 line) | -| `sui/token_bridge/sources/create_wrapped.move` | Modify | Add `assert_not_paused` (2 lines) | - ---- - -## Open Questions for Team - -1. **Action ID**: Use action 4 (whitepaper canonical, length-prefixed) or a SUI-specific - action ID (5 or 6)? This plan assumes action 4. The EVM/SVM PRs have a discrepancy - where the guardian emits length-prefixed action 4 but the contracts parse fixed-layout - with action 4 (EVM) / action 5 (SVM). Need team alignment on the intended approach. - -2. **Events**: Should we emit Move events for `Paused`, `Unpaused`, `PauserAddressesSet`? - SUI supports events via `sui::event::emit`. The EVM PR emits events; the SVM PR doesn't - (Solana doesn't have events in the same sense). Recommendation: yes, emit events. - -3. **Dynamic fields vs struct fields**: This plan uses dynamic fields to avoid breaking - the existing `State` struct. Alternative: modify the struct directly in the migration. - Dynamic fields are safer for upgrades (no storage layout assumptions). - -4. **Backwards compatibility**: The `is_paused()` getter handles missing dynamic fields - gracefully (returns false). Do we also want to handle the case where `set_pauser_addresses` - is called before migration? This plan says no — the governance module requires - `LatestOnly` which requires V__0_3_0, so migration must happen first. diff --git a/sui/examples/coins/sources/coin.move b/sui/examples/coins/sources/coin.move index 108ebe52d69..6bd950ae642 100644 --- a/sui/examples/coins/sources/coin.move +++ b/sui/examples/coins/sources/coin.move @@ -12,7 +12,7 @@ module coins::coin { struct COIN has drop {} fun init(witness: COIN, ctx: &mut TxContext) { - use token_bridge::version_control::{V__0_2_0 as V__CURRENT}; + use token_bridge::version_control::{V__0_3_0 as V__CURRENT}; transfer::public_transfer( create_wrapped::prepare_registration( @@ -62,7 +62,7 @@ module coins::coin_tests { use wormhole::external_address::{Self}; use wormhole::wormhole_scenario::{parse_and_verify_vaa}; - use token_bridge::version_control::{V__0_2_0 as V__CURRENT}; + use token_bridge::version_control::{V__0_3_0 as V__CURRENT}; use coins::coin::{COIN}; From 34092eb8f0af1a46f86aaa4600062ae8decf1fec Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Fri, 29 May 2026 16:09:23 +0200 Subject: [PATCH 03/14] fix: sui coin bytecode version V__0_2_0 to V__0_3_0 --- sdk/js/src/sui/publish.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/js/src/sui/publish.ts b/sdk/js/src/sui/publish.ts index 07b2bbb2f69..2a50ce9d12e 100644 --- a/sdk/js/src/sui/publish.ts +++ b/sdk/js/src/sui/publish.ts @@ -55,7 +55,7 @@ export const getCoinBuildOutput = async ( } const bytecodeHex = - "a11ceb0b060000000901000a020a14031e1704350405392d07669f01088502600ae502050cea02160004010b010c0205020d000002000201020003030c020001000104020700000700010001090801010c020a050600030803040202000302010702080007080100020800080303090002070801010b020209000901010608010105010b0202080008030209000504434f494e095478436f6e7465787408565f5f305f325f3011577261707065644173736574536574757004636f696e0e6372656174655f777261707065640b64756d6d795f6669656c6404696e697414707265706172655f726567697374726174696f6e0f7075626c69635f7472616e736665720673656e646572087472616e736665720a74785f636f6e746578740f76657273696f6e5f636f6e74726f6c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002" + + "a11ceb0b060000000901000a020a14031e1704350405392d07669f01088502600ae502050cea02160004010b010c0205020d000002000201020003030c020001000104020700000700010001090801010c020a050600030803040202000302010702080007080100020800080303090002070801010b020209000901010608010105010b0202080008030209000504434f494e095478436f6e7465787408565f5f305f335f3011577261707065644173736574536574757004636f696e0e6372656174655f777261707065640b64756d6d795f6669656c6404696e697414707265706172655f726567697374726174696f6e0f7075626c69635f7472616e736665720673656e646572087472616e736665720a74785f636f6e746578740f76657273696f6e5f636f6e74726f6c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002" + strippedTokenBridgePackageId + "00020106010000000001090b0031" + decimals.toString(16).padStart(2, "0") + From ebab1a5c7d75c129d713b16b82c85230edd49785 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Fri, 29 May 2026 17:24:35 +0200 Subject: [PATCH 04/14] fix: update sui devnet token bridge state object id --- sdk/js/src/utils/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/js/src/utils/consts.ts b/sdk/js/src/utils/consts.ts index a12a7f5bfed..690ebabd7d8 100644 --- a/sdk/js/src/utils/consts.ts +++ b/sdk/js/src/utils/consts.ts @@ -831,7 +831,7 @@ const DEVNET = { sui: { core: "0xea31c369d1f873d87d37f313ec37f1ee20a0b8136f06e3d3521330ee467312a4", // wormhole module State object ID token_bridge: - "0xbab50719a0e35350677b75bddc25f20bdc7a8c4788c7a0746b9796956380a2d2", // token_bridge module State object ID + "0xfa434e835120d31598abceee7208e5236aead674aa4e216b5ca761454a7351a7", // token_bridge module State object ID nft_bridge: undefined, }, moonbeam: { From 9b0780d3800fa29a7ce2acfd147eb64026a8b316 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Fri, 29 May 2026 17:57:25 +0200 Subject: [PATCH 05/14] fix: update sui devnet token bridge emitter address --- scripts/devnet-consts.json | 2 +- sdk/devnet_consts.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/devnet-consts.json b/scripts/devnet-consts.json index 324f7c33130..8bba6dada73 100644 --- a/scripts/devnet-consts.json +++ b/scripts/devnet-consts.json @@ -150,7 +150,7 @@ }, "21": { "contracts": { - "tokenBridgeEmitterAddress": "50d9394da9f8812b21764b2206fcc2da0ffac108738132a091feec93c8164ec5" + "tokenBridgeEmitterAddress": "7b19543ac266fd13aa5f37421251fdbd2076a266785cf2c21d88199bee1c7d45" } }, "22": { diff --git a/sdk/devnet_consts.go b/sdk/devnet_consts.go index cc62d14adf2..36e6bf1da29 100644 --- a/sdk/devnet_consts.go +++ b/sdk/devnet_consts.go @@ -15,7 +15,7 @@ var knownDevnetTokenbridgeEmitters = map[vaa.ChainID]string{ vaa.ChainIDBSC: "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16", vaa.ChainIDAlgorand: "8ec299cb7f3efec28f542397e07f07118d74c875f85409ed8e6b93c17b60e992", vaa.ChainIDWormchain: "c9138c6e5bd7a2ab79c1a87486c9d7349d064b35ac9f7498f3b207b3a61e6013", - vaa.ChainIDSui: "50d9394da9f8812b21764b2206fcc2da0ffac108738132a091feec93c8164ec5", + vaa.ChainIDSui: "7b19543ac266fd13aa5f37421251fdbd2076a266785cf2c21d88199bee1c7d45", } // KnownDevnetNFTBridgeEmitters is a map of known NFT emitters used during development. From f26b38e66cfbfc072e86303f65df102716663a50 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 1 Jun 2026 14:07:20 +0200 Subject: [PATCH 06/14] refactor: pauser as Pauser/Unpauser Cap --- .../governance/set_pauser_addresses.move | 146 +++++-- sui/token_bridge/sources/pause.move | 159 +++++-- sui/token_bridge/sources/state.move | 28 +- .../sources/test/pause_tests.move | 412 +++++++++++------- 4 files changed, 522 insertions(+), 223 deletions(-) diff --git a/sui/token_bridge/sources/governance/set_pauser_addresses.move b/sui/token_bridge/sources/governance/set_pauser_addresses.move index ca2e771676e..4771aa9a450 100644 --- a/sui/token_bridge/sources/governance/set_pauser_addresses.move +++ b/sui/token_bridge/sources/governance/set_pauser_addresses.move @@ -1,8 +1,24 @@ // SPDX-License-Identifier: Apache 2 -/// This module implements handling a governance VAA to set the pauser and -/// unpauser addresses on the Token Bridge. These addresses control the -/// emergency pause mechanism. +/// This module implements handling a governance VAA to (re)assign the pauser +/// and unpauser for the Token Bridge emergency pause mechanism. +/// +/// The VAA encodes the OWNER address that should receive each capability. The +/// handler MINTS a fresh `PauserCap`/`UnpauserCap`, transfers it to that owner, +/// and records the new cap's object id as the active id in `State` (see +/// `token_bridge::pause`). Because the handler mints and transfers, the active +/// cap is always an owned object — never shared — so only its owner can pause. +/// +/// Each `SetPauserAddresses` mints NEW caps. Rotation = new cap to the new +/// owner; any previously minted cap becomes inert (its id no longer matches the +/// recorded active id). A zero owner records `@0x0` (unassigned) and mints +/// nothing. +/// +/// On Sui the owner is a 32-byte address (an EOA, or an object that should own +/// the cap). A Sui address is 32 bytes — the same size as on SVM — so the +/// canonical action-4 wire format is unchanged; the Guardian treats the value +/// as opaque length-prefixed bytes and the whitepaper delegates interpretation +/// to the receiving runtime. /// /// Wire format (action 4, per whitepaper 0003): /// ``` @@ -11,14 +27,17 @@ /// /// Validation: /// - PauserLen must be 0 (unassigned) or 32 (Sui address size). -/// - UnpauserLen must be 0 (unassigned) or 32 (Sui address size). -/// - An all-zero 32-byte address is treated as unassigned (@0x0). +/// - UnpauserLen must be 0 (unassigned) or 32. +/// - An all-zero 32-byte value is treated as unassigned (@0x0). /// - No trailing bytes allowed (cursor must be fully consumed). module token_bridge::set_pauser_addresses { + use sui::transfer::{Self}; + use sui::tx_context::{TxContext}; use wormhole::bytes::{Self}; use wormhole::cursor::{Self}; use wormhole::governance_message::{Self, DecreeTicket, DecreeReceipt}; + use token_bridge::pause::{Self}; use token_bridge::state::{Self, State}; /// Address length is not 0 or 32. @@ -32,7 +51,9 @@ module token_bridge::set_pauser_addresses { struct GovernanceWitness has drop {} - /// Event emitted when pauser addresses are updated via governance. + /// Event emitted when pauser/unpauser caps are (re)assigned via governance. + /// `pauser`/`unpauser` are the newly minted cap object ids (as `address`); + /// `@0x0` means the role was left unassigned (no cap minted). struct PauserAddressesSet has drop, copy { pauser: address, unpauser: address @@ -52,12 +73,13 @@ module token_bridge::set_pauser_addresses { ) } - /// Execute the `SetPauserAddresses` governance action. - /// Consumes the `DecreeReceipt`, parses the length-prefixed payload, - /// and updates state. + /// Execute the `SetPauserAddresses` governance action. Parses the two owner + /// addresses, mints a cap for each non-zero owner and transfers it there, + /// and records the new cap ids (or @0x0) as active. public fun set_pauser_addresses( token_bridge_state: &mut State, - receipt: DecreeReceipt + receipt: DecreeReceipt, + ctx: &mut TxContext ) { // This capability ensures that the current build version is used. let latest_only = state::assert_latest_only(token_bridge_state); @@ -71,30 +93,65 @@ module token_bridge::set_pauser_addresses { receipt ); - // Parse the length-prefixed payload. + // Parse the length-prefixed owner addresses. let cur = cursor::new(payload); - - let pauser = take_address_length_prefixed(&mut cur); - let unpauser = take_address_length_prefixed(&mut cur); + let pauser_owner = take_address_length_prefixed(&mut cur); + let unpauser_owner = take_address_length_prefixed(&mut cur); // No trailing bytes allowed. cursor::destroy_empty(cur); - // Update state. - state::set_pauser_address(&latest_only, token_bridge_state, pauser); - state::set_unpauser_address( - &latest_only, - token_bridge_state, - unpauser + // Mint + transfer + record for each role. + let pauser_id = assign_pauser(token_bridge_state, &latest_only, pauser_owner, ctx); + let unpauser_id = + assign_unpauser(token_bridge_state, &latest_only, unpauser_owner, ctx); + + sui::event::emit( + PauserAddressesSet { pauser: pauser_id, unpauser: unpauser_id } ); + } - // Emit event. - sui::event::emit(PauserAddressesSet { pauser, unpauser }); + /// Mint a `PauserCap` for `owner` and record its id as active. A zero owner + /// unassigns the role (records @0x0, mints nothing). Returns the recorded id. + fun assign_pauser( + token_bridge_state: &mut State, + latest_only: &state::LatestOnly, + owner: address, + ctx: &mut TxContext + ): address { + if (owner == @0x0) { + state::set_pauser_address(latest_only, token_bridge_state, @0x0); + return @0x0 + }; + let cap = pause::new_pauser_cap(ctx); + let cap_id = pause::pauser_cap_id(&cap); + transfer::public_transfer(cap, owner); + state::set_pauser_address(latest_only, token_bridge_state, cap_id); + cap_id + } + + /// Mint an `UnpauserCap` for `owner` and record its id as active. A zero + /// owner unassigns the role. Returns the recorded id. + fun assign_unpauser( + token_bridge_state: &mut State, + latest_only: &state::LatestOnly, + owner: address, + ctx: &mut TxContext + ): address { + if (owner == @0x0) { + state::set_unpauser_address(latest_only, token_bridge_state, @0x0); + return @0x0 + }; + let cap = pause::new_unpauser_cap(ctx); + let cap_id = pause::unpauser_cap_id(&cap); + transfer::public_transfer(cap, owner); + state::set_unpauser_address(latest_only, token_bridge_state, cap_id); + cap_id } - /// Parse a length-prefixed address from the cursor. - /// Length must be 0 (returns @0x0) or SUI_ADDRESS_SIZE (32). - /// An all-zero 32-byte address is also treated as @0x0. + /// Parse a length-prefixed 32-byte owner address from the cursor. Length + /// must be 0 (returns @0x0, unassigned) or SUI_ADDRESS_SIZE (32). A 32-byte + /// all-zero value decodes to @0x0 via `from_bytes`, i.e. also unassigned. fun take_address_length_prefixed(cur: &mut cursor::Cursor): address { let len = bytes::take_u8(cur); if (len == 0) { @@ -114,18 +171,37 @@ module token_bridge::set_pauser_addresses { } #[test_only] - /// Directly set pauser addresses for tests, bypassing governance VAA. + /// Parse a raw SetPauserAddresses payload (the part after the governance + /// header) into the two owner addresses, exercising the exact decode path + /// used by `set_pauser_addresses` (length validation + no-trailing-bytes). + public fun parse_payload_test_only(payload: vector): (address, address) { + let cur = cursor::new(payload); + let pauser_owner = take_address_length_prefixed(&mut cur); + let unpauser_owner = take_address_length_prefixed(&mut cur); + cursor::destroy_empty(cur); + (pauser_owner, unpauser_owner) + } + + #[test_only] + public fun e_invalid_address_length(): u64 { + E_INVALID_ADDRESS_LENGTH + } + + #[test_only] + /// Directly assign pauser/unpauser owners for tests, bypassing the VAA. + /// Mints + transfers caps exactly like the governance handler. Returns the + /// recorded (pauser_id, unpauser_id). public fun set_pauser_addresses_test_only( token_bridge_state: &mut State, - pauser: address, - unpauser: address - ) { + pauser_owner: address, + unpauser_owner: address, + ctx: &mut TxContext + ): (address, address) { let latest_only = state::assert_latest_only(token_bridge_state); - state::set_pauser_address(&latest_only, token_bridge_state, pauser); - state::set_unpauser_address( - &latest_only, - token_bridge_state, - unpauser - ); + let pauser_id = + assign_pauser(token_bridge_state, &latest_only, pauser_owner, ctx); + let unpauser_id = + assign_unpauser(token_bridge_state, &latest_only, unpauser_owner, ctx); + (pauser_id, unpauser_id) } } diff --git a/sui/token_bridge/sources/pause.move b/sui/token_bridge/sources/pause.move index 8e03ac9b319..9585643a730 100644 --- a/sui/token_bridge/sources/pause.move +++ b/sui/token_bridge/sources/pause.move @@ -1,75 +1,184 @@ // SPDX-License-Identifier: Apache 2 -/// This module implements the `pause` and `unpause` entry functions for the -/// Token Bridge emergency pause mechanism. +/// This module implements the emergency pause mechanism for the Token Bridge. /// -/// - `pause` can only be called by the configured pauser address. -/// - `unpause` can only be called by the configured unpauser address. -/// - Neither function is guarded by `assert_not_paused` (both must be callable -/// regardless of pause state — `pause` is a no-op when already paused, -/// `unpause` must obviously work when paused). +/// Authority is held via capability objects rather than EOA addresses. Two +/// capabilities exist: `PauserCap` (gates `pause`) and `UnpauserCap` (gates +/// `unpause`). The Token Bridge `State` stores the object id of the single +/// ACTIVE capability for each role; `pause`/`unpause` take the capability and +/// check `object::id(cap)` against the stored active id. +/// +/// Why capabilities instead of `tx_context::sender`: +/// - `tx_context::sender` is always the transaction signer, i.e. an EOA. A Sui +/// smart contract (governance program, multisig module) can never be the +/// sender, so it could never hold the role. A capability is an object, which +/// a contract *can* own — so contracts can be pausers. This mirrors +/// `wormhole::emitter::EmitterCap`, whose identity is also its object id. +/// +/// Lifecycle (governance-driven mint): +/// - Capabilities are minted ONLY by `token_bridge::set_pauser_addresses` while +/// handling a `SetPauserAddresses` governance VAA. The VAA encodes the OWNER +/// address that should receive the cap. The handler mints the cap, transfers +/// it to that owner, and records the new cap's id as active in `State`. +/// - Because the handler mints and transfers, the active cap is ALWAYS an owned +/// object — it can never be a shared object, so `pause`/`unpause` (which take +/// `&cap`) cannot be invoked by anyone other than the cap's owner. +/// - Rotation, two ways: +/// 1. Governance mints a NEW cap for a new owner via `SetPauserAddresses`; +/// the previously active cap becomes inert (its id no longer matches the +/// recorded active id) — no clawback required. +/// 2. The current holder transfers the active cap directly (the cap has +/// `store`, so this needs no governance). The cap keeps its id, so it +/// stays active — authority simply moves with the object. If a cap is +/// compromised, governance can always revoke it via path 1. +/// - Unassign: a zero owner records `@0x0` as active and mints nothing; the +/// corresponding entry point then reverts as not-configured. +/// +/// Neither `pause` nor `unpause` is guarded by `assert_not_paused` (both must be +/// callable regardless of pause state — `pause` is a no-op when already paused, +/// `unpause` must obviously work when paused). module token_bridge::pause { + use sui::object::{Self, ID, UID}; use sui::tx_context::{Self, TxContext}; use token_bridge::state::{Self, State}; - /// The configured pauser is the zero address (unassigned). + friend token_bridge::set_pauser_addresses; + + /// The pauser role is unassigned (active id is @0x0). const E_PAUSER_NOT_CONFIGURED: u64 = 0; - /// The configured unpauser is the zero address (unassigned). + /// The unpauser role is unassigned (active id is @0x0). const E_UNPAUSER_NOT_CONFIGURED: u64 = 1; - /// Caller is not the configured pauser. + /// Provided `PauserCap` is not the active pauser. const E_NOT_PAUSER: u64 = 2; - /// Caller is not the configured unpauser. + /// Provided `UnpauserCap` is not the active unpauser. const E_NOT_UNPAUSER: u64 = 3; + /// Capability whose holder may `pause` the bridge, while its id is the + /// active pauser. Minted and transferred by governance. + struct PauserCap has key, store { + id: UID + } + + /// Capability whose holder may `unpause` the bridge, while its id is the + /// active unpauser. Minted and transferred by governance. + struct UnpauserCap has key, store { + id: UID + } + /// Event emitted when the bridge is paused. struct Paused has drop, copy { + /// Object id of the cap used to pause. + cap: ID, + /// Transaction signer (for audit; not the authority). sender: address } /// Event emitted when the bridge is unpaused. struct Unpaused has drop, copy { + /// Object id of the cap used to unpause. + cap: ID, + /// Transaction signer (for audit; not the authority). sender: address } - /// Pause the token bridge. Only callable by the configured pauser. - /// Aborts if pauser is unassigned (@0x0). + /// Mint a new `PauserCap`. Only callable by `set_pauser_addresses` while + /// handling a governance VAA. The handler is responsible for transferring + /// the cap to its owner and recording its id as active. + public(friend) fun new_pauser_cap(ctx: &mut TxContext): PauserCap { + PauserCap { id: object::new(ctx) } + } + + /// Mint a new `UnpauserCap`. Only callable by `set_pauser_addresses`. + public(friend) fun new_unpauser_cap(ctx: &mut TxContext): UnpauserCap { + UnpauserCap { id: object::new(ctx) } + } + + /// The object id of a `PauserCap`, as an `address` (the value recorded as + /// the active pauser in `State`). + public fun pauser_cap_id(cap: &PauserCap): address { + object::id_to_address(&object::id(cap)) + } + + /// The object id of an `UnpauserCap`, as an `address`. + public fun unpauser_cap_id(cap: &UnpauserCap): address { + object::id_to_address(&object::id(cap)) + } + + /// Destroy a `PauserCap`. The active pauser is tracked by id in `State`, so + /// destroying the active cap leaves the role uncallable until governance + /// mints and records a new one. + public fun destroy_pauser_cap(cap: PauserCap) { + let PauserCap { id } = cap; + object::delete(id); + } + + /// Destroy an `UnpauserCap`. + public fun destroy_unpauser_cap(cap: UnpauserCap) { + let UnpauserCap { id } = cap; + object::delete(id); + } + + /// Pause the token bridge. Requires the active `PauserCap`. + /// Aborts if the pauser role is unassigned (@0x0). public fun pause( token_bridge_state: &mut State, + cap: &PauserCap, ctx: &TxContext ) { // Version check. let latest_only = state::assert_latest_only(token_bridge_state); - let configured_pauser = state::pauser(token_bridge_state); - assert!(configured_pauser != @0x0, E_PAUSER_NOT_CONFIGURED); + let configured = state::pauser(token_bridge_state); + assert!(configured != @0x0, E_PAUSER_NOT_CONFIGURED); - let sender = tx_context::sender(ctx); - assert!(sender == configured_pauser, E_NOT_PAUSER); + let cap_id = object::id(cap); + assert!(object::id_to_address(&cap_id) == configured, E_NOT_PAUSER); state::set_paused(&latest_only, token_bridge_state, true); - sui::event::emit(Paused { sender }); + sui::event::emit(Paused { cap: cap_id, sender: tx_context::sender(ctx) }); } - /// Unpause the token bridge. Only callable by the configured unpauser. - /// Aborts if unpauser is unassigned (@0x0). + /// Unpause the token bridge. Requires the active `UnpauserCap`. + /// Aborts if the unpauser role is unassigned (@0x0). /// NOT guarded by assert_not_paused (must be callable when paused). public fun unpause( token_bridge_state: &mut State, + cap: &UnpauserCap, ctx: &TxContext ) { // Version check. let latest_only = state::assert_latest_only(token_bridge_state); - let configured_unpauser = state::unpauser(token_bridge_state); - assert!(configured_unpauser != @0x0, E_UNPAUSER_NOT_CONFIGURED); + let configured = state::unpauser(token_bridge_state); + assert!(configured != @0x0, E_UNPAUSER_NOT_CONFIGURED); - let sender = tx_context::sender(ctx); - assert!(sender == configured_unpauser, E_NOT_UNPAUSER); + let cap_id = object::id(cap); + assert!(object::id_to_address(&cap_id) == configured, E_NOT_UNPAUSER); state::set_paused(&latest_only, token_bridge_state, false); - sui::event::emit(Unpaused { sender }); + sui::event::emit(Unpaused { cap: cap_id, sender: tx_context::sender(ctx) }); + } + + #[test_only] + public fun new_pauser_cap_test_only(ctx: &mut TxContext): PauserCap { + new_pauser_cap(ctx) + } + + #[test_only] + public fun new_unpauser_cap_test_only(ctx: &mut TxContext): UnpauserCap { + new_unpauser_cap(ctx) + } + + #[test_only] + public fun destroy_pauser_cap_test_only(cap: PauserCap) { + destroy_pauser_cap(cap); + } + + #[test_only] + public fun destroy_unpauser_cap_test_only(cap: UnpauserCap) { + destroy_unpauser_cap(cap); } } diff --git a/sui/token_bridge/sources/state.move b/sui/token_bridge/sources/state.move index dac53f269ff..965e5cb1c03 100644 --- a/sui/token_bridge/sources/state.move +++ b/sui/token_bridge/sources/state.move @@ -50,9 +50,9 @@ module token_bridge::state { /// Dynamic field key for the `paused` boolean. struct PausedKey has copy, drop, store {} - /// Dynamic field key for the pauser address. + /// Dynamic field key for the designated pauser capability id. struct PauserKey has copy, drop, store {} - /// Dynamic field key for the unpauser address. + /// Dynamic field key for the designated unpauser capability id. struct UnpauserKey has copy, drop, store {} /// Container for all state variables for Token Bridge. @@ -172,7 +172,8 @@ module token_bridge::state { } } - /// Returns the configured pauser address. Returns `@0x0` if unset. + /// Returns the designated pauser capability's object id (as an `address`). + /// Returns `@0x0` if unset. See `token_bridge::pause`. public fun pauser(self: &State): address { if (field::exists_(&self.id, PauserKey {})) { *field::borrow(&self.id, PauserKey {}) @@ -181,7 +182,8 @@ module token_bridge::state { } } - /// Returns the configured unpauser address. Returns `@0x0` if unset. + /// Returns the designated unpauser capability's object id (as an `address`). + /// Returns `@0x0` if unset. See `token_bridge::pause`. public fun unpauser(self: &State): address { if (field::exists_(&self.id, UnpauserKey {})) { *field::borrow(&self.id, UnpauserKey {}) @@ -358,7 +360,7 @@ module token_bridge::state { } } - /// Set the pauser address. Requires `LatestOnly`. + /// Set the designated pauser capability id. Requires `LatestOnly`. public(friend) fun set_pauser_address( _: &LatestOnly, self: &mut State, @@ -374,7 +376,7 @@ module token_bridge::state { } } - /// Set the unpauser address. Requires `LatestOnly`. + /// Set the designated unpauser capability id. Requires `LatestOnly`. public(friend) fun set_unpauser_address( _: &LatestOnly, self: &mut State, @@ -472,10 +474,16 @@ module token_bridge::state { /// NOTE: Please keep this method as public(friend) because we never want /// to expose this method as a public method. public(friend) fun migrate__v__0_3_0(self: &mut State) { - // Initialize pause dynamic fields with defaults. - // paused = false, pauser = @0x0, unpauser = @0x0 - // Guarded with exists_ check for resilience against partial migration - // retry scenarios. + // Initialize pause dynamic fields with defaults: + // paused = false, pauser cap id = @0x0, unpauser cap id = @0x0. + // + // The `exists_` guard is required (NOT redundant): `migrate` calls this + // handler BEFORE bumping the version in `handle_migrate`. So the version + // guard (`package_utils::migrate_version`, abort E_INCORRECT_OLD_VERSION) + // does not protect this init. Without this guard, a second `migrate` + // would abort here at `field::add` with `EFieldAlreadyExists` instead of + // the meaningful version-mismatch error (see + // `migrate_tests::test_cannot_migrate_again`). if (!field::exists_(&self.id, PausedKey {})) { field::add(&mut self.id, PausedKey {}, false); field::add(&mut self.id, PauserKey {}, @0x0); diff --git a/sui/token_bridge/sources/test/pause_tests.move b/sui/token_bridge/sources/test/pause_tests.move index 36e7a6546d8..df364423d04 100644 --- a/sui/token_bridge/sources/test/pause_tests.move +++ b/sui/token_bridge/sources/test/pause_tests.move @@ -2,9 +2,10 @@ #[test_only] module token_bridge::pause_tests { + use std::vector::{Self}; use sui::test_scenario::{Self}; - use token_bridge::pause::{Self}; + use token_bridge::pause::{Self, PauserCap, UnpauserCap}; use token_bridge::set_pauser_addresses::{Self}; use token_bridge::state::{Self}; use token_bridge::token_bridge_scenario::{ @@ -34,7 +35,7 @@ module token_bridge::pause_tests { // Initialize pause state (simulating migration). state::init_pause_state_test_only(&mut token_bridge_state); - // Default state: not paused, pauser/unpauser = @0x0. + // Default state: not paused, pauser/unpauser cap ids = @0x0 (unassigned). assert!(!state::is_paused(&token_bridge_state), 0); assert!(state::pauser(&token_bridge_state) == @0x0, 0); assert!(state::unpauser(&token_bridge_state) == @0x0, 0); @@ -65,12 +66,12 @@ module token_bridge::pause_tests { } // ======================================================================== - // Set Pauser Addresses (via test helper) + // Set Pauser Addresses (mint + transfer to owner, record cap id) // ======================================================================== #[test] - fun test_set_pauser_addresses() { - let (caller, pauser_addr, unpauser_addr) = three_people(); + fun test_set_pauser_addresses_mints_and_records() { + let (caller, pauser_owner, unpauser_owner) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -81,57 +82,40 @@ module token_bridge::pause_tests { let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - // Set pauser and unpauser. - set_pauser_addresses::set_pauser_addresses_test_only( - &mut token_bridge_state, - pauser_addr, - unpauser_addr - ); + let (pauser_id, unpauser_id) = + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + pauser_owner, + unpauser_owner, + test_scenario::ctx(scenario) + ); - assert!(state::pauser(&token_bridge_state) == pauser_addr, 0); - assert!(state::unpauser(&token_bridge_state) == unpauser_addr, 0); + // Recorded ids are the minted caps' ids (non-zero). + assert!(pauser_id != @0x0, 0); + assert!(unpauser_id != @0x0, 0); + assert!(state::pauser(&token_bridge_state) == pauser_id, 0); + assert!(state::unpauser(&token_bridge_state) == unpauser_id, 0); return_state(token_bridge_state); - test_scenario::end(my_scenario); - } - #[test] - fun test_rotate_pauser_addresses() { - let (caller, pauser_addr, unpauser_addr) = three_people(); - let my_scenario = test_scenario::begin(caller); - let scenario = &mut my_scenario; - - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); - - // Set initial addresses. - set_pauser_addresses::set_pauser_addresses_test_only( - &mut token_bridge_state, - pauser_addr, - unpauser_addr - ); - - // Rotate to new addresses (swap them). - set_pauser_addresses::set_pauser_addresses_test_only( - &mut token_bridge_state, - unpauser_addr, - pauser_addr - ); + // Caps were transferred to the owners. + test_scenario::next_tx(scenario, pauser_owner); + let pauser_cap = test_scenario::take_from_address(scenario, pauser_owner); + assert!(pause::pauser_cap_id(&pauser_cap) == pauser_id, 0); + test_scenario::return_to_address(pauser_owner, pauser_cap); - assert!(state::pauser(&token_bridge_state) == unpauser_addr, 0); - assert!(state::unpauser(&token_bridge_state) == pauser_addr, 0); + test_scenario::next_tx(scenario, unpauser_owner); + let unpauser_cap = + test_scenario::take_from_address(scenario, unpauser_owner); + assert!(pause::unpauser_cap_id(&unpauser_cap) == unpauser_id, 0); + test_scenario::return_to_address(unpauser_owner, unpauser_cap); - return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] fun test_set_pauser_to_zero_unassigns() { - let (caller, pauser_addr, unpauser_addr) = three_people(); + let (caller, pauser_owner, unpauser_owner) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -142,20 +126,25 @@ module token_bridge::pause_tests { let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - // Set addresses. + // Assign owners. set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_addr, - unpauser_addr - ); - - // Unassign by setting to @0x0. - set_pauser_addresses::set_pauser_addresses_test_only( - &mut token_bridge_state, - @0x0, - @0x0 + pauser_owner, + unpauser_owner, + test_scenario::ctx(scenario) ); + // Unassign by setting owners to @0x0 (mints nothing). + let (pauser_id, unpauser_id) = + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + @0x0, + @0x0, + test_scenario::ctx(scenario) + ); + + assert!(pauser_id == @0x0, 0); + assert!(unpauser_id == @0x0, 0); assert!(state::pauser(&token_bridge_state) == @0x0, 0); assert!(state::unpauser(&token_bridge_state) == @0x0, 0); @@ -164,12 +153,12 @@ module token_bridge::pause_tests { } // ======================================================================== - // Pause / Unpause + // Pause / Unpause (capability-gated) // ======================================================================== #[test] - fun test_pauser_can_pause() { - let (caller, pauser_addr, unpauser_addr) = three_people(); + fun test_pauser_cap_can_pause() { + let (caller, pauser_owner, _u) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -179,33 +168,35 @@ module token_bridge::pause_tests { test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_addr, - unpauser_addr + pauser_owner, + @0x0, + test_scenario::ctx(scenario) ); - return_state(token_bridge_state); - // Pause as the configured pauser. - test_scenario::next_tx(scenario, pauser_addr); + // The owner retrieves its cap and pauses. + test_scenario::next_tx(scenario, pauser_owner); let token_bridge_state = take_state(scenario); + let pauser_cap = test_scenario::take_from_address(scenario, pauser_owner); pause::pause( &mut token_bridge_state, + &pauser_cap, test_scenario::ctx(scenario) ); assert!(state::is_paused(&token_bridge_state), 0); + test_scenario::return_to_address(pauser_owner, pauser_cap); return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] - fun test_unpauser_can_unpause() { - let (caller, pauser_addr, unpauser_addr) = three_people(); + fun test_unpauser_cap_can_unpause() { + let (caller, _p, unpauser_owner) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -215,37 +206,37 @@ module token_bridge::pause_tests { test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_addr, - unpauser_addr + @0x0, + unpauser_owner, + test_scenario::ctx(scenario) ); - - // Set paused directly for this test. state::set_paused_test_only(&mut token_bridge_state, true); assert!(state::is_paused(&token_bridge_state), 0); - return_state(token_bridge_state); - // Unpause as the configured unpauser. - test_scenario::next_tx(scenario, unpauser_addr); + test_scenario::next_tx(scenario, unpauser_owner); let token_bridge_state = take_state(scenario); + let unpauser_cap = + test_scenario::take_from_address(scenario, unpauser_owner); pause::unpause( &mut token_bridge_state, + &unpauser_cap, test_scenario::ctx(scenario) ); assert!(!state::is_paused(&token_bridge_state), 0); + test_scenario::return_to_address(unpauser_owner, unpauser_cap); return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] fun test_pause_is_idempotent() { - let (caller, pauser_addr, unpauser_addr) = three_people(); + let (caller, pauser_owner, _u) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -255,43 +246,32 @@ module token_bridge::pause_tests { test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_addr, - unpauser_addr - ); - - return_state(token_bridge_state); - - // Pause twice — should not revert. - test_scenario::next_tx(scenario, pauser_addr); - let token_bridge_state = take_state(scenario); - - pause::pause( - &mut token_bridge_state, + pauser_owner, + @0x0, test_scenario::ctx(scenario) ); - assert!(state::is_paused(&token_bridge_state), 0); - return_state(token_bridge_state); - test_scenario::next_tx(scenario, pauser_addr); + test_scenario::next_tx(scenario, pauser_owner); let token_bridge_state = take_state(scenario); + let pauser_cap = test_scenario::take_from_address(scenario, pauser_owner); - pause::pause( - &mut token_bridge_state, - test_scenario::ctx(scenario) - ); + // Pause twice — should not revert. + pause::pause(&mut token_bridge_state, &pauser_cap, test_scenario::ctx(scenario)); + assert!(state::is_paused(&token_bridge_state), 0); + pause::pause(&mut token_bridge_state, &pauser_cap, test_scenario::ctx(scenario)); assert!(state::is_paused(&token_bridge_state), 0); + test_scenario::return_to_address(pauser_owner, pauser_cap); return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] fun test_unpause_is_idempotent() { - let (caller, _pauser_addr, unpauser_addr) = three_people(); + let (caller, _p, unpauser_owner) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -301,48 +281,33 @@ module token_bridge::pause_tests { test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - caller, - unpauser_addr - ); - - return_state(token_bridge_state); - - // Unpause twice — should not revert (already unpaused). - test_scenario::next_tx(scenario, unpauser_addr); - let token_bridge_state = take_state(scenario); - - pause::unpause( - &mut token_bridge_state, + @0x0, + unpauser_owner, test_scenario::ctx(scenario) ); - assert!(!state::is_paused(&token_bridge_state), 0); - return_state(token_bridge_state); - test_scenario::next_tx(scenario, unpauser_addr); + test_scenario::next_tx(scenario, unpauser_owner); let token_bridge_state = take_state(scenario); + let unpauser_cap = + test_scenario::take_from_address(scenario, unpauser_owner); - pause::unpause( - &mut token_bridge_state, - test_scenario::ctx(scenario) - ); + // Unpause twice — should not revert (already unpaused). + pause::unpause(&mut token_bridge_state, &unpauser_cap, test_scenario::ctx(scenario)); + assert!(!state::is_paused(&token_bridge_state), 0); + pause::unpause(&mut token_bridge_state, &unpauser_cap, test_scenario::ctx(scenario)); assert!(!state::is_paused(&token_bridge_state), 0); + test_scenario::return_to_address(unpauser_owner, unpauser_cap); return_state(token_bridge_state); test_scenario::end(my_scenario); } - // ======================================================================== - // Access Control — Negative Tests - // ======================================================================== - #[test] - #[expected_failure(abort_code = pause::E_NOT_PAUSER)] - fun test_non_pauser_cannot_pause() { - let (caller, pauser_addr, unpauser_addr) = three_people(); + fun test_rotate_deprecates_old_cap() { + let (caller, owner_a, owner_b) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -353,31 +318,49 @@ module token_bridge::pause_tests { let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); + // Assign owner_a. set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_addr, - unpauser_addr + owner_a, + @0x0, + test_scenario::ctx(scenario) ); - return_state(token_bridge_state); - // Try to pause as a random (non-pauser) address. - test_scenario::next_tx(scenario, caller); + // owner_a's cap can pause. + test_scenario::next_tx(scenario, owner_a); let token_bridge_state = take_state(scenario); + let cap_a = test_scenario::take_from_address(scenario, owner_a); + pause::pause(&mut token_bridge_state, &cap_a, test_scenario::ctx(scenario)); + assert!(state::is_paused(&token_bridge_state), 0); + state::set_paused_test_only(&mut token_bridge_state, false); - // You shall not pass! - pause::pause( + // Rotate: governance mints a new cap for owner_b, deprecating cap_a. + set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, + owner_b, + @0x0, test_scenario::ctx(scenario) ); + test_scenario::return_to_address(owner_a, cap_a); + return_state(token_bridge_state); - abort 42 + // owner_b's new cap can pause. + test_scenario::next_tx(scenario, owner_b); + let token_bridge_state = take_state(scenario); + let cap_b = test_scenario::take_from_address(scenario, owner_b); + pause::pause(&mut token_bridge_state, &cap_b, test_scenario::ctx(scenario)); + assert!(state::is_paused(&token_bridge_state), 0); + + test_scenario::return_to_address(owner_b, cap_b); + return_state(token_bridge_state); + test_scenario::end(my_scenario); } #[test] - #[expected_failure(abort_code = pause::E_NOT_UNPAUSER)] - fun test_non_unpauser_cannot_unpause() { - let (caller, pauser_addr, unpauser_addr) = three_people(); + #[expected_failure(abort_code = pause::E_NOT_PAUSER)] + fun test_deprecated_cap_cannot_pause() { + let (caller, owner_a, owner_b) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -388,29 +371,36 @@ module token_bridge::pause_tests { let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); + // Assign owner_a, then rotate to owner_b (deprecating cap_a). set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_addr, - unpauser_addr + owner_a, + @0x0, + test_scenario::ctx(scenario) + ); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + owner_b, + @0x0, + test_scenario::ctx(scenario) ); - - state::set_paused_test_only(&mut token_bridge_state, true); - return_state(token_bridge_state); - // Try to unpause as a random (non-unpauser) address. - test_scenario::next_tx(scenario, caller); + // owner_a's now-deprecated cap must fail. + test_scenario::next_tx(scenario, owner_a); let token_bridge_state = take_state(scenario); + let cap_a = test_scenario::take_from_address(scenario, owner_a); // You shall not pass! - pause::unpause( - &mut token_bridge_state, - test_scenario::ctx(scenario) - ); + pause::pause(&mut token_bridge_state, &cap_a, test_scenario::ctx(scenario)); abort 42 } + // ======================================================================== + // Access Control — Negative Tests + // ======================================================================== + #[test] #[expected_failure(abort_code = pause::E_PAUSER_NOT_CONFIGURED)] fun test_cannot_pause_when_pauser_unassigned() { @@ -425,14 +415,13 @@ module token_bridge::pause_tests { let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - // Pauser is @0x0 (default / unassigned). + // Pauser unassigned; mint a stray cap to attempt with. + let stray_cap = pause::new_pauser_cap_test_only(test_scenario::ctx(scenario)); // You shall not pass! - pause::pause( - &mut token_bridge_state, - test_scenario::ctx(scenario) - ); + pause::pause(&mut token_bridge_state, &stray_cap, test_scenario::ctx(scenario)); + pause::destroy_pauser_cap_test_only(stray_cap); abort 42 } @@ -449,17 +438,44 @@ module token_bridge::pause_tests { test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); state::init_pause_state_test_only(&mut token_bridge_state); - state::set_paused_test_only(&mut token_bridge_state, true); - // Unpauser is @0x0 (default / unassigned). + let stray_cap = pause::new_unpauser_cap_test_only(test_scenario::ctx(scenario)); // You shall not pass! - pause::unpause( + pause::unpause(&mut token_bridge_state, &stray_cap, test_scenario::ctx(scenario)); + + pause::destroy_unpauser_cap_test_only(stray_cap); + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_NOT_PAUSER)] + fun test_stray_cap_cannot_pause() { + let (caller, pauser_owner, _u) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + let wormhole_fee = 350; + set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + + // Designate a real pauser owner, but attempt with a stray (undesignated) cap. + set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, + pauser_owner, + @0x0, test_scenario::ctx(scenario) ); + let stray_cap = pause::new_pauser_cap_test_only(test_scenario::ctx(scenario)); + // You shall not pass! + pause::pause(&mut token_bridge_state, &stray_cap, test_scenario::ctx(scenario)); + + pause::destroy_pauser_cap_test_only(stray_cap); abort 42 } @@ -508,4 +524,94 @@ module token_bridge::pause_tests { return_state(token_bridge_state); test_scenario::end(my_scenario); } + + // ======================================================================== + // Governance Payload Decode (wire format) + // ======================================================================== + + #[test] + fun test_parse_payload_both_owners() { + // [32][A..][32][B..] + let a = x"00000000000000000000000000000000000000000000000000000000000000aa"; + let b = x"00000000000000000000000000000000000000000000000000000000000000bb"; + let payload = vector::empty(); + vector::push_back(&mut payload, 32); + vector::append(&mut payload, a); + vector::push_back(&mut payload, 32); + vector::append(&mut payload, b); + + let (pauser, unpauser) = + set_pauser_addresses::parse_payload_test_only(payload); + assert!(pauser == @0xaa, 0); + assert!(unpauser == @0xbb, 0); + } + + #[test] + fun test_parse_payload_pauser_unassigned() { + // [0][32][B..] -> pauser unassigned, unpauser = B + let b = x"00000000000000000000000000000000000000000000000000000000000000bb"; + let payload = vector::empty(); + vector::push_back(&mut payload, 0); + vector::push_back(&mut payload, 32); + vector::append(&mut payload, b); + + let (pauser, unpauser) = + set_pauser_addresses::parse_payload_test_only(payload); + assert!(pauser == @0x0, 0); + assert!(unpauser == @0xbb, 0); + } + + #[test] + fun test_parse_payload_both_unassigned() { + // [0][0] -> both unassigned + let payload = vector::empty(); + vector::push_back(&mut payload, 0); + vector::push_back(&mut payload, 0); + + let (pauser, unpauser) = + set_pauser_addresses::parse_payload_test_only(payload); + assert!(pauser == @0x0, 0); + assert!(unpauser == @0x0, 0); + } + + #[test] + fun test_parse_payload_all_zero_addr_is_unassigned() { + // [32][0x00..00][0] -> all-zero 32-byte decodes to @0x0 + let zeros = x"0000000000000000000000000000000000000000000000000000000000000000"; + let payload = vector::empty(); + vector::push_back(&mut payload, 32); + vector::append(&mut payload, zeros); + vector::push_back(&mut payload, 0); + + let (pauser, unpauser) = + set_pauser_addresses::parse_payload_test_only(payload); + assert!(pauser == @0x0, 0); + assert!(unpauser == @0x0, 0); + } + + #[test] + #[expected_failure(abort_code = set_pauser_addresses::E_INVALID_ADDRESS_LENGTH)] + fun test_parse_payload_bad_length_aborts() { + // [31][...] -> invalid length + let bad = x"00000000000000000000000000000000000000000000000000000000000000"; + let payload = vector::empty(); + vector::push_back(&mut payload, 31); + vector::append(&mut payload, bad); + + // You shall not pass! + let (_p, _u) = set_pauser_addresses::parse_payload_test_only(payload); + } + + #[test] + #[expected_failure] + fun test_parse_payload_trailing_bytes_aborts() { + // [0][0][extra] -> cursor not empty after parsing both + let payload = vector::empty(); + vector::push_back(&mut payload, 0); + vector::push_back(&mut payload, 0); + vector::push_back(&mut payload, 0xff); + + // You shall not pass! (cursor::destroy_empty aborts) + let (_p, _u) = set_pauser_addresses::parse_payload_test_only(payload); + } } From be447ccf66df469cfa27180b5a4b7e65bf77d4b2 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 1 Jun 2026 14:36:51 +0200 Subject: [PATCH 07/14] fix: update sui devnet token bridge state and emitter ids for cap build --- scripts/devnet-consts.json | 2 +- sdk/devnet_consts.go | 2 +- sdk/js/src/utils/consts.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/devnet-consts.json b/scripts/devnet-consts.json index 8bba6dada73..5f9c9b74c96 100644 --- a/scripts/devnet-consts.json +++ b/scripts/devnet-consts.json @@ -150,7 +150,7 @@ }, "21": { "contracts": { - "tokenBridgeEmitterAddress": "7b19543ac266fd13aa5f37421251fdbd2076a266785cf2c21d88199bee1c7d45" + "tokenBridgeEmitterAddress": "1c11fc81e11014109f388ba3efe4dbf35b278071da2e776fb851d65fca66c125" } }, "22": { diff --git a/sdk/devnet_consts.go b/sdk/devnet_consts.go index 36e6bf1da29..295c7f99600 100644 --- a/sdk/devnet_consts.go +++ b/sdk/devnet_consts.go @@ -15,7 +15,7 @@ var knownDevnetTokenbridgeEmitters = map[vaa.ChainID]string{ vaa.ChainIDBSC: "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16", vaa.ChainIDAlgorand: "8ec299cb7f3efec28f542397e07f07118d74c875f85409ed8e6b93c17b60e992", vaa.ChainIDWormchain: "c9138c6e5bd7a2ab79c1a87486c9d7349d064b35ac9f7498f3b207b3a61e6013", - vaa.ChainIDSui: "7b19543ac266fd13aa5f37421251fdbd2076a266785cf2c21d88199bee1c7d45", + vaa.ChainIDSui: "1c11fc81e11014109f388ba3efe4dbf35b278071da2e776fb851d65fca66c125", } // KnownDevnetNFTBridgeEmitters is a map of known NFT emitters used during development. diff --git a/sdk/js/src/utils/consts.ts b/sdk/js/src/utils/consts.ts index 690ebabd7d8..034e8b28440 100644 --- a/sdk/js/src/utils/consts.ts +++ b/sdk/js/src/utils/consts.ts @@ -831,7 +831,7 @@ const DEVNET = { sui: { core: "0xea31c369d1f873d87d37f313ec37f1ee20a0b8136f06e3d3521330ee467312a4", // wormhole module State object ID token_bridge: - "0xfa434e835120d31598abceee7208e5236aead674aa4e216b5ca761454a7351a7", // token_bridge module State object ID + "0x2a57bc5fea204431f20c6804a35dc23549d09eb2b9162fd1d4c641c4185a3a6b", // token_bridge module State object ID nft_bridge: undefined, }, moonbeam: { From 0e3329c47aa181a6cf4e35f6fbb75299ed3eff99 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 1 Jun 2026 21:30:29 +0200 Subject: [PATCH 08/14] refactor: represent pauser/unpauser as Option --- .../governance/set_pauser_addresses.move | 97 ++++++++++-------- sui/token_bridge/sources/pause.move | 33 ++++--- sui/token_bridge/sources/state.move | 49 ++++----- .../sources/test/pause_tests.move | 99 ++++++++++--------- 4 files changed, 147 insertions(+), 131 deletions(-) diff --git a/sui/token_bridge/sources/governance/set_pauser_addresses.move b/sui/token_bridge/sources/governance/set_pauser_addresses.move index 4771aa9a450..56f258a9d64 100644 --- a/sui/token_bridge/sources/governance/set_pauser_addresses.move +++ b/sui/token_bridge/sources/governance/set_pauser_addresses.move @@ -11,7 +11,7 @@ /// /// Each `SetPauserAddresses` mints NEW caps. Rotation = new cap to the new /// owner; any previously minted cap becomes inert (its id no longer matches the -/// recorded active id). A zero owner records `@0x0` (unassigned) and mints +/// recorded active id). A zero/empty owner records `none` (unassigned) and mints /// nothing. /// /// On Sui the owner is a 32-byte address (an EOA, or an object that should own @@ -28,9 +28,11 @@ /// Validation: /// - PauserLen must be 0 (unassigned) or 32 (Sui address size). /// - UnpauserLen must be 0 (unassigned) or 32. -/// - An all-zero 32-byte value is treated as unassigned (@0x0). +/// - An all-zero 32-byte value is treated as unassigned (`none`). /// - No trailing bytes allowed (cursor must be fully consumed). module token_bridge::set_pauser_addresses { + use std::option::{Self, Option}; + use sui::object::{ID}; use sui::transfer::{Self}; use sui::tx_context::{TxContext}; use wormhole::bytes::{Self}; @@ -52,11 +54,11 @@ module token_bridge::set_pauser_addresses { struct GovernanceWitness has drop {} /// Event emitted when pauser/unpauser caps are (re)assigned via governance. - /// `pauser`/`unpauser` are the newly minted cap object ids (as `address`); - /// `@0x0` means the role was left unassigned (no cap minted). + /// `pauser`/`unpauser` are the newly minted cap object ids, or `none` when + /// the role was left unassigned (no cap minted). struct PauserAddressesSet has drop, copy { - pauser: address, - unpauser: address + pauser: Option, + unpauser: Option } /// Create `DecreeTicket` for `SetPauserAddresses` governance VAA. @@ -74,8 +76,8 @@ module token_bridge::set_pauser_addresses { } /// Execute the `SetPauserAddresses` governance action. Parses the two owner - /// addresses, mints a cap for each non-zero owner and transfers it there, - /// and records the new cap ids (or @0x0) as active. + /// addresses, mints a cap for each present owner and transfers it there, + /// and records the new cap ids (or `none`) as active. public fun set_pauser_addresses( token_bridge_state: &mut State, receipt: DecreeReceipt, @@ -93,10 +95,10 @@ module token_bridge::set_pauser_addresses { receipt ); - // Parse the length-prefixed owner addresses. + // Parse the length-prefixed owner addresses (`none` = unassigned). let cur = cursor::new(payload); - let pauser_owner = take_address_length_prefixed(&mut cur); - let unpauser_owner = take_address_length_prefixed(&mut cur); + let pauser_owner = take_owner_length_prefixed(&mut cur); + let unpauser_owner = take_owner_length_prefixed(&mut cur); // No trailing bytes allowed. cursor::destroy_empty(cur); @@ -111,58 +113,65 @@ module token_bridge::set_pauser_addresses { ); } - /// Mint a `PauserCap` for `owner` and record its id as active. A zero owner - /// unassigns the role (records @0x0, mints nothing). Returns the recorded id. + /// Mint a `PauserCap` for `owner` and record its id as active. A `none` + /// owner unassigns the role (records `none`, mints nothing). Returns the + /// recorded id (`some(cap_id)` or `none`). fun assign_pauser( token_bridge_state: &mut State, latest_only: &state::LatestOnly, - owner: address, + owner: Option
, ctx: &mut TxContext - ): address { - if (owner == @0x0) { - state::set_pauser_address(latest_only, token_bridge_state, @0x0); - return @0x0 + ): Option { + if (option::is_none(&owner)) { + state::set_pauser(latest_only, token_bridge_state, option::none()); + return option::none() }; let cap = pause::new_pauser_cap(ctx); let cap_id = pause::pauser_cap_id(&cap); - transfer::public_transfer(cap, owner); - state::set_pauser_address(latest_only, token_bridge_state, cap_id); - cap_id + transfer::public_transfer(cap, option::destroy_some(owner)); + state::set_pauser(latest_only, token_bridge_state, option::some(cap_id)); + option::some(cap_id) } - /// Mint an `UnpauserCap` for `owner` and record its id as active. A zero - /// owner unassigns the role. Returns the recorded id. + /// Mint an `UnpauserCap` for `owner` and record its id as active. A `none` + /// owner unassigns the role. Returns the recorded id (`some(cap_id)` or + /// `none`). fun assign_unpauser( token_bridge_state: &mut State, latest_only: &state::LatestOnly, - owner: address, + owner: Option
, ctx: &mut TxContext - ): address { - if (owner == @0x0) { - state::set_unpauser_address(latest_only, token_bridge_state, @0x0); - return @0x0 + ): Option { + if (option::is_none(&owner)) { + state::set_unpauser(latest_only, token_bridge_state, option::none()); + return option::none() }; let cap = pause::new_unpauser_cap(ctx); let cap_id = pause::unpauser_cap_id(&cap); - transfer::public_transfer(cap, owner); - state::set_unpauser_address(latest_only, token_bridge_state, cap_id); - cap_id + transfer::public_transfer(cap, option::destroy_some(owner)); + state::set_unpauser(latest_only, token_bridge_state, option::some(cap_id)); + option::some(cap_id) } /// Parse a length-prefixed 32-byte owner address from the cursor. Length - /// must be 0 (returns @0x0, unassigned) or SUI_ADDRESS_SIZE (32). A 32-byte - /// all-zero value decodes to @0x0 via `from_bytes`, i.e. also unassigned. - fun take_address_length_prefixed(cur: &mut cursor::Cursor): address { + /// must be 0 (returns `none`, unassigned) or SUI_ADDRESS_SIZE (32). A + /// 32-byte all-zero value is also treated as `none` (unassigned). + fun take_owner_length_prefixed(cur: &mut cursor::Cursor): Option
{ let len = bytes::take_u8(cur); if (len == 0) { - return @0x0 + return option::none() }; assert!((len as u64) == (SUI_ADDRESS_SIZE as u64), E_INVALID_ADDRESS_LENGTH); let addr_bytes = bytes::take_bytes(cur, (SUI_ADDRESS_SIZE as u64)); - // Convert to address. - sui::address::from_bytes(addr_bytes) + // Convert to address. An all-zero address is treated as unassigned. + let owner = sui::address::from_bytes(addr_bytes); + if (owner == @0x0) { + option::none() + } else { + option::some(owner) + } } #[test_only] @@ -174,10 +183,12 @@ module token_bridge::set_pauser_addresses { /// Parse a raw SetPauserAddresses payload (the part after the governance /// header) into the two owner addresses, exercising the exact decode path /// used by `set_pauser_addresses` (length validation + no-trailing-bytes). - public fun parse_payload_test_only(payload: vector): (address, address) { + public fun parse_payload_test_only( + payload: vector + ): (Option
, Option
) { let cur = cursor::new(payload); - let pauser_owner = take_address_length_prefixed(&mut cur); - let unpauser_owner = take_address_length_prefixed(&mut cur); + let pauser_owner = take_owner_length_prefixed(&mut cur); + let unpauser_owner = take_owner_length_prefixed(&mut cur); cursor::destroy_empty(cur); (pauser_owner, unpauser_owner) } @@ -193,10 +204,10 @@ module token_bridge::set_pauser_addresses { /// recorded (pauser_id, unpauser_id). public fun set_pauser_addresses_test_only( token_bridge_state: &mut State, - pauser_owner: address, - unpauser_owner: address, + pauser_owner: Option
, + unpauser_owner: Option
, ctx: &mut TxContext - ): (address, address) { + ): (Option, Option) { let latest_only = state::assert_latest_only(token_bridge_state); let pauser_id = assign_pauser(token_bridge_state, &latest_only, pauser_owner, ctx); diff --git a/sui/token_bridge/sources/pause.move b/sui/token_bridge/sources/pause.move index 9585643a730..78e0c987716 100644 --- a/sui/token_bridge/sources/pause.move +++ b/sui/token_bridge/sources/pause.move @@ -31,13 +31,14 @@ /// `store`, so this needs no governance). The cap keeps its id, so it /// stays active — authority simply moves with the object. If a cap is /// compromised, governance can always revoke it via path 1. -/// - Unassign: a zero owner records `@0x0` as active and mints nothing; the +/// - Unassign: a zero owner records `none` as active and mints nothing; the /// corresponding entry point then reverts as not-configured. /// /// Neither `pause` nor `unpause` is guarded by `assert_not_paused` (both must be /// callable regardless of pause state — `pause` is a no-op when already paused, /// `unpause` must obviously work when paused). module token_bridge::pause { + use std::option::{Self}; use sui::object::{Self, ID, UID}; use sui::tx_context::{Self, TxContext}; @@ -45,9 +46,9 @@ module token_bridge::pause { friend token_bridge::set_pauser_addresses; - /// The pauser role is unassigned (active id is @0x0). + /// The pauser role is unassigned (active id is `none`). const E_PAUSER_NOT_CONFIGURED: u64 = 0; - /// The unpauser role is unassigned (active id is @0x0). + /// The unpauser role is unassigned (active id is `none`). const E_UNPAUSER_NOT_CONFIGURED: u64 = 1; /// Provided `PauserCap` is not the active pauser. const E_NOT_PAUSER: u64 = 2; @@ -94,15 +95,15 @@ module token_bridge::pause { UnpauserCap { id: object::new(ctx) } } - /// The object id of a `PauserCap`, as an `address` (the value recorded as - /// the active pauser in `State`). - public fun pauser_cap_id(cap: &PauserCap): address { - object::id_to_address(&object::id(cap)) + /// The object id of a `PauserCap` (the value recorded as the active pauser + /// in `State`). + public fun pauser_cap_id(cap: &PauserCap): ID { + object::id(cap) } - /// The object id of an `UnpauserCap`, as an `address`. - public fun unpauser_cap_id(cap: &UnpauserCap): address { - object::id_to_address(&object::id(cap)) + /// The object id of an `UnpauserCap`. + public fun unpauser_cap_id(cap: &UnpauserCap): ID { + object::id(cap) } /// Destroy a `PauserCap`. The active pauser is tracked by id in `State`, so @@ -120,7 +121,7 @@ module token_bridge::pause { } /// Pause the token bridge. Requires the active `PauserCap`. - /// Aborts if the pauser role is unassigned (@0x0). + /// Aborts if the pauser role is unassigned. public fun pause( token_bridge_state: &mut State, cap: &PauserCap, @@ -130,10 +131,10 @@ module token_bridge::pause { let latest_only = state::assert_latest_only(token_bridge_state); let configured = state::pauser(token_bridge_state); - assert!(configured != @0x0, E_PAUSER_NOT_CONFIGURED); + assert!(option::is_some(&configured), E_PAUSER_NOT_CONFIGURED); let cap_id = object::id(cap); - assert!(object::id_to_address(&cap_id) == configured, E_NOT_PAUSER); + assert!(cap_id == option::destroy_some(configured), E_NOT_PAUSER); state::set_paused(&latest_only, token_bridge_state, true); @@ -141,7 +142,7 @@ module token_bridge::pause { } /// Unpause the token bridge. Requires the active `UnpauserCap`. - /// Aborts if the unpauser role is unassigned (@0x0). + /// Aborts if the unpauser role is unassigned. /// NOT guarded by assert_not_paused (must be callable when paused). public fun unpause( token_bridge_state: &mut State, @@ -152,10 +153,10 @@ module token_bridge::pause { let latest_only = state::assert_latest_only(token_bridge_state); let configured = state::unpauser(token_bridge_state); - assert!(configured != @0x0, E_UNPAUSER_NOT_CONFIGURED); + assert!(option::is_some(&configured), E_UNPAUSER_NOT_CONFIGURED); let cap_id = object::id(cap); - assert!(object::id_to_address(&cap_id) == configured, E_NOT_UNPAUSER); + assert!(cap_id == option::destroy_some(configured), E_NOT_UNPAUSER); state::set_paused(&latest_only, token_bridge_state, false); diff --git a/sui/token_bridge/sources/state.move b/sui/token_bridge/sources/state.move index 965e5cb1c03..49403607716 100644 --- a/sui/token_bridge/sources/state.move +++ b/sui/token_bridge/sources/state.move @@ -6,6 +6,7 @@ /// accessing registered assets and verifying `VAA` intended for Token Bridge by /// checking the emitter against its own registered emitters. module token_bridge::state { + use std::option::{Self, Option}; use sui::dynamic_field::{Self as field}; use sui::object::{Self, ID, UID}; use sui::package::{UpgradeCap, UpgradeReceipt, UpgradeTicket}; @@ -172,23 +173,23 @@ module token_bridge::state { } } - /// Returns the designated pauser capability's object id (as an `address`). - /// Returns `@0x0` if unset. See `token_bridge::pause`. - public fun pauser(self: &State): address { + /// Returns the active pauser capability's object id, or `none` if unset + /// (also `none` for pre-pause state). See `token_bridge::pause`. + public fun pauser(self: &State): Option { if (field::exists_(&self.id, PauserKey {})) { - *field::borrow(&self.id, PauserKey {}) + *field::borrow>(&self.id, PauserKey {}) } else { - @0x0 + option::none() } } - /// Returns the designated unpauser capability's object id (as an `address`). - /// Returns `@0x0` if unset. See `token_bridge::pause`. - public fun unpauser(self: &State): address { + /// Returns the active unpauser capability's object id, or `none` if unset. + /// See `token_bridge::pause`. + public fun unpauser(self: &State): Option { if (field::exists_(&self.id, UnpauserKey {})) { - *field::borrow(&self.id, UnpauserKey {}) + *field::borrow>(&self.id, UnpauserKey {}) } else { - @0x0 + option::none() } } @@ -360,14 +361,15 @@ module token_bridge::state { } } - /// Set the designated pauser capability id. Requires `LatestOnly`. - public(friend) fun set_pauser_address( + /// Set the active pauser capability id (`none` to unassign). + /// Requires `LatestOnly`. + public(friend) fun set_pauser( _: &LatestOnly, self: &mut State, - new_pauser: address + new_pauser: Option ) { if (field::exists_(&self.id, PauserKey {})) { - *field::borrow_mut( + *field::borrow_mut>( &mut self.id, PauserKey {} ) = new_pauser; @@ -376,14 +378,15 @@ module token_bridge::state { } } - /// Set the designated unpauser capability id. Requires `LatestOnly`. - public(friend) fun set_unpauser_address( + /// Set the active unpauser capability id (`none` to unassign). + /// Requires `LatestOnly`. + public(friend) fun set_unpauser( _: &LatestOnly, self: &mut State, - new_unpauser: address + new_unpauser: Option ) { if (field::exists_(&self.id, UnpauserKey {})) { - *field::borrow_mut( + *field::borrow_mut>( &mut self.id, UnpauserKey {} ) = new_unpauser; @@ -475,7 +478,7 @@ module token_bridge::state { /// to expose this method as a public method. public(friend) fun migrate__v__0_3_0(self: &mut State) { // Initialize pause dynamic fields with defaults: - // paused = false, pauser cap id = @0x0, unpauser cap id = @0x0. + // paused = false, pauser = none, unpauser = none (roles unassigned). // // The `exists_` guard is required (NOT redundant): `migrate` calls this // handler BEFORE bumping the version in `handle_migrate`. So the version @@ -486,8 +489,8 @@ module token_bridge::state { // `migrate_tests::test_cannot_migrate_again`). if (!field::exists_(&self.id, PausedKey {})) { field::add(&mut self.id, PausedKey {}, false); - field::add(&mut self.id, PauserKey {}, @0x0); - field::add(&mut self.id, UnpauserKey {}, @0x0); + field::add(&mut self.id, PauserKey {}, option::none()); + field::add(&mut self.id, UnpauserKey {}, option::none()); }; } @@ -507,8 +510,8 @@ module token_bridge::state { public fun init_pause_state_test_only(self: &mut State) { if (!field::exists_(&self.id, PausedKey {})) { field::add(&mut self.id, PausedKey {}, false); - field::add(&mut self.id, PauserKey {}, @0x0); - field::add(&mut self.id, UnpauserKey {}, @0x0); + field::add(&mut self.id, PauserKey {}, option::none()); + field::add(&mut self.id, UnpauserKey {}, option::none()); } } diff --git a/sui/token_bridge/sources/test/pause_tests.move b/sui/token_bridge/sources/test/pause_tests.move index df364423d04..3fc56d71cf1 100644 --- a/sui/token_bridge/sources/test/pause_tests.move +++ b/sui/token_bridge/sources/test/pause_tests.move @@ -2,6 +2,7 @@ #[test_only] module token_bridge::pause_tests { + use std::option::{Self}; use std::vector::{Self}; use sui::test_scenario::{Self}; @@ -35,10 +36,10 @@ module token_bridge::pause_tests { // Initialize pause state (simulating migration). state::init_pause_state_test_only(&mut token_bridge_state); - // Default state: not paused, pauser/unpauser cap ids = @0x0 (unassigned). + // Default state: not paused, pauser/unpauser unassigned (none). assert!(!state::is_paused(&token_bridge_state), 0); - assert!(state::pauser(&token_bridge_state) == @0x0, 0); - assert!(state::unpauser(&token_bridge_state) == @0x0, 0); + assert!(option::is_none(&state::pauser(&token_bridge_state)), 0); + assert!(option::is_none(&state::unpauser(&token_bridge_state)), 0); return_state(token_bridge_state); test_scenario::end(my_scenario); @@ -58,8 +59,8 @@ module token_bridge::pause_tests { // Before pause state init, is_paused returns false (backwards compat). assert!(!state::is_paused(&token_bridge_state), 0); - assert!(state::pauser(&token_bridge_state) == @0x0, 0); - assert!(state::unpauser(&token_bridge_state) == @0x0, 0); + assert!(option::is_none(&state::pauser(&token_bridge_state)), 0); + assert!(option::is_none(&state::unpauser(&token_bridge_state)), 0); return_state(token_bridge_state); test_scenario::end(my_scenario); @@ -85,14 +86,14 @@ module token_bridge::pause_tests { let (pauser_id, unpauser_id) = set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_owner, - unpauser_owner, + option::some(pauser_owner), + option::some(unpauser_owner), test_scenario::ctx(scenario) ); - // Recorded ids are the minted caps' ids (non-zero). - assert!(pauser_id != @0x0, 0); - assert!(unpauser_id != @0x0, 0); + // Recorded ids are the minted caps' ids (present). + assert!(option::is_some(&pauser_id), 0); + assert!(option::is_some(&unpauser_id), 0); assert!(state::pauser(&token_bridge_state) == pauser_id, 0); assert!(state::unpauser(&token_bridge_state) == unpauser_id, 0); @@ -101,20 +102,20 @@ module token_bridge::pause_tests { // Caps were transferred to the owners. test_scenario::next_tx(scenario, pauser_owner); let pauser_cap = test_scenario::take_from_address(scenario, pauser_owner); - assert!(pause::pauser_cap_id(&pauser_cap) == pauser_id, 0); + assert!(option::contains(&pauser_id, &pause::pauser_cap_id(&pauser_cap)), 0); test_scenario::return_to_address(pauser_owner, pauser_cap); test_scenario::next_tx(scenario, unpauser_owner); let unpauser_cap = test_scenario::take_from_address(scenario, unpauser_owner); - assert!(pause::unpauser_cap_id(&unpauser_cap) == unpauser_id, 0); + assert!(option::contains(&unpauser_id, &pause::unpauser_cap_id(&unpauser_cap)), 0); test_scenario::return_to_address(unpauser_owner, unpauser_cap); test_scenario::end(my_scenario); } #[test] - fun test_set_pauser_to_zero_unassigns() { + fun test_set_pauser_to_none_unassigns() { let (caller, pauser_owner, unpauser_owner) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; @@ -129,24 +130,24 @@ module token_bridge::pause_tests { // Assign owners. set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_owner, - unpauser_owner, + option::some(pauser_owner), + option::some(unpauser_owner), test_scenario::ctx(scenario) ); - // Unassign by setting owners to @0x0 (mints nothing). + // Unassign by setting owners to none (mints nothing). let (pauser_id, unpauser_id) = set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - @0x0, - @0x0, + option::none(), + option::none(), test_scenario::ctx(scenario) ); - assert!(pauser_id == @0x0, 0); - assert!(unpauser_id == @0x0, 0); - assert!(state::pauser(&token_bridge_state) == @0x0, 0); - assert!(state::unpauser(&token_bridge_state) == @0x0, 0); + assert!(option::is_none(&pauser_id), 0); + assert!(option::is_none(&unpauser_id), 0); + assert!(option::is_none(&state::pauser(&token_bridge_state)), 0); + assert!(option::is_none(&state::unpauser(&token_bridge_state)), 0); return_state(token_bridge_state); test_scenario::end(my_scenario); @@ -170,8 +171,8 @@ module token_bridge::pause_tests { state::init_pause_state_test_only(&mut token_bridge_state); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_owner, - @0x0, + option::some(pauser_owner), + option::none(), test_scenario::ctx(scenario) ); return_state(token_bridge_state); @@ -208,8 +209,8 @@ module token_bridge::pause_tests { state::init_pause_state_test_only(&mut token_bridge_state); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - @0x0, - unpauser_owner, + option::none(), + option::some(unpauser_owner), test_scenario::ctx(scenario) ); state::set_paused_test_only(&mut token_bridge_state, true); @@ -248,8 +249,8 @@ module token_bridge::pause_tests { state::init_pause_state_test_only(&mut token_bridge_state); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_owner, - @0x0, + option::some(pauser_owner), + option::none(), test_scenario::ctx(scenario) ); return_state(token_bridge_state); @@ -283,8 +284,8 @@ module token_bridge::pause_tests { state::init_pause_state_test_only(&mut token_bridge_state); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - @0x0, - unpauser_owner, + option::none(), + option::some(unpauser_owner), test_scenario::ctx(scenario) ); return_state(token_bridge_state); @@ -321,8 +322,8 @@ module token_bridge::pause_tests { // Assign owner_a. set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - owner_a, - @0x0, + option::some(owner_a), + option::none(), test_scenario::ctx(scenario) ); return_state(token_bridge_state); @@ -338,8 +339,8 @@ module token_bridge::pause_tests { // Rotate: governance mints a new cap for owner_b, deprecating cap_a. set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - owner_b, - @0x0, + option::some(owner_b), + option::none(), test_scenario::ctx(scenario) ); test_scenario::return_to_address(owner_a, cap_a); @@ -374,14 +375,14 @@ module token_bridge::pause_tests { // Assign owner_a, then rotate to owner_b (deprecating cap_a). set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - owner_a, - @0x0, + option::some(owner_a), + option::none(), test_scenario::ctx(scenario) ); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - owner_b, - @0x0, + option::some(owner_b), + option::none(), test_scenario::ctx(scenario) ); return_state(token_bridge_state); @@ -466,8 +467,8 @@ module token_bridge::pause_tests { // Designate a real pauser owner, but attempt with a stray (undesignated) cap. set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - pauser_owner, - @0x0, + option::some(pauser_owner), + option::none(), test_scenario::ctx(scenario) ); let stray_cap = pause::new_pauser_cap_test_only(test_scenario::ctx(scenario)); @@ -542,8 +543,8 @@ module token_bridge::pause_tests { let (pauser, unpauser) = set_pauser_addresses::parse_payload_test_only(payload); - assert!(pauser == @0xaa, 0); - assert!(unpauser == @0xbb, 0); + assert!(option::contains(&pauser, &@0xaa), 0); + assert!(option::contains(&unpauser, &@0xbb), 0); } #[test] @@ -557,8 +558,8 @@ module token_bridge::pause_tests { let (pauser, unpauser) = set_pauser_addresses::parse_payload_test_only(payload); - assert!(pauser == @0x0, 0); - assert!(unpauser == @0xbb, 0); + assert!(option::is_none(&pauser), 0); + assert!(option::contains(&unpauser, &@0xbb), 0); } #[test] @@ -570,13 +571,13 @@ module token_bridge::pause_tests { let (pauser, unpauser) = set_pauser_addresses::parse_payload_test_only(payload); - assert!(pauser == @0x0, 0); - assert!(unpauser == @0x0, 0); + assert!(option::is_none(&pauser), 0); + assert!(option::is_none(&unpauser), 0); } #[test] fun test_parse_payload_all_zero_addr_is_unassigned() { - // [32][0x00..00][0] -> all-zero 32-byte decodes to @0x0 + // [32][0x00..00][0] -> all-zero 32-byte decodes to none (unassigned) let zeros = x"0000000000000000000000000000000000000000000000000000000000000000"; let payload = vector::empty(); vector::push_back(&mut payload, 32); @@ -585,8 +586,8 @@ module token_bridge::pause_tests { let (pauser, unpauser) = set_pauser_addresses::parse_payload_test_only(payload); - assert!(pauser == @0x0, 0); - assert!(unpauser == @0x0, 0); + assert!(option::is_none(&pauser), 0); + assert!(option::is_none(&unpauser), 0); } #[test] From 43e364f54917624b10a466f332a6b418d5a8f39e Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 1 Jun 2026 21:55:51 +0200 Subject: [PATCH 09/14] fix: update sui devnet token bridge state and emitter ids for Option build --- scripts/devnet-consts.json | 2 +- sdk/devnet_consts.go | 2 +- sdk/js/src/utils/consts.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/devnet-consts.json b/scripts/devnet-consts.json index 5f9c9b74c96..ead1e9badca 100644 --- a/scripts/devnet-consts.json +++ b/scripts/devnet-consts.json @@ -150,7 +150,7 @@ }, "21": { "contracts": { - "tokenBridgeEmitterAddress": "1c11fc81e11014109f388ba3efe4dbf35b278071da2e776fb851d65fca66c125" + "tokenBridgeEmitterAddress": "07035e0d324df11aca0e5c7eef7d87bca4da632a623a9cb1aabf21215ee98cdc" } }, "22": { diff --git a/sdk/devnet_consts.go b/sdk/devnet_consts.go index 295c7f99600..92abb04a569 100644 --- a/sdk/devnet_consts.go +++ b/sdk/devnet_consts.go @@ -15,7 +15,7 @@ var knownDevnetTokenbridgeEmitters = map[vaa.ChainID]string{ vaa.ChainIDBSC: "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16", vaa.ChainIDAlgorand: "8ec299cb7f3efec28f542397e07f07118d74c875f85409ed8e6b93c17b60e992", vaa.ChainIDWormchain: "c9138c6e5bd7a2ab79c1a87486c9d7349d064b35ac9f7498f3b207b3a61e6013", - vaa.ChainIDSui: "1c11fc81e11014109f388ba3efe4dbf35b278071da2e776fb851d65fca66c125", + vaa.ChainIDSui: "07035e0d324df11aca0e5c7eef7d87bca4da632a623a9cb1aabf21215ee98cdc", } // KnownDevnetNFTBridgeEmitters is a map of known NFT emitters used during development. diff --git a/sdk/js/src/utils/consts.ts b/sdk/js/src/utils/consts.ts index 034e8b28440..b6344dded0f 100644 --- a/sdk/js/src/utils/consts.ts +++ b/sdk/js/src/utils/consts.ts @@ -831,7 +831,7 @@ const DEVNET = { sui: { core: "0xea31c369d1f873d87d37f313ec37f1ee20a0b8136f06e3d3521330ee467312a4", // wormhole module State object ID token_bridge: - "0x2a57bc5fea204431f20c6804a35dc23549d09eb2b9162fd1d4c641c4185a3a6b", // token_bridge module State object ID + "0x952c15855b6b7a806e78e45858f4b909b1e2b25fa79a199e254c5ddf44590f9d", // token_bridge module State object ID nft_bridge: undefined, }, moonbeam: { From 18d7b8be2665745607822cfe5fcee4e199fc951f Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 18 Jun 2026 16:08:48 +0200 Subject: [PATCH 10/14] chore: add freezer and pause expiry --- .../governance/set_pauser_addresses.move | 86 +- sui/token_bridge/sources/pause.move | 229 ++++- sui/token_bridge/sources/state.move | 66 +- .../sources/test/pause_tests.move | 829 +++++++++++++----- 4 files changed, 911 insertions(+), 299 deletions(-) diff --git a/sui/token_bridge/sources/governance/set_pauser_addresses.move b/sui/token_bridge/sources/governance/set_pauser_addresses.move index 56f258a9d64..41c7e648f4c 100644 --- a/sui/token_bridge/sources/governance/set_pauser_addresses.move +++ b/sui/token_bridge/sources/governance/set_pauser_addresses.move @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache 2 -/// This module implements handling a governance VAA to (re)assign the pauser -/// and unpauser for the Token Bridge emergency pause mechanism. +/// This module implements handling a governance VAA to (re)assign the pauser, +/// freezer, and unpauser for the Token Bridge emergency pause mechanism. /// /// The VAA encodes the OWNER address that should receive each capability. The -/// handler MINTS a fresh `PauserCap`/`UnpauserCap`, transfers it to that owner, -/// and records the new cap's object id as the active id in `State` (see -/// `token_bridge::pause`). Because the handler mints and transfers, the active -/// cap is always an owned object — never shared — so only its owner can pause. +/// handler MINTS a fresh `PauserCap`/`FreezerCap`/`UnpauserCap`, transfers it to +/// that owner, and records the new cap's object id as the active id in `State` +/// (see `token_bridge::pause`). Because the handler mints and transfers, the +/// active cap is always an owned object — never shared — so only its owner can +/// invoke the corresponding entry point. /// /// Each `SetPauserAddresses` mints NEW caps. Rotation = new cap to the new /// owner; any previously minted cap becomes inert (its id no longer matches the @@ -22,14 +23,16 @@ /// /// Wire format (action 4, per whitepaper 0003): /// ``` -/// PauserLen(1) | Pauser(PauserLen) | UnpauserLen(1) | Unpauser(UnpauserLen) +/// PauserLen(1) | Pauser(PauserLen) | +/// FreezerLen(1) | Freezer(FreezerLen) | +/// UnpauserLen(1) | Unpauser(UnpauserLen) /// ``` /// /// Validation: -/// - PauserLen must be 0 (unassigned) or 32 (Sui address size). -/// - UnpauserLen must be 0 (unassigned) or 32. +/// - Each length must be 0 (unassigned) or 32 (Sui address size). /// - An all-zero 32-byte value is treated as unassigned (`none`). -/// - No trailing bytes allowed (cursor must be fully consumed). +/// - No trailing bytes allowed (cursor must be fully consumed after the three +/// addresses). module token_bridge::set_pauser_addresses { use std::option::{Self, Option}; use sui::object::{ID}; @@ -53,11 +56,12 @@ module token_bridge::set_pauser_addresses { struct GovernanceWitness has drop {} - /// Event emitted when pauser/unpauser caps are (re)assigned via governance. - /// `pauser`/`unpauser` are the newly minted cap object ids, or `none` when - /// the role was left unassigned (no cap minted). + /// Event emitted when pauser/freezer/unpauser caps are (re)assigned via + /// governance. Each field is the newly minted cap object id, or `none` when + /// that role was left unassigned (no cap minted). struct PauserAddressesSet has drop, copy { pauser: Option, + freezer: Option, unpauser: Option } @@ -75,9 +79,10 @@ module token_bridge::set_pauser_addresses { ) } - /// Execute the `SetPauserAddresses` governance action. Parses the two owner - /// addresses, mints a cap for each present owner and transfers it there, - /// and records the new cap ids (or `none`) as active. + /// Execute the `SetPauserAddresses` governance action. Parses the three + /// owner addresses (pauser, freezer, unpauser), mints a cap for each present + /// owner and transfers it there, and records the new cap ids (or `none`) as + /// active. public fun set_pauser_addresses( token_bridge_state: &mut State, receipt: DecreeReceipt, @@ -98,6 +103,7 @@ module token_bridge::set_pauser_addresses { // Parse the length-prefixed owner addresses (`none` = unassigned). let cur = cursor::new(payload); let pauser_owner = take_owner_length_prefixed(&mut cur); + let freezer_owner = take_owner_length_prefixed(&mut cur); let unpauser_owner = take_owner_length_prefixed(&mut cur); // No trailing bytes allowed. @@ -105,11 +111,17 @@ module token_bridge::set_pauser_addresses { // Mint + transfer + record for each role. let pauser_id = assign_pauser(token_bridge_state, &latest_only, pauser_owner, ctx); + let freezer_id = + assign_freezer(token_bridge_state, &latest_only, freezer_owner, ctx); let unpauser_id = assign_unpauser(token_bridge_state, &latest_only, unpauser_owner, ctx); sui::event::emit( - PauserAddressesSet { pauser: pauser_id, unpauser: unpauser_id } + PauserAddressesSet { + pauser: pauser_id, + freezer: freezer_id, + unpauser: unpauser_id + } ); } @@ -133,6 +145,26 @@ module token_bridge::set_pauser_addresses { option::some(cap_id) } + /// Mint a `FreezerCap` for `owner` and record its id as active. A `none` + /// owner unassigns the role. Returns the recorded id (`some(cap_id)` or + /// `none`). + fun assign_freezer( + token_bridge_state: &mut State, + latest_only: &state::LatestOnly, + owner: Option
, + ctx: &mut TxContext + ): Option { + if (option::is_none(&owner)) { + state::set_freezer(latest_only, token_bridge_state, option::none()); + return option::none() + }; + let cap = pause::new_freezer_cap(ctx); + let cap_id = pause::freezer_cap_id(&cap); + transfer::public_transfer(cap, option::destroy_some(owner)); + state::set_freezer(latest_only, token_bridge_state, option::some(cap_id)); + option::some(cap_id) + } + /// Mint an `UnpauserCap` for `owner` and record its id as active. A `none` /// owner unassigns the role. Returns the recorded id (`some(cap_id)` or /// `none`). @@ -181,16 +213,17 @@ module token_bridge::set_pauser_addresses { #[test_only] /// Parse a raw SetPauserAddresses payload (the part after the governance - /// header) into the two owner addresses, exercising the exact decode path + /// header) into the three owner addresses, exercising the exact decode path /// used by `set_pauser_addresses` (length validation + no-trailing-bytes). public fun parse_payload_test_only( payload: vector - ): (Option
, Option
) { + ): (Option
, Option
, Option
) { let cur = cursor::new(payload); let pauser_owner = take_owner_length_prefixed(&mut cur); + let freezer_owner = take_owner_length_prefixed(&mut cur); let unpauser_owner = take_owner_length_prefixed(&mut cur); cursor::destroy_empty(cur); - (pauser_owner, unpauser_owner) + (pauser_owner, freezer_owner, unpauser_owner) } #[test_only] @@ -199,20 +232,23 @@ module token_bridge::set_pauser_addresses { } #[test_only] - /// Directly assign pauser/unpauser owners for tests, bypassing the VAA. - /// Mints + transfers caps exactly like the governance handler. Returns the - /// recorded (pauser_id, unpauser_id). + /// Directly assign pauser/freezer/unpauser owners for tests, bypassing the + /// VAA. Mints + transfers caps exactly like the governance handler. Returns + /// the recorded (pauser_id, freezer_id, unpauser_id). public fun set_pauser_addresses_test_only( token_bridge_state: &mut State, pauser_owner: Option
, + freezer_owner: Option
, unpauser_owner: Option
, ctx: &mut TxContext - ): (Option, Option) { + ): (Option, Option, Option) { let latest_only = state::assert_latest_only(token_bridge_state); let pauser_id = assign_pauser(token_bridge_state, &latest_only, pauser_owner, ctx); + let freezer_id = + assign_freezer(token_bridge_state, &latest_only, freezer_owner, ctx); let unpauser_id = assign_unpauser(token_bridge_state, &latest_only, unpauser_owner, ctx); - (pauser_id, unpauser_id) + (pauser_id, freezer_id, unpauser_id) } } diff --git a/sui/token_bridge/sources/pause.move b/sui/token_bridge/sources/pause.move index 78e0c987716..b411d310520 100644 --- a/sui/token_bridge/sources/pause.move +++ b/sui/token_bridge/sources/pause.move @@ -2,43 +2,62 @@ /// This module implements the emergency pause mechanism for the Token Bridge. /// -/// Authority is held via capability objects rather than EOA addresses. Two -/// capabilities exist: `PauserCap` (gates `pause`) and `UnpauserCap` (gates -/// `unpause`). The Token Bridge `State` stores the object id of the single -/// ACTIVE capability for each role; `pause`/`unpause` take the capability and -/// check `object::id(cap)` against the stored active id. +/// Authority is held via capability objects rather than EOA addresses. Three +/// capabilities exist: `PauserCap` (gates `pause`), `FreezerCap` (gates +/// `freeze`), and `UnpauserCap` (gates `unpause`). The Token Bridge `State` +/// stores the object id of the single ACTIVE capability for each role; the +/// entry points take the capability and check `object::id(cap)` against the +/// stored active id. /// /// Why capabilities instead of `tx_context::sender`: /// - `tx_context::sender` is always the transaction signer, i.e. an EOA. A Sui /// smart contract (governance program, multisig module) can never be the /// sender, so it could never hold the role. A capability is an object, which -/// a contract *can* own — so contracts can be pausers. This mirrors +/// a contract *can* own — so contracts can hold these roles. This mirrors /// `wormhole::emitter::EmitterCap`, whose identity is also its object id. /// +/// Pause model (whitepaper 0003): +/// - State is a boolean `paused` (authoritative) plus a `pauseExpiry` timestamp +/// (ms). `paused` is the only thing the hot-path `assert_not_paused` reads; a +/// pause is NEVER lifted silently by the passage of time — only an explicit +/// `unpause` or `unpause_expired` call clears it. +/// - `pause` (PauserCap): set paused, push `pauseExpiry` to `now + PAUSE_DURATION` +/// (5 days). Repeatable; each call extends the window. NEVER reduces a +/// `pauseExpiry` already further in the future (a lower-trust pauser cannot +/// curtail a freeze). Not idempotent. +/// - `freeze_bridge` (FreezerCap): set paused, set `pauseExpiry` to the maximum +/// timestamp. The higher-trust counterpart; a frozen bridge is not +/// permissionlessly unpausable in practice and can only be lifted by the +/// unpauser. Idempotent. (Named `freeze_bridge` because `freeze` is reserved +/// in Move; it is the spec's `freeze`.) +/// - `unpause` (UnpauserCap): clear paused, set `pauseExpiry` to `now`. The +/// privileged path to lift any pause (including a freeze) early. Reverts if +/// not currently paused. Recording `now` (rather than 0) leaves on-chain +/// evidence and brings a stale freeze expiry down so a later `pause` works. +/// - `unpause_expired` (PERMISSIONLESS, no cap): clear paused once +/// `now >= pauseExpiry`. Bounds a pauser-initiated pause to PAUSE_DURATION +/// without requiring the unpauser to act. +/// +/// Time comes from the shared `sui::clock::Clock` object (there is no +/// `block.timestamp` in Move), so the entry points take a `&Clock`. +/// /// Lifecycle (governance-driven mint): /// - Capabilities are minted ONLY by `token_bridge::set_pauser_addresses` while /// handling a `SetPauserAddresses` governance VAA. The VAA encodes the OWNER -/// address that should receive the cap. The handler mints the cap, transfers +/// address that should receive each cap. The handler mints the cap, transfers /// it to that owner, and records the new cap's id as active in `State`. /// - Because the handler mints and transfers, the active cap is ALWAYS an owned -/// object — it can never be a shared object, so `pause`/`unpause` (which take +/// object — it can never be a shared object, so the entry points (which take /// `&cap`) cannot be invoked by anyone other than the cap's owner. -/// - Rotation, two ways: -/// 1. Governance mints a NEW cap for a new owner via `SetPauserAddresses`; -/// the previously active cap becomes inert (its id no longer matches the -/// recorded active id) — no clawback required. -/// 2. The current holder transfers the active cap directly (the cap has -/// `store`, so this needs no governance). The cap keeps its id, so it -/// stays active — authority simply moves with the object. If a cap is -/// compromised, governance can always revoke it via path 1. -/// - Unassign: a zero owner records `none` as active and mints nothing; the +/// - Rotation, two ways: (1) governance mints a NEW cap for a new owner, +/// deprecating the old one (its id no longer matches); (2) the holder +/// transfers the active cap directly (it has `store`), authority moving with +/// the object. A compromised cap is always revocable via path (1). +/// - Unassign: a `none` owner records `none` as active and mints nothing; the /// corresponding entry point then reverts as not-configured. -/// -/// Neither `pause` nor `unpause` is guarded by `assert_not_paused` (both must be -/// callable regardless of pause state — `pause` is a no-op when already paused, -/// `unpause` must obviously work when paused). module token_bridge::pause { use std::option::{Self}; + use sui::clock::{Self, Clock}; use sui::object::{Self, ID, UID}; use sui::tx_context::{Self, TxContext}; @@ -54,6 +73,19 @@ module token_bridge::pause { const E_NOT_PAUSER: u64 = 2; /// Provided `UnpauserCap` is not the active unpauser. const E_NOT_UNPAUSER: u64 = 3; + /// The freezer role is unassigned (active id is `none`). + const E_FREEZER_NOT_CONFIGURED: u64 = 4; + /// Provided `FreezerCap` is not the active freezer. + const E_NOT_FREEZER: u64 = 5; + /// `unpause`/`unpause_expired` called while the bridge is not paused. + const E_NOT_PAUSED: u64 = 6; + /// `unpause_expired` called before `pauseExpiry`. + const E_NOT_EXPIRED: u64 = 7; + + /// Temporary-pause duration: 5 days, in milliseconds (Sui Clock is ms). + const PAUSE_DURATION_MS: u64 = 432_000_000; + /// Maximum representable timestamp; used by `freeze_bridge`. + const MAX_TIMESTAMP_MS: u64 = 0xFFFFFFFFFFFFFFFF; /// Capability whose holder may `pause` the bridge, while its id is the /// active pauser. Minted and transferred by governance. @@ -61,21 +93,41 @@ module token_bridge::pause { id: UID } + /// Capability whose holder may `freeze` the bridge, while its id is the + /// active freezer. Minted and transferred by governance. + struct FreezerCap has key, store { + id: UID + } + /// Capability whose holder may `unpause` the bridge, while its id is the /// active unpauser. Minted and transferred by governance. struct UnpauserCap has key, store { id: UID } - /// Event emitted when the bridge is paused. + /// Event emitted when the bridge is paused via `pause`. struct Paused has drop, copy { /// Object id of the cap used to pause. cap: ID, /// Transaction signer (for audit; not the authority). - sender: address + sender: address, + /// Timestamp (ms) at which the pause becomes permissionlessly liftable. + pause_expiry: u64 } - /// Event emitted when the bridge is unpaused. + /// Event emitted when the bridge is frozen via `freeze_bridge`. + struct Frozen has drop, copy { + /// Object id of the cap used to freeze. + cap: ID, + /// Transaction signer (for audit; not the authority). + sender: address, + /// Timestamp (ms) the pause runs until — the maximum timestamp for a + /// freeze. Included so "paused until X" events are uniform across + /// `Paused`/`Frozen`. + pause_expiry: u64 + } + + /// Event emitted when the bridge is unpaused via `unpause`. struct Unpaused has drop, copy { /// Object id of the cap used to unpause. cap: ID, @@ -83,6 +135,13 @@ module token_bridge::pause { sender: address } + /// Event emitted when the bridge is unpaused via the permissionless + /// `unpause_expired`. + struct UnpauseExpired has drop, copy { + /// Transaction signer that lifted the expired pause. + sender: address + } + /// Mint a new `PauserCap`. Only callable by `set_pauser_addresses` while /// handling a governance VAA. The handler is responsible for transferring /// the cap to its owner and recording its id as active. @@ -90,6 +149,11 @@ module token_bridge::pause { PauserCap { id: object::new(ctx) } } + /// Mint a new `FreezerCap`. Only callable by `set_pauser_addresses`. + public(friend) fun new_freezer_cap(ctx: &mut TxContext): FreezerCap { + FreezerCap { id: object::new(ctx) } + } + /// Mint a new `UnpauserCap`. Only callable by `set_pauser_addresses`. public(friend) fun new_unpauser_cap(ctx: &mut TxContext): UnpauserCap { UnpauserCap { id: object::new(ctx) } @@ -101,6 +165,11 @@ module token_bridge::pause { object::id(cap) } + /// The object id of a `FreezerCap`. + public fun freezer_cap_id(cap: &FreezerCap): ID { + object::id(cap) + } + /// The object id of an `UnpauserCap`. public fun unpauser_cap_id(cap: &UnpauserCap): ID { object::id(cap) @@ -114,20 +183,29 @@ module token_bridge::pause { object::delete(id); } + /// Destroy a `FreezerCap`. + public fun destroy_freezer_cap(cap: FreezerCap) { + let FreezerCap { id } = cap; + object::delete(id); + } + /// Destroy an `UnpauserCap`. public fun destroy_unpauser_cap(cap: UnpauserCap) { let UnpauserCap { id } = cap; object::delete(id); } - /// Pause the token bridge. Requires the active `PauserCap`. - /// Aborts if the pauser role is unassigned. + /// Temporarily pause the token bridge. Requires the active `PauserCap`. + /// Sets `paused` and pushes `pauseExpiry` to `now + PAUSE_DURATION` (5 days), + /// never reducing an expiry already further in the future (so a lower-trust + /// pauser cannot curtail a freeze). Not idempotent — each call extends the + /// window. Aborts if the pauser role is unassigned. public fun pause( token_bridge_state: &mut State, cap: &PauserCap, + clock: &Clock, ctx: &TxContext ) { - // Version check. let latest_only = state::assert_latest_only(token_bridge_state); let configured = state::pauser(token_bridge_state); @@ -136,20 +214,59 @@ module token_bridge::pause { let cap_id = object::id(cap); assert!(cap_id == option::destroy_some(configured), E_NOT_PAUSER); + let new_expiry = clock::timestamp_ms(clock) + PAUSE_DURATION_MS; + // Never reduce an expiry already further out (e.g. one set by `freeze`). + if (new_expiry > state::pause_expiry(token_bridge_state)) { + state::set_pause_expiry(&latest_only, token_bridge_state, new_expiry); + }; state::set_paused(&latest_only, token_bridge_state, true); - sui::event::emit(Paused { cap: cap_id, sender: tx_context::sender(ctx) }); + sui::event::emit(Paused { + cap: cap_id, + sender: tx_context::sender(ctx), + pause_expiry: state::pause_expiry(token_bridge_state) + }); } - /// Unpause the token bridge. Requires the active `UnpauserCap`. - /// Aborts if the unpauser role is unassigned. - /// NOT guarded by assert_not_paused (must be callable when paused). + /// Freeze the token bridge for the maximum duration. Requires the active + /// `FreezerCap`. Sets `paused` and `pauseExpiry` to the maximum timestamp. + /// Idempotent. Aborts if the freezer role is unassigned. + /// + /// Named `freeze_bridge` because `freeze` is a reserved word in Move; it is + /// the spec's `freeze`. + public fun freeze_bridge( + token_bridge_state: &mut State, + cap: &FreezerCap, + ctx: &TxContext + ) { + let latest_only = state::assert_latest_only(token_bridge_state); + + let configured = state::freezer(token_bridge_state); + assert!(option::is_some(&configured), E_FREEZER_NOT_CONFIGURED); + + let cap_id = object::id(cap); + assert!(cap_id == option::destroy_some(configured), E_NOT_FREEZER); + + state::set_pause_expiry(&latest_only, token_bridge_state, MAX_TIMESTAMP_MS); + state::set_paused(&latest_only, token_bridge_state, true); + + sui::event::emit(Frozen { + cap: cap_id, + sender: tx_context::sender(ctx), + pause_expiry: MAX_TIMESTAMP_MS + }); + } + + /// Unpause the token bridge. Requires the active `UnpauserCap`. Clears + /// `paused` and sets `pauseExpiry` to `now`. The privileged path to lift any + /// pause (including a freeze) early. Aborts if the unpauser role is + /// unassigned or the bridge is not currently paused. public fun unpause( token_bridge_state: &mut State, cap: &UnpauserCap, + clock: &Clock, ctx: &TxContext ) { - // Version check. let latest_only = state::assert_latest_only(token_bridge_state); let configured = state::unpauser(token_bridge_state); @@ -158,16 +275,49 @@ module token_bridge::pause { let cap_id = object::id(cap); assert!(cap_id == option::destroy_some(configured), E_NOT_UNPAUSER); + assert!(state::is_paused(token_bridge_state), E_NOT_PAUSED); + + state::set_pause_expiry( + &latest_only, + token_bridge_state, + clock::timestamp_ms(clock) + ); state::set_paused(&latest_only, token_bridge_state, false); sui::event::emit(Unpaused { cap: cap_id, sender: tx_context::sender(ctx) }); } + /// Permissionlessly unpause the token bridge once its pause has expired. + /// Clears `paused` and sets `pauseExpiry` to `now`. No capability required. + /// Aborts if the bridge is not currently paused or `now < pauseExpiry`. + public fun unpause_expired( + token_bridge_state: &mut State, + clock: &Clock, + ctx: &TxContext + ) { + let latest_only = state::assert_latest_only(token_bridge_state); + + assert!(state::is_paused(token_bridge_state), E_NOT_PAUSED); + + let now = clock::timestamp_ms(clock); + assert!(now >= state::pause_expiry(token_bridge_state), E_NOT_EXPIRED); + + state::set_pause_expiry(&latest_only, token_bridge_state, now); + state::set_paused(&latest_only, token_bridge_state, false); + + sui::event::emit(UnpauseExpired { sender: tx_context::sender(ctx) }); + } + #[test_only] public fun new_pauser_cap_test_only(ctx: &mut TxContext): PauserCap { new_pauser_cap(ctx) } + #[test_only] + public fun new_freezer_cap_test_only(ctx: &mut TxContext): FreezerCap { + new_freezer_cap(ctx) + } + #[test_only] public fun new_unpauser_cap_test_only(ctx: &mut TxContext): UnpauserCap { new_unpauser_cap(ctx) @@ -178,8 +328,23 @@ module token_bridge::pause { destroy_pauser_cap(cap); } + #[test_only] + public fun destroy_freezer_cap_test_only(cap: FreezerCap) { + destroy_freezer_cap(cap); + } + #[test_only] public fun destroy_unpauser_cap_test_only(cap: UnpauserCap) { destroy_unpauser_cap(cap); } + + #[test_only] + public fun pause_duration_ms(): u64 { + PAUSE_DURATION_MS + } + + #[test_only] + public fun max_timestamp_ms(): u64 { + MAX_TIMESTAMP_MS + } } diff --git a/sui/token_bridge/sources/state.move b/sui/token_bridge/sources/state.move index 49403607716..01235d779e1 100644 --- a/sui/token_bridge/sources/state.move +++ b/sui/token_bridge/sources/state.move @@ -51,8 +51,13 @@ module token_bridge::state { /// Dynamic field key for the `paused` boolean. struct PausedKey has copy, drop, store {} + /// Dynamic field key for the pause expiry timestamp (ms). The point at + /// which an active pause becomes eligible to be lifted permissionlessly. + struct PauseExpiryKey has copy, drop, store {} /// Dynamic field key for the designated pauser capability id. struct PauserKey has copy, drop, store {} + /// Dynamic field key for the designated freezer capability id. + struct FreezerKey has copy, drop, store {} /// Dynamic field key for the designated unpauser capability id. struct UnpauserKey has copy, drop, store {} @@ -173,6 +178,18 @@ module token_bridge::state { } } + /// Returns the pause expiry timestamp (ms): the point at which an active + /// pause becomes eligible to be lifted permissionlessly via + /// `pause::unpause_expired`. Returns `0` if unset (also `0` for pre-pause + /// state). See `token_bridge::pause`. + public fun pause_expiry(self: &State): u64 { + if (field::exists_(&self.id, PauseExpiryKey {})) { + *field::borrow(&self.id, PauseExpiryKey {}) + } else { + 0 + } + } + /// Returns the active pauser capability's object id, or `none` if unset /// (also `none` for pre-pause state). See `token_bridge::pause`. public fun pauser(self: &State): Option { @@ -183,6 +200,16 @@ module token_bridge::state { } } + /// Returns the active freezer capability's object id, or `none` if unset. + /// See `token_bridge::pause`. + public fun freezer(self: &State): Option { + if (field::exists_(&self.id, FreezerKey {})) { + *field::borrow>(&self.id, FreezerKey {}) + } else { + option::none() + } + } + /// Returns the active unpauser capability's object id, or `none` if unset. /// See `token_bridge::pause`. public fun unpauser(self: &State): Option { @@ -361,6 +388,22 @@ module token_bridge::state { } } + /// Set the pause expiry timestamp (ms). Requires `LatestOnly`. + public(friend) fun set_pause_expiry( + _: &LatestOnly, + self: &mut State, + expiry: u64 + ) { + if (field::exists_(&self.id, PauseExpiryKey {})) { + *field::borrow_mut( + &mut self.id, + PauseExpiryKey {} + ) = expiry; + } else { + field::add(&mut self.id, PauseExpiryKey {}, expiry); + } + } + /// Set the active pauser capability id (`none` to unassign). /// Requires `LatestOnly`. public(friend) fun set_pauser( @@ -378,6 +421,23 @@ module token_bridge::state { } } + /// Set the active freezer capability id (`none` to unassign). + /// Requires `LatestOnly`. + public(friend) fun set_freezer( + _: &LatestOnly, + self: &mut State, + new_freezer: Option + ) { + if (field::exists_(&self.id, FreezerKey {})) { + *field::borrow_mut>( + &mut self.id, + FreezerKey {} + ) = new_freezer; + } else { + field::add(&mut self.id, FreezerKey {}, new_freezer); + } + } + /// Set the active unpauser capability id (`none` to unassign). /// Requires `LatestOnly`. public(friend) fun set_unpauser( @@ -478,7 +538,7 @@ module token_bridge::state { /// to expose this method as a public method. public(friend) fun migrate__v__0_3_0(self: &mut State) { // Initialize pause dynamic fields with defaults: - // paused = false, pauser = none, unpauser = none (roles unassigned). + // paused = false, pauseExpiry = 0, pauser/freezer/unpauser = none. // // The `exists_` guard is required (NOT redundant): `migrate` calls this // handler BEFORE bumping the version in `handle_migrate`. So the version @@ -489,7 +549,9 @@ module token_bridge::state { // `migrate_tests::test_cannot_migrate_again`). if (!field::exists_(&self.id, PausedKey {})) { field::add(&mut self.id, PausedKey {}, false); + field::add(&mut self.id, PauseExpiryKey {}, 0u64); field::add(&mut self.id, PauserKey {}, option::none()); + field::add(&mut self.id, FreezerKey {}, option::none()); field::add(&mut self.id, UnpauserKey {}, option::none()); }; } @@ -510,7 +572,9 @@ module token_bridge::state { public fun init_pause_state_test_only(self: &mut State) { if (!field::exists_(&self.id, PausedKey {})) { field::add(&mut self.id, PausedKey {}, false); + field::add(&mut self.id, PauseExpiryKey {}, 0u64); field::add(&mut self.id, PauserKey {}, option::none()); + field::add(&mut self.id, FreezerKey {}, option::none()); field::add(&mut self.id, UnpauserKey {}, option::none()); } } diff --git a/sui/token_bridge/sources/test/pause_tests.move b/sui/token_bridge/sources/test/pause_tests.move index 3fc56d71cf1..6ed8adaeb19 100644 --- a/sui/token_bridge/sources/test/pause_tests.move +++ b/sui/token_bridge/sources/test/pause_tests.move @@ -4,11 +4,12 @@ module token_bridge::pause_tests { use std::option::{Self}; use std::vector::{Self}; - use sui::test_scenario::{Self}; + use sui::clock::{Self, Clock}; + use sui::test_scenario::{Self, Scenario}; - use token_bridge::pause::{Self, PauserCap, UnpauserCap}; + use token_bridge::pause::{Self, PauserCap, FreezerCap, UnpauserCap}; use token_bridge::set_pauser_addresses::{Self}; - use token_bridge::state::{Self}; + use token_bridge::state::{Self, State}; use token_bridge::token_bridge_scenario::{ person, return_state, @@ -17,6 +18,26 @@ module token_bridge::pause_tests { three_people }; + const WORMHOLE_FEE: u64 = 350; + + // ------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------ + + /// A fresh Clock for testing (starts at timestamp 0). + fun new_clock(scenario: &mut Scenario): Clock { + clock::create_for_testing(test_scenario::ctx(scenario)) + } + + /// Set up wormhole + token bridge, init pause state, return State at `caller`. + fun set_up(scenario: &mut Scenario, caller: address): State { + set_up_wormhole_and_token_bridge(scenario, WORMHOLE_FEE); + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + state::init_pause_state_test_only(&mut token_bridge_state); + token_bridge_state + } + // ======================================================================== // Pause State Initialization // ======================================================================== @@ -26,19 +47,13 @@ module token_bridge::pause_tests { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - - // Initialize pause state (simulating migration). - state::init_pause_state_test_only(&mut token_bridge_state); - - // Default state: not paused, pauser/unpauser unassigned (none). + // Default: not paused, expiry 0, all roles unassigned. assert!(!state::is_paused(&token_bridge_state), 0); + assert!(state::pause_expiry(&token_bridge_state) == 0, 0); assert!(option::is_none(&state::pauser(&token_bridge_state)), 0); + assert!(option::is_none(&state::freezer(&token_bridge_state)), 0); assert!(option::is_none(&state::unpauser(&token_bridge_state)), 0); return_state(token_bridge_state); @@ -46,20 +61,20 @@ module token_bridge::pause_tests { } #[test] - fun test_is_paused_returns_false_before_init() { + fun test_pre_init_defaults() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - + set_up_wormhole_and_token_bridge(scenario, WORMHOLE_FEE); test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); - // Before pause state init, is_paused returns false (backwards compat). + // Before init, getters return safe defaults (backwards compat). assert!(!state::is_paused(&token_bridge_state), 0); + assert!(state::pause_expiry(&token_bridge_state) == 0, 0); assert!(option::is_none(&state::pauser(&token_bridge_state)), 0); + assert!(option::is_none(&state::freezer(&token_bridge_state)), 0); assert!(option::is_none(&state::unpauser(&token_bridge_state)), 0); return_state(token_bridge_state); @@ -71,82 +86,80 @@ module token_bridge::pause_tests { // ======================================================================== #[test] - fun test_set_pauser_addresses_mints_and_records() { + fun test_set_pauser_addresses_mints_three_roles() { let (caller, pauser_owner, unpauser_owner) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + // Use `caller` as the freezer owner (distinct from the other two). + let freezer_owner = caller; - let (pauser_id, unpauser_id) = + let (pauser_id, freezer_id, unpauser_id) = set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, option::some(pauser_owner), + option::some(freezer_owner), option::some(unpauser_owner), test_scenario::ctx(scenario) ); - // Recorded ids are the minted caps' ids (present). assert!(option::is_some(&pauser_id), 0); + assert!(option::is_some(&freezer_id), 0); assert!(option::is_some(&unpauser_id), 0); assert!(state::pauser(&token_bridge_state) == pauser_id, 0); + assert!(state::freezer(&token_bridge_state) == freezer_id, 0); assert!(state::unpauser(&token_bridge_state) == unpauser_id, 0); return_state(token_bridge_state); - // Caps were transferred to the owners. + // Caps landed with their owners. test_scenario::next_tx(scenario, pauser_owner); - let pauser_cap = test_scenario::take_from_address(scenario, pauser_owner); - assert!(option::contains(&pauser_id, &pause::pauser_cap_id(&pauser_cap)), 0); - test_scenario::return_to_address(pauser_owner, pauser_cap); + let pc = test_scenario::take_from_address(scenario, pauser_owner); + assert!(option::contains(&pauser_id, &pause::pauser_cap_id(&pc)), 0); + test_scenario::return_to_address(pauser_owner, pc); + + test_scenario::next_tx(scenario, freezer_owner); + let fc = test_scenario::take_from_address(scenario, freezer_owner); + assert!(option::contains(&freezer_id, &pause::freezer_cap_id(&fc)), 0); + test_scenario::return_to_address(freezer_owner, fc); test_scenario::next_tx(scenario, unpauser_owner); - let unpauser_cap = - test_scenario::take_from_address(scenario, unpauser_owner); - assert!(option::contains(&unpauser_id, &pause::unpauser_cap_id(&unpauser_cap)), 0); - test_scenario::return_to_address(unpauser_owner, unpauser_cap); + let uc = test_scenario::take_from_address(scenario, unpauser_owner); + assert!(option::contains(&unpauser_id, &pause::unpauser_cap_id(&uc)), 0); + test_scenario::return_to_address(unpauser_owner, uc); test_scenario::end(my_scenario); } #[test] - fun test_set_pauser_to_none_unassigns() { + fun test_set_to_none_unassigns_all() { let (caller, pauser_owner, unpauser_owner) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); - - // Assign owners. set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, option::some(pauser_owner), + option::some(caller), option::some(unpauser_owner), test_scenario::ctx(scenario) ); - // Unassign by setting owners to none (mints nothing). - let (pauser_id, unpauser_id) = - set_pauser_addresses::set_pauser_addresses_test_only( - &mut token_bridge_state, - option::none(), - option::none(), - test_scenario::ctx(scenario) - ); + let (p, f, u) = set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::none(), + option::none(), + option::none(), + test_scenario::ctx(scenario) + ); - assert!(option::is_none(&pauser_id), 0); - assert!(option::is_none(&unpauser_id), 0); + assert!(option::is_none(&p), 0); + assert!(option::is_none(&f), 0); + assert!(option::is_none(&u), 0); assert!(option::is_none(&state::pauser(&token_bridge_state)), 0); + assert!(option::is_none(&state::freezer(&token_bridge_state)), 0); assert!(option::is_none(&state::unpauser(&token_bridge_state)), 0); return_state(token_bridge_state); @@ -154,329 +167,670 @@ module token_bridge::pause_tests { } // ======================================================================== - // Pause / Unpause (capability-gated) + // pause() — temporary, timed // ======================================================================== #[test] - fun test_pauser_cap_can_pause() { + fun test_pause_sets_paused_and_expiry() { let (caller, pauser_owner, _u) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; - - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, option::some(pauser_owner), option::none(), + option::none(), test_scenario::ctx(scenario) ); return_state(token_bridge_state); - // The owner retrieves its cap and pauses. test_scenario::next_tx(scenario, pauser_owner); let token_bridge_state = take_state(scenario); - let pauser_cap = test_scenario::take_from_address(scenario, pauser_owner); + let cap = test_scenario::take_from_address(scenario, pauser_owner); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 1_000); - pause::pause( - &mut token_bridge_state, - &pauser_cap, - test_scenario::ctx(scenario) - ); + pause::pause(&mut token_bridge_state, &cap, &the_clock, test_scenario::ctx(scenario)); assert!(state::is_paused(&token_bridge_state), 0); + assert!( + state::pause_expiry(&token_bridge_state) == 1_000 + pause::pause_duration_ms(), + 0 + ); - test_scenario::return_to_address(pauser_owner, pauser_cap); + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(pauser_owner, cap); return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] - fun test_unpauser_cap_can_unpause() { - let (caller, _p, unpauser_owner) = three_people(); + fun test_pause_pushes_expiry_forward() { + let (caller, pauser_owner, _u) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; - - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, + option::some(pauser_owner), + option::none(), option::none(), - option::some(unpauser_owner), test_scenario::ctx(scenario) ); - state::set_paused_test_only(&mut token_bridge_state, true); - assert!(state::is_paused(&token_bridge_state), 0); return_state(token_bridge_state); - test_scenario::next_tx(scenario, unpauser_owner); + test_scenario::next_tx(scenario, pauser_owner); let token_bridge_state = take_state(scenario); - let unpauser_cap = - test_scenario::take_from_address(scenario, unpauser_owner); + let cap = test_scenario::take_from_address(scenario, pauser_owner); + let the_clock = new_clock(scenario); - pause::unpause( - &mut token_bridge_state, - &unpauser_cap, - test_scenario::ctx(scenario) - ); + clock::set_for_testing(&mut the_clock, 1_000); + pause::pause(&mut token_bridge_state, &cap, &the_clock, test_scenario::ctx(scenario)); + let first_expiry = state::pause_expiry(&token_bridge_state); - assert!(!state::is_paused(&token_bridge_state), 0); + // Advance time and re-pause: expiry should move forward. + clock::set_for_testing(&mut the_clock, 100_000); + pause::pause(&mut token_bridge_state, &cap, &the_clock, test_scenario::ctx(scenario)); + let second_expiry = state::pause_expiry(&token_bridge_state); + + assert!(second_expiry > first_expiry, 0); + assert!(second_expiry == 100_000 + pause::pause_duration_ms(), 0); - test_scenario::return_to_address(unpauser_owner, unpauser_cap); + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(pauser_owner, cap); return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] - fun test_pause_is_idempotent() { + fun test_pause_does_not_reduce_freeze_expiry() { + // freeze sets max expiry; a subsequent pause must NOT pull it down. let (caller, pauser_owner, _u) = three_people(); + let freezer_owner = caller; let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; - - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, option::some(pauser_owner), + option::some(freezer_owner), option::none(), test_scenario::ctx(scenario) ); return_state(token_bridge_state); + // Freeze first. + test_scenario::next_tx(scenario, freezer_owner); + let token_bridge_state = take_state(scenario); + let fc = test_scenario::take_from_address(scenario, freezer_owner); + pause::freeze_bridge(&mut token_bridge_state, &fc, test_scenario::ctx(scenario)); + assert!(state::pause_expiry(&token_bridge_state) == pause::max_timestamp_ms(), 0); + test_scenario::return_to_address(freezer_owner, fc); + return_state(token_bridge_state); + + // Pause: expiry must remain max (not reduced to now + 5d). test_scenario::next_tx(scenario, pauser_owner); let token_bridge_state = take_state(scenario); - let pauser_cap = test_scenario::take_from_address(scenario, pauser_owner); + let pc = test_scenario::take_from_address(scenario, pauser_owner); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 1_000); + pause::pause(&mut token_bridge_state, &pc, &the_clock, test_scenario::ctx(scenario)); - // Pause twice — should not revert. - pause::pause(&mut token_bridge_state, &pauser_cap, test_scenario::ctx(scenario)); - assert!(state::is_paused(&token_bridge_state), 0); - pause::pause(&mut token_bridge_state, &pauser_cap, test_scenario::ctx(scenario)); + assert!(state::pause_expiry(&token_bridge_state) == pause::max_timestamp_ms(), 0); assert!(state::is_paused(&token_bridge_state), 0); - test_scenario::return_to_address(pauser_owner, pauser_cap); + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(pauser_owner, pc); return_state(token_bridge_state); test_scenario::end(my_scenario); } + // ======================================================================== + // freeze_bridge() + // ======================================================================== + #[test] - fun test_unpause_is_idempotent() { - let (caller, _p, unpauser_owner) = three_people(); + fun test_freeze_sets_max_expiry() { + let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; - - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + let token_bridge_state = set_up(scenario, caller); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::none(), + option::some(caller), + option::none(), + test_scenario::ctx(scenario) + ); + return_state(token_bridge_state); test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + let fc = test_scenario::take_from_address(scenario, caller); + + pause::freeze_bridge(&mut token_bridge_state, &fc, test_scenario::ctx(scenario)); + assert!(state::is_paused(&token_bridge_state), 0); + assert!(state::pause_expiry(&token_bridge_state) == pause::max_timestamp_ms(), 0); + + // Idempotent: freezing again is a no-op effect. + pause::freeze_bridge(&mut token_bridge_state, &fc, test_scenario::ctx(scenario)); + assert!(state::pause_expiry(&token_bridge_state) == pause::max_timestamp_ms(), 0); + + test_scenario::return_to_address(caller, fc); + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // unpause() + // ======================================================================== + + #[test] + fun test_unpause_clears_and_sets_expiry_now() { + let (caller, _p, unpauser_owner) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, option::none(), + option::none(), option::some(unpauser_owner), test_scenario::ctx(scenario) ); + // Start paused. + state::set_paused_test_only(&mut token_bridge_state, true); return_state(token_bridge_state); test_scenario::next_tx(scenario, unpauser_owner); let token_bridge_state = take_state(scenario); - let unpauser_cap = - test_scenario::take_from_address(scenario, unpauser_owner); + let uc = test_scenario::take_from_address(scenario, unpauser_owner); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 5_000); + + pause::unpause(&mut token_bridge_state, &uc, &the_clock, test_scenario::ctx(scenario)); - // Unpause twice — should not revert (already unpaused). - pause::unpause(&mut token_bridge_state, &unpauser_cap, test_scenario::ctx(scenario)); - assert!(!state::is_paused(&token_bridge_state), 0); - pause::unpause(&mut token_bridge_state, &unpauser_cap, test_scenario::ctx(scenario)); assert!(!state::is_paused(&token_bridge_state), 0); + // Expiry set to now (so a later pause is not blocked by a stale value). + assert!(state::pause_expiry(&token_bridge_state) == 5_000, 0); - test_scenario::return_to_address(unpauser_owner, unpauser_cap); + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(unpauser_owner, uc); return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] - fun test_rotate_deprecates_old_cap() { - let (caller, owner_a, owner_b) = three_people(); + fun test_unpause_after_freeze_then_pause_works() { + // freeze -> unpause (expiry=now) -> pause must succeed and set a normal + // 5-day expiry (the stale max expiry must not linger). + let (caller, pauser_owner, unpauser_owner) = three_people(); + let freezer_owner = caller; let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; - - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + let token_bridge_state = set_up(scenario, caller); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::some(pauser_owner), + option::some(freezer_owner), + option::some(unpauser_owner), + test_scenario::ctx(scenario) + ); + return_state(token_bridge_state); test_scenario::next_tx(scenario, caller); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 10_000); + + // Freeze. + test_scenario::next_tx(scenario, freezer_owner); let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + let fc = test_scenario::take_from_address(scenario, freezer_owner); + pause::freeze_bridge(&mut token_bridge_state, &fc, test_scenario::ctx(scenario)); + test_scenario::return_to_address(freezer_owner, fc); + return_state(token_bridge_state); - // Assign owner_a. + // Unpause (expiry -> now = 10_000). + test_scenario::next_tx(scenario, unpauser_owner); + let token_bridge_state = take_state(scenario); + let uc = test_scenario::take_from_address(scenario, unpauser_owner); + pause::unpause(&mut token_bridge_state, &uc, &the_clock, test_scenario::ctx(scenario)); + assert!(state::pause_expiry(&token_bridge_state) == 10_000, 0); + test_scenario::return_to_address(unpauser_owner, uc); + return_state(token_bridge_state); + + // Pause now sets a normal 5-day expiry (10_000 + duration > 10_000). + test_scenario::next_tx(scenario, pauser_owner); + let token_bridge_state = take_state(scenario); + let pc = test_scenario::take_from_address(scenario, pauser_owner); + pause::pause(&mut token_bridge_state, &pc, &the_clock, test_scenario::ctx(scenario)); + assert!(state::is_paused(&token_bridge_state), 0); + assert!( + state::pause_expiry(&token_bridge_state) == 10_000 + pause::pause_duration_ms(), + 0 + ); + test_scenario::return_to_address(pauser_owner, pc); + return_state(token_bridge_state); + + clock::destroy_for_testing(the_clock); + test_scenario::end(my_scenario); + } + + #[test] + #[expected_failure(abort_code = pause::E_NOT_PAUSED)] + fun test_unpause_reverts_when_not_paused() { + let (caller, _p, unpauser_owner) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - option::some(owner_a), option::none(), + option::none(), + option::some(unpauser_owner), test_scenario::ctx(scenario) ); return_state(token_bridge_state); - // owner_a's cap can pause. - test_scenario::next_tx(scenario, owner_a); + test_scenario::next_tx(scenario, unpauser_owner); let token_bridge_state = take_state(scenario); - let cap_a = test_scenario::take_from_address(scenario, owner_a); - pause::pause(&mut token_bridge_state, &cap_a, test_scenario::ctx(scenario)); - assert!(state::is_paused(&token_bridge_state), 0); - state::set_paused_test_only(&mut token_bridge_state, false); + let uc = test_scenario::take_from_address(scenario, unpauser_owner); + let the_clock = new_clock(scenario); + + // Not paused — must revert. + pause::unpause(&mut token_bridge_state, &uc, &the_clock, test_scenario::ctx(scenario)); + + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(unpauser_owner, uc); + return_state(token_bridge_state); + abort 42 + } - // Rotate: governance mints a new cap for owner_b, deprecating cap_a. + // ======================================================================== + // unpause_expired() — permissionless + // ======================================================================== + + #[test] + fun test_unpause_expired_after_expiry() { + let (caller, pauser_owner, _u) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - option::some(owner_b), + option::some(pauser_owner), + option::none(), option::none(), test_scenario::ctx(scenario) ); - test_scenario::return_to_address(owner_a, cap_a); return_state(token_bridge_state); - // owner_b's new cap can pause. - test_scenario::next_tx(scenario, owner_b); + // Pause at t=1000 (expiry = 1000 + 5d). + test_scenario::next_tx(scenario, pauser_owner); let token_bridge_state = take_state(scenario); - let cap_b = test_scenario::take_from_address(scenario, owner_b); - pause::pause(&mut token_bridge_state, &cap_b, test_scenario::ctx(scenario)); - assert!(state::is_paused(&token_bridge_state), 0); + let pc = test_scenario::take_from_address(scenario, pauser_owner); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 1_000); + pause::pause(&mut token_bridge_state, &pc, &the_clock, test_scenario::ctx(scenario)); + test_scenario::return_to_address(pauser_owner, pc); + return_state(token_bridge_state); - test_scenario::return_to_address(owner_b, cap_b); + // Advance past expiry; anyone (caller != pauser) can unpause. + clock::set_for_testing(&mut the_clock, 1_000 + pause::pause_duration_ms() + 1); + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + pause::unpause_expired(&mut token_bridge_state, &the_clock, test_scenario::ctx(scenario)); + assert!(!state::is_paused(&token_bridge_state), 0); + + clock::destroy_for_testing(the_clock); return_state(token_bridge_state); test_scenario::end(my_scenario); } #[test] - #[expected_failure(abort_code = pause::E_NOT_PAUSER)] - fun test_deprecated_cap_cannot_pause() { - let (caller, owner_a, owner_b) = three_people(); + fun test_unpause_expired_at_exact_expiry() { + // Boundary: now == pauseExpiry must succeed (the guard is `>=`). + let (caller, pauser_owner, _u) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::some(pauser_owner), + option::none(), + option::none(), + test_scenario::ctx(scenario) + ); + return_state(token_bridge_state); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + test_scenario::next_tx(scenario, pauser_owner); + let token_bridge_state = take_state(scenario); + let pc = test_scenario::take_from_address(scenario, pauser_owner); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 1_000); + pause::pause(&mut token_bridge_state, &pc, &the_clock, test_scenario::ctx(scenario)); + let expiry = state::pause_expiry(&token_bridge_state); + test_scenario::return_to_address(pauser_owner, pc); + return_state(token_bridge_state); + // Set time to exactly the expiry; unpause_expired must succeed. + clock::set_for_testing(&mut the_clock, expiry); test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + pause::unpause_expired(&mut token_bridge_state, &the_clock, test_scenario::ctx(scenario)); + assert!(!state::is_paused(&token_bridge_state), 0); + + clock::destroy_for_testing(the_clock); + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } - // Assign owner_a, then rotate to owner_b (deprecating cap_a). + #[test] + #[expected_failure(abort_code = pause::E_NOT_EXPIRED)] + fun test_unpause_expired_reverts_before_expiry() { + let (caller, pauser_owner, _u) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - option::some(owner_a), + option::some(pauser_owner), + option::none(), option::none(), test_scenario::ctx(scenario) ); + return_state(token_bridge_state); + + test_scenario::next_tx(scenario, pauser_owner); + let token_bridge_state = take_state(scenario); + let pc = test_scenario::take_from_address(scenario, pauser_owner); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 1_000); + pause::pause(&mut token_bridge_state, &pc, &the_clock, test_scenario::ctx(scenario)); + test_scenario::return_to_address(pauser_owner, pc); + return_state(token_bridge_state); + + // Still within the window — must revert. + clock::set_for_testing(&mut the_clock, 2_000); + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + + pause::unpause_expired(&mut token_bridge_state, &the_clock, test_scenario::ctx(scenario)); + + clock::destroy_for_testing(the_clock); + return_state(token_bridge_state); + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_NOT_PAUSED)] + fun test_unpause_expired_reverts_when_not_paused() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + let the_clock = new_clock(scenario); + + // Not paused — must revert (even though now >= expiry==0). + pause::unpause_expired(&mut token_bridge_state, &the_clock, test_scenario::ctx(scenario)); + + clock::destroy_for_testing(the_clock); + return_state(token_bridge_state); + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_NOT_EXPIRED)] + fun test_unpause_expired_cannot_lift_freeze() { + // freeze sets expiry to max, so unpause_expired can never lift it. + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - option::some(owner_b), + option::none(), + option::some(caller), option::none(), test_scenario::ctx(scenario) ); return_state(token_bridge_state); - // owner_a's now-deprecated cap must fail. - test_scenario::next_tx(scenario, owner_a); + test_scenario::next_tx(scenario, caller); let token_bridge_state = take_state(scenario); - let cap_a = test_scenario::take_from_address(scenario, owner_a); + let fc = test_scenario::take_from_address(scenario, caller); + pause::freeze_bridge(&mut token_bridge_state, &fc, test_scenario::ctx(scenario)); + test_scenario::return_to_address(caller, fc); - // You shall not pass! - pause::pause(&mut token_bridge_state, &cap_a, test_scenario::ctx(scenario)); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, pause::max_timestamp_ms() - 1); + // now < max expiry — must revert. + pause::unpause_expired(&mut token_bridge_state, &the_clock, test_scenario::ctx(scenario)); + + clock::destroy_for_testing(the_clock); + return_state(token_bridge_state); abort 42 } // ======================================================================== - // Access Control — Negative Tests + // Access Control — wrong / unconfigured caps // ======================================================================== #[test] #[expected_failure(abort_code = pause::E_PAUSER_NOT_CONFIGURED)] - fun test_cannot_pause_when_pauser_unassigned() { + fun test_pause_reverts_when_pauser_unassigned() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + let stray = pause::new_pauser_cap_test_only(test_scenario::ctx(scenario)); + let the_clock = new_clock(scenario); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + pause::pause(&mut token_bridge_state, &stray, &the_clock, test_scenario::ctx(scenario)); - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + clock::destroy_for_testing(the_clock); + pause::destroy_pauser_cap_test_only(stray); + return_state(token_bridge_state); + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_NOT_PAUSER)] + fun test_stray_cap_cannot_pause() { + let (caller, pauser_owner, _u) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::some(pauser_owner), + option::none(), + option::none(), + test_scenario::ctx(scenario) + ); + let stray = pause::new_pauser_cap_test_only(test_scenario::ctx(scenario)); + let the_clock = new_clock(scenario); + + pause::pause(&mut token_bridge_state, &stray, &the_clock, test_scenario::ctx(scenario)); + + clock::destroy_for_testing(the_clock); + pause::destroy_pauser_cap_test_only(stray); + return_state(token_bridge_state); + abort 42 + } - // Pauser unassigned; mint a stray cap to attempt with. - let stray_cap = pause::new_pauser_cap_test_only(test_scenario::ctx(scenario)); + #[test] + #[expected_failure(abort_code = pause::E_FREEZER_NOT_CONFIGURED)] + fun test_freeze_reverts_when_freezer_unassigned() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + let stray = pause::new_freezer_cap_test_only(test_scenario::ctx(scenario)); - // You shall not pass! - pause::pause(&mut token_bridge_state, &stray_cap, test_scenario::ctx(scenario)); + pause::freeze_bridge(&mut token_bridge_state, &stray, test_scenario::ctx(scenario)); - pause::destroy_pauser_cap_test_only(stray_cap); + pause::destroy_freezer_cap_test_only(stray); + return_state(token_bridge_state); abort 42 } #[test] - #[expected_failure(abort_code = pause::E_UNPAUSER_NOT_CONFIGURED)] - fun test_cannot_unpause_when_unpauser_unassigned() { + #[expected_failure(abort_code = pause::E_NOT_FREEZER)] + fun test_stray_cap_cannot_freeze() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::none(), + option::some(caller), + option::none(), + test_scenario::ctx(scenario) + ); + let stray = pause::new_freezer_cap_test_only(test_scenario::ctx(scenario)); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + pause::freeze_bridge(&mut token_bridge_state, &stray, test_scenario::ctx(scenario)); - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + pause::destroy_freezer_cap_test_only(stray); + return_state(token_bridge_state); + abort 42 + } + + #[test] + #[expected_failure(abort_code = pause::E_UNPAUSER_NOT_CONFIGURED)] + fun test_unpause_reverts_when_unpauser_unassigned() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); state::set_paused_test_only(&mut token_bridge_state, true); + let stray = pause::new_unpauser_cap_test_only(test_scenario::ctx(scenario)); + let the_clock = new_clock(scenario); - let stray_cap = pause::new_unpauser_cap_test_only(test_scenario::ctx(scenario)); + pause::unpause(&mut token_bridge_state, &stray, &the_clock, test_scenario::ctx(scenario)); - // You shall not pass! - pause::unpause(&mut token_bridge_state, &stray_cap, test_scenario::ctx(scenario)); + clock::destroy_for_testing(the_clock); + pause::destroy_unpauser_cap_test_only(stray); + return_state(token_bridge_state); + abort 42 + } - pause::destroy_unpauser_cap_test_only(stray_cap); + #[test] + #[expected_failure(abort_code = pause::E_NOT_UNPAUSER)] + fun test_stray_cap_cannot_unpause() { + let (caller, _p, unpauser_owner) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::none(), + option::none(), + option::some(unpauser_owner), + test_scenario::ctx(scenario) + ); + state::set_paused_test_only(&mut token_bridge_state, true); + let stray = pause::new_unpauser_cap_test_only(test_scenario::ctx(scenario)); + let the_clock = new_clock(scenario); + + pause::unpause(&mut token_bridge_state, &stray, &the_clock, test_scenario::ctx(scenario)); + + clock::destroy_for_testing(the_clock); + pause::destroy_unpauser_cap_test_only(stray); + return_state(token_bridge_state); abort 42 } + // ======================================================================== + // Rotation + // ======================================================================== + #[test] - #[expected_failure(abort_code = pause::E_NOT_PAUSER)] - fun test_stray_cap_cannot_pause() { - let (caller, pauser_owner, _u) = three_people(); + fun test_rotate_deprecates_old_pauser_cap() { + let (caller, owner_a, owner_b) = three_people(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::some(owner_a), + option::none(), + option::none(), + test_scenario::ctx(scenario) + ); + return_state(token_bridge_state); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); + // owner_a pauses. + test_scenario::next_tx(scenario, owner_a); + let token_bridge_state = take_state(scenario); + let cap_a = test_scenario::take_from_address(scenario, owner_a); + let the_clock = new_clock(scenario); + clock::set_for_testing(&mut the_clock, 1_000); + pause::pause(&mut token_bridge_state, &cap_a, &the_clock, test_scenario::ctx(scenario)); + assert!(state::is_paused(&token_bridge_state), 0); + state::set_paused_test_only(&mut token_bridge_state, false); - test_scenario::next_tx(scenario, caller); + // Rotate to owner_b, deprecating cap_a. + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::some(owner_b), + option::none(), + option::none(), + test_scenario::ctx(scenario) + ); + test_scenario::return_to_address(owner_a, cap_a); + return_state(token_bridge_state); + + // owner_b's new cap works. + test_scenario::next_tx(scenario, owner_b); let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + let cap_b = test_scenario::take_from_address(scenario, owner_b); + pause::pause(&mut token_bridge_state, &cap_b, &the_clock, test_scenario::ctx(scenario)); + assert!(state::is_paused(&token_bridge_state), 0); - // Designate a real pauser owner, but attempt with a stray (undesignated) cap. + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(owner_b, cap_b); + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + #[test] + #[expected_failure(abort_code = pause::E_NOT_PAUSER)] + fun test_deprecated_pauser_cap_fails() { + let (caller, owner_a, owner_b) = three_people(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); set_pauser_addresses::set_pauser_addresses_test_only( &mut token_bridge_state, - option::some(pauser_owner), + option::some(owner_a), + option::none(), option::none(), test_scenario::ctx(scenario) ); - let stray_cap = pause::new_pauser_cap_test_only(test_scenario::ctx(scenario)); + set_pauser_addresses::set_pauser_addresses_test_only( + &mut token_bridge_state, + option::some(owner_b), + option::none(), + option::none(), + test_scenario::ctx(scenario) + ); + return_state(token_bridge_state); + + test_scenario::next_tx(scenario, owner_a); + let token_bridge_state = take_state(scenario); + let cap_a = test_scenario::take_from_address(scenario, owner_a); + let the_clock = new_clock(scenario); - // You shall not pass! - pause::pause(&mut token_bridge_state, &stray_cap, test_scenario::ctx(scenario)); + pause::pause(&mut token_bridge_state, &cap_a, &the_clock, test_scenario::ctx(scenario)); - pause::destroy_pauser_cap_test_only(stray_cap); + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(owner_a, cap_a); + return_state(token_bridge_state); abort 42 } @@ -490,19 +844,12 @@ module token_bridge::pause_tests { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; - - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); + let token_bridge_state = set_up(scenario, caller); state::set_paused_test_only(&mut token_bridge_state, true); - - // You shall not pass! state::assert_not_paused_test_only(&token_bridge_state); + return_state(token_bridge_state); abort 42 } @@ -511,15 +858,8 @@ module token_bridge::pause_tests { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); - let wormhole_fee = 350; - set_up_wormhole_and_token_bridge(scenario, wormhole_fee); - - test_scenario::next_tx(scenario, caller); - let token_bridge_state = take_state(scenario); - state::init_pause_state_test_only(&mut token_bridge_state); - - // Should not revert. state::assert_not_paused_test_only(&token_bridge_state); return_state(token_bridge_state); @@ -527,67 +867,75 @@ module token_bridge::pause_tests { } // ======================================================================== - // Governance Payload Decode (wire format) + // Governance Payload Decode (wire format, 3 owners) // ======================================================================== #[test] - fun test_parse_payload_both_owners() { - // [32][A..][32][B..] + fun test_parse_payload_three_owners() { + // [32][A][32][B][32][C] let a = x"00000000000000000000000000000000000000000000000000000000000000aa"; let b = x"00000000000000000000000000000000000000000000000000000000000000bb"; + let c = x"00000000000000000000000000000000000000000000000000000000000000cc"; let payload = vector::empty(); vector::push_back(&mut payload, 32); vector::append(&mut payload, a); vector::push_back(&mut payload, 32); vector::append(&mut payload, b); + vector::push_back(&mut payload, 32); + vector::append(&mut payload, c); - let (pauser, unpauser) = - set_pauser_addresses::parse_payload_test_only(payload); - assert!(option::contains(&pauser, &@0xaa), 0); - assert!(option::contains(&unpauser, &@0xbb), 0); + let (p, f, u) = set_pauser_addresses::parse_payload_test_only(payload); + assert!(option::contains(&p, &@0xaa), 0); + assert!(option::contains(&f, &@0xbb), 0); + assert!(option::contains(&u, &@0xcc), 0); } #[test] - fun test_parse_payload_pauser_unassigned() { - // [0][32][B..] -> pauser unassigned, unpauser = B - let b = x"00000000000000000000000000000000000000000000000000000000000000bb"; + fun test_parse_payload_middle_unassigned() { + // [32][A][0][32][C] -> freezer unassigned + let a = x"00000000000000000000000000000000000000000000000000000000000000aa"; + let c = x"00000000000000000000000000000000000000000000000000000000000000cc"; let payload = vector::empty(); + vector::push_back(&mut payload, 32); + vector::append(&mut payload, a); vector::push_back(&mut payload, 0); vector::push_back(&mut payload, 32); - vector::append(&mut payload, b); + vector::append(&mut payload, c); - let (pauser, unpauser) = - set_pauser_addresses::parse_payload_test_only(payload); - assert!(option::is_none(&pauser), 0); - assert!(option::contains(&unpauser, &@0xbb), 0); + let (p, f, u) = set_pauser_addresses::parse_payload_test_only(payload); + assert!(option::contains(&p, &@0xaa), 0); + assert!(option::is_none(&f), 0); + assert!(option::contains(&u, &@0xcc), 0); } #[test] - fun test_parse_payload_both_unassigned() { - // [0][0] -> both unassigned + fun test_parse_payload_all_unassigned() { + // [0][0][0] let payload = vector::empty(); vector::push_back(&mut payload, 0); vector::push_back(&mut payload, 0); + vector::push_back(&mut payload, 0); - let (pauser, unpauser) = - set_pauser_addresses::parse_payload_test_only(payload); - assert!(option::is_none(&pauser), 0); - assert!(option::is_none(&unpauser), 0); + let (p, f, u) = set_pauser_addresses::parse_payload_test_only(payload); + assert!(option::is_none(&p), 0); + assert!(option::is_none(&f), 0); + assert!(option::is_none(&u), 0); } #[test] fun test_parse_payload_all_zero_addr_is_unassigned() { - // [32][0x00..00][0] -> all-zero 32-byte decodes to none (unassigned) + // [32][0x00..00][0][0] -> all-zero 32-byte decodes to none let zeros = x"0000000000000000000000000000000000000000000000000000000000000000"; let payload = vector::empty(); vector::push_back(&mut payload, 32); vector::append(&mut payload, zeros); vector::push_back(&mut payload, 0); + vector::push_back(&mut payload, 0); - let (pauser, unpauser) = - set_pauser_addresses::parse_payload_test_only(payload); - assert!(option::is_none(&pauser), 0); - assert!(option::is_none(&unpauser), 0); + let (p, f, u) = set_pauser_addresses::parse_payload_test_only(payload); + assert!(option::is_none(&p), 0); + assert!(option::is_none(&f), 0); + assert!(option::is_none(&u), 0); } #[test] @@ -599,20 +947,19 @@ module token_bridge::pause_tests { vector::push_back(&mut payload, 31); vector::append(&mut payload, bad); - // You shall not pass! - let (_p, _u) = set_pauser_addresses::parse_payload_test_only(payload); + let (_p, _f, _u) = set_pauser_addresses::parse_payload_test_only(payload); } #[test] #[expected_failure] fun test_parse_payload_trailing_bytes_aborts() { - // [0][0][extra] -> cursor not empty after parsing both + // [0][0][0][extra] -> cursor not empty after three owners let payload = vector::empty(); vector::push_back(&mut payload, 0); vector::push_back(&mut payload, 0); + vector::push_back(&mut payload, 0); vector::push_back(&mut payload, 0xff); - // You shall not pass! (cursor::destroy_empty aborts) - let (_p, _u) = set_pauser_addresses::parse_payload_test_only(payload); + let (_p, _f, _u) = set_pauser_addresses::parse_payload_test_only(payload); } } From 44afd0491da1520630165108276c4241303cc081 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 18 Jun 2026 20:53:57 +0200 Subject: [PATCH 11/14] fix: testing address --- sdk/js/src/utils/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/js/src/utils/consts.ts b/sdk/js/src/utils/consts.ts index b6344dded0f..ff87b4d1cc2 100644 --- a/sdk/js/src/utils/consts.ts +++ b/sdk/js/src/utils/consts.ts @@ -831,7 +831,7 @@ const DEVNET = { sui: { core: "0xea31c369d1f873d87d37f313ec37f1ee20a0b8136f06e3d3521330ee467312a4", // wormhole module State object ID token_bridge: - "0x952c15855b6b7a806e78e45858f4b909b1e2b25fa79a199e254c5ddf44590f9d", // token_bridge module State object ID + "0xce11a952fdba661c9c631303cd87693f7be30b6e92fdd8ad90c1fa602449f140", // token_bridge module State object ID nft_bridge: undefined, }, moonbeam: { From d3fc408ab418ac1eb9793f0a66f13975858aeefb Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 22 Jun 2026 16:31:55 +0200 Subject: [PATCH 12/14] fix: ci --- scripts/devnet-consts.json | 2 +- sdk/devnet_consts.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/devnet-consts.json b/scripts/devnet-consts.json index ead1e9badca..215c1cad98f 100644 --- a/scripts/devnet-consts.json +++ b/scripts/devnet-consts.json @@ -150,7 +150,7 @@ }, "21": { "contracts": { - "tokenBridgeEmitterAddress": "07035e0d324df11aca0e5c7eef7d87bca4da632a623a9cb1aabf21215ee98cdc" + "tokenBridgeEmitterAddress": "52544590e5791f8f613a022907266b70c625cc92d42c85188434e1e933afeb84" } }, "22": { diff --git a/sdk/devnet_consts.go b/sdk/devnet_consts.go index 92abb04a569..2f313e10949 100644 --- a/sdk/devnet_consts.go +++ b/sdk/devnet_consts.go @@ -15,7 +15,7 @@ var knownDevnetTokenbridgeEmitters = map[vaa.ChainID]string{ vaa.ChainIDBSC: "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16", vaa.ChainIDAlgorand: "8ec299cb7f3efec28f542397e07f07118d74c875f85409ed8e6b93c17b60e992", vaa.ChainIDWormchain: "c9138c6e5bd7a2ab79c1a87486c9d7349d064b35ac9f7498f3b207b3a61e6013", - vaa.ChainIDSui: "07035e0d324df11aca0e5c7eef7d87bca4da632a623a9cb1aabf21215ee98cdc", + vaa.ChainIDSui: "52544590e5791f8f613a022907266b70c625cc92d42c85188434e1e933afeb84", } // KnownDevnetNFTBridgeEmitters is a map of known NFT emitters used during development. From 8b5b5aa0aebb35e99f3c23f835ad2c55765b809e Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 2 Jul 2026 19:15:18 +0200 Subject: [PATCH 13/14] fix: revert when can't extend as already paused --- sui/token_bridge/sources/pause.move | 22 +++++++++++-------- .../sources/test/pause_tests.move | 16 ++++++++------ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/sui/token_bridge/sources/pause.move b/sui/token_bridge/sources/pause.move index b411d310520..bc83c7076b1 100644 --- a/sui/token_bridge/sources/pause.move +++ b/sui/token_bridge/sources/pause.move @@ -81,6 +81,10 @@ module token_bridge::pause { const E_NOT_PAUSED: u64 = 6; /// `unpause_expired` called before `pauseExpiry`. const E_NOT_EXPIRED: u64 = 7; + /// `pause` would not push `pauseExpiry` strictly forward — the bridge already has an + /// equal-or-later expiry (e.g. it is frozen, or a same-timestamp re-pause). A pauser must not + /// reduce a freeze, and a no-op call aborts rather than emitting a misleading event. + const E_PAUSE_NOT_EXTENDED: u64 = 8; /// Temporary-pause duration: 5 days, in milliseconds (Sui Clock is ms). const PAUSE_DURATION_MS: u64 = 432_000_000; @@ -196,10 +200,12 @@ module token_bridge::pause { } /// Temporarily pause the token bridge. Requires the active `PauserCap`. - /// Sets `paused` and pushes `pauseExpiry` to `now + PAUSE_DURATION` (5 days), - /// never reducing an expiry already further in the future (so a lower-trust - /// pauser cannot curtail a freeze). Not idempotent — each call extends the - /// window. Aborts if the pauser role is unassigned. + /// Pushes `pauseExpiry` to `now + PAUSE_DURATION` (5 days) and sets `paused`. + /// Aborts with `E_PAUSE_NOT_EXTENDED` if the new expiry would not be strictly + /// later than the current one — so a lower-trust pauser can never curtail a + /// freeze, and a call that would change nothing aborts rather than emitting a + /// misleading event. Not idempotent — each successful call extends the window. + /// Also aborts if the pauser role is unassigned. public fun pause( token_bridge_state: &mut State, cap: &PauserCap, @@ -215,16 +221,14 @@ module token_bridge::pause { assert!(cap_id == option::destroy_some(configured), E_NOT_PAUSER); let new_expiry = clock::timestamp_ms(clock) + PAUSE_DURATION_MS; - // Never reduce an expiry already further out (e.g. one set by `freeze`). - if (new_expiry > state::pause_expiry(token_bridge_state)) { - state::set_pause_expiry(&latest_only, token_bridge_state, new_expiry); - }; + assert!(new_expiry > state::pause_expiry(token_bridge_state), E_PAUSE_NOT_EXTENDED); + state::set_pause_expiry(&latest_only, token_bridge_state, new_expiry); state::set_paused(&latest_only, token_bridge_state, true); sui::event::emit(Paused { cap: cap_id, sender: tx_context::sender(ctx), - pause_expiry: state::pause_expiry(token_bridge_state) + pause_expiry: new_expiry }); } diff --git a/sui/token_bridge/sources/test/pause_tests.move b/sui/token_bridge/sources/test/pause_tests.move index 6ed8adaeb19..c7efbd0c181 100644 --- a/sui/token_bridge/sources/test/pause_tests.move +++ b/sui/token_bridge/sources/test/pause_tests.move @@ -244,8 +244,10 @@ module token_bridge::pause_tests { } #[test] - fun test_pause_does_not_reduce_freeze_expiry() { - // freeze sets max expiry; a subsequent pause must NOT pull it down. + #[expected_failure(abort_code = pause::E_PAUSE_NOT_EXTENDED)] + fun test_pause_reverts_when_frozen() { + // freeze sets max expiry; a subsequent pause cannot push it forward, so it must abort + // with E_PAUSE_NOT_EXTENDED (a lower-trust pauser can never curtail a freeze). let (caller, pauser_owner, _u) = three_people(); let freezer_owner = caller; let my_scenario = test_scenario::begin(caller); @@ -269,7 +271,7 @@ module token_bridge::pause_tests { test_scenario::return_to_address(freezer_owner, fc); return_state(token_bridge_state); - // Pause: expiry must remain max (not reduced to now + 5d). + // Pause on a frozen bridge: cannot extend past max, so this aborts. test_scenario::next_tx(scenario, pauser_owner); let token_bridge_state = take_state(scenario); let pc = test_scenario::take_from_address(scenario, pauser_owner); @@ -277,9 +279,7 @@ module token_bridge::pause_tests { clock::set_for_testing(&mut the_clock, 1_000); pause::pause(&mut token_bridge_state, &pc, &the_clock, test_scenario::ctx(scenario)); - assert!(state::pause_expiry(&token_bridge_state) == pause::max_timestamp_ms(), 0); - assert!(state::is_paused(&token_bridge_state), 0); - + // Unreachable — the pause above aborts. Cleanup keeps the borrow checker happy. clock::destroy_for_testing(the_clock); test_scenario::return_to_address(pauser_owner, pc); return_state(token_bridge_state); @@ -785,10 +785,12 @@ module token_bridge::pause_tests { test_scenario::return_to_address(owner_a, cap_a); return_state(token_bridge_state); - // owner_b's new cap works. + // owner_b's new cap works. Advance the clock so the pause pushes the expiry strictly + // forward (the prior pause left `pause_expiry` at 1_000 + 5d). test_scenario::next_tx(scenario, owner_b); let token_bridge_state = take_state(scenario); let cap_b = test_scenario::take_from_address(scenario, owner_b); + clock::set_for_testing(&mut the_clock, 2_000); pause::pause(&mut token_bridge_state, &cap_b, &the_clock, test_scenario::ctx(scenario)); assert!(state::is_paused(&token_bridge_state), 0); From dd6ad35c7b84d08251b001a4f0a99b1fd67d1c5c Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 2 Jul 2026 20:04:24 +0200 Subject: [PATCH 14/14] fix: upgrade build ids --- scripts/devnet-consts.json | 2 +- sdk/devnet_consts.go | 2 +- sdk/js/src/utils/consts.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/devnet-consts.json b/scripts/devnet-consts.json index 215c1cad98f..380b6caccc5 100644 --- a/scripts/devnet-consts.json +++ b/scripts/devnet-consts.json @@ -150,7 +150,7 @@ }, "21": { "contracts": { - "tokenBridgeEmitterAddress": "52544590e5791f8f613a022907266b70c625cc92d42c85188434e1e933afeb84" + "tokenBridgeEmitterAddress": "c2bfb149b1f3e55d054891b27a8e249930e57368e755c11e8fadb9384afce495" } }, "22": { diff --git a/sdk/devnet_consts.go b/sdk/devnet_consts.go index 2f313e10949..2e98437dad4 100644 --- a/sdk/devnet_consts.go +++ b/sdk/devnet_consts.go @@ -15,7 +15,7 @@ var knownDevnetTokenbridgeEmitters = map[vaa.ChainID]string{ vaa.ChainIDBSC: "0000000000000000000000000290fb167208af455bb137780163b7b7a9a10c16", vaa.ChainIDAlgorand: "8ec299cb7f3efec28f542397e07f07118d74c875f85409ed8e6b93c17b60e992", vaa.ChainIDWormchain: "c9138c6e5bd7a2ab79c1a87486c9d7349d064b35ac9f7498f3b207b3a61e6013", - vaa.ChainIDSui: "52544590e5791f8f613a022907266b70c625cc92d42c85188434e1e933afeb84", + vaa.ChainIDSui: "c2bfb149b1f3e55d054891b27a8e249930e57368e755c11e8fadb9384afce495", } // KnownDevnetNFTBridgeEmitters is a map of known NFT emitters used during development. diff --git a/sdk/js/src/utils/consts.ts b/sdk/js/src/utils/consts.ts index ff87b4d1cc2..c88860db030 100644 --- a/sdk/js/src/utils/consts.ts +++ b/sdk/js/src/utils/consts.ts @@ -831,7 +831,7 @@ const DEVNET = { sui: { core: "0xea31c369d1f873d87d37f313ec37f1ee20a0b8136f06e3d3521330ee467312a4", // wormhole module State object ID token_bridge: - "0xce11a952fdba661c9c631303cd87693f7be30b6e92fdd8ad90c1fa602449f140", // token_bridge module State object ID + "0xe536b9894dd511802508cbf5625a4d479c1e64c5aa98b725b1c83d8ef379dee9", // token_bridge module State object ID nft_bridge: undefined, }, moonbeam: {