diff --git a/aptos/CLAUDE.md b/aptos/CLAUDE.md index e362e498..d2bf75bd 100644 --- a/aptos/CLAUDE.md +++ b/aptos/CLAUDE.md @@ -34,14 +34,27 @@ and [evm/src/omni-bridge/contracts/OmniBridge.sol](../evm/src/omni-bridge/contra (`bytes[i]`), `for (i in 0..n)` range loops, `package fun` for cross-module-restricted entry points, and resource-index expressions (`&BridgeState[addr]`). +- **Optional Wormhole publish**: the bridge registers as a Wormhole + emitter inside `initialize` (`wormhole::register_emitter` against the + deployed Wormhole core at `@wormhole = 0x5bc1…`) and stores the + resulting `EmitterCapability` in `BridgeState`. `Admin` then flips + publishing on/off via `set_wormhole_enabled(enable: bool)` — a pure + flag flip, the same emitter id is reused across cycles so off-chain + consumers see a stable identity. When enabled, every `init_transfer`, + `fin_transfer`, `deploy_token`, and `log_metadata` also publishes a + Wormhole VAA whose payload mirrors the EVM `OmniBridgeWormhole.sol` + byte layout. The caller of each entry pays the Wormhole `message_fee` + in `Coin` from their own balance. The Wormhole modules are + vendored as a compile-time stub at `vendor/wormhole/` — see that + package's `Move.toml` for the rationale. ## Module Layout | Module | Purpose | |--------|---------| -| `omni_bridge::omni_bridge` | Main contract: init, deploy_token, init_transfer, fin_transfer, log_metadata, role management, pause, metadata mutation, events, views | +| `omni_bridge::omni_bridge` | Main contract: init, deploy_token, init_transfer, fin_transfer, log_metadata, role management, pause, metadata mutation, optional Wormhole publish, events, views | | `omni_bridge::bridge_token` | Fungible Asset wrapper exposing `create`/`mint`/`burn`/`mutate_metadata` as `package fun` (package-internal only). Holds the per-token capability bundle on the FA object's address | -| `omni_bridge::bridge_types` | Payload structs (`MetadataPayload`, `TransferMessagePayload`) and their Borsh encoders. Events live in `omni_bridge` because Aptos requires `#[event]` and emit-site in the same module | +| `omni_bridge::bridge_types` | Payload structs (`MetadataPayload`, `TransferMessagePayload`) and their Borsh encoders, plus the four Wormhole `*_wormhole_payload` encoders mirroring `OmniBridgeWormhole.sol`. Events live in `omni_bridge` because Aptos requires `#[event]` and emit-site in the same module | | `omni_bridge::borsh` | Borsh sequence encoders (`encode_string`, `encode_byte_vec`). Fixed-width integers and addresses delegate to `std::bcs::to_bytes` directly at call sites (BCS == Borsh for those types) | | `omni_bridge::utils` | `verify_eth_signature` (secp256k1 + keccak256), `normalize_decimals` | @@ -60,6 +73,7 @@ and [evm/src/omni-bridge/contracts/OmniBridge.sol](../evm/src/omni-bridge/contra | `set_near_bridge_derived_address` | Rotate the NEAR MPC signer address | `Admin` | | `grant_role` | Add an address to a role | `Admin` | | `revoke_role` | Remove an address from a role (refuses last Admin) | `Admin` | +| `set_wormhole_enabled` | Turn Wormhole publishing on/off (pure flag flip; emitter registered at `initialize`) | `Admin` | | `bridge_object_address` | Deterministic address of `BridgeState` / locked-token custody | View | | `get_token_address` | NEAR token id → deployed FA metadata object address | View | | `role_holders` | All addresses currently holding a role | View | @@ -117,6 +131,33 @@ side. In particular: `(name, id)` registry for off-chain discovery. 7. **Last-admin guard**: `revoke_role(Admin, last_admin)` aborts with `E_CANNOT_REMOVE_LAST_ADMIN`. Prevents bricking via accidental rotation. +8. **Wormhole emitter + stub package**: `initialize` calls + `wormhole::wormhole::register_emitter()` unconditionally and stores + the resulting `EmitterCapability` in `BridgeState`. There's only ever + one bridge instance, the cap has no `drop` ability (matching the + deployed Wormhole's ABI), and registration is cheap — so we pay the + one-time cost up front instead of conditionally on first enable. As a + consequence, `initialize` has a hard dependency on Wormhole being + deployed at `@wormhole`; on chains without a Wormhole deployment the + bridge cannot be initialized. `set_wormhole_enabled(admin, bool)` + then toggles publishing on/off as a pure flag flip. When enabled, + every public bridge action also calls `publish_message` with a + payload that mirrors the corresponding `OmniBridgeWormhole.sol` + extension byte layout (`init_transfer_wormhole_payload`, + `fin_transfer_wormhole_payload`, `deploy_token_wormhole_payload`, + `log_metadata_wormhole_payload` in `bridge_types`). The Wormhole + nonce is always `0` — the bridge's own `origin_nonce` is the + replay-prevention identifier and is carried in the payload. The + Wormhole Move package is vendored as a compile-time **stub** at + `vendor/wormhole/` (modules `wormhole::wormhole`, `wormhole::emitter`, + `wormhole::state`). The stub mirrors the deployed Wormhole's public + ABI so the bridge can compile against the modern Aptos framework; the + stub modules are NOT republished by `aptos move publish` (only + `aptos/sources/` modules go on chain), and at runtime calls dispatch + by `(address, name)` to the real Wormhole at + `@wormhole = 0x5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625`. + Never change struct field layouts or function signatures in the stub + without confirming the deployed Wormhole's ABI hasn't drifted. ### Security Invariants - **No replay**: `destination_nonce` is checked against `completed_transfers` @@ -174,6 +215,12 @@ Coverage: - **`init_transfer`** (7 tests): bridged-token burn, non-bridge-token lock, origin nonce increment, paused, zero amount, fee≥amount, amount>u64::MAX - Decimal normalization cap +- **Wormhole payload layouts** (5 tests): tag byte, chain-id offsets, and + total length for each of `init_transfer`, `fin_transfer` (with and + without fee_recipient), `deploy_token`, `log_metadata` +- **`set_wormhole_enabled` gating** (3 tests): admin can round-trip + off/on/off/on; setting the flag to its current value is a no-op; + non-admin rejected ## File References - Main contract: [sources/omni_bridge.move](sources/omni_bridge.move) diff --git a/aptos/Move.toml b/aptos/Move.toml index 639c13b3..cee7012d 100644 --- a/aptos/Move.toml +++ b/aptos/Move.toml @@ -20,3 +20,11 @@ rev = "mainnet" git = "https://github.com/aptos-labs/aptos-core.git" subdir = "aptos-move/framework/move-stdlib" rev = "mainnet" + +# Compile-time-only stub for Wormhole. See `vendor/wormhole/Move.toml` +# for the full rationale — the stub mirrors the deployed Wormhole's +# public ABI at `@wormhole = 0x5bc1…` so we can compile against modern +# framework features while still dispatching to the real Wormhole at +# runtime. The stub modules are NOT republished by `aptos move publish`. +[dependencies.Wormhole] +local = "vendor/wormhole" diff --git a/aptos/sources/bridge_types.move b/aptos/sources/bridge_types.move index 29f94ddb..24ab3617 100644 --- a/aptos/sources/bridge_types.move +++ b/aptos/sources/bridge_types.move @@ -19,6 +19,15 @@ module omni_bridge::bridge_types { const PAYLOAD_TYPE_TRANSFER_MESSAGE: u8 = 0; const PAYLOAD_TYPE_METADATA: u8 = 1; + // Wormhole `MessageType` discriminants. Numeric values are on-wire ABI — + // they match the enum in `OmniBridgeWormhole.sol` byte-for-byte so that + // Wormhole consumers can dispatch identically regardless of which chain + // emitted the VAA. + const WH_MSG_INIT_TRANSFER: u8 = 0; + const WH_MSG_FIN_TRANSFER: u8 = 1; + const WH_MSG_DEPLOY_TOKEN: u8 = 2; + const WH_MSG_LOG_METADATA: u8 = 3; + /// `deploy_token` payload signed by the NEAR MPC. struct MetadataPayload has copy, drop, store { /// Source-chain token id (e.g. NEAR account id of the underlying token). @@ -148,5 +157,111 @@ module omni_bridge::bridge_types { buf } + + // -------- Wormhole payload encoders -------- + // + // Byte layout mirrors the corresponding `*Extension` functions in + // `evm/src/omni-bridge/contracts/OmniBridgeWormhole.sol`. The encoding + // is plain Borsh — same primitives as the NEAR-MPC payloads above — + // except `MessageType` tags are taken from the `WH_MSG_*` constants + // (NOT the `PAYLOAD_TYPE_*` constants, which key a different enum). + // + // Optional string fields are encoded as the empty string when absent + // (rather than with a Borsh `Option` tag) to match the EVM layout, + // which has no Optional concept and represents "no value" with `""`. + + /// Wormhole payload for an outbound transfer. Mirrors + /// `initTransferExtension` in OmniBridgeWormhole.sol. + public fun init_transfer_wormhole_payload( + chain_id: u8, + sender: address, + token_address: address, + origin_nonce: u64, + amount: u128, + fee: u128, + native_fee: u128, + recipient: &String, + message: &vector + ): vector { + let buf = vector[]; + buf.push_back(WH_MSG_INIT_TRANSFER); + buf.push_back(chain_id); + buf.append(bcs::to_bytes(&sender)); + buf.push_back(chain_id); + buf.append(bcs::to_bytes(&token_address)); + buf.append(bcs::to_bytes(&origin_nonce)); + buf.append(bcs::to_bytes(&amount)); + buf.append(bcs::to_bytes(&fee)); + buf.append(bcs::to_bytes(&native_fee)); + buf.append(borsh::encode_string(recipient)); + buf.append(borsh::encode_byte_vec(message)); + buf + } + + /// Wormhole payload for finalizing an inbound transfer. Mirrors + /// `finTransferExtension` in OmniBridgeWormhole.sol. `fee_recipient` + /// `None` is encoded as the empty string to match EVM's non-Optional + /// `string`. + public fun fin_transfer_wormhole_payload( + chain_id: u8, + origin_chain: u8, + origin_nonce: u64, + token_address: address, + amount: u128, + fee_recipient: &Option + ): vector { + let buf = vector[]; + buf.push_back(WH_MSG_FIN_TRANSFER); + buf.push_back(origin_chain); + buf.append(bcs::to_bytes(&origin_nonce)); + buf.push_back(chain_id); + buf.append(bcs::to_bytes(&token_address)); + buf.append(bcs::to_bytes(&amount)); + if (fee_recipient.is_some()) { + buf.append(borsh::encode_string(fee_recipient.borrow())); + } else { + // 4-byte LE length prefix of zero, no body. + buf.append(x"00000000"); + }; + buf + } + + /// Wormhole payload for a token deploy. Mirrors `deployTokenExtension` + /// in OmniBridgeWormhole.sol. + public fun deploy_token_wormhole_payload( + chain_id: u8, + token: &String, + token_address: address, + decimals: u8, + origin_decimals: u8 + ): vector { + let buf = vector[]; + buf.push_back(WH_MSG_DEPLOY_TOKEN); + buf.append(borsh::encode_string(token)); + buf.push_back(chain_id); + buf.append(bcs::to_bytes(&token_address)); + buf.push_back(decimals); + buf.push_back(origin_decimals); + buf + } + + /// Wormhole payload for a `log_metadata` event. Mirrors + /// `logMetadataExtension` in OmniBridgeWormhole.sol. + public fun log_metadata_wormhole_payload( + chain_id: u8, + token_address: address, + name: &String, + symbol: &String, + decimals: u8 + ): vector { + let buf = vector[]; + buf.push_back(WH_MSG_LOG_METADATA); + buf.push_back(chain_id); + buf.append(bcs::to_bytes(&token_address)); + buf.append(borsh::encode_string(name)); + buf.append(borsh::encode_string(symbol)); + buf.push_back(decimals); + buf + } } diff --git a/aptos/sources/omni_bridge.move b/aptos/sources/omni_bridge.move index 4b9de2b4..bab0f959 100644 --- a/aptos/sources/omni_bridge.move +++ b/aptos/sources/omni_bridge.move @@ -13,10 +13,15 @@ module omni_bridge::omni_bridge { use std::string::{Self, String}; use std::option::{Self, Option}; use aptos_std::table::{Self, Table}; + use aptos_framework::aptos_coin::AptosCoin; + use aptos_framework::coin; use aptos_framework::event; use aptos_framework::fungible_asset::{Self, Metadata}; use aptos_framework::object::{Self, ExtendRef, Object}; use aptos_framework::primary_fungible_store; + use wormhole::emitter::EmitterCapability; + use wormhole::state as wormhole_state; + use wormhole::wormhole; use omni_bridge::bridge_token; use omni_bridge::bridge_types; @@ -115,7 +120,17 @@ module omni_bridge::omni_bridge { /// on demand for: /// - creating new FA objects in `deploy_token` /// - moving locked tokens out in `fin_transfer` (non-bridge tokens) - extend_ref: ExtendRef + extend_ref: ExtendRef, + /// Wormhole emitter capability. Registered in `initialize` by + /// calling `wormhole::register_emitter()` and held for the + /// lifetime of the bridge. `EmitterCapability` has no `drop` + /// ability (matching the deployed Wormhole's ABI), and there's + /// only ever one bridge instance — so a single up-front + /// registration gives off-chain consumers a stable emitter id + /// across enable/disable cycles. + wormhole_emitter: EmitterCapability, + /// Master switch for Wormhole publishing. + wormhole_enabled: bool } // -------- Events -------- @@ -227,7 +242,9 @@ module omni_bridge::omni_bridge { completed_transfers: table::new(), native_token_metadata, near_to_aptos_token: table::new(), - extend_ref + extend_ref, + wormhole_emitter: wormhole::register_emitter(), + wormhole_enabled: false } ); } @@ -300,6 +317,19 @@ module omni_bridge::omni_bridge { ); } + /// Turn Wormhole publishing on or off. When on, every successful + /// `init_transfer`, `fin_transfer`, `deploy_token`, and `log_metadata` + /// also publishes a Wormhole VAA whose payload mirrors the + /// `OmniBridgeWormhole.sol` extension. When off, the bridge runs as + /// if Wormhole had never been wired up. The caller of each bridge + /// action pays the Wormhole message fee in `Coin` from + /// their own balance. + public entry fun set_wormhole_enabled(admin: &signer, enable: bool) { + let state = &mut BridgeState[bridge_object_address()]; + assert_role(state, ROLE_ADMIN, admin, E_UNAUTHORIZED); + state.wormhole_enabled = enable; + } + /// Update mutable metadata (`icon_uri`, `project_uri`) on a /// bridge-deployed FA. `None` fields are left unchanged. Gated on the /// `metadata_admin` role (separate from the main admin so metadata @@ -338,22 +368,42 @@ module omni_bridge::omni_bridge { /// Permissionless: emit a `LogMetadata` event describing an existing FA. /// The NEAR side picks this event up to decide whether to sign a - /// `deploy_token` payload for the mirror token on its side. - public entry fun log_metadata(token: Object) { + /// `deploy_token` payload for the mirror token on its side. `caller` + /// pays the Wormhole message fee if Wormhole is enabled; the signer is + /// otherwise not read on-chain. + public entry fun log_metadata( + caller: &signer, token: Object + ) { + let state = &mut BridgeState[bridge_object_address()]; + let token_address = token.object_address(); let name = fungible_asset::name(token); let symbol = fungible_asset::symbol(token); let decimals = fungible_asset::decimals(token); event::emit( - LogMetadata { token_address: token.object_address(), name, symbol, decimals } + LogMetadata { token_address, name, symbol, decimals } ); + + if (is_wormhole_enabled(state)) { + let payload = + bridge_types::log_metadata_wormhole_payload( + state.chain_id, + token_address, + &name, + &symbol, + decimals + ); + publish_wormhole(state, caller, payload); + }; } // -------- Bridge operations -------- /// Deploy a bridged FA token. Anyone may submit the transaction — /// security comes from the NEAR MPC signature over the payload, not - /// access control. The transaction signer is not read on-chain. + /// access control. `caller` pays the Wormhole message fee if Wormhole + /// is enabled; the signer is otherwise not read on-chain. public entry fun deploy_token( + caller: &signer, signature_rs: vector, signature_v: u8, token: String, @@ -407,12 +457,26 @@ module omni_bridge::omni_bridge { origin_decimals: payload.metadata_decimals() } ); + + if (is_wormhole_enabled(state)) { + let wh_payload = + bridge_types::deploy_token_wormhole_payload( + state.chain_id, + &payload.metadata_token(), + token_addr, + normalized_decimals, + payload.metadata_decimals() + ); + publish_wormhole(state, caller, wh_payload); + }; } /// Finalize an inbound transfer from another chain. Permissionless — - /// the NEAR MPC signature is the authorization. The transaction signer - /// is not read on-chain. + /// the NEAR MPC signature is the authorization. `caller` pays the + /// Wormhole message fee if Wormhole is enabled; the signer is + /// otherwise not read on-chain. public entry fun fin_transfer( + caller: &signer, signature_rs: vector, signature_v: u8, destination_nonce: u64, @@ -479,6 +543,20 @@ module omni_bridge::omni_bridge { message: payload.transfer_message() } ); + + if (is_wormhole_enabled(state)) { + let fr = payload.transfer_fee_recipient(); + let wh_payload = + bridge_types::fin_transfer_wormhole_payload( + state.chain_id, + origin_chain, + origin_nonce, + token_address, + amount, + &fr + ); + publish_wormhole(state, caller, wh_payload); + }; } /// Start an outbound transfer from Aptos to another chain. @@ -542,6 +620,22 @@ module omni_bridge::omni_bridge { message } ); + + if (is_wormhole_enabled(state)) { + let wh_payload = + bridge_types::init_transfer_wormhole_payload( + state.chain_id, + sender_addr, + token_address, + origin_nonce, + amount, + fee, + native_fee, + &recipient, + &message + ); + publish_wormhole(state, sender, wh_payload); + }; } // -------- Views -------- @@ -735,6 +829,34 @@ module omni_bridge::omni_bridge { ); } + /// True iff `set_wormhole_enabled(true)` is the most recent toggle. + inline fun is_wormhole_enabled(state: &BridgeState): bool { + state.wormhole_enabled + } + + /// Publish `payload` as a Wormhole VAA. Caller must have already + /// confirmed `is_wormhole_enabled(state)` — this helper itself does + /// no flag check, so calling it when disabled would still emit a + /// VAA. The Wormhole nonce is `0` for every message; the bridge's + /// own `current_origin_nonce` (carried inside the payload) is the + /// replay-prevention identifier. `payer` funds the Wormhole message + /// fee from `Coin`. With the current Wormhole + /// `message_fee` at 0 the withdrawal is a no-op, but we still wire + /// the path through so a future fee increase doesn't require a + /// contract upgrade. + fun publish_wormhole( + state: &mut BridgeState, payer: &signer, payload: vector + ) { + let fee = wormhole_state::get_message_fee(); + let fee_coin = coin::withdraw(payer, fee); + wormhole::publish_message( + &mut state.wormhole_emitter, + 0u64, + payload, + fee_coin + ); + } + fun nonce_slot_and_bit(nonce: u64): (u64, u128) { let slot = nonce / BITMAP_WIDTH; let bit_pos = nonce % BITMAP_WIDTH; diff --git a/aptos/tests/omni_bridge_tests.move b/aptos/tests/omni_bridge_tests.move index df9d1ec0..42775086 100644 --- a/aptos/tests/omni_bridge_tests.move +++ b/aptos/tests/omni_bridge_tests.move @@ -812,5 +812,161 @@ module omni_bridge::omni_bridge_tests { b"" ); } + + // -------- Wormhole payload encoding tests -------- + // + // Verify each wormhole payload matches the layout that + // `OmniBridgeWormhole.sol` produces for the equivalent extension. We + // assert on the leading tag byte + chain-id positions + total length + // rather than every byte — the underlying primitives (`bcs::to_bytes`, + // `borsh::encode_string`) are already exhaustively tested in + // `borsh_tests` and the borsh-layout tests above. + + #[test] + fun init_transfer_wormhole_payload_layout() { + let recipient = string::utf8(b"alice.near"); + let message = b"hi"; + let bytes = + bridge_types::init_transfer_wormhole_payload( + 13u8, // chain_id + @0xAA, + @0xBB, + 42u64, + 1_000u128, + 10u128, + 5u128, + &recipient, + &message + ); + // Tag = WH_MSG_INIT_TRANSFER = 0. + assert!(bytes[0] == 0u8, 500); + // chain_id at byte 1 and again before token_address (at 1 + 1 + 32 = 34). + assert!(bytes[1] == 13u8, 501); + assert!(bytes[34] == 13u8, 502); + // Layout: tag(1) + cid(1) + sender(32) + cid(1) + token(32) + // + origin_nonce(8) + amount(16) + fee(16) + native_fee(16) + // + len(4)+"alice.near"(10) + len(4)+"hi"(2) + // = 1+1+32+1+32+8+16+16+16+4+10+4+2 = 143 + assert!(bytes.length() == 143, 503); + } + + #[test] + fun fin_transfer_wormhole_payload_layout_with_fee_recipient() { + let fr = option::some(string::utf8(b"fee.near")); + let bytes = + bridge_types::fin_transfer_wormhole_payload( + 13u8, // chain_id + 7u8, // origin_chain + 42u64, + @0xCC, + 999u128, + &fr + ); + // Tag = WH_MSG_FIN_TRANSFER = 1. + assert!(bytes[0] == 1u8, 510); + // origin_chain at byte 1. + assert!(bytes[1] == 7u8, 511); + // chain_id at 1 + 1 + 8 = 10 (after tag, origin_chain, origin_nonce). + assert!(bytes[10] == 13u8, 512); + // Layout: 1 + 1 + 8 + 1 + 32 + 16 + (4 + 8) = 71 + assert!(bytes.length() == 71, 513); + } + + #[test] + fun fin_transfer_wormhole_payload_layout_without_fee_recipient() { + let fr = option::none(); + let bytes = + bridge_types::fin_transfer_wormhole_payload( + 13u8, 7u8, 42u64, @0xCC, 999u128, &fr + ); + assert!(bytes[0] == 1u8, 520); + // Layout: 1 + 1 + 8 + 1 + 32 + 16 + 4 (empty length prefix) = 63 + assert!(bytes.length() == 63, 521); + // Last 4 bytes are the zero-length prefix. + let len = bytes.length(); + assert!(bytes[len - 4] == 0u8, 522); + assert!(bytes[len - 3] == 0u8, 523); + assert!(bytes[len - 2] == 0u8, 524); + assert!(bytes[len - 1] == 0u8, 525); + } + + #[test] + fun deploy_token_wormhole_payload_layout() { + let token = string::utf8(b"usdc.near"); + let bytes = + bridge_types::deploy_token_wormhole_payload( + 13u8, + &token, + @0xDD, + 8u8, // decimals + 6u8 // origin_decimals + ); + // Tag = WH_MSG_DEPLOY_TOKEN = 2. + assert!(bytes[0] == 2u8, 530); + // chain_id sits after tag + encoded token (4-byte len + 9 utf-8 bytes). + let chain_id_offset = 1 + 4 + 9; + assert!(bytes[chain_id_offset] == 13u8, 531); + // Layout: 1 + (4 + 9) + 1 + 32 + 1 + 1 = 49 + assert!(bytes.length() == 49, 532); + // Last two bytes are decimals + origin_decimals. + let len = bytes.length(); + assert!(bytes[len - 2] == 8u8, 533); + assert!(bytes[len - 1] == 6u8, 534); + } + + #[test] + fun log_metadata_wormhole_payload_layout() { + let name = string::utf8(b"USDC"); + let symbol = string::utf8(b"USDC"); + let bytes = + bridge_types::log_metadata_wormhole_payload( + 13u8, + @0xEE, + &name, + &symbol, + 6u8 + ); + // Tag = WH_MSG_LOG_METADATA = 3. + assert!(bytes[0] == 3u8, 540); + // chain_id at byte 1. + assert!(bytes[1] == 13u8, 541); + // Layout: 1 + 1 + 32 + (4 + 4) + (4 + 4) + 1 = 51 + assert!(bytes.length() == 51, 542); + // Last byte is decimals. + let len = bytes.length(); + assert!(bytes[len - 1] == 6u8, 543); + } + + // -------- set_wormhole_enabled gating -------- + + #[test(deployer = @omni_bridge)] + fun admin_can_toggle_wormhole(deployer: signer) { + // Round-trip: off → on → off → on. Confirms the bool flips + // cleanly. The emitter cap is registered once in `initialize` and + // never re-registered, so all four calls are pure flag flips. + let _ = setup(&deployer); + omni_bridge::set_wormhole_enabled(&deployer, true); + omni_bridge::set_wormhole_enabled(&deployer, false); + omni_bridge::set_wormhole_enabled(&deployer, true); + } + + #[test(deployer = @omni_bridge)] + fun toggling_wormhole_is_idempotent(deployer: signer) { + // Setting the flag to its current value is a no-op — no abort. + let _ = setup(&deployer); + omni_bridge::set_wormhole_enabled(&deployer, false); + omni_bridge::set_wormhole_enabled(&deployer, false); + omni_bridge::set_wormhole_enabled(&deployer, true); + omni_bridge::set_wormhole_enabled(&deployer, true); + } + + // Non-admin cannot toggle wormhole. E_UNAUTHORIZED = 2. + #[test(deployer = @omni_bridge, intruder = @0xBADBAD)] + #[expected_failure(abort_code = 2, location = omni_bridge::omni_bridge)] + fun non_admin_cannot_toggle_wormhole(deployer: signer, intruder: signer) { + let _ = setup(&deployer); + account::create_account_for_test(intruder.address_of()); + omni_bridge::set_wormhole_enabled(&intruder, true); + } } diff --git a/aptos/vendor/wormhole/Move.toml b/aptos/vendor/wormhole/Move.toml new file mode 100644 index 00000000..f5cb7981 --- /dev/null +++ b/aptos/vendor/wormhole/Move.toml @@ -0,0 +1,49 @@ +[package] +name = "Wormhole" +version = "0.1.0" +authors = ["Near One"] + +# Minimal stand-in for `wormhole-foundation/wormhole/aptos/wormhole`. +# +# This package is a COMPILE-TIME ONLY stub. It declares just enough of +# the Wormhole modules — `wormhole::wormhole`, `wormhole::emitter`, +# `wormhole::state` — for our bridge to compile against. The function +# bodies here are placeholders that never execute on chain. +# +# Why this works: +# - At compile time the Move compiler resolves `use wormhole::wormhole;` +# against this stub and verifies signatures. +# - At publish time only modules from `aptos/sources/` go on chain. +# The `wormhole::*` modules are NOT republished (we don't own +# `@wormhole`); they're treated as already-deployed deps. +# - At runtime calls to `wormhole::wormhole::publish_message` dispatch +# by module identity (`(address, name)`) to the REAL Wormhole at +# `@wormhole = 0x5bc1…`, which was published by Wormhole and contains +# the actual implementation. +# +# Critical: do NOT add fields, change field types, reorder fields, or +# change function signatures here without verifying the deployed +# Wormhole's ABI hasn't drifted. The deployed bytecode is the source of +# truth; this stub only mirrors its public ABI. + +[addresses] +# Mainnet Wormhole core address. Override via `--named-addresses` for +# testnet (`0x57641…`) or devnet. +wormhole = "0x5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625" + +# Same modern framework rev as the parent `aptos/` package, so the bridge +# and this stub agree on `aptos_framework::coin::Coin` identity. +[dependencies.AptosFramework] +git = "https://github.com/aptos-labs/aptos-core.git" +subdir = "aptos-move/framework/aptos-framework" +rev = "mainnet" + +[dependencies.AptosStdlib] +git = "https://github.com/aptos-labs/aptos-core.git" +subdir = "aptos-move/framework/aptos-stdlib" +rev = "mainnet" + +[dependencies.MoveStdlib] +git = "https://github.com/aptos-labs/aptos-core.git" +subdir = "aptos-move/framework/move-stdlib" +rev = "mainnet" diff --git a/aptos/vendor/wormhole/sources/emitter.move b/aptos/vendor/wormhole/sources/emitter.move new file mode 100644 index 00000000..78ff381a --- /dev/null +++ b/aptos/vendor/wormhole/sources/emitter.move @@ -0,0 +1,25 @@ +/// Stub mirror of `wormhole::emitter` — see `Move.toml` for rationale. +/// +/// The `EmitterCapability` struct layout matches the deployed Wormhole's +/// (`emitter: u64, sequence: u64`, ability `store`). Layout must not +/// drift from upstream; the runtime VM identifies types by `(address, +/// name)` and rejects struct-shape mismatches. +module wormhole::emitter { + struct EmitterCapability has store { + emitter: u64, + sequence: u64 + } + + public fun get_emitter(emitter_cap: &EmitterCapability): u64 { + emitter_cap.emitter + } + + /// Stub-only constructor used by `wormhole::register_emitter` in this + /// stub. The deployed Wormhole has its own internal emitter registry. + /// Never called at production runtime. + public(friend) fun new_for_testing(): EmitterCapability { + EmitterCapability { emitter: 0, sequence: 0 } + } + + friend wormhole::wormhole; +} diff --git a/aptos/vendor/wormhole/sources/state.move b/aptos/vendor/wormhole/sources/state.move new file mode 100644 index 00000000..830b5c3f --- /dev/null +++ b/aptos/vendor/wormhole/sources/state.move @@ -0,0 +1,11 @@ +/// Stub mirror of `wormhole::state` — see `Move.toml` for rationale. +/// +/// Only the public functions the bridge calls are declared. +module wormhole::state { + public fun get_message_fee(): u64 { + // Stub: never executes on chain. The deployed Wormhole at + // `@wormhole` reads the real configured fee from its on-chain + // state and returns it. + 0 + } +} diff --git a/aptos/vendor/wormhole/sources/wormhole.move b/aptos/vendor/wormhole/sources/wormhole.move new file mode 100644 index 00000000..f4d11c78 --- /dev/null +++ b/aptos/vendor/wormhole/sources/wormhole.move @@ -0,0 +1,41 @@ +/// Stub mirror of `wormhole::wormhole` — see `Move.toml` for rationale. +/// +/// Bodies here are placeholders. At runtime, calls dispatch by module +/// identity to the deployed Wormhole at `@wormhole`, where the real +/// implementation lives. +module wormhole::wormhole { + use aptos_framework::aptos_coin::AptosCoin; + use aptos_framework::coin::{Self, Coin}; + use wormhole::emitter::{Self, EmitterCapability}; + + /// Register an emitter with Wormhole and receive its capability. + /// The deployed Wormhole assigns a fresh sequential emitter id. + public fun register_emitter(): EmitterCapability { + // Stub: never executes on chain. The deployed Wormhole returns + // a freshly registered EmitterCapability. We return a zero-init + // value here so Move unit tests of consumers can exercise the + // "wormhole enabled" code path without aborting. + emitter::new_for_testing() + } + + /// Publish a Wormhole message. Withdraws the fee from `message_fee` + /// (must be at least `state::get_message_fee()`), increments the + /// emitter's sequence, and emits a wormhole event that guardians + /// observe. + public fun publish_message( + emitter_cap: &mut EmitterCapability, + nonce: u64, + payload: vector, + message_fee: Coin + ): u64 { + // Stub: dispose of arguments so the body type-checks. None of + // this runs on chain. + let _ = emitter_cap; + let _ = nonce; + let _ = payload; + // `Coin` has no `drop`; must consume it. Depositing back to + // `@wormhole` is consistent with what the real impl does. + coin::deposit(@wormhole, message_fee); + 0 + } +}