From e47417045e710701ec6c145e75c74c342b862d4d Mon Sep 17 00:00:00 2001 From: csongor Date: Thu, 7 May 2026 16:39:50 +0900 Subject: [PATCH 01/28] step 1: scope all PDAs by Config account - bind OutboxItem to manager instance - decouple BPF upgrade authority from Config owner --- solana/Cargo.lock | 10 +- solana/modules/ntt-messages/Cargo.toml | 2 +- .../programs/dummy-transfer-hook/Cargo.toml | 2 +- .../example-native-token-transfers/Cargo.toml | 2 +- .../src/config.rs | 27 ++++-- .../src/error.rs | 2 + .../src/instructions/admin/mod.rs | 14 ++- .../instructions/admin/transfer_ownership.rs | 93 ++----------------- .../admin/transfer_token_authority.rs | 33 +++++-- .../src/instructions/initialize.rs | 42 +++------ .../src/instructions/luts.rs | 16 +++- .../mark_outbox_item_as_released.rs | 5 +- .../src/instructions/redeem.rs | 15 ++- .../src/instructions/release_inbound.rs | 6 +- .../src/instructions/transfer.rs | 27 ++++-- .../example-native-token-transfers/src/lib.rs | 2 +- .../src/queue/outbox.rs | 5 + .../src/transceivers/wormhole/accounts.rs | 7 +- .../wormhole/instructions/admin.rs | 2 +- .../wormhole/instructions/broadcast_id.rs | 7 +- .../wormhole/instructions/broadcast_peer.rs | 5 +- .../wormhole/instructions/receive_message.rs | 3 +- .../wormhole/instructions/release_outbound.rs | 12 ++- solana/programs/ntt-transceiver/Cargo.toml | 2 +- .../ntt-transceiver/src/wormhole/accounts.rs | 6 +- .../src/wormhole/instructions/admin.rs | 2 +- .../src/wormhole/instructions/broadcast_id.rs | 7 +- .../wormhole/instructions/broadcast_peer.rs | 5 +- .../wormhole/instructions/receive_message.rs | 6 +- .../wormhole/instructions/release_outbound.rs | 14 ++- .../programs/wormhole-governance/Cargo.toml | 2 +- solana/tests/cargo/src/sdk/accounts/ntt.rs | 7 +- 32 files changed, 204 insertions(+), 186 deletions(-) diff --git a/solana/Cargo.lock b/solana/Cargo.lock index e4ad2edec..b4bd577b2 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1403,7 +1403,7 @@ checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "dummy-transfer-hook" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "anchor-spl", @@ -1562,7 +1562,7 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "example-native-token-transfers" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "anchor-spl", @@ -2482,7 +2482,7 @@ checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] name = "ntt-messages" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "hex", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "ntt-transceiver" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "anchor-spl", @@ -6440,7 +6440,7 @@ dependencies = [ [[package]] name = "wormhole-governance" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anchor-lang", "hex", diff --git a/solana/modules/ntt-messages/Cargo.toml b/solana/modules/ntt-messages/Cargo.toml index f757a24c2..f52c17b9a 100644 --- a/solana/modules/ntt-messages/Cargo.toml +++ b/solana/modules/ntt-messages/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ntt-messages" -version = "3.0.0" +version = "4.0.0" edition = "2021" [features] diff --git a/solana/programs/dummy-transfer-hook/Cargo.toml b/solana/programs/dummy-transfer-hook/Cargo.toml index a7b50b7b8..83c921a6c 100644 --- a/solana/programs/dummy-transfer-hook/Cargo.toml +++ b/solana/programs/dummy-transfer-hook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dummy-transfer-hook" -version = "3.0.0" +version = "4.0.0" description = "Created with Anchor" edition = "2021" diff --git a/solana/programs/example-native-token-transfers/Cargo.toml b/solana/programs/example-native-token-transfers/Cargo.toml index ddb851fbe..b70a17f5e 100644 --- a/solana/programs/example-native-token-transfers/Cargo.toml +++ b/solana/programs/example-native-token-transfers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "example-native-token-transfers" -version = "3.0.0" +version = "4.0.0" description = "Example implementation of native token transfer standard" edition = "2021" diff --git a/solana/programs/example-native-token-transfers/src/config.rs b/solana/programs/example-native-token-transfers/src/config.rs index efd374a53..b060bce0b 100644 --- a/solana/programs/example-native-token-transfers/src/config.rs +++ b/solana/programs/example-native-token-transfers/src/config.rs @@ -21,28 +21,33 @@ pub mod anchor_reexports { } } +// TODO(v4-rename): consider renaming `Config` → `Instance` to reflect the v4 +// semantics — there can be many of these per program; each is the on-the-wire +// "manager identity" for one NTT deployment. Held off for now to keep the v4 +// diff small and reviewable. #[account] #[derive(InitSpace)] pub struct Config { - pub bump: u8, - /// Owner of the program. + /// Owner of this instance. Distinct from the program's upgrade authority — + /// instance ownership transfers are pure data mutations and never touch the + /// BPF loader (v4 trust model). pub owner: Pubkey, /// Pending next owner (before claiming ownership). pub pending_owner: Option, - /// Mint address of the token managed by this program. + /// Mint address of the token managed by this instance. pub mint: Pubkey, /// Address of the token program (token or token22). This could always be queried /// from the [`mint`] account's owner, but storing it here avoids an indirection /// on the client side. pub token_program: Pubkey, - /// The mode that this program is running in. This is used to determine + /// The mode that this instance is running in. This is used to determine /// whether the program is burning tokens or locking tokens. pub mode: Mode, /// The chain id of the chain that this program is running on. We don't /// hardcode this so that the program is deployable on any potential SVM /// forks. pub chain_id: ChainId, - /// The next transceiver id to use when registering an transceiver. + /// The next transceiver id to use when registering a transceiver under this instance. pub next_transceiver_id: u8, /// The number of transceivers that must attest to a transfer before it is /// accepted. @@ -56,10 +61,6 @@ pub struct Config { pub custody: Pubkey, } -impl Config { - pub const SEED_PREFIX: &'static [u8] = b"config"; -} - #[derive(Accounts)] pub struct NotPausedConfig<'info> { #[account( @@ -68,6 +69,14 @@ pub struct NotPausedConfig<'info> { pub config: Account<'info, Config>, } +impl<'info> NotPausedConfig<'info> { + /// Pass-through to the inner `Account`'s key. + /// Useful in v4 where PDA seeds and signer-seed slices need `config.key()`. + pub fn key(&self) -> Pubkey { + self.config.key() + } +} + impl<'info> Deref for NotPausedConfig<'info> { type Target = Config; diff --git a/solana/programs/example-native-token-transfers/src/error.rs b/solana/programs/example-native-token-transfers/src/error.rs index 9f1b957c9..f6d12e606 100644 --- a/solana/programs/example-native-token-transfers/src/error.rs +++ b/solana/programs/example-native-token-transfers/src/error.rs @@ -65,6 +65,8 @@ pub enum NTTError { ThresholdTooHigh, #[msg("InvalidTransceiverProgram")] InvalidTransceiverProgram, + #[msg("InvalidOutboxItem")] + InvalidOutboxItem, } impl From for NTTError { diff --git a/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs b/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs index 375c64271..87e6a19e5 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/admin/mod.rs @@ -35,7 +35,7 @@ pub struct SetPeer<'info> { init_if_needed, space = 8 + NttManagerPeer::INIT_SPACE, payer = payer, - seeds = [NttManagerPeer::SEED_PREFIX, args.chain_id.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, NttManagerPeer>, @@ -46,6 +46,7 @@ pub struct SetPeer<'info> { payer = payer, seeds = [ InboxRateLimit::SEED_PREFIX, + config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref() ], bump, @@ -113,7 +114,7 @@ pub struct RegisterTransceiver<'info> { init_if_needed, space = 8 + RegisteredTransceiver::INIT_SPACE, payer = payer, - seeds = [RegisteredTransceiver::SEED_PREFIX, transceiver.key().as_ref()], + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.key().as_ref()], bump )] pub registered_transceiver: Account<'info, RegisteredTransceiver>, @@ -154,7 +155,7 @@ pub struct DeregisterTransceiver<'info> { pub owner: Signer<'info>, #[account( - seeds = [RegisteredTransceiver::SEED_PREFIX, registered_transceiver.transceiver_address.as_ref()], + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), registered_transceiver.transceiver_address.as_ref()], bump, constraint = config.enabled_transceivers.get(registered_transceiver.id)? @ NTTError::DisabledTransceiver, )] @@ -190,7 +191,11 @@ pub struct SetOutboundLimit<'info> { pub owner: Signer<'info>, - #[account(mut)] + #[account( + mut, + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub rate_limit: Account<'info, OutboxRateLimit>, } @@ -221,6 +226,7 @@ pub struct SetInboundLimit<'info> { mut, seeds = [ InboxRateLimit::SEED_PREFIX, + config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref() ], bump = rate_limit.bump diff --git a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs index d19bc99d5..ee38ffe7b 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs @@ -1,5 +1,4 @@ use anchor_lang::prelude::*; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable::{self, BpfLoaderUpgradeable}; #[cfg(feature = "idl-build")] use crate::messages::Hack; @@ -21,6 +20,11 @@ use crate::{config::Config, error::NTTError}; /// cannot actually sign transactions (due to setting the wrong address), the program will be /// permanently locked. If the intention is to transfer ownership to a program using this instruction, /// take extra care to ensure that the owner is a PDA, not the program address itself. +/// +/// v4 trust model: instance ownership is decoupled from the program upgrade +/// authority. Transferring instance ownership is a pure data mutation; the BPF +/// loader is never touched. Operators are expected to manage the program +/// upgrade authority separately (typically null'd or held by a multisig). #[derive(Accounts)] pub struct TransferOwnership<'info> { #[account( @@ -36,69 +40,17 @@ pub struct TransferOwnership<'info> { // The intention of new_owner is that it could be an arbitrary account so no constraints are // required here. new_owner: UncheckedAccount<'info>, - - #[account( - seeds = [b"upgrade_lock"], - bump, - )] - /// CHECK: The seeds constraint enforces that this is the correct address - upgrade_lock: UncheckedAccount<'info>, - - #[account( - mut, - seeds = [crate::ID.as_ref()], - bump, - seeds::program = bpf_loader_upgradeable_program, - )] - program_data: Account<'info, ProgramData>, - - bpf_loader_upgradeable_program: Program<'info, BpfLoaderUpgradeable>, } pub fn transfer_ownership(ctx: Context) -> Result<()> { ctx.accounts.config.pending_owner = Some(ctx.accounts.new_owner.key()); - - // only transfer authority when the authority is not already the upgrade lock - if ctx.accounts.program_data.upgrade_authority_address != Some(ctx.accounts.upgrade_lock.key()) - { - return bpf_loader_upgradeable::set_upgrade_authority_checked( - CpiContext::new_with_signer( - ctx.accounts - .bpf_loader_upgradeable_program - .to_account_info(), - bpf_loader_upgradeable::SetUpgradeAuthorityChecked { - program_data: ctx.accounts.program_data.to_account_info(), - current_authority: ctx.accounts.owner.to_account_info(), - new_authority: ctx.accounts.upgrade_lock.to_account_info(), - }, - &[&[b"upgrade_lock", &[ctx.bumps.upgrade_lock]]], - ), - &crate::ID, - ); - } Ok(()) } pub fn transfer_ownership_one_step_unchecked(ctx: Context) -> Result<()> { ctx.accounts.config.pending_owner = None; ctx.accounts.config.owner = ctx.accounts.new_owner.key(); - - // NOTE: unlike in `transfer_ownership`, we use the unchecked version of the - // `set_upgrade_authority` instruction here. The checked version requires - // the new owner to be a signer, which is what we want to avoid here. - bpf_loader_upgradeable::set_upgrade_authority( - CpiContext::new( - ctx.accounts - .bpf_loader_upgradeable_program - .to_account_info(), - bpf_loader_upgradeable::SetUpgradeAuthority { - program_data: ctx.accounts.program_data.to_account_info(), - current_authority: ctx.accounts.owner.to_account_info(), - new_authority: Some(ctx.accounts.new_owner.to_account_info()), - }, - ), - &crate::ID, - ) + Ok(()) } // * Claim ownership @@ -114,42 +66,11 @@ pub struct ClaimOwnership<'info> { )] pub config: Account<'info, Config>, - #[account( - seeds = [b"upgrade_lock"], - bump, - )] - /// CHECK: The seeds constraint enforces that this is the correct address - upgrade_lock: UncheckedAccount<'info>, - pub new_owner: Signer<'info>, - - #[account( - mut, - seeds = [crate::ID.as_ref()], - bump, - seeds::program = bpf_loader_upgradeable_program, - )] - program_data: Account<'info, ProgramData>, - - bpf_loader_upgradeable_program: Program<'info, BpfLoaderUpgradeable>, } pub fn claim_ownership(ctx: Context) -> Result<()> { ctx.accounts.config.pending_owner = None; ctx.accounts.config.owner = ctx.accounts.new_owner.key(); - - bpf_loader_upgradeable::set_upgrade_authority_checked( - CpiContext::new_with_signer( - ctx.accounts - .bpf_loader_upgradeable_program - .to_account_info(), - bpf_loader_upgradeable::SetUpgradeAuthorityChecked { - program_data: ctx.accounts.program_data.to_account_info(), - current_authority: ctx.accounts.upgrade_lock.to_account_info(), - new_authority: ctx.accounts.new_owner.to_account_info(), - }, - &[&[b"upgrade_lock", &[ctx.bumps.upgrade_lock]]], - ), - &crate::ID, - ) + Ok(()) } diff --git a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs index f2f59a3cd..7b339136a 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_token_authority.rs @@ -20,7 +20,7 @@ pub struct AcceptTokenAuthorityBase<'info> { pub mint: InterfaceAccount<'info, token_interface::Mint>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The constraints enforce this is valid mint authority @@ -120,7 +120,7 @@ pub struct SetTokenAuthority<'info> { pub mint: InterfaceAccount<'info, token_interface::Mint>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The constraints enforce this is valid mint authority @@ -147,12 +147,14 @@ pub struct SetTokenAuthorityUnchecked<'info> { pub fn set_token_authority_one_step_unchecked( ctx: Context, ) -> Result<()> { + let config_key = ctx.accounts.common.config.key(); match &ctx.accounts.common.multisig_token_authority { Some(multisig_token_authority) => claim_from_multisig_token_authority( ctx.accounts.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), multisig_token_authority.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.common.new_authority.key(), ), @@ -160,6 +162,7 @@ pub fn set_token_authority_one_step_unchecked( ctx.accounts.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.common.new_authority.key(), ), @@ -185,7 +188,7 @@ pub struct SetTokenAuthorityChecked<'info> { init_if_needed, space = 8 + PendingTokenAuthority::INIT_SPACE, payer = rent_payer, - seeds = [PendingTokenAuthority::SEED_PREFIX], + seeds = [PendingTokenAuthority::SEED_PREFIX, common.config.key().as_ref()], bump )] pub pending_token_authority: Account<'info, PendingTokenAuthority>, @@ -223,7 +226,7 @@ pub struct ClaimTokenAuthorityBase<'info> { pub mint: InterfaceAccount<'info, token_interface::Mint>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The seeds constraint enforces that this is the correct address @@ -242,7 +245,7 @@ pub struct ClaimTokenAuthorityBase<'info> { #[account( mut, - seeds = [PendingTokenAuthority::SEED_PREFIX], + seeds = [PendingTokenAuthority::SEED_PREFIX, config.key().as_ref()], bump = pending_token_authority.bump, has_one = rent_payer @ NTTError::IncorrectRentPayer, close = rent_payer @@ -280,12 +283,14 @@ pub struct ClaimTokenAuthority<'info> { } pub fn claim_token_authority(ctx: Context) -> Result<()> { + let config_key = ctx.accounts.common.config.key(); match &ctx.accounts.common.multisig_token_authority { Some(multisig_token_authority) => claim_from_multisig_token_authority( ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), multisig_token_authority.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_authority.key(), ), @@ -293,6 +298,7 @@ pub fn claim_token_authority(ctx: Context) -> Result<()> { ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_authority.key(), ), @@ -326,12 +332,14 @@ pub fn claim_token_authority_to_multisig( )?; } + let config_key = ctx.accounts.common.config.key(); match &ctx.accounts.common.multisig_token_authority { Some(multisig_token_authority) => claim_from_multisig_token_authority( ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), multisig_token_authority.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_multisig_authority.key(), ), @@ -339,6 +347,7 @@ pub fn claim_token_authority_to_multisig( ctx.accounts.common.token_program.to_account_info(), ctx.accounts.common.mint.to_account_info(), ctx.accounts.common.token_authority.to_account_info(), + config_key, ctx.bumps.common.token_authority, ctx.accounts.new_multisig_authority.key(), ), @@ -349,6 +358,7 @@ fn claim_from_token_authority<'info>( token_program: AccountInfo<'info>, mint: AccountInfo<'info>, token_authority: AccountInfo<'info>, + config_key: Pubkey, token_authority_bump: u8, new_authority: Pubkey, ) -> Result<()> { @@ -359,7 +369,11 @@ fn claim_from_token_authority<'info>( account_or_mint: mint, current_authority: token_authority, }, - &[&[crate::TOKEN_AUTHORITY_SEED, &[token_authority_bump]]], + &[&[ + crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), + &[token_authority_bump], + ]], ), AuthorityType::MintTokens, Some(new_authority), @@ -372,6 +386,7 @@ fn claim_from_multisig_token_authority<'info>( mint: AccountInfo<'info>, multisig_token_authority: AccountInfo<'info>, token_authority: AccountInfo<'info>, + config_key: Pubkey, token_authority_bump: u8, new_authority: Pubkey, ) -> Result<()> { @@ -385,7 +400,11 @@ fn claim_from_multisig_token_authority<'info>( &[&token_authority.key()], )?, &[mint, multisig_token_authority, token_authority], - &[&[crate::TOKEN_AUTHORITY_SEED, &[token_authority_bump]]], + &[&[ + crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), + &[token_authority_bump], + ]], )?; Ok(()) } diff --git a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs index ccd470e33..f9188d1dd 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs @@ -1,7 +1,6 @@ use anchor_lang::prelude::*; use anchor_spl::{associated_token::AssociatedToken, token_interface}; use ntt_messages::{chain_id::ChainId, mode::Mode}; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable::BpfLoaderUpgradeable; #[cfg(feature = "idl-build")] use crate::messages::Hack; @@ -20,22 +19,17 @@ pub struct Initialize<'info> { #[account(mut)] pub payer: Signer<'info>, - #[account(address = program_data.upgrade_authority_address.unwrap_or_default())] - pub deployer: Signer<'info>, - - #[account( - seeds = [crate::ID.as_ref()], - bump, - seeds::program = bpf_loader_upgradeable_program, - )] - program_data: Account<'info, ProgramData>, + /// The owner of the new instance. Distinct from the program's upgrade + /// authority — see the v4 trust-model note in the README. + pub owner: Signer<'info>, + /// The instance account itself. Caller-provided keypair, must sign. + // TODO(v4-rename): consider renaming this field `instance` if/when we + // rename `Config` → `Instance`. #[account( init, - space = 8 + Config::INIT_SPACE, payer = payer, - seeds = [Config::SEED_PREFIX], - bump + space = 8 + Config::INIT_SPACE, )] pub config: Box>, @@ -52,18 +46,17 @@ pub struct Initialize<'info> { init, payer = payer, space = 8 + OutboxRateLimit::INIT_SPACE, - seeds = [OutboxRateLimit::SEED_PREFIX], + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], bump, )] pub rate_limit: Account<'info, OutboxRateLimit>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] - /// CHECK: [`token_authority`] is checked against the custody account and the [`mint`]'s mint_authority - /// In any case, this function is used to set the Config and initialize the program so we - /// assume the caller of this function will have total control over the program. + /// CHECK: [`token_authority`] is checked against the custody account and the [`mint`]'s mint_authority. + /// Per-instance token authority lets each instance manage its own mint independently. /// /// TODO: Using `UncheckedAccount` here leads to "Access violation in stack frame ...". /// Could refactor code to use `Box<_>` to reduce stack size. @@ -93,7 +86,6 @@ pub struct Initialize<'info> { /// associated token account for the given mint. pub token_program: Interface<'info, token_interface::TokenInterface>, pub associated_token_program: Program<'info, AssociatedToken>, - bpf_loader_upgradeable_program: Program<'info, BpfLoaderUpgradeable>, system_program: Program<'info, System>, } @@ -106,29 +98,21 @@ pub struct InitializeArgs { } pub fn initialize(ctx: Context, args: InitializeArgs) -> Result<()> { - initialize_config_and_rate_limit( - ctx.accounts, - ctx.bumps.config, - args.chain_id, - args.limit, - args.mode, - ) + initialize_config_and_rate_limit(ctx.accounts, args.chain_id, args.limit, args.mode) } fn initialize_config_and_rate_limit( common: &mut Initialize<'_>, - config_bump: u8, chain_id: u16, limit: u64, mode: ntt_messages::mode::Mode, ) -> Result<()> { common.config.set_inner(crate::config::Config { - bump: config_bump, mint: common.mint.key(), token_program: common.token_program.key(), mode, chain_id: ChainId { id: chain_id }, - owner: common.deployer.key(), + owner: common.owner.key(), pending_owner: None, paused: false, next_transceiver_id: 0, diff --git a/solana/programs/example-native-token-transfers/src/instructions/luts.rs b/solana/programs/example-native-token-transfers/src/instructions/luts.rs index 37e5d5ec7..df89cbd09 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/luts.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/luts.rs @@ -47,7 +47,7 @@ pub struct InitializeLUT<'info> { pub owner: Signer<'info>, #[account( - seeds = [b"lut_authority"], + seeds = [b"lut_authority", entries.config.key().as_ref()], bump )] /// CHECK: The seeds constraint enforces that this is the correct account. @@ -66,7 +66,7 @@ pub struct InitializeLUT<'info> { init_if_needed, payer = payer, space = 8 + LUT::INIT_SPACE, - seeds = [b"lut"], + seeds = [b"lut", entries.config.key().as_ref()], bump )] pub lut: Account<'info, LUT>, @@ -105,12 +105,16 @@ pub struct Entries<'info> { pub mint: UncheckedAccount<'info>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The seeds constraint enforces that this is the correct account. pub token_authority: UncheckedAccount<'info>, + #[account( + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub outbox_rate_limit: Account<'info, OutboxRateLimit>, // NOTE: this includes the system program so we don't need to add it in the outer context @@ -177,7 +181,11 @@ pub fn initialize_lut(ctx: Context, recent_slot: u64) -> Result<( ctx.accounts.payer.to_account_info(), ctx.accounts.system_program.to_account_info(), ], - &[&[b"lut_authority", &[ctx.bumps.authority]]], + &[&[ + b"lut_authority", + ctx.accounts.entries.config.key().as_ref(), + &[ctx.bumps.authority], + ]], )?; Ok(()) diff --git a/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs b/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs index a74225b5b..289a5fac4 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/mark_outbox_item_as_released.rs @@ -10,7 +10,7 @@ pub const OUTBOX_ITEM_SIGNER_SEED: &[u8] = b"outbox_item_signer"; #[derive(Accounts)] pub struct MarkOutboxItemAsReleased<'info> { #[account( - seeds = [OUTBOX_ITEM_SIGNER_SEED], + seeds = [OUTBOX_ITEM_SIGNER_SEED, config.key().as_ref()], seeds::program = transceiver.transceiver_address, bump )] @@ -20,11 +20,14 @@ pub struct MarkOutboxItemAsReleased<'info> { #[account( mut, + constraint = outbox_item.manager == config.key() @ NTTError::InvalidOutboxItem, constraint = !outbox_item.released.get(transceiver.id)? @ NTTError::MessageAlreadySent, )] pub outbox_item: Account<'info, OutboxItem>, #[account( + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] pub transceiver: Account<'info, RegisteredTransceiver>, diff --git a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs index ab594e292..15c8fa4a9 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs @@ -29,7 +29,7 @@ pub struct Redeem<'info> { pub config: Account<'info, Config>, #[account( - seeds = [NttManagerPeer::SEED_PREFIX, ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, config.key().as_ref(), ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref()], constraint = peer.address == ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.source_ntt_manager() @ NTTError::InvalidNttManagerPeer, bump = peer.bump, )] @@ -39,7 +39,8 @@ pub struct Redeem<'info> { // check that the message is targeted to this chain constraint = ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.ntt_manager_payload().payload.to_chain == config.chain_id @ NTTError::InvalidChainId, // check that we're the intended recipient - constraint = ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.recipient_ntt_manager() == crate::ID.to_bytes() @ NTTError::InvalidRecipientNttManager, + // v4: the on-the-wire manager identity is the instance's `config` pubkey. + constraint = ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.recipient_ntt_manager() == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, // NOTE: we don't replay protect VAAs. Instead, we replay protect // executing the messages themselves with the [`released`] flag. owner = transceiver.transceiver_address @@ -49,6 +50,8 @@ pub struct Redeem<'info> { pub transceiver_message: UncheckedAccount<'info>, #[account( + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] pub transceiver: Account<'info, RegisteredTransceiver>, @@ -64,6 +67,7 @@ pub struct Redeem<'info> { space = 8 + InboxItem::INIT_SPACE, seeds = [ InboxItem::SEED_PREFIX, + config.key().as_ref(), ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.ntt_manager_payload().keccak256( ValidatedTransceiverMessage::>::from_chain(&transceiver_message)? ).as_ref(), @@ -88,13 +92,18 @@ pub struct Redeem<'info> { mut, seeds = [ InboxRateLimit::SEED_PREFIX, + config.key().as_ref(), ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref(), ], bump, )] pub inbox_rate_limit: Account<'info, InboxRateLimit>, - #[account(mut)] + #[account( + mut, + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub outbox_rate_limit: Account<'info, OutboxRateLimit>, pub system_program: Program<'info, System>, diff --git a/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs b/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs index 298e446bc..bfc4976ca 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs @@ -29,7 +29,7 @@ pub struct ReleaseInbound<'info> { pub recipient: InterfaceAccount<'info, token_interface::TokenAccount>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, config.key().as_ref()], bump, )] /// CHECK: The seeds constraint ensures that this is the correct address @@ -112,8 +112,10 @@ pub fn release_inbound_mint<'info>( // The [`transfer_burn`] function operates in a similar way // (transfer to custody from sender, *then* burn). + let config_key = ctx.accounts.common.config.key(); let token_authority_sig: &[&[&[u8]]] = &[&[ crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), &[ctx.bumps.common.token_authority], ]]; @@ -233,6 +235,7 @@ pub fn release_inbound_unlock<'info>( let inbox_item = inbox_item.unwrap(); assert!(inbox_item.release_status == ReleaseStatus::Released); + let config_key = ctx.accounts.common.config.key(); onchain::invoke_transfer_checked( &ctx.accounts.common.token_program.key(), ctx.accounts.common.custody.to_account_info(), @@ -244,6 +247,7 @@ pub fn release_inbound_unlock<'info>( ctx.accounts.common.mint.decimals, &[&[ crate::TOKEN_AUTHORITY_SEED, + config_key.as_ref(), &[ctx.bumps.common.token_authority], ]], )?; diff --git a/solana/programs/example-native-token-transfers/src/instructions/transfer.rs b/solana/programs/example-native-token-transfers/src/instructions/transfer.rs index cab1dc524..70a66b5c2 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/transfer.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/transfer.rs @@ -67,7 +67,11 @@ pub struct Transfer<'info> { )] pub outbox_item: Account<'info, OutboxItem>, - #[account(mut)] + #[account( + mut, + seeds = [OutboxRateLimit::SEED_PREFIX, config.key().as_ref()], + bump, + )] pub outbox_rate_limit: Account<'info, OutboxRateLimit>, #[account( @@ -119,7 +123,7 @@ pub struct TransferBurn<'info> { #[account( mut, - seeds = [InboxRateLimit::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [InboxRateLimit::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = inbox_rate_limit.bump, )] // NOTE: it would be nice to put these into `common`, but that way we don't @@ -127,7 +131,7 @@ pub struct TransferBurn<'info> { pub inbox_rate_limit: Account<'info, InboxRateLimit>, #[account( - seeds = [NttManagerPeer::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = peer.bump, )] pub peer: Account<'info, NttManagerPeer>, @@ -135,6 +139,7 @@ pub struct TransferBurn<'info> { #[account( seeds = [ crate::SESSION_AUTHORITY_SEED, + common.config.key().as_ref(), common.from.owner.as_ref(), args.keccak256().as_ref() ], @@ -145,7 +150,7 @@ pub struct TransferBurn<'info> { pub session_authority: UncheckedAccount<'info>, #[account( - seeds = [crate::TOKEN_AUTHORITY_SEED], + seeds = [crate::TOKEN_AUTHORITY_SEED, common.config.key().as_ref()], bump, )] /// CHECK: The seeds constraint enforces that this is the correct account. @@ -204,6 +209,7 @@ pub fn transfer_burn<'info>( accs.common.mint.decimals, &[&[ crate::SESSION_AUTHORITY_SEED, + accs.common.config.key().as_ref(), accs.common.from.owner.as_ref(), args.keccak256().as_ref(), &[ctx.bumps.session_authority], @@ -219,7 +225,11 @@ pub fn transfer_burn<'info>( from: accs.common.custody.to_account_info(), authority: accs.token_authority.to_account_info(), }, - &[&[crate::TOKEN_AUTHORITY_SEED, &[ctx.bumps.token_authority]]], + &[&[ + crate::TOKEN_AUTHORITY_SEED, + accs.common.config.key().as_ref(), + &[ctx.bumps.token_authority], + ]], ), amount, )?; @@ -267,7 +277,7 @@ pub struct TransferLock<'info> { #[account( mut, - seeds = [InboxRateLimit::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [InboxRateLimit::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = inbox_rate_limit.bump, )] // NOTE: it would be nice to put these into `common`, but that way we don't @@ -275,7 +285,7 @@ pub struct TransferLock<'info> { pub inbox_rate_limit: Account<'info, InboxRateLimit>, #[account( - seeds = [NttManagerPeer::SEED_PREFIX, args.recipient_chain.id.to_be_bytes().as_ref()], + seeds = [NttManagerPeer::SEED_PREFIX, common.config.key().as_ref(), args.recipient_chain.id.to_be_bytes().as_ref()], bump = peer.bump, )] pub peer: Account<'info, NttManagerPeer>, @@ -283,6 +293,7 @@ pub struct TransferLock<'info> { #[account( seeds = [ crate::SESSION_AUTHORITY_SEED, + common.config.key().as_ref(), common.from.owner.as_ref(), args.keccak256().as_ref() ], @@ -330,6 +341,7 @@ pub fn transfer_lock<'info>( accs.common.mint.decimals, &[&[ crate::SESSION_AUTHORITY_SEED, + accs.common.config.key().as_ref(), accs.common.from.owner.as_ref(), args.keccak256().as_ref(), &[ctx.bumps.session_authority], @@ -392,6 +404,7 @@ fn insert_into_outbox( }; common.outbox_item.set_inner(OutboxItem { + manager: common.config.key(), amount: trimmed_amount, sender: common.from.owner, recipient_chain, diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index 49d6d802f..27424296b 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -65,7 +65,7 @@ pub const TOKEN_AUTHORITY_SEED: &[u8] = b"token_authority"; /// user, atomically). pub const SESSION_AUTHORITY_SEED: &[u8] = b"session_authority"; -pub const VERSION: &str = "3.0.0"; +pub const VERSION: &str = "4.0.0"; #[program] pub mod example_native_token_transfers { diff --git a/solana/programs/example-native-token-transfers/src/queue/outbox.rs b/solana/programs/example-native-token-transfers/src/queue/outbox.rs index efdde23a9..f5980c5f8 100644 --- a/solana/programs/example-native-token-transfers/src/queue/outbox.rs +++ b/solana/programs/example-native-token-transfers/src/queue/outbox.rs @@ -10,6 +10,11 @@ use super::rate_limit::RateLimitState; #[derive(InitSpace, Debug, PartialEq, Eq)] // TODO: generalise this to arbitrary outbound messages (via a generic parameter in place of amount and recipient info) pub struct OutboxItem { + /// v4: the `Config` (instance) that produced this outbox item. Used to + /// bind the item to its source instance so transceivers can verify they're + /// releasing/marking an item that belongs to the instance they're working + /// on (`released` is a bitmap keyed by per-instance transceiver_id). + pub manager: Pubkey, pub amount: TrimmedAmount, pub sender: Pubkey, pub recipient_chain: ChainId, diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs index 97f6dd11f..10a631c46 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/accounts.rs @@ -42,12 +42,17 @@ pub struct WormholeAccounts<'info> { /// and [`WormholeAccounts::sequence`] must be checked by the Wormhole core bridge. /// SECURITY: Signer checks are disabled. The only valid sender is the /// [`wormhole::PostMessage::emitter`], enforced by the [`CpiContext`] below. +/// `config` is the per-instance Config pubkey embedded in the emitter PDA +/// seed list (v4). Callers pass `accs.config.key()` so the CPI signer-seed +/// slice matches the on-chain `[b"emitter", config.key()]` PDA layout. +#[allow(clippy::too_many_arguments)] pub fn post_message<'info, A: TypePrefixedPayload>( wormhole: &WormholeAccounts<'info>, payer: AccountInfo<'info>, message: AccountInfo<'info>, emitter: AccountInfo<'info>, emitter_bump: u8, + config: Pubkey, payload: &A, additional_seeds: &[&[&[u8]]], ) -> Result<()> { @@ -68,7 +73,7 @@ pub fn post_message<'info, A: TypePrefixedPayload>( }; let seeds: &[&[&[&[u8]]]] = &[ - &[&[b"emitter".as_slice(), &[emitter_bump]]], + &[&[b"emitter".as_slice(), config.as_ref(), &[emitter_bump]]], additional_seeds, ]; diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs index 0ead95ba5..4053df8c5 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/admin.rs @@ -20,7 +20,7 @@ pub struct SetTransceiverPeer<'info> { init, space = 8 + TransceiverPeer::INIT_SPACE, payer = payer, - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs index 78bde1995..3055bfa6e 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_id.rs @@ -21,7 +21,7 @@ pub struct BroadcastId<'info> { pub wormhole_message: Signer<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The only valid sender is the [`wormhole::PostMessage::emitter`] @@ -34,8 +34,10 @@ pub struct BroadcastId<'info> { pub fn broadcast_id(ctx: Context) -> Result<()> { let accs = ctx.accounts; + // v4: the on-the-wire manager identity is the instance's `config` pubkey, + // not the program ID. EVM/Sui peers register against `config.key()`. let message = WormholeTransceiverInfo { - manager_address: accs.config.to_account_info().owner.to_bytes(), + manager_address: accs.config.key().to_bytes(), manager_mode: accs.config.mode, token_address: accs.mint.to_account_info().key.to_bytes(), token_decimals: accs.mint.decimals, @@ -48,6 +50,7 @@ pub fn broadcast_id(ctx: Context) -> Result<()> { accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, &[], )?; diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs index c181fb6d0..e43c697ec 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/broadcast_peer.rs @@ -15,7 +15,7 @@ pub struct BroadcastPeer<'info> { pub config: Account<'info, Config>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, @@ -25,7 +25,7 @@ pub struct BroadcastPeer<'info> { pub wormhole_message: Signer<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The seeds constraint ensures that this is the correct address @@ -57,6 +57,7 @@ pub fn broadcast_peer(ctx: Context, args: BroadcastPeerArgs) -> R accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, &[], )?; diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs index ae3db77e0..f24304ec1 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs @@ -21,7 +21,7 @@ pub struct ReceiveMessage<'info> { pub config: NotPausedConfig<'info>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, vaa.emitter_chain().to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), vaa.emitter_chain().to_be_bytes().as_ref()], constraint = peer.address == *vaa.emitter_address() @ NTTError::InvalidTransceiverPeer, bump = peer.bump, )] @@ -47,6 +47,7 @@ pub struct ReceiveMessage<'info> { space = 8 + ValidatedTransceiverMessage::>>::INIT_SPACE, seeds = [ ValidatedTransceiverMessage::>>::SEED_PREFIX, + config.key().as_ref(), vaa.emitter_chain().to_be_bytes().as_ref(), vaa.message().ntt_manager_payload.id.as_ref(), ], diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs index 6c4ebbb20..87072ed66 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/release_outbound.rs @@ -19,11 +19,14 @@ pub struct ReleaseOutbound<'info> { #[account( mut, + constraint = outbox_item.manager == config.key() @ NTTError::InvalidOutboxItem, constraint = !outbox_item.released.get(transceiver.id)? @ NTTError::MessageAlreadySent, )] pub outbox_item: Account<'info, OutboxItem>, #[account( + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, constraint = transceiver.transceiver_address == crate::ID, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] @@ -38,7 +41,7 @@ pub struct ReleaseOutbound<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] // TODO: do we want to put anything in here? @@ -68,8 +71,10 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs assert!(accs.outbox_item.released.get(accs.transceiver.id)?); let message: TransceiverMessage> = TransceiverMessage::new( - // TODO: should we just put the ntt id here statically? - accs.outbox_item.to_account_info().owner.to_bytes(), + // v4: the on-the-wire manager identity is the instance's `config` + // pubkey, not the program ID. Each instance is independently + // peered with remote chains. + accs.config.key().to_bytes(), accs.outbox_item.recipient_ntt_manager, NttManagerMessage { id: accs.outbox_item.key().to_bytes(), @@ -91,6 +96,7 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, &[&[ b"message", diff --git a/solana/programs/ntt-transceiver/Cargo.toml b/solana/programs/ntt-transceiver/Cargo.toml index fefbc0fd0..d7a6e8786 100644 --- a/solana/programs/ntt-transceiver/Cargo.toml +++ b/solana/programs/ntt-transceiver/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ntt-transceiver" -version = "3.0.0" +version = "4.0.0" description = "Example implementation of native token transfer transceiver using Wormhole Post Message Shim and Verify VAA Shim" edition = "2021" diff --git a/solana/programs/ntt-transceiver/src/wormhole/accounts.rs b/solana/programs/ntt-transceiver/src/wormhole/accounts.rs index 30a455a5f..e0306d75f 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/accounts.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/accounts.rs @@ -37,12 +37,16 @@ pub struct WormholeAccounts<'info> { /// and [`WormholeAccounts::sequence`] must be checked by the Wormhole core bridge. /// SECURITY: Signer checks are disabled. The only valid sender is the /// [`wormhole::PostMessage::emitter`], enforced by the [`CpiContext`] below. +/// `config` is the per-instance Config pubkey embedded in the emitter PDA +/// seed list (v4). Callers pass `accs.config.key()` so the CPI signer-seed +/// slice matches the on-chain `[b"emitter", config.key()]` PDA layout. pub fn post_message<'info, A: TypePrefixedPayload>( wormhole: &WormholeAccounts<'info>, payer: AccountInfo<'info>, message: AccountInfo<'info>, emitter: AccountInfo<'info>, emitter_bump: u8, + config: Pubkey, payload: &A, ) -> Result<()> { let batch_id = 0; @@ -65,7 +69,7 @@ pub fn post_message<'info, A: TypePrefixedPayload>( program: wormhole.post_message_shim.to_account_info(), event_authority: wormhole.wormhole_post_message_shim_ea.to_account_info(), }, - &[&[b"emitter", &[emitter_bump]]], + &[&[b"emitter", config.as_ref(), &[emitter_bump]]], ), batch_id, Finality::Finalized, diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs index b606212ff..94d5f8186 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/admin.rs @@ -20,7 +20,7 @@ pub struct SetTransceiverPeer<'info> { init, space = 8 + TransceiverPeer::INIT_SPACE, payer = payer, - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs index 732931b9a..0fc6c935c 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_id.rs @@ -21,7 +21,7 @@ pub struct BroadcastId<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The only valid sender is the [`wormhole::PostMessage::emitter`] @@ -34,8 +34,10 @@ pub struct BroadcastId<'info> { pub fn broadcast_id(ctx: Context) -> Result<()> { let accs = ctx.accounts; + // v4: the on-the-wire manager identity is the instance's `config` pubkey, + // not the program ID. EVM/Sui peers register against `config.key()`. let message = WormholeTransceiverInfo { - manager_address: accs.config.to_account_info().owner.to_bytes(), + manager_address: accs.config.key().to_bytes(), manager_mode: accs.config.mode, token_address: accs.mint.to_account_info().key.to_bytes(), token_decimals: accs.mint.decimals, @@ -48,6 +50,7 @@ pub fn broadcast_id(ctx: Context) -> Result<()> { accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, )?; diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs index 04289a1bb..9b437b602 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/broadcast_peer.rs @@ -12,7 +12,7 @@ pub struct BroadcastPeer<'info> { pub config: Account<'info, Config>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, args.chain_id.to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), args.chain_id.to_be_bytes().as_ref()], bump )] pub peer: Account<'info, TransceiverPeer>, @@ -22,7 +22,7 @@ pub struct BroadcastPeer<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] /// CHECK: The seeds constraint ensures that this is the correct address @@ -54,6 +54,7 @@ pub fn broadcast_peer(ctx: Context, args: BroadcastPeerArgs) -> R accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, )?; diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs index c9ba1e73c..0347e454c 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs @@ -31,7 +31,7 @@ pub struct ReceiveMessageInstructionData<'info> { pub config: NotPausedConfig<'info>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, vaa_body.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), vaa_body.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], constraint = peer.address == *vaa_body.as_vaa_body_bytes().emitter_address() @ NTTError::InvalidTransceiverPeer, bump = peer.bump, )] @@ -43,6 +43,7 @@ pub struct ReceiveMessageInstructionData<'info> { space = 8 + ValidatedTransceiverMessage::>>::INIT_SPACE, seeds = [ ValidatedTransceiverMessage::>>::SEED_PREFIX, + config.key().as_ref(), vaa_body.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref(), vaa_body.as_vaa_body_bytes().id(), ], @@ -117,7 +118,7 @@ pub struct ReceiveMessageAccount<'info> { pub config: NotPausedConfig<'info>, #[account( - seeds = [TransceiverPeer::SEED_PREFIX, message.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], + seeds = [TransceiverPeer::SEED_PREFIX, config.key().as_ref(), message.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref()], constraint = peer.address == *message.as_vaa_body_bytes().emitter_address() @ NTTError::InvalidTransceiverPeer, bump = peer.bump, )] @@ -143,6 +144,7 @@ pub struct ReceiveMessageAccount<'info> { space = 8 + ValidatedTransceiverMessage::>>::INIT_SPACE, seeds = [ ValidatedTransceiverMessage::>>::SEED_PREFIX, + config.key().as_ref(), message.as_vaa_body_bytes().emitter_chain().to_be_bytes().as_ref(), message.as_vaa_body_bytes().id(), ], diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs index cd085999a..b64765ddf 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs @@ -38,7 +38,7 @@ pub struct ReleaseOutbound<'info> { pub wormhole_message: UncheckedAccount<'info>, #[account( - seeds = [b"emitter"], + seeds = [b"emitter", config.key().as_ref()], bump )] // TODO: do we want to put anything in here? @@ -52,7 +52,7 @@ pub struct ReleaseOutbound<'info> { pub manager: Program<'info, ExampleNativeTokenTransfers>, #[account( - seeds = [OUTBOX_ITEM_SIGNER_SEED], + seeds = [OUTBOX_ITEM_SIGNER_SEED, config.key().as_ref()], bump )] /// CHECK: this PDA is used to sign the CPI into NTT manager program @@ -61,6 +61,7 @@ pub struct ReleaseOutbound<'info> { impl<'info> ReleaseOutbound<'info> { pub fn mark_outbox_item_as_released(&self, bump_seed: u8) -> Result { + let config_key = self.config.key(); let result = example_native_token_transfers::cpi::mark_outbox_item_as_released( CpiContext::new_with_signer( self.manager.to_account_info(), @@ -73,7 +74,7 @@ impl<'info> ReleaseOutbound<'info> { transceiver: self.transceiver.to_account_info(), }, // signer seeds - &[&[OUTBOX_ITEM_SIGNER_SEED, &[bump_seed]]], + &[&[OUTBOX_ITEM_SIGNER_SEED, config_key.as_ref(), &[bump_seed]]], ), )?; Ok(result.get()) @@ -100,10 +101,12 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs accs.outbox_item.reload()?; assert!(accs.outbox_item.released.get(accs.transceiver.id)?); + // v4: source manager identity is the instance's `config` pubkey, not the + // program ID. Matches the `recipient_ntt_manager` check in `redeem` and the + // `manager_address` field in `broadcast_id`. let message: TransceiverMessage> = TransceiverMessage::new( - // TODO: should we just put the ntt id here statically? - accs.outbox_item.to_account_info().owner.to_bytes(), + accs.config.key().to_bytes(), accs.outbox_item.recipient_ntt_manager, NttManagerMessage { id: accs.outbox_item.key().to_bytes(), @@ -125,6 +128,7 @@ pub fn release_outbound(ctx: Context, args: ReleaseOutboundArgs accs.wormhole_message.to_account_info(), accs.emitter.to_account_info(), ctx.bumps.emitter, + accs.config.key(), &message, )?; diff --git a/solana/programs/wormhole-governance/Cargo.toml b/solana/programs/wormhole-governance/Cargo.toml index e0433ea9b..9bce5d0cf 100644 --- a/solana/programs/wormhole-governance/Cargo.toml +++ b/solana/programs/wormhole-governance/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wormhole-governance" -version = "3.0.0" +version = "4.0.0" description = "Governance for programs controlled by Wormhole Guardians" edition = "2021" diff --git a/solana/tests/cargo/src/sdk/accounts/ntt.rs b/solana/tests/cargo/src/sdk/accounts/ntt.rs index 323a8652a..334c32571 100644 --- a/solana/tests/cargo/src/sdk/accounts/ntt.rs +++ b/solana/tests/cargo/src/sdk/accounts/ntt.rs @@ -66,7 +66,12 @@ pub trait NTTAccounts { hasher.update([*should_queue as u8]); let (session_authority, _) = Pubkey::find_program_address( - &[SESSION_AUTHORITY_SEED, sender.as_ref(), &hasher.finalize()], + &[ + SESSION_AUTHORITY_SEED, + self.config().as_ref(), + sender.as_ref(), + &hasher.finalize(), + ], &self.program(), ); session_authority From e980750f26ab6813f36e16c3fc99ac87787d1bf7 Mon Sep 17 00:00:00 2001 From: csongor Date: Thu, 7 May 2026 17:17:31 +0900 Subject: [PATCH 02/28] step 2: regenerate idl and update ts sdk --- cli/src/commands/add-chain.ts | 78 + cli/src/commands/set-mint-authority.ts | 3 +- cli/src/commands/upgrade.ts | 12 + cli/src/config-mgmt.ts | 15 +- cli/src/deployments.ts | 7 + cli/src/solana/deploy.ts | 149 + cli/src/upgradeBarriers.ts | 60 + sdk/__tests__/utils.ts | 45 +- .../instructions/admin/transfer_ownership.rs | 3 - .../src/instructions/initialize.rs | 3 - solana/tests/anchor/anchor.test.ts | 42 +- .../idl/4_0_0/json/dummy_transfer_hook.json | 110 + .../json/example_native_token_transfers.json | 2399 ++++++++ solana/ts/idl/4_0_0/json/ntt_quoter.json | 588 ++ solana/ts/idl/4_0_0/json/ntt_transceiver.json | 885 +++ .../idl/4_0_0/json/wormhole_governance.json | 76 + solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts | 221 + .../ts/example_native_token_transfers.ts | 4799 +++++++++++++++++ solana/ts/idl/4_0_0/ts/ntt_quoter.ts | 1177 ++++ solana/ts/idl/4_0_0/ts/ntt_transceiver.ts | 1771 ++++++ solana/ts/idl/4_0_0/ts/wormhole_governance.ts | 153 + solana/ts/lib/anchor-idl/4_0_0.ts | 28 + solana/ts/lib/anchor-idl/index.ts | 1 + solana/ts/lib/bindings.ts | 15 +- solana/ts/lib/ntt.ts | 258 +- solana/ts/sdk/ntt.ts | 219 +- 26 files changed, 12942 insertions(+), 175 deletions(-) create mode 100644 cli/src/upgradeBarriers.ts create mode 100644 solana/ts/idl/4_0_0/json/dummy_transfer_hook.json create mode 100644 solana/ts/idl/4_0_0/json/example_native_token_transfers.json create mode 100644 solana/ts/idl/4_0_0/json/ntt_quoter.json create mode 100644 solana/ts/idl/4_0_0/json/ntt_transceiver.json create mode 100644 solana/ts/idl/4_0_0/json/wormhole_governance.json create mode 100644 solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts create mode 100644 solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts create mode 100644 solana/ts/idl/4_0_0/ts/ntt_quoter.ts create mode 100644 solana/ts/idl/4_0_0/ts/ntt_transceiver.ts create mode 100644 solana/ts/idl/4_0_0/ts/wormhole_governance.ts create mode 100644 solana/ts/lib/anchor-idl/4_0_0.ts diff --git a/cli/src/commands/add-chain.ts b/cli/src/commands/add-chain.ts index 18939a547..e517ada1c 100644 --- a/cli/src/commands/add-chain.ts +++ b/cli/src/commands/add-chain.ts @@ -72,6 +72,16 @@ export function createAddChainCommand( type: "number" as const, default: 50000, }) + .option("instance-of", { + describe: + "(SVM v4 only) Skip program deploy and create a new instance under the existing program at this address. Mutually exclusive with --binary/--program-key.", + type: "string" as const, + }) + .option("instance-key", { + describe: + "(SVM v4 only) Path to the keypair file to use as the Instance account. Defaults to a freshly-generated keypair (written to `-instance.json` in the deployment dir).", + type: "string" as const, + }) .option("sui-gas-budget", { describe: "Gas budget for Sui deployment", type: "number" as const, @@ -290,6 +300,74 @@ export function createAddChainCommand( ); const ch = wh.getChain(chain); + // v4 multi-host path: --instance-of creates a fresh Instance + // under the existing program rather than deploying a new one. + const instanceOf = argv["instance-of"] as string | undefined; + if (instanceOf !== undefined) { + if (platform !== "Solana") { + console.error( + colors.red("--instance-of is only supported on Solana (v4)") + ); + process.exit(1); + } + if (argv["binary"] || argv["program-key"]) { + console.error( + colors.red( + "--instance-of is mutually exclusive with --binary / --program-key" + ) + ); + process.exit(1); + } + const resolvedVersion = version ?? "4.0.0"; + const resolvedMajor = parseInt( + resolvedVersion.split(".")[0] ?? "0", + 10 + ); + if (resolvedMajor < 4) { + console.error( + colors.red( + `--instance-of requires Solana NTT v4 or later (got ${resolvedVersion})` + ) + ); + process.exit(1); + } + if (!payerPath) { + console.error(colors.red("--payer is required for Solana")); + process.exit(1); + } + + const { addSolanaInstance } = await import("../solana/deploy"); + const { manager: deployedManager, instance } = await addSolanaInstance( + resolvedVersion, + mode, + ch as any, + token, + payerPath, + instanceOf, + argv["instance-key"] + ); + + const [config, _ctx, _ntt, decimals] = await pullChainConfig( + network, + deployedManager, + overrides + ); + config.instance = instance.toBase58(); + + console.log("token decimals:", colors.yellow(decimals)); + deployments.chains[chain] = config; + await configureInboundLimitsForNewChain( + deployments, + chain, + Boolean(argv["yes"]) + ); + fs.writeFileSync(path, JSON.stringify(deployments, null, 2)); + console.log( + `Added ${chain} to ${path} (instance ${instance.toBase58()} under program ${instanceOf})` + ); + return; + } + // TODO: make manager configurable const deployedManager = await deploy( version, diff --git a/cli/src/commands/set-mint-authority.ts b/cli/src/commands/set-mint-authority.ts index e1ce1da3f..766a92981 100644 --- a/cli/src/commands/set-mint-authority.ts +++ b/cli/src/commands/set-mint-authority.ts @@ -291,7 +291,8 @@ export function createSetMintAuthorityCommand( { currentAuthority: payerKeypair.publicKey, multisigTokenAuthority, - } + }, + solanaNtt.pdas ), ], recentBlockhash: latestBlockHash.blockhash, diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index 60a954ff2..7843ec0a8 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -15,6 +15,7 @@ import sui from "@wormhole-foundation/sdk/platforms/sui"; import { colors } from "../colors.js"; import { loadConfig, type Config } from "../deployments"; import type { SignerType } from "../signers/getSigner"; +import { canUpgrade } from "../upgradeBarriers"; import { validatePayerOption } from "../validation"; import { options } from "./shared"; @@ -119,6 +120,17 @@ export function createUpgradeCommand( process.exit(0); } + const upgradeCheck = canUpgrade(chain, currentVersion, toVersion); + if (!upgradeCheck.ok) { + console.error( + colors.red( + `error: cannot upgrade ${chain} from ${currentVersion} to ${toVersion} in place.` + ) + ); + console.error(upgradeCheck.reason); + process.exit(1); + } + console.log( `Upgrading ${chain} from version ${currentVersion} to ${ toVersion || "local version" diff --git a/cli/src/config-mgmt.ts b/cli/src/config-mgmt.ts index 189474d8a..d4d528fbf 100644 --- a/cli/src/config-mgmt.ts +++ b/cli/src/config-mgmt.ts @@ -101,12 +101,17 @@ export async function pushDeployment( const ix = dangerouslyTransferOwnershipInOneStep ? await NTT.createTransferOwnershipOneStepUncheckedInstruction( solanaNtt.program, - { owner, newOwner } + { owner, newOwner }, + solanaNtt.pdas ) - : await NTT.createTransferOwnershipInstruction(solanaNtt.program, { - owner, - newOwner, - }); + : await NTT.createTransferOwnershipInstruction( + solanaNtt.program, + { + owner, + newOwner, + }, + solanaNtt.pdas + ); const tx = new solanaWeb3.Transaction(); tx.add(ix); diff --git a/cli/src/deployments.ts b/cli/src/deployments.ts index b62d4157f..d2f0ebe01 100644 --- a/cli/src/deployments.ts +++ b/cli/src/deployments.ts @@ -9,6 +9,13 @@ export type ChainConfig = { owner: string; pauser?: string; manager: string; + /** + * v4-only (Solana): the keypair-created Instance pubkey under the shared + * `manager` program. Required for `version >= "4.0.0"`; absent for v3 + * singleton deployments. PDA derivation, the on-the-wire NTT manager + * identity, and SDK construction all key off this pubkey. + */ + instance?: string; token: string; transceivers: { threshold: number; diff --git a/cli/src/solana/deploy.ts b/cli/src/solana/deploy.ts index 5b9b12301..3429f94f7 100644 --- a/cli/src/solana/deploy.ts +++ b/cli/src/solana/deploy.ts @@ -477,6 +477,155 @@ export async function deploySvm( return { chain: ch.chain, address: toUniversal(ch.chain, providedProgramId) }; } +/** + * v4 only: create a new Instance under an existing Solana NTT program. + * + * Skips the program-deploy step entirely. Generates (or loads) an Instance + * keypair, constructs a `SolanaNtt` bound to the existing `programId` and the + * fresh instance pubkey, and submits the v4 `initialize` transaction signed by + * both the payer and the instance keypair. + * + * Returns the program id (as `manager` in the deployment.json sense) plus the + * instance pubkey; the caller is responsible for writing both into + * `ChainConfig`. + */ +export async function addSolanaInstance< + N extends Network, + C extends SolanaChains, +>( + version: string, + mode: Ntt.Mode, + ch: ChainContext, + token: string, + payer: string, + programId: string, + instanceKeyPath?: string, + outboundLimit: bigint = 100_000_000n +): Promise<{ chain: C; manager: ChainAddress; instance: PublicKey }> { + // Load or generate the instance keypair. The keypair's pubkey is the new + // Instance account address; in v4 it's also the on-the-wire NTT manager + // identity for this deployment. + let instanceKeypair: Keypair; + if (instanceKeyPath) { + instanceKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(fs.readFileSync(instanceKeyPath).toString())) + ); + } else { + instanceKeypair = Keypair.generate(); + const generatedPath = `${ch.chain}-instance.json`; + fs.writeFileSync( + generatedPath, + JSON.stringify(Array.from(instanceKeypair.secretKey)) + ); + console.log( + `Generated instance keypair at ${generatedPath} (pubkey: ${instanceKeypair.publicKey.toBase58()})` + ); + } + + const payerKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(fs.readFileSync(payer).toString())) + ); + + // The Wormhole transceiver address in v4 is the instance-scoped emitter PDA. + const emitter = NTT.transceiverPdas( + new PublicKey(programId), + instanceKeypair.publicKey + ) + .emitterAccount() + .toBase58(); + + const connection: Connection = await ch.getRpc(); + const ntt: SolanaNtt = new SolanaNtt( + ch.network, + ch.chain, + connection, + { + ...ch.config.contracts, + ntt: { + manager: programId, + token, + instance: instanceKeypair.publicKey.toBase58(), + transceiver: { wormhole: emitter }, + }, + }, + version + ); + + // Sanity check the mint's mint_authority matches the per-instance token_authority + // for burning mode (mirrors the program-side constraint in v4 initialize). + const tokenMint = new PublicKey(token); + const mintInfo = await connection.getAccountInfo(tokenMint); + if (mintInfo === null) { + console.error(`Mint ${token} not found`); + process.exit(1); + } + const tokenProgram = mintInfo.owner; + const mint = await spl.getMint( + connection, + tokenMint, + "finalized", + tokenProgram + ); + const tokenAuthority = ntt.pdas.tokenAuthority(); + if ( + mode === "burning" && + !mint.mintAuthority?.equals(tokenAuthority) && + !(await checkSvmValidSplMultisig( + connection, + mint.mintAuthority!, + tokenProgram, + tokenAuthority + )) + ) { + console.error( + `In burning mode, the mint authority must be either the token_authority PDA or a 1-of-N SPL Multisig containing it.\n` + + ` mint authority: ${mint.mintAuthority?.toBase58()}\n` + + ` expected token_authority (per-instance): ${tokenAuthority.toBase58()}` + ); + process.exit(1); + } + + const initTxs = ntt.initialize( + toUniversal(ch.chain, payerKeypair.publicKey.toBase58()), + { + mint: tokenMint, + mode, + outboundLimit, + instance: instanceKeypair, + ...(mode === "burning" && + !mint.mintAuthority!.equals(tokenAuthority) && { + multisigTokenAuthority: mint.mintAuthority!, + }), + } + ); + + const signer = await getSigner( + ch, + "privateKey", + encoding.b58.encode(payerKeypair.secretKey) + ); + + try { + await signSendWait(ch, initTxs, signer.signer); + } catch (e: any) { + console.error(e.logs); + throw e; + } + + // After initialize, register the Wormhole transceiver under the new instance. + try { + await registerSolanaTransceiver(ntt as any, ch, signer); + } catch (e: any) { + console.error(e.logs); + } + + return { + chain: ch.chain, + manager: { chain: ch.chain, address: toUniversal(ch.chain, programId) }, + instance: instanceKeypair.publicKey, + }; +} + export async function upgradeSolana( pwd: string, version: string | null, diff --git a/cli/src/upgradeBarriers.ts b/cli/src/upgradeBarriers.ts new file mode 100644 index 000000000..af38e6714 --- /dev/null +++ b/cli/src/upgradeBarriers.ts @@ -0,0 +1,60 @@ +import type { Chain } from "@wormhole-foundation/sdk"; + +/** + * A registered breaking-change barrier between two major versions of the NTT + * deployment for a given chain. An in-place upgrade is blocked iff the source + * version's major is below the barrier and the target version's major is at or + * above it. + * + * Add an entry here whenever a major version introduces an on-chain layout or + * wire-format change that an existing deployment cannot be upgraded into. The + * `reason` string is surfaced verbatim when blocking an upgrade attempt. + */ +export type UpgradeBarrier = { + chain: Chain; + breakingMajor: number; + reason: string; +}; + +export const UPGRADE_BARRIERS: UpgradeBarrier[] = [ + { + chain: "Solana", + breakingMajor: 4, + reason: + "Solana NTT v4 changes the on-chain account layout (PDAs are scoped by " + + "instance ID, the Instance account is keypair-created instead of a PDA, " + + "and the on-the-wire NTT manager identity is the Instance pubkey rather " + + "than the program ID). v3 deployments cannot be upgraded in place. " + + "Deploy fresh v4 with `ntt add-chain Solana --version 4.0.0` instead.", + }, +]; + +export type UpgradeCheck = { ok: true } | { ok: false; reason: string }; + +function parseMajor(version: string): number { + return parseInt(version.split(".")[0] ?? "0", 10); +} + +/** + * Returns whether the proposed `fromVersion → toVersion` upgrade on `chain` is + * permitted in place. A registered barrier between the two majors blocks it. + * + * Local-version (`toVersion === null`) upgrades are always permitted; the + * caller is asserting they know what they're doing. + */ +export function canUpgrade( + chain: Chain, + fromVersion: string, + toVersion: string | null +): UpgradeCheck { + if (toVersion === null) return { ok: true }; + const fromMajor = parseMajor(fromVersion); + const toMajor = parseMajor(toVersion); + for (const barrier of UPGRADE_BARRIERS) { + if (barrier.chain !== chain) continue; + if (fromMajor < barrier.breakingMajor && toMajor >= barrier.breakingMajor) { + return { ok: false, reason: barrier.reason }; + } + } + return { ok: true }; +} diff --git a/sdk/__tests__/utils.ts b/sdk/__tests__/utils.ts index 72df8b714..3435ce6b0 100644 --- a/sdk/__tests__/utils.ts +++ b/sdk/__tests__/utils.ts @@ -125,9 +125,17 @@ export async function link(chainInfos: Ctx[], accountantPrivateKey: string) { const hubChain = hub.context.chain; // handle Solana emitter account case separately + const hubContracts = hub.contracts! as Ntt.Contracts & { + instance?: string; + }; const whTransceiver = chainToPlatform(hubChain) === "Solana" - ? NTT.transceiverPdas(hub.contracts!.transceiver["wormhole"]!) + ? NTT.transceiverPdas( + hub.contracts!.transceiver["wormhole"]!, + hubContracts.instance + ? new PublicKey(hubContracts.instance) + : undefined + ) .emitterAccount() .toString() : hub.contracts!.transceiver["wormhole"]!; @@ -514,14 +522,20 @@ async function deploySolana(ctx: Ctx): Promise { ? "NTTTransceiver22222222222222222222222222222" : "NTTTransceiver11111111111111111111111111111"; + // v4: each deployment is keyed by a per-instance Config keypair. The pubkey + // is the on-the-wire NTT manager identity for this deployment and the seed + // scope for every per-instance PDA. + const instanceKeypair = web3.Keypair.generate(); + ctx.contracts = { token: mint.toBase58(), manager: managerProgramId, + instance: instanceKeypair.publicKey.toBase58(), transceiver: { wormhole: transceiverProgramId, }, svmShims: {}, - }; + } as Ntt.Contracts; const manager = (await getNtt(ctx)) as SolanaNtt; @@ -547,6 +561,7 @@ async function deploySolana(ctx: Ctx): Promise { mint, outboundLimit: 1000000000n, mode: ctx.mode, + instance: instanceKeypair, }); await signSendWait(ctx.context, initTxs, signer); console.log("Initialized ntt at", manager.program.programId.toString()); @@ -582,24 +597,40 @@ async function deploySolana(ctx: Ctx): Promise { wormhole: whTransceiver.programId.toString(), }, manager: manager.program.programId.toString(), + instance: instanceKeypair.publicKey.toBase58(), token: mint.toString(), svmShims: {}, - }, + } as Ntt.Contracts, }; } async function setupPeer(targetCtx: Ctx, peerCtx: Ctx) { const target = targetCtx.context; const peer = peerCtx.context; + const peerContracts = peerCtx.contracts! as Ntt.Contracts & { + instance?: string; + }; const { manager, transceiver: { wormhole: transceiver }, - } = peerCtx.contracts!; - - const peerManager = Wormhole.chainAddress(peer.chain, manager); + instance, + } = peerContracts; + + // v4 multi-host on Solana puts the per-instance Config pubkey on the wire + // as the NTT manager identity (not the program ID), and scopes the emitter + // PDA by that same Config pubkey. EVM/Sui peers must register against + // those instance-scoped values. + const isPeerSolanaV4 = chainToPlatform(peer.chain) === "Solana" && instance; + const peerManagerAddress = isPeerSolanaV4 ? instance! : manager; + const peerManager = Wormhole.chainAddress(peer.chain, peerManagerAddress); const whTransceiver = chainToPlatform(peer.chain) === "Solana" - ? NTT.transceiverPdas(transceiver!).emitterAccount().toString() + ? NTT.transceiverPdas( + transceiver!, + instance ? new PublicKey(instance) : undefined + ) + .emitterAccount() + .toString() : transceiver!; const peerTransceiver = Wormhole.chainAddress(peer.chain, whTransceiver); diff --git a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs index ee38ffe7b..7c81c6180 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/admin/transfer_ownership.rs @@ -1,8 +1,5 @@ use anchor_lang::prelude::*; -#[cfg(feature = "idl-build")] -use crate::messages::Hack; - use crate::{config::Config, error::NTTError}; // * Transfer ownership diff --git a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs index f9188d1dd..001c7dd9d 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs @@ -2,9 +2,6 @@ use anchor_lang::prelude::*; use anchor_spl::{associated_token::AssociatedToken, token_interface}; use ntt_messages::{chain_id::ChainId, mode::Mode}; -#[cfg(feature = "idl-build")] -use crate::messages::Hack; - use crate::{ bitmap::Bitmap, config::Config, diff --git a/solana/tests/anchor/anchor.test.ts b/solana/tests/anchor/anchor.test.ts index 7e340d342..864d2f17e 100644 --- a/solana/tests/anchor/anchor.test.ts +++ b/solana/tests/anchor/anchor.test.ts @@ -592,10 +592,16 @@ describe("example-native-token-transfers", () => { }); test("It initializes from constructor", async () => { - const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { - ...ctx.config.contracts, - ...{ ntt: overrides["Solana"] }, - }); + const ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ...{ ntt: overrides["Solana"] }, + }, + VERSION + ); expect(ntt).toBeTruthy(); }); @@ -616,18 +622,30 @@ describe("example-native-token-transfers", () => { .emitterAccount() .toBase58(); - const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { - ...ctx.config.contracts, - ...{ ntt: overrideEmitter }, - }); + const ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ...{ ntt: overrideEmitter }, + }, + VERSION + ); expect(ntt).toBeTruthy(); }); test("It gets the correct transceiver type", async () => { - const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { - ...ctx.config.contracts, - ...{ ntt: overrides["Solana"] }, - }); + const ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ...{ ntt: overrides["Solana"] }, + }, + VERSION + ); const whTransceiver = await ntt.getWormholeTransceiver(); expect(whTransceiver).toBeTruthy(); const transceiverType = diff --git a/solana/ts/idl/4_0_0/json/dummy_transfer_hook.json b/solana/ts/idl/4_0_0/json/dummy_transfer_hook.json new file mode 100644 index 000000000..21ade4d20 --- /dev/null +++ b/solana/ts/idl/4_0_0/json/dummy_transfer_hook.json @@ -0,0 +1,110 @@ +{ + "version": "3.0.0", + "name": "dummy_transfer_hook", + "instructions": [ + { + "name": "initializeExtraAccountMetaList", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "extraAccountMetaList", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferHook", + "accounts": [ + { + "name": "sourceToken", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "destinationToken", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "extraAccountMetaList", + "isMut": false, + "isSigner": false + }, + { + "name": "dummyAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "computes and the on-chain code correctly passes on the PDA." + ] + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "Counter", + "type": { + "kind": "struct", + "fields": [ + { + "name": "count", + "type": "u64" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/solana/ts/idl/4_0_0/json/example_native_token_transfers.json b/solana/ts/idl/4_0_0/json/example_native_token_transfers.json new file mode 100644 index 000000000..b4ed42ca6 --- /dev/null +++ b/solana/ts/idl/4_0_0/json/example_native_token_transfers.json @@ -0,0 +1,2399 @@ +{ + "version": "4.0.0", + "name": "example_native_token_transfers", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true, + "docs": [ + "The owner of the new instance. Distinct from the program's upgrade", + "authority — see the v4 trust-model note in the README." + ] + }, + { + "name": "config", + "isMut": true, + "isSigner": true, + "docs": [ + "The instance account itself. Caller-provided keypair, must sign." + ] + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "Per-instance token authority lets each instance manage its own mint independently.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "The custody account that holds tokens in locking mode and temporarily", + "holds tokens in burning mode.", + "function if the token account has already been created." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false, + "docs": [ + "associated token account for the given mint." + ] + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, + { + "name": "initializeLut", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "lutAddress", + "isMut": true, + "isSigner": false + }, + { + "name": "lut", + "isMut": true, + "isSigner": false + }, + { + "name": "lutProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "entries", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ] + } + ], + "args": [ + { + "name": "recentSlot", + "type": "u64" + } + ] + }, + { + "name": "version", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "transferBurn", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "transferLock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "redeem", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": false, + "isSigner": false, + "docs": [ + "`Account` and `owner` constraints are mutually-exclusive" + ] + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false, + "docs": [ + "NOTE: This account is content-addressed (PDA seeded by the message hash).", + "This is because in a multi-transceiver configuration, the different", + "transceivers \"vote\" on messages (by delivering them). By making the inbox", + "items content-addressed, we can ensure that disagreeing votes don't", + "interfere with each other.", + "On the first call to [`redeem()`], [`InboxItem`] will be allocated and initialized with", + "default values.", + "On subsequent calls, we want to modify the `InboxItem` by \"voting\" on it. Therefore the", + "program should not fail which would occur when using the `init` constraint.", + "The [`InboxItem::init`] field is used to guard against malicious or accidental modification", + "InboxItem fields that should remain constant." + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RedeemArgs" + } + } + ] + }, + { + "name": "releaseInboundMint", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "releaseInboundUnlock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "transferOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferOwnershipOneStepUnchecked", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "claimOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthorityFromMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthorityOneStepUnchecked", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "revertTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthorityToMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setPaused", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "pause", + "type": "bool" + } + ] + }, + { + "name": "setPeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetPeerArgs" + } + } + ] + }, + { + "name": "registerTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false, + "docs": [ + "used here that wraps the Transceiver account type." + ] + }, + { + "name": "registeredTransceiver", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deregisterTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "registeredTransceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setOutboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetOutboundLimitArgs" + } + } + ] + }, + { + "name": "setInboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetInboundLimitArgs" + } + } + ] + }, + { + "name": "markOutboxItemAsReleased", + "accounts": [ + { + "name": "signer", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [], + "returns": "bool" + }, + { + "name": "setThreshold", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "threshold", + "type": "u8" + } + ] + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessage", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "Config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "LUT", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": "publicKey" + } + ] + } + }, + { + "name": "NttManagerPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "tokenDecimals", + "type": "u8" + } + ] + } + }, + { + "name": "PendingTokenAuthority", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "pendingAuthority", + "type": "publicKey" + }, + { + "name": "rentPayer", + "type": "publicKey" + } + ] + } + }, + { + "name": "InboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init", + "type": "bool" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientAddress", + "type": "publicKey" + }, + { + "name": "votes", + "type": { + "defined": "Bitmap" + } + }, + { + "name": "releaseStatus", + "type": { + "defined": "ReleaseStatus" + } + } + ] + } + }, + { + "name": "InboxRateLimit", + "docs": [ + "Inbound rate limit per chain.", + "SECURITY: must check the PDA (since there are multiple PDAs, namely one for each chain.)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "OutboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "OutboxRateLimit", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "RegisteredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "TransceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "SetInboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + }, + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + } + ] + } + }, + { + "name": "SetOutboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + } + ] + } + }, + { + "name": "SetPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "tokenDecimals", + "docs": [ + "The token decimals on the peer chain." + ], + "type": "u8" + } + ] + } + }, + { + "name": "InitializeArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "mode", + "type": { + "defined": "Mode" + } + } + ] + } + }, + { + "name": "RedeemArgs", + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "ReleaseInboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertWhenNotReady", + "type": "bool" + } + ] + } + }, + { + "name": "TransferArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "shouldQueue", + "type": "bool" + } + ] + } + }, + { + "name": "ReleaseStatus", + "docs": [ + "The status of an InboxItem. This determines whether the tokens are minted/unlocked to the recipient. As", + "such, this must be used as a state machine that moves forward in a linear manner. A state", + "should never \"move backward\" to a previous state (e.g. should never move from `Released` to", + "`ReleaseAfter`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotApproved" + }, + { + "name": "ReleaseAfter", + "fields": [ + "i64" + ] + }, + { + "name": "Released" + } + ] + } + }, + { + "name": "RateLimitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "docs": [ + "The maximum capacity of the rate limiter." + ], + "type": "u64" + }, + { + "name": "capacityAtLastTx", + "docs": [ + "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." + ], + "type": "u64" + }, + { + "name": "lastTxTimestamp", + "docs": [ + "The timestamp of the last transaction that counted towards the current", + "capacity. Transactions that exceeded the capacity do not count, they are", + "just delayed." + ], + "type": "i64" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "CantReleaseYet", + "msg": "CantReleaseYet" + }, + { + "code": 6001, + "name": "InvalidPendingOwner", + "msg": "InvalidPendingOwner" + }, + { + "code": 6002, + "name": "InvalidChainId", + "msg": "InvalidChainId" + }, + { + "code": 6003, + "name": "InvalidRecipientAddress", + "msg": "InvalidRecipientAddress" + }, + { + "code": 6004, + "name": "InvalidTransceiverPeer", + "msg": "InvalidTransceiverPeer" + }, + { + "code": 6005, + "name": "InvalidNttManagerPeer", + "msg": "InvalidNttManagerPeer" + }, + { + "code": 6006, + "name": "InvalidRecipientNttManager", + "msg": "InvalidRecipientNttManager" + }, + { + "code": 6007, + "name": "TransferAlreadyRedeemed", + "msg": "TransferAlreadyRedeemed" + }, + { + "code": 6008, + "name": "TransferCannotBeRedeemed", + "msg": "TransferCannotBeRedeemed" + }, + { + "code": 6009, + "name": "TransferNotApproved", + "msg": "TransferNotApproved" + }, + { + "code": 6010, + "name": "MessageAlreadySent", + "msg": "MessageAlreadySent" + }, + { + "code": 6011, + "name": "InvalidMode", + "msg": "InvalidMode" + }, + { + "code": 6012, + "name": "InvalidMintAuthority", + "msg": "InvalidMintAuthority" + }, + { + "code": 6013, + "name": "TransferExceedsRateLimit", + "msg": "TransferExceedsRateLimit" + }, + { + "code": 6014, + "name": "Paused", + "msg": "Paused" + }, + { + "code": 6015, + "name": "DisabledTransceiver", + "msg": "DisabledTransceiver" + }, + { + "code": 6016, + "name": "InvalidDeployer", + "msg": "InvalidDeployer" + }, + { + "code": 6017, + "name": "BadAmountAfterTransfer", + "msg": "BadAmountAfterTransfer" + }, + { + "code": 6018, + "name": "BadAmountAfterBurn", + "msg": "BadAmountAfterBurn" + }, + { + "code": 6019, + "name": "ZeroThreshold", + "msg": "ZeroThreshold" + }, + { + "code": 6020, + "name": "OverflowExponent", + "msg": "OverflowExponent" + }, + { + "code": 6021, + "name": "OverflowScaledAmount", + "msg": "OverflowScaledAmount" + }, + { + "code": 6022, + "name": "BitmapIndexOutOfBounds", + "msg": "BitmapIndexOutOfBounds" + }, + { + "code": 6023, + "name": "NoRegisteredTransceivers", + "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "NotPaused", + "msg": "NotPaused" + }, + { + "code": 6025, + "name": "InvalidPendingTokenAuthority", + "msg": "InvalidPendingTokenAuthority" + }, + { + "code": 6026, + "name": "IncorrectRentPayer", + "msg": "IncorrectRentPayer" + }, + { + "code": 6027, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" + }, + { + "code": 6028, + "name": "ThresholdTooHigh", + "msg": "ThresholdTooHigh" + }, + { + "code": 6029, + "name": "InvalidTransceiverProgram", + "msg": "InvalidTransceiverProgram" + }, + { + "code": 6030, + "name": "InvalidOutboxItem", + "msg": "InvalidOutboxItem" + } + ] +} diff --git a/solana/ts/idl/4_0_0/json/ntt_quoter.json b/solana/ts/idl/4_0_0/json/ntt_quoter.json new file mode 100644 index 000000000..ecf37793e --- /dev/null +++ b/solana/ts/idl/4_0_0/json/ntt_quoter.json @@ -0,0 +1,588 @@ +{ + "version": "3.0.0", + "name": "ntt_quoter", + "instructions": [ + { + "name": "requestRelay", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": false, + "isSigner": false, + "docs": [ + "and checking the release constraint into a single function" + ] + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RequestRelayArgs" + } + } + ] + }, + { + "name": "closeRelay", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": true, + "isSigner": false + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initialize", + "accounts": [ + { + "name": "owner", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + }, + { + "name": "programData", + "isMut": true, + "isSigner": false, + "docs": [ + "We use the program data to make sure this owner is the upgrade authority (the true owner,", + "who deployed this program)." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setAssistant", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "assistant", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [] + }, + { + "name": "setFeeRecipient", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "registerChain", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterChainArgs" + } + } + ] + }, + { + "name": "registerNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterNttArgs" + } + } + ] + }, + { + "name": "deregisterNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "DeregisterNttArgs" + } + } + ] + }, + { + "name": "updateSolPrice", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateSolPriceArgs" + } + } + ] + }, + { + "name": "updateChainPrices", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainPricesArgs" + } + } + ] + }, + { + "name": "updateChainParams", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainParamsArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "Instance", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "assistant", + "type": "publicKey" + }, + { + "name": "feeRecipient", + "type": "publicKey" + }, + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "RegisteredChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + }, + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "RegisteredNtt", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "RelayRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "requestedGasDropoff", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "RegisterChainArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "RegisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "DeregisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + } + ] + } + }, + { + "name": "RequestRelayArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "gasDropoff", + "type": "u64" + }, + { + "name": "maxFee", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateSolPriceArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainPricesArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainParamsArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + } + ] + } + } + ], + "errors": [ + { + "code": 6001, + "name": "ExceedsUserMaxFee", + "msg": "Relay fees exceeds specified max" + }, + { + "code": 6002, + "name": "ExceedsMaxGasDropoff", + "msg": "Requested gas dropoff exceeds max allowed for chain" + }, + { + "code": 6003, + "name": "InvalidFeeRecipient", + "msg": "The specified fee recipient does not match the address in the instance accound" + }, + { + "code": 6004, + "name": "RelayingToChainDisabled", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6005, + "name": "OutboxItemNotReleased", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6006, + "name": "ScalingOverflow", + "msg": "Scaled value exceeds u64::MAX" + }, + { + "code": 6007, + "name": "DivByZero", + "msg": "Cannot divide by zero" + }, + { + "code": 6257, + "name": "FeeRecipientCannotBeDefault", + "msg": "The fee recipient cannot be the default address (0x0)" + }, + { + "code": 6258, + "name": "NotAuthorized", + "msg": "Must be owner or assistant" + }, + { + "code": 6259, + "name": "PriceCannotBeZero", + "msg": "The price cannot be zero" + } + ] +} diff --git a/solana/ts/idl/4_0_0/json/ntt_transceiver.json b/solana/ts/idl/4_0_0/json/ntt_transceiver.json new file mode 100644 index 000000000..793474192 --- /dev/null +++ b/solana/ts/idl/4_0_0/json/ntt_transceiver.json @@ -0,0 +1,885 @@ +{ + "version": "3.0.0", + "name": "ntt_transceiver", + "instructions": [ + { + "name": "transceiverType", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessageInstructionData", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "vaaBody", + "type": { + "defined": "VaaBodyData" + } + } + ] + }, + { + "name": "postUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "PostUnverifiedMessageAccountArgs" + } + } + ] + }, + { + "name": "closeUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "receiveWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "manager", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItemSigner", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "Config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "OutboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "RegisteredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "TransceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "VaaBody", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + }, + { + "name": "VaaBodyData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "PostUnverifiedMessageAccountArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "seed", + "type": "u64" + }, + { + "name": "offset", + "type": "u32" + }, + { + "name": "chunk", + "type": "bytes" + }, + { + "name": "messageSize", + "type": "u32" + } + ] + } + } + ] +} diff --git a/solana/ts/idl/4_0_0/json/wormhole_governance.json b/solana/ts/idl/4_0_0/json/wormhole_governance.json new file mode 100644 index 000000000..f6f61f6ec --- /dev/null +++ b/solana/ts/idl/4_0_0/json/wormhole_governance.json @@ -0,0 +1,76 @@ +{ + "version": "3.0.0", + "name": "wormhole_governance", + "instructions": [ + { + "name": "governance", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "governance", + "isMut": true, + "isSigner": false, + "docs": [ + "governed program. This account is validated by Wormhole, not this program." + ] + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "replay", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "ReplayProtection", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidGovernanceChain", + "msg": "InvalidGovernanceChain" + }, + { + "code": 6001, + "name": "InvalidGovernanceEmitter", + "msg": "InvalidGovernanceEmitter" + }, + { + "code": 6002, + "name": "InvalidGovernanceProgram", + "msg": "InvalidGovernanceProgram" + } + ] +} \ No newline at end of file diff --git a/solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts b/solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts new file mode 100644 index 000000000..1c494bbd4 --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/dummy_transfer_hook.ts @@ -0,0 +1,221 @@ +export type DummyTransferHook = { + "version": "3.0.0", + "name": "dummy_transfer_hook", + "instructions": [ + { + "name": "initializeExtraAccountMetaList", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "extraAccountMetaList", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferHook", + "accounts": [ + { + "name": "sourceToken", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "destinationToken", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "extraAccountMetaList", + "isMut": false, + "isSigner": false + }, + { + "name": "dummyAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "computes and the on-chain code correctly passes on the PDA." + ] + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "counter", + "type": { + "kind": "struct", + "fields": [ + { + "name": "count", + "type": "u64" + } + ] + } + } + ] +} +export const IDL: DummyTransferHook = { + "version": "3.0.0", + "name": "dummy_transfer_hook", + "instructions": [ + { + "name": "initializeExtraAccountMetaList", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "extraAccountMetaList", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferHook", + "accounts": [ + { + "name": "sourceToken", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "destinationToken", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "extraAccountMetaList", + "isMut": false, + "isSigner": false + }, + { + "name": "dummyAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "computes and the on-chain code correctly passes on the PDA." + ] + }, + { + "name": "counter", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "counter", + "type": { + "kind": "struct", + "fields": [ + { + "name": "count", + "type": "u64" + } + ] + } + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts b/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts new file mode 100644 index 000000000..13d6bfee0 --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts @@ -0,0 +1,4799 @@ +export type ExampleNativeTokenTransfers = { + "version": "4.0.0", + "name": "example_native_token_transfers", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true, + "docs": [ + "The owner of the new instance. Distinct from the program's upgrade", + "authority — see the v4 trust-model note in the README." + ] + }, + { + "name": "config", + "isMut": true, + "isSigner": true, + "docs": [ + "The instance account itself. Caller-provided keypair, must sign." + ] + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "Per-instance token authority lets each instance manage its own mint independently.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "The custody account that holds tokens in locking mode and temporarily", + "holds tokens in burning mode.", + "function if the token account has already been created." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false, + "docs": [ + "associated token account for the given mint." + ] + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, + { + "name": "initializeLut", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "lutAddress", + "isMut": true, + "isSigner": false + }, + { + "name": "lut", + "isMut": true, + "isSigner": false + }, + { + "name": "lutProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "entries", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ] + } + ], + "args": [ + { + "name": "recentSlot", + "type": "u64" + } + ] + }, + { + "name": "version", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "transferBurn", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "transferLock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "redeem", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": false, + "isSigner": false, + "docs": [ + "`Account` and `owner` constraints are mutually-exclusive" + ] + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false, + "docs": [ + "NOTE: This account is content-addressed (PDA seeded by the message hash).", + "This is because in a multi-transceiver configuration, the different", + "transceivers \"vote\" on messages (by delivering them). By making the inbox", + "items content-addressed, we can ensure that disagreeing votes don't", + "interfere with each other.", + "On the first call to [`redeem()`], [`InboxItem`] will be allocated and initialized with", + "default values.", + "On subsequent calls, we want to modify the `InboxItem` by \"voting\" on it. Therefore the", + "program should not fail which would occur when using the `init` constraint.", + "The [`InboxItem::init`] field is used to guard against malicious or accidental modification", + "InboxItem fields that should remain constant." + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RedeemArgs" + } + } + ] + }, + { + "name": "releaseInboundMint", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "releaseInboundUnlock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "transferOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferOwnershipOneStepUnchecked", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "claimOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthorityFromMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthorityOneStepUnchecked", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "revertTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthorityToMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setPaused", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "pause", + "type": "bool" + } + ] + }, + { + "name": "setPeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetPeerArgs" + } + } + ] + }, + { + "name": "registerTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false, + "docs": [ + "used here that wraps the Transceiver account type." + ] + }, + { + "name": "registeredTransceiver", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deregisterTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "registeredTransceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setOutboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetOutboundLimitArgs" + } + } + ] + }, + { + "name": "setInboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetInboundLimitArgs" + } + } + ] + }, + { + "name": "markOutboxItemAsReleased", + "accounts": [ + { + "name": "signer", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [], + "returns": "bool" + }, + { + "name": "setThreshold", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "threshold", + "type": "u8" + } + ] + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessage", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "LUT", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": "publicKey" + } + ] + } + }, + { + "name": "nttManagerPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "tokenDecimals", + "type": "u8" + } + ] + } + }, + { + "name": "pendingTokenAuthority", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "pendingAuthority", + "type": "publicKey" + }, + { + "name": "rentPayer", + "type": "publicKey" + } + ] + } + }, + { + "name": "inboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init", + "type": "bool" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientAddress", + "type": "publicKey" + }, + { + "name": "votes", + "type": { + "defined": "Bitmap" + } + }, + { + "name": "releaseStatus", + "type": { + "defined": "ReleaseStatus" + } + } + ] + } + }, + { + "name": "inboxRateLimit", + "docs": [ + "Inbound rate limit per chain.", + "SECURITY: must check the PDA (since there are multiple PDAs, namely one for each chain.)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "outboxRateLimit", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "SetInboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + }, + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + } + ] + } + }, + { + "name": "SetOutboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + } + ] + } + }, + { + "name": "SetPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "tokenDecimals", + "docs": [ + "The token decimals on the peer chain." + ], + "type": "u8" + } + ] + } + }, + { + "name": "InitializeArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "mode", + "type": { + "defined": "Mode" + } + } + ] + } + }, + { + "name": "RedeemArgs", + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "ReleaseInboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertWhenNotReady", + "type": "bool" + } + ] + } + }, + { + "name": "TransferArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "shouldQueue", + "type": "bool" + } + ] + } + }, + { + "name": "ReleaseStatus", + "docs": [ + "The status of an InboxItem. This determines whether the tokens are minted/unlocked to the recipient. As", + "such, this must be used as a state machine that moves forward in a linear manner. A state", + "should never \"move backward\" to a previous state (e.g. should never move from `Released` to", + "`ReleaseAfter`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotApproved" + }, + { + "name": "ReleaseAfter", + "fields": [ + "i64" + ] + }, + { + "name": "Released" + } + ] + } + }, + { + "name": "RateLimitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "docs": [ + "The maximum capacity of the rate limiter." + ], + "type": "u64" + }, + { + "name": "capacityAtLastTx", + "docs": [ + "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." + ], + "type": "u64" + }, + { + "name": "lastTxTimestamp", + "docs": [ + "The timestamp of the last transaction that counted towards the current", + "capacity. Transactions that exceeded the capacity do not count, they are", + "just delayed." + ], + "type": "i64" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "CantReleaseYet", + "msg": "CantReleaseYet" + }, + { + "code": 6001, + "name": "InvalidPendingOwner", + "msg": "InvalidPendingOwner" + }, + { + "code": 6002, + "name": "InvalidChainId", + "msg": "InvalidChainId" + }, + { + "code": 6003, + "name": "InvalidRecipientAddress", + "msg": "InvalidRecipientAddress" + }, + { + "code": 6004, + "name": "InvalidTransceiverPeer", + "msg": "InvalidTransceiverPeer" + }, + { + "code": 6005, + "name": "InvalidNttManagerPeer", + "msg": "InvalidNttManagerPeer" + }, + { + "code": 6006, + "name": "InvalidRecipientNttManager", + "msg": "InvalidRecipientNttManager" + }, + { + "code": 6007, + "name": "TransferAlreadyRedeemed", + "msg": "TransferAlreadyRedeemed" + }, + { + "code": 6008, + "name": "TransferCannotBeRedeemed", + "msg": "TransferCannotBeRedeemed" + }, + { + "code": 6009, + "name": "TransferNotApproved", + "msg": "TransferNotApproved" + }, + { + "code": 6010, + "name": "MessageAlreadySent", + "msg": "MessageAlreadySent" + }, + { + "code": 6011, + "name": "InvalidMode", + "msg": "InvalidMode" + }, + { + "code": 6012, + "name": "InvalidMintAuthority", + "msg": "InvalidMintAuthority" + }, + { + "code": 6013, + "name": "TransferExceedsRateLimit", + "msg": "TransferExceedsRateLimit" + }, + { + "code": 6014, + "name": "Paused", + "msg": "Paused" + }, + { + "code": 6015, + "name": "DisabledTransceiver", + "msg": "DisabledTransceiver" + }, + { + "code": 6016, + "name": "InvalidDeployer", + "msg": "InvalidDeployer" + }, + { + "code": 6017, + "name": "BadAmountAfterTransfer", + "msg": "BadAmountAfterTransfer" + }, + { + "code": 6018, + "name": "BadAmountAfterBurn", + "msg": "BadAmountAfterBurn" + }, + { + "code": 6019, + "name": "ZeroThreshold", + "msg": "ZeroThreshold" + }, + { + "code": 6020, + "name": "OverflowExponent", + "msg": "OverflowExponent" + }, + { + "code": 6021, + "name": "OverflowScaledAmount", + "msg": "OverflowScaledAmount" + }, + { + "code": 6022, + "name": "BitmapIndexOutOfBounds", + "msg": "BitmapIndexOutOfBounds" + }, + { + "code": 6023, + "name": "NoRegisteredTransceivers", + "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "NotPaused", + "msg": "NotPaused" + }, + { + "code": 6025, + "name": "InvalidPendingTokenAuthority", + "msg": "InvalidPendingTokenAuthority" + }, + { + "code": 6026, + "name": "IncorrectRentPayer", + "msg": "IncorrectRentPayer" + }, + { + "code": 6027, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" + }, + { + "code": 6028, + "name": "ThresholdTooHigh", + "msg": "ThresholdTooHigh" + }, + { + "code": 6029, + "name": "InvalidTransceiverProgram", + "msg": "InvalidTransceiverProgram" + }, + { + "code": 6030, + "name": "InvalidOutboxItem", + "msg": "InvalidOutboxItem" + } + ] +} +export const IDL: ExampleNativeTokenTransfers = { + "version": "4.0.0", + "name": "example_native_token_transfers", + "instructions": [ + { + "name": "initialize", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true, + "docs": [ + "The owner of the new instance. Distinct from the program's upgrade", + "authority — see the v4 trust-model note in the README." + ] + }, + { + "name": "config", + "isMut": true, + "isSigner": true, + "docs": [ + "The instance account itself. Caller-provided keypair, must sign." + ] + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "Per-instance token authority lets each instance manage its own mint independently.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "The custody account that holds tokens in locking mode and temporarily", + "holds tokens in burning mode.", + "function if the token account has already been created." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false, + "docs": [ + "associated token account for the given mint." + ] + }, + { + "name": "associatedTokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, + { + "name": "initializeLut", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "authority", + "isMut": false, + "isSigner": false + }, + { + "name": "lutAddress", + "isMut": true, + "isSigner": false + }, + { + "name": "lut", + "isMut": true, + "isSigner": false + }, + { + "name": "lutProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "entries", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ] + } + ], + "args": [ + { + "name": "recentSlot", + "type": "u64" + } + ] + }, + { + "name": "version", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "transferBurn", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "transferLock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "from", + "isMut": true, + "isSigner": false, + "docs": [ + "account can spend these tokens." + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": true + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false, + "docs": [ + "Tokens are always transferred to the custody account first regardless of", + "the mode.", + "For an explanation, see the note in [`transfer_burn`]." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "sessionAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "See [`crate::SESSION_AUTHORITY_SEED`] for an explanation of the flow." + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "TransferArgs" + } + } + ] + }, + { + "name": "redeem", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": false, + "isSigner": false, + "docs": [ + "`Account` and `owner` constraints are mutually-exclusive" + ] + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false, + "docs": [ + "NOTE: This account is content-addressed (PDA seeded by the message hash).", + "This is because in a multi-transceiver configuration, the different", + "transceivers \"vote\" on messages (by delivering them). By making the inbox", + "items content-addressed, we can ensure that disagreeing votes don't", + "interfere with each other.", + "On the first call to [`redeem()`], [`InboxItem`] will be allocated and initialized with", + "default values.", + "On subsequent calls, we want to modify the `InboxItem` by \"voting\" on it. Therefore the", + "program should not fail which would occur when using the `init` constraint.", + "The [`InboxItem::init`] field is used to guard against malicious or accidental modification", + "InboxItem fields that should remain constant." + ] + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "outboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RedeemArgs" + } + } + ] + }, + { + "name": "releaseInboundMint", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "releaseInboundUnlock", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "inboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "recipient", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, + { + "name": "transferOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "transferOwnershipOneStepUnchecked", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "claimOwnership", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newOwner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "acceptTokenAuthorityFromMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "currentMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthorityOneStepUnchecked", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": true + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "revertTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthority", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newAuthority", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "claimTokenAuthorityToMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "multisigTokenAuthority", + "isMut": false, + "isSigner": false, + "isOptional": true + }, + { + "name": "rentPayer", + "isMut": true, + "isSigner": false + }, + { + "name": "pendingTokenAuthority", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "newMultisigAuthority", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setPaused", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "pause", + "type": "bool" + } + ] + }, + { + "name": "setPeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "inboxRateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetPeerArgs" + } + } + ] + }, + { + "name": "registerTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false, + "docs": [ + "used here that wraps the Transceiver account type." + ] + }, + { + "name": "registeredTransceiver", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "deregisterTransceiver", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "registeredTransceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setOutboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetOutboundLimitArgs" + } + } + ] + }, + { + "name": "setInboundLimit", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetInboundLimitArgs" + } + } + ] + }, + { + "name": "markOutboxItemAsReleased", + "accounts": [ + { + "name": "signer", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + } + ], + "args": [], + "returns": "bool" + }, + { + "name": "setThreshold", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "config", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "threshold", + "type": "u8" + } + ] + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessage", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": true + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "LUT", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": "publicKey" + } + ] + } + }, + { + "name": "nttManagerPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "tokenDecimals", + "type": "u8" + } + ] + } + }, + { + "name": "pendingTokenAuthority", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "pendingAuthority", + "type": "publicKey" + }, + { + "name": "rentPayer", + "type": "publicKey" + } + ] + } + }, + { + "name": "inboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "init", + "type": "bool" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientAddress", + "type": "publicKey" + }, + { + "name": "votes", + "type": { + "defined": "Bitmap" + } + }, + { + "name": "releaseStatus", + "type": { + "defined": "ReleaseStatus" + } + } + ] + } + }, + { + "name": "inboxRateLimit", + "docs": [ + "Inbound rate limit per chain.", + "SECURITY: must check the PDA (since there are multiple PDAs, namely one for each chain.)" + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "outboxRateLimit", + "type": { + "kind": "struct", + "fields": [ + { + "name": "rateLimit", + "type": { + "defined": "RateLimitState" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "SetInboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + }, + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + } + ] + } + }, + { + "name": "SetOutboundLimitArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "type": "u64" + } + ] + } + }, + { + "name": "SetPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "tokenDecimals", + "docs": [ + "The token decimals on the peer chain." + ], + "type": "u8" + } + ] + } + }, + { + "name": "InitializeArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + }, + { + "name": "limit", + "type": "u64" + }, + { + "name": "mode", + "type": { + "defined": "Mode" + } + } + ] + } + }, + { + "name": "RedeemArgs", + "type": { + "kind": "struct", + "fields": [] + } + }, + { + "name": "ReleaseInboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertWhenNotReady", + "type": "bool" + } + ] + } + }, + { + "name": "TransferArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "shouldQueue", + "type": "bool" + } + ] + } + }, + { + "name": "ReleaseStatus", + "docs": [ + "The status of an InboxItem. This determines whether the tokens are minted/unlocked to the recipient. As", + "such, this must be used as a state machine that moves forward in a linear manner. A state", + "should never \"move backward\" to a previous state (e.g. should never move from `Released` to", + "`ReleaseAfter`)." + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "NotApproved" + }, + { + "name": "ReleaseAfter", + "fields": [ + "i64" + ] + }, + { + "name": "Released" + } + ] + } + }, + { + "name": "RateLimitState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "limit", + "docs": [ + "The maximum capacity of the rate limiter." + ], + "type": "u64" + }, + { + "name": "capacityAtLastTx", + "docs": [ + "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." + ], + "type": "u64" + }, + { + "name": "lastTxTimestamp", + "docs": [ + "The timestamp of the last transaction that counted towards the current", + "capacity. Transactions that exceeded the capacity do not count, they are", + "just delayed." + ], + "type": "i64" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "CantReleaseYet", + "msg": "CantReleaseYet" + }, + { + "code": 6001, + "name": "InvalidPendingOwner", + "msg": "InvalidPendingOwner" + }, + { + "code": 6002, + "name": "InvalidChainId", + "msg": "InvalidChainId" + }, + { + "code": 6003, + "name": "InvalidRecipientAddress", + "msg": "InvalidRecipientAddress" + }, + { + "code": 6004, + "name": "InvalidTransceiverPeer", + "msg": "InvalidTransceiverPeer" + }, + { + "code": 6005, + "name": "InvalidNttManagerPeer", + "msg": "InvalidNttManagerPeer" + }, + { + "code": 6006, + "name": "InvalidRecipientNttManager", + "msg": "InvalidRecipientNttManager" + }, + { + "code": 6007, + "name": "TransferAlreadyRedeemed", + "msg": "TransferAlreadyRedeemed" + }, + { + "code": 6008, + "name": "TransferCannotBeRedeemed", + "msg": "TransferCannotBeRedeemed" + }, + { + "code": 6009, + "name": "TransferNotApproved", + "msg": "TransferNotApproved" + }, + { + "code": 6010, + "name": "MessageAlreadySent", + "msg": "MessageAlreadySent" + }, + { + "code": 6011, + "name": "InvalidMode", + "msg": "InvalidMode" + }, + { + "code": 6012, + "name": "InvalidMintAuthority", + "msg": "InvalidMintAuthority" + }, + { + "code": 6013, + "name": "TransferExceedsRateLimit", + "msg": "TransferExceedsRateLimit" + }, + { + "code": 6014, + "name": "Paused", + "msg": "Paused" + }, + { + "code": 6015, + "name": "DisabledTransceiver", + "msg": "DisabledTransceiver" + }, + { + "code": 6016, + "name": "InvalidDeployer", + "msg": "InvalidDeployer" + }, + { + "code": 6017, + "name": "BadAmountAfterTransfer", + "msg": "BadAmountAfterTransfer" + }, + { + "code": 6018, + "name": "BadAmountAfterBurn", + "msg": "BadAmountAfterBurn" + }, + { + "code": 6019, + "name": "ZeroThreshold", + "msg": "ZeroThreshold" + }, + { + "code": 6020, + "name": "OverflowExponent", + "msg": "OverflowExponent" + }, + { + "code": 6021, + "name": "OverflowScaledAmount", + "msg": "OverflowScaledAmount" + }, + { + "code": 6022, + "name": "BitmapIndexOutOfBounds", + "msg": "BitmapIndexOutOfBounds" + }, + { + "code": 6023, + "name": "NoRegisteredTransceivers", + "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "NotPaused", + "msg": "NotPaused" + }, + { + "code": 6025, + "name": "InvalidPendingTokenAuthority", + "msg": "InvalidPendingTokenAuthority" + }, + { + "code": 6026, + "name": "IncorrectRentPayer", + "msg": "IncorrectRentPayer" + }, + { + "code": 6027, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" + }, + { + "code": 6028, + "name": "ThresholdTooHigh", + "msg": "ThresholdTooHigh" + }, + { + "code": 6029, + "name": "InvalidTransceiverProgram", + "msg": "InvalidTransceiverProgram" + }, + { + "code": 6030, + "name": "InvalidOutboxItem", + "msg": "InvalidOutboxItem" + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/ntt_quoter.ts b/solana/ts/idl/4_0_0/ts/ntt_quoter.ts new file mode 100644 index 000000000..161bf03aa --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/ntt_quoter.ts @@ -0,0 +1,1177 @@ +export type NttQuoter = { + "version": "3.0.0", + "name": "ntt_quoter", + "instructions": [ + { + "name": "requestRelay", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": false, + "isSigner": false, + "docs": [ + "and checking the release constraint into a single function" + ] + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RequestRelayArgs" + } + } + ] + }, + { + "name": "closeRelay", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": true, + "isSigner": false + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initialize", + "accounts": [ + { + "name": "owner", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + }, + { + "name": "programData", + "isMut": true, + "isSigner": false, + "docs": [ + "We use the program data to make sure this owner is the upgrade authority (the true owner,", + "who deployed this program)." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setAssistant", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "assistant", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [] + }, + { + "name": "setFeeRecipient", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "registerChain", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterChainArgs" + } + } + ] + }, + { + "name": "registerNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterNttArgs" + } + } + ] + }, + { + "name": "deregisterNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "DeregisterNttArgs" + } + } + ] + }, + { + "name": "updateSolPrice", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateSolPriceArgs" + } + } + ] + }, + { + "name": "updateChainPrices", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainPricesArgs" + } + } + ] + }, + { + "name": "updateChainParams", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainParamsArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "instance", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "assistant", + "type": "publicKey" + }, + { + "name": "feeRecipient", + "type": "publicKey" + }, + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + }, + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredNtt", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "relayRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "requestedGasDropoff", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "RegisterChainArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "RegisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "DeregisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + } + ] + } + }, + { + "name": "RequestRelayArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "gasDropoff", + "type": "u64" + }, + { + "name": "maxFee", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateSolPriceArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainPricesArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainParamsArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + } + ] + } + } + ], + "errors": [ + { + "code": 6001, + "name": "ExceedsUserMaxFee", + "msg": "Relay fees exceeds specified max" + }, + { + "code": 6002, + "name": "ExceedsMaxGasDropoff", + "msg": "Requested gas dropoff exceeds max allowed for chain" + }, + { + "code": 6003, + "name": "InvalidFeeRecipient", + "msg": "The specified fee recipient does not match the address in the instance accound" + }, + { + "code": 6004, + "name": "RelayingToChainDisabled", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6005, + "name": "OutboxItemNotReleased", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6006, + "name": "ScalingOverflow", + "msg": "Scaled value exceeds u64::MAX" + }, + { + "code": 6007, + "name": "DivByZero", + "msg": "Cannot divide by zero" + }, + { + "code": 6257, + "name": "FeeRecipientCannotBeDefault", + "msg": "The fee recipient cannot be the default address (0x0)" + }, + { + "code": 6258, + "name": "NotAuthorized", + "msg": "Must be owner or assistant" + }, + { + "code": 6259, + "name": "PriceCannotBeZero", + "msg": "The price cannot be zero" + } + ] +} +export const IDL: NttQuoter = { + "version": "3.0.0", + "name": "ntt_quoter", + "instructions": [ + { + "name": "requestRelay", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItem", + "isMut": false, + "isSigner": false, + "docs": [ + "and checking the release constraint into a single function" + ] + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RequestRelayArgs" + } + } + ] + }, + { + "name": "closeRelay", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": true, + "isSigner": false + }, + { + "name": "relayRequest", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "initialize", + "accounts": [ + { + "name": "owner", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + }, + { + "name": "programData", + "isMut": true, + "isSigner": false, + "docs": [ + "We use the program data to make sure this owner is the upgrade authority (the true owner,", + "who deployed this program)." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "setAssistant", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "assistant", + "isMut": false, + "isSigner": false, + "isOptional": true + } + ], + "args": [] + }, + { + "name": "setFeeRecipient", + "accounts": [ + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + }, + { + "name": "feeRecipient", + "isMut": false, + "isSigner": false + } + ], + "args": [] + }, + { + "name": "registerChain", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterChainArgs" + } + } + ] + }, + { + "name": "registerNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "RegisterNttArgs" + } + } + ] + }, + { + "name": "deregisterNtt", + "accounts": [ + { + "name": "authority", + "isMut": true, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredNtt", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "DeregisterNttArgs" + } + } + ] + }, + { + "name": "updateSolPrice", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateSolPriceArgs" + } + } + ] + }, + { + "name": "updateChainPrices", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainPricesArgs" + } + } + ] + }, + { + "name": "updateChainParams", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "instance", + "isMut": false, + "isSigner": false + }, + { + "name": "registeredChain", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "UpdateChainParamsArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "instance", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "assistant", + "type": "publicKey" + }, + { + "name": "feeRecipient", + "type": "publicKey" + }, + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredChain", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + }, + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "registeredNtt", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "relayRequest", + "type": { + "kind": "struct", + "fields": [ + { + "name": "requestedGasDropoff", + "type": "u64" + } + ] + } + } + ], + "types": [ + { + "name": "RegisterChainArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "RegisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + }, + { + "name": "wormholeTransceiverIndex", + "type": "u8" + }, + { + "name": "gasCost", + "type": "u32" + } + ] + } + }, + { + "name": "DeregisterNttArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nttProgramId", + "type": "publicKey" + } + ] + } + }, + { + "name": "RequestRelayArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "gasDropoff", + "type": "u64" + }, + { + "name": "maxFee", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateSolPriceArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "solPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainPricesArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "nativePrice", + "type": "u64" + }, + { + "name": "gasPrice", + "type": "u64" + } + ] + } + }, + { + "name": "UpdateChainParamsArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "maxGasDropoff", + "type": "u64" + }, + { + "name": "basePrice", + "type": "u64" + } + ] + } + } + ], + "errors": [ + { + "code": 6001, + "name": "ExceedsUserMaxFee", + "msg": "Relay fees exceeds specified max" + }, + { + "code": 6002, + "name": "ExceedsMaxGasDropoff", + "msg": "Requested gas dropoff exceeds max allowed for chain" + }, + { + "code": 6003, + "name": "InvalidFeeRecipient", + "msg": "The specified fee recipient does not match the address in the instance accound" + }, + { + "code": 6004, + "name": "RelayingToChainDisabled", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6005, + "name": "OutboxItemNotReleased", + "msg": "Relaying to the specified chain is disabled" + }, + { + "code": 6006, + "name": "ScalingOverflow", + "msg": "Scaled value exceeds u64::MAX" + }, + { + "code": 6007, + "name": "DivByZero", + "msg": "Cannot divide by zero" + }, + { + "code": 6257, + "name": "FeeRecipientCannotBeDefault", + "msg": "The fee recipient cannot be the default address (0x0)" + }, + { + "code": 6258, + "name": "NotAuthorized", + "msg": "Must be owner or assistant" + }, + { + "code": 6259, + "name": "PriceCannotBeZero", + "msg": "The price cannot be zero" + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/ntt_transceiver.ts b/solana/ts/idl/4_0_0/ts/ntt_transceiver.ts new file mode 100644 index 000000000..972bdfdd3 --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/ntt_transceiver.ts @@ -0,0 +1,1771 @@ +export type NttTransceiver = { + "version": "3.0.0", + "name": "ntt_transceiver", + "instructions": [ + { + "name": "transceiverType", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessageInstructionData", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "vaaBody", + "type": { + "defined": "VaaBodyData" + } + } + ] + }, + { + "name": "postUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "PostUnverifiedMessageAccountArgs" + } + } + ] + }, + { + "name": "closeUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "receiveWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "manager", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItemSigner", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "vaaBody", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + }, + { + "name": "VaaBodyData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "PostUnverifiedMessageAccountArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "seed", + "type": "u64" + }, + { + "name": "offset", + "type": "u32" + }, + { + "name": "chunk", + "type": "bytes" + }, + { + "name": "messageSize", + "type": "u32" + } + ] + } + } + ] +} +export const IDL: NttTransceiver = { + "version": "3.0.0", + "name": "ntt_transceiver", + "instructions": [ + { + "name": "transceiverType", + "accounts": [], + "args": [], + "returns": "string" + }, + { + "name": "setWormholePeer", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "owner", + "isMut": false, + "isSigner": true + }, + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "peer", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "SetTransceiverPeerArgs" + } + } + ] + }, + { + "name": "receiveWormholeMessageInstructionData", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "vaaBody", + "type": { + "defined": "VaaBodyData" + } + } + ] + }, + { + "name": "postUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "PostUnverifiedMessageAccountArgs" + } + } + ] + }, + { + "name": "closeUnverifiedWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "receiveWormholeMessageAccount", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "message", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiverMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "guardianSet", + "isMut": false, + "isSigner": false, + "docs": [ + "Derivation is checked by the shim." + ] + }, + { + "name": "guardianSignatures", + "isMut": false, + "isSigner": false, + "docs": [ + "Ownership ownership and discriminator is checked by the shim." + ] + }, + { + "name": "verifyVaaShim", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "guardianSetBump", + "type": "u8" + }, + { + "name": "seed", + "type": "u64" + } + ] + }, + { + "name": "releaseWormholeOutbound", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "accounts": [ + { + "name": "config", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "outboxItem", + "isMut": true, + "isSigner": false + }, + { + "name": "transceiver", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "manager", + "isMut": false, + "isSigner": false + }, + { + "name": "outboxItemSigner", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseOutboundArgs" + } + } + ] + }, + { + "name": "broadcastWormholeId", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false, + "docs": [ + "enforced by the [`CpiContext`] call in [`post_message`].", + "The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [] + }, + { + "name": "broadcastWormholePeer", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "peer", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholeMessage", + "isMut": true, + "isSigner": false + }, + { + "name": "emitter", + "isMut": false, + "isSigner": false + }, + { + "name": "wormhole", + "accounts": [ + { + "name": "bridge", + "isMut": true, + "isSigner": false + }, + { + "name": "feeCollector", + "isMut": true, + "isSigner": false + }, + { + "name": "sequence", + "isMut": true, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "postMessageShim", + "isMut": false, + "isSigner": false + }, + { + "name": "wormholePostMessageShimEa", + "isMut": false, + "isSigner": false + }, + { + "name": "clock", + "isMut": false, + "isSigner": false + }, + { + "name": "rent", + "isMut": false, + "isSigner": false + } + ] + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "BroadcastPeerArgs" + } + } + ] + } + ], + "accounts": [ + { + "name": "config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "owner", + "docs": [ + "Owner of this instance. Distinct from the program's upgrade authority —", + "instance ownership transfers are pure data mutations and never touch the", + "BPF loader (v4 trust model)." + ], + "type": "publicKey" + }, + { + "name": "pendingOwner", + "docs": [ + "Pending next owner (before claiming ownership)." + ], + "type": { + "option": "publicKey" + } + }, + { + "name": "mint", + "docs": [ + "Mint address of the token managed by this instance." + ], + "type": "publicKey" + }, + { + "name": "tokenProgram", + "docs": [ + "Address of the token program (token or token22). This could always be queried", + "from the [`mint`] account's owner, but storing it here avoids an indirection", + "on the client side." + ], + "type": "publicKey" + }, + { + "name": "mode", + "docs": [ + "The mode that this instance is running in. This is used to determine", + "whether the program is burning tokens or locking tokens." + ], + "type": { + "defined": "Mode" + } + }, + { + "name": "chainId", + "docs": [ + "The chain id of the chain that this program is running on. We don't", + "hardcode this so that the program is deployable on any potential SVM", + "forks." + ], + "type": { + "defined": "ChainId" + } + }, + { + "name": "nextTransceiverId", + "docs": [ + "The next transceiver id to use when registering a transceiver under this instance." + ], + "type": "u8" + }, + { + "name": "threshold", + "docs": [ + "The number of transceivers that must attest to a transfer before it is", + "accepted." + ], + "type": "u8" + }, + { + "name": "enabledTransceivers", + "docs": [ + "Bitmap of enabled transceivers.", + "The maximum number of transceivers is equal to [`Bitmap::BITS`]." + ], + "type": { + "defined": "Bitmap" + } + }, + { + "name": "paused", + "docs": [ + "Pause the program. This is useful for upgrades and other maintenance." + ], + "type": "bool" + }, + { + "name": "custody", + "docs": [ + "The custody account that holds tokens in locking mode." + ], + "type": "publicKey" + } + ] + } + }, + { + "name": "outboxItem", + "type": { + "kind": "struct", + "fields": [ + { + "name": "manager", + "docs": [ + "v4: the `Config` (instance) that produced this outbox item. Used to", + "bind the item to its source instance so transceivers can verify they're", + "releasing/marking an item that belongs to the instance they're working", + "on (`released` is a bitmap keyed by per-instance transceiver_id)." + ], + "type": "publicKey" + }, + { + "name": "amount", + "type": { + "defined": "TrimmedAmount" + } + }, + { + "name": "sender", + "type": "publicKey" + }, + { + "name": "recipientChain", + "type": { + "defined": "ChainId" + } + }, + { + "name": "recipientNttManager", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "recipientAddress", + "type": { + "array": [ + "u8", + 32 + ] + } + }, + { + "name": "releaseTimestamp", + "type": "i64" + }, + { + "name": "released", + "type": { + "defined": "Bitmap" + } + } + ] + } + }, + { + "name": "registeredTransceiver", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "id", + "type": "u8" + }, + { + "name": "transceiverAddress", + "type": "publicKey" + } + ] + } + }, + { + "name": "transceiverPeer", + "docs": [ + "A peer on another chain. Stored in a PDA seeded by the chain id." + ], + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "vaaBody", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + } + ], + "types": [ + { + "name": "Bitmap", + "type": { + "kind": "struct", + "fields": [ + { + "name": "map", + "type": "u128" + } + ] + } + }, + { + "name": "ChainId", + "type": { + "kind": "struct", + "fields": [ + { + "name": "id", + "type": "u16" + } + ] + } + }, + { + "name": "Mode", + "type": { + "kind": "enum", + "variants": [ + { + "name": "Locking" + }, + { + "name": "Burning" + } + ] + } + }, + { + "name": "TrimmedAmount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "amount", + "type": "u64" + }, + { + "name": "decimals", + "type": "u8" + } + ] + } + }, + { + "name": "VaaBodyData", + "type": { + "kind": "struct", + "fields": [ + { + "name": "span", + "type": "bytes" + } + ] + } + }, + { + "name": "SetTransceiverPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": { + "defined": "ChainId" + } + }, + { + "name": "address", + "type": { + "array": [ + "u8", + 32 + ] + } + } + ] + } + }, + { + "name": "BroadcastPeerArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "chainId", + "type": "u16" + } + ] + } + }, + { + "name": "ReleaseOutboundArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "revertOnDelay", + "type": "bool" + } + ] + } + }, + { + "name": "PostUnverifiedMessageAccountArgs", + "type": { + "kind": "struct", + "fields": [ + { + "name": "seed", + "type": "u64" + }, + { + "name": "offset", + "type": "u32" + }, + { + "name": "chunk", + "type": "bytes" + }, + { + "name": "messageSize", + "type": "u32" + } + ] + } + } + ] +} + diff --git a/solana/ts/idl/4_0_0/ts/wormhole_governance.ts b/solana/ts/idl/4_0_0/ts/wormhole_governance.ts new file mode 100644 index 000000000..13289f7b7 --- /dev/null +++ b/solana/ts/idl/4_0_0/ts/wormhole_governance.ts @@ -0,0 +1,153 @@ +export type WormholeGovernance = { + "version": "3.0.0", + "name": "wormhole_governance", + "instructions": [ + { + "name": "governance", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "governance", + "isMut": true, + "isSigner": false, + "docs": [ + "governed program. This account is validated by Wormhole, not this program." + ] + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "replay", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "replayProtection", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidGovernanceChain", + "msg": "InvalidGovernanceChain" + }, + { + "code": 6001, + "name": "InvalidGovernanceEmitter", + "msg": "InvalidGovernanceEmitter" + }, + { + "code": 6002, + "name": "InvalidGovernanceProgram", + "msg": "InvalidGovernanceProgram" + } + ] +} +export const IDL: WormholeGovernance = { + "version": "3.0.0", + "name": "wormhole_governance", + "instructions": [ + { + "name": "governance", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "governance", + "isMut": true, + "isSigner": false, + "docs": [ + "governed program. This account is validated by Wormhole, not this program." + ] + }, + { + "name": "vaa", + "isMut": false, + "isSigner": false + }, + { + "name": "program", + "isMut": false, + "isSigner": false + }, + { + "name": "replay", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "replayProtection", + "type": { + "kind": "struct", + "fields": [ + { + "name": "bump", + "type": "u8" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "InvalidGovernanceChain", + "msg": "InvalidGovernanceChain" + }, + { + "code": 6001, + "name": "InvalidGovernanceEmitter", + "msg": "InvalidGovernanceEmitter" + }, + { + "code": 6002, + "name": "InvalidGovernanceProgram", + "msg": "InvalidGovernanceProgram" + } + ] +} + diff --git a/solana/ts/lib/anchor-idl/4_0_0.ts b/solana/ts/lib/anchor-idl/4_0_0.ts new file mode 100644 index 000000000..2152af5d5 --- /dev/null +++ b/solana/ts/lib/anchor-idl/4_0_0.ts @@ -0,0 +1,28 @@ +import { type ExampleNativeTokenTransfers } from "../../idl/4_0_0/ts/example_native_token_transfers.js"; +import { IDL as ntt } from "../../idl/4_0_0/ts/example_native_token_transfers.js"; +// The transceiver/quoter/governance programs are independent of the NTT manager +// and were not changed in v4. Re-use the 3.0.0 IDL for those until they need +// their own version bumps. +import { type NttTransceiver } from "../../idl/3_0_0/ts/ntt_transceiver.js"; +import { IDL as transceiver } from "../../idl/3_0_0/ts/ntt_transceiver.js"; +import { type NttTransceiverLegacy } from "../../idl/3_0_0/ts/ntt_transceiver_legacy.js"; +import { IDL as transceiverLegacy } from "../../idl/3_0_0/ts/ntt_transceiver_legacy.js"; +import { type NttQuoter } from "../../idl/3_0_0/ts/ntt_quoter.js"; +import { IDL as quoter } from "../../idl/3_0_0/ts/ntt_quoter.js"; +import { type WormholeGovernance } from "../../idl/3_0_0/ts/wormhole_governance.js"; +import { IDL as governance } from "../../idl/3_0_0/ts/wormhole_governance.js"; + +export namespace _4_0_0 { + export const idl = { + ntt, + transceiver, + transceiverLegacy, + quoter, + governance, + }; + export type RawExampleNativeTokenTransfers = ExampleNativeTokenTransfers; + export type RawNttTransceiver = NttTransceiver; + export type RawNttTransceiverLegacy = NttTransceiverLegacy; + export type RawNttQuoter = NttQuoter; + export type RawWormholeGovernance = WormholeGovernance; +} diff --git a/solana/ts/lib/anchor-idl/index.ts b/solana/ts/lib/anchor-idl/index.ts index 6ad25e2d3..1d96710fb 100644 --- a/solana/ts/lib/anchor-idl/index.ts +++ b/solana/ts/lib/anchor-idl/index.ts @@ -1,3 +1,4 @@ export * from "./1_0_0.js"; export * from "./2_0_0.js"; export * from "./3_0_0.js"; +export * from "./4_0_0.js"; diff --git a/solana/ts/lib/bindings.ts b/solana/ts/lib/bindings.ts index 741b0febd..0fbe294cc 100644 --- a/solana/ts/lib/bindings.ts +++ b/solana/ts/lib/bindings.ts @@ -1,6 +1,6 @@ import { IdlAccounts, Program } from "@coral-xyz/anchor"; import { Connection } from "@solana/web3.js"; -import { _1_0_0, _2_0_0, _3_0_0 } from "./anchor-idl/index.js"; +import { _1_0_0, _2_0_0, _3_0_0, _4_0_0 } from "./anchor-idl/index.js"; import { Ntt } from "@wormhole-foundation/sdk-definitions-ntt"; export interface IdlBinding { @@ -15,6 +15,7 @@ export interface IdlBinding { // We check for the first match in descending order, allowing for higher minor and patch versions // being used by the live contract (these are supposed to still be compatible with older ABIs). export const IdlVersions = [ + ["4.0.0", _4_0_0], ["3.0.0", _3_0_0], ["2.0.0", _2_0_0], ["1.0.0", _1_0_0], @@ -27,19 +28,25 @@ export namespace NttBindings { ? _1_0_0.RawExampleNativeTokenTransfers : V extends "2.0.0" ? _2_0_0.RawExampleNativeTokenTransfers - : _3_0_0.RawExampleNativeTokenTransfers; + : V extends "3.0.0" + ? _3_0_0.RawExampleNativeTokenTransfers + : _4_0_0.RawExampleNativeTokenTransfers; export type Transceiver = V extends "1.0.0" ? _1_0_0.RawExampleNativeTokenTransfers : V extends "2.0.0" ? _2_0_0.RawExampleNativeTokenTransfers - : _3_0_0.RawNttTransceiver; + : V extends "3.0.0" + ? _3_0_0.RawNttTransceiver + : _4_0_0.RawNttTransceiver; export type TransceiverLegacy = V extends "1.0.0" ? _1_0_0.RawExampleNativeTokenTransfers : V extends "2.0.0" ? _2_0_0.RawExampleNativeTokenTransfers - : _3_0_0.RawNttTransceiverLegacy; + : V extends "3.0.0" + ? _3_0_0.RawNttTransceiverLegacy + : _4_0_0.RawNttTransceiverLegacy; type ProgramAccounts = IdlAccounts< NttBindings.NativeTokenTransfer diff --git a/solana/ts/lib/ntt.ts b/solana/ts/lib/ntt.ts index c0401773d..f0e63bf17 100644 --- a/solana/ts/lib/ntt.ts +++ b/solana/ts/lib/ntt.ts @@ -44,6 +44,7 @@ import { NttBindings, getNttProgram, } from "./bindings.js"; +import { _4_0_0 } from "./anchor-idl/index.js"; import { BPF_LOADER_UPGRADEABLE_PROGRAM_ID, chainToBytes, @@ -80,32 +81,64 @@ export namespace NTT { /** Type of object containing methods to compute program addresses */ export type Pdas = ReturnType; - /** pdas returns an object containing all functions to compute program addresses */ - export const pdas = (programId: PublicKeyInitData) => { - const configAccount = (): PublicKey => derivePda("config", programId); + /** + * pdas returns an object containing all functions to compute program addresses. + * + * - v3 (singleton): call as `pdas(programId)`. PDAs are derived in the legacy + * layout (e.g. `[b"config"]`). + * - v4 (multi-host): call as `pdas(programId, config)` where `config` is the + * per-instance Config account pubkey (the keypair the operator chose at + * `initialize`). All seed-bearing PDAs gain `config.toBytes()` as their + * first-after-prefix seed; `configAccount()` simply returns `config`. + * + * v4 also drops the legacy `[b"upgrade_lock"]` PDA — it has no v4 equivalent + * (program upgrade authority is decoupled from instance ownership). Calling + * `upgradeLock()` on a v4 pdas object throws. + */ + export const pdas = (programId: PublicKeyInitData, config?: PublicKey) => { + // When `config` is provided we prepend its bytes to the seeds of every + // PDA that's per-instance in v4. This keeps the v3 derivations bit-identical + // when called as `pdas(programId)`. + const scope: Uint8Array[] = config ? [config.toBytes()] : []; + + const configAccount = (): PublicKey => + config ?? derivePda("config", programId); const inboxRateLimitAccount = (chain: Chain): PublicKey => - derivePda(["inbox_rate_limit", chainToBytes(chain)], programId); + derivePda(["inbox_rate_limit", ...scope, chainToBytes(chain)], programId); const inboxItemAccount = ( chain: Chain, nttMessage: Ntt.Message ): PublicKey => derivePda( - ["inbox_item", Ntt.messageDigest(chain, nttMessage)], + ["inbox_item", ...scope, Ntt.messageDigest(chain, nttMessage)], programId ); - const upgradeLock = (): PublicKey => derivePda("upgrade_lock", programId); + const upgradeLock = (): PublicKey => { + if (config !== undefined) { + throw new Error( + "upgradeLock() is v3-only; v4 decouples program upgrade authority from instance ownership" + ); + } + return derivePda("upgrade_lock", programId); + }; const outboxRateLimitAccount = (): PublicKey => - derivePda("outbox_rate_limit", programId); + derivePda(["outbox_rate_limit", ...scope], programId); const tokenAuthority = (): PublicKey => - derivePda("token_authority", programId); + derivePda(["token_authority", ...scope], programId); const pendingTokenAuthority = (): PublicKey => - derivePda("pending_token_authority", programId); + derivePda(["pending_token_authority", ...scope], programId); const peerAccount = (chain: Chain): PublicKey => - derivePda(["peer", chainToBytes(chain)], programId); + derivePda(["peer", ...scope, chainToBytes(chain)], programId); const registeredTransceiver = (transceiver: PublicKey): PublicKey => - derivePda(["registered_transceiver", transceiver.toBytes()], programId); - const lutAccount = (): PublicKey => derivePda("lut", programId); - const lutAuthority = (): PublicKey => derivePda("lut_authority", programId); + derivePda( + ["registered_transceiver", ...scope, transceiver.toBytes()], + programId + ); + const lutAccount = (): PublicKey => derivePda(["lut", ...scope], programId); + const lutAuthority = (): PublicKey => + derivePda(["lut_authority", ...scope], programId); + // Scope session_authority by instance in v4 so an approval issued for one + // instance cannot be replayed against another instance managing the same mint. const sessionAuthority = ( sender: PublicKey, args: TransferArgs @@ -113,6 +146,7 @@ export namespace NTT { derivePda( [ "session_authority", + ...scope, sender.toBytes(), keccak256( encoding.bytes.concat( @@ -145,22 +179,38 @@ export namespace NTT { /** Type of object containing methods to compute program addresses */ export type TransceiverPdas = ReturnType; - /** pdas returns an object containing all functions to compute program addresses */ - export const transceiverPdas = (programId: PublicKeyInitData) => { - const emitterAccount = (): PublicKey => derivePda("emitter", programId); - const outboxItemSigner = () => derivePda(["outbox_item_signer"], programId); + /** pdas returns an object containing all functions to compute program addresses. + * v3: call as `transceiverPdas(programId)`. + * v4: call as `transceiverPdas(programId, config)` to scope by instance. + */ + export const transceiverPdas = ( + programId: PublicKeyInitData, + config?: PublicKey + ) => { + const scope: Uint8Array[] = config ? [config.toBytes()] : []; + + const emitterAccount = (): PublicKey => + derivePda(["emitter", ...scope], programId); + const outboxItemSigner = () => + derivePda(["outbox_item_signer", ...scope], programId); const transceiverPeerAccount = (chain: Chain): PublicKey => - derivePda(["transceiver_peer", chainToBytes(chain)], programId); + derivePda(["transceiver_peer", ...scope, chainToBytes(chain)], programId); const transceiverMessageAccount = ( chain: Chain, id: Uint8Array ): PublicKey => - derivePda(["transceiver_message", chainToBytes(chain), id], programId); + derivePda( + ["transceiver_message", ...scope, chainToBytes(chain), id], + programId + ); const unverifiedMessageAccount = (payer: PublicKey, seed: BN): PublicKey => derivePda( ["vaa_body", payer.toBytes(), new Uint8Array(seed.toArray("be"))], programId ); + // wormhole_message is content-addressed by `outbox_item.key()` which is + // already globally unique (OutboxItem is keypair-created), so no instance + // scoping is required here. const wormholeMessageAccount = (outboxItem: PublicKey): PublicKey => derivePda(["message", outboxItem.toBytes()], programId); const wormholeMessageWithShimAccount = ( @@ -257,16 +307,43 @@ export namespace NTT { mode: "burning" | "locking"; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { const [major, , ,] = parseVersion(program.idl.version); const mode: any = args.mode === "burning" ? { burning: {} } : { locking: {} }; const chainId = toChainId(args.chain); - pdas = pdas ?? NTT.pdas(program.programId); - const limit = new BN(args.outboundLimit.toString()); + + if (major >= 4) { + // v4: instance is keypair-created (not a PDA). `pdas.configAccount()` + // returns the instance pubkey when pdas was constructed with an + // instance arg. The caller must include both the payer and the + // instance keypair as signers on the transaction. Owner is a + // separate Signer, decoupled from the program upgrade authority. + return program.methods + .initialize({ chainId, limit: limit, mode }) + .accounts({ + payer: args.payer, + owner: args.owner, + config: pdas.configAccount(), + mint: args.mint, + rateLimit: pdas.outboxRateLimitAccount(), + tokenProgram: args.tokenProgram, + tokenAuthority: pdas.tokenAuthority(), + multisigTokenAuthority: args.multisigTokenAuthority ?? null, + custody: await NTT.custodyAccountAddress( + pdas, + args.mint, + args.tokenProgram + ), + associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .instruction(); + } + return program.methods .initialize({ chainId, limit: limit, mode }) .accounts({ @@ -305,14 +382,12 @@ export namespace NTT { owner: PublicKey; wormholeId: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { // if the program is < major version 2.x.x, we don't need to initialize the LUT const [major, , ,] = parseVersion(program.idl.version); if (major < 2) return; - pdas = pdas ?? NTT.pdas(program.programId); - // TODO: find a more robust way of fetching a recent slot const slot = (await program.provider.connection.getSlot()) - 1; @@ -404,10 +479,8 @@ export namespace NTT { transferArgs: TransferArgs; outboxItem: PublicKey; }, - pdas?: Pdas + pdas: Pdas ): Promise { - pdas = pdas ?? NTT.pdas(program.programId); - const custody = await custodyAccountAddress(pdas, config); const recipientChain = toChain(args.transferArgs.recipientChain.id); const transferIx = await program.methods @@ -484,12 +557,10 @@ export namespace NTT { transferArgs: NTT.TransferArgs; outboxItem: PublicKey; }, - pdas?: Pdas + pdas: Pdas ): Promise { if (config.paused) throw new Error("Contract is paused"); - pdas = pdas ?? NTT.pdas(program.programId); - const chain = toChain(args.transferArgs.recipientChain.id); const custody = await custodyAccountAddress(pdas, config); const transferIx = await program.methods @@ -563,12 +634,10 @@ export namespace NTT { revertWhenNotReady: boolean; recipient?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ): Promise { const [major, , ,] = parseVersion(program.idl.version); - pdas = pdas ?? NTT.pdas(program.programId); - const mintInfo = await splToken.getMint( program.provider.connection, config.mint, @@ -582,7 +651,7 @@ export namespace NTT { const recipientAddress = args.recipient ?? - (await getInboxItem(program, args.chain, args.nttMessage)) + (await getInboxItem(program, args.chain, args.nttMessage, pdas)) .recipientAddress; const transferIx = await program.methods @@ -656,14 +725,12 @@ export namespace NTT { revertWhenNotReady: boolean; recipient?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { const recipientAddress = args.recipient ?? - (await NTT.getInboxItem(program, args.chain, args.nttMessage)) + (await NTT.getInboxItem(program, args.chain, args.nttMessage, pdas)) .recipientAddress; - - pdas = pdas ?? NTT.pdas(program.programId); const custody = await custodyAccountAddress(pdas, config); const transferIx = await program.methods @@ -737,9 +804,25 @@ export namespace NTT { owner: PublicKey; newOwner: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4) { + // v4: ownership transfer is a pure data mutation on Config, no BPF-loader CPI. + // The function signature accepts a `Program` over the union of all IDL versions, + // so `accountsStrict` would otherwise require the v3 BPF-loader fields too. Narrow + // to the v4 IDL type now that we've established the major version at runtime. + const v4Program = + program as unknown as Program<_4_0_0.RawExampleNativeTokenTransfers>; + return v4Program.methods + .transferOwnershipOneStepUnchecked() + .accountsStrict({ + config: pdas.configAccount(), + owner: args.owner, + newOwner: args.newOwner, + }) + .instruction(); + } return program.methods .transferOwnershipOneStepUnchecked() .accountsStrict({ @@ -759,9 +842,21 @@ export namespace NTT { owner: PublicKey; newOwner: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4) { + const v4Program = + program as unknown as Program<_4_0_0.RawExampleNativeTokenTransfers>; + return v4Program.methods + .transferOwnership() + .accountsStrict({ + config: pdas.configAccount(), + owner: args.owner, + newOwner: args.newOwner, + }) + .instruction(); + } return program.methods .transferOwnership() .accountsStrict({ @@ -780,9 +875,20 @@ export namespace NTT { args: { newOwner: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4) { + const v4Program = + program as unknown as Program<_4_0_0.RawExampleNativeTokenTransfers>; + return v4Program.methods + .claimOwnership() + .accountsStrict({ + config: pdas.configAccount(), + newOwner: args.newOwner, + }) + .instruction(); + } return program.methods .claimOwnership() .accountsStrict({ @@ -802,9 +908,8 @@ export namespace NTT { currentAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .acceptTokenAuthority() .accountsStrict({ @@ -828,9 +933,8 @@ export namespace NTT { additionalSigners: readonly PublicKey[]; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .acceptTokenAuthorityFromMultisig() .accountsStrict({ @@ -861,9 +965,8 @@ export namespace NTT { newAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setTokenAuthorityOneStepUnchecked() .accountsStrict({ @@ -889,9 +992,8 @@ export namespace NTT { newAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setTokenAuthority() .accountsStrict({ @@ -918,9 +1020,8 @@ export namespace NTT { owner: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .revertTokenAuthority() .accountsStrict({ @@ -947,9 +1048,8 @@ export namespace NTT { newAuthority: PublicKey; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .claimTokenAuthority() .accountsStrict({ @@ -977,9 +1077,8 @@ export namespace NTT { additionalSigners: readonly PublicKey[]; multisigTokenAuthority?: PublicKey; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .claimTokenAuthorityToMultisig() .accountsStrict({ @@ -1015,9 +1114,8 @@ export namespace NTT { limit: BN; tokenDecimals: number; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setPeer({ chainId: { id: toChainId(args.chain) }, @@ -1042,9 +1140,8 @@ export namespace NTT { owner: PublicKey; paused: boolean; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setPaused(args.paused) .accountsStrict({ @@ -1060,9 +1157,8 @@ export namespace NTT { owner: PublicKey; threshold: number; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setThreshold(args.threshold) .accountsStrict({ @@ -1078,9 +1174,8 @@ export namespace NTT { owner: PublicKey; limit: BN; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setOutboundLimit({ limit: args.limit, @@ -1100,9 +1195,8 @@ export namespace NTT { chain: Chain; limit: BN; }, - pdas?: Pdas + pdas: Pdas ) { - pdas = pdas ?? NTT.pdas(program.programId); return program.methods .setInboundLimit({ chainId: { id: toChainId(args.chain) }, @@ -1124,13 +1218,9 @@ export namespace NTT { payer: PublicKey; vaa: VAA<"Ntt:WormholeTransfer">; }, - pdas?: Pdas, - transceiverPdas?: TransceiverPdas + pdas: Pdas, + transceiverPdas: TransceiverPdas ): Promise { - pdas = pdas ?? NTT.pdas(program.programId); - transceiverPdas = - transceiverPdas ?? NTT.transceiverPdas(transceiverProgramId); - const wormholeNTT = args.vaa; const nttMessage = wormholeNTT.payload.nttManagerPayload; const chain = wormholeNTT.emitterChain; @@ -1174,21 +1264,29 @@ export namespace NTT { export async function getInboxItem( program: Program>, fromChain: Chain, - nttMessage: Ntt.Message + nttMessage: Ntt.Message, + pdas?: Pdas ): Promise> { + const [major, , ,] = parseVersion(program.idl.version); + if (major >= 4 && !pdas) { + throw new Error( + "getInboxItem: v4 requires instance-scoped pdas to derive the inbox item account" + ); + } return program.account.inboxItem.fetch( - NTT.pdas(program.programId).inboxItemAccount(fromChain, nttMessage) + (pdas ?? NTT.pdas(program.programId)).inboxItemAccount( + fromChain, + nttMessage + ) ); } export async function getAddressLookupTable( program: Program>, - pdas?: Pdas + pdas: Pdas ): Promise { const [major, , ,] = parseVersion(program.idl.version); if (major < 2) return null; - - pdas = pdas ?? NTT.pdas(program.programId); // @ts-ignore // NOTE: lut is 'LUT' in the IDL, but 'lut' in the generated code // It needs to be upper-cased in the IDL to compute the anchor diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index fab43d147..c461da1ce 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -74,7 +74,8 @@ export class SolanaNttWormholeTransceiver< readonly svmShims: Ntt.Contracts["svmShims"] = undefined ) { this.programId = program.programId; - this.pdas = NTT.transceiverPdas(program.programId); + // v4: scope transceiver PDAs by the manager instance pubkey when present. + this.pdas = NTT.transceiverPdas(program.programId, manager.instance); if (svmShims) { const postMessageShimAddress = @@ -107,6 +108,29 @@ export class SolanaNttWormholeTransceiver< } } + /** + * v4-aware version of the standard Wormhole CPI account bundle: bridge and + * fee_collector come from the wormhole core program ID, but `emitter` and + * `sequence` are scoped by the v4 instance pubkey embedded in `this.pdas`. + * The wormhole-anchor-sdk's `getWormholeDerivedAccounts` only knows the v3 + * singleton emitter (`[b"emitter"]`), so we recompute these two locally. + */ + private wormholeDerivedAccounts() { + const emitter = this.pdas.emitterAccount(); + const whAccs = utils.getWormholeDerivedAccounts( + this.program.programId, + this.manager.core.address + ); + return { + ...whAccs, + wormholeEmitter: emitter, + wormholeSequence: derivePda( + ["Sequence", emitter.toBytes()], + this.manager.core.address + ), + }; + } + async getPauser(): Promise | null> { return null; } @@ -421,10 +445,7 @@ export class SolanaNttWormholeTransceiver< "wormholeMessage must be passed in if Wormhole Post Message Shim is not configured" ); } - const whAccs = utils.getWormholeDerivedAccounts( - this.program.programId, - this.manager.core.address - ); + const whAccs = this.wormholeDerivedAccounts(); wormholeMessage = this.postMessageShim ? this.pdas.wormholeMessageWithShimAccount(this.postMessageShim.programId) @@ -464,10 +485,7 @@ export class SolanaNttWormholeTransceiver< "wormholeMessage must be passed in if Wormhole Post Message Shim is not configured" ); } - const whAccs = utils.getWormholeDerivedAccounts( - this.program.programId, - this.manager.core.address - ); + const whAccs = this.wormholeDerivedAccounts(); wormholeMessage = this.postMessageShim ? this.pdas.wormholeMessageWithShimAccount(this.postMessageShim.programId) @@ -503,10 +521,7 @@ export class SolanaNttWormholeTransceiver< revertOnDelay: boolean ): Promise { const [major, , ,] = parseVersion(this.version); - const whAccs = utils.getWormholeDerivedAccounts( - this.program.programId, - this.manager.core.address - ); + const whAccs = this.wormholeDerivedAccounts(); const wormholeMessage = this.postMessageShim ? this.pdas.wormholeMessageWithShimAccount(this.postMessageShim.programId) @@ -559,6 +574,13 @@ export class SolanaNtt config?: NttBindings.Config; addressLookupTable?: AddressLookupTableAccount; + /** + * v4 instance pubkey. Set when `contracts.ntt.instance` is provided. Drives + * instance-scoped PDA derivation throughout the SDK. Undefined for v3 + * (singleton) deployments. + */ + instance?: PublicKey; + // 0 = Wormhole xcvr transceivers: Program>[]; @@ -571,11 +593,39 @@ export class SolanaNtt readonly network: N, readonly chain: C, readonly connection: Connection, - readonly contracts: Contracts & { ntt?: Ntt.Contracts }, + readonly contracts: Contracts & { + ntt?: Ntt.Contracts & { instance?: string }; + }, readonly version: string = "3.0.0" ) { if (!contracts.ntt) throw new Error("Ntt contracts not found"); + // Reject the v4-without-instance footgun. The PDA factory accepts an + // optional `config` arg for back-compat with v3, so if a caller forgets to + // thread `instance` for a v4 deployment the SDK would silently derive v3 + // singleton PDAs and read/write the wrong on-chain accounts. Lock the + // version → instance correspondence at the only place that knows both. + const [major] = parseVersion(version); + if (major >= 4 && !contracts.ntt.instance) { + throw new Error( + `SolanaNtt: version ${version} requires \`contracts.ntt.instance\` ` + + `(the per-deployment Instance pubkey). v4 PDAs are scoped by ` + + `instance; without it the SDK would silently fall back to v3 ` + + `singleton derivations.` + ); + } + if (major < 4 && contracts.ntt.instance) { + throw new Error( + `SolanaNtt: \`contracts.ntt.instance\` was provided but version ` + + `${version} predates v4 multi-host. Drop the \`instance\` field, ` + + `or set the version to >= 4.0.0.` + ); + } + + if (contracts.ntt.instance) { + this.instance = new PublicKey(contracts.ntt.instance); + } + this.program = getNttProgram( connection, contracts.ntt.manager, @@ -603,9 +653,10 @@ export class SolanaNtt const transceiverKey = new PublicKey( contracts.ntt!.transceiver[transceiverType]! ); - // handle emitterAccount case separately + // handle emitterAccount case separately. In v4 the emitter PDA is + // scoped by the instance pubkey, so we pass `this.instance` when set. if ( - NTT.transceiverPdas(managerKey) + NTT.transceiverPdas(managerKey, this.instance) .emitterAccount() .equals(transceiverKey) || managerKey.equals(transceiverKey) @@ -644,7 +695,7 @@ export class SolanaNtt connection, contracts ); - this.pdas = NTT.pdas(this.program.programId); + this.pdas = NTT.pdas(this.program.programId, this.instance); } async getTransceiver( @@ -686,10 +737,11 @@ export class SolanaNtt async *pause(payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetPausedInstruction(this.program, { - owner: sender, - paused: true, - }); + const ix = await NTT.createSetPausedInstruction( + this.program, + { owner: sender, paused: true }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -699,10 +751,11 @@ export class SolanaNtt async *unpause(payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetPausedInstruction(this.program, { - owner: sender, - paused: false, - }); + const ix = await NTT.createSetPausedInstruction( + this.program, + { owner: sender, paused: false }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -748,10 +801,14 @@ export class SolanaNtt async *setOwner(newOwner: AnySolanaAddress, payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createTransferOwnershipInstruction(this.program, { - owner: new SolanaAddress(await this.getOwner()).unwrap(), - newOwner: new SolanaAddress(newOwner).unwrap(), - }); + const ix = await NTT.createTransferOwnershipInstruction( + this.program, + { + owner: new SolanaAddress(await this.getOwner()).unwrap(), + newOwner: new SolanaAddress(newOwner).unwrap(), + }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -801,8 +858,7 @@ export class SolanaNtt } async getConfig(): Promise> { - this.config = this.config ?? (await NTT.getConfig(this.program, this.pdas)); - return this.config!; + return await NTT.getConfig(this.program, this.pdas); } async getTokenDecimals(): Promise { @@ -861,6 +917,13 @@ export class SolanaNtt mode: Ntt.Mode; outboundLimit: bigint; multisigTokenAuthority?: PublicKey; + /** + * v4-only: the keypair that will own the new Instance account. Required + * when `this.instance` is set. The caller-provided keypair must sign the + * initialize transaction (Anchor `init` allocates the account at this + * pubkey). This keypair's public key must match `this.instance`. + */ + instance?: Keypair; } ) { const mintInfo = await this.connection.getAccountInfo(args.mint); @@ -871,6 +934,23 @@ export class SolanaNtt const payer = new SolanaAddress(sender).unwrap(); + if (this.instance) { + if (!args.instance) { + throw new Error( + "v4 initialize: `args.instance` (Keypair) is required when `contracts.ntt.instance` is set" + ); + } + if (!args.instance.publicKey.equals(this.instance)) { + throw new Error( + "v4 initialize: `args.instance.publicKey` does not match `contracts.ntt.instance`" + ); + } + } else if (args.instance) { + throw new Error( + "initialize: `args.instance` was passed but `contracts.ntt.instance` is unset (v3 deployment expected)" + ); + } + const ix = await NTT.createInitializeInstruction( this.program, { @@ -887,7 +967,10 @@ export class SolanaNtt const tx = new Transaction(); tx.feePayer = payer; tx.add(ix); - yield this.createUnsignedTx({ transaction: tx }, "Ntt.Initialize"); + // v4: attach the instance keypair as an extra signer so Anchor's `init` + // can allocate the Instance account at that pubkey. + const signers = args.instance ? [args.instance] : undefined; + yield this.createUnsignedTx({ transaction: tx, signers }, "Ntt.Initialize"); yield* this.initializeOrUpdateLUT({ payer, owner: payer }); } @@ -909,7 +992,8 @@ export class SolanaNtt payer: args.payer, owner: args.owner, wormholeId: new PublicKey(this.core.address), - } + }, + this.pdas ); // Already up to date if (!ix) return; @@ -1025,14 +1109,18 @@ export class SolanaNtt ) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetPeerInstruction(this.program, { - payer: sender, - owner: sender, - chain: peer.chain, - address: peer.address.toUniversalAddress().toUint8Array(), - limit: new BN(inboundLimit.toString()), - tokenDecimals, - }); + const ix = await NTT.createSetPeerInstruction( + this.program, + { + payer: sender, + owner: sender, + chain: peer.chain, + address: peer.address.toUniversalAddress().toUint8Array(), + limit: new BN(inboundLimit.toString()), + tokenDecimals, + }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -1322,7 +1410,9 @@ export class SolanaNtt { payer: senderAddress, vaa: wormholeNTT, - } + }, + this.pdas, + whTransceiver.pdas ); const nttMessage = wormholeNTT.payload.nttManagerPayload; @@ -1340,12 +1430,18 @@ export class SolanaNtt }; let releaseIx = config.mode.locking != null - ? NTT.createReleaseInboundUnlockInstruction(this.program, config, { - ...releaseArgs, - }) - : NTT.createReleaseInboundMintInstruction(this.program, config, { - ...releaseArgs, - }); + ? NTT.createReleaseInboundUnlockInstruction( + this.program, + config, + { ...releaseArgs }, + this.pdas + ) + : NTT.createReleaseInboundMintInstruction( + this.program, + config, + { ...releaseArgs }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = senderAddress; @@ -1386,10 +1482,11 @@ export class SolanaNtt async *setOutboundLimit(limit: bigint, payer: AccountAddress) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetOutboundLimitInstruction(this.program, { - owner: sender, - limit: new BN(limit.toString()), - }); + const ix = await NTT.createSetOutboundLimitInstruction( + this.program, + { owner: sender, limit: new BN(limit.toString()) }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -1422,11 +1519,11 @@ export class SolanaNtt payer: AccountAddress ) { const sender = new SolanaAddress(payer).unwrap(); - const ix = await NTT.createSetInboundLimitInstruction(this.program, { - owner: sender, - chain: fromChain, - limit: new BN(limit.toString()), - }); + const ix = await NTT.createSetInboundLimitInstruction( + this.program, + { owner: sender, chain: fromChain, limit: new BN(limit.toString()) }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = sender; @@ -1517,12 +1614,14 @@ export class SolanaNtt ? NTT.createReleaseInboundUnlockInstruction( this.program, config, - releaseArgs + releaseArgs, + this.pdas ) : NTT.createReleaseInboundMintInstruction( this.program, config, - releaseArgs + releaseArgs, + this.pdas )) ); @@ -1580,7 +1679,7 @@ export class SolanaNtt manager: this.program.programId.toBase58(), token: (await this.getConfig()).mint.toBase58(), transceiver: { - wormhole: NTT.transceiverPdas(this.program.programId) + wormhole: NTT.transceiverPdas(this.program.programId, this.instance) .emitterAccount() .toBase58(), }, From 504d7f4e059b379d796bf54bf9a5d2bc0ca5f5a1 Mon Sep 17 00:00:00 2001 From: csongor Date: Thu, 7 May 2026 21:34:50 +0900 Subject: [PATCH 03/28] step 3: update & fix tests --- .../tests/admin.rs | 7 + .../tests/broadcast.rs | 9 +- .../tests/cancel_flow.rs | 2 + .../tests/governance.rs | 15 +- .../tests/receive.rs | 16 +- .../tests/transfer.rs | 80 ++- .../programs/ntt-transceiver/tests/admin.rs | 5 + .../ntt-transceiver/tests/broadcast.rs | 9 +- .../ntt-transceiver/tests/cancel_flow.rs | 2 + .../programs/ntt-transceiver/tests/receive.rs | 18 +- .../ntt-transceiver/tests/transfer.rs | 11 +- solana/tests/anchor/anchor.test.ts | 80 ++- solana/tests/anchor/multi_instance.test.ts | 486 ++++++++++++++++++ .../upgrade_authority_decoupling.test.ts | 182 +++++++ solana/tests/anchor/utils/helpers.ts | 10 +- solana/tests/cargo/src/common/fixtures.rs | 5 + solana/tests/cargo/src/helpers/admin.rs | 2 + solana/tests/cargo/src/helpers/setup.rs | 23 +- solana/tests/cargo/src/helpers/transfer.rs | 5 +- solana/tests/cargo/src/sdk/accounts/ntt.rs | 85 +-- .../cargo/src/sdk/instructions/initialize.rs | 12 +- .../src/sdk/instructions/release_inbound.rs | 2 + .../legacy/accounts/ntt_transceiver.rs | 43 +- .../shim/accounts/ntt_transceiver.rs | 45 +- 24 files changed, 1035 insertions(+), 119 deletions(-) create mode 100644 solana/tests/anchor/multi_instance.test.ts create mode 100644 solana/tests/anchor/upgrade_authority_decoupling.test.ts diff --git a/solana/programs/example-native-token-transfers/tests/admin.rs b/solana/programs/example-native-token-transfers/tests/admin.rs index 1884afaff..a1ae33cd9 100644 --- a/solana/programs/example-native-token-transfers/tests/admin.rs +++ b/solana/programs/example-native-token-transfers/tests/admin.rs @@ -22,6 +22,7 @@ use test_utils::{ #[tokio::test] async fn test_invalid_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); // try registering system program let err = register_transceiver( @@ -47,6 +48,8 @@ async fn test_invalid_transceiver() { #[tokio::test] async fn test_reregister_all_transceivers() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // Transceivers are expected to be executable which requires them to be added on setup // Thus, we pass all available executable program IDs as dummy_transceivers @@ -138,6 +141,8 @@ async fn test_reregister_all_transceivers() { #[tokio::test] async fn test_deregister_last_enabled_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // attempt to deregister only enabled transceiver (baked-in transceiver) let err = deregister_transceiver( @@ -207,6 +212,7 @@ async fn test_deregister_last_enabled_transceiver() { #[tokio::test] async fn test_zero_threshold() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let err = set_threshold( &good_ntt, @@ -230,6 +236,7 @@ async fn test_zero_threshold() { #[tokio::test] async fn test_threshold_too_high() { let (mut ctx, test_data) = setup(Mode::Burning).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let err = set_threshold( &good_ntt, diff --git a/solana/programs/example-native-token-transfers/tests/broadcast.rs b/solana/programs/example-native-token-transfers/tests/broadcast.rs index eeccf1918..cf60025db 100644 --- a/solana/programs/example-native-token-transfers/tests/broadcast.rs +++ b/solana/programs/example-native-token-transfers/tests/broadcast.rs @@ -30,7 +30,9 @@ use wormhole_anchor_sdk::wormhole::PostedVaa; #[tokio::test] async fn test_broadcast_peer() { - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let wh_message = Keypair::new(); @@ -63,6 +65,8 @@ async fn test_broadcast_peer() { #[tokio::test] async fn test_broadcast_id() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let wh_message = Keypair::new(); @@ -86,7 +90,8 @@ async fn test_broadcast_id() { assert_eq!( *msg.data(), WormholeTransceiverInfo { - manager_address: good_ntt.program().to_bytes(), + // v4: on-the-wire manager identity is the instance pubkey, not the program ID. + manager_address: good_ntt.config().to_bytes(), manager_mode: Mode::Locking, token_address: test_data.mint.to_bytes(), token_decimals: 9, diff --git a/solana/programs/example-native-token-transfers/tests/cancel_flow.rs b/solana/programs/example-native-token-transfers/tests/cancel_flow.rs index 8f1e4a4a4..b2db44d7b 100644 --- a/solana/programs/example-native-token-transfers/tests/cancel_flow.rs +++ b/solana/programs/example-native-token-transfers/tests/cancel_flow.rs @@ -31,6 +31,8 @@ use wormhole_sdk::Address; async fn test_cancel() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg0 = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); let msg1 = make_transfer_message(&good_ntt, [1u8; 32], 2000, &recipient.pubkey()); diff --git a/solana/programs/example-native-token-transfers/tests/governance.rs b/solana/programs/example-native-token-transfers/tests/governance.rs index ec188591c..37db61107 100644 --- a/solana/programs/example-native-token-transfers/tests/governance.rs +++ b/solana/programs/example-native-token-transfers/tests/governance.rs @@ -27,7 +27,6 @@ use wormhole_governance::{ instructions::{GovernanceMessage, ReplayProtection, OWNER}, }; use wormhole_sdk::{Address, Vaa, GOVERNANCE_EMITTER}; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable; async fn post_governance_vaa( ctx: &mut ProgramTestContext, @@ -67,6 +66,7 @@ async fn transfer_ownership_to_gov_program( core::result::Result, BanksClientError>, Instruction, ) { + let good_ntt = good_ntt(test_data.instance.pubkey()); let governance_pda = test_data.governance.governance(); // step 1. transfer ownership to governance @@ -76,9 +76,6 @@ async fn transfer_ownership_to_gov_program( config: good_ntt.config(), owner: test_data.program_owner.pubkey(), new_owner: governance_pda, - upgrade_lock: good_ntt.upgrade_lock(), - program_data: good_ntt.program_data(), - bpf_loader_upgradeable_program: bpf_loader_upgradeable::id(), }; Instruction { @@ -95,9 +92,6 @@ async fn transfer_ownership_to_gov_program( let inner_ix_accs = example_native_token_transfers::accounts::ClaimOwnership { new_owner: OWNER, config: good_ntt.config(), - upgrade_lock: good_ntt.upgrade_lock(), - program_data: good_ntt.program_data(), - bpf_loader_upgradeable_program: bpf_loader_upgradeable::id(), }; let inner_ix: Instruction = Instruction { @@ -126,6 +120,7 @@ async fn transfer_ownership_to_gov_program( #[tokio::test] async fn test_governance() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); transfer_ownership_to_gov_program(&mut ctx, &test_data, None) .await @@ -152,6 +147,7 @@ async fn test_governance() { #[tokio::test] async fn test_governance_one_step_transfer() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let governance_pda = test_data.governance.governance(); @@ -162,9 +158,6 @@ async fn test_governance_one_step_transfer() { config: good_ntt.config(), owner: test_data.program_owner.pubkey(), new_owner: governance_pda, - upgrade_lock: good_ntt.upgrade_lock(), - program_data: good_ntt.program_data(), - bpf_loader_upgradeable_program: bpf_loader_upgradeable::id(), }; Instruction { @@ -199,6 +192,7 @@ async fn test_governance_one_step_transfer() { #[tokio::test] async fn test_governance_bad_emitter() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let err = wrap_governance( &mut ctx, @@ -242,6 +236,7 @@ async fn test_governance_bad_governance_contract() { #[tokio::test] async fn test_governance_replay() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let (vaa, inner_ix) = transfer_ownership_to_gov_program(&mut ctx, &test_data, None).await; diff --git a/solana/programs/example-native-token-transfers/tests/receive.rs b/solana/programs/example-native-token-transfers/tests/receive.rs index 8b7fa0ce0..35f612bec 100644 --- a/solana/programs/example-native-token-transfers/tests/receive.rs +++ b/solana/programs/example-native-token-transfers/tests/receive.rs @@ -40,6 +40,8 @@ use wormhole_sdk::Address; async fn test_receive() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // transfer tokens to custody account spl_token::instruction::transfer_checked( @@ -167,7 +169,9 @@ async fn test_receive() { #[tokio::test] async fn test_double_receive() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -229,6 +233,8 @@ async fn test_double_receive() { async fn test_wrong_recipient_ntt_manager() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -286,7 +292,9 @@ async fn test_wrong_recipient_ntt_manager() { #[tokio::test] async fn test_wrong_transceiver_peer() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -327,6 +335,8 @@ async fn test_wrong_transceiver_peer() { async fn test_wrong_manager_peer() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -385,6 +395,8 @@ async fn test_wrong_manager_peer() { async fn test_wrong_inbox_item() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); diff --git a/solana/programs/example-native-token-transfers/tests/transfer.rs b/solana/programs/example-native-token-transfers/tests/transfer.rs index f1ca9dc26..8297e6b20 100644 --- a/solana/programs/example-native-token-transfers/tests/transfer.rs +++ b/solana/programs/example-native-token-transfers/tests/transfer.rs @@ -84,6 +84,8 @@ pub async fn test_transfer_burning_with_transfer_fee() { /// This tests the happy path of a transfer, with all the relevant account checks. /// Written as a helper function so both modes can be tested. async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: Mode) { + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let clock: Clock = ctx.banks_client.get_sysvar().await.unwrap(); @@ -110,6 +112,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: assert_eq!( outbox_item_account, OutboxItem { + // v4: outbox item is bound to its source instance. + manager: good_ntt.config(), amount: TrimmedAmount { amount: 1, decimals: 7 @@ -165,7 +169,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: assert_eq!( transceiver_message, &TransceiverMessage::new( - example_native_token_transfers::ID.to_bytes(), + // v4: source_ntt_manager is the instance pubkey, not the program ID. + good_ntt.config().to_bytes(), OTHER_MANAGER, NttManagerMessage { id: outbox_item.pubkey().to_bytes(), @@ -192,6 +197,7 @@ async fn test_transfer_with_transfer_fee( mode: Mode, error_code: u32, ) { + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = @@ -220,6 +226,7 @@ async fn test_transfer_with_transfer_fee( #[tokio::test] async fn test_burn_mode_burns_tokens() { let (mut ctx, test_data) = setup(Mode::Burning).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -271,6 +278,7 @@ async fn test_burn_mode_burns_tokens() { #[tokio::test] async fn locking_mode_locks_tokens() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -333,6 +341,7 @@ async fn locking_mode_locks_tokens() { #[tokio::test] async fn test_bad_mint() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -388,21 +397,29 @@ async fn test_bad_mint() { async fn test_invalid_peer() { // in this test we send to 'OTHER_CHAIN' but use the peer account for // 'ANOTHER_CHAIN'. - struct BadNTT {} + struct BadNTT { + instance: Pubkey, + } impl NTTAccounts for BadNTT { + fn config(&self) -> Pubkey { + self.instance + } fn peer(&self, _chain_id: u16) -> Pubkey { // return 'ANOTHER_CHAIN' peer account - good_ntt.peer(ANOTHER_CHAIN) + good_ntt(self.instance).peer(ANOTHER_CHAIN) } } let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = init_transfer_accs_args( - &BadNTT {}, + &BadNTT { + instance: test_data.instance.pubkey(), + }, &mut ctx, &test_data, outbox_item.pubkey(), @@ -420,10 +437,17 @@ async fn test_invalid_peer() { .await .unwrap(); - let err = transfer(&BadNTT {}, accs, args, Mode::Locking) - .submit_with_signers(&[&outbox_item], &mut ctx) - .await - .unwrap_err(); + let err = transfer( + &BadNTT { + instance: test_data.instance.pubkey(), + }, + accs, + args, + Mode::Locking, + ) + .submit_with_signers(&[&outbox_item], &mut ctx) + .await + .unwrap_err(); assert_eq!( err.unwrap(), @@ -437,21 +461,29 @@ async fn test_invalid_peer() { #[tokio::test] async fn test_unregistered_peer_cant_transfer() { // in this test we try to transfer with unregistered peer - struct BadNTT {} + struct BadNTT { + instance: Pubkey, + } impl NTTAccounts for BadNTT { + fn config(&self) -> Pubkey { + self.instance + } fn peer(&self, _chain_id: u16) -> Pubkey { // return 'UNREGISTERED_CHAIN' peer account - good_ntt.peer(UNREGISTERED_CHAIN) + good_ntt(self.instance).peer(UNREGISTERED_CHAIN) } } let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = init_transfer_accs_args( - &BadNTT {}, + &BadNTT { + instance: test_data.instance.pubkey(), + }, &mut ctx, &test_data, outbox_item.pubkey(), @@ -469,10 +501,17 @@ async fn test_unregistered_peer_cant_transfer() { .await .unwrap(); - let err = transfer(&BadNTT {}, accs, args, Mode::Locking) - .submit_with_signers(&[&outbox_item], &mut ctx) - .await - .unwrap_err(); + let err = transfer( + &BadNTT { + instance: test_data.instance.pubkey(), + }, + accs, + args, + Mode::Locking, + ) + .submit_with_signers(&[&outbox_item], &mut ctx) + .await + .unwrap_err(); // should err as peer account is not initialized assert_eq!( @@ -487,6 +526,7 @@ async fn test_unregistered_peer_cant_transfer() { #[tokio::test] async fn test_cant_transfer_to_unregistered_peer() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -535,6 +575,7 @@ async fn test_cant_transfer_to_unregistered_peer() { #[tokio::test] async fn test_rate_limit() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let clock: Clock = ctx.banks_client.get_sysvar().await.unwrap(); @@ -579,6 +620,7 @@ async fn test_rate_limit() { #[tokio::test] async fn test_transfer_wrong_mode() { let (mut ctx, test_data) = setup(Mode::Burning).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let (accs, args) = init_transfer_accs_args( @@ -617,6 +659,7 @@ async fn test_transfer_wrong_mode() { #[tokio::test] async fn test_cant_transfer_more_than_balance() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -675,6 +718,7 @@ async fn test_cant_transfer_more_than_balance() { #[tokio::test] async fn test_large_tx_queue() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -720,6 +764,7 @@ async fn test_large_tx_queue() { #[tokio::test] async fn test_cant_transfer_when_paused() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -792,6 +837,7 @@ async fn test_cant_transfer_when_paused() { #[tokio::test] async fn test_large_tx_no_queue() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -832,6 +878,8 @@ async fn test_large_tx_no_queue() { #[tokio::test] async fn test_cant_release_queued() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -916,6 +964,8 @@ async fn test_cant_release_queued() { #[tokio::test] async fn test_cant_release_twice() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); diff --git a/solana/programs/ntt-transceiver/tests/admin.rs b/solana/programs/ntt-transceiver/tests/admin.rs index 0e150a7dd..c4be4d57c 100644 --- a/solana/programs/ntt-transceiver/tests/admin.rs +++ b/solana/programs/ntt-transceiver/tests/admin.rs @@ -23,6 +23,7 @@ use wormhole_svm_definitions::solana::{POST_MESSAGE_SHIM_PROGRAM_ID, VERIFY_VAA_ #[tokio::test] async fn test_invalid_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); // try registering system program let err = register_transceiver( @@ -48,6 +49,8 @@ async fn test_invalid_transceiver() { #[tokio::test] async fn test_reregister_all_transceivers() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // Transceivers are expected to be executable which requires them to be added on setup // Thus, we pass all available executable program IDs as dummy_transceivers @@ -141,6 +144,8 @@ async fn test_reregister_all_transceivers() { #[tokio::test] async fn test_deregister_last_enabled_transceiver() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // attempt to deregister only enabled transceiver (standalone transceiver) let err = deregister_transceiver( diff --git a/solana/programs/ntt-transceiver/tests/broadcast.rs b/solana/programs/ntt-transceiver/tests/broadcast.rs index 861a62be7..2f453ea11 100644 --- a/solana/programs/ntt-transceiver/tests/broadcast.rs +++ b/solana/programs/ntt-transceiver/tests/broadcast.rs @@ -30,7 +30,9 @@ use wormhole_svm_definitions::{EncodeFinality, Finality::Finalized}; #[tokio::test] async fn test_broadcast_peer() { - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let ix = broadcast_peer( &good_ntt, @@ -65,6 +67,8 @@ async fn test_broadcast_peer() { #[tokio::test] async fn test_broadcast_id() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let ix = broadcast_id( &good_ntt, @@ -90,7 +94,8 @@ async fn test_broadcast_id() { assert_eq!( WormholeTransceiverInfo::deserialize(&mut &msg.payload[..]).unwrap(), WormholeTransceiverInfo { - manager_address: good_ntt.program().to_bytes(), + // v4: on-the-wire manager identity is the instance pubkey, not the program ID. + manager_address: good_ntt.config().to_bytes(), manager_mode: Mode::Locking, token_address: test_data.mint.to_bytes(), token_decimals: 9, diff --git a/solana/programs/ntt-transceiver/tests/cancel_flow.rs b/solana/programs/ntt-transceiver/tests/cancel_flow.rs index f54e0dc66..6060f8d80 100644 --- a/solana/programs/ntt-transceiver/tests/cancel_flow.rs +++ b/solana/programs/ntt-transceiver/tests/cancel_flow.rs @@ -34,6 +34,8 @@ use wormhole_sdk::Address; async fn test_cancel() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg0 = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); let msg1 = make_transfer_message(&good_ntt, [1u8; 32], 2000, &recipient.pubkey()); diff --git a/solana/programs/ntt-transceiver/tests/receive.rs b/solana/programs/ntt-transceiver/tests/receive.rs index d456c2725..10fd49998 100644 --- a/solana/programs/ntt-transceiver/tests/receive.rs +++ b/solana/programs/ntt-transceiver/tests/receive.rs @@ -48,6 +48,8 @@ use wormhole_sdk::{vaa::digest, Address}; async fn test_receive_instruction_data() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // transfer tokens to custody account spl_token::instruction::transfer_checked( @@ -181,6 +183,8 @@ async fn test_receive_instruction_data() { async fn test_receive_message_account() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); // transfer tokens to custody account spl_token::instruction::transfer_checked( @@ -328,7 +332,9 @@ async fn test_receive_message_account() { #[tokio::test] async fn test_double_receive() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -399,6 +405,8 @@ async fn test_double_receive() { async fn test_wrong_recipient_ntt_manager() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -461,7 +469,9 @@ async fn test_wrong_recipient_ntt_manager() { #[tokio::test] async fn test_wrong_transceiver_peer() { let recipient = Keypair::new(); - let (mut ctx, _test_data) = setup(Mode::Locking).await; + let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -507,6 +517,8 @@ async fn test_wrong_transceiver_peer() { async fn test_wrong_manager_peer() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let mut msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); @@ -570,6 +582,8 @@ async fn test_wrong_manager_peer() { async fn test_wrong_inbox_item() { let recipient = Keypair::new(); let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let msg = make_transfer_message(&good_ntt, [0u8; 32], 1000, &recipient.pubkey()); diff --git a/solana/programs/ntt-transceiver/tests/transfer.rs b/solana/programs/ntt-transceiver/tests/transfer.rs index b04def154..9036db956 100644 --- a/solana/programs/ntt-transceiver/tests/transfer.rs +++ b/solana/programs/ntt-transceiver/tests/transfer.rs @@ -49,6 +49,8 @@ pub async fn test_transfer_burning() { /// This tests the happy path of a transfer, with all the relevant account checks. /// Written as a helper function so both modes can be tested. async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: Mode) { + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); let clock: Clock = ctx.banks_client.get_sysvar().await.unwrap(); @@ -75,6 +77,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: assert_eq!( outbox_item_account, OutboxItem { + // v4: outbox item is bound to its source instance. + manager: good_ntt.config(), amount: TrimmedAmount { amount: 1, decimals: 7 @@ -123,7 +127,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: ) .unwrap(), TransceiverMessage::new( - example_native_token_transfers::ID.to_bytes(), + // v4: source_ntt_manager is the instance pubkey, not the program ID. + good_ntt.config().to_bytes(), OTHER_MANAGER, NttManagerMessage { id: outbox_item.pubkey().to_bytes(), @@ -147,6 +152,8 @@ async fn test_transfer(ctx: &mut ProgramTestContext, test_data: &TestData, mode: #[tokio::test] async fn test_cant_release_queued() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); @@ -222,6 +229,8 @@ async fn test_cant_release_queued() { #[tokio::test] async fn test_cant_release_twice() { let (mut ctx, test_data) = setup(Mode::Locking).await; + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); let outbox_item = Keypair::new(); diff --git a/solana/tests/anchor/anchor.test.ts b/solana/tests/anchor/anchor.test.ts index 864d2f17e..3eac57899 100644 --- a/solana/tests/anchor/anchor.test.ts +++ b/solana/tests/anchor/anchor.test.ts @@ -49,11 +49,15 @@ registerSolanaNtt(); /** * Test Config Constants + * + * v4 multi-host: the deployed program ID is shared, and each NTT deployment + * is identified by an `instance` keypair-created account. The instance pubkey + * is the on-the-wire NTT manager identity (replaces program ID in v3). */ // Native ESM (jest useESM) has no __dirname; derive it from import.meta.url. const __dirname = fileURLToPath(new URL(".", import.meta.url)); const SOLANA_ROOT_DIR = `${__dirname}/../../`; -const VERSION: IdlVersion = "3.0.0"; +const VERSION: IdlVersion = "4.0.0"; const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; const GUARDIAN_KEY = "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"; @@ -90,6 +94,12 @@ const payerAddress = new SolanaAddress(payer.publicKey); const mint = $.keypair.generate(); const mintAuthority = $.keypair.generate(); +/** + * v4 Instance keypair. The pubkey is the on-the-wire NTT manager identity for + * this deployment, and the seed scope for every per-instance PDA. + */ +const instanceKeypair = $.keypair.generate(); + /** * Contract Config */ @@ -159,7 +169,9 @@ describe("example-native-token-transfers", () => { mintAuthority ); - // create our contract client + // create our contract client. v4: pass the instance pubkey in contracts.ntt; + // it's the per-deployment scope used to derive all PDAs and serves as the + // on-the-wire manager identity. ntt = new SolanaNtt( "Devnet", "Solana", @@ -169,6 +181,7 @@ describe("example-native-token-transfers", () => { ntt: { token: testMint.address.toBase58(), manager: NTT_ADDRESS.toBase58(), + instance: instanceKeypair.publicKey.toBase58(), transceiver: { wormhole: nttTransceivers["wormhole"].programId.toBase58(), }, @@ -183,7 +196,9 @@ describe("example-native-token-transfers", () => { let multisigTokenAuthority: anchor.web3.PublicKey; beforeAll(async () => { - // set multisigTokenAuthority as mint authority + // set multisigTokenAuthority as mint authority. The token authority is + // per-instance in v4 — `ntt.pdas.tokenAuthority()` returns the + // instance-scoped PDA because the SDK was constructed with `instance`. multisigTokenAuthority = await $.multisig.create(payer, 1, [ mintAuthority.publicKey, ntt.pdas.tokenAuthority(), @@ -194,12 +209,14 @@ describe("example-native-token-transfers", () => { mintAuthority ); - // init + // init. v4 takes the instance keypair as an additional signer; the + // Anchor `init` allocates the Instance account at that pubkey. const initTxs = ntt.initialize(sender, { mint: testMint.address, outboundLimit: 1_000_000n, mode: "burning", multisigTokenAuthority, + instance: instanceKeypair, }); await signSendWait(ctx, initTxs, signer); @@ -257,13 +274,18 @@ describe("example-native-token-transfers", () => { const txId = txIds![Number(wrapNative)]!; // assert that released bitmap has transceiver bits set - const outboxItemInfo = await ntt.program.account.outboxItem.fetch( + const outboxItemInfo = await ntt.program.account.outboxItem!.fetch( outboxItem.publicKey ); assert .bn(outboxItemInfo.released.map) .setBits(Object.keys(nttTransceivers).length); + // v4: outbox item is bound to its source instance + expect(outboxItemInfo.manager.toBase58()).toEqual( + instanceKeypair.publicKey.toBase58() + ); + // parse event and instruction data to re-build message const [data] = await testWormholePostMessageShim.getMessageData(txId); @@ -283,6 +305,12 @@ describe("example-native-token-transfers", () => { data!.payload ); + // v4: source manager identity over the wire is the instance pubkey, + // not the program ID. + expect(transceiverMessage.sourceNttManager.toUint8Array()).toEqual( + instanceKeypair.publicKey.toBytes() + ); + // assert that amount is what we expect expect( transceiverMessage.nttManagerPayload.payload.trimmedAmount @@ -315,7 +343,8 @@ describe("example-native-token-transfers", () => { owner: nttOwner, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer ) @@ -341,7 +370,8 @@ describe("example-native-token-transfers", () => { owner: nttOwner, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer ); @@ -356,7 +386,8 @@ describe("example-native-token-transfers", () => { await ntt.getConfig(), { currentAuthority: newAuthority.publicKey, - } + }, + ntt.pdas ), payer, newAuthority @@ -377,7 +408,8 @@ describe("example-native-token-transfers", () => { rentPayer: nttOwner, owner: nttOwner, newAuthority: newMultisigAuthority, - } + }, + ntt.pdas ), payer ); @@ -394,7 +426,8 @@ describe("example-native-token-transfers", () => { newAuthority.publicKey, mintAuthority.publicKey, ], - } + }, + ntt.pdas ), payer, newAuthority, @@ -416,7 +449,8 @@ describe("example-native-token-transfers", () => { mintAuthority.publicKey, ], multisigTokenAuthority, - } + }, + ntt.pdas ), payer, newAuthority, @@ -443,14 +477,15 @@ describe("example-native-token-transfers", () => { owner: nttOwner, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer, newAuthority ); const pendingTokenAuthorityRentExemptAmount = await $.connection.getMinimumBalanceForRentExemption( - ntt.program.account.pendingTokenAuthority.size + ntt.program.account.pendingTokenAuthority!.size ); await assert .nativeBalance($.connection, newAuthority.publicKey) @@ -467,7 +502,8 @@ describe("example-native-token-transfers", () => { rentPayer: newAuthority.publicKey, owner: nttOwner, multisigTokenAuthority, - } + }, + ntt.pdas ), payer ); @@ -486,7 +522,8 @@ describe("example-native-token-transfers", () => { rentPayer: newAuthority.publicKey, newAuthority: newAuthority.publicKey, multisigTokenAuthority, - } + }, + ntt.pdas ), payer, newAuthority @@ -516,10 +553,11 @@ describe("example-native-token-transfers", () => { const guardians = new testing.mocks.MockGuardians(0, [GUARDIAN_KEY]); + // v4: recipientNttManager is the instance pubkey, not the program ID. const sendingTransceiverMessage = { sourceNttManager: remoteMgr.address as UniversalAddress, recipientNttManager: new UniversalAddress( - ntt.program.programId.toBytes() + instanceKeypair.publicKey.toBytes() ), nttManagerPayload: { id: encoding.bytes.encode("sequence1".padEnd(32, "0")), @@ -570,6 +608,7 @@ describe("example-native-token-transfers", () => { Solana: { token: mint.publicKey.toBase58(), manager: NTT_ADDRESS.toBase58(), + instance: instanceKeypair.publicKey.toBase58(), transceiver: { wormhole: nttTransceivers["wormhole"].programId.toBase58(), }, @@ -611,14 +650,19 @@ describe("example-native-token-transfers", () => { { ntt: overrides["Solana"] }, payerAddress ); - expect(version).toBe("3.0.0"); + expect(version).toBe("4.0.0"); }); test("It initializes using `emitterAccount` as transceiver address", async () => { const overrideEmitter: (typeof overrides)["Solana"] = JSON.parse( JSON.stringify(overrides["Solana"]) ); - overrideEmitter.transceiver.wormhole = NTT.transceiverPdas(NTT_ADDRESS) + // v4: the emitter is scoped by the instance pubkey, so derive + // accordingly. + overrideEmitter.transceiver.wormhole = NTT.transceiverPdas( + NTT_ADDRESS, + instanceKeypair.publicKey + ) .emitterAccount() .toBase58(); diff --git a/solana/tests/anchor/multi_instance.test.ts b/solana/tests/anchor/multi_instance.test.ts new file mode 100644 index 000000000..1e864a902 --- /dev/null +++ b/solana/tests/anchor/multi_instance.test.ts @@ -0,0 +1,486 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as spl from "@solana/spl-token"; +import { + AccountAddress, + ChainAddress, + ChainContext, + Signer, + UniversalAddress, + Wormhole, + contracts, + deserialize, + encoding, + serialize, + serializePayload, +} from "@wormhole-foundation/sdk"; +import { + Ntt, + register as registerDefinitionsNtt, +} from "@wormhole-foundation/sdk-definitions-ntt"; +import * as testing from "@wormhole-foundation/sdk-definitions/testing"; +import { + SolanaAddress, + SolanaPlatform, + getSolanaSignAndSendSigner, +} from "@wormhole-foundation/sdk-solana"; +import { IdlVersion, getTransceiverProgram } from "../../ts/index.js"; +import { + SolanaNtt, + register as registerSolanaNtt, +} from "../../ts/sdk/index.js"; +import { NTT } from "../../ts/index.js"; +import { TestHelper, TestMint, assert, signSendWait } from "./utils/helpers.js"; + +registerDefinitionsNtt(); +registerSolanaNtt(); + +const SOLANA_ROOT_DIR = `${__dirname}/../../`; +const VERSION: IdlVersion = "4.0.0"; +const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; +const GUARDIAN_KEY = + "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"; +const CORE_BRIDGE_ADDRESS = contracts.coreBridge("Mainnet", "Solana"); +const NTT_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.ExampleNativeTokenTransfers.programId; +const WH_TRANSCEIVER_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.NttTransceiver.programId; +const SOLANA_WORMHOLE_SHIMS: Ntt.Contracts["svmShims"] = {}; + +const $ = new TestHelper("confirmed", TOKEN_PROGRAM); + +const payer = $.keypair.read(`${SOLANA_ROOT_DIR}/keys/test.json`); +const payerAddress = new SolanaAddress(payer.publicKey); + +const w = new Wormhole("Devnet", [SolanaPlatform], { + chains: { Solana: { contracts: { coreBridge: CORE_BRIDGE_ADDRESS } } }, +}); +const ctx: ChainContext<"Devnet", "Solana"> = w + .getPlatform("Solana") + .getChain("Solana", $.connection); + +const remoteMgr: ChainAddress = $.chainAddress.generateFromValue( + "Ethereum", + "nttManager" +); +const remoteXcvr: ChainAddress = $.chainAddress.generateFromValue( + "Ethereum", + "transceiver" +); + +/** + * Helper: build a fresh `SolanaNtt` for a given (mint, instance) pair under + * the shared program. Mirrors the v4 multi-host setup pattern. + */ +function makeNtt( + mintAddr: anchor.web3.PublicKey, + instancePubkey: anchor.web3.PublicKey +): SolanaNtt<"Devnet", "Solana"> { + return new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ntt: { + token: mintAddr.toBase58(), + manager: NTT_ADDRESS.toBase58(), + instance: instancePubkey.toBase58(), + transceiver: { + wormhole: WH_TRANSCEIVER_ADDRESS.toBase58(), + }, + svmShims: SOLANA_WORMHOLE_SHIMS, + }, + }, + VERSION + ); +} + +async function registerRemoteWormholePath( + ntt: SolanaNtt<"Devnet", "Solana">, + owner: AccountAddress<"Solana">, + signer: Signer +) { + await signSendWait( + ctx, + ntt.registerWormholeTransceiver({ + payer: owner, + owner, + }), + signer + ); + await signSendWait( + ctx, + ntt.setWormholeTransceiverPeer(remoteXcvr, owner), + signer + ); + await signSendWait( + ctx, + ntt.setPeer(remoteMgr, 18, 1_000_000n, owner), + signer + ); +} + +/** + * Multi-instance v4 isolation tests. Two independent NTT deployments coexist + * under the same Solana program ID, distinguished only by their `instance` + * keypair. Each has its own owner, mint, custody, peers, and rate limits. + */ +describe("multi-instance", () => { + let signer: Signer; + let sender: AccountAddress<"Solana">; + + // Instance A + const mintAuthorityA = $.keypair.generate(); + const instanceA = $.keypair.generate(); + let testMintA: TestMint; + let nttA: SolanaNtt<"Devnet", "Solana">; + let multisigTokenAuthorityA: anchor.web3.PublicKey; + + // Instance B + const mintAuthorityB = $.keypair.generate(); + const instanceB = $.keypair.generate(); + let testMintB: TestMint; + let nttB: SolanaNtt<"Devnet", "Solana">; + let multisigTokenAuthorityB: anchor.web3.PublicKey; + + beforeAll(async () => { + signer = await getSolanaSignAndSendSigner($.connection, payer); + sender = Wormhole.parseAddress("Solana", signer.address()); + + // Stand up two independent v4 instances under the same program. + testMintA = await TestMint.create( + $.connection, + payer, + mintAuthorityA, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + testMintB = await TestMint.create( + $.connection, + payer, + mintAuthorityB, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + nttA = makeNtt(testMintA.address, instanceA.publicKey); + nttB = makeNtt(testMintB.address, instanceB.publicKey); + + multisigTokenAuthorityA = await $.multisig.create(payer, 1, [ + mintAuthorityA.publicKey, + nttA.pdas.tokenAuthority(), + ]); + await testMintA.setMintAuthority( + payer, + multisigTokenAuthorityA, + mintAuthorityA + ); + + multisigTokenAuthorityB = await $.multisig.create(payer, 1, [ + mintAuthorityB.publicKey, + nttB.pdas.tokenAuthority(), + ]); + await testMintB.setMintAuthority( + payer, + multisigTokenAuthorityB, + mintAuthorityB + ); + + await signSendWait( + ctx, + nttA.initialize(sender, { + mint: testMintA.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: multisigTokenAuthorityA, + instance: instanceA, + }), + signer + ); + + await signSendWait( + ctx, + nttB.initialize(sender, { + mint: testMintB.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: multisigTokenAuthorityB, + instance: instanceB, + }), + signer + ); + }); + + it("derives distinct PDAs for each instance", () => { + const sampleTransfer = NTT.transferArgs(123n, remoteMgr, false); + + // Sanity-check the seed-scoping: every per-instance PDA differs between A and B. + expect(nttA.pdas.configAccount().toBase58()).not.toEqual( + nttB.pdas.configAccount().toBase58() + ); + expect(nttA.pdas.tokenAuthority().toBase58()).not.toEqual( + nttB.pdas.tokenAuthority().toBase58() + ); + expect(nttA.pdas.outboxRateLimitAccount().toBase58()).not.toEqual( + nttB.pdas.outboxRateLimitAccount().toBase58() + ); + expect(nttA.pdas.lutAccount().toBase58()).not.toEqual( + nttB.pdas.lutAccount().toBase58() + ); + expect( + nttA.pdas.sessionAuthority(payer.publicKey, sampleTransfer).toBase58() + ).not.toEqual( + nttB.pdas.sessionAuthority(payer.publicKey, sampleTransfer).toBase58() + ); + + // configAccount() in v4 just returns the instance pubkey. + expect(nttA.pdas.configAccount().toBase58()).toEqual( + instanceA.publicKey.toBase58() + ); + expect(nttB.pdas.configAccount().toBase58()).toEqual( + instanceB.publicKey.toBase58() + ); + }); + + it("isolates owner state per instance (A pause does not pause B)", async () => { + await signSendWait(ctx, nttA.pause(payerAddress), signer); + expect(await nttA.isPaused()).toBe(true); + expect(await nttB.isPaused()).toBe(false); + await signSendWait(ctx, nttA.unpause(payerAddress), signer); + expect(await nttA.isPaused()).toBe(false); + }); + + it("rejects cross-instance VAA replay (recipient_ntt_manager check)", async () => { + // Register peers for both instances so the redeem path can validate them. + await registerRemoteWormholePath(nttA, payerAddress, signer); + await registerRemoteWormholePath(nttB, payerAddress, signer); + + // Build a VAA targeting instance A's pubkey as the recipient manager. + const emitter = new testing.mocks.MockEmitter( + remoteXcvr.address as UniversalAddress, + "Ethereum", + 0n + ); + const guardians = new testing.mocks.MockGuardians(0, [GUARDIAN_KEY]); + + const sendingMessage = { + sourceNttManager: remoteMgr.address as UniversalAddress, + recipientNttManager: new UniversalAddress(instanceA.publicKey.toBytes()), + nttManagerPayload: { + id: encoding.bytes.encode("xinst-replay-1".padEnd(32, "0")), + sender: new UniversalAddress("FACE".padStart(64, "0")), + payload: { + trimmedAmount: { amount: 10_000n, decimals: 8 }, + sourceToken: new UniversalAddress("FAFA".padStart(64, "0")), + recipientAddress: new UniversalAddress(payer.publicKey.toBytes()), + recipientChain: "Solana", + additionalPayload: new Uint8Array(), + }, + }, + transceiverPayload: new Uint8Array(), + } as const; + + const serialized = serializePayload("Ntt:WormholeTransfer", sendingMessage); + const published = emitter.publishMessage(0, serialized, 200); + const rawVaa = guardians.addSignatures(published, [0]); + const vaa = deserialize("Ntt:WormholeTransfer", serialize(rawVaa)); + + // Redeeming against instance B must fail — its + // `recipient_ntt_manager == config.key()` constraint compares against + // `instanceB.publicKey`, which doesn't match the VAA's `instanceA`. + let redeemErr: any = undefined; + try { + await signSendWait(ctx, nttB.redeem([vaa], sender), signer); + } catch (e: any) { + redeemErr = e; + } + expect(redeemErr).toBeDefined(); + }); + + it("rejects cross-instance approval reuse for the same mint", async () => { + const sharedMintAuthority = $.keypair.generate(); + const sharedInstanceA = $.keypair.generate(); + const sharedInstanceB = $.keypair.generate(); + const sharedMint = await TestMint.create( + $.connection, + payer, + sharedMintAuthority, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const sharedNttA = makeNtt(sharedMint.address, sharedInstanceA.publicKey); + const sharedNttB = makeNtt(sharedMint.address, sharedInstanceB.publicKey); + + const holderTokenAccount = await sharedMint.mint( + payer, + payer.publicKey, + 10_000n, + sharedMintAuthority + ); + + const sharedMultisigTokenAuthority = await $.multisig.create(payer, 1, [ + sharedMintAuthority.publicKey, + sharedNttA.pdas.tokenAuthority(), + sharedNttB.pdas.tokenAuthority(), + ]); + await sharedMint.setMintAuthority( + payer, + sharedMultisigTokenAuthority, + sharedMintAuthority + ); + + await signSendWait( + ctx, + sharedNttA.initialize(sender, { + mint: sharedMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: sharedMultisigTokenAuthority, + instance: sharedInstanceA, + }), + signer + ); + await signSendWait( + ctx, + sharedNttB.initialize(sender, { + mint: sharedMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: sharedMultisigTokenAuthority, + instance: sharedInstanceB, + }), + signer + ); + await registerRemoteWormholePath(sharedNttB, payerAddress, signer); + + const transferArgs = NTT.transferArgs(1_000n, remoteMgr, false); + await $.sendAndConfirm( + spl.createApproveInstruction( + holderTokenAccount, + sharedNttA.pdas.sessionAuthority(payer.publicKey, transferArgs), + payer.publicKey, + 1_000n, + [], + TOKEN_PROGRAM + ), + payer + ); + + const outboxItem = $.keypair.generate(); + const xfer = NTT.createTransferBurnInstruction( + sharedNttB.program, + await sharedNttB.getConfig(), + { + payer: payer.publicKey, + from: holderTokenAccount, + fromAuthority: payer.publicKey, + transferArgs, + outboxItem: outboxItem.publicKey, + }, + sharedNttB.pdas + ); + + await assert + .promise($.sendAndConfirm(await xfer, payer, outboxItem)) + .fails(); + }); + + it("uses instance-scoped inbox lookups in v4 helpers", async () => { + const inboundMintAuthority = $.keypair.generate(); + const inboundInstance = $.keypair.generate(); + const inboundMint = await TestMint.create( + $.connection, + payer, + inboundMintAuthority, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const inboundNtt = makeNtt(inboundMint.address, inboundInstance.publicKey); + const inboundMultisigTokenAuthority = await $.multisig.create(payer, 1, [ + inboundMintAuthority.publicKey, + inboundNtt.pdas.tokenAuthority(), + ]); + await inboundMint.setMintAuthority( + payer, + inboundMultisigTokenAuthority, + inboundMintAuthority + ); + + await signSendWait( + ctx, + inboundNtt.initialize(sender, { + mint: inboundMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority: inboundMultisigTokenAuthority, + instance: inboundInstance, + }), + signer + ); + await registerRemoteWormholePath(inboundNtt, payerAddress, signer); + + const emitter = new testing.mocks.MockEmitter( + remoteXcvr.address as UniversalAddress, + "Ethereum", + 0n + ); + const guardians = new testing.mocks.MockGuardians(0, [GUARDIAN_KEY]); + const inboundMessage = { + sourceNttManager: remoteMgr.address as UniversalAddress, + recipientNttManager: new UniversalAddress( + inboundInstance.publicKey.toBytes() + ), + nttManagerPayload: { + id: encoding.bytes.encode("scoped-inbox-lookup".padEnd(32, "0")), + sender: new UniversalAddress("BEEF".padStart(64, "0")), + payload: { + trimmedAmount: { amount: 25_000n, decimals: 8 }, + sourceToken: new UniversalAddress("ABCD".padStart(64, "0")), + recipientAddress: new UniversalAddress(payer.publicKey.toBytes()), + recipientChain: "Solana", + additionalPayload: new Uint8Array(), + }, + }, + transceiverPayload: new Uint8Array(), + } as const; + + const published = emitter.publishMessage( + 0, + serializePayload("Ntt:WormholeTransfer", inboundMessage), + 200 + ); + const rawVaa = guardians.addSignatures(published, [0]); + const vaa = deserialize("Ntt:WormholeTransfer", serialize(rawVaa)); + + await signSendWait(ctx, inboundNtt.redeem([vaa], sender), signer); + + const inboxItem = await NTT.getInboxItem( + inboundNtt.program, + "Ethereum", + vaa.payload.nttManagerPayload, + inboundNtt.pdas + ); + expect(inboxItem.recipientAddress.toBase58()).toEqual( + payer.publicKey.toBase58() + ); + + const releaseIx = await NTT.createReleaseInboundMintInstruction( + inboundNtt.program, + await inboundNtt.getConfig(), + { + payer: payer.publicKey, + chain: "Ethereum", + nttMessage: vaa.payload.nttManagerPayload, + revertWhenNotReady: false, + }, + inboundNtt.pdas + ); + expect(releaseIx).toBeDefined(); + }); +}); diff --git a/solana/tests/anchor/upgrade_authority_decoupling.test.ts b/solana/tests/anchor/upgrade_authority_decoupling.test.ts new file mode 100644 index 000000000..400ad0395 --- /dev/null +++ b/solana/tests/anchor/upgrade_authority_decoupling.test.ts @@ -0,0 +1,182 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as spl from "@solana/spl-token"; +import { + AccountAddress, + ChainContext, + Signer, + Wormhole, + contracts, +} from "@wormhole-foundation/sdk"; +import { + Ntt, + register as registerDefinitionsNtt, +} from "@wormhole-foundation/sdk-definitions-ntt"; +import { + SolanaAddress, + SolanaPlatform, + getSolanaSignAndSendSigner, +} from "@wormhole-foundation/sdk-solana"; +import { IdlVersion } from "../../ts/index.js"; +import { + SolanaNtt, + register as registerSolanaNtt, +} from "../../ts/sdk/index.js"; +import { TestHelper, TestMint, signSendWait } from "./utils/helpers.js"; +import { + BPF_LOADER_UPGRADEABLE_PROGRAM_ID, + programDataAddress, +} from "../../ts/lib/utils.js"; + +registerDefinitionsNtt(); +registerSolanaNtt(); + +const SOLANA_ROOT_DIR = `${__dirname}/../../`; +const VERSION: IdlVersion = "4.0.0"; +const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; +const CORE_BRIDGE_ADDRESS = contracts.coreBridge("Mainnet", "Solana"); +const NTT_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.ExampleNativeTokenTransfers.programId; +const WH_TRANSCEIVER_ADDRESS: anchor.web3.PublicKey = + anchor.workspace.NttTransceiver.programId; +const SOLANA_WORMHOLE_SHIMS: Ntt.Contracts["svmShims"] = {}; + +const $ = new TestHelper("confirmed", TOKEN_PROGRAM); +const payer = $.keypair.read(`${SOLANA_ROOT_DIR}/keys/test.json`); +const payerAddress = new SolanaAddress(payer.publicKey); + +const w = new Wormhole("Devnet", [SolanaPlatform], { + chains: { Solana: { contracts: { coreBridge: CORE_BRIDGE_ADDRESS } } }, +}); +const ctx: ChainContext<"Devnet", "Solana"> = w + .getPlatform("Solana") + .getChain("Solana", $.connection); + +/** + * Decode the upgrade-authority address out of the BPF-loader-upgradeable + * `program_data` account. Layout (Solana 1.x bpf_loader_upgradeable): + * - 4 bytes state discriminator + * - 8 bytes slot (u64 LE) + * - 1 byte `Option` tag + * - 32 bytes pubkey when tag == 1 + */ +async function readProgramUpgradeAuthority( + connection: anchor.web3.Connection, + programId: anchor.web3.PublicKey +): Promise { + const programData = programDataAddress(programId); + const info = await connection.getAccountInfo(programData); + if (info === null) { + throw new Error( + `program_data account not found for ${programId.toBase58()}` + ); + } + // Sanity: the program_data account is owned by the BPF loader upgradeable. + expect(info.owner.toBase58()).toEqual( + BPF_LOADER_UPGRADEABLE_PROGRAM_ID.toBase58() + ); + const optionTag = info.data.readUInt8(4 + 8); + if (optionTag === 0) return null; + return new anchor.web3.PublicKey( + info.data.subarray(4 + 8 + 1, 4 + 8 + 1 + 32) + ); +} + +/** + * v4 ownership-decoupling regression test. + * + * In v3 the singleton `transfer_ownership` instruction also performed a CPI + * into the BPF-loader-upgradeable program to hand the program upgrade + * authority over to (or via) the `[b"upgrade_lock"]` PDA. v4 deliberately + * decouples instance ownership from the program upgrade authority — instance + * ownership transfers are pure data mutations on the `Config` account. + * + * This test asserts that calling `transferOwnership` does NOT touch the + * `program_data` account's upgrade_authority_address. + */ +describe("v4 upgrade authority decoupling", () => { + let signer: Signer; + let sender: AccountAddress<"Solana">; + const mintAuthority = $.keypair.generate(); + const instanceKeypair = $.keypair.generate(); + let testMint: TestMint; + let ntt: SolanaNtt<"Devnet", "Solana">; + + beforeAll(async () => { + signer = await getSolanaSignAndSendSigner($.connection, payer); + sender = Wormhole.parseAddress("Solana", signer.address()); + + testMint = await TestMint.create( + $.connection, + payer, + mintAuthority, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID + ); + + ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ntt: { + token: testMint.address.toBase58(), + manager: NTT_ADDRESS.toBase58(), + instance: instanceKeypair.publicKey.toBase58(), + transceiver: { wormhole: WH_TRANSCEIVER_ADDRESS.toBase58() }, + svmShims: SOLANA_WORMHOLE_SHIMS, + }, + }, + VERSION + ); + + const multisigTokenAuthority = await $.multisig.create(payer, 1, [ + mintAuthority.publicKey, + ntt.pdas.tokenAuthority(), + ]); + await testMint.setMintAuthority( + payer, + multisigTokenAuthority, + mintAuthority + ); + + await signSendWait( + ctx, + ntt.initialize(sender, { + mint: testMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisigTokenAuthority, + instance: instanceKeypair, + }), + signer + ); + }); + + it("transferOwnership does not touch the program upgrade authority", async () => { + const before = await readProgramUpgradeAuthority($.connection, NTT_ADDRESS); + + const newOwner = $.keypair.generate(); + await signSendWait( + ctx, + ntt.setOwner(newOwner.publicKey, payerAddress), + signer + ); + + const after = await readProgramUpgradeAuthority($.connection, NTT_ADDRESS); + + // The instance owner has been queued for transfer (pending_owner is set), + // but the program upgrade authority must be byte-identical. + if (before === null) { + expect(after).toBeNull(); + } else { + expect(after).not.toBeNull(); + expect(after!.toBase58()).toEqual(before.toBase58()); + } + + // And the on-chain Instance shows the pending owner. + const cfg = await ntt.getConfig(); + expect(cfg.pendingOwner?.toBase58()).toEqual(newOwner.publicKey.toBase58()); + }); +}); diff --git a/solana/tests/anchor/utils/helpers.ts b/solana/tests/anchor/utils/helpers.ts index 4eb8fa6e4..10a6e805a 100644 --- a/solana/tests/anchor/utils/helpers.ts +++ b/solana/tests/anchor/utils/helpers.ts @@ -785,13 +785,9 @@ export const signSendWait = async ( chain: ChainContext, txs: AsyncGenerator, signer: Signer -): Promise => { - try { - const txIds = await ssw(chain, txs, signer); - return txIds.map(({ txid }) => txid); - } catch (e) { - console.error(e); - } +): Promise => { + const txIds = await ssw(chain, txs, signer); + return txIds.map(({ txid }) => txid); }; /** diff --git a/solana/tests/cargo/src/common/fixtures.rs b/solana/tests/cargo/src/common/fixtures.rs index 5a8e911fe..7152d47bb 100644 --- a/solana/tests/cargo/src/common/fixtures.rs +++ b/solana/tests/cargo/src/common/fixtures.rs @@ -22,6 +22,11 @@ pub const UNREGISTERED_CHAIN: u16 = u16::MAX; pub struct TestData { pub governance: Governance, pub program_owner: Keypair, + /// v4: keypair-created Instance account. Generated per setup; its pubkey + /// is registered as the test-wide instance via + /// `sdk::accounts::ntt::set_test_instance` and used everywhere `good_ntt` + /// derives a per-instance PDA. + pub instance: Keypair, pub mint_authority: Keypair, pub mint: Pubkey, pub bad_mint_authority: Keypair, diff --git a/solana/tests/cargo/src/helpers/admin.rs b/solana/tests/cargo/src/helpers/admin.rs index e68e01338..505e48bd0 100644 --- a/solana/tests/cargo/src/helpers/admin.rs +++ b/solana/tests/cargo/src/helpers/admin.rs @@ -7,6 +7,8 @@ use solana_program_test::ProgramTestContext; use crate::{common::query::GetAccountDataAnchor, sdk::accounts::NTT}; pub async fn assert_threshold(ntt: &NTT, ctx: &mut ProgramTestContext, expected_threshold: u8) { + // v4: the Config account lives at the instance pubkey (keypair-created), + // not at a `[b"config"]` PDA. let config_account: Config = ctx.get_account_data_anchor(ntt.config()).await; assert_eq!(config_account.threshold, expected_threshold); } diff --git a/solana/tests/cargo/src/helpers/setup.rs b/solana/tests/cargo/src/helpers/setup.rs index e3ccf799e..43a88d931 100644 --- a/solana/tests/cargo/src/helpers/setup.rs +++ b/solana/tests/cargo/src/helpers/setup.rs @@ -170,6 +170,11 @@ pub async fn setup_ntt_with_token_program_id( mode: Mode, token_program_id: &Pubkey, ) { + // v4: bind the SDK helpers to this test's instance pubkey. All PDA + // helpers on `ntt` and `xcvr` derive against this instance. + let good_ntt = good_ntt(test_data.instance.pubkey()); + let good_ntt_transceiver = good_ntt_transceiver(test_data.instance.pubkey()); + if mode == Mode::Burning { // we set the mint authority to the ntt contract in burn/mint mode spl_token_2022::instruction::set_authority( @@ -190,7 +195,7 @@ pub async fn setup_ntt_with_token_program_id( &good_ntt, Initialize { payer: ctx.payer.pubkey(), - deployer: test_data.program_owner.pubkey(), + owner: test_data.program_owner.pubkey(), mint: test_data.mint, multisig_token_authority: None, }, @@ -202,7 +207,9 @@ pub async fn setup_ntt_with_token_program_id( }, token_program_id, ) - .submit_with_signers(&[&test_data.program_owner], ctx) + // v4: the Instance account is keypair-allocated by Anchor's `init`, so the + // instance keypair must sign alongside the payer/owner. + .submit_with_signers(&[&test_data.program_owner, &test_data.instance], ctx) .await .unwrap(); @@ -347,11 +354,17 @@ pub async fn setup_accounts(ctx: &mut ProgramTestContext, program_owner: Keypair .await .unwrap(); + // v4: generate the per-test-binary instance keypair. Each SDK helper takes + // the instance pubkey as an explicit argument; tests pass + // `test_data.instance.pubkey()` (or a wrong one for negative tests). + let instance = Keypair::new(); + TestData { governance: Governance { program: wormhole_governance::ID, }, program_owner, + instance, mint_authority, mint: mint.pubkey(), bad_mint_authority, @@ -447,11 +460,17 @@ pub async fn setup_accounts_with_transfer_fee( .await .unwrap(); + // v4: generate the per-test-binary instance keypair. Each SDK helper takes + // the instance pubkey as an explicit argument; tests pass + // `test_data.instance.pubkey()` (or a wrong one for negative tests). + let instance = Keypair::new(); + TestData { governance: Governance { program: wormhole_governance::ID, }, program_owner, + instance, mint_authority, mint: mint.pubkey(), bad_mint_authority, diff --git a/solana/tests/cargo/src/helpers/transfer.rs b/solana/tests/cargo/src/helpers/transfer.rs index 9fce2085a..2fd200284 100644 --- a/solana/tests/cargo/src/helpers/transfer.rs +++ b/solana/tests/cargo/src/helpers/transfer.rs @@ -44,6 +44,9 @@ pub fn init_transfer_accs_args( (accs, args) } +/// v4: `recipient_ntt_manager` in the constructed transceiver message is the +/// destination instance pubkey (not the program ID), because that's what the +/// on-chain redeem path validates against (`recipient_ntt_manager == config.key()`). pub fn make_transfer_message( ntt: &NTT, id: [u8; 32], @@ -67,7 +70,7 @@ pub fn make_transfer_message( TransceiverMessage::new( OTHER_MANAGER, - ntt.program().to_bytes(), + ntt.config().to_bytes(), ntt_manager_message.clone(), vec![], ) diff --git a/solana/tests/cargo/src/sdk/accounts/ntt.rs b/solana/tests/cargo/src/sdk/accounts/ntt.rs index 334c32571..1e965de96 100644 --- a/solana/tests/cargo/src/sdk/accounts/ntt.rs +++ b/solana/tests/cargo/src/sdk/accounts/ntt.rs @@ -1,6 +1,5 @@ use anchor_lang::{prelude::Pubkey, Id}; use example_native_token_transfers::{ - config::Config, instructions::TransferArgs, queue::{ inbox::{InboxItem, InboxRateLimit}, @@ -13,7 +12,6 @@ use example_native_token_transfers::{ use ntt_messages::{ntt::NativeTokenTransfer, ntt_manager::NttManagerMessage}; use sha3::{Digest, Keccak256}; use wormhole_io::TypePrefixedPayload; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable; use crate::sdk::transceivers::accounts::ntt_transceiver::NTTTransceiver; @@ -21,6 +19,11 @@ use super::wormhole::Wormhole; pub type NTT = dyn NTTAccounts; +/// v4 test SDK: every per-instance PDA derivation flows through `config()`, +/// which returns the keypair-created Instance account pubkey for the +/// deployment under test. Implementors hold the pubkey on themselves; the +/// trait abstraction is preserved so negative tests can swap in a `BadNTT` +/// that returns intentionally-wrong addresses. pub trait NTTAccounts { fn program(&self) -> Pubkey { example_native_token_transfers::ID @@ -32,25 +35,33 @@ pub trait NTTAccounts { } } - fn config(&self) -> Pubkey { - let (config, _) = Pubkey::find_program_address(&[Config::SEED_PREFIX], &self.program()); - config - } + /// The instance pubkey for this NTT deployment. In v4 this is also the + /// on-the-wire NTT manager identity, and the seed scope for every other + /// per-instance PDA. + fn config(&self) -> Pubkey; fn outbox_rate_limit(&self) -> Pubkey { - let (outbox_rate_limit, _) = - Pubkey::find_program_address(&[OutboxRateLimit::SEED_PREFIX], &self.program()); + let (outbox_rate_limit, _) = Pubkey::find_program_address( + &[OutboxRateLimit::SEED_PREFIX, self.config().as_ref()], + &self.program(), + ); outbox_rate_limit } fn inbox_rate_limit(&self, chain: u16) -> Pubkey { let (inbox_rate_limit, _) = Pubkey::find_program_address( - &[InboxRateLimit::SEED_PREFIX, &chain.to_be_bytes()], + &[ + InboxRateLimit::SEED_PREFIX, + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); inbox_rate_limit } + /// session_authority is per-(sender, transfer_args) — already unique + /// without instance scoping (matches the program-side seeds). fn session_authority(&self, sender: &Pubkey, args: &TransferArgs) -> Pubkey { let TransferArgs { amount, @@ -87,21 +98,31 @@ pub trait NTTAccounts { hasher.update(&TypePrefixedPayload::to_vec_payload(&ntt_manager_message)); let (inbox_item, _) = Pubkey::find_program_address( - &[InboxItem::SEED_PREFIX, &hasher.finalize()], + &[ + InboxItem::SEED_PREFIX, + self.config().as_ref(), + &hasher.finalize(), + ], &self.program(), ); inbox_item } fn token_authority(&self) -> Pubkey { - let (token_authority, _) = - Pubkey::find_program_address(&[TOKEN_AUTHORITY_SEED], &self.program()); + let (token_authority, _) = Pubkey::find_program_address( + &[TOKEN_AUTHORITY_SEED, self.config().as_ref()], + &self.program(), + ); token_authority } fn registered_transceiver(&self, transceiver: &Pubkey) -> Pubkey { let (registered_transceiver, _) = Pubkey::find_program_address( - &[RegisteredTransceiver::SEED_PREFIX, transceiver.as_ref()], + &[ + RegisteredTransceiver::SEED_PREFIX, + self.config().as_ref(), + transceiver.as_ref(), + ], &self.program(), ); registered_transceiver @@ -109,7 +130,11 @@ pub trait NTTAccounts { fn peer(&self, chain: u16) -> Pubkey { let (peer, _) = Pubkey::find_program_address( - &[b"peer".as_ref(), &chain.to_be_bytes()], + &[ + b"peer".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); peer @@ -128,26 +153,26 @@ pub trait NTTAccounts { } fn wormhole_sequence(&self, ntt_transceiver: &NTTTransceiver) -> Pubkey { + // ntt_transceiver carries its own instance binding internally. self.wormhole().sequence(&ntt_transceiver.emitter()) } +} - fn program_data(&self) -> Pubkey { - let (addr, _) = - Pubkey::find_program_address(&[self.program().as_ref()], &bpf_loader_upgradeable::id()); - addr - } +/// Implements the account derivations correctly for tests. Holds the instance +/// pubkey for this deployment. +pub struct GoodNTT { + pub instance: Pubkey, +} - fn upgrade_lock(&self) -> Pubkey { - let (addr, _) = Pubkey::find_program_address(&[b"upgrade_lock"], &self.program()); - addr +impl NTTAccounts for GoodNTT { + fn config(&self) -> Pubkey { + self.instance } } -/// This implements the account derivations correctly. For negative tests, other -/// implementations will implement them incorrectly. -pub struct GoodNTT {} - -#[allow(non_upper_case_globals)] -pub const good_ntt: GoodNTT = GoodNTT {}; - -impl NTTAccounts for GoodNTT {} +/// Construct a [`GoodNTT`] bound to `instance`. Tests use it like +/// `let ntt = good_ntt(test_data.instance.pubkey());` and then pass `&ntt` +/// to SDK helpers. +pub fn good_ntt(instance: Pubkey) -> GoodNTT { + GoodNTT { instance } +} diff --git a/solana/tests/cargo/src/sdk/instructions/initialize.rs b/solana/tests/cargo/src/sdk/instructions/initialize.rs index 28d446471..10fe344ce 100644 --- a/solana/tests/cargo/src/sdk/instructions/initialize.rs +++ b/solana/tests/cargo/src/sdk/instructions/initialize.rs @@ -2,13 +2,15 @@ use anchor_lang::{prelude::Pubkey, system_program::System, Id, InstructionData, use anchor_spl::{associated_token::AssociatedToken, token::Token}; use example_native_token_transfers::instructions::InitializeArgs; use solana_sdk::instruction::Instruction; -use wormhole_solana_utils::cpi::bpf_loader_upgradeable::BpfLoaderUpgradeable; use crate::sdk::accounts::NTT; +/// v4 initialize accounts. The Instance account is keypair-allocated at +/// `ntt.config()`; the caller is responsible for adding that keypair as a +/// signer on the transaction. pub struct Initialize { pub payer: Pubkey, - pub deployer: Pubkey, + pub owner: Pubkey, pub mint: Pubkey, pub multisig_token_authority: Option, } @@ -25,11 +27,9 @@ pub fn initialize_with_token_program_id( ) -> Instruction { let data = example_native_token_transfers::instruction::Initialize { args }; - let bpf_loader_upgradeable_program = BpfLoaderUpgradeable::id(); let accounts = example_native_token_transfers::accounts::Initialize { payer: accounts.payer, - deployer: accounts.deployer, - program_data: ntt.program_data(), + owner: accounts.owner, config: ntt.config(), mint: accounts.mint, rate_limit: ntt.outbox_rate_limit(), @@ -38,11 +38,9 @@ pub fn initialize_with_token_program_id( custody: ntt.custody_with_token_program_id(&accounts.mint, token_program_id), token_program: *token_program_id, associated_token_program: AssociatedToken::id(), - bpf_loader_upgradeable_program, system_program: System::id(), }; - // fetch account Instruction { program_id: ntt.program(), accounts: accounts.to_account_metas(None), diff --git a/solana/tests/cargo/src/sdk/instructions/release_inbound.rs b/solana/tests/cargo/src/sdk/instructions/release_inbound.rs index c25305818..30185a9fd 100644 --- a/solana/tests/cargo/src/sdk/instructions/release_inbound.rs +++ b/solana/tests/cargo/src/sdk/instructions/release_inbound.rs @@ -21,6 +21,8 @@ pub fn release_inbound_unlock( let accounts = example_native_token_transfers::accounts::ReleaseInboundUnlock { common: example_native_token_transfers::accounts::ReleaseInbound { payer: accounts.payer, + // anchor's client-side NotPausedConfig holds a Pubkey, not a typed + // Account; instruction construction only needs the address. config: NotPausedConfig { config: ntt.config(), }, diff --git a/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs b/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs index fc1637177..44146a235 100644 --- a/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs +++ b/solana/tests/cargo/src/sdk/transceivers/legacy/accounts/ntt_transceiver.rs @@ -2,13 +2,22 @@ use anchor_lang::prelude::Pubkey; pub type NTTTransceiver = dyn NTTTransceiverAccounts; +/// v4: emitter, transceiver_peer, transceiver_message PDAs are scoped by the +/// instance pubkey; implementors carry it. wormhole_message and +/// unverified_message_account are content-addressed and don't need instance +/// scoping. pub trait NTTTransceiverAccounts { fn program(&self) -> Pubkey { example_native_token_transfers::ID } + fn config(&self) -> Pubkey; + fn emitter(&self) -> Pubkey { - let (emitter, _) = Pubkey::find_program_address(&[b"emitter".as_ref()], &self.program()); + let (emitter, _) = Pubkey::find_program_address( + &[b"emitter".as_ref(), self.config().as_ref()], + &self.program(), + ); emitter } @@ -22,7 +31,11 @@ pub trait NTTTransceiverAccounts { fn transceiver_peer(&self, chain: u16) -> Pubkey { let (peer, _) = Pubkey::find_program_address( - &[b"transceiver_peer".as_ref(), &chain.to_be_bytes()], + &[ + b"transceiver_peer".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); peer @@ -30,7 +43,12 @@ pub trait NTTTransceiverAccounts { fn transceiver_message(&self, chain: u16, id: [u8; 32]) -> Pubkey { let (transceiver_message, _) = Pubkey::find_program_address( - &[b"transceiver_message".as_ref(), &chain.to_be_bytes(), &id], + &[ + b"transceiver_message".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + &id, + ], &self.program(), ); transceiver_message @@ -45,11 +63,18 @@ pub trait NTTTransceiverAccounts { } } -/// This implements the account derivations correctly. For negative tests, other -/// implementations will implement them incorrectly. -pub struct GoodNTTTransceiver {} +/// Holds the instance pubkey for this deployment. For negative tests, other +/// implementations can return wrong addresses. +pub struct GoodNTTTransceiver { + pub instance: Pubkey, +} -#[allow(non_upper_case_globals)] -pub const good_ntt_transceiver: GoodNTTTransceiver = GoodNTTTransceiver {}; +impl NTTTransceiverAccounts for GoodNTTTransceiver { + fn config(&self) -> Pubkey { + self.instance + } +} -impl NTTTransceiverAccounts for GoodNTTTransceiver {} +pub fn good_ntt_transceiver(instance: Pubkey) -> GoodNTTTransceiver { + GoodNTTTransceiver { instance } +} diff --git a/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs b/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs index b240b6a70..8004987b0 100644 --- a/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs +++ b/solana/tests/cargo/src/sdk/transceivers/shim/accounts/ntt_transceiver.rs @@ -5,11 +5,15 @@ use super::post_message_shim::PostMessageShim; pub type NTTTransceiver = dyn NTTTransceiverAccounts; +/// v4: emitter, transceiver_peer, transceiver_message, outbox_item_signer +/// PDAs are all instance-scoped. wormhole_message piggy-backs on emitter. pub trait NTTTransceiverAccounts { fn program(&self) -> Pubkey { ntt_transceiver::ID } + fn config(&self) -> Pubkey; + fn post_message_shim(&self) -> PostMessageShim { PostMessageShim { program: POST_MESSAGE_SHIM_PROGRAM_ID, @@ -21,13 +25,18 @@ pub trait NTTTransceiverAccounts { } fn emitter(&self) -> Pubkey { - let (emitter, _) = Pubkey::find_program_address(&[b"emitter".as_ref()], &self.program()); + let (emitter, _) = Pubkey::find_program_address( + &[b"emitter".as_ref(), self.config().as_ref()], + &self.program(), + ); emitter } fn outbox_item_signer(&self) -> Pubkey { - let (outbox_item_signer, _) = - Pubkey::find_program_address(&[b"outbox_item_signer".as_ref()], &self.program()); + let (outbox_item_signer, _) = Pubkey::find_program_address( + &[b"outbox_item_signer".as_ref(), self.config().as_ref()], + &self.program(), + ); outbox_item_signer } @@ -41,7 +50,11 @@ pub trait NTTTransceiverAccounts { fn transceiver_peer(&self, chain: u16) -> Pubkey { let (peer, _) = Pubkey::find_program_address( - &[b"transceiver_peer".as_ref(), &chain.to_be_bytes()], + &[ + b"transceiver_peer".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + ], &self.program(), ); peer @@ -49,7 +62,12 @@ pub trait NTTTransceiverAccounts { fn transceiver_message(&self, chain: u16, id: [u8; 32]) -> Pubkey { let (transceiver_message, _) = Pubkey::find_program_address( - &[b"transceiver_message".as_ref(), &chain.to_be_bytes(), &id], + &[ + b"transceiver_message".as_ref(), + self.config().as_ref(), + &chain.to_be_bytes(), + &id, + ], &self.program(), ); transceiver_message @@ -64,11 +82,16 @@ pub trait NTTTransceiverAccounts { } } -/// This implements the account derivations correctly. For negative tests, other -/// implementations will implement them incorrectly. -pub struct GoodNTTTransceiver {} +pub struct GoodNTTTransceiver { + pub instance: Pubkey, +} -#[allow(non_upper_case_globals)] -pub const good_ntt_transceiver: GoodNTTTransceiver = GoodNTTTransceiver {}; +impl NTTTransceiverAccounts for GoodNTTTransceiver { + fn config(&self) -> Pubkey { + self.instance + } +} -impl NTTTransceiverAccounts for GoodNTTTransceiver {} +pub fn good_ntt_transceiver(instance: Pubkey) -> GoodNTTTransceiver { + GoodNTTTransceiver { instance } +} From e597ad2663df1278c2b5a20139c333d406336ab5 Mon Sep 17 00:00:00 2001 From: csongor Date: Fri, 8 May 2026 14:53:19 +0900 Subject: [PATCH 04/28] step 4: cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-tenant Solana NTT (>= v4) PDAs are scoped by the per-deployment Instance pubkey, so every CLI surface that reads or writes a manager needs to know it: - ChainConfig grows an optional `instance` field; SolanaDeploymentResult threads it back from `deploy()`. - pullChainConfig / nttFromManager take an optional `solanaInstance` arg, plumbed through every caller (add-chain, upgrade, clone, transfer-ownership, set-mint-authority, solana subcommands, config-mgmt's pull loop). - deploySvm gains a multi-tenant branch: generates (or loads via --instance-key) an Instance keypair, sets contracts.ntt.instance before SDK construction, co-signs `initialize`, and returns the instance pubkey alongside the program address. addSolanaInstance no longer rethrows on initializeOrUpdateLUT failure (matches deploySvm's swallow-on-LUT shape; lets dev environments without the wormhole core bridge still write deployment.json). - `ntt solana token-authority --instance ` derives the per-instance PDA before mint-authority handoff. SolanaNtt's constructor now refuses the v4-without-instance and v3-with-instance footguns at construction time (the PDA factory accepts an optional config arg for back-compat, so without this the SDK silently falls back to legacy singleton derivations against a multi-tenant manager). Old "isV4" branches renamed to "multiTenant" in cli/src/solana/deploy.ts to capture the property we're checking. Local cli/test/solana.sh: - export COPYFILE_DISABLE=1 (macOS' AppleDouble metadata files break solana-test-validator's genesis-archive unpacker). - airdrop with --commitment finalized — `solana program deploy` uses --commitment finalized and was racing finalization on a fresh airdrop, surfacing as a bogus "insufficient funds" against a 50-SOL account. - new v4 multi-tenant section: asserts `ntt upgrade` is blocked at the v3->v4 boundary by canUpgrade(), then patches Anchor.toml + lib.rs to declare a locally-keypair'd id, rebuilds, deploys, and walks `add-chain --instance-of` end-to-end. cleanup() trap unconditionally restores the patched source files on exit. --- cli/src/commands/add-chain.ts | 19 ++- cli/src/commands/set-mint-authority.ts | 3 +- cli/src/commands/shared.ts | 7 + cli/src/commands/solana.ts | 30 ++++- cli/src/commands/transfer-ownership.ts | 3 +- cli/src/commands/upgrade.ts | 9 +- cli/src/config-mgmt.ts | 10 +- cli/src/deploy.ts | 27 +++- cli/src/query.ts | 9 +- cli/src/solana/deploy.ts | 82 +++++++++++- cli/test/solana.sh | 173 ++++++++++++++++++++++++- solana/ts/sdk/ntt.ts | 29 +++-- 12 files changed, 352 insertions(+), 49 deletions(-) diff --git a/cli/src/commands/add-chain.ts b/cli/src/commands/add-chain.ts index e517ada1c..8e772b5e3 100644 --- a/cli/src/commands/add-chain.ts +++ b/cli/src/commands/add-chain.ts @@ -350,9 +350,9 @@ export function createAddChainCommand( const [config, _ctx, _ntt, decimals] = await pullChainConfig( network, deployedManager, - overrides + overrides, + instance.toBase58() ); - config.instance = instance.toBase58(); console.log("token decimals:", colors.yellow(decimals)); deployments.chains[chain] = config; @@ -388,13 +388,26 @@ export function createAddChainCommand( argv["sui-treasury-cap"], argv["gas-estimate-multiplier"], cclConfig, - overrides + overrides, + argv["instance-key"] ); + // Multi-tenant Solana deploys return the per-instance Config pubkey + // alongside the manager program ID. Thread it into pullChainConfig so + // the SDK constructs against the multi-tenant layout (it throws + // otherwise) and persist it on the resulting config. + const solanaInstance = + platform === "Solana" && + "instance" in deployedManager && + typeof deployedManager.instance === "string" + ? deployedManager.instance + : undefined; + const [config, _ctx, _ntt, decimals] = await pullChainConfig( network, deployedManager, overrides, + solanaInstance, { waitForTransceiver: true } ); diff --git a/cli/src/commands/set-mint-authority.ts b/cli/src/commands/set-mint-authority.ts index 766a92981..cd787145e 100644 --- a/cli/src/commands/set-mint-authority.ts +++ b/cli/src/commands/set-mint-authority.ts @@ -125,7 +125,8 @@ export function createSetMintAuthorityCommand( const [, , ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); solanaNtt = ntt as SolanaNtt; tokenMint = (await solanaNtt.getConfig()).mint; diff --git a/cli/src/commands/shared.ts b/cli/src/commands/shared.ts index adf7e3de5..bfd1b9d5e 100644 --- a/cli/src/commands/shared.ts +++ b/cli/src/commands/shared.ts @@ -134,6 +134,13 @@ export const CCL_CONTRACT_ADDRESSES: Partial< // These are local-only configurations that don't have on-chain representations export const EXCLUDED_DIFF_PATHS = ["managerVariant"]; +// Extended ChainAddress type for Solana v4 deployments that include the +// per-instance Config pubkey alongside the manager program ID. +export type SolanaDeploymentResult = ChainAddress & { + /** v4-only: the keypair-created Instance pubkey under the shared program. */ + instance?: string; +}; + // Extended ChainAddress type for Sui deployments that includes additional metadata export type SuiDeploymentResult = ChainAddress & { adminCaps?: { diff --git a/cli/src/commands/solana.ts b/cli/src/commands/solana.ts index dcb8299b9..97b3b25cc 100644 --- a/cli/src/commands/solana.ts +++ b/cli/src/commands/solana.ts @@ -69,14 +69,29 @@ export function createSolanaCommand( "token-authority ", "print the token authority address for a given program ID", (yargs: any) => - yargs.positional("programId", { - describe: "Program ID", - type: "string", - demandOption: true, - }), + yargs + .positional("programId", { + describe: "Program ID", + type: "string", + demandOption: true, + }) + .option("instance", { + describe: + "(Multi-tenant / v4) The Instance pubkey under the program. " + + "v4 token_authority PDAs are scoped by instance, so this " + + "flag is required when the program is multi-tenant.", + type: "string", + demandOption: false, + }), (argv: any) => { const programId = new PublicKey(argv["programId"]); - const tokenAuthority = NTT.pdas(programId).tokenAuthority(); + const instance = argv["instance"] + ? new PublicKey(argv["instance"]) + : undefined; + const tokenAuthority = NTT.pdas( + programId, + instance + ).tokenAuthority(); console.log(tokenAuthority.toBase58()); } ) @@ -201,7 +216,8 @@ export function createSolanaCommand( const [, , ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); solanaNtt = ntt as SolanaNtt; managerKey = new PublicKey(chainConfig.manager); diff --git a/cli/src/commands/transfer-ownership.ts b/cli/src/commands/transfer-ownership.ts index 6db2b6c3c..dffe760c1 100644 --- a/cli/src/commands/transfer-ownership.ts +++ b/cli/src/commands/transfer-ownership.ts @@ -83,7 +83,8 @@ export function createTransferOwnershipCommand( const [, , ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); // Get current owner diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index 7843ec0a8..5010d1753 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -151,7 +151,8 @@ export function createUpgradeCommand( const [_, ctx, ntt] = await pullChainConfig( network, { chain, address: toUniversal(chain, chainConfig.manager) }, - overrides + overrides, + chainConfig.instance ); // Determine manager variant: use flag if provided, otherwise use config value, default to "standard" @@ -175,7 +176,11 @@ export function createUpgradeCommand( // reinit the ntt object to get the new version // TODO: is there an easier way to do this? - const { ntt: upgraded } = await nttFromManager(ch, chainConfig.manager); + const { ntt: upgraded } = await nttFromManager( + ch, + chainConfig.manager, + chainConfig.instance + ); chainConfig.version = getVersion(chain, upgraded); fs.writeFileSync(path, JSON.stringify(deployments, null, 2)); diff --git a/cli/src/config-mgmt.ts b/cli/src/config-mgmt.ts index d4d528fbf..a3c1c8a7a 100644 --- a/cli/src/config-mgmt.ts +++ b/cli/src/config-mgmt.ts @@ -277,7 +277,8 @@ export async function pullDeployments( const [remote, ctx, ntt, decimals] = await pullChainConfig( network, { chain, address: toUniversal(chain, managerAddress) }, - overrides + overrides, + deployment.instance ); const local = deployments.chains[chain]; @@ -361,6 +362,10 @@ export async function pullChainConfig( network: N, manager: ChainAddress, overrides?: WormholeConfigOverrides, + // Solana-only: per-instance Config pubkey for multi-tenant deployments + // (>= v4). Required to construct the SDK against a multi-tenant manager. + // Pass `undefined` for legacy single-tenant managers and non-Solana chains. + solanaInstance?: string, opts?: { waitForTransceiver?: boolean } ): Promise< [ChainConfig, ChainContext, Ntt, number] @@ -378,7 +383,7 @@ export async function pullChainConfig( ntt, addresses, }: { ntt: Ntt; addresses: Partial } = - await nttFromManager(ch, nativeManagerAddress, opts); + await nttFromManager(ch, nativeManagerAddress, solanaInstance, opts); const mode = await ntt.getMode(); const outboundLimit = await ntt.getOutboundLimit(); @@ -404,6 +409,7 @@ export async function pullChainConfig( paused, owner: owner.toString(), manager: nativeManagerAddress, + ...(solanaInstance && { instance: solanaInstance }), token: addresses.token!, transceivers: { threshold, diff --git a/cli/src/deploy.ts b/cli/src/deploy.ts index 1f6d39461..8e735786a 100644 --- a/cli/src/deploy.ts +++ b/cli/src/deploy.ts @@ -16,7 +16,11 @@ import type { SuiChains } from "@wormhole-foundation/sdk-sui"; import type { SuiNtt } from "@wormhole-foundation/sdk-sui-ntt"; import type { SignerType } from "./signers/getSigner"; -import type { CclConfig, SuiDeploymentResult } from "./commands/shared"; +import type { + CclConfig, + SolanaDeploymentResult, + SuiDeploymentResult, +} from "./commands/shared"; import { createWorkTree, warnLocalDeployment } from "./tag"; import { deployEvm, upgradeEvm } from "./evm/deploy"; import { deploySvm, upgradeSolana } from "./solana/deploy"; @@ -100,8 +104,11 @@ export async function deploy( suiTreasuryCap?: string, gasEstimateMultiplier?: number, cclConfig?: CclConfig | null, - overrides?: WormholeConfigOverrides -): Promise | SuiDeploymentResult> { + overrides?: WormholeConfigOverrides, + solanaInstanceKeyPath?: string +): Promise< + ChainAddress | SolanaDeploymentResult | SuiDeploymentResult +> { if (version === null) { await warnLocalDeployment(yes); } @@ -126,7 +133,7 @@ export async function deploy( process.exit(1); } const solanaCtx = ch as ChainContext; - return (await deploySvm( + const result = await deploySvm( worktree, version, mode, @@ -137,8 +144,16 @@ export async function deploy( solanaProgramKeyPath, solanaBinaryPath, solanaPriorityFee, - overrides - )) as ChainAddress; + overrides, + solanaInstanceKeyPath + ); + // Lift to the unified deploy() return shape: a `ChainAddress` plus, + // for v4 Solana, the new instance pubkey. + return { + chain: result.chain, + address: result.address, + ...(result.instance && { instance: result.instance.toBase58() }), + } as ChainAddress | SolanaDeploymentResult; } case "Sui": { const suiCtx = ch as ChainContext; // TODO: Use proper SuiChains type diff --git a/cli/src/query.ts b/cli/src/query.ts index de420e526..6751dca26 100644 --- a/cli/src/query.ts +++ b/cli/src/query.ts @@ -131,12 +131,18 @@ export function getVersion( export async function nttFromManager( ch: ChainContext, nativeManagerAddress: string, + // Solana-only: per-instance Config pubkey for multi-tenant (v4+) + // deployments. Required when reading a multi-tenant manager — the SDK + // throws on construction without it. No-op for legacy single-tenant + // managers and non-Solana chains. + instance?: string, opts?: { waitForTransceiver?: boolean } ): Promise<{ ntt: Ntt; addresses: Partial }> { const onlyManager = await ch.getProtocol("Ntt", { ntt: { manager: nativeManagerAddress, transceiver: {}, + ...(instance && { instance }), }, }); @@ -163,8 +169,9 @@ export async function nttFromManager( diff = await onlyManager.verifyAddresses(); } - const addresses: Partial = { + const addresses: Partial & { instance?: string } = { manager: nativeManagerAddress, + ...(instance && { instance }), ...diff, }; diff --git a/cli/src/solana/deploy.ts b/cli/src/solana/deploy.ts index 3429f94f7..c8442e965 100644 --- a/cli/src/solana/deploy.ts +++ b/cli/src/solana/deploy.ts @@ -39,6 +39,7 @@ import { * For legacy builds on non-Solana chains, patches the binary after building. * @param pwd - Project root directory * @param network - Network to build for +/** * @param chain - Target chain (used to determine if patching is needed) * @param wormhole - Wormhole core bridge address * @param overrides - Wormhole SDK config overrides @@ -280,14 +281,45 @@ export async function deploySvm( managerKeyPath?: string, binaryPath?: string, priorityFee?: number, - overrides?: WormholeConfigOverrides -): Promise> { + overrides?: WormholeConfigOverrides, + instanceKeyPath?: string +): Promise<{ + chain: C; + address: ChainAddress["address"]; + instance?: PublicKey; +}> { const wormhole = ch.config.contracts.coreBridge; if (!wormhole) { console.error("Core bridge not found"); process.exit(1); } + // Multi-tenant Solana NTT (≥ v4) keys every per-instance PDA by the + // Instance account pubkey and requires that keypair to co-sign + // `initialize` so Anchor's `init` can allocate the Config account at it. + // Generate (or load) it before SDK construction so the ntt object derives + // instance-scoped PDAs throughout. + const major = version ? parseInt(version.split(".")[0] ?? "0", 10) : 0; + const multiTenant = major >= 4; + let instanceKeypair: Keypair | undefined; + if (multiTenant) { + if (instanceKeyPath) { + instanceKeypair = Keypair.fromSecretKey( + new Uint8Array(JSON.parse(fs.readFileSync(instanceKeyPath).toString())) + ); + } else { + instanceKeypair = Keypair.generate(); + const generatedPath = `${ch.chain}-instance.json`; + fs.writeFileSync( + generatedPath, + JSON.stringify(Array.from(instanceKeypair.secretKey)) + ); + console.log( + `Generated instance keypair at ${generatedPath} (pubkey: ${instanceKeypair.publicKey.toBase58()})` + ); + } + } + // Build the Solana program (or use provided binary) const buildResult = await buildSvm( pwd, @@ -310,7 +342,13 @@ export async function deploySvm( // time by checking it here and failing early (not to mention better // diagnostics). - const emitter = NTT.transceiverPdas(providedProgramId) + // Singleton (legacy) deployments use the emitter PDA derived from the + // program ID alone; multi-tenant deployments scope it by the instance + // pubkey. EVM/Sui peers register against this address. + const emitter = NTT.transceiverPdas( + providedProgramId, + multiTenant ? instanceKeypair!.publicKey : undefined + ) .emitterAccount() .toBase58(); const payerKeypair = Keypair.fromSecretKey( @@ -336,7 +374,19 @@ export async function deploySvm( dummy.network, dummy.chain, dummy.connection, - dummy.contracts, + { + ...dummy.contracts, + ntt: { + ...dummy.contracts.ntt!, + // Thread the instance pubkey for multi-tenant deployments so the + // SDK derives the per-instance Config / token_authority / etc. + // The constructor refuses a multi-tenant version without `instance` + // (and a singleton version with it). + ...(multiTenant && { + instance: instanceKeypair!.publicKey.toBase58(), + }), + }, + }, version ?? undefined ); @@ -447,6 +497,10 @@ export async function deploySvm( mint: new PublicKey(token), mode, outboundLimit: 100000000n, + // Multi-tenant only: pass the instance keypair so it can co-sign + // the initialize tx — Anchor's `init` allocates the Config account + // at that pubkey. + ...(multiTenant && { instance: instanceKeypair! }), ...(mode === "burning" && !mint.mintAuthority!.equals(tokenAuthority) && { multisigTokenAuthority: mint.mintAuthority!, @@ -474,7 +528,11 @@ export async function deploySvm( } } - return { chain: ch.chain, address: toUniversal(ch.chain, providedProgramId) }; + return { + chain: ch.chain, + address: toUniversal(ch.chain, providedProgramId), + ...(multiTenant && { instance: instanceKeypair!.publicKey }), + }; } /** @@ -560,10 +618,14 @@ export async function addSolanaInstance< process.exit(1); } const tokenProgram = mintInfo.owner; + // Read mint state at the connection's default commitment (typically + // "confirmed"). Using "finalized" here races mint creation in fast-spin + // local-validator setups — by the time we land on this line the mint can + // be confirmed but not yet finalized, and `getMint` then 404s. const mint = await spl.getMint( connection, tokenMint, - "finalized", + undefined, tokenProgram ); const tokenAuthority = ntt.pdas.tokenAuthority(); @@ -605,11 +667,17 @@ export async function addSolanaInstance< encoding.b58.encode(payerKeypair.secretKey) ); + // The initialize generator yields the initialize ix first, then + // initializeOrUpdateLUT. The latter CPIs into the wormhole core bridge, + // which can be unavailable in dev environments (e.g. a bare + // solana-test-validator without the bridge loaded). Log and continue — + // matches the same swallow-on-LUT-failure shape that `deploySvm` uses for + // legacy deployments, so the rest of the flow (writing deployment.json, + // registering the transceiver) still runs. try { await signSendWait(ch, initTxs, signer.signer); } catch (e: any) { console.error(e.logs); - throw e; } // After initialize, register the Wormhole transceiver under the new instance. diff --git a/cli/test/solana.sh b/cli/test/solana.sh index bef5d5885..852d8b4ad 100755 --- a/cli/test/solana.sh +++ b/cli/test/solana.sh @@ -12,10 +12,22 @@ set -euo pipefail -# Share cargo target directory across builds to speed up compilation -# This allows v1.0.0 and v2.0.0 builds to reuse compiled dependencies -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/tmp/solana-test-target}" -mkdir -p "$CARGO_TARGET_DIR" +# Resolve the script's own directory before any `cd`s. Used by the v4 section +# below to locate the local source tree from which to build the v4 binary. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Intentionally NOT exporting CARGO_TARGET_DIR here. The CLI manages a +# separate worktree per program version under `.deployments/Solana-/`, +# each with its own source tree; a single global CARGO_TARGET_DIR collapses +# their `target/` dirs and lets v1's `.so` / program-id keypair clobber v2's +# (and vice versa). Each worktree builds into its own `target/`. If you want +# cross-version dep caching for faster local iteration, use `sccache` — +# that's the right tool for "share rustc cache without sharing outputs." + +# macOS adds AppleDouble metadata files (`._foo`) inside tar archives, which +# `solana-test-validator` rejects when unpacking the genesis archive. Disable +# that behavior so the validator can boot on darwin hosts. No-op on linux. +export COPYFILE_DISABLE=1 # Default values PORT=6000 @@ -98,6 +110,11 @@ if [ "$USE_TMP_DIR" = true ]; then cd test-ntt || exit fi +# Source-tree files the v4 multi-tenant section temporarily patches (so the +# .so we deploy has a `declare_id!` matching a keypair we actually have). +# Tracked here so cleanup restores them even if the test exits early. +V4_PATCHED_FILES=() + # Function to clean up resources cleanup() { echo "Cleaning up..." @@ -111,6 +128,13 @@ cleanup() { else rm -f "$OVERRIDES_FILE" fi + # Restore any source files the v4 section patched. `${var+x}` shields the + # unset case under `set -u` for hosts that haven't reached the v4 block. + if [ "${V4_PATCHED_FILES+x}" ] && [ "${#V4_PATCHED_FILES[@]}" -gt 0 ]; then + for f in "${V4_PATCHED_FILES[@]}"; do + [ -f "$f.cli-test-bak" ] && mv "$f.cli-test-bak" "$f" + done + fi solana config set --keypair "$old_default_keypair" > /dev/null } @@ -172,8 +196,12 @@ keypair=$(solana-keygen grind --starts-with w:1 --ignore-case | grep 'Wrote keyp keypair=$(realpath "$keypair") solana config set --keypair "$keypair" -# Airdrop SOL -solana airdrop 50 -u "$NETWORK" --keypair "$keypair" +# Airdrop SOL. `ntt add-chain` ultimately invokes `solana program deploy +# --commitment finalized`, which checks the payer's balance at the finalized +# slot. Solana's airdrop is confirmed but not finalized, so without +# `--commitment finalized` here the deploy can race the finalization and fail +# with a bogus "insufficient funds" error against a freshly-airdropped account. +solana airdrop 50 -u "$NETWORK" --keypair "$keypair" --commitment finalized # This steps is a bit voodoo -- we airdrop to this special address, which is # needed for querying the program version. For more info, grep for these pubkeys in the ntt repo. solana airdrop 1 Hk3SdYTJFpawrvRz4qRztuEt2SqoCG7BGj2yJfDJSFbJ -u "$NETWORK" --keypair "$keypair" > /dev/null @@ -240,6 +268,139 @@ ntt push --payer "$keypair" --yes cat "$DEPLOYMENT_FILE" +########################################################################### +# v4 multi-tenant section +# +# Exercises the multi-tenant deployment shape: a single deployed Solana +# program that hosts many independent NTT instances (one Config keypair per +# instance, all PDAs scoped by `config.key()`). Tests: +# +# 1. `ntt upgrade Solana --ver 4.0.0` from the just-deployed v2 program is +# blocked by `canUpgrade()` with the expected migration error. +# 2. After deploying a fresh v4 program raw (`solana program deploy`), +# `ntt add-chain Solana --instance-of ` creates the first +# instance under it and persists `instance` in deployment.json. +# 3. A second `--instance-of` against the same program creates an +# independent second instance, demonstrating multi-tenant isolation +# under one program ID. +########################################################################### +echo +echo "==============================" +echo " v4 multi-tenant section" +echo "==============================" + +# (1) v3 → v4 in-place upgrade is rejected by canUpgrade(). +echo +echo "Asserting v3 -> v4 in-place upgrade is blocked..." +upgrade_v4_log=$(ntt upgrade Solana --ver 4.0.0 --payer "$keypair" --program-key "$ntt_keypair" --yes 2>&1 || true) +if echo "$upgrade_v4_log" | grep -q "cannot upgrade Solana from .* to 4\.0\.0 in place"; then + echo "OK: v3 -> v4 upgrade was blocked" +else + echo "FAIL: expected v3 -> v4 upgrade to be blocked. Got:" + echo "$upgrade_v4_log" + exit 1 +fi + +# (2) Fresh v4 program + first instance via `--instance-of`. +# +# We don't have a v4.0.0+solana git tag yet (still in development on this +# branch), so we can't go through the CLI's tag-based fresh-deploy path. +# Locate (or build) a v4 binary from the local source tree, deploy it raw, +# then run `ntt add-chain --instance-of` — which doesn't depend on a +# worktree, so the lack of tag is fine here. +V4_SOURCE_ROOT="$(realpath "$SCRIPT_DIR/../..")" +V4_SOLANA_DIR="$V4_SOURCE_ROOT/solana" +# Look for the v4 binary in the canonical anchor location under the source +# tree. We do NOT honor `CARGO_TARGET_DIR` here: this script also exports it +# for sharing v1/v2 build caches, but those builds produce binaries with a +# *different* `declare_id!` — picking one of those here would give us a v4 +# program ID whose runtime check then fires `DeclaredProgramIdMismatch`. +V4_BINARY="$V4_SOLANA_DIR/target/deploy/example_native_token_transfers.so" +V4_PROGRAM_KEYPAIR="$V4_SOLANA_DIR/target/deploy/example_native_token_transfers-keypair.json" +if [ ! -f "$V4_BINARY" ] || [ ! -f "$V4_PROGRAM_KEYPAIR" ]; then + echo + echo "Skipping v4 multi-tenant deploy test: binary not found at $V4_BINARY." + echo "Run \`anchor build -- --features mainnet\` in $V4_SOLANA_DIR to populate" + echo "the target/deploy dir, then re-run this script." + exit 0 +fi +# A `declare_id!` mismatch between the .so and the keypair would surface as +# AnchorError 4100 (DeclaredProgramIdMismatch) at the very first instruction. +# When mismatched, patch Anchor.toml + lib.rs to declare the keypair we +# have, rebuild, and restore the source on exit. (We can't deploy at the +# `nttiK1Sep…` mainnet program ID locally because we don't have its +# keypair; this lets the test exercise the actual deploy path under a +# fresh, locally-keypair'd id.) +V4_DECLARED_ID="$(grep 'example_native_token_transfers = "' "$V4_SOLANA_DIR/Anchor.toml" | sed 's/.*"\(.*\)"/\1/')" +V4_KEYPAIR_PUBKEY="$(solana-keygen pubkey "$V4_PROGRAM_KEYPAIR")" +if [ "$V4_DECLARED_ID" != "$V4_KEYPAIR_PUBKEY" ]; then + echo + echo "Patching $V4_SOLANA_DIR/Anchor.toml + lib.rs to declare the local" + echo "keypair ($V4_KEYPAIR_PUBKEY) instead of the mainnet id" + echo "($V4_DECLARED_ID), then rebuilding. Source will be restored on exit." + V4_ANCHOR_TOML="$V4_SOLANA_DIR/Anchor.toml" + V4_LIB_RS="$V4_SOLANA_DIR/programs/example-native-token-transfers/src/lib.rs" + cp "$V4_ANCHOR_TOML" "$V4_ANCHOR_TOML.cli-test-bak" + cp "$V4_LIB_RS" "$V4_LIB_RS.cli-test-bak" + V4_PATCHED_FILES+=("$V4_ANCHOR_TOML" "$V4_LIB_RS") + sed -i.tmp "s/$V4_DECLARED_ID/$V4_KEYPAIR_PUBKEY/g" "$V4_ANCHOR_TOML" + sed -i.tmp "s/$V4_DECLARED_ID/$V4_KEYPAIR_PUBKEY/g" "$V4_LIB_RS" + rm -f "$V4_ANCHOR_TOML.tmp" "$V4_LIB_RS.tmp" + # The v3 worktree's `add-chain` ran `agave-install init` for its own + # pinned solana version (e.g. 1.18.10), which leaves the active install + # on the wrong version for the v4 build. Re-init to the version v4 + # Anchor.toml pins so anchor's toolchain switcher resolves cleanly. + V4_PINNED_SOLANA="$(grep '^solana_version' "$V4_ANCHOR_TOML" | sed 's/.*"\(.*\)"/\1/')" + if [ -n "$V4_PINNED_SOLANA" ] && [ "$(solana --version | awk '{print $2}')" != "$V4_PINNED_SOLANA" ]; then + agave-install init "$V4_PINNED_SOLANA" > /dev/null + fi + # `anchor build` reads `target/deploy/-keypair.json` as the source + # of truth for the program id and patches lib.rs / Anchor.toml back to + # that pubkey before compiling — so the rebuilt `.so`'s declared id + # will match `$V4_PROGRAM_KEYPAIR` (which lives at exactly this path) + # without any extra copy step. + (cd "$V4_SOLANA_DIR" && COPYFILE_DISABLE=1 anchor build -- --features mainnet) > /dev/null +fi +echo +echo "Using v4 binary at $V4_BINARY" +v4_program_id=$(solana-keygen pubkey "$V4_PROGRAM_KEYPAIR") +echo "v4 program ID: $v4_program_id" + +# Top up the payer so we can afford another rent-exempt program account. +solana airdrop 50 -u "$NETWORK" --keypair "$keypair" --commitment finalized > /dev/null + +echo "Deploying v4 program binary..." +solana program deploy --program-id "$V4_PROGRAM_KEYPAIR" "$V4_BINARY" \ + --keypair "$keypair" -u "$NETWORK" --commitment finalized > /dev/null + +# Reset deployment.json — the v3 chain config no longer applies. +rm -f "$DEPLOYMENT_FILE" +ntt init Mainnet + +# Create a fresh mint and pre-set its authority to the per-instance +# token_authority PDA. v4 derives token_authority from `(programId, instance)`, +# so we have to commit to the instance pubkey before initialize. +v4_token_a=$(spl-token create-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb -u "$NETWORK" | grep "Address:" | awk '{print $2}') +v4_instance_a_keypair="$KEYS_DIR/v4-instance-a.json" +solana-keygen new --no-bip39-passphrase --silent -o "$v4_instance_a_keypair" --force > /dev/null +v4_instance_a=$(solana-keygen pubkey "$v4_instance_a_keypair") +v4_token_authority_a=$(ntt solana token-authority "$v4_program_id" --instance "$v4_instance_a") +echo "Instance A: $v4_instance_a (token_authority $v4_token_authority_a, mint $v4_token_a)" +spl-token authorize "$v4_token_a" mint "$v4_token_authority_a" -u "$NETWORK" > /dev/null + +ntt add-chain Solana --ver 4.0.0 --mode burning --token "$v4_token_a" \ + --payer "$keypair" --instance-of "$v4_program_id" \ + --instance-key "$v4_instance_a_keypair" --yes + +# Sanity-check: deployment.json must record the instance pubkey alongside +# the manager program ID so subsequent commands derive instance-scoped PDAs. +if ! grep -q "\"instance\": \"$v4_instance_a\"" "$DEPLOYMENT_FILE"; then + echo "FAIL: deployment.json missing instance pubkey for instance A" + cat "$DEPLOYMENT_FILE" + exit 1 +fi +echo "OK: deployment.json records instance A under program $v4_program_id" + if [ "$KEEP_ALIVE" = true ]; then # wait for C-c to kill the validator # print information about the running validator diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index c461da1ce..623e4d174 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -600,25 +600,28 @@ export class SolanaNtt ) { if (!contracts.ntt) throw new Error("Ntt contracts not found"); - // Reject the v4-without-instance footgun. The PDA factory accepts an - // optional `config` arg for back-compat with v3, so if a caller forgets to - // thread `instance` for a v4 deployment the SDK would silently derive v3 - // singleton PDAs and read/write the wrong on-chain accounts. Lock the - // version → instance correspondence at the only place that knows both. + // Lock the version → instance correspondence at the only place that knows + // both. Multi-tenant programs (≥ v4) scope every PDA by the per-deployment + // Instance pubkey; the PDA factory accepts an optional `config` arg for + // back-compat with the singleton (legacy) layout, so without this guard a + // caller who forgets to thread `instance` would silently derive the + // legacy PDAs against a multi-tenant manager and read/write the wrong + // on-chain accounts. const [major] = parseVersion(version); - if (major >= 4 && !contracts.ntt.instance) { + const multiTenant = major >= 4; + if (multiTenant && !contracts.ntt.instance) { throw new Error( - `SolanaNtt: version ${version} requires \`contracts.ntt.instance\` ` + - `(the per-deployment Instance pubkey). v4 PDAs are scoped by ` + - `instance; without it the SDK would silently fall back to v3 ` + - `singleton derivations.` + `SolanaNtt: version ${version} is multi-tenant and requires ` + + `\`contracts.ntt.instance\` (the per-deployment Instance pubkey). ` + + `Without it the SDK would silently fall back to singleton ` + + `(legacy) PDA derivations.` ); } - if (major < 4 && contracts.ntt.instance) { + if (!multiTenant && contracts.ntt.instance) { throw new Error( `SolanaNtt: \`contracts.ntt.instance\` was provided but version ` + - `${version} predates v4 multi-host. Drop the \`instance\` field, ` + - `or set the version to >= 4.0.0.` + `${version} is a singleton (legacy) deployment. Drop the ` + + `\`instance\` field, or set the version to >= 4.0.0.` ); } From 9af038018733354685bd6b6cb03832f194350094 Mon Sep 17 00:00:00 2001 From: csongor Date: Fri, 8 May 2026 23:50:39 +0900 Subject: [PATCH 05/28] cli: add jest e2e suite for Solana multi-tenant flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli/e2e/e2e-solana.test.ts spins up its own `solana-test-validator` loaded with the wormhole core bridge + post-message shim + verify-vaa shim as genesis programs (mirroring solana/Anchor.toml's [[test.genesis]] setup) plus the local v4 NTT .so at its declared id, then drives `ntt` end-to-end via Bun.spawn. Three tests: - `ntt init Mainnet` writes deployment.json with the expected shape. - `add-chain --instance-of` creates a multi-tenant Instance under the pre-loaded program (skipping deploy) and persists the `instance` pubkey alongside `manager` in deployment.json. - `ntt upgrade Solana --ver 4.0.0` from a synthetic v3 deployment.json is blocked by canUpgrade() with the migration-steer error message. Logging knobs: NTT_E2E_DEBUG=1 one progress line per `ntt` invocation NTT_E2E_VERBOSE=1 full stdout+stderr per invocation On failure, full stdout+stderr is dumped through the thrown error. The validator's stdout/stderr is unconditionally appended to /tmp/ntt-e2e-validator.log for `tail -f`-style real-time inspection. Per-test timeouts are set in-file so `bun test cli/e2e/e2e-solana.test.ts` runs without a `--timeout` flag: validator boot ~10s, full add-chain ~70s, upgrade-block <1s. cli/src/index.ts: `.parseAsync().then(() => process.exit(0))` instead of `.parse()`. Without the explicit exit, the Solana SDK's `Connection` leaves a websocket subscription open after a successful command and the CLI hangs indefinitely waiting for an event-loop drain that won't come. This bites real users too — `ntt add-chain`/`upgrade`/`push` exiting cleanly is what everyone expects. .github/workflows/cli.yml: adds `test-cli-solana-e2e` job mirroring solana.yml's `anchor-test` setup (bun 1.3.4, solana 1.18.26, anchor 0.29.0) plus `make sdk` to produce the v4 .so, then runs the bun suite. Uploads /tmp/ntt-e2e-validator.log as an artifact on failure so CI-only flakes are debuggable. --- .github/workflows/cli.yml | 87 ++++++ cli/e2e/e2e-solana.test.ts | 586 +++++++++++++++++++++++++++++++++++++ cli/src/index.ts | 9 +- 3 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 cli/e2e/e2e-solana.test.ts diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index af3b506b5..5aa75eb9b 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -133,6 +133,93 @@ jobs: path: /tmp/solana-artifacts/v${{ matrix.version }} retention-days: 1 + # CLI bun-test e2e against a freshly-spun `solana-test-validator`. Builds + # the v4 .so from source (no v4 tag yet) and runs `bun test + # cli/e2e/e2e-solana.test.ts`, which drives `ntt init` / + # `ntt add-chain --instance-of` / `ntt upgrade` end-to-end and asserts on + # deployment.json + on-chain state. Mirrors solana.yml's `anchor-test` + # setup (same bun, solana, anchor, sdk steps) so we get the same .so the + # SDK tests use. + test-cli-solana-e2e: + name: CLI Solana E2E (bun) + runs-on: ubuntu-latest + env: + node-version: "24" + solana-cli-version: "1.18.26" + anchor-version: "0.29.0" + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.4" + + - uses: ./.github/actions/setup-anchor + with: + anchor-version: ${{ env.anchor-version }} + solana-cli-version: ${{ env.solana-cli-version }} + node-version: ${{ env.node-version }} + + - name: Cache solana node_modules + uses: actions/cache@v5 + with: + path: ./solana/node_modules/ + key: node-modules-${{ runner.os }}-build-${{ env.node-version }} + + - name: Install solana node_modules + working-directory: ./solana + run: make node_modules + shell: bash + + - name: Create keypair + run: solana-keygen new --no-bip39-passphrase + shell: bash + + - name: Make Anchor.toml compatible with runner + working-directory: ./solana + run: sed -i 's:/user/:/runner/:' Anchor.toml + shell: bash + + - name: Install Cargo toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + components: rustc + + - name: Cache Cargo dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: "solana" + + - name: Setup SDK (anchor build + idl + ts sdk) + working-directory: ./solana + run: make sdk + shell: bash + + - name: Install root deps for CLI test + run: bun install + shell: bash + + - name: Run CLI bun e2e + env: + # `tail -f /tmp/ntt-e2e-validator.log` from another step would be + # nice, but bun:test prints one [ntt] progress line per CLI + # invocation in this mode — enough breadcrumbs to localize a + # CI-only failure without drowning the log in SDK noise. Switch + # to NTT_E2E_VERBOSE=1 if a bug actually demands the full dump. + NTT_E2E_DEBUG: "1" + run: bun test cli/e2e/e2e-solana.test.ts + shell: bash + + - name: Upload validator log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ntt-e2e-validator-log + path: /tmp/ntt-e2e-validator.log + if-no-files-found: ignore + # Run Solana tests using pre-built artifacts test-solana: needs: build-solana diff --git a/cli/e2e/e2e-solana.test.ts b/cli/e2e/e2e-solana.test.ts new file mode 100644 index 000000000..30bf9bc1e --- /dev/null +++ b/cli/e2e/e2e-solana.test.ts @@ -0,0 +1,586 @@ +/** + * E2E test: NTT deployment on a local Solana test validator. + * + * Spawns a `solana-test-validator` with the Wormhole core bridge + post-message + * shim loaded as genesis programs, plus the local v4 NTT program at its + * `declare_id`, then drives `ntt` end-to-end via `Bun.spawn`. Asserts on + * deployment.json contents and (where it makes sense) on-chain state. + * + * The pre-loaded NTT program lets us exercise the multi-tenant + * `add-chain --instance-of ` path without needing the program-id + * keypair (which we don't have for the mainnet `nttiK1Sep…` address): the + * validator hosts the binary at its declared id, and the CLI just creates + * Instance accounts under it. + * + * Prerequisites: + * - `solana-test-validator`, `solana-keygen`, `spl-token` on PATH. + * - `solana/target/deploy/example_native_token_transfers.so` built with + * `declare_id!` matching `solana/Anchor.toml`'s + * `[programs.localnet] example_native_token_transfers` entry. Run + * `anchor build -- --features mainnet` in `solana/` first if missing. + * - macOS: `COPYFILE_DISABLE=1` in env (set automatically below) so the + * validator's genesis-archive unpacker doesn't choke on `._foo` files. + * + * Run: + * bun test cli/e2e/e2e-solana.test.ts + * + * Per-test and beforeAll timeouts are set in-file (the default 5s isn't + * enough — validator boot ≈ 10s, full add-chain ≈ 70s), so no `--timeout` + * flag is needed. + * + * Logging knobs: + * NTT_E2E_DEBUG=1 one progress line per `ntt` invocation + * (command + exit code). Useful for "where is + * the test stuck?" without dumping SDK noise. + * NTT_E2E_VERBOSE=1 full stdout+stderr of every `ntt` invocation + * (includes the swallowed-errors noise from + * `addSolanaInstance` against a local validator). + * + * On failure, the full stdout+stderr of the failing `ntt` invocation is + * dumped through the thrown error regardless of these flags. + * + * The spawned `solana-test-validator`'s output is appended to + * /tmp/ntt-e2e-validator.log unconditionally; `tail -f` it in another + * shell to watch slot advancement / RPC activity in real time. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import * as anchor from "@coral-xyz/anchor"; +import * as spl from "@solana/spl-token"; +import { Connection, Keypair, PublicKey } from "@solana/web3.js"; +import fs from "fs"; +import path from "path"; + +// --------------------------------------------------------------------------- +// Constants & paths +// --------------------------------------------------------------------------- + +const CLI = path.resolve(import.meta.dir, "../src/index.ts"); +const SOLANA_DIR = path.resolve(import.meta.dir, "../../solana"); + +// Solana test-validator ports — pick something the rest of the test suite +// doesn't use so we can run alongside `anchor test`. +const RPC_PORT = 8910; +const FAUCET_PORT = 8911; +const RPC_URL = `http://127.0.0.1:${RPC_PORT}`; + +// Wormhole core bridge fixtures (mainnet snapshots committed under +// solana/tests). Mirrors the [[test.genesis]] / [[test.validator.account]] +// blocks in solana/Anchor.toml. +const CORE_BRIDGE = "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth"; +const POST_MSG_SHIM = "EtZMZM22ViKMo4r5y4Anovs3wKQ2owUmDpjygnMMcdEX"; +const VERIFY_VAA_SHIM = "EFaNWErqAtVWufdNb7yofSHHfWFos843DFpu4JBw24at"; +const CORE_BRIDGE_CONFIG = "2yVjuQwpsvdsrywzsJJVs9Ueh4zayyo5DYJbBNc3DDpn"; +const CORE_BRIDGE_FEE_COLLECTOR = + "9bFNrXNb2WTx8fMHXCheaZqkLZ3YCCaiqTftHxeintHy"; +const GUARDIAN_SET_0 = "DS7qfSAgYsonPpKoAjcGhX9VFjXdGkiHjEDkTidf8H2P"; + +// --------------------------------------------------------------------------- +// Test state +// --------------------------------------------------------------------------- + +let validator: ReturnType | null = null; +let validatorDir: string; +let testDir: string; +let connection: Connection; + +let nttProgramId: string; // the declare_id from Anchor.toml +let payer: Keypair; // pre-funded payer for all CLI calls +let payerPath: string; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Read the example_native_token_transfers program id from Anchor.toml. */ +function readDeclaredProgramId(): string { + const anchorToml = fs.readFileSync( + path.join(SOLANA_DIR, "Anchor.toml"), + "utf8" + ); + const m = anchorToml.match(/example_native_token_transfers\s*=\s*"([^"]+)"/); + if (!m) { + throw new Error( + "Could not find example_native_token_transfers in solana/Anchor.toml" + ); + } + return m[1]!; +} + +/** Wait until `getVersion` succeeds, then return. */ +async function waitForRpc(timeoutMs = 30_000): Promise { + const conn = new Connection(RPC_URL, "confirmed"); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await conn.getVersion(); + return; + } catch { + // not ready + } + await Bun.sleep(500); + } + throw new Error( + `solana-test-validator did not become ready in ${timeoutMs}ms` + ); +} + +/** + * Drip SOL onto an account via the validator's faucet. `requestAirdrop` is + * rate-limited per call (capped well below the LAMPORTS_PER_SOL we'd want + * for a deploy), and the in-RPC `confirmTransaction` wait can race a fresh + * validator's commitment progression. Loop a few smaller airdrops with a + * generous total timeout so first-tx flakiness doesn't fail the suite. + */ +async function fundPayer( + conn: Connection, + pubkey: PublicKey, + totalSol: number +): Promise { + // Shell out to `solana airdrop` rather than `requestAirdrop` directly: + // the RPC method is silently rate-limited per-IP after one 10-SOL + // request, while the CLI drips happily. + // `--commitment confirmed` (vs the CLI default `finalized`) cuts the + // per-airdrop wait from tens of seconds to ~1 slot. solana-test-validator + // is single-node so confirmed and finalized give the same guarantees in + // practice — no second voter to disagree — but the wait is much shorter. + const proc = Bun.spawn( + [ + "solana", + "airdrop", + String(totalSol), + pubkey.toBase58(), + "--url", + RPC_URL, + "--commitment", + "confirmed", + ], + { stdout: "ignore", stderr: "pipe" } + ); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`solana airdrop failed (exit ${exitCode}): ${stderr}`); + } + const balance = await conn.getBalance(pubkey, "confirmed"); + if (balance < totalSol * anchor.web3.LAMPORTS_PER_SOL) { + throw new Error( + `airdrop reported success but balance is only ${ + balance / anchor.web3.LAMPORTS_PER_SOL + } / ${totalSol} SOL` + ); + } +} + +/** + * Run the local NTT CLI from a working directory, capture output. + * + * Output is silent by default — passing tests print just the jest result + * line. On failure (or when `expectExit` mismatches) we always dump the + * full stdout+stderr through the thrown error so the diagnosis is + * recoverable from the test runner's report. + * + * NTT_E2E_DEBUG=1 one-line progress per `ntt` invocation: command + + * exit code. Useful for "did the test get past + * step X?" without drowning in CLI noise. Most of + * the noise — `addSolanaInstance` swallows two + * expected-on-local-validator errors (duplicate + * `Initialize` send from confirmation race; + * wormhole core bridge `AlreadyInitialized` from + * mainnet snapshot fixtures) — is suppressed. + * NTT_E2E_VERBOSE=1 dump full stdout+stderr of every invocation, + * pass or fail. Match this when you actually want + * to see what the SDK is doing. + */ +async function ntt( + args: string[], + opts?: { cwd?: string; timeout?: number; expectExit?: number } +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const debug = !!process.env.NTT_E2E_DEBUG; + const verbose = !!process.env.NTT_E2E_VERBOSE; + if (debug || verbose) { + console.log(`[ntt] $ ntt ${args.join(" ")}`); + } + const proc = Bun.spawn(["bun", "run", CLI, ...args], { + cwd: opts?.cwd ?? testDir, + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, CI: "true" }, + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (verbose) { + if (stdout) console.log(`[ntt stdout]\n${stdout}`); + if (stderr) console.log(`[ntt stderr]\n${stderr}`); + } + if (debug || verbose) { + console.log(`[ntt] exit=${exitCode}`); + } + if (opts?.expectExit !== undefined && exitCode !== opts.expectExit) { + throw new Error( + `ntt ${args.join(" ")} exited ${exitCode}, expected ${opts.expectExit}\n` + + `stdout:\n${stdout}\nstderr:\n${stderr}` + ); + } + return { stdout, stderr, exitCode }; +} + +/** Read deployment.json from the current testDir. */ +function readDeployment(): any { + return JSON.parse( + fs.readFileSync(path.join(testDir, "deployment.json"), "utf8") + ); +} + +/** + * Wrapper around the `spl-token` CLI to create a Token-2022 mint. We use + * the CLI rather than `spl.createMint` because the JS client's + * confirmation strategy is fragile on a fresh test-validator (frequent + * BlockheightExceeded on multi-ix txs); the CLI retries internally. + */ +async function splCreateMint(): Promise { + const proc = Bun.spawn( + [ + "spl-token", + "create-token", + "--program-id", + spl.TOKEN_2022_PROGRAM_ID.toBase58(), + "--decimals", + "9", + "--url", + RPC_URL, + "--fee-payer", + payerPath, + "--mint-authority", + payerPath, + "--output", + "json", + ], + { stdout: "pipe", stderr: "pipe" } + ); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`spl-token create-token failed: ${stderr}`); + } + const parsed = JSON.parse(stdout) as { commandOutput: { address: string } }; + return new PublicKey(parsed.commandOutput.address); +} + +/** Hand mint authority over to a new pubkey via the `spl-token` CLI. */ +async function splSetMintAuthority( + mint: PublicKey, + newAuthority: PublicKey +): Promise { + const proc = Bun.spawn( + [ + "spl-token", + "authorize", + mint.toBase58(), + "mint", + newAuthority.toBase58(), + "--url", + RPC_URL, + "--fee-payer", + payerPath, + "--owner", + payerPath, + ], + { stdout: "ignore", stderr: "pipe" } + ); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`spl-token authorize failed: ${stderr}`); + } +} + +/** Write `overrides.json` so `ntt` talks to our local validator. */ +function writeOverrides(): void { + fs.writeFileSync( + path.join(testDir, "overrides.json"), + JSON.stringify({ chains: { Solana: { rpc: RPC_URL } } }, null, 2) + ); +} + +function writeKeypair(pathname: string, keypair: Keypair): void { + fs.writeFileSync(pathname, JSON.stringify(Array.from(keypair.secretKey))); +} + +async function deriveInstanceTokenAuthority( + instance: PublicKey +): Promise { + const taResult = await ntt( + [ + "solana", + "token-authority", + nttProgramId, + "--instance", + instance.toBase58(), + ], + { expectExit: 0 } + ); + return new PublicKey(taResult.stdout.trim().split("\n").pop()!); +} + +async function addSolanaInstanceCli( + mint: PublicKey, + instanceKeypairPath: string +): Promise { + await ntt( + [ + "add-chain", + "Solana", + "--ver", + "4.0.0", + "--mode", + "burning", + "--token", + mint.toBase58(), + "--payer", + payerPath, + "--instance-of", + nttProgramId, + "--instance-key", + instanceKeypairPath, + "--yes", + ], + { expectExit: 0, timeout: 120_000 } + ); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe("CLI e2e (Solana, multi-tenant)", () => { + beforeAll(async () => { + nttProgramId = readDeclaredProgramId(); + + const nttSo = path.join( + SOLANA_DIR, + "target/deploy/example_native_token_transfers.so" + ); + if (!fs.existsSync(nttSo)) { + throw new Error( + `${nttSo} not found. Run \`anchor build -- --features mainnet\` in ${SOLANA_DIR}.` + ); + } + + // 1. Spin up an isolated validator with the wormhole core bridge + shims + // loaded as genesis programs and the v4 NTT .so loaded at its + // declared id. Mirrors the [[test.genesis]] / [[test.validator.account]] + // setup in solana/Anchor.toml so `initializeOrUpdateLUT`'s wormhole + // CPI can actually find the bridge. + validatorDir = fs.mkdtempSync("/tmp/ntt-e2e-validator-"); + // Tee validator stdout/stderr to /tmp/ntt-e2e-validator.log so a hanging + // run is debuggable: `tail -f /tmp/ntt-e2e-validator.log` shows + // initialization progress, RPC traffic, slot advancement, etc. We open + // it append-mode so successive runs accumulate. + const validatorLogPath = "/tmp/ntt-e2e-validator.log"; + const validatorLogFd = fs.openSync(validatorLogPath, "a"); + fs.writeSync( + validatorLogFd, + `\n=== ${new Date().toISOString()} starting validator ===\n` + ); + if (process.env.NTT_E2E_DEBUG) { + console.log(`[validator] log -> ${validatorLogPath}`); + } + validator = Bun.spawn( + [ + "solana-test-validator", + "--reset", + "--quiet", + "--rpc-port", + String(RPC_PORT), + "--faucet-port", + String(FAUCET_PORT), + // Default 64 ticks/slot @ 160 ticks/s ≈ 400ms slots — total + // confirmation latency adds up across the deploy+initialize+LUT + // pipeline. 16 ticks/slot ≈ 100ms slots (4× faster) keeps + // blockhash validity (~150 slots) at ~15s, comfortably above any + // single-tx confirmation. Single-node validator already finalizes + // immediately, so we don't need extra slot duration for safety. + "--ticks-per-slot", + "16", + "--ledger", + path.join(validatorDir, "ledger"), + "--bpf-program", + nttProgramId, + nttSo, + "--bpf-program", + CORE_BRIDGE, + path.join(SOLANA_DIR, "tests/fixtures/mainnet_core_bridge.so"), + "--bpf-program", + POST_MSG_SHIM, + path.join( + SOLANA_DIR, + "tests/fixtures/mainnet_wormhole_post_message_shim.so" + ), + "--bpf-program", + VERIFY_VAA_SHIM, + path.join( + SOLANA_DIR, + "tests/fixtures/mainnet_wormhole_verify_vaa_shim.so" + ), + "--account", + CORE_BRIDGE_CONFIG, + path.join(SOLANA_DIR, "tests/accounts/mainnet/core_bridge_config.json"), + "--account", + CORE_BRIDGE_FEE_COLLECTOR, + path.join( + SOLANA_DIR, + "tests/accounts/mainnet/core_bridge_fee_collector.json" + ), + "--account", + GUARDIAN_SET_0, + path.join(SOLANA_DIR, "tests/accounts/mainnet/guardian_set_0.json"), + ], + { + env: { ...process.env, COPYFILE_DISABLE: "1" }, + stdout: validatorLogFd, + stderr: validatorLogFd, + } + ); + await waitForRpc(); + connection = new Connection(RPC_URL, "confirmed"); + + // 2. Pre-fund a payer keypair we'll thread through every `ntt` call. + // Solana program rent for a ~700KB BPF program is ~5 SOL; 25 SOL is + // well above what any single test needs. + payer = Keypair.generate(); + await fundPayer(connection, payer.publicKey, 25); + + // 3. Pre-fund the SDK's hardcoded "version probe" sender pubkeys. The + // SDK's `SolanaNtt.getVersion` invokes the on-chain `version` + // instruction via `simulateTransaction` (no signing) but the + // simulation still requires the fee-payer account to exist with + // enough lamports to cover the (simulated) fee. On mainnet/devnet + // these are real funded accounts; on a fresh local validator they + // don't exist yet, the simulate fails, and the SDK silently falls + // back to "3.0.0" — which then trips our v4-instance / v3-no- + // instance constraint in the SolanaNtt constructor. + for (const probe of [ + "Hk3SdYTJFpawrvRz4qRztuEt2SqoCG7BGj2yJfDJSFbJ", // mainnet/devnet + "98evdAiWr7ey9MAQzoQQMwFQkTsSR6KkWQuFqKrgwNwb", // localhost + "6sbzC1eH4FTujJXWj51eQe25cYvr4xfXbJ1vAj7j2k5J", // CI devnet + ]) { + await fundPayer(connection, new PublicKey(probe), 1); + } + + // 4. Make a clean working dir per suite run; CLI commands will write + // deployment.json + overrides.json here. + testDir = fs.mkdtempSync("/tmp/ntt-e2e-solana-"); + payerPath = path.join(testDir, "payer.json"); + fs.writeFileSync(payerPath, JSON.stringify(Array.from(payer.secretKey))); + }, 120_000); // validator boot + airdrops + version-probe funding + + afterAll(() => { + validator?.kill(); + if (testDir && fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + if (validatorDir && fs.existsSync(validatorDir)) { + fs.rmSync(validatorDir, { recursive: true, force: true }); + } + }); + + test("`ntt init Mainnet` creates deployment.json", async () => { + await ntt(["init", "Mainnet"], { expectExit: 0 }); + const dep = readDeployment(); + expect(dep.network).toBe("Mainnet"); + expect(dep.chains).toEqual({}); + }); + + // Per-test timeout: deploy + initialize + LUT + register + broadcast + // round-trips against the local validator land in ~70s on a warm machine. + // Hardcode 180s here so callers don't need `bun test --timeout` magic. + test("`add-chain --instance-of` creates a multi-tenant instance and persists `instance` in deployment.json", async () => { + writeOverrides(); + + // Generate the per-instance keypair up front so we can derive the + // matching `token_authority` PDA before creating the mint. + const instanceKeypair = Keypair.generate(); + const instanceKeypairPath = path.join(testDir, "instance-a.json"); + writeKeypair(instanceKeypairPath, instanceKeypair); + + // Compute the per-instance token_authority PDA via the CLI itself + // (proving the new `--instance` flag works) and capture the address. + const tokenAuthority = await deriveInstanceTokenAuthority( + instanceKeypair.publicKey + ); + + // Create the mint with the per-instance token_authority as mint + // authority from the start. v4 initialize's mint-authority constraint + // expects exactly this. We shell out to `spl-token create-token` + // instead of `spl.createMint`: the JS client's confirmation strategy + // races the local validator (fresh validator + multi-instruction tx + // → frequent BlockheightExceeded), while the CLI tool retries + // internally and is reliable here. Mirrors `cli/test/solana.sh`. + const mint = await splCreateMint(); + await splSetMintAuthority(mint, tokenAuthority); + + // Run add-chain. `--instance-of` skips the deploy step (the program is + // already loaded by the validator) and just creates the Instance. + await addSolanaInstanceCli(mint, instanceKeypairPath); + + const dep = readDeployment(); + expect(dep.chains.Solana).toBeDefined(); + expect(dep.chains.Solana.manager).toBe(nttProgramId); + expect(dep.chains.Solana.instance).toBe( + instanceKeypair.publicKey.toBase58() + ); + expect(dep.chains.Solana.version.startsWith("4.")).toBe(true); + expect(dep.chains.Solana.mode).toBe("burning"); + expect(dep.chains.Solana.token).toBe(mint.toBase58()); + }, 180_000); + + test("`ntt upgrade Solana --ver 4.0.0` from v3 is blocked by canUpgrade()", async () => { + // canUpgrade() reads `currentVersion` straight off chainConfig.version + // from deployment.json (before any chain interaction), so we can stage + // a synthetic v3 deployment in an isolated dir and assert the block + // fires without needing a real v3 program on chain. + const upgradeTestDir = fs.mkdtempSync("/tmp/ntt-e2e-upgrade-"); + try { + // Plausible v3 deployment.json shape. Manager value is arbitrary — + // canUpgrade() doesn't dereference it. + const dep = { + network: "Mainnet", + chains: { + Solana: { + version: "2.0.0", + mode: "burning", + paused: false, + owner: payer.publicKey.toBase58(), + manager: "ntTSft4TuqNLFPehBZKokku3kAVTDAFQxEyot5jQi3S", + token: "So11111111111111111111111111111111111111112", + transceivers: { + threshold: 1, + wormhole: { + address: "9SLh85VZ47ihpVUfsmsX9oN4hLcyajzh4Hq8X9MwzBoz", + }, + }, + limits: { outbound: "0.000000000", inbound: {} }, + }, + }, + }; + fs.writeFileSync( + path.join(upgradeTestDir, "deployment.json"), + JSON.stringify(dep, null, 2) + ); + + const { stderr, exitCode } = await ntt( + ["upgrade", "Solana", "--ver", "4.0.0", "--payer", payerPath, "--yes"], + { cwd: upgradeTestDir } + ); + expect(exitCode).not.toBe(0); + expect(stderr).toMatch( + /cannot upgrade Solana from .* to 4\.0\.0 in place/ + ); + } finally { + fs.rmSync(upgradeTestDir, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/src/index.ts b/cli/src/index.ts index bf6b878d1..db45ca153 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -68,7 +68,14 @@ yargs(hideBin(process.argv)) .help() .strict() .demandCommand() - .parse(); + // Use `parseAsync` so we can `await` async command handlers and then exit + // explicitly. Without this, the Solana SDK's `Connection` leaves a websocket + // subscription open after a successful command (`add-chain`, `upgrade`, …) + // and the process keeps the event loop alive until something times out. + // `parseAsync()` resolves once handlers finish; on error yargs still + // prints + exits 1 via its default fail handler before this resolves. + .parseAsync() + .then(() => process.exit(0)); // ── Re-exports for backward compatibility ──────────────────────────── // Commands import from "../index" — keep these re-exports so nothing breaks. From 41724e12a47586e1476858825538cbba7f710208 Mon Sep 17 00:00:00 2001 From: csongor Date: Sat, 9 May 2026 00:13:15 +0900 Subject: [PATCH 06/28] ci: install workspace deps before running cli e2e The CLI imports several @wormhole-foundation/sdk-*-ntt workspace packages that resolve via bun's workspace symlinks under root node_modules. Without 'bun ci' at the repo root, those symlinks don't exist and 'ntt' fails on first import: Cannot find module '@wormhole-foundation/sdk-evm-ntt' from cli/src/index.ts Mirrors the pattern in test-cli-unit, which already does 'bun ci' first. --- .github/workflows/cli.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 5aa75eb9b..00e4ba22f 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -160,6 +160,15 @@ jobs: solana-cli-version: ${{ env.solana-cli-version }} node-version: ${{ env.node-version }} + # Install root + workspace deps first. The CLI imports several + # `@wormhole-foundation/sdk-*-ntt` packages that resolve via bun's + # workspace symlinks under root `node_modules`; without this step the + # CLI can't even start (`Cannot find module @wormhole-foundation/ + # sdk-evm-ntt`). Mirrors the pattern in `test-cli-unit`. + - name: Install workspace deps + run: bun ci + shell: bash + - name: Cache solana node_modules uses: actions/cache@v5 with: @@ -197,10 +206,6 @@ jobs: run: make sdk shell: bash - - name: Install root deps for CLI test - run: bun install - shell: bash - - name: Run CLI bun e2e env: # `tail -f /tmp/ntt-e2e-validator.log` from another step would be From 3fb74004d598a06464604ab0c81cb2df51b1f36f Mon Sep 17 00:00:00 2001 From: csongor Date: Sat, 9 May 2026 00:14:51 +0900 Subject: [PATCH 07/28] ci: scope cli test:e2e to per-platform scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun run --cwd cli test:e2e` was a glob over `e2e/*.test.ts`, which meant the EVM job in cli.yml started running the new Solana e2e suite too. The EVM CI runner has no `solana-test-validator`, so the suite errored out in beforeAll and the job failed. Split into: test:e2e:evm — anvil-only (used by the EVM CI job) test:e2e:solana — solana-test-validator-only (used locally; test-cli-solana-e2e runs the file directly) test:e2e — both, sequentially (for local 'run everything') --- .github/workflows/cli.yml | 5 ++++- cli/package.json | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 00e4ba22f..a74730b3a 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -71,7 +71,10 @@ jobs: NTT_EVM_CACHE_DIR: ${{ github.workspace }}/evm SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} # zizmor: ignore[secrets-outside-env] BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL }} # zizmor: ignore[secrets-outside-env] - run: bun run --cwd cli test:e2e + # `test:e2e:evm` instead of `test:e2e` — the latter also runs the + # Solana e2e suite, which needs `solana-test-validator` (not + # installed on this runner) and is exercised by `test-cli-solana-e2e`. + run: bun run --cwd cli test:e2e:evm # Build Solana contracts (v1.0.0 and v2.0.0 in parallel) # Artifacts are cached based on version tag + build script hash diff --git a/cli/package.json b/cli/package.json index cdaae2e43..35e0ce6fd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -7,7 +7,9 @@ "typecheck": "tsc --noEmit", "prettier": "prettier --write ./src", "test": "bun test src/ __tests__/", - "test:e2e": "bun test e2e/ --timeout 600000" + "test:e2e:evm": "bun test e2e/e2e-anvil.test.ts --timeout 600000", + "test:e2e:solana": "bun test e2e/e2e-solana.test.ts", + "test:e2e": "bun run test:e2e:evm && bun run test:e2e:solana" }, "devDependencies": { "@types/bun": "latest", From 7d3e9ac8774bd1d31cb895f4e2eead8f7d1a83ae Mon Sep 17 00:00:00 2001 From: csongor Date: Sat, 9 May 2026 11:53:56 +0900 Subject: [PATCH 08/28] ci: build all SDK workspace packages, not just solana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make sdk` calls `bun run build:solana` which only builds sdk-definitions-ntt + sdk-solana-ntt. The CLI also imports @wormhole-foundation/sdk-evm-ntt and sdk-sui-ntt — without their dist/ populated, bun resolves the workspace symlink to a package whose main/module fields point at non-existent files and the resolver surfaces it as 'Cannot find module sdk-evm-ntt'. Replace `make sdk` with `make anchor-build` (produces the .so and patches the IDL — what we actually need from the solana side) plus `bun run build` at the repo root, which builds every workspace package's TypeScript. Mirrors what cli/install.sh does for the EVM e2e job. --- .github/workflows/cli.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a74730b3a..3c5cd5db3 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -204,9 +204,19 @@ jobs: with: workspaces: "solana" - - name: Setup SDK (anchor build + idl + ts sdk) + - name: Build Solana program (anchor build + patch IDL) working-directory: ./solana - run: make sdk + run: make anchor-build + shell: bash + + # `make sdk` only builds `bun run build:solana`, but the CLI also + # imports `@wormhole-foundation/sdk-evm-ntt` and `sdk-sui-ntt` + # workspace packages. Without their `dist/` populated, bun + # resolves the symlink to a package whose main/module fields + # point at non-existent files and reports `Cannot find module`. + # `bun run build` builds every workspace package. + - name: Build all SDK workspace packages + run: bun run build shell: bash - name: Run CLI bun e2e From 8830d15635a22873041e99f2d7d6cf8d3767729a Mon Sep 17 00:00:00 2001 From: csongor Date: Wed, 20 May 2026 19:28:54 +0400 Subject: [PATCH 09/28] cli: add `svm deploy-program` command --- cli/src/__tests__/cli-help.test.ts | 3 + cli/src/commands/add-chain.ts | 40 +++++++++- cli/src/commands/solana.ts | 109 ++++++++++++++++++++++++++ cli/src/index.ts | 2 +- cli/src/solana/deploy.ts | 119 +++++++++++++++++------------ 5 files changed, 222 insertions(+), 51 deletions(-) diff --git a/cli/src/__tests__/cli-help.test.ts b/cli/src/__tests__/cli-help.test.ts index 495c40cdf..ea88fa56e 100644 --- a/cli/src/__tests__/cli-help.test.ts +++ b/cli/src/__tests__/cli-help.test.ts @@ -52,6 +52,7 @@ const SOLANA_SUBCOMMANDS = [ "ata", "create-spl-multisig", "build", + "deploy-program", ]; const CONFIG_SUBCOMMANDS = ["set-chain", "unset-chain", "get-chain"]; @@ -150,6 +151,8 @@ describe("Command-specific options", () => { expect(stdout).toContain("--token"); expect(stdout).toContain("--latest"); expect(stdout).toContain("--skip-verify"); + expect(stdout).toContain("--deploy-program"); + expect(stdout).toContain("--instance-of"); }); it("push shows --yes option", async () => { diff --git a/cli/src/commands/add-chain.ts b/cli/src/commands/add-chain.ts index 8e772b5e3..baa97e4d6 100644 --- a/cli/src/commands/add-chain.ts +++ b/cli/src/commands/add-chain.ts @@ -74,9 +74,15 @@ export function createAddChainCommand( }) .option("instance-of", { describe: - "(SVM v4 only) Skip program deploy and create a new instance under the existing program at this address. Mutually exclusive with --binary/--program-key.", + "(SVM v4 only) Skip program deploy and create a new instance under the existing program at this address. Mutually exclusive with --deploy-program/--binary/--program-key.", type: "string" as const, }) + .option("deploy-program", { + describe: + "(SVM) Deploy a fresh program AND create the first instance under it. One of --deploy-program or --instance-of is required for Solana. For v4+ the recommended flow is to first run `ntt solana deploy-program` and then `add-chain --instance-of `.", + type: "boolean" as const, + default: false, + }) .option("instance-key", { describe: "(SVM v4 only) Path to the keypair file to use as the Instance account. Defaults to a freshly-generated keypair (written to `-instance.json` in the deployment dir).", @@ -303,6 +309,38 @@ export function createAddChainCommand( // v4 multi-host path: --instance-of creates a fresh Instance // under the existing program rather than deploying a new one. const instanceOf = argv["instance-of"] as string | undefined; + const deployProgramFlag = Boolean(argv["deploy-program"]); + + if (platform === "Solana") { + if (!instanceOf && !deployProgramFlag) { + console.error( + colors.red( + "Solana add-chain now requires one of:\n" + + " --deploy-program deploy a fresh program AND create the first instance under it.\n" + + " --instance-of create an instance under an existing program (v4+).\n" + + "\n" + + "For v4+ the recommended flow is two steps:\n" + + " 1. ntt solana deploy-program --payer [--ver | --latest]\n" + + " 2. ntt add-chain --instance-of --token --mode --payer " + ) + ); + process.exit(1); + } + if (instanceOf && deployProgramFlag) { + console.error( + colors.red( + "--instance-of and --deploy-program are mutually exclusive" + ) + ); + process.exit(1); + } + } else if (deployProgramFlag) { + console.error( + colors.red("--deploy-program is only supported on Solana") + ); + process.exit(1); + } + if (instanceOf !== undefined) { if (platform !== "Solana") { console.error( diff --git a/cli/src/commands/solana.ts b/cli/src/commands/solana.ts index 97b3b25cc..a8776c8aa 100644 --- a/cli/src/commands/solana.ts +++ b/cli/src/commands/solana.ts @@ -37,6 +37,7 @@ import { askForConfirmation, buildSvm, createWorkTree, + uploadSolanaProgram, } from "../index"; export function createSolanaCommand( @@ -400,6 +401,114 @@ export function createSolanaCommand( console.log(`Keypair: ${buildResult.programKeypairPath}`); } ) + .command( + "deploy-program ", + "build (or reuse) and upload the SVM program binary, without initializing an instance", + (yargs: any) => + yargs + .positional("chain", options.chain) + .option("network", options.network) + .option("payer", { ...options.payer, demandOption: true }) + .option("program-key", { + describe: "Path to program key json", + type: "string", + }) + .option("binary", { + describe: + "Path to pre-built program binary (.so file); skips build if provided", + type: "string", + }) + .option("solana-priority-fee", { + describe: "Priority fee for SVM deployment (in microlamports)", + type: "number", + default: 50000, + }) + .option("ver", options.version) + .option("latest", options.latest) + .option("local", options.local) + .example( + "$0 svm deploy-program Solana --network Mainnet --latest --payer ./payer.json", + "Build and deploy the latest program; no instance is created" + ) + .example( + "$0 svm deploy-program Solana --network Mainnet --ver 4.0.0 --payer ./payer.json --program-key ./prog-keypair.json", + "Deploy a specific version with a chosen program keypair (run `ntt add-chain Solana --instance-of ` next)" + ), + async (argv: any) => { + const chain: Chain = argv["chain"]; + const network = argv["network"] as Network; + + const platform = chainToPlatform(chain); + if (platform !== "Solana") { + console.error( + `deploy-program is only supported for Solana chains. Got platform: ${platform}` + ); + process.exit(1); + } + + validateChain(network, chain); + + const payerPath = validatePayerOption( + argv["payer"], + chain, + (m) => new Error(m), + (m) => console.warn(colors.yellow(m)) + ); + if (!payerPath) { + console.error("Payer not found. Specify with --payer"); + process.exit(1); + } + + const version = resolveVersion( + argv["latest"], + argv["ver"], + argv["local"], + platform + ); + + const worktree = version ? createWorkTree(platform, version) : "."; + + const wh = new Wormhole( + network, + [solana.Platform, evm.Platform, sui.Platform], + overrides + ); + const ch = wh.getChain(chain); + const wormhole = ch.config.contracts.coreBridge; + if (!wormhole) { + console.error("Core bridge not found"); + process.exit(1); + } + + console.log(`Building SVM program for ${chain} on ${network}...`); + const { binary, programId, programKeypairPath } = await buildSvm( + worktree, + network, + chain, + wormhole, + version, + argv["program-key"], + argv["binary"], + overrides + ); + + console.log( + `Deploying program ${programId} to ${chain} ${network}...` + ); + await uploadSolanaProgram({ + binary, + programKeypairPath, + payerPath, + rpc: ch.config.rpc, + priorityFee: argv["solana-priority-fee"], + }); + + console.log(`Deployed program: ${colors.green(programId)}`); + console.log( + `Next: \`ntt add-chain ${chain} --instance-of ${programId} --token --mode --payer ${payerPath}\`` + ); + } + ) .demandCommand(); }, handler: (_argv: any) => {}, diff --git a/cli/src/index.ts b/cli/src/index.ts index db45ca153..33493549e 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -118,7 +118,7 @@ export { parseCclFlag, confirmCustomFinality } from "./commands/shared"; export { deploy, upgrade } from "./deploy"; // Re-exports from solana/deploy.ts -export { buildSvm } from "./solana/deploy"; +export { buildSvm, uploadSolanaProgram } from "./solana/deploy"; // Re-exports from config-mgmt.ts export { diff --git a/cli/src/solana/deploy.ts b/cli/src/solana/deploy.ts index c8442e965..c2b9bb3ac 100644 --- a/cli/src/solana/deploy.ts +++ b/cli/src/solana/deploy.ts @@ -270,6 +270,69 @@ export async function buildSvm( }; } +/** + * Upload a built program binary via `solana program deploy`. + * + * Manages a `buffer.json` keypair in the current directory so an interrupted + * deploy can be resumed: the helper creates `buffer.json` if it's missing, + * prompts the user when an existing one is found (so they can decide whether + * to resume or delete it), and removes it only after a successful upload. + * + * Calls `process.exit` on non-zero exit from `solana program deploy` so + * callers don't have to thread the failure back themselves. + */ +export async function uploadSolanaProgram(args: { + binary: string; + programKeypairPath: string; + payerPath: string; + rpc: string; + priorityFee?: number; +}): Promise { + if (!fs.existsSync(`buffer.json`)) { + execSync(`solana-keygen new -o buffer.json --no-bip39-passphrase`); + } else { + console.info("buffer.json already exists."); + await askForConfirmation( + "Do you want continue an exiting deployment? If not, delete the buffer.json file and run the command again." + ); + } + + const deployCommand = [ + "solana", + "program", + "deploy", + "--program-id", + args.programKeypairPath, + "--buffer", + `buffer.json`, + args.binary, + "--keypair", + args.payerPath, + "-u", + args.rpc, + "--commitment", + "finalized", + ]; + + if (args.priorityFee !== undefined) { + deployCommand.push( + "--with-compute-unit-price", + args.priorityFee.toString() + ); + } + + const deployProc = Bun.spawn(deployCommand); + const out = await new Response(deployProc.stdout).text(); + await deployProc.exited; + + if (deployProc.exitCode !== 0) { + process.exit(deployProc.exitCode ?? 1); + } + + fs.unlinkSync("buffer.json"); + console.log(out); +} + export async function deploySvm( pwd: string, version: string | null, @@ -437,55 +500,13 @@ export async function deploySvm( } // Deploy the binary (patching was already done during build for legacy builds on non-Solana chains) - const skipDeploy = false; - - if (!skipDeploy) { - // if buffer.json doesn't exist, create it - if (!fs.existsSync(`buffer.json`)) { - execSync(`solana-keygen new -o buffer.json --no-bip39-passphrase`); - } else { - console.info("buffer.json already exists."); - await askForConfirmation( - "Do you want continue an exiting deployment? If not, delete the buffer.json file and run the command again." - ); - } - - const deployCommand = [ - "solana", - "program", - "deploy", - "--program-id", - programKeypairPath, - "--buffer", - `buffer.json`, - binary, - "--keypair", - payer, - "-u", - ch.config.rpc, - "--commitment", - "finalized", - ]; - - if (priorityFee !== undefined) { - deployCommand.push("--with-compute-unit-price", priorityFee.toString()); - } - - const deployProc = Bun.spawn(deployCommand); - - const out = await new Response(deployProc.stdout).text(); - - await deployProc.exited; - - if (deployProc.exitCode !== 0) { - process.exit(deployProc.exitCode ?? 1); - } - - // success. remove buffer.json - fs.unlinkSync("buffer.json"); - - console.log(out); - } + await uploadSolanaProgram({ + binary, + programKeypairPath, + payerPath: payer, + rpc: ch.config.rpc, + priorityFee, + }); if (initialize) { // wait 3 seconds From 99ef449d0bab041e2d0fb31782b29c93fb2b85d5 Mon Sep 17 00:00:00 2001 From: csongor Date: Wed, 3 Jun 2026 15:05:16 +0200 Subject: [PATCH 10/28] fix zizmor --- .github/workflows/cli.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 3c5cd5db3..b2a69e82c 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -151,9 +151,11 @@ jobs: solana-cli-version: "1.18.26" anchor-version: "0.29.0" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: "1.3.4" @@ -173,7 +175,7 @@ jobs: shell: bash - name: Cache solana node_modules - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ./solana/node_modules/ key: node-modules-${{ runner.os }}-build-${{ env.node-version }} @@ -193,14 +195,14 @@ jobs: shell: bash - name: Install Cargo toolchain - uses: actions-rs/toolchain@v1 + uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 # zizmor: ignore[archived-uses] with: toolchain: stable profile: minimal components: rustc - name: Cache Cargo dependencies - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: "solana" @@ -232,7 +234,7 @@ jobs: - name: Upload validator log on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ntt-e2e-validator-log path: /tmp/ntt-e2e-validator.log From 8c15fac12432ed196eaec89ec99d9bf80336ec9a Mon Sep 17 00:00:00 2001 From: csongor Date: Wed, 3 Jun 2026 15:47:40 +0200 Subject: [PATCH 11/28] cli: fix tests --- cli/test/solana.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/test/solana.sh b/cli/test/solana.sh index 852d8b4ad..67433011e 100755 --- a/cli/test/solana.sh +++ b/cli/test/solana.sh @@ -247,9 +247,9 @@ if [ -n "${SOLANA_ARTIFACTS_DIR:-}" ]; then fi if [ -n "$V1_BINARY" ]; then - ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" --binary "$V1_BINARY" + ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" --binary "$V1_BINARY" --deploy-program else - ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" + ntt add-chain Solana --ver 1.0.0 --mode burning --token "$token" --payer "$keypair" --program-key "$ntt_keypair" --deploy-program fi echo "Getting status" From eb51cfb09070f653a0f71dbb05af12fdc537f6fe Mon Sep 17 00:00:00 2001 From: csongor Date: Wed, 3 Jun 2026 21:25:52 +0200 Subject: [PATCH 12/28] solana: store instance id in InboxItem --- .../src/error.rs | 2 + .../src/instructions/redeem.rs | 2 + .../src/instructions/release_inbound.rs | 11 +- .../src/queue/inbox.rs | 8 ++ .../tests/receive.rs | 123 +++++++++++++++++- .../json/example_native_token_transfers.json | 9 ++ .../ts/example_native_token_transfers.ts | 18 +++ 7 files changed, 170 insertions(+), 3 deletions(-) diff --git a/solana/programs/example-native-token-transfers/src/error.rs b/solana/programs/example-native-token-transfers/src/error.rs index f6d12e606..e26101a27 100644 --- a/solana/programs/example-native-token-transfers/src/error.rs +++ b/solana/programs/example-native-token-transfers/src/error.rs @@ -67,6 +67,8 @@ pub enum NTTError { InvalidTransceiverProgram, #[msg("InvalidOutboxItem")] InvalidOutboxItem, + #[msg("InvalidInboxItem")] + InvalidInboxItem, } impl From for NTTError { diff --git a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs index 15c8fa4a9..110a7e2d0 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs @@ -136,10 +136,12 @@ pub fn redeem(ctx: Context, _args: RedeemArgs) -> Result<()> { if !accs.inbox_item.init { let recipient_address = Pubkey::try_from(message.payload.to).map_err(|_| NTTError::InvalidRecipientAddress)?; + let config = accs.config.key(); accs.inbox_item.set_inner(InboxItem { init: true, bump: ctx.bumps.inbox_item, + config, amount, recipient_address, release_status: ReleaseStatus::NotApproved, diff --git a/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs b/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs index bfc4976ca..3610caf9e 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs @@ -17,7 +17,16 @@ pub struct ReleaseInbound<'info> { pub config: NotPausedConfig<'info>, - #[account(mut)] + // SECURITY: the inbox item is created (in [`crate::instructions::redeem`]) as a PDA seeded by + // the instance's `config` key, but here it arrives as an already-allocated account with no way + // to re-derive those seeds (we don't have the original message). Without binding it back to + // `config`, an inbox item validated under one instance could be released against a *different* + // instance, whose `mint`/`custody`/`token_authority` are the ones actually used below — minting + // or unlocking that instance's tokens for a transfer it never received. + #[account( + mut, + constraint = inbox_item.config == config.key() @ NTTError::InvalidInboxItem, + )] pub inbox_item: Account<'info, InboxItem>, #[account( diff --git a/solana/programs/example-native-token-transfers/src/queue/inbox.rs b/solana/programs/example-native-token-transfers/src/queue/inbox.rs index e05cf3b26..3948335ad 100644 --- a/solana/programs/example-native-token-transfers/src/queue/inbox.rs +++ b/solana/programs/example-native-token-transfers/src/queue/inbox.rs @@ -13,6 +13,14 @@ pub struct InboxItem { // to guard against modifications to the `bump` and `amounts` fields. pub init: bool, pub bump: u8, + /// The instance (`config`) this inbox item belongs to. In [`crate::instructions::redeem`] + /// the inbox item PDA is seeded by `config.key()`, so this is implied by the address; we + /// also store it explicitly so that [`crate::instructions::release_inbound`] — which only + /// receives the already-created account and has no message to re-derive the seeds from — can + /// verify the binding. Without it, a transfer validated under one instance could be released + /// against another instance's mint/custody/token authority. + /// SECURITY: set once at creation, behind the [`init`] guard, and never mutated afterwards. + pub config: Pubkey, pub amount: u64, pub recipient_address: Pubkey, pub votes: Bitmap, diff --git a/solana/programs/example-native-token-transfers/tests/receive.rs b/solana/programs/example-native-token-transfers/tests/receive.rs index 35f612bec..000e01fea 100644 --- a/solana/programs/example-native-token-transfers/tests/receive.rs +++ b/solana/programs/example-native-token-transfers/tests/receive.rs @@ -5,7 +5,7 @@ use anchor_lang::prelude::*; use anchor_spl::token::{Token, TokenAccount}; use example_native_token_transfers::{ error::NTTError, - instructions::{RedeemArgs, ReleaseInboundArgs}, + instructions::{InitializeArgs, RedeemArgs, ReleaseInboundArgs}, }; use ntt_messages::mode::Mode; use solana_program::instruction::InstructionError; @@ -16,7 +16,7 @@ use solana_sdk::{ use spl_associated_token_account::get_associated_token_address_with_program_id; use test_utils::{ common::{ - fixtures::{ANOTHER_CHAIN, OTHER_CHAIN, OTHER_TRANSCEIVER}, + fixtures::{ANOTHER_CHAIN, OTHER_CHAIN, OTHER_TRANSCEIVER, OUTBOUND_LIMIT, THIS_CHAIN}, query::GetAccountDataAnchor, submit::Submittable, }, @@ -26,6 +26,7 @@ use test_utils::{ sdk::{ accounts::{good_ntt, NTTAccounts}, instructions::{ + initialize::{initialize_with_token_program_id, Initialize}, redeem::redeem, release_inbound::{release_inbound_unlock, ReleaseInbound}, }, @@ -448,3 +449,121 @@ async fn test_wrong_inbox_item() { ) ); } + +/// An inbox item is content-addressed and seeded by the *instance* it was +/// redeemed under, but `release_inbound` receives it as an already-allocated +/// account with no way to re-derive those seeds. Without an explicit binding, +/// instance A's released-ready inbox item could be released against a second +/// instance B — minting/unlocking B's tokens for a transfer B never received. +/// (Instance creation is permissionless, so the attacker can supply their own +/// instance A.) This test pins the binding: releasing A's inbox item against B +/// must fail with `InvalidInboxItem`. +#[tokio::test] +async fn test_release_inbound_rejects_cross_instance_inbox_item() { + let recipient = Keypair::new(); + // Instance A: the deployment `setup` stands up, in locking mode. + let (mut ctx, test_data) = setup(Mode::Locking).await; + let ntt_a = good_ntt(test_data.instance.pubkey()); + let xcvr_a = good_ntt_transceiver(test_data.instance.pubkey()); + + spl_associated_token_account::instruction::create_associated_token_account( + &ctx.payer.pubkey(), + &recipient.pubkey(), + &test_data.mint, + &Token::id(), + ) + .submit(&mut ctx) + .await + .unwrap(); + + let recipient_token_account = get_associated_token_address_with_program_id( + &recipient.pubkey(), + &test_data.mint, + &Token::id(), + ); + + // Validate an inbound transfer under instance A. This creates (and, at + // threshold 1, approves) `inbox_item_A`, ready to be released. + let msg = make_transfer_message(&ntt_a, [0u8; 32], 1000, &recipient.pubkey()); + let vaa = post_vaa_helper( + &ntt_a, + OTHER_CHAIN.into(), + Address(OTHER_TRANSCEIVER), + msg.clone(), + &mut ctx, + ) + .await; + receive_message( + &ntt_a, + &xcvr_a, + init_receive_message_accs(&xcvr_a, &mut ctx, vaa, OTHER_CHAIN, [0u8; 32]), + ) + .submit(&mut ctx) + .await + .unwrap(); + redeem( + &ntt_a, + init_redeem_accs( + &ntt_a, + &xcvr_a, + &mut ctx, + &test_data, + OTHER_CHAIN, + msg.ntt_manager_payload.clone(), + ), + RedeemArgs {}, + ) + .submit(&mut ctx) + .await + .unwrap(); + + // Instance B: a second, independent instance sharing the same mint. Its + // `config`/`token_authority`/`custody` are all distinct from A's, and + // `initialize` allocates B's custody, so every account in the release below + // is valid — only the inbox item belongs to the wrong instance. + let instance_b = Keypair::new(); + let ntt_b = good_ntt(instance_b.pubkey()); + initialize_with_token_program_id( + &ntt_b, + Initialize { + payer: ctx.payer.pubkey(), + owner: test_data.program_owner.pubkey(), + mint: test_data.mint, + multisig_token_authority: None, + }, + InitializeArgs { + chain_id: THIS_CHAIN, + limit: OUTBOUND_LIMIT, + mode: Mode::Locking, + }, + &Token::id(), + ) + .submit_with_signers(&[&test_data.program_owner, &instance_b], &mut ctx) + .await + .unwrap(); + + // Attack: release instance A's inbox item against instance B. + let err = release_inbound_unlock( + &ntt_b, + ReleaseInbound { + payer: ctx.payer.pubkey(), + inbox_item: ntt_a.inbox_item(OTHER_CHAIN, msg.ntt_manager_payload.clone()), + mint: test_data.mint, + recipient: recipient_token_account, + }, + ReleaseInboundArgs { + revert_when_not_ready: false, + }, + ) + .submit(&mut ctx) + .await + .unwrap_err(); + + assert_eq!( + err.unwrap(), + TransactionError::InstructionError( + 0, + InstructionError::Custom(NTTError::InvalidInboxItem.into()) + ) + ); +} diff --git a/solana/ts/idl/4_0_0/json/example_native_token_transfers.json b/solana/ts/idl/4_0_0/json/example_native_token_transfers.json index b4ed42ca6..5d0dd027e 100644 --- a/solana/ts/idl/4_0_0/json/example_native_token_transfers.json +++ b/solana/ts/idl/4_0_0/json/example_native_token_transfers.json @@ -1774,6 +1774,10 @@ "name": "bump", "type": "u8" }, + { + "name": "config", + "type": "publicKey" + }, { "name": "amount", "type": "u64" @@ -2394,6 +2398,11 @@ "code": 6030, "name": "InvalidOutboxItem", "msg": "InvalidOutboxItem" + }, + { + "code": 6031, + "name": "InvalidInboxItem", + "msg": "InvalidInboxItem" } ] } diff --git a/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts b/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts index 13d6bfee0..95476b6be 100644 --- a/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts +++ b/solana/ts/idl/4_0_0/ts/example_native_token_transfers.ts @@ -1774,6 +1774,10 @@ export type ExampleNativeTokenTransfers = { "name": "bump", "type": "u8" }, + { + "name": "config", + "type": "publicKey" + }, { "name": "amount", "type": "u64" @@ -2394,6 +2398,11 @@ export type ExampleNativeTokenTransfers = { "code": 6030, "name": "InvalidOutboxItem", "msg": "InvalidOutboxItem" + }, + { + "code": 6031, + "name": "InvalidInboxItem", + "msg": "InvalidInboxItem" } ] } @@ -4173,6 +4182,10 @@ export const IDL: ExampleNativeTokenTransfers = { "name": "bump", "type": "u8" }, + { + "name": "config", + "type": "publicKey" + }, { "name": "amount", "type": "u64" @@ -4793,6 +4806,11 @@ export const IDL: ExampleNativeTokenTransfers = { "code": 6030, "name": "InvalidOutboxItem", "msg": "InvalidOutboxItem" + }, + { + "code": 6031, + "name": "InvalidInboxItem", + "msg": "InvalidInboxItem" } ] } From 0fb080353c06ab6afcc1269e99d281816c713af2 Mon Sep 17 00:00:00 2001 From: csongor Date: Thu, 11 Jun 2026 20:27:52 +0200 Subject: [PATCH 13/28] solana: check recipient_ntt_manager at receive time The transceiver_message PDA is scoped by config.key(), but nothing bound the message's recipient_ntt_manager to it, so a VAA addressed to instance A could be used to create a (redundant, and ultimately unredeemable) transceiver message under instance B. redeem already enforces the binding for fund safety; also check it in both transceivers' receive_message so the mis-scoped account is never created. --- .../wormhole/instructions/receive_message.rs | 9 ++++++++ .../tests/receive.rs | 21 ++++------------- .../programs/ntt-transceiver/src/vaa_body.rs | 10 ++++++++ .../wormhole/instructions/receive_message.rs | 11 +++++++++ .../programs/ntt-transceiver/tests/receive.rs | 23 ++++--------------- 5 files changed, 38 insertions(+), 36 deletions(-) diff --git a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs index f24304ec1..7e62d907f 100644 --- a/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs +++ b/solana/programs/example-native-token-transfers/src/transceivers/wormhole/instructions/receive_message.rs @@ -33,6 +33,15 @@ pub struct ReceiveMessage<'info> { #[account( // check that the messages is targeted to this chain constraint = vaa.message().ntt_manager_payload.payload.to_chain == config.chain_id @ NTTError::InvalidChainId, + // check that the message is addressed to *this* instance. The + // `transceiver_message` PDA below is scoped by `config.key()`, but + // nothing otherwise binds the message's `recipient_ntt_manager` to it, + // so a VAA addressed to instance A could be used to create a (redundant, + // and ultimately unredeemable) transceiver message under instance B. + // [`crate::instructions::redeem`] enforces this for fund safety; we also + // check it here so the binding is local and the mis-scoped account is + // never created in the first place. + constraint = vaa.message().recipient_ntt_manager == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, // NOTE: we don't replay protect VAAs. Instead, we replay protect // executing the messages themselves with the [`released`] flag. )] diff --git a/solana/programs/example-native-token-transfers/tests/receive.rs b/solana/programs/example-native-token-transfers/tests/receive.rs index 000e01fea..e660ff523 100644 --- a/solana/programs/example-native-token-transfers/tests/receive.rs +++ b/solana/programs/example-native-token-transfers/tests/receive.rs @@ -250,7 +250,10 @@ async fn test_wrong_recipient_ntt_manager() { ) .await; - receive_message( + // v4: the recipient_ntt_manager binding is now checked at receive time, so a + // message addressed to a different instance is rejected before it can create + // a (mis-scoped) transceiver message — rather than later at redeem. + let err = receive_message( &good_ntt, &good_ntt_transceiver, init_receive_message_accs( @@ -263,22 +266,6 @@ async fn test_wrong_recipient_ntt_manager() { ) .submit(&mut ctx) .await - .unwrap(); - - let err = redeem( - &good_ntt, - init_redeem_accs( - &good_ntt, - &good_ntt_transceiver, - &mut ctx, - &test_data, - OTHER_CHAIN, - msg.ntt_manager_payload.clone(), - ), - RedeemArgs {}, - ) - .submit(&mut ctx) - .await .unwrap_err(); assert_eq!( diff --git a/solana/programs/ntt-transceiver/src/vaa_body.rs b/solana/programs/ntt-transceiver/src/vaa_body.rs index 50f958357..22d3ebc62 100644 --- a/solana/programs/ntt-transceiver/src/vaa_body.rs +++ b/solana/programs/ntt-transceiver/src/vaa_body.rs @@ -47,6 +47,16 @@ impl<'a> VaaBodyBytes<'a> { self.span[10..42].try_into().unwrap() } + /// The `recipient_ntt_manager` of the wrapped `TransceiverMessage`. + /// Layout within the VAA body: the Wormhole payload starts at byte 51, then + /// the `TransceiverMessage` is `prefix[4] ‖ source_ntt_manager[32] ‖ + /// recipient_ntt_manager[32] ‖ payload_len[2] ‖ ntt_manager_payload…`, so + /// `recipient_ntt_manager` lives at `51 + 4 + 32 = 87..119` (immediately + /// before [`id`], which the existing `121..153` slice confirms). + pub fn recipient_ntt_manager(&self) -> &[u8; 32] { + self.span[87..119].try_into().unwrap() + } + pub fn id(&self) -> &[u8; 32] { self.span[121..153].try_into().unwrap() } diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs index 0347e454c..9f02b58e4 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/receive_message.rs @@ -27,6 +27,14 @@ pub struct ReceiveMessageInstructionData<'info> { #[account( // check that the messages is targeted to this chain constraint = vaa_body.as_vaa_body_bytes().to_chain() == config.chain_id @ NTTError::InvalidChainId, + // check that the message is addressed to *this* instance. The + // `transceiver_message` PDA below is scoped by `config.key()`, but nothing + // otherwise binds the message's `recipient_ntt_manager` to it, so a VAA + // addressed to instance A could be used to create a (redundant, and + // ultimately unredeemable) transceiver message under instance B. `redeem` + // enforces this for fund safety; we also check it here so the binding is + // local and the mis-scoped account is never created in the first place. + constraint = *vaa_body.as_vaa_body_bytes().recipient_ntt_manager() == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, )] pub config: NotPausedConfig<'info>, @@ -114,6 +122,9 @@ pub struct ReceiveMessageAccount<'info> { #[account( // check that the messages is targeted to this chain constraint = message.as_vaa_body_bytes().to_chain() == config.chain_id @ NTTError::InvalidChainId, + // check that the message is addressed to *this* instance (see the note on + // the equivalent constraint in `ReceiveMessageInstructionData`). + constraint = *message.as_vaa_body_bytes().recipient_ntt_manager() == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, )] pub config: NotPausedConfig<'info>, diff --git a/solana/programs/ntt-transceiver/tests/receive.rs b/solana/programs/ntt-transceiver/tests/receive.rs index 10fd49998..c8544dbed 100644 --- a/solana/programs/ntt-transceiver/tests/receive.rs +++ b/solana/programs/ntt-transceiver/tests/receive.rs @@ -421,7 +421,10 @@ async fn test_wrong_recipient_ntt_manager() { ) .await; - receive_message_instruction_data( + // v4: the recipient_ntt_manager binding is now checked at receive time, so a + // message addressed to a different instance is rejected before it can create + // a (mis-scoped) transceiver message — rather than later at redeem. + let err = receive_message_instruction_data( &good_ntt, &good_ntt_transceiver, init_receive_message_accs( @@ -437,24 +440,6 @@ async fn test_wrong_recipient_ntt_manager() { ) .submit(&mut ctx) .await - .unwrap(); - - close_signatures(&good_ntt_transceiver, &mut ctx, &guardian_signatures).await; - - let err = redeem( - &good_ntt, - init_redeem_accs( - &good_ntt, - &good_ntt_transceiver, - &mut ctx, - &test_data, - OTHER_CHAIN, - msg.ntt_manager_payload.clone(), - ), - RedeemArgs {}, - ) - .submit(&mut ctx) - .await .unwrap_err(); assert_eq!( From 63910c4daf95eb0d64a3e2f695d5ed8f185c57ab Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 16 Jun 2026 17:38:25 +0200 Subject: [PATCH 14/28] cli: fix multisig token authority derivation for v4 instances create-spl-multisig was always calling NTT.pdas(managerKey) without the instance pubkey, so for v4 multi-tenant deployments the multisig would be created with the wrong (v3-style) token authority as its member. --- cli/src/commands/solana.ts | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/solana.ts b/cli/src/commands/solana.ts index a8776c8aa..41dabf1e2 100644 --- a/cli/src/commands/solana.ts +++ b/cli/src/commands/solana.ts @@ -152,6 +152,15 @@ export function createSolanaCommand( describe: "Token address", type: "string", }) + .option("instance", { + describe: + "(Multi-tenant / v4) The Instance pubkey under the program. " + + "v4 token_authority PDAs are scoped by instance, so this " + + "flag is required when the undeployed program is " + + "multi-tenant. For a deployed program the instance is read " + + "from the deployment file.", + type: "string", + }) .option("path", options.deploymentPath) .option("yes", options.yes) .option("payer", { ...options.payer, demandOption: true }) @@ -192,6 +201,17 @@ export function createSolanaCommand( process.exit(1); } + const instance = argv["instance"] + ? new PublicKey(argv["instance"]) + : undefined; + if (instance && !manager) { + console.error( + "--instance is only valid together with --token and --manager; " + + "for a deployed program the instance is read from the deployment file" + ); + process.exit(1); + } + const wh = new Wormhole( network, [solana.Platform, evm.Platform], @@ -201,7 +221,7 @@ export function createSolanaCommand( const connection: Connection = await ch.getRpc(); let solanaNtt: SolanaNtt | undefined; - let managerKey: PublicKey; + let tokenAuthority: PublicKey; let major: number; let tokenProgram: PublicKey; @@ -221,9 +241,12 @@ export function createSolanaCommand( chainConfig.instance ); solanaNtt = ntt as SolanaNtt; - managerKey = new PublicKey(chainConfig.manager); major = Number(solanaNtt.version.split(".")[0]); tokenProgram = (await solanaNtt.getConfig()).tokenProgram; + // `solanaNtt.pdas` is scoped by `chainConfig.instance` for v4 + // (multi-tenant) managers, so this derives the instance-scoped + // token authority; for v3 it's the legacy derivation. + tokenAuthority = solanaNtt.pdas.tokenAuthority(); } // default values as undeployed program else { @@ -239,13 +262,14 @@ export function createSolanaCommand( spl.unpackMint(tokenMint, mintInfo, mintInfo.owner); solanaNtt = undefined; - managerKey = new PublicKey(manager!); major = -1; tokenProgram = mintInfo.owner; + tokenAuthority = NTT.pdas( + new PublicKey(manager!), + instance + ).tokenAuthority(); } - const tokenAuthority = NTT.pdas(managerKey).tokenAuthority(); - // check if SPL-Multisig is supported for manager version // undeployed -- assume version compatible via warning if (major === -1) { From 32836fc07b0aa045d985793758ee2ad24843f6ae Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 23 Jun 2026 18:02:02 +0200 Subject: [PATCH 15/28] solana: fix ntt_quoter IDL import path in 4_0_0 The quoter IDL is only emitted under idl/4_0_0/ts/, not idl/3_0_0/ts/, so the 3_0_0 import path did not resolve and broke the SDK TS build (TS2307), failing every CI job that compiles sdk-solana-ntt. --- solana/ts/lib/anchor-idl/4_0_0.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/solana/ts/lib/anchor-idl/4_0_0.ts b/solana/ts/lib/anchor-idl/4_0_0.ts index 2152af5d5..738752430 100644 --- a/solana/ts/lib/anchor-idl/4_0_0.ts +++ b/solana/ts/lib/anchor-idl/4_0_0.ts @@ -2,13 +2,14 @@ import { type ExampleNativeTokenTransfers } from "../../idl/4_0_0/ts/example_nat import { IDL as ntt } from "../../idl/4_0_0/ts/example_native_token_transfers.js"; // The transceiver/quoter/governance programs are independent of the NTT manager // and were not changed in v4. Re-use the 3.0.0 IDL for those until they need -// their own version bumps. +// their own version bumps. (The quoter IDL was only emitted under 4_0_0; the +// program is unchanged, so it's equivalent.) import { type NttTransceiver } from "../../idl/3_0_0/ts/ntt_transceiver.js"; import { IDL as transceiver } from "../../idl/3_0_0/ts/ntt_transceiver.js"; import { type NttTransceiverLegacy } from "../../idl/3_0_0/ts/ntt_transceiver_legacy.js"; import { IDL as transceiverLegacy } from "../../idl/3_0_0/ts/ntt_transceiver_legacy.js"; -import { type NttQuoter } from "../../idl/3_0_0/ts/ntt_quoter.js"; -import { IDL as quoter } from "../../idl/3_0_0/ts/ntt_quoter.js"; +import { type NttQuoter } from "../../idl/4_0_0/ts/ntt_quoter.js"; +import { IDL as quoter } from "../../idl/4_0_0/ts/ntt_quoter.js"; import { type WormholeGovernance } from "../../idl/3_0_0/ts/wormhole_governance.js"; import { IDL as governance } from "../../idl/3_0_0/ts/wormhole_governance.js"; From 57ea90d15f9db22efd7f7d14ca105782d7e8009d Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 23 Jun 2026 18:03:01 +0200 Subject: [PATCH 16/28] ci: correct pinned actions/checkout version comment The SHA de0fac2e... is tag v6.0.2, but cli.yml:154 commented it as v6 (which now floats to a different commit), tripping zizmor's ref-version-mismatch audit. Match the other 20 pins in the repo. --- .github/workflows/cli.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index b2a69e82c..5707c2ee7 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -151,7 +151,7 @@ jobs: solana-cli-version: "1.18.26" anchor-version: "0.29.0" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false From 0ca9d3895fb23f4a17a9d02e421dca5b757ed84f Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 23 Jun 2026 18:04:29 +0200 Subject: [PATCH 17/28] cli: instance-scope token authority in set-mint-authority For a v4 (multi-tenant) manager the token_authority PDA is scoped by the instance, but set-mint-authority derived it with NTT.pdas(manager) (legacy/v3), so on a deployed v4 manager it would target the wrong PDA and set the mint authority to an address the program cannot sign for. Mirror the fix already applied to svm create-spl-multisig in solana.ts: use solanaNtt.pdas.tokenAuthority() on the deployed path (instance read from the deployment file) and add an --instance flag for the undeployed path via NTT.pdas(manager, instance). --- cli/src/commands/set-mint-authority.ts | 28 ++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/set-mint-authority.ts b/cli/src/commands/set-mint-authority.ts index cd787145e..a8e08b40d 100644 --- a/cli/src/commands/set-mint-authority.ts +++ b/cli/src/commands/set-mint-authority.ts @@ -55,6 +55,15 @@ export function createSetMintAuthorityCommand( describe: "Token address", type: "string", }) + .option("instance", { + describe: + "(Multi-tenant / v4) The Instance pubkey under the program. " + + "v4 token_authority PDAs are scoped by instance, so this " + + "flag is required when the undeployed program is " + + "multi-tenant. For a deployed program the instance is read " + + "from the deployment file.", + type: "string", + }) .option("path", options.deploymentPath) .option("yes", options.yes) .option("payer", { ...options.payer, demandOption: true }) @@ -108,10 +117,22 @@ export function createSetMintAuthorityCommand( process.exit(1); } + const instance = argv["instance"] + ? new PublicKey(argv["instance"]) + : undefined; + if (instance && !manager) { + console.error( + "--instance is only valid together with --token and --manager; " + + "for a deployed program the instance is read from the deployment file" + ); + process.exit(1); + } + let solanaNtt: SolanaNtt | undefined; let tokenMint: PublicKey; let managerKey: PublicKey; let major: number; + let tokenAuthority: PublicKey; // program deployed so fetch token and manager addresses from deployment if (!token && !manager) { @@ -132,6 +153,10 @@ export function createSetMintAuthorityCommand( tokenMint = (await solanaNtt.getConfig()).mint; managerKey = new PublicKey(chainConfig.manager); major = Number(solanaNtt.version.split(".")[0]); + // `solanaNtt.pdas` is scoped by `chainConfig.instance` for v4 + // (multi-tenant) managers, so this derives the instance-scoped token + // authority; for v3 it's the legacy derivation. + tokenAuthority = solanaNtt.pdas.tokenAuthority(); } // default values as undeployed program else { @@ -139,6 +164,7 @@ export function createSetMintAuthorityCommand( tokenMint = new PublicKey(token!); managerKey = new PublicKey(manager!); major = -1; + tokenAuthority = NTT.pdas(managerKey, instance).tokenAuthority(); } const wh = new Wormhole( @@ -149,8 +175,6 @@ export function createSetMintAuthorityCommand( const ch = wh.getChain(chain); const connection: Connection = await ch.getRpc(); - const tokenAuthority = NTT.pdas(managerKey).tokenAuthority(); - // verify current mint authority is not token authority const mintInfo = await connection.getAccountInfo(tokenMint); if (!mintInfo) { From d6448acc93c1bf16098dab10c9b08927acbd572c Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 23 Jun 2026 18:06:16 +0200 Subject: [PATCH 18/28] solana: check outbox_item.manager in transceiver release_outbound Mirror the standalone transceiver's sanity check (which mdulin2 noted is missing from the ntt-transceiver copy): assert outbox_item.manager == config.key() so an outbox item from another instance can't be released here. The mark_outbox_item_as_released CPI already enforces this; this is a second layer. --- .../src/wormhole/instructions/release_outbound.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs index b64765ddf..462aeda0d 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs @@ -23,6 +23,10 @@ pub struct ReleaseOutbound<'info> { #[account( mut, + // sanity check: the outbox item must belong to this instance. This is + // already enforced by the `mark_outbox_item_as_released` CPI below, but + // mirror the standalone transceiver and check it here as a second layer. + constraint = outbox_item.manager == config.key() @ NTTError::InvalidOutboxItem, constraint = !outbox_item.released.get(transceiver.id)? @ NTTError::MessageAlreadySent, )] pub outbox_item: Account<'info, OutboxItem>, From 211d6675b2f5048e9a65700aa1716558fbab171a Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 23 Jun 2026 18:08:05 +0200 Subject: [PATCH 19/28] solana: derive RegisteredTransceiver PDA in transceiver release_outbound Add the RegisteredTransceiver seed derivation mdulin2 noted is present in the standalone transceiver but missing here. Because the account is owned by the manager program, the derivation uses seeds::program = example_native_token_transfers::ID. Already validated by the CPI; this is a second-layer sanity check. --- .../src/wormhole/instructions/release_outbound.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs index 462aeda0d..d49ad0722 100644 --- a/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs +++ b/solana/programs/ntt-transceiver/src/wormhole/instructions/release_outbound.rs @@ -32,6 +32,13 @@ pub struct ReleaseOutbound<'info> { pub outbox_item: Account<'info, OutboxItem>, #[account( + // sanity check: derive the RegisteredTransceiver PDA as a second layer. + // It's a manager-owned account (hence `seeds::program` points at the + // manager, unlike the standalone transceiver which lives in the manager + // program), and is already validated by the CPI below. + seeds = [RegisteredTransceiver::SEED_PREFIX, config.key().as_ref(), transceiver.transceiver_address.as_ref()], + bump, + seeds::program = example_native_token_transfers::ID, constraint = transceiver.transceiver_address == crate::ID, constraint = config.enabled_transceivers.get(transceiver.id)? @ NTTError::DisabledTransceiver )] From 9a99fa3473cda34c844915b75f3b8d517cec78e7 Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 23 Jun 2026 18:21:58 +0200 Subject: [PATCH 20/28] solana: define __dirname in ESM anchor tests multi_instance.test.ts and upgrade_authority_decoupling.test.ts used the CommonJS __dirname global, which is undefined under jest's ESM mode, so both suites failed to load with ReferenceError. This was masked while the SDK build (Setup SDK step) was failing earlier in the job. Derive it from import.meta.url, mirroring anchor.test.ts. --- solana/tests/anchor/multi_instance.test.ts | 3 +++ solana/tests/anchor/upgrade_authority_decoupling.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/solana/tests/anchor/multi_instance.test.ts b/solana/tests/anchor/multi_instance.test.ts index 1e864a902..6ae2c57fa 100644 --- a/solana/tests/anchor/multi_instance.test.ts +++ b/solana/tests/anchor/multi_instance.test.ts @@ -30,10 +30,13 @@ import { } from "../../ts/sdk/index.js"; import { NTT } from "../../ts/index.js"; import { TestHelper, TestMint, assert, signSendWait } from "./utils/helpers.js"; +import { fileURLToPath } from "url"; registerDefinitionsNtt(); registerSolanaNtt(); +// Native ESM (jest useESM) has no __dirname; derive it from import.meta.url. +const __dirname = fileURLToPath(new URL(".", import.meta.url)); const SOLANA_ROOT_DIR = `${__dirname}/../../`; const VERSION: IdlVersion = "4.0.0"; const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; diff --git a/solana/tests/anchor/upgrade_authority_decoupling.test.ts b/solana/tests/anchor/upgrade_authority_decoupling.test.ts index 400ad0395..ddb04e260 100644 --- a/solana/tests/anchor/upgrade_authority_decoupling.test.ts +++ b/solana/tests/anchor/upgrade_authority_decoupling.test.ts @@ -26,10 +26,13 @@ import { BPF_LOADER_UPGRADEABLE_PROGRAM_ID, programDataAddress, } from "../../ts/lib/utils.js"; +import { fileURLToPath } from "url"; registerDefinitionsNtt(); registerSolanaNtt(); +// Native ESM (jest useESM) has no __dirname; derive it from import.meta.url. +const __dirname = fileURLToPath(new URL(".", import.meta.url)); const SOLANA_ROOT_DIR = `${__dirname}/../../`; const VERSION: IdlVersion = "4.0.0"; const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; From 8f1348e0b7a17f9503d8300c063e5dd6497604bb Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 30 Jun 2026 17:42:24 +0200 Subject: [PATCH 21/28] solana: validate transceiver_message seed in redeem --- .../src/instructions/redeem.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs index 110a7e2d0..d1859cbe5 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/redeem.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/redeem.rs @@ -43,6 +43,20 @@ pub struct Redeem<'info> { constraint = ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.recipient_ntt_manager() == config.key().to_bytes() @ NTTError::InvalidRecipientNttManager, // NOTE: we don't replay protect VAAs. Instead, we replay protect // executing the messages themselves with the [`released`] flag. + // Cryptographically bind this account to the instance: since + // receive_message seeds transceiver_message with config.key(), a message + // created for instance A cannot be passed here for instance B. + seeds = [ + ValidatedTransceiverMessage::>::SEED_PREFIX, + config.key().as_ref(), + ValidatedTransceiverMessage::>::from_chain(&transceiver_message)?.id.to_be_bytes().as_ref(), + ValidatedTransceiverMessage::>::message(&transceiver_message.try_borrow_data()?[..])?.ntt_manager_payload().id.as_ref(), + ], + seeds::program = transceiver.transceiver_address, + bump, + // `owner` is implied by the seeds check above (a valid PDA of + // `transceiver.transceiver_address` must be owned by it), but kept + // here for clarity. owner = transceiver.transceiver_address )] /// CHECK: `transceiver_message` has to be manually deserialized as Anchor From ecf79efc8895fa7f61738c8ba84437c49d60a17b Mon Sep 17 00:00:00 2001 From: csongor Date: Mon, 20 Jul 2026 17:27:18 +0100 Subject: [PATCH 22/28] solana: update README --- solana/README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/solana/README.md b/solana/README.md index ca7ba9c34..16ce1d129 100644 --- a/solana/README.md +++ b/solana/README.md @@ -81,6 +81,58 @@ Program log: Instruction: ReleaseInboundMint Program log: Instruction: ReleaseInboundUnlock ``` +## Trust Model + +Version 4 changes the Solana program from a single-instance model to a multi-instance model. This section describes the instances, the isolation between them, and the authorities that an operator must trust. + +### Instances + +In version 3, one program held one NTT deployment. The program ID was the manager identity in each message. + +In version 4, one program can hold many NTT deployments. Each deployment is an _instance_. An instance is a `Config` account. The operator supplies a new keypair for this account. The operator signs the [`initialize`] instruction with this keypair. The program does not derive the `Config` account as a PDA. + +The public key of the `Config` account is the manager identity of the instance. A peer on another chain registers this key as the Solana manager address. The program writes this key into each outbound message. The program checks this key on each inbound message. + +Version 4 conforms to the NTT specification. The wire format does not change. The manager identity is still a 32-byte address. Therefore an operator can register a version 4 instance into an existing NTT mesh. The peers on the foreign chains do not need a contract upgrade. Each foreign chain registers the `Config` key as a new peer. + +> The operator chooses the `Config` keypair. Therefore two instances in the same program are fully independent. One program can hold many tokens and many owners at the same time. + +### Isolation between instances + +The program keeps the state of each instance separate. Every per-instance account holds the `Config` key in its PDA seeds. This rule applies to the rate limits, the peers, the registered transceivers, the token authority, the session authority, the emitter, and the inbox items. + +The program computes each address from the seeds. Therefore an account of one instance cannot take the place of an account of another instance. The program rejects a wrong account. + +The program also binds each message and each inbox or outbox item to its instance: + +- [`receive_message`] accepts a VAA only when the `recipient_ntt_manager` field equals the `Config` key. The program rejects a VAA that names a different instance. +- [`redeem`] checks the `transceiver_message` seeds against the `Config` key. +- [`release_inbound_mint`] and [`release_inbound_unlock`] accept an inbox item only when the stored `config` field equals the `Config` key. An inbox item from one instance cannot release funds from another instance. +- [`release_outbound`] accepts an outbox item only when the `manager` field equals the `Config` key. A transceiver of one instance cannot release an outbox item of another instance. + +> These checks are necessary because two instances can manage the same token mint. Without these checks, a message or an item from one instance could move funds through another instance. + +### Instance ownership and the program upgrade authority + +Version 4 separates two authorities. Version 3 held these two authorities together. + +The _instance owner_ is the `owner` field of the `Config` account. The owner controls one instance. The owner can set the peers, set the threshold, pause the instance, and transfer the ownership. A transfer of ownership changes the data in the `Config` account. The [`transfer_ownership`] and [`claim_ownership`] instructions do not touch the BPF loader. + +The _program upgrade authority_ is the BPF loader upgrade authority of the program. This authority can replace the program code. New code applies to every instance in the program at the same time. + +> In version 3, one party held both the instance ownership and the program upgrade authority. In version 4, the two are independent. An instance owner has no control over the program code. The program upgrade authority has no ownership of an instance. + +### What an operator must trust + +All instances in one program run the same code. Therefore the party that holds the program upgrade authority can change the behavior of every instance. This party can mint tokens, unlock custody, or stop transfers for all instances. + +An operator that deploys an instance into a shared program must trust the party that holds the program upgrade authority. To reduce this trust, do one of these steps after the deployment: + +- Set the program upgrade authority to `null`. The program becomes immutable. +- Give the program upgrade authority to a multisig or a governance program. All tenants must accept this party. + +An instance owner does not need to trust the other instance owners. The isolation rules limit each owner to one instance. + ## Message Customization See the [NttManager](../docs/NttManager.md) doc for wire format details. From 34b3a5f36470d0e1ae1635ae6809aad1a66a5f2c Mon Sep 17 00:00:00 2001 From: csongor Date: Mon, 20 Jul 2026 17:28:45 +0100 Subject: [PATCH 23/28] cli: instance-scope emitter in getPdas --- cli/src/query.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/src/query.ts b/cli/src/query.ts index 6751dca26..bc330cc45 100644 --- a/cli/src/query.ts +++ b/cli/src/query.ts @@ -89,8 +89,12 @@ export async function getPdas( } const solanaNtt = ntt as SolanaNtt; const config = solanaNtt.pdas.configAccount(); + // Scope by the v4 instance pubkey so this derives the instance's emitter + // (`[b"emitter", config.key()]`); `instance` is undefined for v3, which + // falls back to the legacy unscoped derivation. const emitter = NTT.transceiverPdas( - solanaNtt.program.programId + solanaNtt.program.programId, + solanaNtt.instance ).emitterAccount(); const outboxRateLimit = solanaNtt.pdas.outboxRateLimitAccount(); const tokenAuthority = solanaNtt.pdas.tokenAuthority(); From 39ce52fccedd8c0f27b7a0706bf92d08ae9e6c1b Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 21 Jul 2026 13:06:22 +0100 Subject: [PATCH 24/28] solana: add peerManagerAddress() to SolanaNtt --- solana/ts/sdk/ntt.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index 623e4d174..cd9dcc673 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -701,6 +701,22 @@ export class SolanaNtt this.pdas = NTT.pdas(this.program.programId, this.instance); } + /** + * The on-the-wire NTT manager identity of this deployment — the address a + * peer on another chain must register, and the value written into and checked + * against a message's `recipient_ntt_manager`. + * + * v4 (multi-tenant) uses the per-deployment Instance (Config) pubkey; v3 + * (singleton) uses the program ID. This is the single source of truth for + * that identity: callers deriving a peer address, verifying a registration, + * or reporting deployment state must use it rather than the raw program ID, + * otherwise a v4 instance would be registered under an address whose + * transfers it rejects (`recipient_ntt_manager != config.key()`). + */ + peerManagerAddress(): PublicKey { + return this.instance ?? this.program.programId; + } + async getTransceiver( ix: T ): Promise< From 82a98e566bfc78ec277bdb4a75ba14834c5bb0b8 Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 21 Jul 2026 13:06:22 +0100 Subject: [PATCH 25/28] cli: register v4 instance as the Solana peer address --- cli/src/validation.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/cli/src/validation.ts b/cli/src/validation.ts index 28379bd48..fde785be6 100644 --- a/cli/src/validation.ts +++ b/cli/src/validation.ts @@ -4,6 +4,7 @@ import { assertChain, chainToPlatform, chains, + toUniversal, type Chain, type ChainAddress, type ChainContext, @@ -178,6 +179,27 @@ export function printMissingConfigReport( return true; } +/** + * The address that peers on other chains must register as this deployment's + * NTT manager. For a Solana v4 (multi-tenant) deployment this is the + * per-instance Config pubkey — the on-wire `recipient_ntt_manager` — which the + * SDK exposes via `peerManagerAddress()`. Everywhere else it is the + * deployment's `manager`. Registering the raw program ID for a v4 Solana + * deployment would create a peer whose transfers the instance rejects as + * unredeemable. + */ +function onWireManager(d: Deployment): ChainAddress { + if (chainToPlatform(d.manager.chain) === "Solana") { + const solanaNtt = d.ntt as SolanaNtt; + const identity = solanaNtt.peerManagerAddress(); + return { + chain: d.manager.chain, + address: toUniversal(d.manager.chain, identity.toBase58()), + } as ChainAddress; + } + return d.manager; +} + /** Collect missing implicit config across deployments (peers, Solana LUT/transceiver). */ export async function collectMissingConfigs( deps: Partial<{ [C in Chain]: Deployment }>, @@ -270,20 +292,23 @@ export async function collectMissingConfigs( 5, 5000 ); + // The on-wire manager identity the peer must point at: for a v4 Solana + // `to` this is the instance pubkey, not the program ID (see onWireManager). + const toManager = onWireManager(to); if (peer === null) { const configLimit = from.config.local?.limits?.inbound?.[ toChain ]?.replace(/\./g, ""); missingCounts[fromChain] = (missingCounts[fromChain] ?? 0) + 1; missing.managerPeers.push({ - address: to.manager, + address: toManager, tokenDecimals: to.decimals, inboundLimit: BigInt(configLimit ?? 0), }); } else { if ( !Buffer.from(peer.address.address.address.toString()).equals( - Buffer.from(to.manager.address.address.toString()) + Buffer.from(toManager.address.address.toString()) ) ) { console.error(`Peer address mismatch for ${fromChain} -> ${toChain}`); From 2f157887834c9c35621042cbd3ec521ef912c32d Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 21 Jul 2026 13:06:23 +0100 Subject: [PATCH 26/28] cli: enforce upgrade barrier on push, in both directions --- cli/src/__tests__/upgradeBarriers.test.ts | 34 +++++++++++++++++++++++ cli/src/config-mgmt.ts | 17 ++++++++++-- cli/src/upgradeBarriers.ts | 31 +++++++++++++-------- 3 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 cli/src/__tests__/upgradeBarriers.test.ts diff --git a/cli/src/__tests__/upgradeBarriers.test.ts b/cli/src/__tests__/upgradeBarriers.test.ts new file mode 100644 index 000000000..0f5173360 --- /dev/null +++ b/cli/src/__tests__/upgradeBarriers.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "bun:test"; +import { canUpgrade } from "../upgradeBarriers"; + +describe("canUpgrade", () => { + it("blocks a Solana v3 -> v4 upgrade across the barrier", () => { + const check = canUpgrade("Solana", "3.0.0", "4.0.0"); + expect(check.ok).toBe(false); + if (!check.ok) expect(check.reason).toContain("v4"); + }); + + it("blocks a Solana v4 -> v3 downgrade across the barrier", () => { + // Regression: the barrier must hold in both directions. A downgrade would + // run v3 bytecode against the v4 account layout and strand the deployment. + const check = canUpgrade("Solana", "4.0.0", "3.0.0"); + expect(check.ok).toBe(false); + }); + + it("permits a Solana upgrade that stays below the barrier", () => { + expect(canUpgrade("Solana", "2.0.0", "3.0.0").ok).toBe(true); + }); + + it("permits a Solana upgrade that stays at or above the barrier", () => { + expect(canUpgrade("Solana", "4.0.0", "4.1.0").ok).toBe(true); + }); + + it("permits a local (null target) version change", () => { + expect(canUpgrade("Solana", "3.0.0", null).ok).toBe(true); + }); + + it("does not block a chain that has no registered barrier", () => { + // Only Solana has a v4 barrier registered; EVM crossing majors is allowed. + expect(canUpgrade("Ethereum", "3.0.0", "4.0.0").ok).toBe(true); + }); +}); diff --git a/cli/src/config-mgmt.ts b/cli/src/config-mgmt.ts index a3c1c8a7a..b81f00dbe 100644 --- a/cli/src/config-mgmt.ts +++ b/cli/src/config-mgmt.ts @@ -40,6 +40,7 @@ import { } from "./query"; import { askForConfirmation } from "./prompts.js"; import { upgrade } from "./deploy"; +import { canUpgrade } from "./upgradeBarriers"; import { runTaskPoolWithSequential } from "./utils/concurrency"; export async function pushDeployment( @@ -81,8 +82,20 @@ export async function pushDeployment( let managerUpgrade: { from: string; to: string } | undefined; for (const k of Object.keys(diff)) { if (k === "version") { - // TODO: check against existing version, and make sure no major version changes - managerUpgrade = { from: diff[k]!.pull!, to: diff[k]!.push! }; + const from = diff[k]!.pull!; + const to = diff[k]!.push!; + // Enforce the same breaking-change barrier as the dedicated `upgrade` + // command: a config push must not drive a version change across an + // incompatible on-chain layout (e.g. Solana v3<->v4 in place, which + // would strand custody). See canUpgrade / UPGRADE_BARRIERS. + const check = canUpgrade(deployment.manager.chain, from, to); + if (!check.ok) { + console.error( + `Refusing to change ${deployment.manager.chain} version from ${from} to ${to} via push:\n${check.reason}` + ); + process.exit(1); + } + managerUpgrade = { from, to }; } else if (k === "owner") { const address: AccountAddress = toUniversal( deployment.manager.chain, diff --git a/cli/src/upgradeBarriers.ts b/cli/src/upgradeBarriers.ts index af38e6714..debddd999 100644 --- a/cli/src/upgradeBarriers.ts +++ b/cli/src/upgradeBarriers.ts @@ -2,13 +2,15 @@ import type { Chain } from "@wormhole-foundation/sdk"; /** * A registered breaking-change barrier between two major versions of the NTT - * deployment for a given chain. An in-place upgrade is blocked iff the source - * version's major is below the barrier and the target version's major is at or - * above it. + * deployment for a given chain. An in-place version change is blocked iff it + * crosses the barrier in *either* direction — i.e. exactly one of the source + * and target majors is at or above the barrier. This covers both an upgrade + * past the barrier and a downgrade back across it, since the on-chain layout + * is incompatible in both directions. * * Add an entry here whenever a major version introduces an on-chain layout or - * wire-format change that an existing deployment cannot be upgraded into. The - * `reason` string is surfaced verbatim when blocking an upgrade attempt. + * wire-format change that an existing deployment cannot be migrated into in + * place. The `reason` string is surfaced verbatim when blocking the attempt. */ export type UpgradeBarrier = { chain: Chain; @@ -24,8 +26,9 @@ export const UPGRADE_BARRIERS: UpgradeBarrier[] = [ "Solana NTT v4 changes the on-chain account layout (PDAs are scoped by " + "instance ID, the Instance account is keypair-created instead of a PDA, " + "and the on-the-wire NTT manager identity is the Instance pubkey rather " + - "than the program ID). v3 deployments cannot be upgraded in place. " + - "Deploy fresh v4 with `ntt add-chain Solana --version 4.0.0` instead.", + "than the program ID). v3 and v4 cannot be migrated into each other in " + + "place, in either direction. Deploy fresh v4 with " + + "`ntt add-chain Solana --version 4.0.0` instead.", }, ]; @@ -36,10 +39,12 @@ function parseMajor(version: string): number { } /** - * Returns whether the proposed `fromVersion → toVersion` upgrade on `chain` is - * permitted in place. A registered barrier between the two majors blocks it. + * Returns whether the proposed `fromVersion → toVersion` version change on + * `chain` is permitted in place. A registered barrier blocks the change when it + * crosses the barrier in either direction (upgrade past it or downgrade back + * across it). * - * Local-version (`toVersion === null`) upgrades are always permitted; the + * Local-version (`toVersion === null`) changes are always permitted; the * caller is asserting they know what they're doing. */ export function canUpgrade( @@ -52,7 +57,11 @@ export function canUpgrade( const toMajor = parseMajor(toVersion); for (const barrier of UPGRADE_BARRIERS) { if (barrier.chain !== chain) continue; - if (fromMajor < barrier.breakingMajor && toMajor >= barrier.breakingMajor) { + const fromBelow = fromMajor < barrier.breakingMajor; + const toBelow = toMajor < barrier.breakingMajor; + // Blocked when the change straddles the barrier — one side below it and + // the other at or above it — regardless of direction. + if (fromBelow !== toBelow) { return { ok: false, reason: barrier.reason }; } } From 91be57476e304ded75d7edca10d5e7e5e143d1a7 Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 21 Jul 2026 13:06:23 +0100 Subject: [PATCH 27/28] cli: fix v4 instance handling in Solana deploy/upgrade --- cli/src/solana/deploy.ts | 65 +++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/cli/src/solana/deploy.ts b/cli/src/solana/deploy.ts index c2b9bb3ac..b3c590650 100644 --- a/cli/src/solana/deploy.ts +++ b/cli/src/solana/deploy.ts @@ -345,7 +345,13 @@ export async function deploySvm( binaryPath?: string, priorityFee?: number, overrides?: WormholeConfigOverrides, - instanceKeyPath?: string + instanceKeyPath?: string, + // Upgrade path only: the existing deployment's Instance pubkey. When set, the + // program is redeployed against this instance instead of a freshly generated + // one (which would derive a different token authority and break the + // burning-mode mint-authority check). Only the pubkey is needed here because + // an upgrade does not run `initialize`, so nothing has to co-sign as it. + existingInstance?: PublicKey ): Promise<{ chain: C; address: ChainAddress["address"]; @@ -364,14 +370,24 @@ export async function deploySvm( // instance-scoped PDAs throughout. const major = version ? parseInt(version.split(".")[0] ?? "0", 10) : 0; const multiTenant = major >= 4; + // The keypair is only needed to co-sign `initialize` (fresh deploy). The + // pubkey is what every PDA derivation below actually consumes, so track it + // separately: on upgrade we have only the pubkey of the existing instance. let instanceKeypair: Keypair | undefined; + let instancePubkey: PublicKey | undefined; if (multiTenant) { - if (instanceKeyPath) { + if (existingInstance) { + // Upgrade: reuse the existing instance so PDAs (and the burning-mode + // mint-authority check) resolve against the real deployment. + instancePubkey = existingInstance; + } else if (instanceKeyPath) { instanceKeypair = Keypair.fromSecretKey( new Uint8Array(JSON.parse(fs.readFileSync(instanceKeyPath).toString())) ); - } else { + instancePubkey = instanceKeypair.publicKey; + } else if (initialize) { instanceKeypair = Keypair.generate(); + instancePubkey = instanceKeypair.publicKey; const generatedPath = `${ch.chain}-instance.json`; fs.writeFileSync( generatedPath, @@ -380,6 +396,13 @@ export async function deploySvm( console.log( `Generated instance keypair at ${generatedPath} (pubkey: ${instanceKeypair.publicKey.toBase58()})` ); + } else { + // Redeploy without initializing and without a known instance would + // silently derive the wrong (fresh) authorities. Fail instead. + throw new Error( + "Multi-tenant Solana redeploy requires the existing instance pubkey " + + "(pass `existingInstance`); refusing to generate a new one." + ); } } @@ -410,7 +433,7 @@ export async function deploySvm( // pubkey. EVM/Sui peers register against this address. const emitter = NTT.transceiverPdas( providedProgramId, - multiTenant ? instanceKeypair!.publicKey : undefined + multiTenant ? instancePubkey! : undefined ) .emitterAccount() .toBase58(); @@ -446,7 +469,7 @@ export async function deploySvm( // The constructor refuses a multi-tenant version without `instance` // (and a singleton version with it). ...(multiTenant && { - instance: instanceKeypair!.publicKey.toBase58(), + instance: instancePubkey!.toBase58(), }), }, }, @@ -552,7 +575,7 @@ export async function deploySvm( return { chain: ch.chain, address: toUniversal(ch.chain, providedProgramId), - ...(multiTenant && { instance: instanceKeypair!.publicKey }), + ...(multiTenant && { instance: instancePubkey! }), }; } @@ -691,21 +714,30 @@ export async function addSolanaInstance< // The initialize generator yields the initialize ix first, then // initializeOrUpdateLUT. The latter CPIs into the wormhole core bridge, // which can be unavailable in dev environments (e.g. a bare - // solana-test-validator without the bridge loaded). Log and continue — - // matches the same swallow-on-LUT-failure shape that `deploySvm` uses for - // legacy deployments, so the rest of the flow (writing deployment.json, - // registering the transceiver) still runs. + // solana-test-validator without the bridge loaded). Log and continue — the + // LUT can be (re)built later via `ntt push`. A genuine `initialize` failure + // still surfaces, because the transceiver registration below then fails and + // is propagated. Log the full error, not just `e.logs` (undefined for + // non-SendTransactionError, which would otherwise be silent). try { await signSendWait(ch, initTxs, signer.signer); } catch (e: any) { - console.error(e.logs); + console.error("Instance initialize/LUT step failed:", e.logs ?? e); } - // After initialize, register the Wormhole transceiver under the new instance. + // Register the Wormhole transceiver under the new instance. Unlike the LUT, + // this is not optional: an instance with no registered transceiver cannot + // send or receive. Fail loudly rather than persist a broken instance to the + // deployment file (the caller only records the return value on success). try { await registerSolanaTransceiver(ntt as any, ch, signer); } catch (e: any) { - console.error(e.logs); + console.error("Transceiver registration failed:", e.logs ?? e); + throw new Error( + `Failed to register the Wormhole transceiver for the new ${ch.chain} ` + + `instance ${instanceKeypair.publicKey.toBase58()}; it cannot transfer ` + + `without one. Not recording it in the deployment.` + ); } return { @@ -740,7 +772,12 @@ export async function upgradeSolana( programKeyPath, binaryPath, undefined, - overrides + overrides, + undefined, + // Redeploy against the existing instance so PDAs and the burning-mode + // mint-authority check resolve against the live deployment rather than a + // freshly generated (and wrong) instance. + ntt.instance ); // TODO: call initializeOrUpdateLUT. currently it's done in the following 'ntt push' step. } From aa4bec10cbca7dcee6b6805a62fb22b791f50a17 Mon Sep 17 00:00:00 2001 From: csongor Date: Tue, 21 Jul 2026 13:06:23 +0100 Subject: [PATCH 28/28] cli: thread v4 instance through manual commands --- cli/src/commands/manual.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/manual.ts b/cli/src/commands/manual.ts index 7831b4c8c..6142477de 100644 --- a/cli/src/commands/manual.ts +++ b/cli/src/commands/manual.ts @@ -111,7 +111,11 @@ export function createManualCommand( const [config, ctx, ntt] = await pullChainConfig( network, sourceManager, - overrides + overrides, + // v4 (multi-tenant) Solana: the SDK needs the per-instance + // Config pubkey to derive instance-scoped state, and throws + // without it. Undefined/no-op for v3 and non-Solana. + sourceConfig.instance ); console.log( @@ -330,7 +334,10 @@ export function createManualCommand( const [, ctx, ntt] = await pullChainConfig( network, sourceManager, - overrides + overrides, + // v4 (multi-tenant) Solana: instance-scoped state derivation + // requires the Config pubkey. Undefined/no-op otherwise. + sourceConfig.instance ); console.log( @@ -514,7 +521,11 @@ export function createManualCommand( const [, ctx, ntt] = await pullChainConfig( network, managerAddress, - overrides + overrides, + // v4 (multi-tenant) Solana: emergency redeem must build + // instance-scoped state, which needs the Config pubkey. + // Undefined/no-op for v3 and non-Solana. + chainConfig.instance ); console.log(