diff --git a/scripts/devnet-consts.json b/scripts/devnet-consts.json index 324f7c33130..380b6caccc5 100644 --- a/scripts/devnet-consts.json +++ b/scripts/devnet-consts.json @@ -150,7 +150,7 @@ }, "21": { "contracts": { - "tokenBridgeEmitterAddress": "50d9394da9f8812b21764b2206fcc2da0ffac108738132a091feec93c8164ec5" + "tokenBridgeEmitterAddress": "c2bfb149b1f3e55d054891b27a8e249930e57368e755c11e8fadb9384afce495" } }, "22": { diff --git a/sdk/devnet_consts.go b/sdk/devnet_consts.go index cc62d14adf2..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: "50d9394da9f8812b21764b2206fcc2da0ffac108738132a091feec93c8164ec5", + vaa.ChainIDSui: "c2bfb149b1f3e55d054891b27a8e249930e57368e755c11e8fadb9384afce495", } // KnownDevnetNFTBridgeEmitters is a map of known NFT emitters used during development. 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") + diff --git a/sdk/js/src/utils/consts.ts b/sdk/js/src/utils/consts.ts index a12a7f5bfed..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: - "0xbab50719a0e35350677b75bddc25f20bdc7a8c4788c7a0746b9796956380a2d2", // token_bridge module State object ID + "0xe536b9894dd511802508cbf5625a4d479c1e64c5aa98b725b1c83d8ef379dee9", // token_bridge module State object ID nft_bridge: undefined, }, moonbeam: { 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}; 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..41c7e648f4c --- /dev/null +++ b/sui/token_bridge/sources/governance/set_pauser_addresses.move @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: Apache 2 + +/// 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`/`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 +/// 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 +/// 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): +/// ``` +/// PauserLen(1) | Pauser(PauserLen) | +/// FreezerLen(1) | Freezer(FreezerLen) | +/// UnpauserLen(1) | Unpauser(UnpauserLen) +/// ``` +/// +/// Validation: +/// - 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 after the three +/// addresses). +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}; + 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. + 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/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 + } + + /// 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. 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, + ctx: &mut TxContext + ) { + // 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 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. + cursor::destroy_empty(cur); + + // 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, + freezer: freezer_id, + unpauser: unpauser_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: Option
, + ctx: &mut TxContext + ): 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, option::destroy_some(owner)); + state::set_pauser(latest_only, token_bridge_state, option::some(cap_id)); + 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`). + fun assign_unpauser( + token_bridge_state: &mut State, + latest_only: &state::LatestOnly, + owner: Option
, + ctx: &mut TxContext + ): 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, 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 `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 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. 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] + public fun action(): u8 { + ACTION_SET_PAUSER_ADDRESSES + } + + #[test_only] + /// Parse a raw SetPauserAddresses payload (the part after the governance + /// 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
) { + 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, freezer_owner, unpauser_owner) + } + + #[test_only] + public fun e_invalid_address_length(): u64 { + E_INVALID_ADDRESS_LENGTH + } + + #[test_only] + /// 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) { + 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, freezer_id, unpauser_id) + } +} 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..bc83c7076b1 --- /dev/null +++ b/sui/token_bridge/sources/pause.move @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: Apache 2 + +/// This module implements the emergency pause mechanism for the Token Bridge. +/// +/// 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 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 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 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, +/// 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. +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}; + + use token_bridge::state::{Self, State}; + + friend token_bridge::set_pauser_addresses; + + /// The pauser role is unassigned (active id is `none`). + const E_PAUSER_NOT_CONFIGURED: u64 = 0; + /// 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; + /// 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; + /// `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; + /// 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. + struct PauserCap has key, store { + 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 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, + /// Timestamp (ms) at which the pause becomes permissionlessly liftable. + pause_expiry: u64 + } + + /// 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, + /// Transaction signer (for audit; not the authority). + 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. + public(friend) fun new_pauser_cap(ctx: &mut TxContext): PauserCap { + 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) } + } + + /// 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 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) + } + + /// 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 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); + } + + /// Temporarily pause the token bridge. Requires the active `PauserCap`. + /// 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, + clock: &Clock, + ctx: &TxContext + ) { + let latest_only = state::assert_latest_only(token_bridge_state); + + let configured = state::pauser(token_bridge_state); + assert!(option::is_some(&configured), E_PAUSER_NOT_CONFIGURED); + + 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; + 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: new_expiry + }); + } + + /// 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 + ) { + let latest_only = state::assert_latest_only(token_bridge_state); + + let configured = state::unpauser(token_bridge_state); + assert!(option::is_some(&configured), E_UNPAUSER_NOT_CONFIGURED); + + 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) + } + + #[test_only] + public fun destroy_pauser_cap_test_only(cap: PauserCap) { + 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 a0c424ae5fe..01235d779e1 100644 --- a/sui/token_bridge/sources/state.move +++ b/sui/token_bridge/sources/state.move @@ -6,6 +6,8 @@ /// 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}; use sui::table::{Self, Table}; @@ -26,13 +28,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 +49,18 @@ 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 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 {} + /// Container for all state variables for Token Bridge. struct State has key, store { id: UID, @@ -149,6 +167,59 @@ 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 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 { + if (field::exists_(&self.id, PauserKey {})) { + *field::borrow>(&self.id, PauserKey {}) + } else { + option::none() + } + } + + /// 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 { + if (field::exists_(&self.id, UnpauserKey {})) { + *field::borrow>(&self.id, UnpauserKey {}) + } else { + option::none() + } + } + #[test_only] public fun borrow_mut_token_registry_test_only( self: &mut State @@ -290,6 +361,100 @@ 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 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( + _: &LatestOnly, + self: &mut State, + new_pauser: Option + ) { + 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 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( + _: &LatestOnly, + self: &mut State, + new_unpauser: Option + ) { + 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 +536,24 @@ 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, 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 + // 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, 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()); + }; } #[test_only] @@ -385,6 +566,32 @@ 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, 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()); + } + } + + #[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 +600,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..c7efbd0c181 --- /dev/null +++ b/sui/token_bridge/sources/test/pause_tests.move @@ -0,0 +1,967 @@ +// SPDX-License-Identifier: Apache 2 + +#[test_only] +module token_bridge::pause_tests { + use std::option::{Self}; + use std::vector::{Self}; + use sui::clock::{Self, Clock}; + use sui::test_scenario::{Self, Scenario}; + + use token_bridge::pause::{Self, PauserCap, FreezerCap, UnpauserCap}; + use token_bridge::set_pauser_addresses::{Self}; + use token_bridge::state::{Self, State}; + use token_bridge::token_bridge_scenario::{ + person, + return_state, + set_up_wormhole_and_token_bridge, + take_state, + 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 + // ======================================================================== + + #[test] + fun test_default_pause_state() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + let token_bridge_state = set_up(scenario, caller); + + // 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); + test_scenario::end(my_scenario); + } + + #[test] + fun test_pre_init_defaults() { + let caller = person(); + let my_scenario = test_scenario::begin(caller); + let scenario = &mut my_scenario; + + set_up_wormhole_and_token_bridge(scenario, WORMHOLE_FEE); + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + + // 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); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // Set Pauser Addresses (mint + transfer to owner, record cap id) + // ======================================================================== + + #[test] + 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); + + // Use `caller` as the freezer owner (distinct from the other two). + let freezer_owner = caller; + + 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) + ); + + 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 landed with their owners. + test_scenario::next_tx(scenario, pauser_owner); + 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 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_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); + + 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) + ); + + 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(&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); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // pause() — temporary, timed + // ======================================================================== + + #[test] + 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 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); + + test_scenario::next_tx(scenario, pauser_owner); + let token_bridge_state = take_state(scenario); + 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, &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 + ); + + 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_pushes_expiry_forward() { + 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); + + test_scenario::next_tx(scenario, pauser_owner); + let token_bridge_state = take_state(scenario); + 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, &cap, &the_clock, test_scenario::ctx(scenario)); + let first_expiry = state::pause_expiry(&token_bridge_state); + + // 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); + + 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] + #[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); + 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(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 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); + 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)); + + // 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); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // freeze_bridge() + // ======================================================================== + + #[test] + fun test_freeze_sets_max_expiry() { + 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) + ); + return_state(token_bridge_state); + + test_scenario::next_tx(scenario, caller); + let token_bridge_state = take_state(scenario); + 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 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)); + + 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); + + 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_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 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); + 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); + + // 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::none(), + option::none(), + option::some(unpauser_owner), + test_scenario::ctx(scenario) + ); + return_state(token_bridge_state); + + test_scenario::next_tx(scenario, unpauser_owner); + let token_bridge_state = take_state(scenario); + 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 + } + + // ======================================================================== + // 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(pauser_owner), + option::none(), + option::none(), + test_scenario::ctx(scenario) + ); + return_state(token_bridge_state); + + // Pause at t=1000 (expiry = 1000 + 5d). + 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); + + // 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] + 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); + + 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); + 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_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(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::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); + 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); + + 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 — wrong / unconfigured caps + // ======================================================================== + + #[test] + #[expected_failure(abort_code = pause::E_PAUSER_NOT_CONFIGURED)] + 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); + + 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 + } + + #[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 + } + + #[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)); + + pause::freeze_bridge(&mut token_bridge_state, &stray, test_scenario::ctx(scenario)); + + pause::destroy_freezer_cap_test_only(stray); + return_state(token_bridge_state); + abort 42 + } + + #[test] + #[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)); + + pause::freeze_bridge(&mut token_bridge_state, &stray, test_scenario::ctx(scenario)); + + 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); + + 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 + } + + #[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] + 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); + + // 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); + + // 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. 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); + + 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(owner_a), + option::none(), + option::none(), + 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); + + pause::pause(&mut token_bridge_state, &cap_a, &the_clock, test_scenario::ctx(scenario)); + + clock::destroy_for_testing(the_clock); + test_scenario::return_to_address(owner_a, cap_a); + return_state(token_bridge_state); + 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 token_bridge_state = set_up(scenario, caller); + + state::set_paused_test_only(&mut token_bridge_state, true); + state::assert_not_paused_test_only(&token_bridge_state); + + return_state(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 token_bridge_state = set_up(scenario, caller); + + state::assert_not_paused_test_only(&token_bridge_state); + + return_state(token_bridge_state); + test_scenario::end(my_scenario); + } + + // ======================================================================== + // Governance Payload Decode (wire format, 3 owners) + // ======================================================================== + + #[test] + 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 (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_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, c); + + 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_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 (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][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 (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] + #[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); + + let (_p, _f, _u) = set_pauser_addresses::parse_payload_test_only(payload); + } + + #[test] + #[expected_failure] + fun test_parse_payload_trailing_bytes_aborts() { + // [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); + + let (_p, _f, _u) = set_pauser_addresses::parse_payload_test_only(payload); + } +} 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 {}