From b09ddcc72b502524d6cf9eb8f39440c19c8a66e5 Mon Sep 17 00:00:00 2001 From: Ivan Frolov Date: Mon, 6 Jul 2026 16:25:31 +0200 Subject: [PATCH] feat: added sui support --- .github/workflows/sui.yaml | 49 + .github/workflows/update-contracts.yaml | 53 + near/omni-bridge/src/lib.rs | 1 + near/omni-tests/src/helpers.rs | 6 + near/omni-tests/src/omni_token.rs | 6 +- near/omni-types/src/lib.rs | 31 +- near/omni-types/src/tests/lib_test.rs | 56 +- sui/.gitignore | 7 + sui/AGENTS.md | 1 + sui/CLAUDE.md | 153 ++ sui/Move.lock | 23 + sui/Move.toml | 8 + sui/README.md | 131 ++ sui/sources/borsh.move | 32 + sui/sources/bridge_types.move | 151 ++ sui/sources/omni_bridge.move | 1000 +++++++++++++ sui/sources/utils.move | 102 ++ sui/tests/borsh_tests.move | 49 + sui/tests/bridge_types_tests.move | 138 ++ sui/tests/omni_bridge_tests.move | 1322 +++++++++++++++++ sui/tests/test_coin.move | 29 + sui/tests/utils_tests.move | 130 ++ sui/token_template/.gitignore | 1 + sui/token_template/Move.lock | 23 + sui/token_template/Move.toml | 8 + sui/token_template/sources/template_coin.move | 50 + 26 files changed, 3552 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/sui.yaml create mode 100644 sui/.gitignore create mode 120000 sui/AGENTS.md create mode 100644 sui/CLAUDE.md create mode 100644 sui/Move.lock create mode 100644 sui/Move.toml create mode 100644 sui/README.md create mode 100644 sui/sources/borsh.move create mode 100644 sui/sources/bridge_types.move create mode 100644 sui/sources/omni_bridge.move create mode 100644 sui/sources/utils.move create mode 100644 sui/tests/borsh_tests.move create mode 100644 sui/tests/bridge_types_tests.move create mode 100644 sui/tests/omni_bridge_tests.move create mode 100644 sui/tests/test_coin.move create mode 100644 sui/tests/utils_tests.move create mode 100644 sui/token_template/.gitignore create mode 100644 sui/token_template/Move.lock create mode 100644 sui/token_template/Move.toml create mode 100644 sui/token_template/sources/template_coin.move diff --git a/.github/workflows/sui.yaml b/.github/workflows/sui.yaml new file mode 100644 index 000000000..2d7e9459d --- /dev/null +++ b/.github/workflows/sui.yaml @@ -0,0 +1,49 @@ +name: Sui CI + +on: + push: + branches: [ main ] + paths: + - 'sui/**' + - '.github/workflows/sui.yaml' + pull_request: + paths: + - 'sui/**' + - '.github/workflows/sui.yaml' + +env: + SUI_VERSION: "1.74.1" + +jobs: + test: + name: Build and Test + runs-on: ubuntu-latest + + defaults: + run: + working-directory: sui + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Sui CLI + run: | + ASSET="sui-mainnet-v${SUI_VERSION}-ubuntu-x86_64.tgz" + URL="https://github.com/MystenLabs/sui/releases/download/mainnet-v${SUI_VERSION}/${ASSET}" + curl -fsSL "$URL" -o sui.tgz + mkdir -p sui-bin + tar -xzf sui.tgz -C sui-bin + sudo mv sui-bin/sui /usr/local/bin/ + rm -rf sui.tgz sui-bin + sui --version + + - name: Build + run: sui move build + + - name: Run tests + run: sui move test + + - name: Build token template + working-directory: sui/token_template + run: sui move build diff --git a/.github/workflows/update-contracts.yaml b/.github/workflows/update-contracts.yaml index eded3113a..95381d386 100644 --- a/.github/workflows/update-contracts.yaml +++ b/.github/workflows/update-contracts.yaml @@ -6,6 +6,11 @@ on: workflow_dispatch: name: Update Contracts + +env: + APTOS_CLI_VERSION: "9.2.0" + SUI_VERSION: "1.74.1" + jobs: update-contracts: runs-on: warp-ubuntu-2404-x64-16x @@ -67,6 +72,48 @@ jobs: path: solana/target/idl/bridge_token_factory.json if-no-files-found: error + - name: Install Aptos CLI + run: | + ASSET="aptos-cli-${APTOS_CLI_VERSION}-Linux-x86_64.zip" + URL="https://github.com/aptos-labs/aptos-core/releases/download/aptos-cli-v${APTOS_CLI_VERSION}/${ASSET}" + curl -fsSL "$URL" -o aptos-cli.zip + unzip -q aptos-cli.zip + sudo mv aptos /usr/local/bin/ + rm aptos-cli.zip + aptos --version + + # Reference bytecode compiled with the test/CI placeholder address + # (Aptos bytecode embeds the named address; deployments recompile + # with the deployer's address). + - name: Build Aptos contract + working-directory: aptos + run: aptos move compile --save-metadata --named-addresses omni_bridge=0xCAFE + timeout-minutes: 30 + + - name: Install Sui CLI + run: | + ASSET="sui-mainnet-v${SUI_VERSION}-ubuntu-x86_64.tgz" + URL="https://github.com/MystenLabs/sui/releases/download/mainnet-v${SUI_VERSION}/${ASSET}" + curl -fsSL "$URL" -o sui.tgz + mkdir -p sui-bin + tar -xzf sui.tgz -C sui-bin + sudo mv sui-bin/sui /usr/local/bin/ + rm -rf sui.tgz sui-bin + sui --version + # Create a client config now, on a step whose stdout is discarded. + # Otherwise the first `sui` invocation that needs one (the build + # step below) prints the interactive config-creation prose — and a + # generated key's recovery phrase — to stdout, corrupting the + # captured base64 JSON artifact. `sui client` is offline here. + sui client --yes envs >/dev/null 2>&1 + + # Sui packages publish from the 0x0 placeholder, so both the bytecode + # modules and the base64 publish payload are directly usable. + - name: Build Sui contract + working-directory: sui + run: sui move build --dump-bytecode-as-base64 > sui_omni_bridge.json + timeout-minutes: 30 + - name: Archive built WASM files env: RAW_TAG: ${{ github.ref_name }} @@ -77,6 +124,12 @@ jobs: find ./near/target/near -name "*.wasm" -exec cp {} artifacts/ \; cp ./solana/target/verifiable/bridge_token_factory.so artifacts cp ./solana/target/verifiable/bridge_token_factory_fogo.so artifacts + zip -j artifacts/aptos_omni_bridge.zip \ + aptos/build/OmniBridge/bytecode_modules/*.mv \ + aptos/build/OmniBridge/package-metadata.bcs + zip -j artifacts/sui_omni_bridge.zip \ + sui/build/OmniBridge/bytecode_modules/*.mv + cp sui/sui_omni_bridge.json artifacts/ zip -j "$ZIP_NAME" artifacts/* shell: bash diff --git a/near/omni-bridge/src/lib.rs b/near/omni-bridge/src/lib.rs index 0847183d7..4f649448d 100644 --- a/near/omni-bridge/src/lib.rs +++ b/near/omni-bridge/src/lib.rs @@ -1445,6 +1445,7 @@ impl Contract { s if s.starts_with("fogo") => ChainKind::Fogo, s if s.starts_with("strk") || s.starts_with("starknet") => ChainKind::Strk, s if s.starts_with("aptos") => ChainKind::Aptos, + s if s.starts_with("sui") => ChainKind::Sui, _ => env::panic_str(&BridgeError::CannotDetermineOriginChain.as_ref()), }; diff --git a/near/omni-tests/src/helpers.rs b/near/omni-tests/src/helpers.rs index 4ac5d38bf..e965f268d 100644 --- a/near/omni-tests/src/helpers.rs +++ b/near/omni-tests/src/helpers.rs @@ -199,6 +199,12 @@ pub mod tests { .unwrap() } + pub fn sui_factory_address() -> OmniAddress { + "sui:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf" + .parse() + .unwrap() + } + pub fn sol_factory_address() -> OmniAddress { "sol:11111111111111111111111111111111".parse().unwrap() } diff --git a/near/omni-tests/src/omni_token.rs b/near/omni-tests/src/omni_token.rs index 71d83e2e8..d7bf7bed1 100644 --- a/near/omni-tests/src/omni_token.rs +++ b/near/omni-tests/src/omni_token.rs @@ -19,8 +19,9 @@ mod tests { bnb_token_address, eth_eoa_address, eth_factory_address, eth_token_address, fogo_factory_address, get_test_deploy_token_args, hyperevm_factory_address, locker_wasm, mock_global_contract_deployer_wasm, mock_prover_wasm, omni_token_wasm, pol_factory_address, - sol_factory_address, sol_token_address, strk_factory_address, token_deployer_wasm, - wasm_code_hash, GLOBAL_STORAGE_COST_PER_BYTE, NEP141_DEPOSIT, STORAGE_DEPOSIT_PER_BYTE, + sol_factory_address, sol_token_address, strk_factory_address, sui_factory_address, + token_deployer_wasm, wasm_code_hash, GLOBAL_STORAGE_COST_PER_BYTE, NEP141_DEPOSIT, + STORAGE_DEPOSIT_PER_BYTE, }; const PREV_TOKEN_DEPLOYER_WASM_FILEPATH: &str = "src/data/legacy_token_deployer-0.2.4.wasm"; @@ -144,6 +145,7 @@ mod tests { ChainKind::HyperEvm => hyperevm_factory_address(), ChainKind::Strk => strk_factory_address(), ChainKind::Aptos => aptos_factory_address(), + ChainKind::Sui => sui_factory_address(), ChainKind::Near | ChainKind::Btc | ChainKind::Zcash => panic!("Unsupported chain"), }; diff --git a/near/omni-types/src/lib.rs b/near/omni-types/src/lib.rs index 1a8f88961..2aafa0984 100644 --- a/near/omni-types/src/lib.rs +++ b/near/omni-types/src/lib.rs @@ -83,6 +83,8 @@ pub enum ChainKind { Fogo, #[serde(alias = "aptos")] Aptos, + #[serde(alias = "sui")] + Sui, } impl ChainKind { @@ -101,7 +103,8 @@ impl ChainKind { | Self::Sol | Self::Strk | Self::Fogo - | Self::Aptos => false, + | Self::Aptos + | Self::Sui => false, } } @@ -119,7 +122,8 @@ impl ChainKind { | Self::Strk | Self::Abs | Self::Fogo - | Self::Aptos => false, + | Self::Aptos + | Self::Sui => false, } } @@ -137,7 +141,8 @@ impl ChainKind { | Self::Abs | Self::Btc | Self::Zcash - | Self::Aptos => false, + | Self::Aptos + | Self::Sui => false, } } } @@ -174,6 +179,7 @@ impl TryFrom for ChainKind { 11 => Ok(Self::Abs), 12 => Ok(Self::Fogo), 13 => Ok(Self::Aptos), + 14 => Ok(Self::Sui), _ => Err(format!("{input:?} invalid chain kind")), } } @@ -183,6 +189,10 @@ pub type EvmAddress = H160; pub type UTXOChainAddress = String; pub type StarknetAddress = H256; pub type AptosAddress = H256; +/// Sui coins are types, not addresses: for tokens this carries +/// `keccak256(canonical coin type string)`; for accounts, the native +/// 32-byte Sui address. +pub type SuiAddress = H256; pub const ZERO_ACCOUNT_ID: &str = "0000000000000000000000000000000000000000000000000000000000000000"; @@ -204,6 +214,7 @@ pub enum OmniAddress { Abs(EvmAddress), Fogo(SolAddress), Aptos(AptosAddress), + Sui(SuiAddress), } impl OmniAddress { @@ -224,6 +235,7 @@ impl OmniAddress { ChainKind::Abs => Ok(Self::Abs(H160::ZERO)), ChainKind::Fogo => Ok(Self::Fogo(SolAddress::ZERO)), ChainKind::Aptos => Ok(Self::Aptos(H256::ZERO)), + ChainKind::Sui => Ok(Self::Sui(H256::ZERO)), } } @@ -267,6 +279,7 @@ impl OmniAddress { )), ChainKind::Strk => Ok(Self::Strk(H256(address.try_into().map_err(stringify)?))), ChainKind::Aptos => Ok(Self::Aptos(H256(address.try_into().map_err(stringify)?))), + ChainKind::Sui => Ok(Self::Sui(H256(address.try_into().map_err(stringify)?))), } } @@ -286,6 +299,7 @@ impl OmniAddress { Self::Abs(_) => ChainKind::Abs, Self::Fogo(_) => ChainKind::Fogo, Self::Aptos(_) => ChainKind::Aptos, + Self::Sui(_) => ChainKind::Sui, } } @@ -305,6 +319,7 @@ impl OmniAddress { Self::Abs(address) => ("abs", address.to_string()), Self::Fogo(address) => ("fogo", address.to_string()), Self::Aptos(address) => ("aptos", address.to_string()), + Self::Sui(address) => ("sui", address.to_string()), }; if skip_zero_address && self.is_zero() { @@ -326,7 +341,7 @@ impl OmniAddress { Self::Near(address) => *address == ZERO_ACCOUNT_ID, Self::Sol(address) | Self::Fogo(address) => address.is_zero(), Self::Btc(address) | Self::Zcash(address) => address.is_empty(), - Self::Strk(address) | Self::Aptos(address) => address.is_zero(), + Self::Strk(address) | Self::Aptos(address) | Self::Sui(address) => address.is_zero(), } } @@ -336,6 +351,7 @@ impl OmniAddress { Self::Fogo(address) => Self::hashed_token_prefix("fogo", &H256(address.0)), Self::Strk(address) => Self::hashed_token_prefix("strk", address), Self::Aptos(address) => Self::hashed_token_prefix("aptos", address), + Self::Sui(address) => Self::hashed_token_prefix("sui", address), Self::Eth(address) => { if self.is_zero() { "eth".to_string() @@ -426,6 +442,7 @@ impl FromStr for OmniAddress { "strk" => Ok(Self::Strk(recipient.parse().map_err(stringify)?)), "fogo" => Ok(Self::Fogo(recipient.parse().map_err(stringify)?)), "aptos" => Ok(Self::Aptos(recipient.parse().map_err(stringify)?)), + "sui" => Ok(Self::Sui(recipient.parse().map_err(stringify)?)), _ => Err(format!("Chain {chain} is not supported")), } } @@ -971,6 +988,12 @@ pub fn get_native_token_address(chain_kind: ChainKind) -> Result OmniAddress::from_str( "aptos:0x000000000000000000000000000000000000000000000000000000000000000a", ), + // Sui coins are identified by keccak256 of the canonical coin type + // string; this is keccak256(b"0000...0002::sui::SUI") — identical + // on mainnet/testnet/devnet since 0x2::sui::SUI is a framework type. + ChainKind::Sui => OmniAddress::from_str( + "sui:0x6696387aecbb705205026783042f803871c190570dd0a57882d9d35ee0df700c", + ), ChainKind::Eth | ChainKind::Near | ChainKind::Sol diff --git a/near/omni-types/src/tests/lib_test.rs b/near/omni-types/src/tests/lib_test.rs index 253c338fc..c280768cc 100644 --- a/near/omni-types/src/tests/lib_test.rs +++ b/near/omni-types/src/tests/lib_test.rs @@ -9,7 +9,7 @@ use crate::{ }; use std::str::FromStr; -fn chain_kinds_for_borsh() -> [ChainKind; 14] { +fn chain_kinds_for_borsh() -> [ChainKind; 15] { [ ChainKind::Eth, ChainKind::Near, @@ -25,6 +25,7 @@ fn chain_kinds_for_borsh() -> [ChainKind; 14] { ChainKind::Abs, ChainKind::Fogo, ChainKind::Aptos, + ChainKind::Sui, ] } @@ -56,6 +57,10 @@ fn omni_addresses_for_borsh() -> Vec { H256::from_str("0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf") .unwrap(), ), + OmniAddress::Sui( + H256::from_str("0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf") + .unwrap(), + ), ] } @@ -204,6 +209,14 @@ fn test_chain_kind_from_omni_address() { ChainKind::Aptos, "Aptos", ); + test_chain_kind( + OmniAddress::Sui( + H256::from_str("0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf") + .unwrap(), + ), + ChainKind::Sui, + "Sui", + ); } #[test] @@ -292,6 +305,16 @@ fn test_omni_address_from_str() { )), "Should parse Aptos address", ), + ( + "sui:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf".to_string(), + Ok(OmniAddress::Sui( + H256::from_str( + "0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf", + ) + .unwrap(), + )), + "Should parse Sui address", + ), ( "invalid_format".to_string(), Err("ERR_INVALID_HEX".to_string()), @@ -370,6 +393,16 @@ fn test_omni_address_display() { "aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf".to_string(), "Aptos address should format as aptos:0x...", ), + ( + OmniAddress::Sui( + H256::from_str( + "0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf", + ) + .unwrap(), + ), + "sui:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf".to_string(), + "Sui address should format as sui:0x...", + ), ]; for (address, expected, message) in test_cases { @@ -591,6 +624,9 @@ fn test_chain_kind_from_str() { let chain: ChainKind = "Abs".parse().unwrap(); assert_eq!(chain, ChainKind::Abs); + + let chain: ChainKind = "Sui".parse().unwrap(); + assert_eq!(chain, ChainKind::Sui); } #[test] @@ -630,9 +666,25 @@ fn test_get_native_token_address_returns_expected_addresses() { "Aptos native token should be the canonical APT FA metadata address (0xa)" ); + // Sui should return keccak256 of the canonical SUI coin type string + // (b"0000...0002::sui::SUI") — the wire-format token id for native SUI + let sui_address = get_native_token_address(ChainKind::Sui).unwrap(); + assert_eq!( + sui_address, + OmniAddress::Sui(H256([ + 0x66, 0x96, 0x38, 0x7a, 0xec, 0xbb, 0x70, 0x52, 0x05, 0x02, 0x67, 0x83, 0x04, 0x2f, + 0x80, 0x38, 0x71, 0xc1, 0x90, 0x57, 0x0d, 0xd0, 0xa5, 0x78, 0x82, 0xd9, 0xd3, 0x5e, + 0xe0, 0xdf, 0x70, 0x0c, + ])), + "Sui native token should be keccak256 of the canonical SUI coin type string" + ); + // All other chains should return zero addresses for chain_kind in chain_kinds_for_borsh() { - if chain_kind == ChainKind::Strk || chain_kind == ChainKind::Aptos { + if matches!( + chain_kind, + ChainKind::Strk | ChainKind::Aptos | ChainKind::Sui + ) { continue; } let address = get_native_token_address(chain_kind).unwrap(); diff --git a/sui/.gitignore b/sui/.gitignore new file mode 100644 index 000000000..95b6ebed7 --- /dev/null +++ b/sui/.gitignore @@ -0,0 +1,7 @@ +build/ +# Per-deployer publish record. NOT committed: `sui move test` resolves the +# package's named address from it, but the offline-precomputed signature +# vectors in tests/ are keccak-bound to the package at 0x0, so a real +# published address relocates every bridged coin type and breaks the +# signature tests. Delete/relocate your local copy before `sui move test`. +Published.toml diff --git a/sui/AGENTS.md b/sui/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/sui/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/sui/CLAUDE.md b/sui/CLAUDE.md new file mode 100644 index 000000000..758dfe31a --- /dev/null +++ b/sui/CLAUDE.md @@ -0,0 +1,153 @@ +# OmniBridge Sui Contract + +## Overview +Cross-chain bridge for Sui, enabling token transfers between Sui and other +chains via NEAR Protocol. Mirrors the Aptos, Starknet and EVM +implementations in this repo: see +[aptos/sources/omni_bridge.move](../aptos/sources/omni_bridge.move), +[starknet/src/omni_bridge.cairo](../starknet/src/omni_bridge.cairo) and +[evm/src/omni-bridge/contracts/OmniBridge.sol](../evm/src/omni-bridge/contracts/OmniBridge.sol). + +## Architecture +- **NEAR-centric**: all transfers route through NEAR (Sui ↔ NEAR ↔ other + chain). +- **Security**: NEAR→Sui messages carry Ethereum-style ECDSA signatures by + the NEAR MPC, verified in `utils::verify_eth_signature` against + `near_bridge_derived_address`. Sui→NEAR proofs are MPC reads of the + events emitted here (`mpc-omni-prover` on NEAR) — no Wormhole, no light + client. +- **Token identity**: Sui coins are types, not addresses. The 32-byte + wire-format token id is `keccak256(canonical type string of T)` in the + `std::type_name::with_defining_ids` form (64 lowercase hex chars, no + `0x`, `::module::NAME`). `OmniAddress::Sui` on NEAR is `H256`, exactly + like Aptos/Starknet. Events additionally carry the type string + (`coin_type`) and the shared state keeps a `token_registry` + (id → TypeName) reverse map. +- **Token model**: bridged coins (mint/burn via `TreasuryCap` custody) or + native coins (lock/unlock in a `Bag` of `Balance` keyed by + `TypeName`). Bridge-token status == presence of the type in the + `treasuries` ObjectBag — one source of truth. +- **Shared-object state**: one `BridgeState` shared object created in + `init` at publish. Sui `init` cannot take parameters, so the MPC signer + and chain id are set by a one-shot Admin `initialize` call; every bridge + operation aborts with `E_NOT_INITIALIZED` until then. +- **Upgrade safety**: `BridgeState.version` is asserted by every entry + point (`E_WRONG_VERSION`); upgrades bump `VERSION` and ship an + Admin-gated `migrate`. Old package versions keep running on Sui — the + version gate stops them from touching state. +- **Role-based access control**: `roles: Table>` + checked against `ctx.sender()`, same discriminants and semantics as + Aptos (`ROLE_ADMIN = 0`, `ROLE_PAUSER = 1`, `ROLE_METADATA_ADMIN = 2`; + grant/revoke by Admin; last-admin guard). + +## Module Layout + +| Module | Purpose | +|--------|---------| +| `omni_bridge::omni_bridge` | Main contract: init/initialize, deploy_token, init_transfer, fin_transfer, log_metadata (+ `_registry` variant), set_token_metadata, roles, pause, migrate, events, views | +| `omni_bridge::bridge_types` | Payload structs (`MetadataPayload`, `TransferMessagePayload`) and their Borsh encoders | +| `omni_bridge::borsh` | Borsh sequence encoders (u32-LE length prefix). Fixed-width integers and addresses use `std::bcs::to_bytes` directly at call sites (BCS == Borsh for those types) | +| `omni_bridge::utils` | `verify_eth_signature`, `normalize_decimals` (clamp 9), `coin_type_string`, `token_address` (keccak id), `type_package_address` | +| `token_template::template_coin` | Separate per-token package template for `deploy_token` (one-time-witness constraint) | + +## Core Functions + +| Function | Purpose | Access | +|----------|---------|--------| +| `initialize` | One-shot: set MPC signer address + chain id | Admin | +| `init_transfer` | Send tokens from Sui: burns (bridged) or locks (native), collects optional `Coin` native fee | Public | +| `fin_transfer` | Receive tokens: verifies MPC signature, marks `destination_nonce`, mints or unlocks to `recipient` | Public | +| `deploy_token` | Bind a pre-published coin to a signed MetadataPayload; takes `TreasuryCap` + `UpgradeCap` (frozen) + `CoinMetadata` | Public (signature-authorized) | +| `log_metadata` / `log_metadata_registry` | Emit `LogMetadata` for an existing coin (classic `CoinMetadata` / new `coin_registry::Currency`) | Public | +| `set_token_metadata` | Update `description` / `icon_url` on a bridge-deployed coin | MetadataAdmin | +| `set_near_bridge_derived_address` / `set_chain_id` | Correct the MPC signer address / chain id after `initialize` (both baked into the signed preimage, so both are recoverable) | Admin | +| `set_pause_flags` / `pause_all` | Pause bitmap (`0x01` init, `0x02` fin, `0x04` deploy) | Admin / Pauser | +| `grant_role` / `revoke_role` | Role management (last-admin guard) | Admin | +| `migrate` | Bump shared-object version after a package upgrade | Admin | +| Views | `is_transfer_finalised`, `get_token_address`, `get_coin_type`, `is_bridge_token`, `locked_balance`, `current_origin_nonce`, `pause_flags`, `chain_id`, `role_holders`, `has_role`, `all_roles` | — | + +## Borsh / signature encoding + +Payloads are byte-identical to the Aptos/Starknet layout; the 32-byte +`token_address` slot carries the keccak type id and `recipient` a native +32-byte Sui address. The destination chain id byte is interleaved before +both (OmniAddress enum tag) and bound into the signed hash, not the +payload. `fee_recipient` is a tagged Borsh `Option`; `message` is +UNTAGGED (empty ⇒ zero bytes; else u32-LE length + bytes). + +**Critical Sui difference**: `ecdsa_k1::secp256k1_ecrecover(sig, msg, 0)` +takes the RAW message and hashes it internally with keccak256 — pass the +borsh payload itself, never a digest (a naive Aptos port double-hashes and +always fails). Signature is 65 bytes `r||s||v`; NEAR emits +`v = recovery_id + 27`, normalized to {0,1} before the native call. +Ethereum address = last 20 bytes of `keccak256(decompressed_pubkey[1..65])`. + +## Important Notes + +### Design Decisions +1. **`deploy_token` cannot create the coin** (one-time-witness rule) — it + binds a pre-published `TreasuryCap`. Binding checks: zero supply, + version-1 `UpgradeCap` for `T`'s defining package (then + `make_immutable`), `CoinMetadata` equality with the signed payload. + Front-running with a regulated coin (retained `DenyCapV2`) remains + possible and is a documented, accepted griefing risk (see README) — + regulated-ness is not on-chain-verifiable. +2. **Decimals clamp = 9** (SUI convention; Aptos uses 8). Sui `Coin` + amounts are u64. NEAR does all decimal scaling; the signed amount + arrives pre-scaled — this side only bounds u128 → u64. +3. **`init_transfer` takes an exact `Coin`** (its full value is the + amount) — PTBs make exact splitting trivial client-side; no refund + path. `fee < amount` enforced; fee is bookkeeping inside the amount. +4. **`CoinMetadata` is surrendered** to the bridge in `deploy_token` + (ObjectBag) so `set_token_metadata` can mutate it later — template + coins must NOT freeze their metadata. +5. **Chain id is a parameter** (`initialize`), expected to be + `ChainKind::Sui = 14`; must be reserved with maintainers before + deployment. It is interleaved as the OmniAddress tag byte in every + signed transfer payload, so a wrong value silently rejects all inbound + transfers — `initialize` rejects `0`, and `set_chain_id` (admin) can + correct a wrong non-zero value so a misconfig is never a permanent + brick. +6. **Native SUI wire id** = + `keccak256(b"0000…0002::sui::SUI")` — needed for NEAR's + `get_native_token_address`. + +### Security Invariants +- **No replay**: `destination_nonce` checked + marked used *before* + signature verification and token movement in `fin_transfer`; + `origin_nonce` incremented at the top of `init_transfer`. +- **Signature binds the coin type**: `fin_transfer` derives the payload + `token_address` from `T` itself — a wrong type argument reconstructs + different bytes and fails recovery. +- **No token release without signature**: mint/unlock happens only after + `verify_eth_signature`. +- **Version gate on every state-touching entry point**; the four bridge + operations (init/fin transfer, deploy_token, log_metadata) additionally + require `initialize` to have run, as do the `set_near_bridge_derived_address` + / `set_chain_id` config setters. Role/pause admin entries are + version-gated only, and `migrate` is role-gated only (it must run while + the version is stale). +- **Custody cannot be moved by the deployer**: state is a shared object; + only module code touches the `Bag`/`ObjectBag` fields. The package + `UpgradeCap` is the root of trust — keep it in a multisig. + +### Bitmap layout +`completed_transfers: Table` packs nonces 128-per-slot +(`slot = nonce / 128`, `bit = nonce % 128`) — identical to Aptos. + +## Testing +```sh +cd sui && sui move test # 86 tests +cd sui/token_template && sui move build +``` +Signature-vector tests use real secp256k1 signatures generated offline +(key `0x4c0883…a033`, the well-known test key); the fin_transfer happy +path doubles as a byte-exactness proof of the payload encoder against an +independent Python implementation. + +## File References +- Main contract: [sources/omni_bridge.move](sources/omni_bridge.move) +- Payload types and Borsh: [sources/bridge_types.move](sources/bridge_types.move) +- Borsh primitives: [sources/borsh.move](sources/borsh.move) +- Signature verification / identity helpers: [sources/utils.move](sources/utils.move) +- Token template: [token_template/sources/template_coin.move](token_template/sources/template_coin.move) diff --git a/sui/Move.lock b/sui/Move.lock new file mode 100644 index 000000000..a4f9a502d --- /dev/null +++ b/sui/Move.lock @@ -0,0 +1,23 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "b124567746b3a78a7e294ac2de265f693401ec9d" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.OmniBridge] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "b124567746b3a78a7e294ac2de265f693401ec9d" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } diff --git a/sui/Move.toml b/sui/Move.toml new file mode 100644 index 000000000..f673d8e07 --- /dev/null +++ b/sui/Move.toml @@ -0,0 +1,8 @@ +[package] +name = "OmniBridge" +edition = "2024.beta" +version = "0.1.0" +authors = ["Near One"] + +[addresses] +omni_bridge = "0x0" diff --git a/sui/README.md b/sui/README.md new file mode 100644 index 000000000..767ee66b1 --- /dev/null +++ b/sui/README.md @@ -0,0 +1,131 @@ +# Omni Bridge — Sui + +Sui side of the NEAR Omni Bridge. Enables token transfers between Sui and +other chains via NEAR Protocol (Sui ↔ NEAR ↔ other chain). Mirrors the +[Aptos](../aptos) and [Starknet](../starknet) implementations. + +## Trust model + +- **Sui → NEAR** (outbound): the contract emits `InitTransfer` / + `LogMetadata` / `FinTransfer` / `DeployToken` events; the NEAR MPC + network reads them from Sui full nodes (`verify_foreign_transaction`) + and the NEAR-side `mpc-omni-prover` verifies the MPC response. No + Wormhole, no light client. +- **NEAR → Sui** (inbound): `fin_transfer` / `deploy_token` verify an + Ethereum-style secp256k1 signature produced by the NEAR MPC over a + borsh-encoded payload, recovered against the configured + `near_bridge_derived_address` (20 bytes, key path `bridge-1`). + +## Token identity + +Sui coins are *types* (`Coin`), not addresses. The wire-format token id +— what `OmniAddress::Sui` carries on NEAR, what events emit as +`token_address`, and what the signed `TransferMessagePayload` contains — +is: + +``` +keccak256(canonical_type_string(T)) +``` + +where the canonical type string is the on-chain `std::type_name` form: +64-char lowercase hex defining-package id, **no `0x` prefix**, +`::module::NAME` (e.g. `0000…0002::sui::SUI`). Events also carry the type +string in a `coin_type` field, and the bridge keeps an on-chain +`token_registry` (id → type) so relayers/indexers can resolve ids without +external state. Sui-native coins are onboarded with `log_metadata` +(classic `CoinMetadata`) or `log_metadata_registry` (coins under +the newer `coin_registry` Currency standard that may have no legacy +metadata object). Native SUI's token id is +`keccak256(b"0000000000000000000000000000000000000000000000000000000000000002::sui::SUI")` += `0x669638…df700c`. + +## Deployment + +1. `sui client publish` — `init` creates the shared `BridgeState` with the + publisher holding the `Admin` / `Pauser` / `MetadataAdmin` roles. +2. `initialize(state, near_bridge_derived_address, chain_id)` — one-shot, + Admin-gated. `chain_id` is the `ChainKind::Sui` discriminant on NEAR + (expected **14** — must be reserved with the omni-bridge maintainers + before mainnet deployment). Every bridge operation aborts until this + has run. +3. Guard the package `UpgradeCap` (multisig) — it is the real root of + trust for upgrades. The shared state carries a `version` gate + + `migrate` entry point for the upgrade flow. + +## Deploying a bridged token (NEAR-originated token on Sui) + +Sui cannot create a currency at runtime (`create_currency` requires a +one-time witness, which only exists in a fresh package's `init`), so +unlike Aptos this is a two-transaction flow: + +1. Copy [`token_template/`](token_template), rename the module + OTW + struct, set `decimals = min(origin_decimals, 9)`, `symbol`, `name` to + the values from the MPC-signed `MetadataPayload`, and publish it. The + publisher receives the `TreasuryCap`, `CoinMetadata` and `UpgradeCap`. +2. Call `deploy_token(state, signature, token, name, symbol, decimals, + treasury_cap, upgrade_cap, coin_metadata)`. The bridge verifies the + MPC signature and binds `T` to the NEAR token id after checking: + - `TreasuryCap` total supply is zero, + - the `UpgradeCap` controls `T`'s defining package at version 1 — it + is then made immutable (one coin per package, forever), + - `CoinMetadata` name/symbol equal the signed payload and decimals + equal the clamped value. + +The `DeployToken` event is then proven to NEAR (`bind_token`). + +### Known residual risk (accepted design trade-off) + +The MPC-signed `MetadataPayload` contains only +`(near_token_id, name, symbol, decimals)` — it *cannot* name the Sui coin +type, because package ids don't exist when NEAR signs. `deploy_token` is +deliberately permissionless (parity with the sibling chains), so a +front-runner watching NEAR for deploy signatures can bind their own +metadata-matching coin first. The binding checks make such a coin +functionally identical to an honest one, **except** a coin pre-created as +a *regulated* currency: the attacker would retain its `DenyCapV2` and +could later freeze transfers of that bridged token (griefing, not theft — +but the binding is permanent). Regulated-ness is not verifiable on-chain +today. Operational mitigations: relayers should submit `deploy_token` +promptly after the signature appears, and `PAUSE_DEPLOY_TOKEN` (0x04) can +gate the window. + +## Native fees + +`init_transfer` collects the optional `native_fee` as `Coin` into +bridge custody. Custodied native fees back the wrapped-native SUI minted +to fee recipients on NEAR and can leave custody again through a regular +`fin_transfer`. + +## Testing + +```sh +cd sui +sui move test +``` + +Coverage highlights: byte-exact borsh payload layouts, real secp256k1 +signature vectors (generated offline; positive + negative), end-to-end +lock→unlock and deploy→mint→burn flows, nonce-bitmap word boundaries, +role/pause/version gates, deploy_token binding guards. + +## NEAR-side status + +Mirroring the Aptos rollout (PRs #626 / #629): + +- **Done**: `ChainKind::Sui` (= 14) + `OmniAddress::Sui(H256)` wiring in + `omni-types` (`new_zero`, `new_from_slice`, `get_token_prefix` → + `hashed_token_prefix("sui", …)`, `get_native_token_address` → the + keccak constant above), the origin-chain token-prefix match in + `omni-bridge`, and the enum-stability tests. + +Remaining follow-ups: + +- `near/omni-types/src/sui/events.rs` parsers — blocked on near/mpc + defining the Sui read support (`SuiRpcRequest` / `SuiExtractedValue` / + `SuiFinality`), which does not exist yet as of 2026-07. + The emitter for the factory check should be the event type's + **defining package id** parsed from the event type tag (stable across + package upgrades), analogous to the Aptos type-tag-address rule. +- `MpcFinality::Sui` + dispatch in `mpc-omni-prover`. +- DAO calls: `add_factory(OmniAddress::Sui(...))`, `add_prover`, + `add_token_deployer`, `deploy_native_token` for wrapped SUI. diff --git a/sui/sources/borsh.move b/sui/sources/borsh.move new file mode 100644 index 000000000..9b7ec2e06 --- /dev/null +++ b/sui/sources/borsh.move @@ -0,0 +1,32 @@ +/// Borsh encoding helpers used to serialize cross-chain payloads. +/// +/// Bridge payloads must be byte-identical to the encoding produced by the +/// NEAR side of the bridge so that the recovered signer matches +/// `near_bridge_derived_address`. +/// +/// For fixed-width unsigned integers and `address`, Sui's native BCS +/// encoding is byte-identical to Borsh, so call sites use `bcs::to_bytes` +/// directly — no wrapper. Sequences are the exception: Borsh uses a fixed +/// 4-byte little-endian length prefix where BCS uses ULEB128, so the +/// helpers below encode that prefix explicitly. +module omni_bridge::borsh; + +use std::string::String; + +/// Borsh-style byte vector: 4-byte little-endian length + bytes. +public fun encode_byte_vec(val: &vector): vector { + let len = val.length() as u32; + let mut result = vector[ + (len & 0xFF) as u8, + ((len >> 8) & 0xFF) as u8, + ((len >> 16) & 0xFF) as u8, + ((len >> 24) & 0xFF) as u8, + ]; + result.append(*val); + result +} + +/// Borsh-style string: 4-byte little-endian length + UTF-8 bytes. +public fun encode_string(val: &String): vector { + encode_byte_vec(val.as_bytes()) +} diff --git a/sui/sources/bridge_types.move b/sui/sources/bridge_types.move new file mode 100644 index 000000000..6da46963d --- /dev/null +++ b/sui/sources/bridge_types.move @@ -0,0 +1,151 @@ +/// Cross-chain payload structs and their Borsh encodings. +/// +/// The Borsh layout in each `*_to_borsh` is byte-compatible with the +/// Aptos / Starknet / EVM siblings: see `aptos/sources/bridge_types.move`, +/// `starknet/src/bridge_types.cairo` and +/// `evm/src/omni-bridge/contracts/OmniBridge.sol`. The only difference on +/// Sui is what the 32-byte `token_address` denotes: the keccak256 hash of +/// the coin's canonical type string (see `utils::token_address_bytes`), +/// since Sui coins are types, not addresses. +/// +/// Signatures are passed around as a single 65-byte `r || s || v` vector — +/// exactly what the NEAR MPC emits. +module omni_bridge::bridge_types; + +use omni_bridge::borsh; +use std::bcs; +use std::string::String; + +// Payload type tags — must match the rust `PayloadType` enum on NEAR. +const PAYLOAD_TYPE_TRANSFER_MESSAGE: u8 = 0; +const PAYLOAD_TYPE_METADATA: u8 = 1; + +/// `deploy_token` payload signed by the NEAR MPC. +public struct MetadataPayload has copy, drop { + /// NEAR token account id of the token being deployed. + token: String, + name: String, + symbol: String, + decimals: u8, +} + +/// `fin_transfer` payload signed by the NEAR MPC. +public struct TransferMessagePayload has copy, drop { + destination_nonce: u64, + origin_chain: u8, + origin_nonce: u64, + /// keccak256 of the coin's canonical type string. + token_address: address, + amount: u128, + recipient: address, + fee_recipient: Option, + /// Empty vector == no message (NEAR never signs `Some(empty)`). + message: vector, +} + +// -------- Constructors -------- + +public fun new_metadata_payload( + token: String, + name: String, + symbol: String, + decimals: u8, +): MetadataPayload { + MetadataPayload { token, name, symbol, decimals } +} + +public fun new_transfer_message_payload( + destination_nonce: u64, + origin_chain: u8, + origin_nonce: u64, + token_address: address, + amount: u128, + recipient: address, + fee_recipient: Option, + message: vector, +): TransferMessagePayload { + TransferMessagePayload { + destination_nonce, + origin_chain, + origin_nonce, + token_address, + amount, + recipient, + fee_recipient, + message, + } +} + +// -------- Accessors -------- + +public fun metadata_token(self: &MetadataPayload): String { + self.token +} + +public fun metadata_name(self: &MetadataPayload): String { + self.name +} + +public fun metadata_symbol(self: &MetadataPayload): String { + self.symbol +} + +public fun metadata_decimals(self: &MetadataPayload): u8 { + self.decimals +} + +public fun transfer_fee_recipient(self: &TransferMessagePayload): Option { + self.fee_recipient +} + +public fun transfer_message(self: &TransferMessagePayload): vector { + self.message +} + +// -------- Borsh encoding -------- + +/// Borsh encoding of `MetadataPayload`. Byte-identical to Aptos / Starknet +/// / EVM. +public fun metadata_to_borsh(self: &MetadataPayload): vector { + let mut buf = vector[PAYLOAD_TYPE_METADATA]; + buf.append(borsh::encode_string(&self.token)); + buf.append(borsh::encode_string(&self.name)); + buf.append(borsh::encode_string(&self.symbol)); + buf.push_back(self.decimals); + buf +} + +/// Borsh encoding of `TransferMessagePayload`. Byte-identical to the +/// sibling chains. `chain_id` is interleaved as the OmniAddress tag before +/// each of `token_address` and `recipient` and is bound into the signed +/// hash (not the payload), preventing cross-chain replay. +public fun transfer_message_to_borsh( + self: &TransferMessagePayload, + chain_id: u8, +): vector { + let mut buf = vector[PAYLOAD_TYPE_TRANSFER_MESSAGE]; + buf.append(bcs::to_bytes(&self.destination_nonce)); + buf.push_back(self.origin_chain); + buf.append(bcs::to_bytes(&self.origin_nonce)); + buf.push_back(chain_id); + buf.append(bcs::to_bytes(&self.token_address)); + buf.append(bcs::to_bytes(&self.amount)); + buf.push_back(chain_id); + buf.append(bcs::to_bytes(&self.recipient)); + + if (self.fee_recipient.is_some()) { + buf.push_back(1); + buf.append(borsh::encode_string(self.fee_recipient.borrow())); + } else { + buf.push_back(0); + }; + + // Note: matches Aptos / Starknet — `message` is NOT wrapped in an + // Option byte tag. Empty contributes nothing; non-empty contributes + // only the length-prefixed bytes. + if (!self.message.is_empty()) { + buf.append(borsh::encode_byte_vec(&self.message)); + }; + + buf +} diff --git a/sui/sources/omni_bridge.move b/sui/sources/omni_bridge.move new file mode 100644 index 000000000..ff36327fd --- /dev/null +++ b/sui/sources/omni_bridge.move @@ -0,0 +1,1000 @@ +/// Sui side of the NEAR Omni Bridge. +/// +/// Cross-chain bridge contract enabling token transfers between Sui and +/// other chains via NEAR Protocol. All transfers route through NEAR +/// (Sui <-> NEAR <-> other chain). Security is rooted in Ethereum-style +/// ECDSA signatures by the NEAR MPC, verified against +/// `near_bridge_derived_address`; Sui -> NEAR proofs are MPC reads of the +/// events emitted here (no Wormhole). +/// +/// See [aptos/sources/omni_bridge.move] and +/// [starknet/src/omni_bridge.cairo] for the sibling implementations whose +/// payload encodings this module mirrors. Sui-specific differences: +/// - Coins are types, not addresses: the wire-format `token_address` is +/// `keccak256(canonical type string of T)` (see `utils`). +/// - Coins cannot be created at runtime (one-time-witness rule), so +/// `deploy_token` binds a `TreasuryCap` from a pre-published +/// per-token package instead of creating the token itself. +/// - State lives in one shared `BridgeState` object; `init` cannot take +/// parameters, so the MPC signer address and chain id are set by a +/// one-shot admin `initialize` call after publish. +module omni_bridge::omni_bridge; + +use omni_bridge::bridge_types; +use omni_bridge::utils; +use std::string::String; +use std::type_name::{Self, TypeName}; +use sui::bag::{Self, Bag}; +use sui::balance::Balance; +use sui::coin::{Self, Coin, CoinMetadata, TreasuryCap}; +use sui::coin_registry; +use sui::event; +use sui::object_bag::{Self, ObjectBag}; +use sui::package::UpgradeCap; +use sui::sui::SUI; +use sui::table::{Self, Table}; + +// -------- Errors -------- +// Values 1-12 match the Aptos contract; 13+ are Sui-specific. +// Numeric values are part of the test contract - never reorder. + +// Lifecycle / auth +const E_ALREADY_INITIALIZED: u64 = 1; +/// Caller does not hold the role required for this call. The role +/// being checked is implicit from the entry point - see the assert +/// site for which `ROLE_*` was required. +const E_UNAUTHORIZED: u64 = 2; + +// Pause flags +const E_INIT_TRANSFER_PAUSED: u64 = 3; +const E_FIN_TRANSFER_PAUSED: u64 = 4; +const E_DEPLOY_TOKEN_PAUSED: u64 = 5; + +// Deploy token +const E_TOKEN_ALREADY_DEPLOYED: u64 = 6; + +// Transfer +const E_NONCE_ALREADY_USED: u64 = 7; +const E_ZERO_AMOUNT: u64 = 8; +const E_INVALID_FEE: u64 = 9; +const E_AMOUNT_OVERFLOW: u64 = 10; + +// Metadata +const E_NOT_BRIDGE_TOKEN: u64 = 11; + +// Role management +/// Revoking would leave the `Admin` role with zero holders - would +/// brick the bridge (no one could grant/revoke roles again). +const E_CANNOT_REMOVE_LAST_ADMIN: u64 = 12; + +// Sui-specific +/// `initialize` has not been called yet (no MPC signer configured). +const E_NOT_INITIALIZED: u64 = 13; +/// Shared object version does not match this package version - the +/// caller is running against stale (or too-new) code. See `migrate`. +const E_WRONG_VERSION: u64 = 14; +/// The coin type is already registered with the bridge. +const E_TYPE_ALREADY_USED: u64 = 15; +/// `deploy_token` requires a `TreasuryCap` with zero total supply. +const E_SUPPLY_NOT_ZERO: u64 = 16; +/// The `UpgradeCap` does not control the coin's defining package at +/// version 1. +const E_INVALID_UPGRADE_CAP: u64 = 17; +/// `CoinMetadata` does not match the MPC-signed metadata payload. +const E_METADATA_MISMATCH: u64 = 18; +/// `migrate` called but the shared object is already at this version. +const E_NOT_MIGRATION: u64 = 19; +/// The MPC signer address must be exactly 20 bytes. +const E_INVALID_DERIVED_ADDRESS: u64 = 20; +/// `chain_id` must be non-zero (0 is the unconfigured sentinel). +const E_INVALID_CHAIN_ID: u64 = 21; + +/// Largest amount that fits in `u64`, used to bound `u128` payload +/// amounts before they're handed to the Sui coin APIs. +const MAX_U64_AS_U128: u128 = 0xFFFFFFFFFFFFFFFF; + +// -------- Pause flags -------- + +const PAUSE_INIT_TRANSFER: u8 = 0x01; +const PAUSE_FIN_TRANSFER: u8 = 0x02; +const PAUSE_DEPLOY_TOKEN: u8 = 0x04; +const PAUSE_ALL: u8 = 0xFF; + +/// Bitmap word width - keep at 128 so each entry packs nonces +/// [n*128, n*128+127]. +const BITMAP_WIDTH: u64 = 128; + +/// Version of the `BridgeState` shared-object layout/logic. Every entry +/// point asserts it so users cannot execute against a stale package after +/// an upgrade (Sui upgrades create a new package address while the old +/// code keeps running). Bump on upgrade and ship a `migrate` path. +const VERSION: u64 = 1; + +/// Privileged on-chain role discriminants. Each role is a `u8` so the +/// numbering stays aligned with the Aptos/Starknet siblings. Adding a +/// new role only requires: +/// 1. add a `ROLE_*` const + an entry in `all_roles()` +/// 2. populate it in `init` (or grant it via `Admin` using `grant_role`) +/// 3. assert it at the gated call site +/// +/// Numeric values are part of the on-chain ABI - never reorder. +const ROLE_ADMIN: u8 = 0; +const ROLE_PAUSER: u8 = 1; +const ROLE_METADATA_ADMIN: u8 = 2; + +/// Top-level bridge state: a single shared object created at publish. +public struct BridgeState has key { + id: UID, + /// See `VERSION`. + version: u64, + /// Role discriminant -> list of holder addresses. Each role can + /// have any number of holders; all of them are equally privileged. + /// See the `ROLE_*` constants for the discriminant values. + roles: Table>, + /// Bitfield of paused operations. See `PAUSE_*` constants. + pause_flags: u8, + /// 20-byte recovered address of the NEAR MPC-derived Ethereum signer. + /// Empty until `initialize` - every bridge operation aborts until set. + near_bridge_derived_address: vector, + /// Chain id of this bridge instance (mixed into transfer payload + /// hashes to prevent cross-chain replay). The `ChainKind::Sui` + /// discriminant on NEAR. + chain_id: u8, + /// Monotonically increasing origin nonce assigned to outbound + /// transfers. + current_origin_nonce: u64, + /// Bitmap of finalized destination nonces. `slot = nonce / 128`, + /// `bit = nonce % 128`. + completed_transfers: Table, + /// Locked-coin custody (and collected SUI native fees): + /// `TypeName -> Balance`. + custody: Bag, + /// Bridge-deployed token treasuries: `TypeName -> TreasuryCap`. + /// Presence of a type here is the single source of truth for + /// "is a bridge token". + treasuries: ObjectBag, + /// `CoinMetadata` objects surrendered by `deploy_token`, kept + /// bridge-owned so `set_token_metadata` can mutate them: + /// `TypeName -> CoinMetadata`. + metadata_objects: ObjectBag, + /// NEAR token account id -> bridge-deployed coin type. + near_to_sui_token: Table, + /// keccak256(type string) -> coin type. Reverse map so relayers and + /// indexers can resolve wire-format token ids to concrete types. + /// Populated by `log_metadata` and `deploy_token`. + token_registry: Table, +} + +// -------- Events -------- + +public struct InitTransfer has copy, drop { + sender: address, + token_address: address, + coin_type: String, + origin_nonce: u64, + amount: u128, + fee: u128, + native_fee: u128, + recipient: String, + message: vector, +} + +public struct FinTransfer has copy, drop { + origin_chain: u8, + origin_nonce: u64, + token_address: address, + coin_type: String, + amount: u128, + recipient: address, + fee_recipient: Option, + message: vector, +} + +public struct DeployToken has copy, drop { + token_address: address, + coin_type: String, + near_token_id: String, + name: String, + symbol: String, + decimals: u8, + origin_decimals: u8, +} + +public struct LogMetadata has copy, drop { + token_address: address, + coin_type: String, + name: String, + symbol: String, + decimals: u8, +} + +public struct PauseStateChanged has copy, drop { + old_flags: u8, + new_flags: u8, + admin: address, +} + +// Emitted on `set_token_metadata`. `description` / `icon_url` are +// `None` for fields the caller did not change. +public struct TokenMetadataChanged has copy, drop { + token_address: address, + coin_type: String, + description: Option, + icon_url: Option, + admin: address, +} + +// -------- Initialization -------- + +/// Runs exactly once at package publish. Creates the shared bridge state +/// with the publisher as the sole holder of every role. The MPC signer +/// address and chain id cannot be passed here (Sui `init` takes no +/// parameters) - call `initialize` next. +fun init(ctx: &mut TxContext) { + let sender = ctx.sender(); + let mut roles = table::new>(ctx); + roles.add(ROLE_ADMIN, vector[sender]); + roles.add(ROLE_PAUSER, vector[sender]); + roles.add(ROLE_METADATA_ADMIN, vector[sender]); + + transfer::share_object(BridgeState { + id: object::new(ctx), + version: VERSION, + roles, + pause_flags: 0, + near_bridge_derived_address: vector[], + chain_id: 0, + current_origin_nonce: 0, + completed_transfers: table::new(ctx), + custody: bag::new(ctx), + treasuries: object_bag::new(ctx), + metadata_objects: object_bag::new(ctx), + near_to_sui_token: table::new(ctx), + token_registry: table::new(ctx), + }); +} + +/// One-shot post-publish configuration. Callable once by an `Admin`; +/// every bridge operation aborts with `E_NOT_INITIALIZED` until this +/// has run. `chain_id` is the `ChainKind::Sui` discriminant on NEAR +/// (expected 14) — it is interleaved as the OmniAddress tag byte in every +/// signed transfer payload, so it MUST match NEAR's discriminant or all +/// inbound `fin_transfer`s fail signature verification. `0` is rejected +/// (it is the unconfigured sentinel); a wrong non-zero value is +/// recoverable via `set_chain_id`. +public fun initialize( + state: &mut BridgeState, + near_bridge_derived_address: vector, + chain_id: u8, + ctx: &TxContext, +) { + assert_version(state); + assert_role(state, ROLE_ADMIN, ctx.sender()); + assert!(state.near_bridge_derived_address.is_empty(), E_ALREADY_INITIALIZED); + assert!(near_bridge_derived_address.length() == 20, E_INVALID_DERIVED_ADDRESS); + assert!(chain_id != 0, E_INVALID_CHAIN_ID); + state.near_bridge_derived_address = near_bridge_derived_address; + state.chain_id = chain_id; +} + +// -------- Admin -------- + +/// Add `new_holder` to the set of `role` holders. No-op if the +/// address already holds the role. Caller must hold `ROLE_ADMIN`. +/// Works for every role, including `ROLE_ADMIN` itself. +public fun grant_role( + state: &mut BridgeState, + role: u8, + new_holder: address, + ctx: &TxContext, +) { + assert_version(state); + assert_role(state, ROLE_ADMIN, ctx.sender()); + add_role_holder(state, role, new_holder); +} + +/// Remove `holder` from the set of `role` holders. No-op if the +/// address does not hold the role. Caller must hold `ROLE_ADMIN`. +/// Refuses to remove the last `ROLE_ADMIN` holder, which would brick +/// the bridge's role management. +public fun revoke_role( + state: &mut BridgeState, + role: u8, + holder: address, + ctx: &TxContext, +) { + assert_version(state); + assert_role(state, ROLE_ADMIN, ctx.sender()); + remove_role_holder(state, role, holder); +} + +/// Rotate the NEAR MPC signer address. Admin-only. +public fun set_near_bridge_derived_address( + state: &mut BridgeState, + new_address: vector, + ctx: &TxContext, +) { + assert_version(state); + assert_role(state, ROLE_ADMIN, ctx.sender()); + assert_configured(state); + assert!(new_address.length() == 20, E_INVALID_DERIVED_ADDRESS); + state.near_bridge_derived_address = new_address; +} + +/// Correct the configured `chain_id`. Admin-only. Needed because +/// `chain_id` is baked into the signed transfer-payload preimage: a wrong +/// value silently rejects every inbound transfer, and `initialize` is +/// one-shot, so without this setter a misconfiguration would be an +/// unrecoverable brick. Rejects `0` (the unconfigured sentinel). +public fun set_chain_id(state: &mut BridgeState, new_chain_id: u8, ctx: &TxContext) { + assert_version(state); + assert_role(state, ROLE_ADMIN, ctx.sender()); + assert_configured(state); + assert!(new_chain_id != 0, E_INVALID_CHAIN_ID); + state.chain_id = new_chain_id; +} + +public fun set_pause_flags(state: &mut BridgeState, flags: u8, ctx: &TxContext) { + assert_version(state); + assert_role(state, ROLE_ADMIN, ctx.sender()); + let old = state.pause_flags; + state.pause_flags = flags; + event::emit(PauseStateChanged { + old_flags: old, + new_flags: flags, + admin: ctx.sender(), + }); +} + +public fun pause_all(state: &mut BridgeState, ctx: &TxContext) { + assert_version(state); + assert_role(state, ROLE_PAUSER, ctx.sender()); + let old = state.pause_flags; + state.pause_flags = PAUSE_ALL; + event::emit(PauseStateChanged { + old_flags: old, + new_flags: PAUSE_ALL, + admin: ctx.sender(), + }); +} + +/// Bring the shared object up to this package's `VERSION` after an +/// upgrade. Admin-only. Any state added in future versions must be +/// created here (`init` does not rerun on upgrades). +public fun migrate(state: &mut BridgeState, ctx: &TxContext) { + assert_role(state, ROLE_ADMIN, ctx.sender()); + assert!(state.version < VERSION, E_NOT_MIGRATION); + state.version = VERSION; +} + +// -------- Token discovery -------- + +/// Permissionless: emit a `LogMetadata` event describing an existing coin. +/// The NEAR side picks this event up (via MPC read) to decide whether to +/// sign a `deploy_token` payload for the mirror token on its side. Also +/// registers the coin type in `token_registry` so off-chain actors can +/// resolve the 32-byte token id back to the concrete type. +public fun log_metadata( + state: &mut BridgeState, + coin_metadata: &CoinMetadata, +) { + assert_version(state); + assert_configured(state); + + register_coin_type(state); + + event::emit(LogMetadata { + token_address: utils::token_address(), + coin_type: utils::coin_type_string(), + name: coin::get_name(coin_metadata), + symbol: coin::get_symbol(coin_metadata).to_string(), + decimals: coin::get_decimals(coin_metadata), + }); +} + +/// `log_metadata` variant for coins created under the newer +/// `sui::coin_registry` Currency standard, which may have no legacy +/// `CoinMetadata` object at all. +public fun log_metadata_registry( + state: &mut BridgeState, + currency: &coin_registry::Currency, +) { + assert_version(state); + assert_configured(state); + + register_coin_type(state); + + event::emit(LogMetadata { + token_address: utils::token_address(), + coin_type: utils::coin_type_string(), + name: coin_registry::name(currency), + symbol: coin_registry::symbol(currency), + decimals: coin_registry::decimals(currency), + }); +} + +// -------- Bridge operations -------- + +/// Register a bridged coin for a NEAR token. Anyone may submit the +/// transaction - security comes from the NEAR MPC signature over the +/// payload, not access control (parity with the sibling chains). +/// +/// Sui cannot create a currency at runtime (`create_currency` needs the +/// one-time witness of `T`), so unlike Aptos the coin arrives +/// pre-published (see `token_template/`) and this call BINDS it to the +/// signed payload. The signed payload cannot name `T`, so the binding is +/// constrained instead: +/// - `treasury_cap` must have zero supply (protects NEAR's +/// locked-token accounting), +/// - `upgrade_cap` must control `T`'s defining package at version 1 +/// and is made immutable here (no future upgrades, one coin per +/// package), +/// - `coin_metadata` must match the signed name/symbol and the clamped +/// decimals, and is surrendered to the bridge so only +/// `set_token_metadata` can mutate it. +/// +/// Residual risk (accepted, documented in the README): a front-runner +/// can bind their own coin matching all of the above; such a coin is +/// functionally identical unless it was created as a regulated currency, +/// whose retained `DenyCapV2` allows freezing transfers later - +/// regulated-ness is not verifiable on-chain today. +public fun deploy_token( + state: &mut BridgeState, + signature: vector, + token: String, + name: String, + symbol: String, + decimals: u8, + treasury_cap: TreasuryCap, + upgrade_cap: UpgradeCap, + coin_metadata: CoinMetadata, +) { + assert_version(state); + assert_configured(state); + assert!((state.pause_flags & PAUSE_DEPLOY_TOKEN) == 0, E_DEPLOY_TOKEN_PAUSED); + + let payload = bridge_types::new_metadata_payload(token, name, symbol, decimals); + let encoded = payload.metadata_to_borsh(); + utils::verify_eth_signature(&encoded, &signature, &state.near_bridge_derived_address); + + let token_id = payload.metadata_token(); + assert!(!state.near_to_sui_token.contains(token_id), E_TOKEN_ALREADY_DEPLOYED); + let key = type_name::with_defining_ids(); + assert!(!state.treasuries.contains(key), E_TYPE_ALREADY_USED); + + assert!(coin::total_supply(&treasury_cap) == 0, E_SUPPLY_NOT_ZERO); + + assert!( + upgrade_cap.upgrade_package().to_address() == utils::type_package_address() && + upgrade_cap.version() == 1, + E_INVALID_UPGRADE_CAP, + ); + sui::package::make_immutable(upgrade_cap); + + let normalized_decimals = utils::normalize_decimals(payload.metadata_decimals()); + assert!( + coin::get_decimals(&coin_metadata) == normalized_decimals && + coin::get_name(&coin_metadata) == payload.metadata_name() && + *coin::get_symbol(&coin_metadata).as_bytes() == *payload.metadata_symbol().as_bytes(), + E_METADATA_MISMATCH, + ); + + state.treasuries.add(key, treasury_cap); + state.metadata_objects.add(key, coin_metadata); + state.near_to_sui_token.add(token_id, key); + register_coin_type(state); + + event::emit(DeployToken { + token_address: utils::token_address(), + coin_type: utils::coin_type_string(), + near_token_id: token_id, + name: payload.metadata_name(), + symbol: payload.metadata_symbol(), + decimals: normalized_decimals, + origin_decimals: payload.metadata_decimals(), + }); +} + +/// Update mutable metadata (`description`, `icon_url`) on a +/// bridge-deployed coin. `None` fields are left unchanged. Gated on the +/// `MetadataAdmin` role (separate from the main admin so metadata +/// refreshes don't require the high-privilege admin key). Aborts if `T` +/// is not a bridge-deployed token. +public fun set_token_metadata( + state: &mut BridgeState, + description: Option, + icon_url: Option, + ctx: &TxContext, +) { + assert_version(state); + assert_role(state, ROLE_METADATA_ADMIN, ctx.sender()); + assert!(is_bridge_token(state), E_NOT_BRIDGE_TOKEN); + + let key = type_name::with_defining_ids(); + let cap = state.treasuries.borrow>(key); + let coin_metadata = + state.metadata_objects.borrow_mut>(key); + + if (description.is_some()) { + coin::update_description(cap, coin_metadata, *description.borrow()); + }; + if (icon_url.is_some()) { + coin::update_icon_url(cap, coin_metadata, *icon_url.borrow()); + }; + + event::emit(TokenMetadataChanged { + token_address: utils::token_address(), + coin_type: utils::coin_type_string(), + description, + icon_url, + admin: ctx.sender(), + }); +} + +/// Start an outbound transfer from Sui to another chain. +/// +/// The full value of `coin` is the transfer amount (callers split an +/// exact coin in the same PTB); `fee` is the token-denominated part of +/// that amount claimable by the relayer on NEAR; `native_fee_coin`'s +/// value is the SUI-denominated relayer fee (pass a zero coin for none). +/// `recipient` and `message` are opaque to this module - they get +/// emitted verbatim and decoded by the NEAR side. +public fun init_transfer( + state: &mut BridgeState, + coin: Coin, + fee: u64, + native_fee_coin: Coin, + recipient: String, + message: vector, + ctx: &TxContext, +) { + assert_version(state); + assert_configured(state); + assert!((state.pause_flags & PAUSE_INIT_TRANSFER) == 0, E_INIT_TRANSFER_PAUSED); + + let amount = coin.value(); + assert!(amount > 0, E_ZERO_AMOUNT); + assert!(fee < amount, E_INVALID_FEE); + + // Match the EVM/Starknet/Aptos semantics: increment first, then use. + state.current_origin_nonce = state.current_origin_nonce + 1; + let origin_nonce = state.current_origin_nonce; + + if (is_bridge_token(state)) { + burn_bridge_token(state, coin); + } else { + deposit(state, coin.into_balance()); + }; + + let native_fee = native_fee_coin.value(); + if (native_fee > 0) { + // Native fees join the SUI custody balance: they back the wrapped + // native token minted to the fee recipient on NEAR and can leave + // custody again through a regular `fin_transfer`. + deposit(state, native_fee_coin.into_balance()); + } else { + native_fee_coin.destroy_zero(); + }; + + event::emit(InitTransfer { + sender: ctx.sender(), + token_address: utils::token_address(), + coin_type: utils::coin_type_string(), + origin_nonce, + amount: amount as u128, + fee: fee as u128, + native_fee: native_fee as u128, + recipient, + message, + }); +} + +/// Finalize an inbound transfer from another chain. Permissionless - +/// the NEAR MPC signature is the authorization. The transaction sender +/// is not read on-chain. +/// +/// The wire-format token id is derived from `T` itself +/// (`keccak256(canonical type string)`), so the signature check binds the +/// generic type argument: submitting with the wrong `T` reconstructs a +/// different payload and the signature verification fails. +public fun fin_transfer( + state: &mut BridgeState, + signature: vector, + destination_nonce: u64, + origin_chain: u8, + origin_nonce: u64, + amount: u128, + recipient: address, + fee_recipient: Option, + message: vector, + ctx: &mut TxContext, +) { + assert_version(state); + assert_configured(state); + assert!((state.pause_flags & PAUSE_FIN_TRANSFER) == 0, E_FIN_TRANSFER_PAUSED); + + // Replay protection before anything else (checks-effects-interactions). + assert!( + !is_nonce_used(&state.completed_transfers, destination_nonce), + E_NONCE_ALREADY_USED, + ); + mark_nonce_used(&mut state.completed_transfers, destination_nonce); + + let payload = bridge_types::new_transfer_message_payload( + destination_nonce, + origin_chain, + origin_nonce, + utils::token_address(), + amount, + recipient, + fee_recipient, + message, + ); + let encoded = payload.transfer_message_to_borsh(state.chain_id); + utils::verify_eth_signature(&encoded, &signature, &state.near_bridge_derived_address); + + // Cap to u64 - Sui coin amounts are u64. + assert!(amount <= MAX_U64_AS_U128, E_AMOUNT_OVERFLOW); + let amount_u64 = amount as u64; + + let coin = if (is_bridge_token(state)) { + mint_bridge_token(state, amount_u64, ctx) + } else { + // Locked-token path: release from bridge custody. + coin::from_balance(withdraw(state, amount_u64), ctx) + }; + transfer::public_transfer(coin, recipient); + + event::emit(FinTransfer { + origin_chain, + origin_nonce, + token_address: utils::token_address(), + coin_type: utils::coin_type_string(), + amount, + recipient, + fee_recipient: payload.transfer_fee_recipient(), + message: payload.transfer_message(), + }); +} + +// -------- Views -------- + +public fun is_configured(state: &BridgeState): bool { + !state.near_bridge_derived_address.is_empty() +} + +public fun current_origin_nonce(state: &BridgeState): u64 { + state.current_origin_nonce +} + +public fun pause_flags(state: &BridgeState): u8 { + state.pause_flags +} + +public fun chain_id(state: &BridgeState): u8 { + state.chain_id +} + +/// Return all addresses currently holding `role`. Empty vector if the +/// role has never been populated (won't happen for roles seeded in +/// `init`). +public fun role_holders(state: &BridgeState, role: u8): vector
{ + if (state.roles.contains(role)) { + *state.roles.borrow(role) + } else { + vector[] + } +} + +/// True if `addr` is one of the holders of `role`. +public fun has_role(state: &BridgeState, role: u8, addr: address): bool { + is_role_holder(state, role, addr) +} + +public fun is_transfer_finalised(state: &BridgeState, nonce: u64): bool { + is_nonce_used(&state.completed_transfers, nonce) +} + +/// True iff `T` was registered by this bridge's `deploy_token` (i.e. the +/// bridge holds its `TreasuryCap`). Authoritative - one source of truth. +public fun is_bridge_token(state: &BridgeState): bool { + state.treasuries.contains(type_name::with_defining_ids()) +} + +/// NEAR token id -> deployed coin type string (canonical `type_name` +/// form), if `deploy_token` has run for it. +public fun get_token_address(state: &BridgeState, near_token_id: String): Option { + if (state.near_to_sui_token.contains(near_token_id)) { + let coin_type = *state.near_to_sui_token.borrow(near_token_id); + option::some(coin_type.into_string().to_string()) + } else { + option::none() + } +} + +/// Resolve a 32-byte wire-format token id back to the canonical coin type +/// string. Populated by `log_metadata` and `deploy_token`. +public fun get_coin_type(state: &BridgeState, token_address: address): Option { + if (state.token_registry.contains(token_address)) { + let coin_type = *state.token_registry.borrow(token_address); + option::some(coin_type.into_string().to_string()) + } else { + option::none() + } +} + +/// Coin currently held in custody for `T` (locked transfers + collected +/// native fees for `T = SUI`). +public fun locked_balance(state: &BridgeState): u64 { + let key = type_name::with_defining_ids(); + if (state.custody.contains(key)) { + state.custody.borrow>(key).value() + } else { + 0 + } +} + +// -------- Role registry -------- + +/// Human-readable name + numeric id for a role. Returned by +/// `all_roles()` so off-chain callers can discover the role table +/// without hardcoding `ROLE_*` constants. +public struct RoleInfo has copy, drop { + name: String, + id: u8, +} + +public fun role_info_name(self: &RoleInfo): String { + self.name +} + +public fun role_info_id(self: &RoleInfo): u8 { + self.id +} + +public fun all_roles(): vector { + vector[ + RoleInfo { name: b"Admin".to_string(), id: ROLE_ADMIN }, + RoleInfo { name: b"Pauser".to_string(), id: ROLE_PAUSER }, + RoleInfo { name: b"MetadataAdmin".to_string(), id: ROLE_METADATA_ADMIN }, + ] +} + +// -------- Internal: gates -------- + +fun assert_version(state: &BridgeState) { + assert!(state.version == VERSION, E_WRONG_VERSION); +} + +fun assert_configured(state: &BridgeState) { + assert!(is_configured(state), E_NOT_INITIALIZED); +} + +/// Assert that `who` holds `role`, aborting with `E_UNAUTHORIZED` +/// otherwise. +fun assert_role(state: &BridgeState, role: u8, who: address) { + assert!(is_role_holder(state, role, who), E_UNAUTHORIZED); +} + +// -------- Internal: nonce bitmap -------- + +fun nonce_slot_and_bit(nonce: u64): (u64, u128) { + let slot = nonce / BITMAP_WIDTH; + let bit_pos = nonce % BITMAP_WIDTH; + let bit = 1u128 << (bit_pos as u8); + (slot, bit) +} + +fun is_nonce_used(bitmap: &Table, nonce: u64): bool { + let (slot, bit) = nonce_slot_and_bit(nonce); + if (!bitmap.contains(slot)) { + return false + }; + (*bitmap.borrow(slot) & bit) != 0 +} + +fun mark_nonce_used(bitmap: &mut Table, nonce: u64) { + let (slot, bit) = nonce_slot_and_bit(nonce); + if (bitmap.contains(slot)) { + let word = bitmap.borrow_mut(slot); + *word = *word | bit; + } else { + bitmap.add(slot, bit); + }; +} + +// -------- Internal: custody & bridge tokens -------- + +/// Join `balance` into the custody bag under `T`'s type key. +fun deposit(state: &mut BridgeState, balance: Balance) { + let key = type_name::with_defining_ids(); + if (state.custody.contains(key)) { + state.custody.borrow_mut>(key).join(balance); + } else { + state.custody.add(key, balance); + }; +} + +/// Split `amount` out of the custody bag. Aborts (in `balance::split`) if +/// custody holds less than `amount`; aborts in `bag::borrow_mut` if `T` +/// was never locked at all. +fun withdraw(state: &mut BridgeState, amount: u64): Balance { + let key = type_name::with_defining_ids(); + state.custody.borrow_mut>(key).split(amount) +} + +/// Burn a bridge-deployed coin via its stored `TreasuryCap`. +fun burn_bridge_token(state: &mut BridgeState, coin: Coin) { + let key = type_name::with_defining_ids(); + coin::burn(state.treasuries.borrow_mut>(key), coin); +} + +/// Mint a bridge-deployed coin via its stored `TreasuryCap`. +fun mint_bridge_token( + state: &mut BridgeState, + amount: u64, + ctx: &mut TxContext, +): Coin { + let key = type_name::with_defining_ids(); + coin::mint(state.treasuries.borrow_mut>(key), amount, ctx) +} + +/// Record `T` in the wire-id -> type reverse map (idempotent). +fun register_coin_type(state: &mut BridgeState) { + let token_address = utils::token_address(); + if (!state.token_registry.contains(token_address)) { + state.token_registry.add(token_address, type_name::with_defining_ids()); + }; +} + +// -------- Internal: roles -------- + +/// True if `addr` is one of the holders of `role`. +fun is_role_holder(state: &BridgeState, role: u8, addr: address): bool { + if (!state.roles.contains(role)) { + return false + }; + state.roles.borrow(role).contains(&addr) +} + +/// Add `addr` to `role`. No-op if already present. Caller MUST have +/// already authorized the change. +fun add_role_holder(state: &mut BridgeState, role: u8, addr: address) { + if (!state.roles.contains(role)) { + state.roles.add(role, vector[addr]); + return + }; + let holders = state.roles.borrow_mut(role); + if (!holders.contains(&addr)) { + holders.push_back(addr); + }; +} + +/// Remove `addr` from `role`. No-op if not present. Refuses to remove +/// the last `Admin` holder to keep the bridge governable. +fun remove_role_holder(state: &mut BridgeState, role: u8, addr: address) { + if (!state.roles.contains(role)) { + return + }; + let holders = state.roles.borrow_mut(role); + let (found, idx) = holders.index_of(&addr); + if (!found) { + return + }; + if (role == ROLE_ADMIN) { + assert!(holders.length() > 1, E_CANNOT_REMOVE_LAST_ADMIN); + }; + holders.remove(idx); +} + +// -------- Test helpers -------- + +#[test_only] +public fun init_for_testing(ctx: &mut TxContext) { + init(ctx); +} + +#[test_only] +public fun test_mark_nonce_used(state: &mut BridgeState, nonce: u64) { + mark_nonce_used(&mut state.completed_transfers, nonce); +} + +#[test_only] +public fun set_version_for_testing(state: &mut BridgeState, version: u64) { + state.version = version; +} + +#[test_only] +public fun test_token_description(state: &BridgeState): String { + let key = type_name::with_defining_ids(); + coin::get_description(state.metadata_objects.borrow>(key)) +} + +#[test_only] +public fun test_token_icon_url(state: &BridgeState): Option { + let key = type_name::with_defining_ids(); + coin::get_icon_url(state.metadata_objects.borrow>(key)) +} + +// Event structs have private fields outside this module, so tests build +// expected values through these constructors and compare whole structs. + +#[test_only] +public fun new_init_transfer_event( + sender: address, + token_address: address, + coin_type: String, + origin_nonce: u64, + amount: u128, + fee: u128, + native_fee: u128, + recipient: String, + message: vector, +): InitTransfer { + InitTransfer { + sender, + token_address, + coin_type, + origin_nonce, + amount, + fee, + native_fee, + recipient, + message, + } +} + +#[test_only] +public fun new_fin_transfer_event( + origin_chain: u8, + origin_nonce: u64, + token_address: address, + coin_type: String, + amount: u128, + recipient: address, + fee_recipient: Option, + message: vector, +): FinTransfer { + FinTransfer { + origin_chain, + origin_nonce, + token_address, + coin_type, + amount, + recipient, + fee_recipient, + message, + } +} + +#[test_only] +public fun new_deploy_token_event( + token_address: address, + coin_type: String, + near_token_id: String, + name: String, + symbol: String, + decimals: u8, + origin_decimals: u8, +): DeployToken { + DeployToken { + token_address, + coin_type, + near_token_id, + name, + symbol, + decimals, + origin_decimals, + } +} + +#[test_only] +public fun new_log_metadata_event( + token_address: address, + coin_type: String, + name: String, + symbol: String, + decimals: u8, +): LogMetadata { + LogMetadata { token_address, coin_type, name, symbol, decimals } +} diff --git a/sui/sources/utils.move b/sui/sources/utils.move new file mode 100644 index 000000000..202155545 --- /dev/null +++ b/sui/sources/utils.move @@ -0,0 +1,102 @@ +/// Shared utilities for the Omni Bridge: Ethereum-style signature +/// verification (used to validate MPC-signed payloads from NEAR), decimal +/// normalization, and coin-type identity helpers. +module omni_bridge::utils; + +use std::string::{Self, String}; +use std::type_name; +use sui::address; +use sui::ecdsa_k1; +use sui::hash; + +/// Signature payload could not be parsed. +#[allow(unused_const)] +const E_INVALID_SIGNATURE_LENGTH: u64 = 1; +/// Recovered Ethereum address does not match the expected signer (also used +/// for malformed expected addresses). +#[allow(unused_const)] +const E_INVALID_SIGNATURE: u64 = 3; + +/// Decimals are clamped to 9 (native SUI precision) because Sui `Coin` +/// amounts are `u64` (max ~1.84e19). +const MAX_ALLOWED_DECIMALS: u8 = 9; + +/// Cap decimals at the protocol-wide maximum. +public fun normalize_decimals(decimals: u8): u8 { + if (decimals > MAX_ALLOWED_DECIMALS) { + MAX_ALLOWED_DECIMALS + } else { + decimals + } +} + +/// Verify an Ethereum-style 65-byte signature (`r || s || v`) over +/// `message_bytes`. +/// +/// `v` is the Ethereum recovery id as emitted by the NEAR MPC +/// (`recovery_id + 27`); 0/1 are also accepted. Sui's +/// `secp256k1_ecrecover` hashes the message internally (flag 0 = +/// keccak256), so `message_bytes` is the RAW borsh payload — never a +/// digest. The Ethereum address is the last 20 bytes of +/// `keccak256(uncompressed_pubkey[1..65])`. +public fun verify_eth_signature( + message_bytes: &vector, + signature: &vector, + expected_address: &vector, +) { + assert!(signature.length() == 65, E_INVALID_SIGNATURE_LENGTH); + assert!(expected_address.length() == 20, E_INVALID_SIGNATURE); + + let mut sig = *signature; + let v = &mut sig[64]; + if (*v >= 27) { + *v = *v - 27; + }; + + let compressed = ecdsa_k1::secp256k1_ecrecover(&sig, message_bytes, 0); + let uncompressed = ecdsa_k1::decompress_pubkey(&compressed); + + // Skip the 0x04 prefix; hash the raw 64-byte public key. + let mut pk64 = vector[]; + let mut i = 1; + while (i < 65) { + pk64.push_back(uncompressed[i]); + i = i + 1; + }; + let digest = hash::keccak256(&pk64); + + let mut addr = vector[]; + let mut j = 12; + while (j < 32) { + addr.push_back(digest[j]); + j = j + 1; + }; + + assert!(addr == *expected_address, E_INVALID_SIGNATURE); +} + +/// Canonical coin-type string for `T`: 64 lowercase hex chars (no `0x`) +/// of the DEFINING package id, then `::module::Name`. This exact form is +/// the preimage of the 32-byte token id used on the wire. +public fun coin_type_string(): String { + string::from_ascii(type_name::with_defining_ids().into_string()) +} + +/// 32-byte wire identity of coin type `T`: +/// `keccak256(coin_type_string())`. This is what `OmniAddress::Sui` +/// carries on NEAR and what the MPC-signed `TransferMessagePayload` +/// contains as `token_address`. +public fun token_address_bytes(): vector { + hash::keccak256(coin_type_string().as_bytes()) +} + +/// `token_address_bytes()` as a Sui `address` (for events). +public fun token_address(): address { + address::from_bytes(token_address_bytes()) +} + +/// The DEFINING package id of `T` as an address. Used by `deploy_token` +/// to check that a supplied `UpgradeCap` controls `T`'s package. +public fun type_package_address(): address { + type_name::defining_id() +} diff --git a/sui/tests/borsh_tests.move b/sui/tests/borsh_tests.move new file mode 100644 index 000000000..3eb57f482 --- /dev/null +++ b/sui/tests/borsh_tests.move @@ -0,0 +1,49 @@ +#[test_only] +module omni_bridge::borsh_tests; + +use omni_bridge::borsh; +use std::string; + +#[test] +fun encode_byte_vec_empty() { + let encoded = borsh::encode_byte_vec(&vector[]); + assert!(encoded == vector[0, 0, 0, 0]); +} + +#[test] +fun encode_byte_vec_short() { + let encoded = borsh::encode_byte_vec(&vector[0xAA, 0xBB, 0xCC]); + assert!(encoded == vector[3, 0, 0, 0, 0xAA, 0xBB, 0xCC]); +} + +#[test] +fun encode_byte_vec_multibyte_length() { + // 300 = 0x012C -> little-endian prefix [0x2C, 0x01, 0, 0]. + let mut payload = vector[]; + let mut i = 0u64; + while (i < 300) { + payload.push_back(0x11); + i = i + 1; + }; + let encoded = borsh::encode_byte_vec(&payload); + assert!(encoded.length() == 304); + assert!(encoded[0] == 0x2C); + assert!(encoded[1] == 0x01); + assert!(encoded[2] == 0); + assert!(encoded[3] == 0); + assert!(encoded[4] == 0x11); + assert!(encoded[303] == 0x11); +} + +#[test] +fun encode_string_hello() { + let encoded = borsh::encode_string(&string::utf8(b"hello")); + assert!(encoded == vector[5, 0, 0, 0, 104, 101, 108, 108, 111]); +} + +#[test] +fun encode_string_empty() { + let encoded = borsh::encode_string(&string::utf8(b"")); + assert!(encoded == vector[0, 0, 0, 0]); +} + diff --git a/sui/tests/bridge_types_tests.move b/sui/tests/bridge_types_tests.move new file mode 100644 index 000000000..183894be3 --- /dev/null +++ b/sui/tests/bridge_types_tests.move @@ -0,0 +1,138 @@ +#[test_only] +module omni_bridge::bridge_types_tests; + +use omni_bridge::bridge_types; +use std::string; + +const CHAIN_ID: u8 = 14; + +fun token_address(): address { + @0x1111111111111111111111111111111111111111111111111111111111111111 +} + +fun recipient(): address { + @0x2222222222222222222222222222222222222222222222222222222222222222 +} + +#[test] +fun metadata_payload_layout() { + let payload = bridge_types::new_metadata_payload( + string::utf8(b"wrap.near"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + ); + let encoded = payload.metadata_to_borsh(); + + // 0x01 | str(9) | str(12) | str(5) | u8 = 1 + 13 + 16 + 9 + 1 = 40. + assert!(encoded.length() == 40); + assert!(encoded[0] == 1); // PayloadType::Metadata + assert!(encoded[1] == 9 && encoded[2] == 0 && encoded[3] == 0 && encoded[4] == 0); + assert!(encoded[5] == 0x77); // 'w' of "wrap.near" + assert!(encoded[14] == 12 && encoded[15] == 0); + assert!(encoded[18] == 0x57); // 'W' of "Wrapped NEAR" + assert!(encoded[30] == 5 && encoded[31] == 0); + assert!(encoded[34] == 0x77); // 'w' of "wNEAR" + assert!(encoded[39] == 24); // decimals +} + +#[test] +fun transfer_message_layout_with_fee_recipient() { + let payload = bridge_types::new_transfer_message_payload( + 42, // destination_nonce + 1, // origin_chain (Near) + 7, // origin_nonce + token_address(), + 1_000_000, // amount + recipient(), + option::some(string::utf8(b"relayer.near")), + vector[], // empty message contributes nothing + ); + let encoded = payload.transfer_message_to_borsh(CHAIN_ID); + + // 1 + 8 + 1 + 8 + 1 + 32 + 16 + 1 + 32 + 1 + 4 + 12 = 117. + assert!(encoded.length() == 117); + assert!(encoded[0] == 0); // PayloadType::TransferMessage + // destination_nonce u64 LE + assert!(encoded[1] == 42 && encoded[2] == 0 && encoded[8] == 0); + assert!(encoded[9] == 1); // origin_chain + assert!(encoded[10] == 7 && encoded[17] == 0); // origin_nonce u64 LE + assert!(encoded[18] == CHAIN_ID); // OmniAddress tag for token_address + assert!(encoded[19] == 0x11 && encoded[50] == 0x11); // 32-byte token address + // amount u128 LE: 1_000_000 = 0x0F4240 + assert!(encoded[51] == 0x40 && encoded[52] == 0x42 && encoded[53] == 0x0F); + assert!(encoded[54] == 0 && encoded[66] == 0); + assert!(encoded[67] == CHAIN_ID); // OmniAddress tag for recipient + assert!(encoded[68] == 0x22 && encoded[99] == 0x22); // 32-byte recipient + assert!(encoded[100] == 1); // fee_recipient Option tag: Some + assert!(encoded[101] == 12 && encoded[102] == 0); // fee_recipient length + assert!(encoded[105] == 0x72); // 'r' of "relayer.near" + assert!(encoded[116] == 0x72); // 'r' of "...near"... last byte is 'r' +} + +#[test] +fun transfer_message_layout_no_fee_recipient() { + let payload = bridge_types::new_transfer_message_payload( + 42, + 1, + 7, + token_address(), + 1_000_000, + recipient(), + option::none(), + vector[], + ); + let encoded = payload.transfer_message_to_borsh(CHAIN_ID); + + // 1 + 8 + 1 + 8 + 1 + 32 + 16 + 1 + 32 + 1 = 101. + assert!(encoded.length() == 101); + assert!(encoded[100] == 0); // fee_recipient Option tag: None +} + +#[test] +fun transfer_message_appends_untagged_message() { + let payload = bridge_types::new_transfer_message_payload( + 42, + 1, + 7, + token_address(), + 1_000_000, + recipient(), + option::none(), + vector[0xDE, 0xAD], + ); + let encoded = payload.transfer_message_to_borsh(CHAIN_ID); + + // No Option tag for message: just u32-LE length + bytes. + assert!(encoded.length() == 107); + assert!(encoded[100] == 0); // fee_recipient None + assert!(encoded[101] == 2 && encoded[102] == 0 && encoded[103] == 0 && encoded[104] == 0); + assert!(encoded[105] == 0xDE && encoded[106] == 0xAD); +} + +#[test] +fun accessors_round_trip() { + let payload = bridge_types::new_transfer_message_payload( + 1, + 2, + 3, + token_address(), + 4, + recipient(), + option::some(string::utf8(b"fee.near")), + vector[0x01], + ); + assert!(payload.transfer_fee_recipient() == option::some(string::utf8(b"fee.near"))); + assert!(payload.transfer_message() == vector[0x01]); + + let metadata = bridge_types::new_metadata_payload( + string::utf8(b"t"), + string::utf8(b"n"), + string::utf8(b"s"), + 8, + ); + assert!(metadata.metadata_token() == string::utf8(b"t")); + assert!(metadata.metadata_name() == string::utf8(b"n")); + assert!(metadata.metadata_symbol() == string::utf8(b"s")); + assert!(metadata.metadata_decimals() == 8); +} diff --git a/sui/tests/omni_bridge_tests.move b/sui/tests/omni_bridge_tests.move new file mode 100644 index 000000000..73a00f763 --- /dev/null +++ b/sui/tests/omni_bridge_tests.move @@ -0,0 +1,1322 @@ +#[test_only] +module omni_bridge::omni_bridge_tests; + +use omni_bridge::omni_bridge::{Self, BridgeState}; +use omni_bridge::test_coin::{Self, TEST_COIN}; +use omni_bridge::utils; +use std::string; +use sui::coin; +use sui::event; +use sui::sui::SUI; +use sui::test_scenario::{Self, Scenario}; + +const ADMIN: address = @0xAD; +const USER: address = @0xB0B; +const CHAIN_ID: u8 = 14; + +const ROLE_ADMIN: u8 = 0; +const ROLE_PAUSER: u8 = 1; +const ROLE_METADATA_ADMIN: u8 = 2; + +fun derived_address(): vector { + vector[ + 0xB9, 0x60, 0xBE, 0xD5, 0x3C, 0x17, 0xF9, 0xA0, 0x21, 0x53, 0x8B, 0x5D, + 0x6F, 0x08, 0xE7, 0x46, 0x6B, 0x96, 0x6C, 0x53, + ] +} + +/// Publish-equivalent: run `init` as ADMIN and advance one tx so the +/// shared state can be taken. +fun setup(): Scenario { + let mut ts = test_scenario::begin(ADMIN); + omni_bridge::init_for_testing(ts.ctx()); + ts.next_tx(ADMIN); + ts +} + +/// Setup + `initialize` with the test MPC signer and chain id 14. +fun setup_configured(): Scenario { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::initialize(&mut state, derived_address(), CHAIN_ID, ts.ctx()); + test_scenario::return_shared(state); + ts.next_tx(ADMIN); + ts +} + +// -------- init / initialize -------- + +#[test] +fun init_seeds_all_roles_to_publisher() { + let ts = setup(); + let state = ts.take_shared(); + assert!(state.has_role(ROLE_ADMIN, ADMIN)); + assert!(state.has_role(ROLE_PAUSER, ADMIN)); + assert!(state.has_role(ROLE_METADATA_ADMIN, ADMIN)); + assert!(!state.has_role(ROLE_ADMIN, USER)); + assert!(!state.is_configured()); + assert!(state.pause_flags() == 0); + assert!(state.current_origin_nonce() == 0); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun initialize_sets_config() { + let ts = setup_configured(); + let state = ts.take_shared(); + assert!(state.is_configured()); + assert!(state.chain_id() == CHAIN_ID); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_ALREADY_INITIALIZED)] +fun initialize_twice_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::initialize(&mut state, derived_address(), CHAIN_ID, ts.ctx()); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun initialize_by_non_admin_aborts() { + let mut ts = setup(); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::initialize(&mut state, derived_address(), CHAIN_ID, ts.ctx()); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_INVALID_DERIVED_ADDRESS)] +fun initialize_with_short_address_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + let mut addr = derived_address(); + addr.pop_back(); + omni_bridge::initialize(&mut state, addr, CHAIN_ID, ts.ctx()); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_INVALID_CHAIN_ID)] +fun initialize_with_zero_chain_id_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::initialize(&mut state, derived_address(), 0, ts.ctx()); + abort 0 +} + +#[test] +fun admin_corrects_chain_id() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_chain_id(&mut state, 15, ts.ctx()); + assert!(state.chain_id() == 15); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_INVALID_CHAIN_ID)] +fun set_chain_id_zero_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_chain_id(&mut state, 0, ts.ctx()); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun set_chain_id_by_non_admin_aborts() { + let mut ts = setup_configured(); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::set_chain_id(&mut state, 15, ts.ctx()); + abort 0 +} + +// -------- roles -------- + +#[test] +fun grant_role_adds_holder_idempotently() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::grant_role(&mut state, ROLE_PAUSER, USER, ts.ctx()); + assert!(state.has_role(ROLE_PAUSER, USER)); + omni_bridge::grant_role(&mut state, ROLE_PAUSER, USER, ts.ctx()); + assert!(state.role_holders(ROLE_PAUSER).length() == 2); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun revoke_role_removes_holder() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::grant_role(&mut state, ROLE_PAUSER, USER, ts.ctx()); + omni_bridge::revoke_role(&mut state, ROLE_PAUSER, USER, ts.ctx()); + assert!(!state.has_role(ROLE_PAUSER, USER)); + // Revoking a non-holder is a no-op. + omni_bridge::revoke_role(&mut state, ROLE_PAUSER, USER, ts.ctx()); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun admin_can_step_down_when_second_admin_exists() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::grant_role(&mut state, ROLE_ADMIN, USER, ts.ctx()); + omni_bridge::revoke_role(&mut state, ROLE_ADMIN, ADMIN, ts.ctx()); + assert!(!state.has_role(ROLE_ADMIN, ADMIN)); + assert!(state.has_role(ROLE_ADMIN, USER)); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_CANNOT_REMOVE_LAST_ADMIN)] +fun revoking_last_admin_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::revoke_role(&mut state, ROLE_ADMIN, ADMIN, ts.ctx()); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun grant_role_by_non_admin_aborts() { + let mut ts = setup(); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::grant_role(&mut state, ROLE_PAUSER, USER, ts.ctx()); + abort 0 +} + +#[test] +fun all_roles_lists_three() { + assert!(omni_bridge::all_roles().length() == 3); +} + +#[test] +fun role_holders_of_unknown_role_is_empty() { + let ts = setup(); + let state = ts.take_shared(); + assert!(state.role_holders(99).is_empty()); + test_scenario::return_shared(state); + ts.end(); +} + +// -------- pause -------- + +#[test] +fun admin_sets_pause_flags() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::set_pause_flags(&mut state, 0x03, ts.ctx()); + assert!(state.pause_flags() == 0x03); + omni_bridge::set_pause_flags(&mut state, 0x00, ts.ctx()); + assert!(state.pause_flags() == 0x00); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun pauser_can_pause_all() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::grant_role(&mut state, ROLE_PAUSER, USER, ts.ctx()); + test_scenario::return_shared(state); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::pause_all(&mut state, ts.ctx()); + assert!(state.pause_flags() == 0xFF); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun pause_all_by_non_pauser_aborts() { + let mut ts = setup(); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::pause_all(&mut state, ts.ctx()); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun set_pause_flags_by_non_admin_aborts() { + let mut ts = setup(); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::set_pause_flags(&mut state, 0xFF, ts.ctx()); + abort 0 +} + +// -------- migrate / rotation -------- + +#[test] +#[expected_failure(abort_code = omni_bridge::E_NOT_MIGRATION)] +fun migrate_at_current_version_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::migrate(&mut state, ts.ctx()); + abort 0 +} + +#[test] +fun admin_rotates_derived_address() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let mut rotated = derived_address(); + *(&mut rotated[0]) = 0x00; + omni_bridge::set_near_bridge_derived_address(&mut state, rotated, ts.ctx()); + test_scenario::return_shared(state); + ts.end(); +} + +// -------- version gate -------- + +#[test] +#[expected_failure(abort_code = omni_bridge::E_WRONG_VERSION)] +fun stale_version_aborts_entry_points() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_version_for_testing(&mut state, 0); + omni_bridge::init_transfer( + &mut state, + coin::mint_for_testing(100, ts.ctx()), + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +fun migrate_from_older_version_succeeds() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_version_for_testing(&mut state, 0); + omni_bridge::migrate(&mut state, ts.ctx()); + // Entry points work again after migration. + omni_bridge::init_transfer( + &mut state, + coin::mint_for_testing(100, ts.ctx()), + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + assert!(state.current_origin_nonce() == 1); + test_scenario::return_shared(state); + ts.end(); +} + +// -------- init_transfer -------- + +#[test] +fun init_transfer_locks_coin_and_emits_event() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let coin = coin::mint_for_testing(1_000, ts.ctx()); + omni_bridge::init_transfer( + &mut state, + coin, + 10, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[0xAB], + ts.ctx(), + ); + + assert!(state.locked_balance() == 1_000); + assert!(state.current_origin_nonce() == 1); + + let events = event::events_by_type(); + assert!(events.length() == 1); + let expected = omni_bridge::new_init_transfer_event( + ADMIN, + utils::token_address(), + utils::coin_type_string(), + 1, + 1_000, + 10, + 0, + string::utf8(b"near:bob.near"), + vector[0xAB], + ); + assert!(events[0] == expected); + + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun init_transfer_collects_native_fee() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let coin = coin::mint_for_testing(500, ts.ctx()); + let native_fee = coin::mint_for_testing(50, ts.ctx()); + omni_bridge::init_transfer( + &mut state, + coin, + 0, + native_fee, + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + + assert!(state.locked_balance() == 500); + assert!(state.locked_balance() == 50); + + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun init_transfer_increments_origin_nonce() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let mut i = 0u64; + while (i < 3) { + omni_bridge::init_transfer( + &mut state, + coin::mint_for_testing(100, ts.ctx()), + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + i = i + 1; + }; + assert!(state.current_origin_nonce() == 3); + assert!(state.locked_balance() == 300); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_ZERO_AMOUNT)] +fun init_transfer_zero_amount_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::init_transfer( + &mut state, + coin::zero(ts.ctx()), + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_INVALID_FEE)] +fun init_transfer_fee_not_less_than_amount_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::init_transfer( + &mut state, + coin::mint_for_testing(100, ts.ctx()), + 100, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_INIT_TRANSFER_PAUSED)] +fun init_transfer_paused_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_pause_flags(&mut state, 0x01, ts.ctx()); + omni_bridge::init_transfer( + &mut state, + coin::mint_for_testing(100, ts.ctx()), + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_NOT_INITIALIZED)] +fun init_transfer_unconfigured_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + omni_bridge::init_transfer( + &mut state, + coin::mint_for_testing(100, ts.ctx()), + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + abort 0 +} + +// -------- fin_transfer -------- + +// Signature over the borsh TransferMessagePayload for: +// dest_nonce=5, origin_chain=1 (Near), origin_nonce=99, +// token=TEST_COIN (keccak of its type string), amount=250, +// recipient=@0xB0B, fee_recipient=Some("relayer.near"), empty message, +// chain_id=14 — signed by the key behind `derived_address()`. +fun fin_signature(): vector { + vector[ + 0x97, 0x1A, 0xC1, 0x79, 0x65, 0xB9, 0x3A, 0x62, 0xE0, 0x7F, 0x56, 0x11, + 0xA5, 0x46, 0xD9, 0x78, 0xBE, 0x47, 0x3C, 0x54, 0x7C, 0x9C, 0x23, 0x1F, + 0x5F, 0x20, 0x3C, 0x5A, 0x9A, 0x6D, 0xB7, 0xD8, 0x56, 0xE7, 0x54, 0xE0, + 0x51, 0x09, 0xB7, 0x35, 0xBC, 0x95, 0x1D, 0xD9, 0x7F, 0x69, 0x18, 0x8B, + 0x57, 0xDE, 0x99, 0x89, 0xAB, 0xFD, 0xB5, 0x70, 0x32, 0xFB, 0xE9, 0xDA, + 0x70, 0xF2, 0xA7, 0xDE, 0x1C, + ] +} + +fun call_fin_transfer(state: &mut BridgeState, ts: &mut Scenario) { + omni_bridge::fin_transfer( + state, + fin_signature(), + 5, // destination_nonce + 1, // origin_chain + 99, // origin_nonce + 250, + USER, + option::some(string::utf8(b"relayer.near")), + vector[], + ts.ctx(), + ); +} + +fun lock_some_test_coin(state: &mut BridgeState, amount: u64, ts: &mut Scenario) { + omni_bridge::init_transfer( + state, + coin::mint_for_testing(amount, ts.ctx()), + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); +} + +#[test] +fun fin_transfer_unlocks_to_recipient() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + lock_some_test_coin(&mut state, 1_000, &mut ts); + + call_fin_transfer(&mut state, &mut ts); + + assert!(state.locked_balance() == 750); + assert!(state.is_transfer_finalised(5)); + assert!(!state.is_transfer_finalised(4)); + + let events = event::events_by_type(); + assert!(events.length() == 1); + let expected = omni_bridge::new_fin_transfer_event( + 1, + 99, + utils::token_address(), + utils::coin_type_string(), + 250, + USER, + option::some(string::utf8(b"relayer.near")), + vector[], + ); + assert!(events[0] == expected); + + test_scenario::return_shared(state); + // The recipient received the released coin. + ts.next_tx(USER); + let received = ts.take_from_address>(USER); + assert!(received.value() == 250); + ts.return_to_sender(received); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_NONCE_ALREADY_USED)] +fun fin_transfer_replay_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + lock_some_test_coin(&mut state, 1_000, &mut ts); + call_fin_transfer(&mut state, &mut ts); + call_fin_transfer(&mut state, &mut ts); + abort 0 +} + +#[test] +#[expected_failure] +fun fin_transfer_tampered_signature_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + lock_some_test_coin(&mut state, 1_000, &mut ts); + let mut sig = fin_signature(); + *(&mut sig[0]) = 0x00; + omni_bridge::fin_transfer( + &mut state, + sig, + 5, + 1, + 99, + 250, + USER, + option::some(string::utf8(b"relayer.near")), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure] +fun fin_transfer_wrong_amount_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + lock_some_test_coin(&mut state, 1_000, &mut ts); + omni_bridge::fin_transfer( + &mut state, + fin_signature(), + 5, + 1, + 99, + 251, // not what was signed + USER, + option::some(string::utf8(b"relayer.near")), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_AMOUNT_OVERFLOW)] +fun fin_transfer_amount_over_u64_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + // Signature over dest_nonce=6, origin_nonce=100, amount=2^64, + // fee_recipient=None — valid signature, unrepresentable amount. + let sig = vector[ + 0xCD, 0x9E, 0xE7, 0x3E, 0xA4, 0x95, 0x05, 0x17, 0xA4, 0xE0, 0xFE, 0x9C, + 0xB0, 0x4B, 0x63, 0x95, 0x4D, 0xC2, 0xDE, 0x52, 0x77, 0xE4, 0x6F, 0x0E, + 0x66, 0xAE, 0xA8, 0xFF, 0x33, 0x33, 0x22, 0x6D, 0x6E, 0x4C, 0x6C, 0x45, + 0xC0, 0x76, 0x78, 0xF8, 0xF2, 0x10, 0x4E, 0xBD, 0x3E, 0x3F, 0xEE, 0x12, + 0xED, 0x44, 0x18, 0x80, 0x79, 0x7C, 0x51, 0xA9, 0xD1, 0xAD, 0xC8, 0x34, + 0xFA, 0x95, 0x89, 0x7B, 0x1B, + ]; + omni_bridge::fin_transfer( + &mut state, + sig, + 6, + 1, + 100, + 0x10000000000000000, // 2^64 + USER, + option::none(), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_FIN_TRANSFER_PAUSED)] +fun fin_transfer_paused_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_pause_flags(&mut state, 0x02, ts.ctx()); + call_fin_transfer(&mut state, &mut ts); + abort 0 +} + +#[test] +#[expected_failure] +fun fin_transfer_with_wrong_coin_type_aborts() { + // The payload's token_address is derived from T itself, so submitting + // the TEST_COIN signature with a different type argument reconstructs + // different bytes and must fail signature verification. + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + lock_some_test_coin(&mut state, 1_000, &mut ts); + omni_bridge::fin_transfer( + &mut state, + fin_signature(), + 5, + 1, + 99, + 250, + USER, + option::some(string::utf8(b"relayer.near")), + vector[], + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_NOT_INITIALIZED)] +fun fin_transfer_unconfigured_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + call_fin_transfer(&mut state, &mut ts); + abort 0 +} + +#[test] +fun nonce_bitmap_word_boundaries() { + let ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::test_mark_nonce_used(&mut state, 127); + assert!(state.is_transfer_finalised(127)); + assert!(!state.is_transfer_finalised(128)); + assert!(!state.is_transfer_finalised(126)); + omni_bridge::test_mark_nonce_used(&mut state, 128); + assert!(state.is_transfer_finalised(128)); + // Marking is idempotent. + omni_bridge::test_mark_nonce_used(&mut state, 128); + assert!(state.is_transfer_finalised(128)); + assert!(state.is_transfer_finalised(127)); + // Out-of-order distant nonces land in distinct slots. + omni_bridge::test_mark_nonce_used(&mut state, 1_000_000); + assert!(state.is_transfer_finalised(1_000_000)); + assert!(!state.is_transfer_finalised(999_999)); + test_scenario::return_shared(state); + ts.end(); +} + +// -------- deploy_token / set_token_metadata -------- + +// Signature over MetadataPayload(token="wrap.testnet", name="Wrapped NEAR", +// symbol="wNEAR", decimals=24), signed by the key behind +// `derived_address()`. Clamped on-chain decimals = 9. +fun deploy_signature(): vector { + vector[ + 0xEA, 0xF3, 0x60, 0x57, 0xBF, 0xCF, 0x7D, 0x5F, 0x95, 0x64, 0xFB, 0x00, + 0x2A, 0xDF, 0x73, 0x1F, 0xCD, 0x65, 0x29, 0xED, 0xDB, 0x0A, 0xB9, 0x10, + 0xAD, 0xC1, 0x88, 0xD8, 0x47, 0xCF, 0xDD, 0xE3, 0x6C, 0x83, 0x68, 0xC3, + 0xB2, 0x7B, 0x2C, 0x0C, 0xA1, 0x1F, 0x0F, 0xF3, 0x07, 0x8F, 0x5F, 0x3B, + 0x81, 0xBB, 0xFA, 0xAF, 0x25, 0x04, 0xE6, 0xD9, 0xB4, 0x39, 0x50, 0x42, + 0xD9, 0xB1, 0x98, 0x75, 0x1C, + ] +} + +/// TreasuryCap + CoinMetadata matching the signed payload (decimals +/// pre-clamped to 9) + an UpgradeCap for TEST_COIN's defining package +/// (`omni_bridge` == @0x0 in unit tests) at version 1. +fun deploy_fixtures( + ts: &mut Scenario, +): ( + coin::TreasuryCap, + coin::CoinMetadata, + sui::package::UpgradeCap, +) { + let (cap, metadata) = test_coin::create_currency( + 9, + b"wNEAR", + b"Wrapped NEAR", + ts.ctx(), + ); + let upgrade_cap = sui::package::test_publish(object::id_from_address(@omni_bridge), ts.ctx()); + (cap, metadata, upgrade_cap) +} + +fun call_deploy_token(state: &mut BridgeState, ts: &mut Scenario) { + let (cap, metadata, upgrade_cap) = deploy_fixtures(ts); + omni_bridge::deploy_token( + state, + deploy_signature(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); +} + +#[test] +fun deploy_token_registers_bridge_token() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + + assert!(state.is_bridge_token()); + assert!( + state.get_token_address(string::utf8(b"wrap.testnet")) + == option::some(utils::coin_type_string()), + ); + assert!( + state.get_coin_type(utils::token_address()) + == option::some(utils::coin_type_string()), + ); + + let events = event::events_by_type(); + assert!(events.length() == 1); + let expected = omni_bridge::new_deploy_token_event( + utils::token_address(), + utils::coin_type_string(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 9, // clamped + 24, // origin + ); + assert!(events[0] == expected); + + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun bridged_token_mints_on_fin_and_burns_on_init() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + + // fin_transfer mints (no custody involved). + call_fin_transfer(&mut state, &mut ts); + assert!(state.locked_balance() == 0); + + test_scenario::return_shared(state); + ts.next_tx(USER); + let minted = ts.take_from_address>(USER); + assert!(minted.value() == 250); + + // init_transfer burns the bridged token instead of locking it. + let mut state = ts.take_shared(); + omni_bridge::init_transfer( + &mut state, + minted, + 0, + coin::zero(ts.ctx()), + string::utf8(b"near:bob.near"), + vector[], + ts.ctx(), + ); + assert!(state.locked_balance() == 0); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_TYPE_ALREADY_USED)] +fun deploy_token_same_type_for_second_near_token_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + // Signature over MetadataPayload(token="usdt.testnet", same + // name/symbol/decimals) — a different NEAR token id must not be + // bindable to the already-used coin type. + let usdt_sig = vector[ + 0x8D, 0xB7, 0x79, 0x62, 0x0B, 0x57, 0x86, 0xDA, 0xE5, 0xA0, 0x9F, 0x0F, + 0x94, 0x93, 0x47, 0x57, 0x01, 0x0F, 0x8F, 0x07, 0x74, 0x8A, 0xBA, 0x59, + 0xFA, 0xD3, 0x41, 0xB6, 0x46, 0x5F, 0xC3, 0xC8, 0x72, 0xA5, 0xA3, 0x6C, + 0x7D, 0x6F, 0x73, 0x4C, 0xC2, 0xBA, 0x41, 0x11, 0x2C, 0x21, 0x00, 0x92, + 0x2A, 0x3C, 0xF8, 0x4F, 0x07, 0x86, 0x90, 0x20, 0xB4, 0xA9, 0x62, 0x1E, + 0xC3, 0xA3, 0xA0, 0xA9, 0x1C, + ]; + let (cap, metadata, upgrade_cap) = deploy_fixtures(&mut ts); + omni_bridge::deploy_token( + &mut state, + usdt_sig, + string::utf8(b"usdt.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_INVALID_UPGRADE_CAP)] +fun deploy_token_upgraded_cap_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let (cap, metadata) = test_coin::create_currency( + 9, + b"wNEAR", + b"Wrapped NEAR", + ts.ctx(), + ); + // Simulate a completed package upgrade: cap version becomes 2 (and the + // package id moves to the new version's id). `authorize_upgrade` + // reserves package id 0x0 as its already-authorized sentinel, so the + // cap must start from a non-zero id here; either failing conjunct of + // the upgrade-cap check yields E_INVALID_UPGRADE_CAP. + let mut upgrade_cap = sui::package::test_publish( + object::id_from_address(@0xABC), + ts.ctx(), + ); + let ticket = sui::package::authorize_upgrade( + &mut upgrade_cap, + sui::package::compatible_policy(), + vector[0x01], + ); + let receipt = sui::package::test_upgrade(ticket); + sui::package::commit_upgrade(&mut upgrade_cap, receipt); + omni_bridge::deploy_token( + &mut state, + deploy_signature(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_NOT_INITIALIZED)] +fun deploy_token_unconfigured_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_TOKEN_ALREADY_DEPLOYED)] +fun deploy_token_twice_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + call_deploy_token(&mut state, &mut ts); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_SUPPLY_NOT_ZERO)] +fun deploy_token_nonzero_supply_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let (mut cap, metadata, upgrade_cap) = deploy_fixtures(&mut ts); + let premint = coin::mint(&mut cap, 1, ts.ctx()); + transfer::public_transfer(premint, ADMIN); + omni_bridge::deploy_token( + &mut state, + deploy_signature(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_INVALID_UPGRADE_CAP)] +fun deploy_token_wrong_upgrade_cap_package_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let (cap, metadata) = test_coin::create_currency( + 9, + b"wNEAR", + b"Wrapped NEAR", + ts.ctx(), + ); + let wrong = sui::package::test_publish(object::id_from_address(@0xBEEF), ts.ctx()); + omni_bridge::deploy_token( + &mut state, + deploy_signature(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + wrong, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_METADATA_MISMATCH)] +fun deploy_token_wrong_decimals_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + // Coin published with 6 decimals; the signed payload clamps 24 -> 9. + let (cap, metadata) = test_coin::create_currency( + 6, + b"wNEAR", + b"Wrapped NEAR", + ts.ctx(), + ); + let upgrade_cap = sui::package::test_publish(object::id_from_address(@omni_bridge), ts.ctx()); + omni_bridge::deploy_token( + &mut state, + deploy_signature(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_METADATA_MISMATCH)] +fun deploy_token_wrong_name_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let (cap, metadata) = test_coin::create_currency( + 9, + b"wNEAR", + b"Wrong Name", + ts.ctx(), + ); + let upgrade_cap = sui::package::test_publish(object::id_from_address(@omni_bridge), ts.ctx()); + omni_bridge::deploy_token( + &mut state, + deploy_signature(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_METADATA_MISMATCH)] +fun deploy_token_wrong_symbol_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let (cap, metadata) = test_coin::create_currency( + 9, + b"EVIL", + b"Wrapped NEAR", + ts.ctx(), + ); + let upgrade_cap = sui::package::test_publish(object::id_from_address(@omni_bridge), ts.ctx()); + omni_bridge::deploy_token( + &mut state, + deploy_signature(), + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure] +fun deploy_token_tampered_signature_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let (cap, metadata, upgrade_cap) = deploy_fixtures(&mut ts); + let mut sig = deploy_signature(); + *(&mut sig[0]) = 0x00; + omni_bridge::deploy_token( + &mut state, + sig, + string::utf8(b"wrap.testnet"), + string::utf8(b"Wrapped NEAR"), + string::utf8(b"wNEAR"), + 24, + cap, + upgrade_cap, + metadata, + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_DEPLOY_TOKEN_PAUSED)] +fun deploy_token_paused_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_pause_flags(&mut state, 0x04, ts.ctx()); + call_deploy_token(&mut state, &mut ts); + abort 0 +} + +#[test] +fun metadata_admin_updates_token_metadata() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + omni_bridge::set_token_metadata( + &mut state, + option::some(string::utf8(b"Bridged wNEAR")), + option::some(std::ascii::string(b"https://example.com/wnear.png")), + ts.ctx(), + ); + let events = event::events_by_type(); + assert!(events.length() == 1); + + // Both fields land in the stored CoinMetadata. + assert!( + omni_bridge::test_token_description(&state) + == string::utf8(b"Bridged wNEAR"), + ); + assert!( + omni_bridge::test_token_icon_url(&state) + == option::some(sui::url::new_unsafe_from_bytes(b"https://example.com/wnear.png")), + ); + + // `None` leaves a field unchanged. + omni_bridge::set_token_metadata( + &mut state, + option::some(string::utf8(b"v2")), + option::none(), + ts.ctx(), + ); + assert!(omni_bridge::test_token_description(&state) == string::utf8(b"v2")); + assert!( + omni_bridge::test_token_icon_url(&state) + == option::some(sui::url::new_unsafe_from_bytes(b"https://example.com/wnear.png")), + ); + + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_NOT_INITIALIZED)] +fun log_metadata_unconfigured_aborts() { + let mut ts = setup(); + let mut state = ts.take_shared(); + let (cap, metadata) = test_coin::create_currency(6, b"TST", b"Test Coin", ts.ctx()); + omni_bridge::log_metadata(&mut state, &metadata); + std::unit_test::destroy(cap); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun revoke_role_by_non_admin_aborts() { + let mut ts = setup(); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::revoke_role(&mut state, ROLE_ADMIN, ADMIN, ts.ctx()); + abort 0 +} + +#[test] +fun set_token_metadata_updates_description() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + omni_bridge::set_token_metadata( + &mut state, + option::some(string::utf8(b"Bridged wNEAR")), + option::none(), + ts.ctx(), + ); + assert!( + omni_bridge::test_token_description(&state) + == string::utf8(b"Bridged wNEAR"), + ); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun granted_metadata_admin_can_set_metadata() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + omni_bridge::grant_role(&mut state, ROLE_METADATA_ADMIN, USER, ts.ctx()); + test_scenario::return_shared(state); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::set_token_metadata( + &mut state, + option::some(string::utf8(b"by user")), + option::none(), + ts.ctx(), + ); + assert!( + omni_bridge::test_token_description(&state) + == string::utf8(b"by user"), + ); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun revoked_metadata_admin_cannot_set_metadata() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + omni_bridge::grant_role(&mut state, ROLE_METADATA_ADMIN, USER, ts.ctx()); + omni_bridge::revoke_role(&mut state, ROLE_METADATA_ADMIN, USER, ts.ctx()); + test_scenario::return_shared(state); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::set_token_metadata( + &mut state, + option::some(string::utf8(b"x")), + option::none(), + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun set_token_metadata_by_non_admin_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + call_deploy_token(&mut state, &mut ts); + test_scenario::return_shared(state); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::set_token_metadata( + &mut state, + option::some(string::utf8(b"x")), + option::none(), + ts.ctx(), + ); + abort 0 +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_NOT_BRIDGE_TOKEN)] +fun set_token_metadata_on_non_bridge_token_aborts() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + omni_bridge::set_token_metadata( + &mut state, + option::some(string::utf8(b"x")), + option::none(), + ts.ctx(), + ); + abort 0 +} + +// -------- log_metadata -------- + +#[test] +fun log_metadata_emits_and_registers() { + let mut ts = setup_configured(); + let mut state = ts.take_shared(); + let (cap, metadata) = test_coin::create_currency(6, b"TST", b"Test Coin", ts.ctx()); + + omni_bridge::log_metadata(&mut state, &metadata); + + let events = event::events_by_type(); + assert!(events.length() == 1); + let expected = omni_bridge::new_log_metadata_event( + utils::token_address(), + utils::coin_type_string(), + string::utf8(b"Test Coin"), + string::utf8(b"TST"), + 6, + ); + assert!(events[0] == expected); + + // Reverse registry is populated (idempotently). + omni_bridge::log_metadata(&mut state, &metadata); + let coin_type = state.get_coin_type(utils::token_address()); + assert!(coin_type == option::some(utils::coin_type_string())); + + std::unit_test::destroy(cap); + std::unit_test::destroy(metadata); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun log_metadata_registry_emits_and_registers() { + let mut ts = setup_configured(); + // Build a coin_registry::Currency from legacy metadata via + // the framework's test-only helpers (registry creation requires the + // system address). + let (cap, metadata) = test_coin::create_currency(6, b"TST", b"Test Coin", ts.ctx()); + ts.next_tx(@0x0); + let mut registry = sui::coin_registry::create_coin_data_registry_for_testing(ts.ctx()); + let currency = sui::coin_registry::migrate_legacy_metadata_for_testing( + &mut registry, + &metadata, + ts.ctx(), + ); + ts.next_tx(ADMIN); + + let mut state = ts.take_shared(); + omni_bridge::log_metadata_registry(&mut state, ¤cy); + + let events = event::events_by_type(); + assert!(events.length() == 1); + let expected = omni_bridge::new_log_metadata_event( + utils::token_address(), + utils::coin_type_string(), + string::utf8(b"Test Coin"), + string::utf8(b"TST"), + 6, + ); + assert!(events[0] == expected); + assert!( + state.get_coin_type(utils::token_address()) + == option::some(utils::coin_type_string()), + ); + + std::unit_test::destroy(cap); + std::unit_test::destroy(metadata); + std::unit_test::destroy(currency); + std::unit_test::destroy(registry); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +fun get_coin_type_unknown_is_none() { + let ts = setup_configured(); + let state = ts.take_shared(); + assert!(state.get_coin_type(@0xDEAD).is_none()); + test_scenario::return_shared(state); + ts.end(); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::E_UNAUTHORIZED)] +fun rotate_derived_address_by_non_admin_aborts() { + let mut ts = setup_configured(); + ts.next_tx(USER); + let mut state = ts.take_shared(); + omni_bridge::set_near_bridge_derived_address(&mut state, derived_address(), ts.ctx()); + abort 0 +} diff --git a/sui/tests/test_coin.move b/sui/tests/test_coin.move new file mode 100644 index 000000000..1ee600b23 --- /dev/null +++ b/sui/tests/test_coin.move @@ -0,0 +1,29 @@ +/// Test-only coin with a real one-time witness, so tests can exercise the +/// `deploy_token` flow (which needs an actual `TreasuryCap`/`CoinMetadata` +/// pair) as well as plain lock/unlock paths. +#[test_only] +#[allow(deprecated_usage)] +module omni_bridge::test_coin; + +use sui::coin::{Self, CoinMetadata, TreasuryCap}; +use sui::test_utils; + +public struct TEST_COIN has drop {} + +/// Mirror of what a per-token template package's `init` does. +public fun create_currency( + decimals: u8, + symbol: vector, + name: vector, + ctx: &mut TxContext, +): (TreasuryCap, CoinMetadata) { + coin::create_currency( + test_utils::create_one_time_witness(), + decimals, + symbol, + name, + b"", + option::none(), + ctx, + ) +} diff --git a/sui/tests/utils_tests.move b/sui/tests/utils_tests.move new file mode 100644 index 000000000..30c6b2818 --- /dev/null +++ b/sui/tests/utils_tests.move @@ -0,0 +1,130 @@ +#[test_only] +module omni_bridge::utils_tests; + +use omni_bridge::utils; +use sui::sui::SUI; + +// Vectors generated offline with secp256k1 key +// 0x4c0883a69102937d6231471b5dbb6204fe512961708279feb1be6ae5538da033 +// signing keccak256(b"omni bridge test message") — the same construction the +// NEAR MPC uses over borsh payloads (signature emitted as r||s||(recid+27)). +fun test_message(): vector { + b"omni bridge test message" +} + +fun test_signer_address(): vector { + vector[ + 0xB9, 0x60, 0xBE, 0xD5, 0x3C, 0x17, 0xF9, 0xA0, 0x21, 0x53, 0x8B, 0x5D, + 0x6F, 0x08, 0xE7, 0x46, 0x6B, 0x96, 0x6C, 0x53, + ] +} + +fun test_signature(): vector { + vector[ + 0x98, 0xA0, 0xEA, 0x1B, 0xDD, 0x29, 0xDC, 0xC3, 0x14, 0x96, 0x82, 0x22, + 0xC0, 0x6B, 0x54, 0xB7, 0x20, 0xDE, 0x16, 0x6B, 0x65, 0x58, 0xCC, 0x4A, + 0xE7, 0x0B, 0x16, 0xCC, 0x80, 0x44, 0xDB, 0x41, 0x33, 0xCD, 0x7F, 0xAC, + 0x84, 0x34, 0x21, 0x62, 0x1F, 0x89, 0x90, 0x59, 0xDA, 0x98, 0x01, 0x1B, + 0xFD, 0xDF, 0xD0, 0x6A, 0xEF, 0x56, 0x04, 0x7D, 0xCA, 0x8C, 0xBC, 0x54, + 0xE0, 0x3C, 0xC5, 0xF8, 0x1C, + ] +} + +#[test] +fun verify_eth_signature_accepts_valid() { + utils::verify_eth_signature( + &test_message(), + &test_signature(), + &test_signer_address(), + ); +} + +#[test] +fun verify_eth_signature_accepts_normalized_v() { + // Same signature with v already normalized to {0,1}. + let mut sig = test_signature(); + let last = sig.length() - 1; + *(&mut sig[last]) = sig[last] - 27; + utils::verify_eth_signature(&test_message(), &sig, &test_signer_address()); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::utils::E_INVALID_SIGNATURE)] +fun verify_eth_signature_rejects_wrong_signer() { + let mut wrong = test_signer_address(); + *(&mut wrong[0]) = 0x00; + utils::verify_eth_signature(&test_message(), &test_signature(), &wrong); +} + +#[test] +#[expected_failure] +fun verify_eth_signature_rejects_tampered_message() { + // Tampering the message makes recovery yield a different (or no) key; + // either way verification must abort. + utils::verify_eth_signature( + &b"omni bridge test messagX", + &test_signature(), + &test_signer_address(), + ); +} + +#[test] +#[expected_failure] +fun verify_eth_signature_rejects_wrong_recovery_id() { + // Same r||s with the other recovery id: recovers a different key (or + // fails outright) — verification must abort either way. + let mut sig = test_signature(); + let last = sig.length() - 1; + *(&mut sig[last]) = 27; // vector was signed with v = 28 + utils::verify_eth_signature(&test_message(), &sig, &test_signer_address()); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::utils::E_INVALID_SIGNATURE_LENGTH)] +fun verify_eth_signature_rejects_wrong_length() { + let mut sig = test_signature(); + sig.pop_back(); + utils::verify_eth_signature(&test_message(), &sig, &test_signer_address()); +} + +#[test] +#[expected_failure(abort_code = omni_bridge::utils::E_INVALID_SIGNATURE)] +fun verify_eth_signature_rejects_short_expected_address() { + let mut addr = test_signer_address(); + addr.pop_back(); + utils::verify_eth_signature(&test_message(), &test_signature(), &addr); +} + +#[test] +fun normalize_decimals_clamps_at_nine() { + assert!(utils::normalize_decimals(18) == 9); + assert!(utils::normalize_decimals(9) == 9); + assert!(utils::normalize_decimals(6) == 6); + assert!(utils::normalize_decimals(0) == 0); +} + +#[test] +fun coin_type_string_of_sui() { + let s = utils::coin_type_string(); + assert!( + *s.as_bytes() == + b"0000000000000000000000000000000000000000000000000000000000000002::sui::SUI", + ); +} + +#[test] +fun token_address_of_sui_is_keccak_of_type() { + // keccak256(b"00...02::sui::SUI") computed offline. + let expected = vector[ + 0x66, 0x96, 0x38, 0x7A, 0xEC, 0xBB, 0x70, 0x52, 0x05, 0x02, 0x67, 0x83, + 0x04, 0x2F, 0x80, 0x38, 0x71, 0xC1, 0x90, 0x57, 0x0D, 0xD0, 0xA5, 0x78, + 0x82, 0xD9, 0xD3, 0x5E, 0xE0, 0xDF, 0x70, 0x0C, + ]; + assert!(utils::token_address_bytes() == expected); + assert!(utils::token_address().to_bytes() == expected); +} + +#[test] +fun type_package_address_of_sui() { + assert!(utils::type_package_address() == @0x2); +} diff --git a/sui/token_template/.gitignore b/sui/token_template/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/sui/token_template/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/sui/token_template/Move.lock b/sui/token_template/Move.lock new file mode 100644 index 000000000..7d0f6770c --- /dev/null +++ b/sui/token_template/Move.lock @@ -0,0 +1,23 @@ +# Generated by move; do not edit +# This file should be checked in. + +[move] +version = 4 + +[pinned.testnet.MoveStdlib] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/move-stdlib", rev = "b124567746b3a78a7e294ac2de265f693401ec9d" } +use_environment = "testnet" +manifest_digest = "C4FE4C91DE74CBF223B2E380AE40F592177D21870DC2D7EB6227D2D694E05363" +deps = {} + +[pinned.testnet.OmniBridgeTokenTemplate] +source = { root = true } +use_environment = "testnet" +manifest_digest = "5745706258F61D6CE210904B3E6AE87A73CE9D31A6F93BE4718C442529332A87" +deps = { std = "MoveStdlib", sui = "Sui" } + +[pinned.testnet.Sui] +source = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "b124567746b3a78a7e294ac2de265f693401ec9d" } +use_environment = "testnet" +manifest_digest = "7AFB66695545775FBFBB2D3078ADFD084244D5002392E837FDE21D9EA1C6D01C" +deps = { MoveStdlib = "MoveStdlib" } diff --git a/sui/token_template/Move.toml b/sui/token_template/Move.toml new file mode 100644 index 000000000..b46f74c67 --- /dev/null +++ b/sui/token_template/Move.toml @@ -0,0 +1,8 @@ +[package] +name = "OmniBridgeTokenTemplate" +edition = "2024.beta" +version = "0.1.0" +authors = ["Near One"] + +[addresses] +token_template = "0x0" diff --git a/sui/token_template/sources/template_coin.move b/sui/token_template/sources/template_coin.move new file mode 100644 index 000000000..91d65bd14 --- /dev/null +++ b/sui/token_template/sources/template_coin.move @@ -0,0 +1,50 @@ +/// Per-token template for the Omni Bridge `deploy_token` flow. +/// +/// Sui cannot create a currency at runtime — `coin::create_currency` +/// needs a one-time witness, which only exists in the `init` of a +/// freshly published package. So each NEAR-originated token bridged onto +/// Sui gets its own copy of this tiny package. +/// +/// To deploy a bridged token: +/// 1. Copy this package. Rename the module and the OTW struct to the +/// new token's symbol (the struct MUST be the module name in ALL +/// CAPS), and set `decimals` / `symbol` / `name` below to the values +/// from the MPC-signed MetadataPayload, with +/// `decimals = min(origin_decimals, 9)`. +/// 2. Publish. The publisher receives the `TreasuryCap`, the +/// `CoinMetadata` and the package `UpgradeCap`. +/// 3. Call `omni_bridge::deploy_token` with the MPC +/// signature and all three objects. The bridge verifies the +/// signature, requires zero supply, requires (and then freezes) the +/// version-1 `UpgradeCap`, checks the metadata against the signed +/// payload, and takes custody of the cap and metadata. +/// +/// One coin per package: the bridge makes the package immutable during +/// `deploy_token`, so a second coin can never be added to it. +/// +/// `coin::create_currency` is deprecated in favor of the coin_registry +/// Currency standard, but the bridge's `deploy_token` binds the classic +/// `CoinMetadata` object (Wormhole-precedent; also what wallets and +/// explorers still index), so the template intentionally stays on it. +#[allow(deprecated_usage)] +module token_template::template_coin; + +use sui::coin; + +public struct TEMPLATE_COIN has drop {} + +fun init(witness: TEMPLATE_COIN, ctx: &mut TxContext) { + let (treasury_cap, metadata) = coin::create_currency( + witness, + 9, // decimals: min(origin_decimals, 9) + b"TMPL", // symbol: from the signed MetadataPayload + b"Template Token", // name: from the signed MetadataPayload + b"", // description + option::none(), // icon url + ctx, + ); + transfer::public_transfer(treasury_cap, ctx.sender()); + // NOT frozen: `deploy_token` takes the metadata by value and keeps it + // bridge-owned so `set_token_metadata` can update it later. + transfer::public_transfer(metadata, ctx.sender()); +}