diff --git a/.gitignore b/.gitignore index 020952138..0fc585922 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ cache/ out/ target/ +build/ # Ignores development broadcast logs !/broadcast diff --git a/sui/README.md b/sui/README.md new file mode 100644 index 000000000..7c402fe8b --- /dev/null +++ b/sui/README.md @@ -0,0 +1,28 @@ +# Sui NTT + +The project is structured into three packages: + +- [`ntt`](./packages/ntt) (NTT manager) +- [`ntt_common`](./packages_ntt_common) +- [`wormhole_transciever`](./packages/wormhole_transceiver) + +In the NTT architecture, the NTT manager implements the token transfer logic, and sends/receives messages via potentially multiple transceivers in a threshold configuration. +Here, we decouple the implementation of the manager from the transceiver, so they don't need to know about each other. Indeed, the `ntt` and `wormhole_transceiver` packages don't depend on each other. + +Instead, they communicate via permissioned structs defined in [`ntt_common`](./packages/ntt_common). + +## `ntt_common` + +The [`ntt_common`](./packages/ntt_common) package contains common struct definitions that are shared between the NTT manager and transceivers. + +There are two flavours of these structs: _unpermissioned_ and _permissioned_. + +By unpermissioned, we mean that these can be created and destructed by any module. +These define the structs in the wire format, and as such come with (de)serialiser functions too. Holding a value of these types gives no guarantees about the provenance of the data, so they are exclusively used for structuring information. These structs are defined in the [`messages`](./packages/ntt_common/sources/messages) folder. + +On the other hand, construction/consumption of permissioned structs is restricted, and thus provide specific gurantees about the information contained within. +The NTT manager sends messages by creating a value of type [`OutboundMessage`](./packages/ntt_common/sources/outbound_message.move), which the transceiver consumes and processes. +In the other direction, the NTT manager receives messages by consuming [`ValidatedTransceiverMessage`](./packages/ntt_common/sources/validated_transceiver_message.move) structs that are created by the transceiver. See the documentation in those modules for more details on the implementation. +These inbound/outbound permissioned structs are passed between the manager and transceivers in a programmable transaction block, meaning that the contracts don't directly call each other. As such, care has been taken to restrict the capabilities of these structs sufficiently to ensure that the client constructing the PTB can not do the wrong thing. + +The intention is for a single `ntt_common` module to be deployed, and shared between all NTT manager & transceiver instances. diff --git a/sui/packages/ntt/Move.toml b/sui/packages/ntt/Move.toml new file mode 100644 index 000000000..a6a832399 --- /dev/null +++ b/sui/packages/ntt/Move.toml @@ -0,0 +1,29 @@ +[package] +name = "Ntt" +edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move +license = "Apache 2.0" + +[dependencies.Sui] +git = "https://github.com/MystenLabs/sui.git" +subdir = "crates/sui-framework/packages/sui-framework" +rev = "framework/testnet" +override = true + +[dependencies.Wormhole] +# git = "https://github.com/wormhole-foundation/wormhole.git" +# rev = "sui/mainnet" +# TODO: we're using this fork temporarily which allows us to create VAAs for testing +git = "https://github.com/wormholelabs-xyz/wormhole.git" +rev = "sui/vaa-new-test-only" +subdir = "sui/wormhole" + +[dependencies.NttCommon] +local = "../ntt_common" + +[addresses] +ntt = "0x0" + +[dev-dependencies] + +[dev-addresses] +wormhole = "0x10" diff --git a/sui/packages/ntt/sources/auth.move b/sui/packages/ntt/sources/auth.move new file mode 100644 index 000000000..480eb9c36 --- /dev/null +++ b/sui/packages/ntt/sources/auth.move @@ -0,0 +1,7 @@ +module ntt::auth { + public struct Auth has drop {} + + public(package) fun new_auth(): Auth { + Auth {} + } +} diff --git a/sui/packages/ntt/sources/datatypes/mode.move b/sui/packages/ntt/sources/datatypes/mode.move new file mode 100644 index 000000000..a4dfcd8ca --- /dev/null +++ b/sui/packages/ntt/sources/datatypes/mode.move @@ -0,0 +1,57 @@ +module ntt::mode { + use wormhole::cursor; + + #[error] + const EInvalidMode: vector = + b"Invalid mode byte in serialized state."; + + public enum Mode has copy, store, drop { + Locking, + Burning + } + + public fun locking(): Mode { + Mode::Locking + } + + public fun burning(): Mode { + Mode::Burning + } + + public fun is_locking(mode: &Mode): bool { + match (mode) { + Mode::Locking => true, + _ => false + } + } + + public fun is_burning(mode: &Mode): bool { + match (mode) { + Mode::Burning => true, + _ => false + } + } + + public fun serialize(mode: Mode): vector { + match (mode) { + Mode::Locking => vector[0], + Mode::Burning => vector[1] + } + } + + public fun take_bytes(cur: &mut cursor::Cursor): Mode { + let byte = cur.poke(); + match (byte) { + 0 => Mode::Locking, + 1 => Mode::Burning, + _ => abort(EInvalidMode) + } + } + + public fun parse(buf: vector): Mode { + let mut cur = cursor::new(buf); + let mode = take_bytes(&mut cur); + cur.destroy_empty(); + mode + } +} diff --git a/sui/packages/ntt/sources/datatypes/peer.move b/sui/packages/ntt/sources/datatypes/peer.move new file mode 100644 index 000000000..72b19ff33 --- /dev/null +++ b/sui/packages/ntt/sources/datatypes/peer.move @@ -0,0 +1,41 @@ +module ntt::peer { + use wormhole::external_address::ExternalAddress; + use ntt::rate_limit::{Self, RateLimitState}; + + public struct Peer has store { + address: ExternalAddress, + token_decimals: u8, + inbound_rate_limit: RateLimitState, + } + + // NOTE: this is public. There are no assumptions about a `Peer` being created internally. + // Instead, `Peer`s are always looked up from the state object whenever they + // are needed, and write access to the state object is protected. + public fun new(address: ExternalAddress, token_decimals: u8, inbound_limit: u64): Peer { + Peer { + address, + token_decimals, + inbound_rate_limit: rate_limit::new(inbound_limit), + } + } + + public fun set_address(peer: &mut Peer, address: ExternalAddress) { + peer.address = address; + } + + public fun set_token_decimals(peer: &mut Peer, token_decimals: u8) { + peer.token_decimals = token_decimals; + } + + public fun borrow_address(peer: &Peer): &ExternalAddress { + &peer.address + } + + public fun get_token_decimals(peer: &Peer): u8 { + peer.token_decimals + } + + public fun borrow_inbound_rate_limit_mut(peer: &mut Peer): &mut RateLimitState { + &mut peer.inbound_rate_limit + } +} diff --git a/sui/packages/ntt/sources/inbox.move b/sui/packages/ntt/sources/inbox.move new file mode 100644 index 000000000..d4699788a --- /dev/null +++ b/sui/packages/ntt/sources/inbox.move @@ -0,0 +1,129 @@ +module ntt::inbox { + use sui::clock::Clock; + use sui::table::{Self, Table}; + use ntt_common::bitmap::{Self, Bitmap}; + use ntt_common::ntt_manager_message::NttManagerMessage; + + #[error] + const ETransferCannotBeRedeemed: vector + = b"Transfer cannot be redeemed yet"; + + #[error] + const ETransferAlreadyRedeemed: vector + = b"Transfer already redeemed"; + + // === Inbox === + + /// The inbox. + /// It's a key-value store where the key is a message containing some + /// payload 'K', and the value is the value 'K' along with information about + /// how many transceivers have voted for it, and whether it has been processed yet. + /// + /// For security reasons, a mutable reference to an inbox should never be exposed publicly. + /// This is because priviliged functions are gated by a mutable reference to the inbox. + /// + /// In practice, 'K' is instantiated with NativeTokenTransfer, but is written generically + /// here to make reasoning about the code easier (as the inbox doesn't care + /// about the things it stores) + public struct Inbox has store { + entries: Table, InboxItem> + } + + public fun new(ctx: &mut TxContext): Inbox { + Inbox { + entries: table::new(ctx), + } + } + + // === Inbox key === + + /// The inbox key is a message from a chain. We include the entire ntt + /// manager message here, not just the payload 'K', because there might be + /// multiple messages with the same content, so the manager message metadata + /// (message id in particular) helps disambiguate. Similarly, manager + /// message ids are only locally unique (per chain), so we also include the + /// origin chain in the key. + /// + /// By having transceivers `vote` on messages keyed by the message content, + /// we guarantee that when a particular message receives two votes, both of + /// those votes are actually for the exact same message. + public struct InboxKey has store, copy, drop { + chain_id: u16, + message: NttManagerMessage + } + + /// A public constructor for inbox key. + /// No action is privileged by holding an `InboxKey`, so it's safe to make + /// its constructor public. + public fun new_inbox_key( + chain_id: u16, + message: NttManagerMessage + ): InboxKey { + InboxKey { + chain_id, + message + } + } + + // === Inbox item === + + public struct InboxItem has store { + votes: Bitmap, + release_status: ReleaseStatus, + data: K, + } + + public enum ReleaseStatus has copy, drop, store { + NotApproved, + ReleaseAfter(u64), + Released, + } + + public fun count_enabled_votes(self: &InboxItem, enabled: &Bitmap): u8 { + let both = self.votes.and(enabled); + both.count_ones() + } + + + public fun try_release(inbox_item: &mut InboxItem, clock: &Clock): bool { + match (inbox_item.release_status) { + ReleaseStatus::NotApproved => false, + ReleaseStatus::ReleaseAfter(release_timestamp) => { + if (release_timestamp <= clock.timestamp_ms()) { + inbox_item.release_status = ReleaseStatus::Released; + true + } else { + false + } + }, + ReleaseStatus::Released => abort ETransferAlreadyRedeemed + } + } + + public fun release_after(inbox_item: &mut InboxItem, release_timestamp: u64) { + if (inbox_item.release_status != ReleaseStatus::NotApproved) { + abort ETransferCannotBeRedeemed + }; + inbox_item.release_status = ReleaseStatus::ReleaseAfter(release_timestamp); + } + + public fun vote(inbox: &mut Inbox, transceiver_index: u8, entry: InboxKey) { + let (_, _, data) = entry.message.destruct(); + if (!inbox.entries.contains(entry)) { + inbox.entries.add(entry, InboxItem { + votes: bitmap::empty(), + release_status: ReleaseStatus::NotApproved, + data + }) + }; + inbox.entries.borrow_mut(entry).votes.enable(transceiver_index); + } + + public fun borrow_inbox_item_mut(inbox: &mut Inbox, key: InboxKey): &mut InboxItem { + inbox.entries.borrow_mut(key) + } + + public fun borrow_inbox_item(inbox: &Inbox, key: InboxKey): &InboxItem { + inbox.entries.borrow(key) + } +} diff --git a/sui/packages/ntt/sources/ntt.move b/sui/packages/ntt/sources/ntt.move new file mode 100644 index 000000000..f6a03f3d4 --- /dev/null +++ b/sui/packages/ntt/sources/ntt.move @@ -0,0 +1,302 @@ +module ntt::ntt { + use wormhole::external_address::{Self, ExternalAddress}; + use wormhole::bytes32; + use sui::balance::Balance; + use sui::clock::Clock; + use sui::coin::{Self, Coin, CoinMetadata}; + use ntt_common::trimmed_amount::{Self, TrimmedAmount}; + use ntt::state::State; + use ntt::outbox::{Self, OutboxKey}; + use ntt_common::native_token_transfer::{Self, NativeTokenTransfer}; + use ntt_common::ntt_manager_message::{Self, NttManagerMessage}; + use ntt_common::validated_transceiver_message::ValidatedTransceiverMessage; + use ntt::upgrades::VersionGated; + + #[error] + const ETransferExceedsRateLimit: vector + = b"Transfer exceeds rate limit"; + + #[error] + const ECantReleaseYet: vector + = b"Can't release yet"; + + #[error] + const EWrongDestinationChain: vector + = b"Wrong destination chain"; + + #[allow(lint(coin_field))] + public struct TransferTicket { + coins: Coin, + token_address: ExternalAddress, + trimmed_amount: TrimmedAmount, + recipient_chain: u16, + recipient: ExternalAddress, + payload: Option>, + recipient_manager: ExternalAddress, + should_queue: bool, + } + + #[test_only] + /// Create a transfer ticket for testing purposes + public fun new_transfer_ticket( + coins: Coin, + token_address: ExternalAddress, + trimmed_amount: TrimmedAmount, + recipient_chain: u16, + recipient: ExternalAddress, + payload: Option>, + recipient_manager: ExternalAddress, + should_queue: bool + ): TransferTicket { + TransferTicket { + coins, + token_address, + trimmed_amount, + recipient_chain, + recipient, + payload, + recipient_manager, + should_queue + } + } + + // upgrade safe + public fun prepare_transfer( + state: &State, + mut coins: Coin, + coin_meta: &CoinMetadata, + recipient_chain: u16, + recipient: vector, + payload: Option>, + should_queue: bool, + ): ( + TransferTicket, + Balance // dust (TODO: should we create a coin for it?) + ) { + let from_decimals = coin_meta.get_decimals(); + let peer = state.borrow_peer(recipient_chain); + let to_decimals = peer.get_token_decimals(); + let recipient_manager = *peer.borrow_address(); + let (trimmed_amount, dust) = + trimmed_amount::remove_dust(&mut coins, from_decimals, to_decimals); + + let ticket = TransferTicket { + coins, + token_address: wormhole::external_address::from_id(object::id(coin_meta)), + trimmed_amount, + recipient_chain, + recipient: external_address::new(bytes32::new(recipient)), + payload, + recipient_manager, + should_queue, + }; + + (ticket, dust) + } + + public fun transfer_tx_sender( + state: &mut State, + version_gated: VersionGated, + ticket: TransferTicket, + clock: &Clock, + ctx: &TxContext + ): OutboxKey { + transfer_impl(state, version_gated, ticket, clock, ctx.sender()) + } + + public fun transfer_with_auth( + auth: &Auth, + state: &mut State, + version_gated: VersionGated, + ticket: TransferTicket, + clock: &Clock, + ): OutboxKey { + transfer_impl(state, version_gated, ticket, clock, ntt_common::contract_auth::assert_auth_type(auth)) + } + + fun transfer_impl( + state: &mut State, + version_gated: VersionGated, + ticket: TransferTicket, + clock: &Clock, + sender: address + ): OutboxKey { + version_gated.check_version(state); + + let TransferTicket { + coins, + token_address, + trimmed_amount, + recipient_chain, + recipient, + payload, + recipient_manager, + should_queue + } = ticket; + + if (state.borrow_mode().is_locking()) { + coin::put(state.borrow_balance_mut(), coins); + } else { + coin::burn(state.borrow_treasury_cap_mut(), coins); + }; + + let consumed_or_delayed + = state.borrow_outbox_mut() + .borrow_rate_limit_mut() + .consume_or_delay(clock, trimmed_amount.amount()); + + let release_timestamp = if (consumed_or_delayed.is_delayed()) { + let release_timestamp = consumed_or_delayed.delayed_until(); + if (!should_queue) { + abort ETransferExceedsRateLimit + }; + release_timestamp + } else { + // consumed. refill inbox rate limit + state.borrow_peer_mut(recipient_chain) + .borrow_inbound_rate_limit_mut() + .refill(clock, trimmed_amount.amount()); + clock.timestamp_ms() + }; + + let message_id = state.next_message_id(); + + state.borrow_outbox_mut().add( + outbox::new_outbox_item( + release_timestamp, + recipient_manager, + ntt_manager_message::new( + message_id, + external_address::from_address(sender), + native_token_transfer::new( + trimmed_amount, + token_address, + recipient, + recipient_chain, + payload + ) + ) + ) + ) + } + + public fun redeem( + state: &mut State, + version_gated: VersionGated, + coin_meta: &CoinMetadata, + validated_message: ValidatedTransceiverMessage>, + clock: &Clock, + ) { + version_gated.check_version(state); + + let (chain_id, source_ntt_manager, ntt_manager_message) = + validated_message.destruct_recipient_only(&ntt::auth::new_auth()); + + let ntt_manager_message = ntt_common::ntt_manager_message::map!(ntt_manager_message, |buf| { + native_token_transfer::parse(buf) + }); + + assert!(source_ntt_manager == state.borrow_peer(chain_id).borrow_address()); + + // NOTE: this checks that the transceiver is in fact registered + state.vote(chain_id, ntt_manager_message); + + let (_id, _sender, payload) = ntt_manager_message.destruct(); + let (trimmed_amount, _source_token, _recipient, to_chain, _payload) = payload.destruct(); + assert!(to_chain == state.get_chain_id(), EWrongDestinationChain); + + let amount = trimmed_amount.untrim(coin_meta.get_decimals()); + + let inbox_item = state.borrow_inbox_item_mut(chain_id, ntt_manager_message); + let num_votes = inbox_item.count_enabled_votes(&state.get_enabled_transceivers()); + if (num_votes < state.get_threshold()) { + return + }; + + // TODO: should this last part be a separate function? so attestation handling, THEN this + + let consumed_or_delayed + = state.borrow_peer_mut(chain_id) + .borrow_inbound_rate_limit_mut() + .consume_or_delay(clock, amount); + + let release_timestamp = if (consumed_or_delayed.is_delayed()) { + consumed_or_delayed.delayed_until() + } else { + // consumed. refill outbox rate limit + state.borrow_outbox_mut() + .borrow_rate_limit_mut() + .refill(clock, amount); + clock.timestamp_ms() + }; + + let inbox_item = state.borrow_inbox_item_mut(chain_id, ntt_manager_message); + inbox_item.release_after(release_timestamp) + } + + public fun release( + state: &mut State, + version_gated: VersionGated, + from_chain_id: u16, + message: NttManagerMessage, + coin_meta: &CoinMetadata, + clock: &Clock, + ctx: &mut TxContext + ) { + // NOTE: payload handling must be done by modifying the implementation + // here. the default NTT implementation simply ignores the payload + let (recipient, coins, _payload) = release_impl( + state, + version_gated, + from_chain_id, + message, + coin_meta, + clock, + ctx + ); + + transfer::public_transfer(coins, recipient) + } + + fun release_impl( + state: &mut State, + version_gated: VersionGated, + chain_id: u16, + message: NttManagerMessage, + coin_meta: &CoinMetadata, + clock: &Clock, + ctx: &mut TxContext + ): (address, Coin, Option>) { + + version_gated.check_version(state); + + // NOTE: this validates that the message has enough votes etc + let released = state.try_release_in(chain_id, message, clock); + + if (!released) { + abort ECantReleaseYet + }; + + let (_, _, payload) = message.destruct(); + + // TODO: to_chain is verified when inserting into the inbox in `redeem`. + // should we verify it here too? + let (trimmed_amount, _source_token, recipient, _to_chain, payload) = payload.destruct(); + + let amount = trimmed_amount.untrim(coin_meta.get_decimals()); + + (recipient.to_address(), mint_or_unlock(state, amount, ctx), payload) + } + + fun mint_or_unlock( + state: &mut State, + amount: u64, + ctx: &mut TxContext + ): Coin { + if (state.borrow_mode().is_locking()) { + coin::take(state.borrow_balance_mut(), amount, ctx) + } else { + coin::mint(state.borrow_treasury_cap_mut(), amount, ctx) + } + } +} diff --git a/sui/packages/ntt/sources/outbox.move b/sui/packages/ntt/sources/outbox.move new file mode 100644 index 000000000..9509ee484 --- /dev/null +++ b/sui/packages/ntt/sources/outbox.move @@ -0,0 +1,103 @@ +module ntt::outbox { + use sui::table::{Self, Table}; + use sui::clock::Clock; + use wormhole::bytes32::Bytes32; + use wormhole::external_address::ExternalAddress; + use ntt_common::bitmap::{Self, Bitmap}; + use ntt::rate_limit::{Self, RateLimitState}; + use ntt_common::ntt_manager_message::NttManagerMessage; + + #[error] + const EMessageAlreadySent: vector + = b"Message has already been sent by this transceiver"; + + public struct Outbox has store { + entries: Table>, + rate_limit: RateLimitState, + } + + public(package) fun new(outbound_limit: u64, ctx: &mut TxContext): Outbox { + Outbox { + entries: table::new(ctx), + rate_limit: rate_limit::new(outbound_limit), + } + } + + public fun borrow_rate_limit_mut(outbox: &mut Outbox): &mut RateLimitState { + &mut outbox.rate_limit + } + + public struct OutboxKey has copy, drop, store { + id: Bytes32, + } + + public fun get_id(key: &OutboxKey): Bytes32 { + key.id + } + + public fun new_outbox_key(id: Bytes32): OutboxKey { + OutboxKey { id } + } + + public struct OutboxItem has store { + release_timestamp: u64, + released: Bitmap, + recipient_ntt_manager: ExternalAddress, + data: NttManagerMessage, + } + + public fun new_outbox_item( + release_timestamp: u64, + recipient_ntt_manager: ExternalAddress, + data: NttManagerMessage + ): OutboxItem { + OutboxItem { + release_timestamp, + released: bitmap::empty(), + recipient_ntt_manager, + data: data, + } + } + + public fun add(outbox: &mut Outbox, item: OutboxItem): OutboxKey { + let key = OutboxKey { id: item.data.get_id() }; + outbox.entries.add(key, item); + key + } + + public fun borrow(outbox: &Outbox, key: OutboxKey): &OutboxItem { + outbox.entries.borrow(key) + } + + public fun borrow_data(outbox: &OutboxItem): &NttManagerMessage { + &outbox.data + } + + public fun borrow_recipient_ntt_manager_address( + outbox_item: &OutboxItem, + ): &ExternalAddress { + &outbox_item.recipient_ntt_manager + } + + public(package) fun try_release( + outbox: &mut Outbox, + key: OutboxKey, + transceiver_index: u8, + clock: &Clock + ): bool { + let outbox_item = outbox.entries.borrow_mut(key); + let now = clock.timestamp_ms(); + + if (outbox_item.release_timestamp > now) { + return false + }; + + if (outbox_item.released.get(transceiver_index)) { + abort EMessageAlreadySent + }; + + outbox_item.released.enable(transceiver_index); + + true + } +} diff --git a/sui/packages/ntt/sources/rate_limit.move b/sui/packages/ntt/sources/rate_limit.move new file mode 100644 index 000000000..7d91b45bc --- /dev/null +++ b/sui/packages/ntt/sources/rate_limit.move @@ -0,0 +1,142 @@ +module ntt::rate_limit { + use sui::clock::Clock; + + const RATE_LIMIT_DURATION: u64 = 24 * 60 * 60 * 1000; // 24 hours in ms + + #[error] + const EInvalidRateLimitResult: vector + = b"Invalid RateLimitResult"; + + public enum RateLimitResult has drop { + Consumed, + Delayed(u64), + } + + public fun is_consumed(result: &RateLimitResult): bool { + match (result) { + RateLimitResult::Consumed => true, + _ => false, + } + } + + public fun is_delayed(result: &RateLimitResult): bool { + match (result) { + RateLimitResult::Delayed(_) => true, + _ => false, + } + } + + public fun delayed_until(result: &RateLimitResult): u64 { + match (result) { + RateLimitResult::Delayed(until) => *until, + _ => abort EInvalidRateLimitResult, + } + } + + public struct RateLimitState has store { + /// The maximum capacity of the rate limiter. + limit: u64, + /// The capacity of the rate limiter at `last_tx_timestamp`. + /// The actual current capacity is calculated in `capacity_at`, by + /// accounting for the time that has passed since `last_tx_timestamp` and + /// the refill rate. + capacity_at_last_tx: u64, + /// The timestamp (in ms) of the last transaction that counted towards the current + /// capacity. Transactions that exceeded the capacity do not count, they are + /// just delayed. + last_tx_timestamp: u64, + } + + public fun new(limit: u64): RateLimitState { + RateLimitState { + limit: limit, + capacity_at_last_tx: limit, + last_tx_timestamp: 0, + } + } + + public fun capacity_at(self: &RateLimitState, now: u64): u64 { + assert!(self.last_tx_timestamp <= now); + + let limit = (self.limit as u128); + + // morally this is + // capacity = old_capacity + (limit / rate_limit_duration) * time_passed + // + // but we instead write it as + // capacity = old_capacity + (limit * time_passed) / rate_limit_duration + // as it has better numerical stability. + // + // This can overflow u64 (if limit is close to u64 max), so we use u128 + // for the intermediate calculations. Theoretically it could also overflow u128 + // if limit == time_passed == u64 max, but that will take a very long time. + + let capacity_at_last_tx = self.capacity_at_last_tx; + + let calculated_capacity = { + let time_passed = now - self.last_tx_timestamp; + (capacity_at_last_tx as u128) + + (time_passed as u128) * limit / (Self::RATE_LIMIT_DURATION as u128) + }; + + // The use of `min` here prevents truncation. + // The value of `limit` is u64 in reality. If both `calculated_capacity` and `limit` are at + // their maxiumum possible values (u128::MAX and u64::MAX), then u64::MAX will be chosen by + // `min`. So truncation is not possible. + min!(calculated_capacity, limit) as u64 + } + + macro fun min($x: _, $y: _): _ { + let x = $x; + let y = $y; + if (x < y) x + else y + } + + public fun consume_or_delay(self: &mut RateLimitState, clock: &Clock, amount: u64): RateLimitResult { + let now = clock.timestamp_ms(); + let capacity = self.capacity_at(now); + if (capacity >= amount) { + self.capacity_at_last_tx = capacity - amount; + self.last_tx_timestamp = now; + RateLimitResult::Consumed + } else { + RateLimitResult::Delayed(now + Self::RATE_LIMIT_DURATION) + } + } + + public fun refill(self: &mut RateLimitState, clock: &Clock, amount: u64) { + // saturating add + let new_amount: u128 = (self.capacity_at(clock.timestamp_ms()) as u128) + (amount as u128); + let new_amount = min!(new_amount, 0xFFFF_FFFF_FFFF_FFFF) as u64; + + self.capacity_at_last_tx = min!(new_amount, self.limit); + } + + public fun set_limit(self: &mut RateLimitState, limit: u64, clock: &Clock) { + let old_limit = self.limit; + let now = clock.timestamp_ms(); + let current_capacity = self.capacity_at(now); + + self.limit = limit; + + let new_capacity: u64 = if (old_limit > limit) { + // decrease in limit, + let diff = old_limit - limit; + if (diff > current_capacity) { + 0 + } else { + current_capacity - diff + } + } else { + // increase in limit + let diff = limit - old_limit; + let new_capacity: u128 = (current_capacity as u128) + (diff as u128); + // saturating add + min!(new_capacity, 0xFFFF_FFFF_FFFF_FFFF) as u64 + }; + + self.capacity_at_last_tx = new_capacity.min(limit); + self.last_tx_timestamp = now; + } +} diff --git a/sui/packages/ntt/sources/setup.move b/sui/packages/ntt/sources/setup.move new file mode 100644 index 000000000..2ef0e52d7 --- /dev/null +++ b/sui/packages/ntt/sources/setup.move @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache 2 + +/// This module implements the mechanism to publish the NTT contract and +/// initialize `State` as a shared object. +module ntt::setup { + use sui::package; + use sui::coin::{TreasuryCap}; + + use ntt::state; + use ntt::mode::Mode; + + /// Capability created at `init`, which will be destroyed once + /// `complete` is called. This ensures only the deployer can + /// create the shared `State`. + public struct DeployerCap has key, store { + id: UID + } + + /// Called automatically when module is first published. Transfers + /// `DeployerCap` to sender. + fun init(ctx: &mut TxContext) { + let deployer = DeployerCap { id: object::new(ctx) }; + transfer::transfer(deployer, tx_context::sender(ctx)); + } + + #[test_only] + public fun init_test_only(ctx: &mut TxContext) { + init(ctx); + + // This will be created and sent to the transaction sender + // automatically when the contract is published. + transfer::public_transfer( + package::test_publish(object::id_from_address(@ntt), ctx), + tx_context::sender(ctx) + ); + } + + #[allow(lint(share_owned))] + /// Only the owner of the `DeployerCap` can call this method. This + /// method destroys the capability and shares the `State` object. + public fun complete( + deployer: DeployerCap, + upgrade_cap: sui::package::UpgradeCap, + chain_id: u16, + mode: Mode, + treasury_cap: Option>, + ctx: &mut TxContext + ): (state::AdminCap, ntt::upgrades::UpgradeCap) { + // Destroy deployer cap + let DeployerCap { id } = deployer; + object::delete(id); + + let upgrade_cap = ntt::upgrades::new_upgrade_cap( + upgrade_cap, + ctx + ); + + // Share new state + let (state, admin_cap) = state::new( + chain_id, + mode, + treasury_cap, + ctx + ); + + transfer::public_share_object(state); + (admin_cap, upgrade_cap) + } +} diff --git a/sui/packages/ntt/sources/state.move b/sui/packages/ntt/sources/state.move new file mode 100644 index 000000000..f03b78c2d --- /dev/null +++ b/sui/packages/ntt/sources/state.move @@ -0,0 +1,249 @@ +module ntt::state { + use sui::coin::TreasuryCap; + use sui::table::{Self, Table}; + use sui::balance::Balance; + use sui::clock::Clock; + use wormhole::bytes32::{Self, Bytes32}; + use wormhole::external_address::ExternalAddress; + use ntt::mode::Mode; + use ntt::peer::{Self, Peer}; + use ntt_common::bitmap::Bitmap; + use ntt::outbox::{Self, Outbox}; + use ntt::inbox::{Self, Inbox, InboxItem}; + use ntt::transceiver_registry::{Self, TransceiverRegistry}; + use ntt_common::native_token_transfer::NativeTokenTransfer; + use ntt_common::ntt_manager_message::{Self, NttManagerMessage}; + + /// NOTE: this is a shared object, so anyone can grab a mutable reference to + /// it. Thus, functions are access-controlled by (package) visibility. + public struct State has key, store { + id: UID, + mode: Mode, + /// balance of locked tokens (in burning mode, it's always empty) + balance: Balance, // TODO: rename to custody or something + threshold: u8, + /// treasury cap for managing wrapped asset (in locking mode, it's None) + treasury_cap: Option>, + peers: Table, + outbox: Outbox, + inbox: Inbox, + transceivers: TransceiverRegistry, + chain_id: u16, + next_sequence: u64, + + version: u64, + } + + public(package) fun new( + chain_id: u16, + mode: Mode, + treasury_cap: Option>, + ctx: &mut TxContext + ): (State, AdminCap) { + // treasury_cap is None iff we're in locking mode + assert!(treasury_cap.is_none() == mode.is_locking()); + + let u64max = 0xFFFFFFFFFFFFFFFF; + let state = State { + id: object::new(ctx), + mode, + treasury_cap, + balance: sui::balance::zero(), + threshold: 0, + peers: table::new(ctx), + outbox: outbox::new(u64max, ctx), + inbox: inbox::new(ctx), + transceivers: transceiver_registry::new(ctx), + chain_id, + next_sequence: 1, + version: 0, + }; + + let admin_cap = AdminCap { + id: object::new(ctx) + }; + + (state, admin_cap) + } + + #[test_only] + public fun mint_for_test(self: &mut State, amount: u64, ctx: &mut TxContext): sui::coin::Coin { + self.treasury_cap.borrow_mut().mint(amount, ctx) + } + + public(package) fun set_version(self: &mut State, new_version: u64) { + assert!(new_version >= self.version); + self.version = new_version; + } + + public fun get_version(self: &State): u64 { + self.version + } + + public fun borrow_mode(self: &State): &Mode { + &self.mode + } + + public fun get_chain_id(self: &State): u16 { + self.chain_id + } + + public(package) fun borrow_balance_mut(self: &mut State): &mut Balance { + &mut self.balance + } + + public(package) fun borrow_balance(self: &State): &Balance { + &self.balance + } + + public fun get_threshold(self: &State): u8 { + self.threshold + } + + public(package) fun borrow_treasury_cap_mut(self: &mut State): &mut TreasuryCap { + self.treasury_cap.borrow_mut() + } + + public(package) fun borrow_treasury_cap(self: &State): &TreasuryCap { + self.treasury_cap.borrow() + } + + public(package) fun borrow_outbox_mut(self: &mut State): &mut Outbox { + &mut self.outbox + } + + public fun borrow_outbox(self: &State): &Outbox { + &self.outbox + } + + public fun get_enabled_transceivers(self: &State): Bitmap { + self.transceivers.get_enabled_transceivers() + } + + public fun borrow_peer(self: &State, chain: u16): &Peer { + self.peers.borrow(chain) + } + + public(package) fun borrow_peer_mut( + self: &mut State, + chain: u16 + ): &mut Peer { + self.peers.borrow_mut(chain) + } + + public(package) fun try_release_in( + self: &mut State, + chain_id: u16, + message: NttManagerMessage, + clock: &Clock + ): bool { + let inbox_key = inbox::new_inbox_key(chain_id, message); + self.inbox.borrow_inbox_item_mut(inbox_key).try_release(clock) + } + + public fun borrow_inbox_item( + self: &State, + chain_id: u16, + message: NttManagerMessage + ): &InboxItem { + let inbox_key = inbox::new_inbox_key(chain_id, message); + self.inbox.borrow_inbox_item(inbox_key) + } + + public(package) fun borrow_inbox_item_mut( + self: &mut State, + chain_id: u16, + message: NttManagerMessage + ): &mut InboxItem { + let inbox_key = inbox::new_inbox_key(chain_id, message); + self.inbox.borrow_inbox_item_mut(inbox_key) + } + + public fun create_transceiver_message( + self: &mut State, + message_id: Bytes32, + clock: &Clock + ): ntt_common::outbound_message::OutboundMessage { + let transceiver_index = self.transceivers.transceiver_id(); + let outbox_key = outbox::new_outbox_key(message_id); + let released = self.outbox.try_release(outbox_key, transceiver_index, clock); + assert!(released); + let outbox_item = self.outbox.borrow(outbox_key); + let message = *outbox_item.borrow_data(); + let recipient_ntt_manager = *outbox_item.borrow_recipient_ntt_manager_address(); + let message = ntt_manager_message::map!(message, |m| m.to_bytes()); + ntt_common::outbound_message::new(&ntt::auth::new_auth(), message, recipient_ntt_manager) + } + + // TODO: this currently allows a disabled transceiver to vote. should we disallow that? + // disabled votes don't count towards the threshold, so it's not a problem + public(package) fun vote( + self: &mut State, + chain_id: u16, + message: NttManagerMessage + ) { + let transceiver_index = self.transceivers.transceiver_id(); + let inbox_key = inbox::new_inbox_key(chain_id, message); + self.inbox.vote(transceiver_index, inbox_key) + } + + public(package) fun next_message_id(self: &mut State): Bytes32 { + let sequence = self.next_sequence; + self.next_sequence = sequence + 1; + bytes32::from_u256_be(sequence as u256) + } + + ////// Admin stuff + + public struct AdminCap has key, store { + id: UID + } + + public fun set_peer( + _: &AdminCap, + state: &mut State, + chain: u16, + address: ExternalAddress, + token_decimals: u8, + inbound_limit: u64, + clock: &Clock + ) { + if (state.peers.contains(chain)) { + let existing_peer = state.peers.borrow_mut(chain); + existing_peer.set_address(address); + existing_peer.set_token_decimals(token_decimals); + existing_peer.borrow_inbound_rate_limit_mut().set_limit(inbound_limit, clock); + } else { + state.peers.add(chain, peer::new(address, token_decimals, inbound_limit)) + } + } + + public fun set_outbound_rate_limit( + _: &AdminCap, + state: &mut State, + limit: u64, + clock: &Clock + ) { + state.outbox.borrow_rate_limit_mut().set_limit(limit, clock) + } + + public fun set_threshold( + _: &AdminCap, + state: &mut State, + threshold: u8 + ) { + state.threshold = threshold + } + + public fun register_transceiver(self: &mut State, _: &AdminCap) { + self.transceivers.register_transceiver(); + } + + public fun enable_transceiver(self: &mut State, _: &AdminCap, id: u8) { + self.transceivers.enable_transceiver(id); + } + + public fun disable_transceiver(self: &mut State, _: &AdminCap, id: u8) { + self.transceivers.disable_transceiver(id); + } +} diff --git a/sui/packages/ntt/sources/transceiver_registry.move b/sui/packages/ntt/sources/transceiver_registry.move new file mode 100644 index 000000000..49a2fdfbd --- /dev/null +++ b/sui/packages/ntt/sources/transceiver_registry.move @@ -0,0 +1,72 @@ +module ntt::transceiver_registry { + use sui::dynamic_field; + use ntt_common::bitmap::{Self, Bitmap}; + + #[error] + const EUnregisteredTransceiver: vector = + b"Unregistered transceiver"; + + // TODO: make the transceivers enumerable. what's the best way to do it? the + // keys are heterogeneous, so maybe just storing a different data structure + // that's keyed by id? or even a vector of pairs. we can keep it sorted for + // logarithmic access if need be (but probably overkill) + public struct TransceiverRegistry has key, store { + id: UID, + next_id: u8, + enabled_bitmap: Bitmap + } + + public(package) fun new(ctx: &mut TxContext): TransceiverRegistry { + TransceiverRegistry { + id: object::new(ctx), + next_id: 0, + enabled_bitmap: bitmap::empty() + } + } + + public fun next_id(registry: &mut TransceiverRegistry): u8 { + let id = registry.next_id; + registry.next_id = id + 1; + id + } + + public fun get_enabled_transceivers(registry: &TransceiverRegistry): Bitmap { + registry.enabled_bitmap + } + + // TODO: do we want to put anything here? + public struct TransceiverInfo has copy, drop, store { + id: u8 + } + + public struct Key has copy, drop, store {} + + public fun register_transceiver(registry: &mut TransceiverRegistry) { + let id = next_id(registry); + registry.add(id); + registry.enable_transceiver(id); + } + + public fun enable_transceiver(registry: &mut TransceiverRegistry, id: u8) { + registry.enabled_bitmap.enable(id) + } + + public fun disable_transceiver(registry: &mut TransceiverRegistry, id: u8) { + registry.enabled_bitmap.disable(id) + } + + public fun transceiver_id(registry: &TransceiverRegistry): u8 { + registry.borrow().id + } + + // helpers + fun add(registry: &mut TransceiverRegistry, id: u8) { + dynamic_field::add(&mut registry.id, Key {}, TransceiverInfo { id }); + } + + fun borrow(registry: &TransceiverRegistry): &TransceiverInfo { + let key = Key {}; + assert!(dynamic_field::exists_with_type<_, TransceiverInfo>(®istry.id, key), EUnregisteredTransceiver); + dynamic_field::borrow(®istry.id, key) + } +} diff --git a/sui/packages/ntt/sources/upgrades.move b/sui/packages/ntt/sources/upgrades.move new file mode 100644 index 000000000..6aaf876de --- /dev/null +++ b/sui/packages/ntt/sources/upgrades.move @@ -0,0 +1,71 @@ +module ntt::upgrades { + use ntt::state::State; + + const VERSION: u64 = 0; + + public struct UpgradeCap has key, store { + id: UID, + cap: sui::package::UpgradeCap + } + + public fun new_upgrade_cap( + cap: sui::package::UpgradeCap, + ctx: &mut TxContext + ): UpgradeCap { + let id = object::new(ctx); + wormhole::package_utils::assert_package_upgrade_cap( + &cap, + sui::package::compatible_policy(), + 1 + ); + + UpgradeCap { id, cap } + } + + public fun authorize_upgrade( + cap: &mut UpgradeCap, + digest: vector + ): sui::package::UpgradeTicket { + let policy = cap.cap.upgrade_policy(); + return cap.cap.authorize_upgrade(policy, digest) + } + + public fun commit_upgrade( + cap: &mut UpgradeCap, + state: &mut State, + receipt: sui::package::UpgradeReceipt + ) { + state.set_version(VERSION); + cap.cap.commit_upgrade(receipt) + } + + /// A "marker" type that marks functions that are version gated. + /// + /// This is purely for documentation purposes, to make it easier to reason + /// about which public functions are version gated, because the only way to + /// consume this is by calling the `version_check` function. + /// + /// The contract should never instantiate this type directly, and instead + /// take it as an argument from public functions. That way, version checking + /// is immediately visible through the entire callstack just by looking at + /// function signatures. + public struct VersionGated {} + + public fun new_version_gated(): VersionGated { + VersionGated {} + } + + #[error] + const EVersionMismatch: vector = + b"Version mismatch: the upgrade is not compatible with the current version"; + + public fun check_version( + version_gated: VersionGated, + state: &State + ) { + let VersionGated {} = version_gated; + if (state.get_version() != VERSION) { + abort EVersionMismatch + } + } +} diff --git a/sui/packages/ntt/tests/ntt_scenario.move b/sui/packages/ntt/tests/ntt_scenario.move new file mode 100644 index 000000000..e629a0d6d --- /dev/null +++ b/sui/packages/ntt/tests/ntt_scenario.move @@ -0,0 +1,192 @@ +#[test_only] +/// This module implements ways to initialize NTT in a test scenario. +/// It provides common setup functions and test utilities. +module ntt::ntt_scenario { + use sui::test_scenario::{Self as ts, Scenario}; + use sui::coin::{Self, CoinMetadata}; + use sui::clock::{Self, Clock}; + use wormhole::external_address::{Self, ExternalAddress}; + use wormhole::bytes32; + use ntt::state::{Self, State, AdminCap}; + use ntt::mode; + use ntt_common::trimmed_amount; + use ntt_common::native_token_transfer::{Self, NativeTokenTransfer}; + use ntt_common::ntt_manager_message::{Self, NttManagerMessage}; + + // Test addresses + const ADMIN: address = @0x1111; + const USER_A: address = @0xAAAA; + const USER_B: address = @0xBBBB; + const USER_C: address = @0xCCCC; + + // Test constants + const CHAIN_ID: u16 = 1; + const PEER_CHAIN_ID: u16 = 2; + const DECIMALS: u8 = 9; + const RATE_LIMIT: u64 = 5000000000; // 5 tokens with 9 decimals + const THRESHOLD: u8 = 2; + + public fun chain_id(): u16 { + CHAIN_ID + } + + public fun peer_chain_id(): u16 { + PEER_CHAIN_ID + } + + public fun peer_manager_address(): ExternalAddress { + external_address::new(bytes32::from_bytes(x"0000000000000000000000000000000000000000000000000000000000000001")) + } + + public fun decimals(): u8 { + DECIMALS + } + + // Test helper structs + public struct NTT_SCENARIO has drop {} + + /// Set up a basic NTT test environment with: + /// - Test coin with treasury cap + /// - NTT state in burning mode + /// - Two registered transceivers + /// - One peer chain + /// - Clock for rate limiting + // TODO: create a locking mode test + public fun setup(scenario: &mut Scenario) { + let sender = scenario.sender(); + scenario.next_tx(ADMIN); + + // Create test coin + let (treasury_cap, metadata) = coin::create_currency( + NTT_SCENARIO {}, + DECIMALS, + b"TEST", + b"Test Coin", + b"A test coin for NTT", + option::none(), + ts::ctx(scenario) + ); + + // Initialize NTT state + let (mut state, admin_cap) = state::new( + CHAIN_ID, + mode::burning(), + option::some(treasury_cap), + ts::ctx(scenario) + ); + + // Register transceivers + state::register_transceiver(&mut state, &admin_cap); + state::register_transceiver(&mut state, &admin_cap); + + // Create clock for rate limiting + let clock = take_clock(scenario); + + // Set up a test peer + let peer_address = peer_manager_address(); + state::set_peer(&admin_cap, &mut state, PEER_CHAIN_ID, peer_address, DECIMALS, RATE_LIMIT, &clock); + + // Set threshold + state::set_threshold(&admin_cap, &mut state, THRESHOLD); + + // Transfer objects to shared storage + transfer::public_share_object(state); + transfer::public_transfer(admin_cap, ADMIN); + return_clock(clock); + transfer::public_share_object(metadata); + scenario.next_tx(sender); + } + + /// Helper function to create a test transfer message + public fun create_test_message( + amount: u64, + recipient: vector, + sequence: u64 + ): NttManagerMessage { + let trimmed_amount = trimmed_amount::trim(amount, DECIMALS, DECIMALS); + let recipient_addr = external_address::new(bytes32::from_bytes(recipient)); + let source_token = external_address::new(bytes32::from_bytes(x"0000000000000000000000000000000000000000000000000000000000000002")); + + let transfer = native_token_transfer::new( + trimmed_amount, + source_token, + recipient_addr, + CHAIN_ID, + option::none() + ); + + let sender = external_address::new(bytes32::from_bytes(x"0000000000000000000000000000000000000000000000000000000000000003")); + let id = bytes32::from_u256_be((sequence as u256)); + + ntt_manager_message::new(id, sender, transfer) + } + + /// Helper function to take NTT state from scenario + public fun take_state(scenario: &Scenario): State { + ts::take_shared(scenario) + } + + /// Helper function to return NTT state to scenario + public fun return_state(state: State) { + ts::return_shared(state); + } + + /// Helper function to take admin cap from scenario + public fun take_admin_cap(scenario: &Scenario): AdminCap { + ts::take_from_address(scenario, ADMIN) + } + + /// Helper function to return admin cap to scenario + public fun return_admin_cap(cap: AdminCap) { + transfer::public_transfer(cap, ADMIN); + } + + public fun take_coin_metadata(scenario: &Scenario): CoinMetadata { + ts::take_shared(scenario) + } + + public fun return_coin_metadata(metadata: CoinMetadata) { + ts::return_shared(metadata); + } + + /// Helper function to take clock from scenario + public fun take_clock(scenario: &mut Scenario): Clock { + clock::create_for_testing(ts::ctx(scenario)) + } + + public fun return_clock(clock: Clock) { + clock::destroy_for_testing(clock) + } + + /// Helper function to get test addresses + public fun test_addresses(): (address, address, address, address) { + (ADMIN, USER_A, USER_B, USER_C) + } +} + +#[test_only] +module ntt::test_transceiver_a { + public struct Auth has drop {} + + public fun auth(): Auth { + Auth {} + } +} + +#[test_only] +module ntt::test_transceiver_b { + public struct Auth has drop {} + + public fun auth(): Auth { + Auth {} + } +} + +#[test_only] +module ntt::test_transceiver_c { + public struct Auth has drop {} + + public fun auth(): Auth { + Auth {} + } +} diff --git a/sui/packages/ntt/tests/ntt_tests.move b/sui/packages/ntt/tests/ntt_tests.move new file mode 100644 index 000000000..d1a681bcf --- /dev/null +++ b/sui/packages/ntt/tests/ntt_tests.move @@ -0,0 +1,642 @@ +#[test_only] +module ntt::ntt_tests { + use sui::coin::Coin; + use sui::test_scenario; + use wormhole::external_address; + use ntt::ntt_scenario; + use ntt::state::{Self}; + use ntt::ntt; + use ntt::upgrades; + use ntt_common::ntt_manager_message; + use ntt_common::native_token_transfer; + use ntt::test_transceiver_a; + use ntt::test_transceiver_b; + use ntt::test_transceiver_c; + + const TEST_AMOUNT: u64 = 1000000001; // 1 token with 9 decimals and some dust + const TEST_DUST: u64 = 1; + + #[test] + fun test_basic_setup() { + let (admin, _, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(admin); + ntt_scenario::setup(&mut scenario); + test_scenario::end(scenario); + } + + #[test] + fun test_transceiver_registration() { + let (admin, _, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(admin); + ntt_scenario::setup(&mut scenario); + + // Test that transceivers were properly registered + let state = ntt_scenario::take_state(&scenario); + assert!(state::get_enabled_transceivers(&state).count_ones() == 2); + ntt_scenario::return_state(state); + + test_scenario::end(scenario); + } + + #[test] + fun test_transfer_message_creation() { + let (admin, _, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(admin); + ntt_scenario::setup(&mut scenario); + + let recipient = x"000000000000000000000000000000000000000000000000000000000000dead"; + let message = ntt_scenario::create_test_message(TEST_AMOUNT, recipient, 1); + + // Verify message contents + let (_id, _sender, transfer) = message.destruct(); + let to_chain = transfer.get_to_chain(); + assert!(to_chain == ntt_scenario::chain_id()); + + test_scenario::end(scenario); + } + + #[test] + fun test_message_attestation() { + let (admin, _, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(admin); + ntt_scenario::setup(&mut scenario); + + // Create test message + let recipient = x"000000000000000000000000000000000000000000000000000000000000dead"; + let message = ntt_scenario::create_test_message(TEST_AMOUNT, recipient, 1); + + // Get state and vote on message + let mut state = ntt_scenario::take_state(&scenario); + state::vote(&mut state, ntt_scenario::peer_chain_id(), message); + + // Verify vote was counted + let inbox_item = state::borrow_inbox_item(&state, ntt_scenario::peer_chain_id(), message); + let vote_count = inbox_item.count_enabled_votes(&state::get_enabled_transceivers(&state)); + assert!(vote_count == 1); + + ntt_scenario::return_state(state); + test_scenario::end(scenario); + } + + #[test] + fun test_message_threshold() { + let (admin, _, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(admin); + ntt_scenario::setup(&mut scenario); + + // Create test message + let recipient = x"000000000000000000000000000000000000000000000000000000000000dead"; + let message = ntt_scenario::create_test_message(TEST_AMOUNT, recipient, 1); + + // Get state and vote with both transceivers + let mut state = ntt_scenario::take_state(&scenario); + + // First vote + state::vote(&mut state, ntt_scenario::peer_chain_id(), message); + { + let inbox_item = state::borrow_inbox_item(&state, ntt_scenario::peer_chain_id(), message); + let vote_count = inbox_item.count_enabled_votes(&state::get_enabled_transceivers(&state)); + assert!(vote_count == 1); + }; + + // Second vote + state::vote(&mut state, ntt_scenario::peer_chain_id(), message); + { + let inbox_item = state::borrow_inbox_item(&state, ntt_scenario::peer_chain_id(), message); + let vote_count = inbox_item.count_enabled_votes(&state::get_enabled_transceivers(&state)); + assert!(vote_count == 2); + // Verify threshold is met + assert!(vote_count >= state.get_threshold()); + }; + + ntt_scenario::return_state(state); + test_scenario::end(scenario); + } + + #[test, expected_failure(abort_code = ::ntt::transceiver_registry::EUnregisteredTransceiver)] + fun test_unregistered_transceiver_cant_vote() { + let (admin, _, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(admin); + ntt_scenario::setup(&mut scenario); + + // Create test message + let recipient = x"000000000000000000000000000000000000000000000000000000000000dead"; + let message = ntt_scenario::create_test_message(TEST_AMOUNT, recipient, 1); + + // Get state and vote with both transceivers + let mut state = ntt_scenario::take_state(&scenario); + + state::vote(&mut state, ntt_scenario::peer_chain_id(), message); + { + let inbox_item = state::borrow_inbox_item(&state, ntt_scenario::peer_chain_id(), message); + let vote_count = inbox_item.count_enabled_votes(&state::get_enabled_transceivers(&state)); + assert!(vote_count == 1); + }; + + ntt_scenario::return_state(state); + test_scenario::end(scenario); + } + + #[test] + fun test_transfer() { + let (_, user_a, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(user_a); + ntt_scenario::setup(&mut scenario); + + scenario.next_tx(user_a); + + // Take state and clock + let mut state = ntt_scenario::take_state(&scenario); + let clock = ntt_scenario::take_clock(&mut scenario); + let coin_meta = ntt_scenario::take_coin_metadata(&scenario); + + let coins = state.mint_for_test(TEST_AMOUNT, scenario.ctx()); + + // Create transfer ticket + let recipient = x"000000000000000000000000000000000000000000000000000000000000dead"; + let (ticket, dust) = ntt::prepare_transfer( + &state, + coins, + &coin_meta, + ntt_scenario::peer_chain_id(), // recipient_chain + recipient, + option::none(), + false // should_queue + ); + + assert!(dust.value() == TEST_DUST); + + // Initial balance check + let initial_balance = if (state.borrow_mode().is_locking()) { + state.borrow_balance().value() + } else { + state.borrow_treasury_cap().total_supply() + }; + + // Execute transfer + let outbox_key = ntt::transfer_tx_sender( + &mut state, + upgrades::new_version_gated(), + ticket, + &clock, + scenario.ctx() + ); + + // Verify state after transfer + if (state.borrow_mode().is_locking()) { + // In locking mode, tokens should be in the state's balance + assert!(state.borrow_balance().value() == initial_balance + (TEST_AMOUNT - TEST_DUST)) + } else { + assert!(state.borrow_treasury_cap().total_supply() == initial_balance - (TEST_AMOUNT - TEST_DUST)) + }; + + // Verify outbox item + let message = *state.borrow_outbox().borrow(outbox_key).borrow_data(); + + // Verify message contents + let (message_id, _, transfer) = message.destruct(); + let (trimmed_amount, _, recipient_addr, to_chain, _payload) = transfer.destruct(); + assert!(trimmed_amount.untrim(ntt_scenario::decimals()) == TEST_AMOUNT - TEST_DUST); + assert!(to_chain == ntt_scenario::peer_chain_id()); + assert!(recipient_addr.to_bytes() == recipient); + + let transceiver_a_message = state.create_transceiver_message( + message_id, + &clock + ); + + let transceiver_b_message = state.create_transceiver_message( + message_id, + &clock + ); + + let (manager_message_a, source_manager_a, recipient_manager_a) = + transceiver_a_message.unwrap_outbound_message(&test_transceiver_a::auth()); + + let (manager_message_b, source_manager_b, recipient_manager_b) = + transceiver_b_message.unwrap_outbound_message(&test_transceiver_b::auth()); + + assert!(manager_message_a == manager_message_b); + assert!(source_manager_a == source_manager_b); + assert!(recipient_manager_a == recipient_manager_b); + + assert!(source_manager_a == external_address::from_address(@ntt)); + + let manager_message = ntt_manager_message::map!(manager_message_a, |x| native_token_transfer::parse(x)); + + assert!(manager_message == ntt_manager_message::new( + message_id, + external_address::from_address(user_a), + native_token_transfer::new( + ntt_common::trimmed_amount::new( + TEST_AMOUNT / 10, // token has 9 decimals + 8 + ), + external_address::from_id(object::id(&coin_meta)), + recipient_addr, + ntt_scenario::peer_chain_id(), + option::none() + ) + )); + + // Clean up + ntt_scenario::return_state(state); + ntt_scenario::return_clock(clock); + ntt_scenario::return_coin_metadata(coin_meta); + sui::test_utils::destroy(dust); + test_scenario::end(scenario); + } + + #[test, expected_failure(abort_code = ::ntt::outbox::EMessageAlreadySent)] + fun test_transfer_cant_release_twice() { + let (_, user_a, _, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(user_a); + ntt_scenario::setup(&mut scenario); + + // Take state and clock + let mut state = ntt_scenario::take_state(&scenario); + let clock = ntt_scenario::take_clock(&mut scenario); + let coin_meta = ntt_scenario::take_coin_metadata(&scenario); + + let coins = state.mint_for_test(TEST_AMOUNT, scenario.ctx()); + + // Create transfer ticket + let recipient = x"000000000000000000000000000000000000000000000000000000000000dead"; + let (ticket, dust) = ntt::prepare_transfer( + &state, + coins, + &coin_meta, + ntt_scenario::peer_chain_id(), // recipient_chain + recipient, + option::none(), + false // should_queue + ); + + // Execute transfer + let outbox_key = ntt::transfer_tx_sender( + &mut state, + upgrades::new_version_gated(), + ticket, + &clock, + scenario.ctx() + ); + + let message = *state.borrow_outbox().borrow(outbox_key).borrow_data(); + let (message_id, _, _) = message.destruct(); + + let transceiver_a_message = state.create_transceiver_message( + message_id, + &clock + ); + + sui::test_utils::destroy(transceiver_a_message); + + // this will fail, because transceiver a already released the message + let transceiver_a_message = state.create_transceiver_message( + message_id, + &clock + ); + sui::test_utils::destroy(transceiver_a_message); + + // Clean up + ntt_scenario::return_state(state); + ntt_scenario::return_clock(clock); + ntt_scenario::return_coin_metadata(coin_meta); + sui::test_utils::destroy(dust); + test_scenario::end(scenario); + } + + #[test] + fun test_redeem() { + let (_, user_a, user_b, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(user_a); + ntt_scenario::setup(&mut scenario); + + let mut state = ntt_scenario::take_state(&scenario); + let clock = ntt_scenario::take_clock(&mut scenario); + let coin_meta = ntt_scenario::take_coin_metadata(&scenario); + + let message_id = wormhole::bytes32::from_u256_be(100); + let manager_message = ntt_manager_message::new( + message_id, + external_address::from_address(user_a), + native_token_transfer::new( + ntt_common::trimmed_amount::new( + TEST_AMOUNT / 10, // token has 9 decimals + 8 + ), + external_address::from_id(object::id(&coin_meta)), + external_address::from_address(user_b), + ntt_scenario::chain_id(), // TODO: test with wrong target chain id + option::none() + ) + ); + + let manager_message_encoded = ntt_manager_message::map!(manager_message, |x| x.to_bytes()); + + let validated_transceiver_message_a = ntt_common::validated_transceiver_message::new( + &test_transceiver_a::auth(), + ntt_scenario::peer_chain_id(), + ntt_common::transceiver_message_data::new( + ntt_scenario::peer_manager_address(), + external_address::from_address(@ntt), + manager_message_encoded + ) + ); + + let validated_transceiver_message_b = ntt_common::validated_transceiver_message::new( + &test_transceiver_b::auth(), + ntt_scenario::peer_chain_id(), + ntt_common::transceiver_message_data::new( + ntt_scenario::peer_manager_address(), + external_address::from_address(@ntt), + manager_message_encoded + ) + ); + + ntt::redeem( + &mut state, + upgrades::new_version_gated(), + &coin_meta, + validated_transceiver_message_a, + &clock + ); + + ntt::redeem( + &mut state, + upgrades::new_version_gated(), + &coin_meta, + validated_transceiver_message_b, + &clock + ); + + ntt::release( + &mut state, + upgrades::new_version_gated(), + ntt_scenario::peer_chain_id(), + manager_message, + &coin_meta, + &clock, + scenario.ctx() + ); + + scenario.next_tx(user_a); + + let coins = scenario.take_from_address>(user_b); + + assert!(coins.value() == TEST_AMOUNT - TEST_DUST); + + ntt_scenario::return_state(state); + ntt_scenario::return_clock(clock); + ntt_scenario::return_coin_metadata(coin_meta); + sui::test_utils::destroy(coins); + scenario.end(); + } + + #[test, expected_failure(abort_code = ::ntt::ntt::ECantReleaseYet)] + fun test_redeem_no_threshold() { + let (_, user_a, user_b, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(user_a); + ntt_scenario::setup(&mut scenario); + + let mut state = ntt_scenario::take_state(&scenario); + let clock = ntt_scenario::take_clock(&mut scenario); + let coin_meta = ntt_scenario::take_coin_metadata(&scenario); + + let message_id = wormhole::bytes32::from_u256_be(100); + let manager_message = ntt_manager_message::new( + message_id, + external_address::from_address(user_a), + native_token_transfer::new( + ntt_common::trimmed_amount::new( + TEST_AMOUNT / 10, // token has 9 decimals + 8 + ), + external_address::from_id(object::id(&coin_meta)), + external_address::from_address(user_b), + ntt_scenario::chain_id(), + option::none() + ) + ); + + let manager_message_encoded = ntt_manager_message::map!(manager_message, |x| x.to_bytes()); + + let validated_transceiver_message_a = ntt_common::validated_transceiver_message::new( + &test_transceiver_a::auth(), + ntt_scenario::peer_chain_id(), + ntt_common::transceiver_message_data::new( + ntt_scenario::peer_manager_address(), + external_address::from_address(@ntt), + manager_message_encoded + ) + ); + + ntt::redeem( + &mut state, + upgrades::new_version_gated(), + &coin_meta, + validated_transceiver_message_a, + &clock + ); + + ntt::release( + &mut state, + upgrades::new_version_gated(), + ntt_scenario::peer_chain_id(), + manager_message, + &coin_meta, + &clock, + scenario.ctx() + ); + + ntt_scenario::return_state(state); + ntt_scenario::return_clock(clock); + ntt_scenario::return_coin_metadata(coin_meta); + scenario.end(); + } + + #[test, expected_failure(abort_code = ::ntt::ntt::ECantReleaseYet)] + fun test_redeem_no_threshold_double_vote() { + let (_, user_a, user_b, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(user_a); + ntt_scenario::setup(&mut scenario); + + let mut state = ntt_scenario::take_state(&scenario); + let clock = ntt_scenario::take_clock(&mut scenario); + let coin_meta = ntt_scenario::take_coin_metadata(&scenario); + + let message_id = wormhole::bytes32::from_u256_be(100); + let manager_message = ntt_manager_message::new( + message_id, + external_address::from_address(user_a), + native_token_transfer::new( + ntt_common::trimmed_amount::new( + TEST_AMOUNT / 10, // token has 9 decimals + 8 + ), + external_address::from_id(object::id(&coin_meta)), + external_address::from_address(user_b), + ntt_scenario::chain_id(), + option::none() + ) + ); + + let manager_message_encoded = ntt_manager_message::map!(manager_message, |x| x.to_bytes()); + + let validated_transceiver_message_a = ntt_common::validated_transceiver_message::new( + &test_transceiver_a::auth(), + ntt_scenario::peer_chain_id(), + ntt_common::transceiver_message_data::new( + ntt_scenario::peer_manager_address(), + external_address::from_address(@ntt), + manager_message_encoded + ) + ); + + ntt::redeem( + &mut state, + upgrades::new_version_gated(), + &coin_meta, + validated_transceiver_message_a, + &clock + ); + + // NOTE: transceiver A will vote again. it succeeds, but won't tick the vote count + let validated_transceiver_message_a = ntt_common::validated_transceiver_message::new( + &test_transceiver_a::auth(), + ntt_scenario::peer_chain_id(), + ntt_common::transceiver_message_data::new( + ntt_scenario::peer_manager_address(), + external_address::from_address(@ntt), + manager_message_encoded + ) + ); + + ntt::redeem( + &mut state, + upgrades::new_version_gated(), + &coin_meta, + validated_transceiver_message_a, + &clock + ); + + ntt::release( + &mut state, + upgrades::new_version_gated(), + ntt_scenario::peer_chain_id(), + manager_message, + &coin_meta, + &clock, + scenario.ctx() + ); + + ntt_scenario::return_state(state); + ntt_scenario::return_clock(clock); + ntt_scenario::return_coin_metadata(coin_meta); + scenario.end(); + } + + #[test, expected_failure(abort_code = ::ntt::ntt::EWrongDestinationChain)] + fun test_redeem_wrong_dest_chain() { + let (_, user_a, user_b, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(user_a); + ntt_scenario::setup(&mut scenario); + + let mut state = ntt_scenario::take_state(&scenario); + let clock = ntt_scenario::take_clock(&mut scenario); + let coin_meta = ntt_scenario::take_coin_metadata(&scenario); + + let message_id = wormhole::bytes32::from_u256_be(100); + let manager_message = ntt_manager_message::new( + message_id, + external_address::from_address(user_a), + native_token_transfer::new( + ntt_common::trimmed_amount::new( + TEST_AMOUNT / 10, // token has 9 decimals + 8 + ), + external_address::from_id(object::id(&coin_meta)), + external_address::from_address(user_b), + ntt_scenario::chain_id() + 1, // NOTE: wrong destination chain + option::none() + ) + ); + + let manager_message_encoded = ntt_manager_message::map!(manager_message, |x| x.to_bytes()); + + let validated_transceiver_message_a = ntt_common::validated_transceiver_message::new( + &test_transceiver_a::auth(), + ntt_scenario::peer_chain_id(), + ntt_common::transceiver_message_data::new( + ntt_scenario::peer_manager_address(), + external_address::from_address(@ntt), + manager_message_encoded + ) + ); + + ntt::redeem( + &mut state, + upgrades::new_version_gated(), + &coin_meta, + validated_transceiver_message_a, + &clock + ); + + ntt_scenario::return_state(state); + ntt_scenario::return_clock(clock); + ntt_scenario::return_coin_metadata(coin_meta); + scenario.end(); + } + + #[test, expected_failure(abort_code = ::ntt_common::validated_transceiver_message::EInvalidRecipientManager)] + fun test_redeem_wrong_recipient_manager() { + let (_, user_a, user_b, _) = ntt_scenario::test_addresses(); + let mut scenario = test_scenario::begin(user_a); + ntt_scenario::setup(&mut scenario); + + let mut state = ntt_scenario::take_state(&scenario); + let clock = ntt_scenario::take_clock(&mut scenario); + let coin_meta = ntt_scenario::take_coin_metadata(&scenario); + + let message_id = wormhole::bytes32::from_u256_be(100); + let manager_message = ntt_manager_message::new( + message_id, + external_address::from_address(user_a), + native_token_transfer::new( + ntt_common::trimmed_amount::new( + TEST_AMOUNT / 10, // token has 9 decimals + 8 + ), + external_address::from_id(object::id(&coin_meta)), + external_address::from_address(user_b), + ntt_scenario::chain_id(), + option::none() + ) + ); + + let manager_message_encoded = ntt_manager_message::map!(manager_message, |x| x.to_bytes()); + + let validated_transceiver_message_a = ntt_common::validated_transceiver_message::new( + &test_transceiver_a::auth(), + ntt_scenario::peer_chain_id(), + ntt_common::transceiver_message_data::new( + ntt_scenario::peer_manager_address(), + external_address::from_address(@wormhole), // NOTE: wrong recipient manager + manager_message_encoded + ) + ); + + ntt::redeem( + &mut state, + upgrades::new_version_gated(), + &coin_meta, + validated_transceiver_message_a, + &clock + ); + + ntt_scenario::return_state(state); + ntt_scenario::return_clock(clock); + ntt_scenario::return_coin_metadata(coin_meta); + scenario.end(); + } +} diff --git a/sui/packages/ntt_common/Move.toml b/sui/packages/ntt_common/Move.toml new file mode 100644 index 000000000..0e1487043 --- /dev/null +++ b/sui/packages/ntt_common/Move.toml @@ -0,0 +1,26 @@ +[package] +name = "NttCommon" +edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move +license = "Apache 2.0" + +[dependencies.Sui] +git = "https://github.com/MystenLabs/sui.git" +subdir = "crates/sui-framework/packages/sui-framework" +rev = "framework/testnet" +override = true + +[dependencies.Wormhole] +# git = "https://github.com/wormhole-foundation/wormhole.git" +# rev = "sui/mainnet" +# TODO: we're using this fork temporarily which allows us to create VAAs for testing +git = "https://github.com/wormholelabs-xyz/wormhole.git" +rev = "sui/vaa-new-test-only" +subdir = "sui/wormhole" + +[addresses] +ntt_common = "0x11" + +[dev-dependencies] + +[dev-addresses] +wormhole = "0x10" diff --git a/sui/packages/ntt_common/sources/contract_auth.move b/sui/packages/ntt_common/sources/contract_auth.move new file mode 100644 index 000000000..f0adce7a3 --- /dev/null +++ b/sui/packages/ntt_common/sources/contract_auth.move @@ -0,0 +1,96 @@ +/// The contract authentication system. +/// +/// Since Move doesn't have an analogous mechanism to EVM's `msg.sender`, we +/// need a way for contracts to be able to identify themselves for permissioned operations. +/// +/// We implement the following scheme: +/// +/// If the contract has an `Auth` struct in any of its modules, then we assume +/// only that contract can create a value of that type. If we receive a value of that type, +/// we then assume the contract wanted to call us. +/// +/// The fully qualified type identifer is going to be
::::Auth. +/// Then we take the identity of the contract to be
. +/// The Sui runtime resolves fully qualified type identifiers with the address +/// that originally defined the type, meaning it will remain constant between +/// contract upgrades. +/// +/// It's that
that for example the ntt manager registers when it sets +/// up a new transceiver. +/// +/// The `assert_auth_type` function checks that a reference to such a value is +/// indeed an "auth type", and returns the
component if it is. +/// +/// TODO: are we being too lax about allowing any module name? Maybe we should +/// allow the `assert_auth_type` caller to speficy the name of the auth type it +/// expects. For example, when expecting a transceiver, it might require the +/// auth type to be called `TransceiverAuth`. We could also restrict the module name. +/// The issue with this current approach is that it doesn't allow for +/// fine-grained access control, where a contract that wants to authenticate +/// itself for multiple different operations, it cannot separate those auth +/// types, as they are interchangeable under the current implementation of `assert_auth_type`. +module ntt_common::contract_auth { + use std::type_name; + use sui::address; + use sui::hex; + + #[error] + const EInvalidAuthType: vector = + b"Invalid auth type"; + + public fun get_auth_address(): Option
{ + let fqt = type_name::get(); + + let address_hex = fqt.get_address().into_bytes(); + let addy = address::from_bytes(hex::decode(address_hex)); + + let mod = fqt.get_module().into_bytes(); + + let mut expected = address_hex; + expected.append(b"::"); + expected.append(mod); + expected.append(b"::Auth"); + + if (fqt.into_string().into_bytes() == expected) { + option::some(addy) + } else { + option::none() + } + } + + public fun is_auth_type(): bool { + get_auth_address().is_some() + } + + public fun assert_auth_type(auth: &Auth): address { + let maybe_addy = get_auth_address(); + if (maybe_addy.is_none()) { + abort EInvalidAuthType + }; + *maybe_addy.borrow() + } +} + +#[test_only] +module ntt_common::auth { + public struct Auth {} +} + +#[test_only] +module ntt_common::other_auth { + public struct Auth {} +} + +#[test_only] +module ntt_common::contract_auth_test { + use ntt_common::contract_auth::is_auth_type; + + public struct NotAuth {} + + #[test] + public fun test_is_auth_type() { + assert!(is_auth_type()); + assert!(is_auth_type()); + assert!(!is_auth_type()); + } +} diff --git a/sui/packages/ntt_common/sources/datatypes/bitmap.move b/sui/packages/ntt_common/sources/datatypes/bitmap.move new file mode 100644 index 000000000..29206c589 --- /dev/null +++ b/sui/packages/ntt_common/sources/datatypes/bitmap.move @@ -0,0 +1,56 @@ +module ntt_common::bitmap { + const MAX_U128: u128 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; + + public struct Bitmap has store, drop, copy { + bitmap: u128, + } + + public fun empty(): Bitmap { + Bitmap { bitmap: 0 } + } + + public fun and(a: &Bitmap, b: &Bitmap): Bitmap { + Bitmap { bitmap: a.bitmap & b.bitmap } + } + + public fun enable(bitmap: &mut Bitmap, index: u8) { + let bitmask = 1 << index; + bitmap.bitmap = bitmap.bitmap | bitmask + } + + public fun disable(bitmap: &mut Bitmap, index: u8) { + let bitmask = 1 << index; + bitmap.bitmap = bitmap.bitmap & (bitmask ^ MAX_U128) + } + + public fun get(bitmap: &Bitmap, index: u8): bool { + let bitmask = 1 << index; + bitmap.bitmap & bitmask > 0 + } + + public fun count_ones(bitmap: &Bitmap): u8 { + let mut count = 0; + let mut mask = 1; + let mut i = 0; + while (i < 128) { + if (bitmap.bitmap & mask > 0) { + count = count + 1; + }; + mask = mask << 1; + i = i + 1; + }; + count + } + + #[test] + public fun test_count_ones() { + let all = Bitmap { bitmap: MAX_U128 }; + assert!(count_ones(&all) == 128); + + let none = Bitmap { bitmap: 0 }; + assert!(count_ones(&none) == 0); + + let seven = Bitmap { bitmap: 2u128.pow(7) - 1 }; + assert!(count_ones(&seven) == 7); + } +} diff --git a/sui/packages/ntt_common/sources/datatypes/bytes4.move b/sui/packages/ntt_common/sources/datatypes/bytes4.move new file mode 100644 index 000000000..20dcf5442 --- /dev/null +++ b/sui/packages/ntt_common/sources/datatypes/bytes4.move @@ -0,0 +1,123 @@ +// TODO: review this is correct (should be same as bytes32) +module ntt_common::bytes4 { + use wormhole::bytes::{Self}; + use wormhole::cursor::{Cursor}; + + // Invalid vector length to create `Bytes4`. + const E_INVALID_BYTES4: u64 = 0; + // Found non-zero bytes when attempting to trim `vector`. + const E_CANNOT_TRIM_NONZERO: u64 = 1; + + const LEN: u64 = 4; + + /// Container for `vector`, which has length == 4. + public struct Bytes4 has copy, drop, store { + data: vector + } + + public fun length(): u64 { + LEN + } + + /// Create new `Bytes4`, which checks the length of input `data`. + public fun new(data: vector): Bytes4 { + assert!(is_valid(&data), E_INVALID_BYTES4); + Bytes4 { data } + } + + /// Create new `Bytes4` of all zeros. + public fun default(): Bytes4 { + let mut data = vector::empty(); + let mut i = 0; + while (i < LEN) { + vector::push_back(&mut data, 0); + i = i + 1; + }; + new(data) + } + + /// Retrieve underlying `data`. + public fun data(self: &Bytes4): vector { + self.data + } + + /// Either trim or pad (depending on length of the input `vector`) to 4 + /// bytes. + public fun from_bytes(mut buf: vector): Bytes4 { + let len = vector::length(&buf); + if (len > LEN) { + trim_nonzero_left(&mut buf); + new(buf) + } else { + new(pad_right(&buf, false)) + } + } + + /// Destroy `Bytes4` for its underlying data. + public fun to_bytes(value: Bytes4): vector { + let Bytes4 { data } = value; + data + } + + /// Drain 4 elements of `Cursor` to create `Bytes20`. + public fun take(cur: &mut Cursor): Bytes4 { + new(bytes::take_bytes(cur, LEN)) + } + + /// Validate that any of the bytes in underlying data is non-zero. + public fun is_nonzero(self: &Bytes4): bool { + let mut i = 0; + while (i < LEN) { + if (*vector::borrow(&self.data, i) > 0) { + return true + }; + i = i + 1; + }; + + false + } + + /// Check that the input data is correct length. + fun is_valid(data: &vector): bool { + vector::length(data) == LEN + } + + /// For vector size less than 4, add zeros to the right. + fun pad_right(data: &vector, data_reversed: bool): vector { + let mut out = vector::empty(); + let len = vector::length(data); + + if (data_reversed) { + let mut i = 0; + while (i < len) { + vector::push_back( + &mut out, + *vector::borrow(data, len - i - 1) + ); + i = i + 1; + }; + } else { + vector::append(&mut out, *data); + }; + + let mut i = len; + while (i < LEN) { + vector::push_back(&mut out, 0); + i = i + 1; + }; + + out + } + + /// Trim bytes from the left if they are zero. If any of these bytes + /// are non-zero, abort. + fun trim_nonzero_left(data: &mut vector) { + vector::reverse(data); + let (mut i, n) = (0, vector::length(data) - LEN); + while (i < n) { + assert!(vector::pop_back(data) == 0, E_CANNOT_TRIM_NONZERO); + i = i + 1; + }; + vector::reverse(data); + } +} diff --git a/sui/packages/ntt_common/sources/datatypes/trimmed_amount.move b/sui/packages/ntt_common/sources/datatypes/trimmed_amount.move new file mode 100644 index 000000000..46b6acf97 --- /dev/null +++ b/sui/packages/ntt_common/sources/datatypes/trimmed_amount.move @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache 2 + +/// Amounts represented in transfers are capped at 8 decimals. This +/// means that any amount that's given as having more decimals is truncated to 8 +/// decimals. On the way out, these amount have to be scaled back to the +/// original decimal amount. This module defines [`TrimmedAmount`], which +/// represents amounts that have been capped at 8 decimals. +/// +/// The functions [`trim`] and [`untrim`] take care of convertion to/from +/// this type given the original amount's decimals. +module ntt_common::trimmed_amount { + use sui::coin::Coin; + use sui::balance::{Self, Balance}; + use wormhole::bytes; + use wormhole::cursor::{Self, Cursor}; + + /// Maximum number of decimals supported in trimmed amounts + const TRIMMED_DECIMALS: u8 = 8; + + /// Error when exponent calculation overflows + const E_OVERFLOW_EXPONENT: u64 = 0; + /// Error when scaling amount overflows + const E_OVERFLOW_SCALED_AMOUNT: u64 = 1; + + const U64_MAX: u64 = 18446744073709551615; + + /// Container holding a trimmed amount and its decimal precision + public struct TrimmedAmount has store, copy, drop { + amount: u64, + decimals: u8 + } + + /// Create new TrimmedAmount with given amount and decimals + public fun new(amount: u64, decimals: u8): TrimmedAmount { + TrimmedAmount { + amount, + decimals + } + } + + /// Scale amount between different decimal precisions + public fun scale(amount: u64, from_decimals: u8, to_decimals: u8): u64 { + if (from_decimals == to_decimals) { + return amount + }; + + if (from_decimals > to_decimals) { + let power = from_decimals - to_decimals; + assert!(power <= 18, E_OVERFLOW_EXPONENT); + let scaling_factor = 10u64.pow(power); + amount / scaling_factor + } else { + let power = to_decimals - from_decimals; + assert!(power <= 18, E_OVERFLOW_EXPONENT); + let scaling_factor = 10u64.pow(power); + assert!(amount <= (U64_MAX / scaling_factor), E_OVERFLOW_SCALED_AMOUNT); + amount * scaling_factor + } + } + + /// Trim amount to specified decimal precision, capped at TRIMMED_DECIMALS + public fun trim(amount: u64, from_decimals: u8, to_decimals: u8): TrimmedAmount { + let to_decimals = min(TRIMMED_DECIMALS, min(from_decimals, to_decimals)); + let amount = scale(amount, from_decimals, to_decimals); + new(amount, to_decimals) + } + + /// Scale amount back to original decimal precision + public fun untrim(self: &TrimmedAmount, to_decimals: u8): u64 { + scale(self.amount, self.decimals, to_decimals) + } + + /// Remove dust from amount by trimming and scaling back. Returns both the + /// trimmed amount and modifies the original amount in place. + public fun remove_dust( + coin: &mut Coin, + from_decimals: u8, + to_decimals: u8 + ): (TrimmedAmount, Balance) { + let amount = coin.value(); + let trimmed = trim(amount, from_decimals, to_decimals); + let without_dust = untrim(&trimmed, from_decimals); + (trimmed, coin.balance_mut().split(amount - without_dust)) + } + + public fun amount(self: &TrimmedAmount): u64 { + self.amount + } + + public fun decimals(self: &TrimmedAmount): u8 { + self.decimals + } + + public fun take_bytes(cur: &mut Cursor): TrimmedAmount { + let decimals = cursor::poke(cur); + let amount = bytes::take_u64_be(cur); + new(amount, decimals) + } + + public fun to_bytes(self: &TrimmedAmount): vector { + let mut result = vector::empty(); + bytes::push_u8(&mut result, self.decimals); + bytes::push_u64_be(&mut result, self.amount); + result + } + + fun min(a: u8, b: u8): u8 { + if (a < b) { a } else { b } + } +} + +#[test_only] +module ntt_common::trimmed_amount_tests { + use ntt_common::trimmed_amount; + + #[test] + fun test_trim_and_untrim() { + let amount = 100555555555555555; + let trimmed = trimmed_amount::trim(amount, 18, 9); + let untrimmed = trimmed_amount::untrim(&trimmed, 18); + assert!(untrimmed == 100555550000000000, 0); + + let amount = 100000000000000000; + let trimmed = trimmed_amount::trim(amount, 7, 11); + assert!(trimmed_amount::amount(&trimmed) == amount, 0); + + let trimmed = trimmed_amount::trim(158434, 6, 3); + assert!(trimmed_amount::amount(&trimmed) == 158, 0); + assert!(trimmed_amount::decimals(&trimmed) == 3, 0); + + let small_amount = trimmed_amount::new(1, 6); + let scaled = trimmed_amount::untrim(&small_amount, 13); + assert!(scaled == 10000000, 0); + } + + #[test] + #[expected_failure(abort_code = ntt_common::trimmed_amount::E_OVERFLOW_EXPONENT)] + fun test_scale_overflow_exponent() { + trimmed_amount::scale(100, 0, 255); + } + + #[test] + #[expected_failure(abort_code = ntt_common::trimmed_amount::E_OVERFLOW_SCALED_AMOUNT)] + fun test_scale_overflow_amount() { + trimmed_amount::scale(18446744073709551615, 10, 11); + } +} diff --git a/sui/packages/ntt_common/sources/messages/native_token_transfer.move b/sui/packages/ntt_common/sources/messages/native_token_transfer.move new file mode 100644 index 000000000..841f11117 --- /dev/null +++ b/sui/packages/ntt_common/sources/messages/native_token_transfer.move @@ -0,0 +1,128 @@ +module ntt_common::native_token_transfer { + use wormhole::bytes; + use wormhole::cursor::Cursor; + use ntt_common::bytes4::{Self}; + use ntt_common::trimmed_amount::{Self, TrimmedAmount}; + use wormhole::external_address::{Self, ExternalAddress}; + + /// Prefix for all NativeTokenTransfer payloads + /// This is 0x99'N''T''T' + const NTT_PREFIX: vector = x"994E5454"; + + #[error] + const EIncorrectPrefix: vector + = b"incorrect prefix"; + + public struct NativeTokenTransfer has copy, store, drop { + amount: TrimmedAmount, + source_token: ExternalAddress, + to: ExternalAddress, + to_chain: u16, + payload: Option>, + } + + public fun new( + amount: TrimmedAmount, + source_token: ExternalAddress, + to: ExternalAddress, + to_chain: u16, + payload: Option>, + ): NativeTokenTransfer { + NativeTokenTransfer { + amount, + source_token, + to, + to_chain, + payload + } + } + + public fun get_to_chain( + message: &NativeTokenTransfer + ): u16 { + message.to_chain + } + + public fun borrow_payload( + message: &NativeTokenTransfer + ): &Option> { + &message.payload + } + + public fun destruct( + message: NativeTokenTransfer + ): ( + TrimmedAmount, + ExternalAddress, + ExternalAddress, + u16, + Option> + ) { + let NativeTokenTransfer { + amount, + source_token, + to, + to_chain, + payload + } = message; + (amount, source_token, to, to_chain, payload) + } + + public fun to_bytes( + message: NativeTokenTransfer + ): vector { + let NativeTokenTransfer { + amount, + source_token, + to, + to_chain, + payload + } = message; + + let mut buf = vector::empty(); + + buf.append(NTT_PREFIX); + buf.append(amount.to_bytes()); + buf.append(source_token.to_bytes()); + buf.append(to.to_bytes()); + bytes::push_u16_be(&mut buf, to_chain); + if (payload.is_some()) { + let payload = payload.destroy_some(); + bytes::push_u16_be(&mut buf, payload.length() as u16); + buf.append(payload); + }; + + buf + } + + public fun take_bytes(cur: &mut Cursor): NativeTokenTransfer { + let ntt_prefix = bytes4::take(cur); + assert!(ntt_prefix.to_bytes() == NTT_PREFIX, EIncorrectPrefix); + let decimals = bytes::take_u8(cur); + let amount = bytes::take_u64_be(cur); + let amount = trimmed_amount::new(amount, decimals); + let source_token = external_address::take_bytes(cur); + let to = external_address::take_bytes(cur); + let to_chain = bytes::take_u16_be(cur); + + let payload = if (!cur.is_empty()) { + let len = bytes::take_u16_be(cur); + let payload = bytes::take_bytes(cur, len as u64); + option::some(payload) + } else { + option::none() + }; + + NativeTokenTransfer { + amount, + source_token, + to, + to_chain, + payload + } + } + + public fun parse(buf: vector): NativeTokenTransfer { + ntt_common::parse::parse!(buf, |x| take_bytes(x)) + } +} diff --git a/sui/packages/ntt_common/sources/messages/ntt_manager_message.move b/sui/packages/ntt_common/sources/messages/ntt_manager_message.move new file mode 100644 index 000000000..2af001688 --- /dev/null +++ b/sui/packages/ntt_common/sources/messages/ntt_manager_message.move @@ -0,0 +1,100 @@ +module ntt_common::ntt_manager_message { + use wormhole::bytes::{Self}; + use wormhole::cursor::{Self}; + use wormhole::bytes32::{Self, Bytes32}; + use wormhole::external_address::{Self, ExternalAddress}; + + public struct NttManagerMessage has store, copy, drop { + // unique message identifier + id: Bytes32, + // original message sender address. + sender: ExternalAddress, + // thing inside + payload: A, + } + + const E_PAYLOAD_TOO_LONG: u64 = 0; + + public fun new( + id: Bytes32, + sender: ExternalAddress, + payload: A + ): NttManagerMessage { + NttManagerMessage { + id, + sender, + payload + } + } + + public fun get_id( + message: &NttManagerMessage + ): Bytes32 { + message.id + } + + public fun borrow_payload( + message: &NttManagerMessage + ): &A { + &message.payload + } + + public fun destruct( + message: NttManagerMessage + ):( + Bytes32, + ExternalAddress, + A + ) { + let NttManagerMessage { + id, + sender, + payload + } = message; + (id, sender, payload) + } + + public fun to_bytes( + message: NttManagerMessage> + ): vector { + let NttManagerMessage {id, sender, payload} = message; + assert!(vector::length(&payload) < (((1<<16)-1) as u64), E_PAYLOAD_TOO_LONG); + let payload_length = (vector::length(&payload) as u16); + + let mut buf: vector = vector::empty(); + + vector::append(&mut buf, id.to_bytes()); + vector::append(&mut buf, sender.to_bytes()); + bytes::push_u16_be(&mut buf, payload_length); + vector::append(&mut buf, payload); + + buf + } + + public fun take_bytes(cur: &mut cursor::Cursor): NttManagerMessage> { + let id = bytes32::take_bytes(cur); + let sender = external_address::take_bytes(cur); + let payload_length = bytes::take_u16_be(cur); + let payload = bytes::take_bytes(cur, (payload_length as u64)); + + NttManagerMessage { + id, + sender, + payload + } + } + + public macro fun map<$A, $B>( + $message: NttManagerMessage<$A>, + $f: |$A| -> $B + ): NttManagerMessage<$B> { + let (id, sender, payload) = destruct($message); + new(id, sender, $f(payload)) + } + + public fun parse( + buf: vector + ): NttManagerMessage> { + ntt_common::parse::parse!(buf, |x| take_bytes(x)) + } +} diff --git a/sui/packages/ntt_common/sources/messages/transceiver_message.move b/sui/packages/ntt_common/sources/messages/transceiver_message.move new file mode 100644 index 000000000..5c7b00ec8 --- /dev/null +++ b/sui/packages/ntt_common/sources/messages/transceiver_message.move @@ -0,0 +1,269 @@ +module ntt_common::transceiver_message_data { + use wormhole::bytes; + use wormhole::cursor::Cursor; + use wormhole::external_address::{Self, ExternalAddress}; + use ntt_common::ntt_manager_message::{Self, NttManagerMessage}; + + #[error] + const EIncorrectPayloadLength: vector + = b"incorrect payload length"; + + public struct TransceiverMessageData has drop, copy { + source_ntt_manager_address: ExternalAddress, + recipient_ntt_manager_address: ExternalAddress, + ntt_manager_payload: NttManagerMessage, + } + + public fun new( + source_ntt_manager_address: ExternalAddress, + recipient_ntt_manager_address: ExternalAddress, + ntt_manager_payload: NttManagerMessage + ): TransceiverMessageData { + TransceiverMessageData { + source_ntt_manager_address, + recipient_ntt_manager_address, + ntt_manager_payload + } + } + + public fun destruct( + message_data: TransceiverMessageData + ): ( + ExternalAddress, + ExternalAddress, + NttManagerMessage + ) { + let TransceiverMessageData { + source_ntt_manager_address, + recipient_ntt_manager_address, + ntt_manager_payload + } = message_data; + + (source_ntt_manager_address, recipient_ntt_manager_address, ntt_manager_payload) + } + + public macro fun map<$A, $B>( + $message_data: TransceiverMessageData<$A>, + $f: |$A| -> $B + ): TransceiverMessageData<$B> { + let ( + source_ntt_manager_address, + recipient_ntt_manager_address, + ntt_manager_payload + ) = destruct($message_data); + new( + source_ntt_manager_address, + recipient_ntt_manager_address, + ntt_manager_message::map!(ntt_manager_payload, $f) + ) + } + + public fun to_bytes( + message_data: TransceiverMessageData> + ): vector { + let TransceiverMessageData { + source_ntt_manager_address, + recipient_ntt_manager_address, + ntt_manager_payload + } = message_data; + + let mut buf: vector = vector::empty(); + + buf.append(source_ntt_manager_address.to_bytes()); + buf.append(recipient_ntt_manager_address.to_bytes()); + bytes::push_u16_be(&mut buf, ntt_manager_payload.to_bytes().length() as u16); + buf.append(ntt_manager_payload.to_bytes()); + + buf + } + + public fun take_bytes(cur: &mut Cursor): TransceiverMessageData> { + let source_ntt_manager_address = external_address::take_bytes(cur); + let recipient_ntt_manager_address = external_address::take_bytes(cur); + let ntt_manager_payload_len = bytes::take_u16_be(cur); + let remaining = cur.data().length(); + let ntt_manager_payload = ntt_manager_message::take_bytes(cur); + let bytes_read = remaining - cur.data().length(); + assert!(bytes_read as u16 == ntt_manager_payload_len, EIncorrectPayloadLength); + + TransceiverMessageData { + source_ntt_manager_address, + recipient_ntt_manager_address, + ntt_manager_payload + } + } + + public fun parse(data: vector): TransceiverMessageData> { + ntt_common::parse::parse!(data, |x| take_bytes(x)) + } +} + +module ntt_common::transceiver_message { + use wormhole::bytes::{Self}; + use wormhole::cursor::Cursor; + use ntt_common::bytes4::{Self, Bytes4}; + use ntt_common::transceiver_message_data::{Self, TransceiverMessageData}; + + #[error] + const EIncorrectPrefix: vector + = b"incorrect prefix"; + + public struct PrefixOf has drop, copy { + prefix: Bytes4 + } + + public fun prefix(_: &E, prefix: Bytes4): PrefixOf { + PrefixOf { prefix } + } + + public struct TransceiverMessage has drop, copy { + message_data: TransceiverMessageData, + transceiver_payload: vector + } + + public fun new( + message_data: TransceiverMessageData, + transceiver_payload: vector + ): TransceiverMessage { + TransceiverMessage { + message_data, + transceiver_payload + } + } + + public macro fun map<$T, $A, $B>( + $message_data: TransceiverMessage<$T, $A>, + $f: |$A| -> $B + ): TransceiverMessage<$T, $B> { + let ( + message_data, + transceiver_payload + ) = destruct($message_data); + new( + transceiver_message_data::map!(message_data, $f), + transceiver_payload + ) + } + + public fun destruct( + message: TransceiverMessage + ): ( + TransceiverMessageData, + vector + ) { + let TransceiverMessage { + message_data, + transceiver_payload + } = message; + + (message_data, transceiver_payload) + } + + public fun to_bytes( + message: TransceiverMessage>, + prefix: PrefixOf, + ): vector { + let TransceiverMessage { + message_data, + transceiver_payload + } = message; + + let mut buf: vector = vector::empty(); + + buf.append(prefix.prefix.to_bytes()); + buf.append(message_data.to_bytes()); + bytes::push_u16_be(&mut buf, transceiver_payload.length() as u16); + buf.append(transceiver_payload); + + buf + } + + public fun take_bytes( + prefix: PrefixOf, + cur: &mut Cursor + ): TransceiverMessage> { + let prefix_bytes = bytes4::take(cur); + assert!(prefix_bytes == prefix.prefix, EIncorrectPrefix); + let message_data = transceiver_message_data::take_bytes(cur); + let transceiver_payload_len = bytes::take_u16_be(cur); + let transceiver_payload = bytes::take_bytes(cur, transceiver_payload_len as u64); + + TransceiverMessage { + message_data, + transceiver_payload + } + } + + public fun parse(prefix: PrefixOf, data: vector): TransceiverMessage> { + ntt_common::parse::parse!(data, |x| take_bytes(prefix, x)) + } +} + +#[test_only] +module ntt_common::transceiver_message_tests { + use wormhole::bytes32; + use wormhole::external_address; + use ntt_common::transceiver_message; + use ntt_common::transceiver_message_data; + use ntt_common::ntt_manager_message; + use ntt_common::native_token_transfer::{Self, NativeTokenTransfer}; + use ntt_common::bytes4; + use ntt_common::trimmed_amount; + + public struct WhTransceiver has drop {} + + #[test] + public fun test_deserialize_transceiver_message() { + let data = x"9945ff10042942fafabe0000000000000000000000000000000000000000000000000000042942fababe00000000000000000000000000000000000000000000000000000091128434bafe23430000000000000000000000000000000000ce00aa00000000004667921341234300000000000000000000000000000000000000000000000000004f994e545407000000000012d687beefface00000000000000000000000000000000000000000000000000000000feebcafe0000000000000000000000000000000000000000000000000000000000110000"; + + let wh_prefix = transceiver_message::prefix(&WhTransceiver{}, bytes4::from_bytes(x"9945FF10")); + + let message = ntt_common::parse::parse!(data, |x| transceiver_message::take_bytes(wh_prefix, x)); + let message = ntt_common::transceiver_message::map!(message, |x| native_token_transfer::parse(x)); + + let expected = transceiver_message::new( + transceiver_message_data::new( + external_address::new(bytes32::from_bytes(vector[ + 0x04u8, 0x29, 0x42, 0xFA, 0xFA, 0xBE, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ])), + external_address::new(bytes32::from_bytes(vector[ + 0x04, 0x29, 0x42, 0xFA, 0xBA, 0xBE, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ])), + ntt_manager_message::new( + bytes32::from_bytes(vector[ + 0x12, 0x84, 0x34, 0xBA, 0xFE, 0x23, 0x43, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0xCE, 0, 0xAA, 0, 0, 0, 0, 0, + ]), + external_address::new(bytes32::from_bytes(vector[ + 0x46, 0x67, 0x92, 0x13, 0x41, 0x23, 0x43, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ])), + native_token_transfer::new( + trimmed_amount::new( + 1234567, + 7 + ), + external_address::new(bytes32::from_bytes(vector[ + 0xBE, 0xEF, 0xFA, 0xCE, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ])), + external_address::new(bytes32::from_bytes(vector[ + 0xFE, 0xEB, 0xCA, 0xFE, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ])), + 17, + option::none() + ) + ) + ), + b"" + ); + + assert!(message == expected); + + // roundtrip + assert!(transceiver_message::map!(expected, |x| native_token_transfer::to_bytes(x)).to_bytes(wh_prefix) == data); + } +} diff --git a/sui/packages/ntt_common/sources/outbound_message.move b/sui/packages/ntt_common/sources/outbound_message.move new file mode 100644 index 000000000..59a88378e --- /dev/null +++ b/sui/packages/ntt_common/sources/outbound_message.move @@ -0,0 +1,53 @@ +/// Outbound communication between managers and transceivers. +/// +/// The manager issues values of type `OutboundMessage` +/// when it wants a particular transceiver (in this case the one that defines +/// the type `SomeTransceiverAuth`) to send a message. +/// +/// The transceiver can unwrap the message using `unwrap_outbound_message`, and +/// indeed it has to, because `OutboundMessage`s cannot be dropped otherwise. +/// The manager implements replay protection, so it will only issue the message once. +/// (TODO: should we relax this? no harm in sending the same message multiple +/// times, the receiving side implements replay protection anyway) +/// Thus, it's crucial that the intended transceiver consumes the message. +/// +/// Additionally, the `source_ntt_manager` field encodes the address of the +/// manager that sent the message, which is verified by checking the +/// `ManagerAuth` type in `new`. This way, the transceiver doesn't have to trust +/// the particular manager implementation to ensure it's not lying about its own +/// identity. +/// This is not super relevant in the current setup where transceivers are +/// deployed alongside the managers, but it is an important design decision for +/// the future, if we want to share a single transceiver between multiple managers. +module ntt_common::outbound_message { + use wormhole::external_address::{Self, ExternalAddress}; + use ntt_common::contract_auth; + use ntt_common::ntt_manager_message::NttManagerMessage; + + /// Wraps a message to be sent by `Transceiver`. + /// Only the relevant transceiver can unwrap the message via `unwrap_outbound_message`. + public struct OutboundMessage { + message: NttManagerMessage>, + source_ntt_manager: ExternalAddress, + recipient_ntt_manager: ExternalAddress, + } + + public fun new( + auth: &ManagerAuth, + message: NttManagerMessage>, + recipient_ntt_manager: ExternalAddress, + ): OutboundMessage { + let manager_address = contract_auth::assert_auth_type(auth); + let source_ntt_manager = external_address::from_address(manager_address); + OutboundMessage { message, source_ntt_manager, recipient_ntt_manager } + } + + public fun unwrap_outbound_message( + message: OutboundMessage, + auth: &TransceiverAuth, + ): (NttManagerMessage>, ExternalAddress, ExternalAddress) { + contract_auth::assert_auth_type(auth); + let OutboundMessage { message, source_ntt_manager, recipient_ntt_manager } = message; + (message, source_ntt_manager, recipient_ntt_manager) + } +} diff --git a/sui/packages/ntt_common/sources/utils/parse.move b/sui/packages/ntt_common/sources/utils/parse.move new file mode 100644 index 000000000..98a607dcf --- /dev/null +++ b/sui/packages/ntt_common/sources/utils/parse.move @@ -0,0 +1,10 @@ +module ntt_common::parse { + use wormhole::cursor; + + public macro fun parse<$T>($buf: vector, $parser: |&mut cursor::Cursor| -> $T): $T { + let mut cur = cursor::new($buf); + let result = $parser(&mut cur); + cursor::destroy_empty(cur); + result + } +} diff --git a/sui/packages/ntt_common/sources/validated_transceiver_message.move b/sui/packages/ntt_common/sources/validated_transceiver_message.move new file mode 100644 index 000000000..3d046aae4 --- /dev/null +++ b/sui/packages/ntt_common/sources/validated_transceiver_message.move @@ -0,0 +1,57 @@ +/// Inbound communication between transceivers and managers. +/// +/// The transceiver validates the message, and constructs +/// a `ValidatedTransceiverMessage` value. +/// +/// The `new` function requires authenticates the transceiver, and stores its +/// identity in a phantom type parameter. +/// Thus, when the manager receives a value of type +/// `ValidatedTransceiverMessage`, it knows it was +/// created by the transceiver that defines the `SomeTransceiverAuth` type. +/// +/// The manager will then consume this message using the `destruct_recipient_only`. +/// As the function's name suggests, only the intended manager can consume the message. +/// This guarantees that once the transceiver has validated the message, it will be seen by +/// the appropriate manager. +/// This is not a strict security requirement, as long as the transceiver +/// doesn't implement its internal replay protection, because then no denial of +/// service is possible by a malicious client "hijacking" a validation. +/// Nevertheless, we restrict the consumption in this way to provide a static +/// guarantee that the message will be sent to the right place. +module ntt_common::validated_transceiver_message { + use wormhole::external_address::{Self, ExternalAddress}; + use ntt_common::transceiver_message_data::TransceiverMessageData; + use ntt_common::ntt_manager_message::NttManagerMessage; + + #[error] + const EInvalidRecipientManager: vector = + b"Invalid recipient manager."; + + public struct ValidatedTransceiverMessage { + from_chain: u16, + message: TransceiverMessageData + } + + public fun new( + auth: &TransceiverAuth, // only the transceiver can create it + from_chain: u16, + message: TransceiverMessageData + ): ValidatedTransceiverMessage { + ntt_common::contract_auth::assert_auth_type(auth); + ValidatedTransceiverMessage { + from_chain, + message + } + } + + public fun destruct_recipient_only( + message: ValidatedTransceiverMessage, + auth: &ManagerAuth, // only the recipient mangaer can destruct + ): (u16, ExternalAddress, NttManagerMessage) { + let ValidatedTransceiverMessage { from_chain, message } = message; + let (source_ntt_manager, recipient_ntt_manager, ntt_manager_message) = message.destruct(); + let caller_manager = ntt_common::contract_auth::assert_auth_type(auth); + assert!(external_address::from_address(caller_manager) == recipient_ntt_manager, EInvalidRecipientManager); + (from_chain, source_ntt_manager, ntt_manager_message) + } +} diff --git a/sui/packages/wormhole_transceiver/Move.toml b/sui/packages/wormhole_transceiver/Move.toml new file mode 100644 index 000000000..7e9dd799d --- /dev/null +++ b/sui/packages/wormhole_transceiver/Move.toml @@ -0,0 +1,32 @@ +[package] +name = "WormholeTransceiver" +edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move +license = "Apache 2.0" + +[dependencies.Sui] +git = "https://github.com/MystenLabs/sui.git" +subdir = "crates/sui-framework/packages/sui-framework" +rev = "framework/testnet" +override = true + +[dependencies.Wormhole] +# git = "https://github.com/wormhole-foundation/wormhole.git" +# rev = "sui/mainnet" +# TODO: we're using this fork temporarily which allows us to create VAAs for testing +git = "https://github.com/wormholelabs-xyz/wormhole.git" +rev = "sui/vaa-new-test-only" +subdir = "sui/wormhole" + +[dependencies.NttCommon] +local = "../ntt_common" + +[dependencies.Ntt] +local = "../ntt" + +[addresses] +wormhole_transceiver = "0x0" + +[dev-dependencies] + +[dev-addresses] +wormhole = "0x10" diff --git a/sui/packages/wormhole_transceiver/sources/messages/wormhole_transceiver_info.move b/sui/packages/wormhole_transceiver/sources/messages/wormhole_transceiver_info.move new file mode 100644 index 000000000..d4215db97 --- /dev/null +++ b/sui/packages/wormhole_transceiver/sources/messages/wormhole_transceiver_info.move @@ -0,0 +1,136 @@ + +module wormhole_transceiver::wormhole_transceiver_info { + use wormhole::external_address::{Self,ExternalAddress}; + use wormhole::bytes; + use wormhole::cursor::{Self, Cursor}; + use ntt_common::bytes4::{Self}; + use ntt::mode::{Self, Mode}; + + const INFO_PREFIX: vector = x"9C23BD3B"; + + #[error] + const EIncorrectPrefix: vector + = b"incorrect prefix"; + + // https://github.com/wormhole-foundation/native-token-transfers/blob/b6b681a77e8289869f35862b261b8048e3f5d398/evm/src/libraries/TransceiverStructs.sol#L409C12-L409C27 + public struct WormholeTransceiverInfo has drop{ + manager_address: ExternalAddress, + manager_mode: Mode, + token_address: ExternalAddress, + token_decimals: u8 + } + + public(package) fun new(manager_address: ExternalAddress, mode: Mode, token_address: ExternalAddress, decimals: u8): WormholeTransceiverInfo{ + WormholeTransceiverInfo { + manager_address: manager_address, + manager_mode: mode, + token_address: token_address, + token_decimals: decimals + } + } + + public fun to_bytes(self: &WormholeTransceiverInfo): vector { + let mut buf = vector::empty(); + + buf.append(INFO_PREFIX); + buf.append(self.manager_address.to_bytes()); // decimals and amount + buf.append(self.manager_mode.serialize()); // 32 bytes + buf.append(self.token_address.to_bytes()); // 32 bytes + bytes::push_u8(&mut buf, self.token_decimals); // 2 bytes + buf + } + + public fun take_bytes(cur: &mut Cursor): WormholeTransceiverInfo { + let ntt_prefix = bytes4::take(cur); + assert!(ntt_prefix.to_bytes() == INFO_PREFIX, EIncorrectPrefix); + let manager_address = external_address::take_bytes(cur); + let mode = mode::parse(bytes::take_bytes(cur, 1)); + let token_address = external_address::take_bytes(cur); + let token_decimals = bytes::take_u8(cur); + + WormholeTransceiverInfo { + manager_address: manager_address, + manager_mode: mode, + token_address: token_address, + token_decimals: token_decimals + } + } + + public fun parse(buf: vector): WormholeTransceiverInfo { + let mut cur = cursor::new(buf); + let info = take_bytes(&mut cur); + cur.destroy_empty(); + info + } + + #[test] + public fun test_round_trip() { + let reg = new(external_address::from_address(@102), mode::burning(), external_address::from_address(@304), 9); + + let reg_bytes = reg.to_bytes(); + + let reg_round_trip = parse(reg_bytes); + + assert!(reg.manager_address == reg_round_trip.manager_address); + assert!(reg.manager_mode == reg_round_trip.manager_mode); + assert!(reg.token_address == reg_round_trip.token_address); + assert!(reg.token_decimals == reg_round_trip.token_decimals); + } + + #[test] + public fun test_raw_to_bytes() { + let mut raw_bytes = vector::empty(); + + let prefix = vector[0x9c, 0x23, 0xbd, 0x3b]; + let manager_address = vector[0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x2]; + let mode = vector[0x1]; + + let token_address = vector[0x4, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x5]; + let token_decimals = vector[0x3]; + + vector::append(&mut raw_bytes, prefix); + vector::append(&mut raw_bytes, manager_address); + vector::append(&mut raw_bytes, mode); + vector::append(&mut raw_bytes, token_address); + vector::append(&mut raw_bytes, token_decimals); + + + let reg = parse(raw_bytes); + + assert!(reg.manager_address.to_bytes() == manager_address); + assert!(reg.manager_mode == mode::burning()); + assert!(reg.token_address.to_bytes() == token_address); + assert!(reg.token_decimals == 3); + } + + #[test] + public fun test_reg_to_raw_bytes(){ + let mut raw_bytes = vector::empty(); + + + let prefix = vector[0x9c, 0x23, 0xbd, 0x3b]; + let manager_address = vector[0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x2]; + let mode = vector[0x0]; + + let token_address = vector[0x4, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x5]; + let token_decimals = vector[0x9]; + + vector::append(&mut raw_bytes, prefix); + vector::append(&mut raw_bytes, manager_address); + vector::append(&mut raw_bytes, mode); + vector::append(&mut raw_bytes, token_address); + vector::append(&mut raw_bytes, token_decimals); + + // Create value + let reg = new(external_address::from_address(@0x0100000000000000000000000000000000000000000000000000000000000002), mode::locking(), external_address::from_address(@0x0400000000000000000000000000000000000000000000000000000000000005), 9); + + assert!(reg.manager_address.to_bytes() == manager_address); + assert!(reg.manager_mode == mode::locking()); + assert!(reg.token_address.to_bytes() == token_address); + assert!(reg.token_decimals == 9); + + let derived = to_bytes(®); + assert!(derived == raw_bytes); + } + +} \ No newline at end of file diff --git a/sui/packages/wormhole_transceiver/sources/messages/wormhole_transceiver_registration.move b/sui/packages/wormhole_transceiver/sources/messages/wormhole_transceiver_registration.move new file mode 100644 index 000000000..2de5fc41e --- /dev/null +++ b/sui/packages/wormhole_transceiver/sources/messages/wormhole_transceiver_registration.move @@ -0,0 +1,103 @@ + +module wormhole_transceiver::wormhole_transceiver_registration { + use wormhole::external_address::{Self,ExternalAddress}; + use wormhole::bytes; + use wormhole::cursor::{Self, Cursor}; + use ntt_common::bytes4::{Self}; + const REGISTRATION_PREFIX: vector = x"18fc67c2"; + + #[error] + const EIncorrectPrefix: vector + = b"incorrect prefix"; + + // https://github.com/wormhole-foundation/native-token-transfers/blob/b6b681a77e8289869f35862b261b8048e3f5d398/evm/src/libraries/TransceiverStructs.sol#L441 + public struct WormholeTransceiverRegistration has drop { + transceiver_chain_id: u16, + transceiver_address: ExternalAddress + } + + public(package) fun new(transceiver_chain_id: u16, transceiver_address: ExternalAddress): WormholeTransceiverRegistration{ + WormholeTransceiverRegistration { + transceiver_chain_id: transceiver_chain_id, + transceiver_address: transceiver_address + } + } + + public fun to_bytes(self: &WormholeTransceiverRegistration): vector { + let mut buf = vector::empty(); + + buf.append(REGISTRATION_PREFIX); + bytes::push_u16_be(&mut buf, self.transceiver_chain_id); + buf.append(self.transceiver_address.to_bytes()); + buf + } + + public fun take_bytes(cur: &mut Cursor): WormholeTransceiverRegistration { + let ntt_prefix = bytes4::take(cur); + assert!(ntt_prefix.to_bytes() == REGISTRATION_PREFIX, EIncorrectPrefix); + let chain_id = bytes::take_u16_be(cur); + let transceiver_address = external_address::take_bytes(cur); + + WormholeTransceiverRegistration { + transceiver_chain_id: chain_id, + transceiver_address: transceiver_address + } + } + + public fun parse(buf: vector): WormholeTransceiverRegistration { + let mut cur = cursor::new(buf); + let reg = take_bytes(&mut cur); + cur.destroy_empty(); + reg + } + + #[test] + public fun test_round_trip() { + let reg = new(1,external_address::from_address(@102)); + + let reg_bytes = reg.to_bytes(); + + let reg_round_trip = parse(reg_bytes); + + assert!(reg.transceiver_address == reg_round_trip.transceiver_address); + assert!(reg.transceiver_chain_id == reg_round_trip.transceiver_chain_id); + } + + #[test] + public fun test_raw_to_bytes() { + let mut raw_bytes = vector::empty(); + + let prefix = vector[0x18, 0xfc, 0x67, 0xc2]; + let transceiver_address = vector[0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x2]; + let chain_id = vector[0x4, 0x56]; + vector::append(&mut raw_bytes, prefix); + vector::append(&mut raw_bytes, chain_id); + vector::append(&mut raw_bytes, transceiver_address); + + let reg = parse(raw_bytes); + + assert!(reg.transceiver_address.to_bytes() == transceiver_address); + assert!(reg.transceiver_chain_id == 0x456); + } + + #[test] + public fun test_reg_to_raw_bytes(){ + let mut raw_bytes = vector::empty(); + + // Test bytes + let prefix = vector[0x18, 0xfc, 0x67, 0xc2]; + let transceiver_address = vector[0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x2]; + let chain_id = vector[0x4, 0x56]; + vector::append(&mut raw_bytes, prefix); + vector::append(&mut raw_bytes, chain_id); + vector::append(&mut raw_bytes, transceiver_address); + + // Create value + let reg = new(0x456,external_address::from_address(@0x0100000000000000000000000000000000000000000000000000000000000002)); + assert!(reg.transceiver_address.to_bytes() == transceiver_address); + assert!(reg.transceiver_chain_id == 0x456); + + let derived = to_bytes(®); + assert!(derived == raw_bytes); + } +} \ No newline at end of file diff --git a/sui/packages/wormhole_transceiver/sources/wormhole_transceiver.move b/sui/packages/wormhole_transceiver/sources/wormhole_transceiver.move new file mode 100644 index 000000000..a557e752b --- /dev/null +++ b/sui/packages/wormhole_transceiver/sources/wormhole_transceiver.move @@ -0,0 +1,179 @@ +module wormhole_transceiver::wormhole_transceiver { + use sui::table::{Self, Table}; + use wormhole::vaa::{Self, VAA}; + use wormhole::emitter::EmitterCap; + use wormhole::external_address::ExternalAddress; + use wormhole::publish_message::MessageTicket; + use ntt_common::outbound_message::OutboundMessage; + use ntt_common::validated_transceiver_message::{Self, ValidatedTransceiverMessage}; + use ntt_common::transceiver_message::{Self, PrefixOf}; + use ntt_common::transceiver_message_data; + use ntt::state::{State as ManagerState}; + use sui::coin::{CoinMetadata}; + + public struct Auth has drop {} + + public fun prefix(): PrefixOf { + transceiver_message::prefix(&Auth {}, ntt_common::bytes4::new(x"9945FF10")) + } + + public struct State has key, store { + id: UID, + peers: Table, + emitter_cap: EmitterCap, + } + + public(package) fun new( + wormhole_state: &wormhole::state::State, + ctx: &mut TxContext + ): State { + State { + id: object::new(ctx), + peers: table::new(ctx), + emitter_cap: wormhole::emitter::new(wormhole_state, ctx), // Creates a new emitter cap for WH core. Acts as the *peer* on the other side. + } + } + + public struct DeployerCap has key, store { + id: UID + } + + // Only callable by the 'creator' of the module. + fun init(ctx: &mut TxContext) { // Made on creation of module + let deployer = DeployerCap { id: object::new(ctx) }; + transfer::transfer(deployer, tx_context::sender(ctx)); + } + + #[allow(lint(share_owned))] + public fun complete(deployer: DeployerCap, wormhole_state: &wormhole::state::State, ctx: &mut TxContext): AdminCap { + let DeployerCap { id } = deployer; + object::delete(id); // Deletion means that nothing can redeploy this again... + + let state = new(wormhole_state, ctx); + transfer::public_share_object(state); + + AdminCap { id: object::new(ctx) } + } + + public fun release_outbound( + state: &mut State, + message: OutboundMessage, + ): Option { + + let (ntt_manager_message, source_ntt_manager, recipient_ntt_manager) + = message.unwrap_outbound_message(&Auth {}); + + let transceiver_message = transceiver_message::new( + transceiver_message_data::new( + source_ntt_manager, + recipient_ntt_manager, + ntt_manager_message + ), + vector[] + ); + let transceiver_message_encoded = transceiver_message.to_bytes(prefix()); + + let message_ticket = wormhole::publish_message::prepare_message( + &mut state.emitter_cap, + 0, + transceiver_message_encoded, + ); + option::some(message_ticket) + } + + public fun validate_message( + state: &State, + vaa: VAA, + ): ValidatedTransceiverMessage> { + let (emitter_chain, emitter_address, payload) + = vaa::take_emitter_info_and_payload(vaa); + + assert!(state.peers.borrow(emitter_chain) == emitter_address); + + let transceiver_message = ntt_common::transceiver_message::parse(prefix(), payload); + + let (message_data, _) = transceiver_message.destruct(); + + validated_transceiver_message::new( + &Auth {}, + emitter_chain, + message_data, + ) + } + + ////// Admin stuff + + public struct AdminCap has key, store { + id: UID + } + + // public fun set_peer( + // _: &AdminCap, + // state: &mut State, + // chain: u16, + // peer: ExternalAddress + // ) { + // if (state.peers.contains(chain)) { + // state.peers.remove(chain); + // }; + // state.peers.add(chain, peer); + // } + + public fun set_peer(_ : &AdminCap, state: &mut State, chain: u16, peer: ExternalAddress): Option{ + + // Cannot replace WH peers because of complexities with the accountant, according to EVM implementation. + assert!(!state.peers.contains(chain)); + state.peers.add(chain, peer); + + broadcast_peer(chain, peer, state) + } + + /* + Broadcast Peer in Solana + Transceiver Registration in EVM + + NTT Accountant must know which transceivers registered each other as peers. + */ + fun broadcast_peer(chain_id: u16, peer_address: ExternalAddress, state: &mut State): Option{ + + let transceiver_registration_struct = wormhole_transceiver::wormhole_transceiver_registration::new(chain_id, peer_address); + let message_ticket = wormhole::publish_message::prepare_message( + &mut state.emitter_cap, + 0, + transceiver_registration_struct.to_bytes(), + ); + option::some(message_ticket) + } + + /* + TransceiverInit on EVM + BroadCastId on Solana + + Deployment of a new transceiver and notice to the NTT accountant. + Added as a separate function instead of in `init/complete` because + we want to keep these functions simple and dependency free. Additionally, the deployer of NTT may not want + the NTT accountant to begin with but does want it in the future. + If wanted in the future, an admin would call this function to allow the NTT accountant to work. + */ + public fun broadcast_id(_: &AdminCap, coin_meta: &CoinMetadata, state: &mut State, manager_state: &ManagerState): Option { + + let mut manager_address_opt: Option
= ntt_common::contract_auth::get_auth_address(); + let manager_address = option::extract(&mut manager_address_opt); + + let external_address_manager_address = wormhole::external_address::from_address(manager_address); + + let transceiver_info_struct = wormhole_transceiver::wormhole_transceiver_info::new(external_address_manager_address, *manager_state.borrow_mode(), wormhole::external_address::from_id(object::id(coin_meta)), coin_meta.get_decimals()); + + let message_ticket = wormhole::publish_message::prepare_message( + &mut state.emitter_cap, + 0, + transceiver_info_struct.to_bytes(), + ); + option::some(message_ticket) + } + + #[test] + public fun test_auth_type() { + assert!(ntt_common::contract_auth::is_auth_type(), 0); + } +}