Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions aptos/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,27 @@ and [evm/src/omni-bridge/contracts/OmniBridge.sol](../evm/src/omni-bridge/contra
(`bytes[i]`), `for (i in 0..n)` range loops, `package fun` for
cross-module-restricted entry points, and resource-index expressions
(`&BridgeState[addr]`).
- **Optional Wormhole publish**: the bridge registers as a Wormhole
emitter inside `initialize` (`wormhole::register_emitter` against the
deployed Wormhole core at `@wormhole = 0x5bc1…`) and stores the
resulting `EmitterCapability` in `BridgeState`. `Admin` then flips
publishing on/off via `set_wormhole_enabled(enable: bool)` — a pure
flag flip, the same emitter id is reused across cycles so off-chain
consumers see a stable identity. When enabled, every `init_transfer`,
`fin_transfer`, `deploy_token`, and `log_metadata` also publishes a
Wormhole VAA whose payload mirrors the EVM `OmniBridgeWormhole.sol`
byte layout. The caller of each entry pays the Wormhole `message_fee`
in `Coin<AptosCoin>` from their own balance. The Wormhole modules are
vendored as a compile-time stub at `vendor/wormhole/` — see that
package's `Move.toml` for the rationale.

## Module Layout

| Module | Purpose |
|--------|---------|
| `omni_bridge::omni_bridge` | Main contract: init, deploy_token, init_transfer, fin_transfer, log_metadata, role management, pause, metadata mutation, events, views |
| `omni_bridge::omni_bridge` | Main contract: init, deploy_token, init_transfer, fin_transfer, log_metadata, role management, pause, metadata mutation, optional Wormhole publish, events, views |
| `omni_bridge::bridge_token` | Fungible Asset wrapper exposing `create`/`mint`/`burn`/`mutate_metadata` as `package fun` (package-internal only). Holds the per-token capability bundle on the FA object's address |
| `omni_bridge::bridge_types` | Payload structs (`MetadataPayload`, `TransferMessagePayload`) and their Borsh encoders. Events live in `omni_bridge` because Aptos requires `#[event]` and emit-site in the same module |
| `omni_bridge::bridge_types` | Payload structs (`MetadataPayload`, `TransferMessagePayload`) and their Borsh encoders, plus the four Wormhole `*_wormhole_payload` encoders mirroring `OmniBridgeWormhole.sol`. Events live in `omni_bridge` because Aptos requires `#[event]` and emit-site in the same module |
| `omni_bridge::borsh` | Borsh sequence encoders (`encode_string`, `encode_byte_vec`). Fixed-width integers and addresses delegate to `std::bcs::to_bytes` directly at call sites (BCS == Borsh for those types) |
| `omni_bridge::utils` | `verify_eth_signature` (secp256k1 + keccak256), `normalize_decimals` |

Expand All @@ -60,6 +73,7 @@ and [evm/src/omni-bridge/contracts/OmniBridge.sol](../evm/src/omni-bridge/contra
| `set_near_bridge_derived_address` | Rotate the NEAR MPC signer address | `Admin` |
| `grant_role` | Add an address to a role | `Admin` |
| `revoke_role` | Remove an address from a role (refuses last Admin) | `Admin` |
| `set_wormhole_enabled` | Turn Wormhole publishing on/off (pure flag flip; emitter registered at `initialize`) | `Admin` |
| `bridge_object_address` | Deterministic address of `BridgeState` / locked-token custody | View |
| `get_token_address` | NEAR token id → deployed FA metadata object address | View |
| `role_holders` | All addresses currently holding a role | View |
Expand Down Expand Up @@ -117,6 +131,33 @@ side. In particular:
`(name, id)` registry for off-chain discovery.
7. **Last-admin guard**: `revoke_role(Admin, last_admin)` aborts with
`E_CANNOT_REMOVE_LAST_ADMIN`. Prevents bricking via accidental rotation.
8. **Wormhole emitter + stub package**: `initialize` calls
`wormhole::wormhole::register_emitter()` unconditionally and stores
the resulting `EmitterCapability` in `BridgeState`. There's only ever
one bridge instance, the cap has no `drop` ability (matching the
deployed Wormhole's ABI), and registration is cheap — so we pay the
one-time cost up front instead of conditionally on first enable. As a
consequence, `initialize` has a hard dependency on Wormhole being
deployed at `@wormhole`; on chains without a Wormhole deployment the
bridge cannot be initialized. `set_wormhole_enabled(admin, bool)`
then toggles publishing on/off as a pure flag flip. When enabled,
every public bridge action also calls `publish_message` with a
payload that mirrors the corresponding `OmniBridgeWormhole.sol`
extension byte layout (`init_transfer_wormhole_payload`,
`fin_transfer_wormhole_payload`, `deploy_token_wormhole_payload`,
`log_metadata_wormhole_payload` in `bridge_types`). The Wormhole
nonce is always `0` — the bridge's own `origin_nonce` is the
replay-prevention identifier and is carried in the payload. The
Wormhole Move package is vendored as a compile-time **stub** at
`vendor/wormhole/` (modules `wormhole::wormhole`, `wormhole::emitter`,
`wormhole::state`). The stub mirrors the deployed Wormhole's public
ABI so the bridge can compile against the modern Aptos framework; the
stub modules are NOT republished by `aptos move publish` (only
`aptos/sources/` modules go on chain), and at runtime calls dispatch
by `(address, name)` to the real Wormhole at
`@wormhole = 0x5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625`.
Never change struct field layouts or function signatures in the stub
without confirming the deployed Wormhole's ABI hasn't drifted.

### Security Invariants
- **No replay**: `destination_nonce` is checked against `completed_transfers`
Expand Down Expand Up @@ -174,6 +215,12 @@ Coverage:
- **`init_transfer`** (7 tests): bridged-token burn, non-bridge-token lock,
origin nonce increment, paused, zero amount, fee≥amount, amount>u64::MAX
- Decimal normalization cap
- **Wormhole payload layouts** (5 tests): tag byte, chain-id offsets, and
total length for each of `init_transfer`, `fin_transfer` (with and
without fee_recipient), `deploy_token`, `log_metadata`
- **`set_wormhole_enabled` gating** (3 tests): admin can round-trip
off/on/off/on; setting the flag to its current value is a no-op;
non-admin rejected

## File References
- Main contract: [sources/omni_bridge.move](sources/omni_bridge.move)
Expand Down
8 changes: 8 additions & 0 deletions aptos/Move.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ rev = "mainnet"
git = "https://github.com/aptos-labs/aptos-core.git"
subdir = "aptos-move/framework/move-stdlib"
rev = "mainnet"

# Compile-time-only stub for Wormhole. See `vendor/wormhole/Move.toml`
# for the full rationale — the stub mirrors the deployed Wormhole's
# public ABI at `@wormhole = 0x5bc1…` so we can compile against modern
# framework features while still dispatching to the real Wormhole at
# runtime. The stub modules are NOT republished by `aptos move publish`.
[dependencies.Wormhole]
local = "vendor/wormhole"
115 changes: 115 additions & 0 deletions aptos/sources/bridge_types.move
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ module omni_bridge::bridge_types {
const PAYLOAD_TYPE_TRANSFER_MESSAGE: u8 = 0;
const PAYLOAD_TYPE_METADATA: u8 = 1;

// Wormhole `MessageType` discriminants. Numeric values are on-wire ABI —
// they match the enum in `OmniBridgeWormhole.sol` byte-for-byte so that
// Wormhole consumers can dispatch identically regardless of which chain
// emitted the VAA.
const WH_MSG_INIT_TRANSFER: u8 = 0;
const WH_MSG_FIN_TRANSFER: u8 = 1;
const WH_MSG_DEPLOY_TOKEN: u8 = 2;
const WH_MSG_LOG_METADATA: u8 = 3;

/// `deploy_token` payload signed by the NEAR MPC.
struct MetadataPayload has copy, drop, store {
/// Source-chain token id (e.g. NEAR account id of the underlying token).
Expand Down Expand Up @@ -148,5 +157,111 @@ module omni_bridge::bridge_types {

buf
}

// -------- Wormhole payload encoders --------
//
// Byte layout mirrors the corresponding `*Extension` functions in
// `evm/src/omni-bridge/contracts/OmniBridgeWormhole.sol`. The encoding
// is plain Borsh — same primitives as the NEAR-MPC payloads above —
// except `MessageType` tags are taken from the `WH_MSG_*` constants
// (NOT the `PAYLOAD_TYPE_*` constants, which key a different enum).
//
// Optional string fields are encoded as the empty string when absent
// (rather than with a Borsh `Option` tag) to match the EVM layout,
// which has no Optional concept and represents "no value" with `""`.

/// Wormhole payload for an outbound transfer. Mirrors
/// `initTransferExtension` in OmniBridgeWormhole.sol.
public fun init_transfer_wormhole_payload(
chain_id: u8,
sender: address,
token_address: address,
origin_nonce: u64,
amount: u128,
fee: u128,
native_fee: u128,
recipient: &String,
message: &vector<u8>
): vector<u8> {
let buf = vector[];
buf.push_back(WH_MSG_INIT_TRANSFER);
buf.push_back(chain_id);
buf.append(bcs::to_bytes(&sender));
buf.push_back(chain_id);
buf.append(bcs::to_bytes(&token_address));
buf.append(bcs::to_bytes(&origin_nonce));
buf.append(bcs::to_bytes(&amount));
buf.append(bcs::to_bytes(&fee));
buf.append(bcs::to_bytes(&native_fee));
buf.append(borsh::encode_string(recipient));
buf.append(borsh::encode_byte_vec(message));
buf
}

/// Wormhole payload for finalizing an inbound transfer. Mirrors
/// `finTransferExtension` in OmniBridgeWormhole.sol. `fee_recipient`
/// `None` is encoded as the empty string to match EVM's non-Optional
/// `string`.
public fun fin_transfer_wormhole_payload(
chain_id: u8,
origin_chain: u8,
origin_nonce: u64,
token_address: address,
amount: u128,
fee_recipient: &Option<String>
): vector<u8> {
let buf = vector[];
buf.push_back(WH_MSG_FIN_TRANSFER);
buf.push_back(origin_chain);
buf.append(bcs::to_bytes(&origin_nonce));
buf.push_back(chain_id);
buf.append(bcs::to_bytes(&token_address));
buf.append(bcs::to_bytes(&amount));
if (fee_recipient.is_some()) {
buf.append(borsh::encode_string(fee_recipient.borrow()));
} else {
// 4-byte LE length prefix of zero, no body.
buf.append(x"00000000");
};
buf
}

/// Wormhole payload for a token deploy. Mirrors `deployTokenExtension`
/// in OmniBridgeWormhole.sol.
public fun deploy_token_wormhole_payload(
chain_id: u8,
token: &String,
token_address: address,
decimals: u8,
origin_decimals: u8
): vector<u8> {
let buf = vector[];
buf.push_back(WH_MSG_DEPLOY_TOKEN);
buf.append(borsh::encode_string(token));
buf.push_back(chain_id);
buf.append(bcs::to_bytes(&token_address));
buf.push_back(decimals);
buf.push_back(origin_decimals);
buf
}

/// Wormhole payload for a `log_metadata` event. Mirrors
/// `logMetadataExtension` in OmniBridgeWormhole.sol.
public fun log_metadata_wormhole_payload(
chain_id: u8,
token_address: address,
name: &String,
symbol: &String,
decimals: u8
): vector<u8> {
let buf = vector[];
buf.push_back(WH_MSG_LOG_METADATA);
buf.push_back(chain_id);
buf.append(bcs::to_bytes(&token_address));
buf.append(borsh::encode_string(name));
buf.append(borsh::encode_string(symbol));
buf.push_back(decimals);
buf
}
}

Loading
Loading