diff --git a/ci_tests/src/index.ts b/ci_tests/src/index.ts index f650d6cd9..1164659d4 100644 --- a/ci_tests/src/index.ts +++ b/ci_tests/src/index.ts @@ -330,6 +330,15 @@ async function initSolana( }); console.log("Initialized ntt at", manager.program.programId.toString()); + // NOTE: this is a hack. The next instruction will fail if we don't wait + // here, because the address lookup table is not yet available, despite + // the transaction having been confirmed. + // Looks like a bug, but I haven't investigated further. In practice, this + // won't be an issue, becase the address lookup table will have been + // created well before anyone is trying to use it, but we might want to be + // mindful in the deploy script too. + await new Promise((resolve) => setTimeout(resolve, 400)); + await manager.registerTransceiver({ payer: SOL_PRIVATE_KEY, owner: SOL_PRIVATE_KEY, diff --git a/solana/Anchor.toml b/solana/Anchor.toml index f58cf4128..9f6445f30 100644 --- a/solana/Anchor.toml +++ b/solana/Anchor.toml @@ -7,9 +7,10 @@ seeds = false skip-lint = false [programs.localnet] +dummy_transfer_hook = "BgabMDLaxsyB7eGMBt9L22MSk9KMrL4zY2iNe14kyFP5" example_native_token_transfers = "nttiK1SepaQt6sZ4WGW5whvc9tEnGXGxuKeptcQPCcS" -wormhole_governance = "wgvEiKVzX9yyEoh41jZAdC6JqGUTS4CFXbFGBV5TKdZ" ntt_quoter = "9jFBLvMZZERVmeY4tbq5MejbXRE18paGEuoB6xVJZgGe" +wormhole_governance = "wgvEiKVzX9yyEoh41jZAdC6JqGUTS4CFXbFGBV5TKdZ" [registry] url = "https://api.apr.dev" @@ -31,7 +32,10 @@ address = "worm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth" program = "programs/example-native-token-transfers/tests/fixtures/mainnet_core_bridge.so" [test.validator] +bind_address = "0.0.0.0" url = "https://api.mainnet-beta.solana.com" +ledger = ".anchor/test-ledger" +rpc_port = 8899 ticks_per_slot = 16 [[test.validator.account]] diff --git a/solana/Cargo.lock b/solana/Cargo.lock index 69c482321..78cde78b6 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1392,6 +1392,17 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "dummy-transfer-hook" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "solana-program", + "spl-tlv-account-resolution 0.6.3", + "spl-transfer-hook-interface 0.6.3", +] + [[package]] name = "dyn-clone" version = "1.0.16" @@ -1557,6 +1568,7 @@ dependencies = [ "serde_json", "serde_wormhole", "sha3 0.10.8", + "solana-address-lookup-table-program", "solana-program", "solana-program-runtime", "solana-program-test", diff --git a/solana/Cargo.toml b/solana/Cargo.toml index 49269f5c1..5eb52e53b 100644 --- a/solana/Cargo.toml +++ b/solana/Cargo.toml @@ -47,6 +47,7 @@ anchor-spl = "0.29.0" solana-program = "=1.18.10" solana-program-runtime = "=1.18.10" solana-program-test = "=1.18.10" +solana-address-lookup-table-program = "=1.18.10" spl-token = "4.0.0" spl-token-2022 = "3.0.2" diff --git a/solana/programs/dummy-transfer-hook/Cargo.toml b/solana/programs/dummy-transfer-hook/Cargo.toml new file mode 100644 index 000000000..6f78242db --- /dev/null +++ b/solana/programs/dummy-transfer-hook/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "dummy-transfer-hook" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "dummy_transfer_hook" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +mainnet = [] +solana-devnet = [] +tilt-devnet = [] +tilt-devnet2 = [ "tilt-devnet" ] + +[dependencies] +anchor-lang.workspace = true +anchor-spl.workspace = true +solana-program.workspace = true +spl-tlv-account-resolution = "0.6.3" +spl-transfer-hook-interface = "0.6.3" diff --git a/solana/programs/dummy-transfer-hook/Xargo.toml b/solana/programs/dummy-transfer-hook/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/solana/programs/dummy-transfer-hook/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/solana/programs/dummy-transfer-hook/src/lib.rs b/solana/programs/dummy-transfer-hook/src/lib.rs new file mode 100644 index 000000000..a49720bdf --- /dev/null +++ b/solana/programs/dummy-transfer-hook/src/lib.rs @@ -0,0 +1,171 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; +use spl_tlv_account_resolution::state::ExtraAccountMetaList; + +declare_id!("BgabMDLaxsyB7eGMBt9L22MSk9KMrL4zY2iNe14kyFP5"); + +/// Index of the sender token account in the accounts passed to the transfer hook +pub const SENDER_TOKEN_ACCOUNT_INDEX: u8 = 0; +/// Index of the mint account in the accounts passed to the transfer hook +pub const MINT_ACCOUNT_INDEX: u8 = 1; +/// Index of the destination token account in the accounts passed to the transfer hook +pub const DESTINATION_TOKEN_ACCOUNT_INDEX: u8 = 2; +/// Index of the authority account in the accounts passed to the transfer hook +pub const AUTHORITY_ACCOUNT_INDEX: u8 = 3; + +/// Number of extra accounts in the ExtraAccountMetaList account +pub const EXTRA_ACCOUNTS_LEN: u8 = 2; + +#[program] +pub mod dummy_transfer_hook { + use spl_tlv_account_resolution::{ + account::ExtraAccountMeta, seeds::Seed, state::ExtraAccountMetaList, + }; + use spl_transfer_hook_interface::instruction::{ExecuteInstruction, TransferHookInstruction}; + + use super::*; + + pub fn initialize_extra_account_meta_list( + ctx: Context, + ) -> Result<()> { + let account_metas = vec![ + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: "dummy_account".as_bytes().to_vec(), + }, + // owner field of the sender token account + Seed::AccountData { + account_index: SENDER_TOKEN_ACCOUNT_INDEX, + data_index: 32, + length: 32, + }, + ], + false, // is_signer + false, // is_writable + )?, + ExtraAccountMeta::new_with_seeds( + &[Seed::Literal { + bytes: "counter".as_bytes().to_vec(), + }], + false, // is_signer + true, // is_writable + )?, + ]; + + assert_eq!(EXTRA_ACCOUNTS_LEN as usize, account_metas.len()); + + // initialize ExtraAccountMetaList account with extra accounts + ExtraAccountMetaList::init::( + &mut ctx.accounts.extra_account_meta_list.try_borrow_mut_data()?, + &account_metas, + )?; + + Ok(()) + } + + pub fn transfer_hook(ctx: Context, _amount: u64) -> Result<()> { + ctx.accounts.counter.count += 1; + Ok(()) + } + + // NOTE: the CPI call makes that the token2022 program makes (naturally) does not + // follow the anchor calling convention, so we need to implement a fallback + // instruction to handle the custom instruction + pub fn fallback<'info>( + program_id: &Pubkey, + accounts: &'info [AccountInfo<'info>], + data: &[u8], + ) -> Result<()> { + let instruction = TransferHookInstruction::unpack(data)?; + + // match instruction discriminator to transfer hook interface execute instruction + // token2022 program CPIs this instruction on token transfer + match instruction { + TransferHookInstruction::Execute { amount } => { + let amount_bytes = amount.to_le_bytes(); + + // invoke custom transfer hook instruction on our program + __private::__global::transfer_hook(program_id, accounts, &amount_bytes) + } + _ => Err(ProgramError::InvalidInstructionData.into()), + } + } +} + +#[account] +#[derive(InitSpace)] +pub struct Counter { + pub count: u64, +} + +#[derive(Accounts)] +pub struct InitializeExtraAccountMetaList<'info> { + #[account(mut)] + payer: Signer<'info>, + + /// CHECK: ExtraAccountMetaList Account, must use these seeds + #[account( + init, + payer = payer, + space = ExtraAccountMetaList::size_of(EXTRA_ACCOUNTS_LEN as usize)?, + seeds = [b"extra-account-metas", mint.key().as_ref()], + bump + )] + pub extra_account_meta_list: AccountInfo<'info>, + pub mint: InterfaceAccount<'info, Mint>, + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + + #[account( + init, + payer = payer, + space = 8 + Counter::INIT_SPACE, + seeds = [b"counter"], + bump + )] + pub counter: Account<'info, Counter>, + + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +/// NOTE: this is just a dummy transfer hook to test that the accounts are +/// passed in correctly. Do NOT use this as a starting point in a real +/// application, as it's not secure. +pub struct TransferHook<'info> { + #[account( + token::mint = mint, + )] + pub source_token: InterfaceAccount<'info, TokenAccount>, + pub mint: InterfaceAccount<'info, Mint>, + #[account( + token::mint = mint, + )] + pub destination_token: InterfaceAccount<'info, TokenAccount>, + /// CHECK: source token account authority, can be SystemAccount or PDA owned by another program + pub authority: UncheckedAccount<'info>, + /// CHECK: ExtraAccountMetaList Account, + #[account( + seeds = [b"extra-account-metas", mint.key().as_ref()], + bump + )] + pub extra_account_meta_list: UncheckedAccount<'info>, + #[account( + seeds = [b"dummy_account", source_token.owner.as_ref()], + bump + )] + /// CHECK: dummy account. It just tests that the off-chain code correctly + /// computes and the on-chain code correctly passes on the PDA. + pub dummy_account: AccountInfo<'info>, + + #[account( + mut, + seeds = [b"counter"], + bump + )] + pub counter: Account<'info, Counter>, +} diff --git a/solana/programs/example-native-token-transfers/Cargo.toml b/solana/programs/example-native-token-transfers/Cargo.toml index bc064e81d..c14f40a9e 100644 --- a/solana/programs/example-native-token-transfers/Cargo.toml +++ b/solana/programs/example-native-token-transfers/Cargo.toml @@ -9,15 +9,17 @@ crate-type = ["cdylib", "lib"] name = "example_native_token_transfers" [features] -default = ["mainnet"] +default = ["owner-recovery", "mainnet"] no-entrypoint = [] no-idl = [] no-log-ix-name = [] cpi = ["no-entrypoint"] idl-build = [ "anchor-lang/idl-build", - "anchor-spl/idl-build" + "anchor-spl/idl-build", ] +# whether the owner can recover transactions +owner-recovery = [] # cargo-test-sbf will pass this along test-sbf = [] # networks @@ -38,6 +40,7 @@ bitmaps = "3.2.1" hex.workspace = true cfg-if.workspace = true solana-program.workspace = true +solana-address-lookup-table-program.workspace = true spl-token-2022 = { workspace = true, features = ["no-entrypoint"] } wormhole-anchor-sdk.workspace = true wormhole-io.workspace = true diff --git a/solana/programs/example-native-token-transfers/src/error.rs b/solana/programs/example-native-token-transfers/src/error.rs index 75fcbff04..e244abc89 100644 --- a/solana/programs/example-native-token-transfers/src/error.rs +++ b/solana/programs/example-native-token-transfers/src/error.rs @@ -51,6 +51,8 @@ pub enum NTTError { OverflowScaledAmount, #[msg("BitmapIndexOutOfBounds")] BitmapIndexOutOfBounds, + #[msg("FeatureNotEnabled")] + FeatureNotEnabled, } impl From for NTTError { 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 b6150ac32..555dfd316 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs @@ -65,6 +65,7 @@ pub struct Initialize<'info> { payer = payer, associated_token::mint = mint, associated_token::authority = token_authority, + associated_token::token_program = token_program, )] /// The custody account that holds tokens in locking mode. /// NOTE: the account is unconditionally initialized, but not used in diff --git a/solana/programs/example-native-token-transfers/src/instructions/luts.rs b/solana/programs/example-native-token-transfers/src/instructions/luts.rs new file mode 100644 index 000000000..c9a79b527 --- /dev/null +++ b/solana/programs/example-native-token-transfers/src/instructions/luts.rs @@ -0,0 +1,172 @@ +//! This instructions manages a canonical address lookup table (or LUT) for the +//! NTT program. +//! LUTs in general can be created permissionlessly, so support from the +//! program's side is not strictly necessary. When submitting a transaction, the +//! client could just manage its own ad-hoc lookup table. +//! Nevertheless, we provide this instruction to make it easier for the client +//! to query the lookup table from a deterministic address, and for integrators +//! to be able to fetch the accounts from the LUT in a standardised way. +//! +//! This way, the client sdk can abstract away the lookup table logic in a +//! maintanable way. +//! +//! The [`initialize_lut`] instruction can be called multiple times, each time +//! it will create a new lookup table, with the accounts defined in the +//! [`Entries`] struct. +//! An alternative would be to keep extending the existing lookup table, but +//! ensuring the instruction is idempotent (which requires ensuring no duplicate +//! entries) has O(n^2) complexity (since LUTs are append only, we can't keep it +//! sorted), and in the worst case would require ~16k checks. So we keep things +//! simple, and just create a new LUT each time. This operation won't be called +//! often, so the extra allocation is justifiable. +//! +//! Because of all the above, this instruction can be called permissionlessly. + +use anchor_lang::prelude::*; +use solana_address_lookup_table_program; +use solana_program::program::{invoke, invoke_signed}; + +use crate::{config::Config, queue::outbox::OutboxRateLimit, transceivers::wormhole::accounts::*}; + +#[account] +#[derive(InitSpace)] +pub struct LUT { + pub bump: u8, + pub address: Pubkey, +} + +#[derive(Accounts)] +#[instruction(recent_slot: u64)] +pub struct InitializeLUT<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + #[account( + seeds = [b"lut_authority"], + bump + )] + pub authority: AccountInfo<'info>, + + #[account( + mut, + seeds = [authority.key().as_ref(), &recent_slot.to_le_bytes()], + seeds::program = solana_address_lookup_table_program::id(), + bump + )] + pub lut_address: AccountInfo<'info>, + + #[account( + init_if_needed, + payer = payer, + space = 8 + LUT::INIT_SPACE, + seeds = [b"lut"], + bump + )] + pub lut: Account<'info, LUT>, + + /// CHECK: address lookup table program (checked by instruction) + #[account(executable)] + pub lut_program: AccountInfo<'info>, + + pub system_program: Program<'info, System>, + + /// These are the entries that will populate the LUT. + pub entries: Entries<'info>, +} + +#[derive(Accounts)] +pub struct Entries<'info> { + pub config: Account<'info, Config>, + + #[account( + constraint = custody.key() == config.custody, + )] + pub custody: AccountInfo<'info>, + + #[account( + constraint = token_program.key() == config.token_program, + )] + pub token_program: AccountInfo<'info>, + + #[account( + constraint = mint.key() == config.mint, + )] + pub mint: AccountInfo<'info>, + + #[account( + seeds = [crate::TOKEN_AUTHORITY_SEED], + bump, + )] + pub token_authority: AccountInfo<'info>, + + 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 + pub wormhole: WormholeAccounts<'info>, +} + +pub fn initialize_lut(ctx: Context, recent_slot: u64) -> Result<()> { + let (ix, lut_address) = solana_address_lookup_table_program::instruction::create_lookup_table( + ctx.accounts.authority.key(), + ctx.accounts.payer.key(), + recent_slot, + ); + + // just a sanity check, should never be hit, so we don't provide a custom + // error message + assert_eq!(lut_address, ctx.accounts.lut_address.key()); + + // the LUT might already exist, in which case the new one will simply + // override it. Since we don't delete the old LUTs, this is safe -- clients + // holding references to old LUTs will still be able to use them. + ctx.accounts.lut.set_inner(LUT { + bump: ctx.bumps.lut, + address: lut_address, + }); + + // NOTE: LUTs can be permissionlessly created (i.e. the authority does + // not need to sign the transaction). This means that the LUT might + // already exist (if someone frontran us). However, it's not a problem: + // AddressLookupTable::create_lookup_table checks if the LUT already + // exists and does nothing if it does. + // + // LUTs can only be created permissionlessly, but only the authority is + // authorised to actually populate the fields, so we don't have to worry + // about the frontrunner populating it with junk. The only risk of that would + // be the LUT being filled to capacity (256 addresses), with no + // possibility for us to add our own accounts -- no other security impact. + invoke( + &ix, + &[ + ctx.accounts.lut_address.to_account_info(), + ctx.accounts.authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + )?; + + let entries_infos = ctx.accounts.entries.to_account_infos(); + let mut entries = Vec::with_capacity(1 + entries_infos.len()); + entries.push(crate::ID); + entries.extend(entries_infos.into_iter().map(|x| x.key)); + + let ix = solana_address_lookup_table_program::instruction::extend_lookup_table( + ctx.accounts.lut_address.key(), + ctx.accounts.authority.key(), + Some(ctx.accounts.payer.key()), + entries, + ); + + invoke_signed( + &ix, + &[ + ctx.accounts.lut_address.to_account_info(), + ctx.accounts.authority.to_account_info(), + ctx.accounts.payer.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + &[&[b"lut_authority", &[ctx.bumps.authority]]], + )?; + + Ok(()) +} diff --git a/solana/programs/example-native-token-transfers/src/instructions/mod.rs b/solana/programs/example-native-token-transfers/src/instructions/mod.rs index a716c567e..7d0a7426d 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/mod.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/mod.rs @@ -1,11 +1,15 @@ pub mod admin; pub mod initialize; +pub mod luts; +pub mod recover; pub mod redeem; pub mod release_inbound; pub mod transfer; pub use admin::*; pub use initialize::*; +pub use luts::*; +pub use recover::*; pub use redeem::*; pub use release_inbound::*; pub use transfer::*; diff --git a/solana/programs/example-native-token-transfers/src/instructions/recover.rs b/solana/programs/example-native-token-transfers/src/instructions/recover.rs new file mode 100644 index 000000000..645bc8be1 --- /dev/null +++ b/solana/programs/example-native-token-transfers/src/instructions/recover.rs @@ -0,0 +1,180 @@ +//! This module implements instructions to recover transfers. Only the owner can +//! execute these instructions. +//! +//! Recovery means that the tokens are redeemed, but instead of sending them to +//! the recipient, they are sent to a recovery account. The recovery account is +//! a token account of the appropriate mint. +//! +//! This is useful in case the underlying token implements a blocklisting +//! mechanism (such as OFAC sanctions), and the recipient is blocked, meaning +//! the tokens are irredeemable. +//! +//! In such cases, the owner can recover the transfer by sending them to the +//! recovery address (typically controlled by the owner, though we're not +//! prescriptive about access control of that account). +//! Ideally, it would be nice to attempt to make the transfer to the original +//! recipient, and only allow recovery if that fails. However, solana's runtime does +//! not allow recovering from a failed CPI call, so that is not possible. +//! +//! This feature is opt-in, and hidden behind a feature flag ("owner-recovery"). +//! When that flag is set to false, the instructions in this module will revert. + +use anchor_lang::prelude::*; +use anchor_spl::token_interface; + +use crate::instructions::release_inbound::*; + +#[account] +#[derive(InitSpace)] +pub struct RecoveryAccount { + /// The bump seed for the recovery account + pub bump: u8, + /// The token account that will receive the recovered tokens + pub recovery_address: Pubkey, +} + +impl RecoveryAccount { + pub const SEED: &'static [u8] = b"recovery"; +} + +#[derive(Accounts)] +pub struct InitializeRecoveryAccount<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + pub config: Account<'info, crate::config::Config>, + + #[account( + constraint = owner.key() == config.owner + )] + pub owner: Signer<'info>, + + #[account( + init, + payer = payer, + space = 8 + RecoveryAccount::INIT_SPACE, + seeds = [RecoveryAccount::SEED], + bump, + )] + pub recovery: Account<'info, RecoveryAccount>, + + #[account( + token::mint = config.mint, + )] + pub recovery_account: InterfaceAccount<'info, token_interface::TokenAccount>, + + system_program: Program<'info, System>, +} + +pub fn initialize_recovery_account(ctx: Context) -> Result<()> { + // This is the most important instruction to check the feature flag, as the + // other instructions cannot be called if the [`RecoveryAccount`] is not + // initialized anyway. + ensure_feature_enabled()?; + + ctx.accounts.recovery.set_inner(RecoveryAccount { + bump: ctx.bumps.recovery, + recovery_address: ctx.accounts.recovery_account.key(), + }); + Ok(()) +} + +#[derive(Accounts)] +pub struct UpdateRecoveryAddress<'info> { + pub config: Account<'info, crate::config::Config>, + + #[account( + constraint = owner.key() == config.owner + )] + pub owner: Signer<'info>, + + #[account(mut)] + pub recovery: Account<'info, RecoveryAccount>, + + #[account( + token::mint = config.mint, + )] + pub new_recovery_account: InterfaceAccount<'info, token_interface::TokenAccount>, +} + +pub fn update_recovery_address(ctx: Context) -> Result<()> { + ensure_feature_enabled()?; + + ctx.accounts.recovery.recovery_address = ctx.accounts.new_recovery_account.key(); + Ok(()) +} + +#[derive(Accounts)] +pub struct RecoverMint<'info> { + pub release_inbound_mint: ReleaseInboundMint<'info>, + + #[account( + constraint = owner.key() == release_inbound_mint.common.config.owner, + )] + pub owner: Signer<'info>, + + pub recovery: Account<'info, RecoveryAccount>, + + #[account( + mut, + constraint = recovery_account.key() == recovery.recovery_address, + )] + pub recovery_account: InterfaceAccount<'info, token_interface::TokenAccount>, +} + +pub fn recover_mint<'info>( + ctx: Context<'_, '_, '_, 'info, RecoverMint<'info>>, + args: ReleaseInboundArgs, +) -> Result<()> { + ensure_feature_enabled()?; + + let accounts = &mut ctx.accounts.release_inbound_mint; + accounts.common.recipient = ctx.accounts.recovery_account.clone(); + let ctx = Context { + accounts, + bumps: ctx.bumps.release_inbound_mint, + ..ctx + }; + release_inbound_mint(ctx, args) +} + +#[derive(Accounts)] +pub struct RecoverUnlock<'info> { + pub release_inbound_unlock: ReleaseInboundUnlock<'info>, + + #[account( + constraint = owner.key() == release_inbound_unlock.common.config.owner, + )] + pub owner: Signer<'info>, + + pub recovery: Account<'info, RecoveryAccount>, + + #[account( + mut, + constraint = recovery_account.key() == recovery.recovery_address, + )] + pub recovery_account: InterfaceAccount<'info, token_interface::TokenAccount>, +} + +pub fn recover_unlock<'info>( + ctx: Context<'_, '_, '_, 'info, RecoverUnlock<'info>>, + args: ReleaseInboundArgs, +) -> Result<()> { + ensure_feature_enabled()?; + + let accounts = &mut ctx.accounts.release_inbound_unlock; + accounts.common.recipient = ctx.accounts.recovery_account.clone(); + let ctx = Context { + accounts, + bumps: ctx.bumps.release_inbound_unlock, + ..ctx + }; + release_inbound_unlock(ctx, args) +} + +fn ensure_feature_enabled() -> Result<()> { + #[cfg(not(feature = "owner-recovery"))] + return Err(crate::error::NTTError::FeatureNotEnabled.into()); + #[cfg(feature = "owner-recovery")] + return Ok(()); +} 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 60cdbb6b5..fffa9f6c9 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 @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface; use ntt_messages::mode::Mode; +use spl_token_2022::onchain; use crate::{ config::*, @@ -21,7 +22,8 @@ pub struct ReleaseInbound<'info> { #[account( mut, associated_token::authority = inbox_item.recipient_address, - associated_token::mint = mint + associated_token::mint = mint, + associated_token::token_program = token_program, )] pub recipient: InterfaceAccount<'info, token_interface::TokenAccount>, @@ -39,6 +41,13 @@ pub struct ReleaseInbound<'info> { pub mint: InterfaceAccount<'info, token_interface::Mint>, pub token_program: Interface<'info, token_interface::TokenInterface>, + + /// CHECK: the token program checks if this indeed the right authority for the mint + #[account( + mut, + address = config.custody + )] + pub custody: InterfaceAccount<'info, token_interface::TokenAccount>, } #[derive(AnchorDeserialize, AnchorSerialize)] @@ -50,7 +59,10 @@ pub struct ReleaseInboundArgs { #[derive(Accounts)] pub struct ReleaseInboundMint<'info> { - common: ReleaseInbound<'info>, + #[account( + constraint = common.config.mode == Mode::Burning @ NTTError::InvalidMode, + )] + pub common: ReleaseInbound<'info>, } /// Release an inbound transfer and mint the tokens to the recipient. @@ -60,8 +72,8 @@ pub struct ReleaseInboundMint<'info> { /// Setting this flag to `false` is useful when bundling this instruction /// together with [`crate::instructions::redeem`] in a transaction, so that the minting /// is attempted optimistically. -pub fn release_inbound_mint( - ctx: Context, +pub fn release_inbound_mint<'info>( + ctx: Context<'_, '_, '_, 'info, ReleaseInboundMint<'info>>, args: ReleaseInboundArgs, ) -> Result<()> { let inbox_item = &mut ctx.accounts.common.inbox_item; @@ -77,38 +89,48 @@ pub fn release_inbound_mint( } assert!(inbox_item.release_status == ReleaseStatus::Released); - match ctx.accounts.common.config.mode { - Mode::Burning => token_interface::mint_to( - CpiContext::new_with_signer( - ctx.accounts.common.token_program.to_account_info(), - token_interface::MintTo { - mint: ctx.accounts.common.mint.to_account_info(), - to: ctx.accounts.common.recipient.to_account_info(), - authority: ctx.accounts.common.token_authority.clone(), - }, - &[&[ - crate::TOKEN_AUTHORITY_SEED, - &[ctx.bumps.common.token_authority], - ]], - ), - inbox_item.amount, + token_interface::mint_to( + CpiContext::new_with_signer( + ctx.accounts.common.token_program.to_account_info(), + token_interface::MintTo { + mint: ctx.accounts.common.mint.to_account_info(), + to: ctx.accounts.common.custody.to_account_info(), + authority: ctx.accounts.common.token_authority.clone(), + }, + &[&[ + crate::TOKEN_AUTHORITY_SEED, + &[ctx.bumps.common.token_authority], + ]], ), - Mode::Locking => Err(NTTError::InvalidMode.into()), - } + inbox_item.amount, + )?; + + onchain::invoke_transfer_checked( + &ctx.accounts.common.token_program.key(), + ctx.accounts.common.custody.to_account_info(), + ctx.accounts.common.mint.to_account_info(), + ctx.accounts.common.recipient.to_account_info(), + ctx.accounts.common.token_authority.clone(), + ctx.remaining_accounts, + inbox_item.amount, + ctx.accounts.common.mint.decimals, + &[&[ + crate::TOKEN_AUTHORITY_SEED, + &[ctx.bumps.common.token_authority], + ]], + )?; + Ok(()) } // Lock/unlock #[derive(Accounts)] pub struct ReleaseInboundUnlock<'info> { - common: ReleaseInbound<'info>, - /// CHECK: the token program checks if this indeed the right authority for the mint #[account( - mut, - address = common.config.custody + constraint = common.config.mode == Mode::Locking @ NTTError::InvalidMode, )] - pub custody: InterfaceAccount<'info, token_interface::TokenAccount>, + pub common: ReleaseInbound<'info>, } /// Release an inbound transfer and unlock the tokens to the recipient. @@ -118,8 +140,8 @@ pub struct ReleaseInboundUnlock<'info> { /// Setting this flag to `false` is useful when bundling this instruction /// together with [`crate::instructions::redeem`], so that the unlocking /// is attempted optimistically. -pub fn release_inbound_unlock( - ctx: Context, +pub fn release_inbound_unlock<'info>( + ctx: Context<'_, '_, '_, 'info, ReleaseInboundUnlock<'info>>, args: ReleaseInboundArgs, ) -> Result<()> { let inbox_item = &mut ctx.accounts.common.inbox_item; @@ -134,25 +156,19 @@ pub fn release_inbound_unlock( } } - assert!(inbox_item.release_status == ReleaseStatus::Released); - match ctx.accounts.common.config.mode { - Mode::Burning => Err(NTTError::InvalidMode.into()), - Mode::Locking => token_interface::transfer_checked( - CpiContext::new_with_signer( - ctx.accounts.common.token_program.to_account_info(), - token_interface::TransferChecked { - from: ctx.accounts.custody.to_account_info(), - to: ctx.accounts.common.recipient.to_account_info(), - authority: ctx.accounts.common.token_authority.clone(), - mint: ctx.accounts.common.mint.to_account_info(), - }, - &[&[ - crate::TOKEN_AUTHORITY_SEED, - &[ctx.bumps.common.token_authority], - ]], - ), - inbox_item.amount, - ctx.accounts.common.mint.decimals, - ), - } + onchain::invoke_transfer_checked( + &ctx.accounts.common.token_program.key(), + ctx.accounts.common.custody.to_account_info(), + ctx.accounts.common.mint.to_account_info(), + ctx.accounts.common.recipient.to_account_info(), + ctx.accounts.common.token_authority.clone(), + ctx.remaining_accounts, + inbox_item.amount, + ctx.accounts.common.mint.decimals, + &[&[ + crate::TOKEN_AUTHORITY_SEED, + &[ctx.bumps.common.token_authority], + ]], + )?; + Ok(()) } 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 82891ad8d..3d32fb7ef 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/transfer.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/transfer.rs @@ -2,6 +2,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface; use ntt_messages::{chain_id::ChainId, mode::Mode, trimmed_amount::TrimmedAmount}; +use spl_token_2022::onchain; use crate::{ bitmap::Bitmap, @@ -51,6 +52,12 @@ pub struct Transfer<'info> { #[account(mut)] pub outbox_rate_limit: Account<'info, OutboxRateLimit>, + #[account( + mut, + address = config.custody + )] + pub custody: InterfaceAccount<'info, token_interface::TokenAccount>, + pub system_program: Program<'info, System>, } @@ -84,6 +91,9 @@ impl TransferArgs { #[derive(Accounts)] #[instruction(args: TransferArgs)] pub struct TransferBurn<'info> { + #[account( + constraint = common.config.mode == Mode::Burning @ NTTError::InvalidMode, + )] pub common: Transfer<'info>, #[account( @@ -110,15 +120,18 @@ pub struct TransferBurn<'info> { bump, )] pub session_authority: AccountInfo<'info>, -} -pub fn transfer_burn(ctx: Context, args: TransferArgs) -> Result<()> { - require_eq!( - ctx.accounts.common.config.mode, - Mode::Burning, - NTTError::InvalidMode - ); + #[account( + seeds = [crate::TOKEN_AUTHORITY_SEED], + bump, + )] + pub token_authority: AccountInfo<'info>, +} +pub fn transfer_burn<'info>( + ctx: Context<'_, '_, '_, 'info, TransferBurn<'info>>, + args: TransferArgs, +) -> Result<()> { let accs = ctx.accounts; let TransferArgs { mut amount, @@ -135,30 +148,50 @@ pub fn transfer_burn(ctx: Context, args: TransferArgs) -> Result<( ) .map_err(NTTError::from)?; - let before = accs.common.from.amount; + let before = accs.common.custody.amount; + + onchain::invoke_transfer_checked( + &accs.common.token_program.key(), + accs.common.from.to_account_info(), + accs.common.mint.to_account_info(), + accs.common.custody.to_account_info(), + accs.session_authority.to_account_info(), + ctx.remaining_accounts, + amount, + accs.common.mint.decimals, + &[&[ + crate::SESSION_AUTHORITY_SEED, + accs.common.from.owner.as_ref(), + args.keccak256().as_ref(), + &[ctx.bumps.session_authority], + ]], + )?; token_interface::burn( CpiContext::new_with_signer( accs.common.token_program.to_account_info(), token_interface::Burn { mint: accs.common.mint.to_account_info(), - from: accs.common.from.to_account_info(), - authority: accs.session_authority.to_account_info(), + from: accs.common.custody.to_account_info(), + authority: accs.token_authority.to_account_info(), }, - &[&[ - crate::SESSION_AUTHORITY_SEED, - accs.common.from.owner.as_ref(), - args.keccak256().as_ref(), - &[ctx.bumps.session_authority], - ]], + &[&[crate::TOKEN_AUTHORITY_SEED, &[ctx.bumps.token_authority]]], ), amount, )?; - accs.common.from.reload()?; - let after = accs.common.from.amount; + accs.common.custody.reload()?; + let after = accs.common.custody.amount; - if after != before - amount { + // NOTE: we currently do not support tokens with fees. Support could be + // added, but it would require the client to calculate the amount _before_ + // paying fees that results in an amount that can safely be trimmed. + // Otherwise, if the amount after paying fees has dust, then that amount + // would be lost. + // To support fee tokens, we would first transfer the amount, _then_ assert + // that the resulting amount has no dust (instead of removing dust before + // the transfer like we do now). + if after != before { return Err(NTTError::BadAmountAfterBurn.into()); } @@ -173,7 +206,9 @@ pub fn transfer_burn(ctx: Context, args: TransferArgs) -> Result<( recipient_ntt_manager, recipient_address, should_queue, - ) + )?; + + Ok(()) } // Lock/unlock @@ -181,6 +216,9 @@ pub fn transfer_burn(ctx: Context, args: TransferArgs) -> Result<( #[derive(Accounts)] #[instruction(args: TransferArgs)] pub struct TransferLock<'info> { + #[account( + constraint = common.config.mode == Mode::Locking @ NTTError::InvalidMode, + )] pub common: Transfer<'info>, #[account( @@ -207,21 +245,12 @@ pub struct TransferLock<'info> { bump, )] pub session_authority: AccountInfo<'info>, - - #[account( - mut, - address = common.config.custody - )] - pub custody: InterfaceAccount<'info, token_interface::TokenAccount>, } -pub fn transfer_lock(ctx: Context, args: TransferArgs) -> Result<()> { - require_eq!( - ctx.accounts.common.config.mode, - Mode::Locking, - NTTError::InvalidMode - ); - +pub fn transfer_lock<'info>( + ctx: Context<'_, '_, '_, 'info, TransferLock<'info>>, + args: TransferArgs, +) -> Result<()> { let accs = ctx.accounts; let TransferArgs { mut amount, @@ -238,30 +267,27 @@ pub fn transfer_lock(ctx: Context, args: TransferArgs) -> Result<( ) .map_err(NTTError::from)?; - let before = accs.custody.amount; + let before = accs.common.custody.amount; - token_interface::transfer_checked( - CpiContext::new_with_signer( - accs.common.token_program.to_account_info(), - token_interface::TransferChecked { - from: accs.common.from.to_account_info(), - to: accs.custody.to_account_info(), - authority: accs.session_authority.to_account_info(), - mint: accs.common.mint.to_account_info(), - }, - &[&[ - crate::SESSION_AUTHORITY_SEED, - accs.common.from.owner.as_ref(), - args.keccak256().as_ref(), - &[ctx.bumps.session_authority], - ]], - ), + onchain::invoke_transfer_checked( + &accs.common.token_program.key(), + accs.common.from.to_account_info(), + accs.common.mint.to_account_info(), + accs.common.custody.to_account_info(), + accs.session_authority.to_account_info(), + ctx.remaining_accounts, amount, accs.common.mint.decimals, + &[&[ + crate::SESSION_AUTHORITY_SEED, + accs.common.from.owner.as_ref(), + args.keccak256().as_ref(), + &[ctx.bumps.session_authority], + ]], )?; - accs.custody.reload()?; - let after = accs.custody.amount; + accs.common.custody.reload()?; + let after = accs.common.custody.amount; // NOTE: we currently do not support tokens with fees. Support could be // added, but it would require the client to calculate the amount _before_ diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index 1d3ebc3bb..dfdfa60d6 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -1,3 +1,5 @@ +#![feature(type_changing_struct_update)] + use anchor_lang::prelude::*; // TODO: is there a more elegant way of checking that these 3 features are mutually exclusive? @@ -73,15 +75,35 @@ pub mod example_native_token_transfers { instructions::initialize(ctx, args) } + pub fn initialize_lut(ctx: Context, recent_slot: u64) -> Result<()> { + instructions::initialize_lut(ctx, recent_slot) + } + + /// Initialize the recovery account. + /// The recovery flow + pub fn initialize_recovery_account(ctx: Context) -> Result<()> { + instructions::initialize_recovery_account(ctx) + } + + pub fn update_recovery_address(ctx: Context) -> Result<()> { + instructions::update_recovery_address(ctx) + } + pub fn version(_ctx: Context) -> Result { Ok(VERSION.to_string()) } - pub fn transfer_burn(ctx: Context, args: TransferArgs) -> Result<()> { + pub fn transfer_burn<'info>( + ctx: Context<'_, '_, '_, 'info, TransferBurn<'info>>, + args: TransferArgs, + ) -> Result<()> { instructions::transfer_burn(ctx, args) } - pub fn transfer_lock(ctx: Context, args: TransferArgs) -> Result<()> { + pub fn transfer_lock<'info>( + ctx: Context<'_, '_, '_, 'info, TransferLock<'info>>, + args: TransferArgs, + ) -> Result<()> { instructions::transfer_lock(ctx, args) } @@ -89,20 +111,34 @@ pub mod example_native_token_transfers { instructions::redeem(ctx, args) } - pub fn release_inbound_mint( - ctx: Context, + pub fn release_inbound_mint<'info>( + ctx: Context<'_, '_, '_, 'info, ReleaseInboundMint<'info>>, args: ReleaseInboundArgs, ) -> Result<()> { instructions::release_inbound_mint(ctx, args) } - pub fn release_inbound_unlock( - ctx: Context, + pub fn release_inbound_unlock<'info>( + ctx: Context<'_, '_, '_, 'info, ReleaseInboundUnlock<'info>>, args: ReleaseInboundArgs, ) -> Result<()> { instructions::release_inbound_unlock(ctx, args) } + pub fn recover_unlock<'info>( + ctx: Context<'_, '_, '_, 'info, RecoverUnlock<'info>>, + args: ReleaseInboundArgs, + ) -> Result<()> { + instructions::recover_unlock(ctx, args) + } + + pub fn recover_mint<'info>( + ctx: Context<'_, '_, '_, 'info, RecoverMint<'info>>, + args: ReleaseInboundArgs, + ) -> Result<()> { + instructions::recover_mint(ctx, args) + } + pub fn transfer_ownership(ctx: Context) -> Result<()> { instructions::transfer_ownership(ctx) } diff --git a/solana/programs/example-native-token-transfers/tests/sdk/instructions/transfer.rs b/solana/programs/example-native-token-transfers/tests/sdk/instructions/transfer.rs index 9ab3255fc..810348e04 100644 --- a/solana/programs/example-native-token-transfers/tests/sdk/instructions/transfer.rs +++ b/solana/programs/example-native-token-transfers/tests/sdk/instructions/transfer.rs @@ -33,6 +33,7 @@ pub fn transfer_burn(ntt: &NTT, transfer: Transfer, args: TransferArgs) -> Instr inbox_rate_limit: ntt.inbox_rate_limit(chain_id), peer: transfer.peer, session_authority, + token_authority: ntt.token_authority(), }; Instruction { @@ -51,7 +52,6 @@ pub fn transfer_lock(ntt: &NTT, transfer: Transfer, args: TransferArgs) -> Instr common: common(ntt, &transfer), inbox_rate_limit: ntt.inbox_rate_limit(chain_id), peer: transfer.peer, - custody: ntt.custody(&transfer.mint), session_authority, }; Instruction { @@ -90,5 +90,6 @@ fn common(ntt: &NTT, transfer: &Transfer) -> example_native_token_transfers::acc outbox_item: transfer.outbox_item, outbox_rate_limit: ntt.outbox_rate_limit(), system_program: System::id(), + custody: ntt.custody(&transfer.mint), } } diff --git a/solana/tests/example-native-token-transfer.ts b/solana/tests/example-native-token-transfer.ts index 21613204b..ef4d16768 100644 --- a/solana/tests/example-native-token-transfer.ts +++ b/solana/tests/example-native-token-transfer.ts @@ -16,14 +16,17 @@ import { serializePayload, deserializePayload, } from "@wormhole-foundation/sdk-definitions"; -import { NttMessage, postVaa, NTT, nttMessageLayout } from "../ts/sdk"; +import { postVaa, NTT, nttMessageLayout } from "../ts/sdk"; +import { WormholeTransceiverMessage } from "../ts/sdk/nttLayout"; + import { - NativeTokenTransfer, - TransceiverMessage, - WormholeTransceiverMessage, - nativeTokenTransferLayout, - nttManagerMessageLayout, -} from "../ts/sdk/nttLayout"; + PublicKey, + SystemProgram, + Transaction, + sendAndConfirmTransaction, +} from "@solana/web3.js"; + +import { DummyTransferHook } from "../target/types/dummy_transfer_hook"; export const GUARDIAN_KEY = "cfb12303a19cde580bb4dd771639b0d26bc68353645571a8cff516ab2ee113a0"; @@ -47,26 +50,90 @@ describe("example-native-token-transfers", () => { }); const user = anchor.web3.Keypair.generate(); let tokenAccount: anchor.web3.PublicKey; + const recoveryTokenAccount = anchor.web3.Keypair.generate(); + + const mint = anchor.web3.Keypair.generate(); + + const dummyTransferHook = anchor.workspace + .DummyTransferHook as anchor.Program; + + const [extraAccountMetaListPDA] = PublicKey.findProgramAddressSync( + [Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()], + dummyTransferHook.programId + ); + + const [counterPDA] = PublicKey.findProgramAddressSync( + [Buffer.from("counter")], + dummyTransferHook.programId + ); + + async function counterValue(): Promise { + const counter = await dummyTransferHook.account.counter.fetch(counterPDA); + return counter.count + } - let mint: anchor.web3.PublicKey; + it("Initialize mint", async () => { + const extensions = [spl.ExtensionType.TransferHook]; + const mintLen = spl.getMintLen(extensions); + const lamports = await connection.getMinimumBalanceForRentExemption( + mintLen + ); - before(async () => { - // airdrop some tokens to payer - mint = await spl.createMint(connection, payer, owner.publicKey, null, 9); + const transaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports, + programId: spl.TOKEN_2022_PROGRAM_ID, + }), + spl.createInitializeTransferHookInstruction( + mint.publicKey, + owner.publicKey, + dummyTransferHook.programId, + spl.TOKEN_2022_PROGRAM_ID + ), + spl.createInitializeMintInstruction( + mint.publicKey, + 9, + owner.publicKey, + null, + spl.TOKEN_2022_PROGRAM_ID + ) + ); + + await sendAndConfirmTransaction(connection, transaction, [payer, mint]); tokenAccount = await spl.createAssociatedTokenAccount( connection, payer, - mint, - user.publicKey + mint.publicKey, + user.publicKey, + undefined, + spl.TOKEN_2022_PROGRAM_ID, + spl.ASSOCIATED_TOKEN_PROGRAM_ID ); + + await spl.createAccount( + connection, + payer, + mint.publicKey, + payer.publicKey, + recoveryTokenAccount, + undefined, + spl.TOKEN_2022_PROGRAM_ID + ); + await spl.mintTo( connection, payer, - mint, + mint.publicKey, tokenAccount, owner, - BigInt(10000000) + BigInt(10000000), + undefined, + undefined, + spl.TOKEN_2022_PROGRAM_ID ); }); @@ -75,26 +142,68 @@ describe("example-native-token-transfers", () => { expect(version).to.equal("1.0.0"); }); - describe("Locking", () => { + it("Create ExtraAccountMetaList Account", async () => { + const initializeExtraAccountMetaListInstruction = + await dummyTransferHook.methods + .initializeExtraAccountMetaList() + .accountsStrict({ + payer: payer.publicKey, + mint: mint.publicKey, + counter: counterPDA, + extraAccountMetaList: extraAccountMetaListPDA, + tokenProgram: spl.TOKEN_2022_PROGRAM_ID, + associatedTokenProgram: spl.ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }) + .instruction(); + + const transaction = new Transaction().add( + initializeExtraAccountMetaListInstruction + ); + + await sendAndConfirmTransaction(connection, transaction, [payer]); + }); + + describe("Burning", () => { + const guardians = new MockGuardians(0, [GUARDIAN_KEY]); + + const emitter = new MockEmitter( + Buffer.from("transceiver".padStart(32, "\0")).toString("hex"), + toChainId("ethereum"), + Number(0) // sequence + ); + before(async () => { await spl.setAuthority( connection, payer, - mint, + mint.publicKey, owner, - 0, // mint - ntt.tokenAuthorityAddress() + spl.AuthorityType.MintTokens, + ntt.tokenAuthorityAddress(), + [], + undefined, + spl.TOKEN_2022_PROGRAM_ID ); await ntt.initialize({ payer, owner: payer, chain: "solana", - mint, + mint: mint.publicKey, outboundLimit: new BN(1000000), - mode: "locking", + mode: "burning", }); + // NOTE: this is a hack. The next instruction will fail if we don't wait + // here, because the address lookup table is not yet available, despite + // the transaction having been confirmed. + // Looks like a bug, but I haven't investigated further. In practice, this + // won't be an issue, becase the address lookup table will have been + // created well before anyone is trying to use it, but we might want to be + // mindful in the deploy script too. + await new Promise((resolve) => setTimeout(resolve, 200)); + await ntt.registerTransceiver({ payer, owner: payer, @@ -152,7 +261,7 @@ describe("example-native-token-transfers", () => { messageData.message.payload ); - // assert theat amount is what we expect + // assert that amount is what we expect expect( transceiverMessage.nttManagerPayload.payload.trimmedAmount ).to.deep.equal({ amount: 10000n, decimals: 8 }); @@ -160,30 +269,10 @@ describe("example-native-token-transfers", () => { const balance = await connection.getTokenAccountBalance(tokenAccount); expect(balance.value.amount).to.equal("9900000"); - // grab logs - // await connection.confirmTransaction(redeemTx, 'confirmed'); - // const tx = await anchor.getProvider().connection.getParsedTransaction(redeemTx, { - // commitment: "confirmed", - // }); - // console.log(tx); - - // const log = tx.meta.logMessages[1]; - // const message = log.substring(log.indexOf(':') + 1); - // console.log(message); - - // TODO: assert other stuff in the message - // console.log(nttManagerMessage); + expect((await counterValue()).toString()).to.be.eq("1") }); it("Can receive tokens", async () => { - const emitter = new MockEmitter( - Buffer.from("transceiver".padStart(32, "\0")).toString("hex"), - toChainId("ethereum"), - Number(0) // sequence - ); - - const guardians = new MockGuardians(0, [GUARDIAN_KEY]); - const sendingTransceiverMessage: WormholeTransceiverMessage< typeof nttMessageLayout > = { @@ -198,7 +287,7 @@ describe("example-native-token-transfers", () => { sender: new UniversalAddress("FACE".padStart(64, "0")), payload: { trimmedAmount: { - amount: 10000n, + amount: 5000n, decimals: 8, }, sourceToken: new UniversalAddress("FAFA".padStart(64, "0")), @@ -230,19 +319,97 @@ describe("example-native-token-transfers", () => { }); expect(released).to.equal(true); + + expect((await counterValue()).toString()).to.be.eq("2") }); - }); - // describe('Burning', () => { - // beforeEach(async () => { - // await ntt.initialize({ - // payer, - // owner, - // chain: 'solana', - // mint, - // outboundLimit: new BN(1000000), - // mode: 'burning' - // }) - // }); - // }); + describe("Recovery", () => { + it("Can initialize recovery account", async () => { + await ntt.initializeRecoveryAccount({ + payer, + owner: payer, + recoveryTokenAccount: tokenAccount, + }); + + const recoveryAccount = await ntt.getRecoveryAccount(); + + expect(recoveryAccount?.toBase58()).to.equal(tokenAccount.toBase58()); + }); + + it("Can update recovery account", async () => { + await ntt.updateRecoveryAddress({ + // payer, + owner: payer, + newRecoveryAccount: recoveryTokenAccount.publicKey, + }); + + const recoveryAccount = await ntt.getRecoveryAccount(); + + expect(recoveryAccount?.toBase58()).to.equal( + recoveryTokenAccount.publicKey.toBase58() + ); + }); + + it("Owner can recover transfers", async () => { + const sendingTransceiverMessage: WormholeTransceiverMessage< + typeof nttMessageLayout + > = { + sourceNttManager: new UniversalAddress( + encoding.bytes.encode("nttManager".padStart(32, "\0")) + ), + recipientNttManager: new UniversalAddress( + ntt.program.programId.toBytes() + ), + nttManagerPayload: { + id: encoding.bytes.encode("sequence2".padEnd(32, "0")), + sender: new UniversalAddress("FACE".padStart(64, "0")), + payload: { + trimmedAmount: { + amount: 5000n, + decimals: 8, + }, + sourceToken: new UniversalAddress("FAFA".padStart(64, "0")), + recipientAddress: new UniversalAddress(user.publicKey.toBytes()), + recipientChain: "Solana", + }, + }, + transceiverPayload: { forSpecializedRelayer: false }, + } as const; + + const serialized = serializePayload( + "Ntt:WormholeTransfer", + sendingTransceiverMessage + ); + + const published = emitter.publishMessage( + 0, // nonce + Buffer.from(serialized), + 0 // consistency level + ); + + const vaaBuf = guardians.addSignatures(published, [0]); + + await postVaa(connection, payer, vaaBuf, ntt.wormholeId); + + const released = await ntt.redeem({ + payer, + vaa: vaaBuf, + recover: payer, + }); + + expect(released).to.equal(true); + + const account = await spl.getAccount( + connection, + recoveryTokenAccount.publicKey, + undefined, + spl.TOKEN_2022_PROGRAM_ID + ); + + expect(account.amount).to.equal(BigInt(50000)); + + expect((await counterValue()).toString()).to.be.eq("3") + }); + }); + }); }); diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index 81f38595c..f2d40f50b 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -10,8 +10,7 @@ import { nativeTokenTransferLayout } from './nttLayout' import { derivePostedVaaKey, getWormholeDerivedAccounts } from '@certusone/wormhole-sdk/lib/cjs/solana/wormhole' -import { BN, translateError, type IdlAccounts, Program, AnchorProvider, Wallet, } from '@coral-xyz/anchor' -import { associatedAddress } from '@coral-xyz/anchor/dist/cjs/utils/token' +import { BN, translateError, type IdlAccounts, Program, web3, } from '@coral-xyz/anchor' import { getAssociatedTokenAddressSync } from '@solana/spl-token' import { PublicKey, Keypair, @@ -20,8 +19,13 @@ import { sendAndConfirmTransaction, type TransactionSignature, type Connection, + SystemProgram, TransactionMessage, - VersionedTransaction + VersionedTransaction, + Commitment, + AccountMeta, + AddressLookupTableProgram, + AddressLookupTableAccount } from '@solana/web3.js' import { Keccak } from 'sha3' import { type ExampleNativeTokenTransfers as RawExampleNativeTokenTransfers } from '../../target/types/example_native_token_transfers' @@ -76,6 +80,7 @@ export class NTT { readonly wormholeId: PublicKey // mapping from error code to error message. Used for prettifying error messages private readonly errors: Map + addressLookupTable: web3.AddressLookupTableAccount | null = null constructor(connection: Connection, args: { nttId: NttProgramId, wormholeId: WormholeProgramId }) { // TODO: initialise a new Program here with a passed in Connection @@ -105,6 +110,18 @@ export class NTT { return this.derivePda('config') } + lutAccountAddress(): PublicKey { + return this.derivePda('lut') + } + + lutAuthorityAddress(): PublicKey { + return this.derivePda('lut_authority') + } + + recoveryAccountAddress(): PublicKey { + return this.derivePda('recovery') + } + outboxRateLimitAccountAddress(): PublicKey { return this.derivePda('outbox_rate_limit') } @@ -207,7 +224,7 @@ export class NTT { // little endian length prefix. const buffer = Buffer.from(txSimulation.value.returnData?.data[0], 'base64') const len = buffer.readUInt32LE(0) - return buffer.slice(4, len + 4).toString() + return buffer.subarray(4, len + 4).toString() } // Instructions @@ -219,7 +236,7 @@ export class NTT { mint: PublicKey outboundLimit: BN mode: 'burning' | 'locking' - }) { + }): Promise { const mode: any = args.mode === 'burning' ? { burning: {} } @@ -232,7 +249,7 @@ export class NTT { const tokenProgram = mintInfo.owner const ix = await this.program.methods .initialize({ chainId, limit: args.outboundLimit, mode }) - .accounts({ + .accountsStrict({ payer: args.payer.publicKey, deployer: args.owner.publicKey, programData: programDataAddress(this.program.programId), @@ -241,10 +258,144 @@ export class NTT { rateLimit: this.outboxRateLimitAccountAddress(), tokenProgram, tokenAuthority: this.tokenAuthorityAddress(), - custody: await this.custodyAccountAddress(args.mint), + custody: await this.custodyAccountAddress(args.mint, tokenProgram), bpfLoaderUpgradeableProgram: BPF_LOADER_UPGRADEABLE_PROGRAM_ID, + associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }).instruction(); + await this.sendAndConfirmTransaction(new Transaction().add(ix), [args.payer, args.owner], false); + await this.initializeOrUpdateLUT({ payer: args.payer }) + } + + // This function should be called after each upgrade. If there's nothing to + // do, it won't actually submit a transaction, so it's cheap to call. + async initializeOrUpdateLUT(args: { + payer: Keypair + }): Promise { + // TODO: find a more robust way of fetching a recent slot + const slot = await this.program.provider.connection.getSlot() - 1 + + const [_, lutAddress] = web3.AddressLookupTableProgram.createLookupTable({ + authority: this.lutAuthorityAddress(), + payer: args.payer.publicKey, + recentSlot: slot, + }); + + const whAccs = getWormholeDerivedAccounts(this.program.programId, this.wormholeId) + const config = await this.getConfig() + + const entries = { + config: this.configAccountAddress(), + custody: await this.custodyAccountAddress(config), + tokenProgram: await this.tokenProgram(config), + mint: await this.mintAccountAddress(config), + tokenAuthority: this.tokenAuthorityAddress(), + outboxRateLimit: this.outboxRateLimitAccountAddress(), + wormhole: { + bridge: whAccs.wormholeBridge, + feeCollector: whAccs.wormholeFeeCollector, + sequence: whAccs.wormholeSequence, + program: this.wormholeId, + systemProgram: SystemProgram.programId, + clock: web3.SYSVAR_CLOCK_PUBKEY, + rent: web3.SYSVAR_RENT_PUBKEY, + } + }; + + // collect all pubkeys in entries recursively + const collectPubkeys = (obj: any): Array => { + const pubkeys = new Array() + for (const key in obj) { + const value = obj[key] + if (value instanceof PublicKey) { + pubkeys.push(value) + } else if (typeof value === 'object') { + pubkeys.push(...collectPubkeys(value, pubkeys)) + } + } + return pubkeys + } + const pubkeys = collectPubkeys(entries).map(pk => pk.toBase58()) + + var existingLut: web3.AddressLookupTableAccount | null = null + try { + existingLut = await this.getAddressLookupTable(false) + } catch { + // swallow errors here, it just means that lut doesn't exist + } + + if (existingLut !== null) { + const existingPubkeys = existingLut.state.addresses?.map(a => a.toBase58()) ?? [] + + // if pubkeys contains keys that are not in the existing LUT, we need to + // add them to the LUT + const missingPubkeys = pubkeys.filter(pk => !existingPubkeys.includes(pk)) + + if (missingPubkeys.length === 0) { + return existingLut + } + } + + const ix = await this.program.methods + .initializeLut(new BN(slot)) + .accountsStrict({ + payer: args.payer.publicKey, + authority: this.lutAuthorityAddress(), + lutAddress, + lut: this.lutAccountAddress(), + lutProgram: AddressLookupTableProgram.programId, + systemProgram: SystemProgram.programId, + entries }).instruction(); - return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.payer, args.owner]); + + const signers = [args.payer] + await this.sendAndConfirmTransaction(new Transaction().add(ix), signers, false); + + // NOTE: explicitly invalidate the cache. This is the only operation that + // modifies the LUT, so this is the only place we need to invalide. + return this.getAddressLookupTable(false) + } + + async initializeRecoveryAccount(args: { + payer: Keypair + owner: Keypair + recoveryTokenAccount: PublicKey + }) { + const ix: TransactionInstruction = await this.program.methods + .initializeRecoveryAccount() + .accountsStrict({ + payer: args.payer.publicKey, + config: this.configAccountAddress(), + owner: args.owner.publicKey, + recovery: this.recoveryAccountAddress(), + recoveryAccount: args.recoveryTokenAccount, + systemProgram: SystemProgram.programId, + }) + .instruction(); + + const signers = [args.payer, args.owner]; + + await this.sendAndConfirmTransaction(new Transaction().add(ix), signers); + } + + async updateRecoveryAddress(args: { + owner: Keypair + newRecoveryAccount: PublicKey + }) { + const ix: TransactionInstruction = await this.program.methods + .updateRecoveryAddress() + .accountsStrict({ + // payer: args.payer.publicKey, + config: this.configAccountAddress(), + owner: args.owner.publicKey, + recovery: this.recoveryAccountAddress(), + newRecoveryAccount: args.newRecoveryAccount, + }) + .instruction(); + + const signers = [args.owner]; + + await this.sendAndConfirmTransaction(new Transaction().add(ix), signers); } async transfer(args: { @@ -298,7 +449,9 @@ export class NTT { args.from, this.sessionAuthorityAddress(args.fromAuthority.publicKey, transferArgs), args.fromAuthority.publicKey, - BigInt(args.amount.toString()) + BigInt(args.amount.toString()), + [], + config.tokenProgram ); const tx = new Transaction() tx.add(approveIx, transferIx, releaseIx) @@ -310,9 +463,27 @@ export class NTT { /** * Like `sendAndConfirmTransaction` but parses the anchor error code. */ - private async sendAndConfirmTransaction(tx: Transaction, signers: Keypair[]): Promise { + private async sendAndConfirmTransaction(tx: Transaction, signers: Keypair[], useLut = true): Promise { + const blockhash = await this.program.provider.connection.getLatestBlockhash() + const luts: AddressLookupTableAccount[] = [] + if (useLut) { + luts.push(await this.getAddressLookupTable()) + } + try { - return await sendAndConfirmTransaction(this.program.provider.connection, tx, signers) + const messageV0 = new TransactionMessage({ + payerKey: signers[0].publicKey, + recentBlockhash: blockhash.blockhash, + instructions: tx.instructions, + }).compileToV0Message(luts) + + const transactionV0 = new VersionedTransaction(messageV0) + transactionV0.sign(signers) + + // The types for this function are wrong -- the type says it doesn't + // support version transactions, but it does 🤫 + // @ts-ignore + return await sendAndConfirmTransaction(this.program.provider.connection, transactionV0) } catch (err) { throw translateError(err, this.errors) } @@ -349,22 +520,59 @@ export class NTT { shouldQueue: args.shouldQueue } - return await this.program.methods + const transferIx = await this.program.methods .transferBurn(transferArgs) - .accounts({ + .accountsStrict({ common: { payer: args.payer, config: { config: this.configAccountAddress() }, mint, + tokenProgram: await this.tokenProgram(config), from: args.from, + tokenProgram: await this.tokenProgram(config), outboxItem: args.outboxItem, - outboxRateLimit: this.outboxRateLimitAccountAddress() + outboxRateLimit: this.outboxRateLimitAccountAddress(), + custody: await this.custodyAccountAddress(config), + systemProgram: SystemProgram.programId, }, peer: this.peerAccountAddress(args.recipientChain), inboxRateLimit: this.inboxRateLimitAccountAddress(args.recipientChain), - sessionAuthority: this.sessionAuthorityAddress(args.fromAuthority, transferArgs) + sessionAuthority: this.sessionAuthorityAddress(args.fromAuthority, transferArgs), + tokenAuthority: this.tokenAuthorityAddress() }) .instruction() + + const mintInfo = await splToken.getMint( + this.program.provider.connection, + config.mint, + undefined, + config.tokenProgram + ) + const transferHook = splToken.getTransferHook(mintInfo) + + if (transferHook) { + const source = args.from + const mint = config.mint + const destination = await this.custodyAccountAddress(config) + const owner = this.sessionAuthorityAddress(args.fromAuthority, transferArgs) + await addExtraAccountMetasForExecute( + this.program.provider.connection, + transferIx, + transferHook.programId, + source, + mint, + destination, + owner, + // TODO(csongor): compute the amount that's passed into transfer. + // Leaving this 0 is fine unless the transfer hook accounts addresses + // depend on the amount (which is unlikely). + // If this turns out to be the case, the amount to put here is the + // untrimmed amount after removing dust. + 0, + ); + } + + return transferIx } /** @@ -398,7 +606,7 @@ export class NTT { shouldQueue: args.shouldQueue } - return await this.program.methods + const transferIx = await this.program.methods .transferLock(transferArgs) .accounts({ common: { @@ -408,14 +616,47 @@ export class NTT { from: args.from, tokenProgram: await this.tokenProgram(config), outboxItem: args.outboxItem, - outboxRateLimit: this.outboxRateLimitAccountAddress() + outboxRateLimit: this.outboxRateLimitAccountAddress(), + custody: await this.custodyAccountAddress(config) }, peer: this.peerAccountAddress(args.recipientChain), inboxRateLimit: this.inboxRateLimitAccountAddress(args.recipientChain), - custody: await this.custodyAccountAddress(config), sessionAuthority: this.sessionAuthorityAddress(args.fromAuthority, transferArgs) }) .instruction() + + const mintInfo = await splToken.getMint( + this.program.provider.connection, + config.mint, + undefined, + config.tokenProgram + ) + const transferHook = splToken.getTransferHook(mintInfo) + + if (transferHook) { + const source = args.from + const mint = config.mint + const destination = await this.custodyAccountAddress(config) + const owner = this.sessionAuthorityAddress(args.fromAuthority, transferArgs) + await addExtraAccountMetasForExecute( + this.program.provider.connection, + transferIx, + transferHook.programId, + source, + mint, + destination, + owner, + // TODO(csongor): compute the amount that's passed into transfer. + // Leaving this 0 is fine unless the transfer hook accounts addresses + // depend on the amount (which is unlikely). + // If this turns out to be the case, the amount to put here is the + // untrimmed amount after removing dust. + 0, + ); + } + + return transferIx + } /** @@ -468,7 +709,7 @@ export class NTT { tx.add(await this.createReleaseOutboundInstruction(txArgs)) const signers = [args.payer] - return await sendAndConfirmTransaction(this.program.provider.connection, tx, signers) + return await this.sendAndConfirmTransaction(tx, signers) } // TODO: document that if recipient is provided, then the instruction can be @@ -480,6 +721,7 @@ export class NTT { revertOnDelay: boolean recipient?: PublicKey config?: Config + recover?: Keypair }): Promise { const config = await this.getConfig(args.config) @@ -492,21 +734,72 @@ export class NTT { const mint = await this.mintAccountAddress(config) - return await this.program.methods - .releaseInboundMint({ + let accounts = { + common: { + payer: args.payer, + config: { config: this.configAccountAddress() }, + inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), + recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), + mint, + tokenAuthority: this.tokenAuthorityAddress(), + tokenProgram: config.tokenProgram, + custody: await this.custodyAccountAddress(config) + } + } + + var transferIx: TransactionInstruction; + + if (args.recover) { + const recoveryAccount = await this.getRecoveryAccount() + if (!recoveryAccount) { + throw new Error('Recovery account not initialized') + } + transferIx = await this.program.methods + .recoverMint({ revertOnDelay: args.revertOnDelay }) - .accounts({ - common: { - payer: args.payer, - config: { config: this.configAccountAddress() }, - inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), - recipient: getAssociatedTokenAddressSync(mint, recipientAddress), - mint, - tokenAuthority: this.tokenAuthorityAddress() - } + .accountsStrict({ + releaseInboundMint: accounts, + owner: args.recover.publicKey, + recovery: this.recoveryAccountAddress(), + recoveryAccount }) .instruction() + } else { + transferIx = await this.program.methods + .releaseInboundMint({ + revertOnDelay: args.revertOnDelay + }) + .accountsStrict(accounts) + .instruction() + } + + const mintInfo = await splToken.getMint(this.program.provider.connection, config.mint, undefined, config.tokenProgram) + const transferHook = splToken.getTransferHook(mintInfo) + + if (transferHook) { + const source = await this.custodyAccountAddress(config) + const mint = config.mint + const destination = getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram) + const owner = this.tokenAuthorityAddress() + await addExtraAccountMetasForExecute( + this.program.provider.connection, + transferIx, + transferHook.programId, + source, + mint, + destination, + owner, + // TODO(csongor): compute the amount that's passed into transfer. + // Leaving this 0 is fine unless the transfer hook accounts addresses + // depend on the amount (which is unlikely). + // If this turns out to be the case, the amount to put here is the + // untrimmed amount after removing dust. + 0, + ); + } + + return transferIx } async releaseInboundMint(args: { @@ -515,6 +808,7 @@ export class NTT { nttMessage: NttMessage revertOnDelay: boolean config?: Config + recover?: Keypair }): Promise { if (await this.isPaused()) { throw new Error('Contract is paused') @@ -529,6 +823,9 @@ export class NTT { tx.add(await this.createReleaseInboundMintInstruction(txArgs)) const signers = [args.payer] + if (args.recover) { + signers.push(args.recover) + } await this.sendAndConfirmTransaction(tx, signers) } @@ -539,6 +836,7 @@ export class NTT { revertOnDelay: boolean recipient?: PublicKey config?: Config + recover?: Keypair }): Promise { const config = await this.getConfig(args.config) @@ -551,22 +849,74 @@ export class NTT { const mint = await this.mintAccountAddress(config) - return await this.program.methods - .releaseInboundUnlock({ - revertOnDelay: args.revertOnDelay - }) - .accounts({ - common: { - payer: args.payer, - config: { config: this.configAccountAddress() }, - inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), - recipient: getAssociatedTokenAddressSync(mint, recipientAddress), - mint, - tokenAuthority: this.tokenAuthorityAddress() - }, + let accounts = { + common: { + payer: args.payer, + config: { config: this.configAccountAddress() }, + inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), + recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), + mint, + tokenAuthority: this.tokenAuthorityAddress(), + tokenProgram: config.tokenProgram, custody: await this.custodyAccountAddress(config) - }) - .instruction() + }, + }; + + var transferIx: TransactionInstruction; + + if (args.recover) { + const recoveryAccount = await this.getRecoveryAccount() + if (!recoveryAccount) { + throw new Error('Recovery account not initialized') + } + transferIx = + await this.program.methods + .recoverUnlock({ + revertOnDelay: args.revertOnDelay + }) + .accountsStrict({ + releaseInboundUnlock: accounts, + owner: args.recover.publicKey, + recovery: this.recoveryAccountAddress(), + recoveryAccount + }) + .instruction() + } else { + transferIx = + await this.program.methods + .releaseInboundUnlock({ + revertOnDelay: args.revertOnDelay + }) + .accountsStrict(accounts) + .instruction() + } + + const mintInfo = await splToken.getMint(this.program.provider.connection, config.mint, undefined, config.tokenProgram) + const transferHook = splToken.getTransferHook(mintInfo) + + if (transferHook) { + const source = await this.custodyAccountAddress(config) + const mint = config.mint + const destination = getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram) + const owner = this.tokenAuthorityAddress() + await addExtraAccountMetasForExecute( + this.program.provider.connection, + transferIx, + transferHook.programId, + source, + mint, + destination, + owner, + // TODO(csongor): compute the amount that's passed into transfer. + // Leaving this 0 is fine unless the transfer hook accounts addresses + // depend on the amount (which is unlikely). + // If this turns out to be the case, the amount to put here is the + // untrimmed amount after removing dust. + 0, + ); + } + + return transferIx } async releaseInboundUnlock(args: { @@ -575,6 +925,7 @@ export class NTT { nttMessage: NttMessage revertOnDelay: boolean config?: Config + recover?: Keypair }): Promise { if (await this.isPaused()) { throw new Error('Contract is paused') @@ -589,6 +940,9 @@ export class NTT { tx.add(await this.createReleaseInboundUnlockInstruction(txArgs)) const signers = [args.payer] + if (args.recover) { + signers.push(args.recover) + } await this.sendAndConfirmTransaction(tx, signers) } @@ -614,7 +968,7 @@ export class NTT { peer: this.peerAccountAddress(args.chain), inboxRateLimit: this.inboxRateLimitAccountAddress(args.chain) }).instruction() - return await sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.payer, args.owner]) + return await this.sendAndConfirmTransaction(new Transaction().add(ix), [args.payer, args.owner]) } async setWormholeTransceiverPeer(args: { @@ -651,7 +1005,7 @@ export class NTT { program: this.wormholeId } }).instruction() - return await sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) + return await this.sendAndConfirmTransaction(new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) } async registerTransceiver(args: { @@ -684,8 +1038,8 @@ export class NTT { program: this.wormholeId } }).instruction() - return await sendAndConfirmTransaction( - this.program.provider.connection, new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) + return await this.sendAndConfirmTransaction( + new Transaction().add(ix, broadcastIx), [args.payer, args.owner, wormholeMessage]) } async setOutboundLimit(args: { @@ -701,7 +1055,7 @@ export class NTT { config: this.configAccountAddress(), rateLimit: this.outboxRateLimitAccountAddress(), }).instruction(); - return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.owner]); + return this.sendAndConfirmTransaction(new Transaction().add(ix), [args.owner]); } async setInboundLimit(args: { @@ -718,7 +1072,7 @@ export class NTT { config: this.configAccountAddress(), rateLimit: this.inboxRateLimitAccountAddress(args.chain), }).instruction(); - return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.owner]); + return this.sendAndConfirmTransaction(new Transaction().add(ix), [args.owner]); } async createReceiveWormholeMessageInstruction(args: { @@ -800,6 +1154,7 @@ export class NTT { payer: Keypair vaa: SignedVaa config?: Config + recover?: Keypair // owner keypair if recovering }): Promise { const config = await this.getConfig(args.config) @@ -839,7 +1194,7 @@ export class NTT { recipient: new PublicKey(nttMessage.payload.recipientAddress.toUint8Array()), chain: chainId, revertOnDelay: false, - config: config + config } if (config.mode.locking != null) { @@ -849,6 +1204,9 @@ export class NTT { } const signers = [args.payer] + if (args.recover) { + signers.push(args.recover) + } await this.sendAndConfirmTransaction(tx, signers) // Let's check if the transfer was released @@ -870,6 +1228,11 @@ export class NTT { return config ?? await this.program.account.config.fetch(this.configAccountAddress()) } + async getRecoveryAccount(): Promise { + const account = await this.program.account.recoveryAccount.fetchNullable(this.recoveryAccountAddress()) + return account?.recoveryAddress + } + async isPaused(config?: Config): Promise { return (await this.getConfig(config)).paused } @@ -886,16 +1249,31 @@ export class NTT { return await this.program.account.inboxItem.fetch(this.inboxItemAccountAddress(chain, nttMessage)) } + async getAddressLookupTable(useCache = true): Promise { + if (!useCache || !this.addressLookupTable) { + const lut = await this.program.account.lut.fetchNullable(this.lutAccountAddress()) + if (!lut) { + throw new Error('Address lookup table not found. Did you forget to call initializeLUT?') + } + const response = await this.program.provider.connection.getAddressLookupTable(lut.address) + this.addressLookupTable = response.value + } + if (!this.addressLookupTable) { + throw new Error('Address lookup table not found. Did you forget to call initializeLUT?') + } + return this.addressLookupTable + } + /** * Returns the address of the custody account. If the config is available * (i.e. the program is initialised), the mint is derived from the config. * Otherwise, the mint must be provided. */ - async custodyAccountAddress(configOrMint: Config | PublicKey): Promise { + async custodyAccountAddress(configOrMint: Config | PublicKey, tokenProgram = splToken.TOKEN_PROGRAM_ID): Promise { if (configOrMint instanceof PublicKey) { - return associatedAddress({ mint: configOrMint, owner: this.tokenAuthorityAddress() }) + return splToken.getAssociatedTokenAddress(configOrMint, this.tokenAuthorityAddress(), true, tokenProgram) } else { - return associatedAddress({ mint: await this.mintAccountAddress(configOrMint), owner: this.tokenAuthorityAddress() }) + return splToken.getAssociatedTokenAddress(configOrMint.mint, this.tokenAuthorityAddress(), true, configOrMint.tokenProgram) } } } @@ -903,3 +1281,86 @@ export class NTT { function exhaustive(_: never): A { throw new Error('Impossible') } + +/** + * TODO: this is copied from @solana/spl-token, because the most recent released + * version (0.4.3) is broken (does object equality instead of structural on the pubkey) + * + * this version fixes that error, looks like it's also fixed on main: + * https://github.com/solana-labs/solana-program-library/blob/ad4eb6914c5e4288ad845f29f0003cd3b16243e7/token/js/src/extensions/transferHook/instructions.ts#L208 + */ +async function addExtraAccountMetasForExecute( + connection: Connection, + instruction: TransactionInstruction, + programId: PublicKey, + source: PublicKey, + mint: PublicKey, + destination: PublicKey, + owner: PublicKey, + amount: number | bigint, + commitment?: Commitment +) { + const validateStatePubkey = splToken.getExtraAccountMetaAddress(mint, programId); + const validateStateAccount = await connection.getAccountInfo(validateStatePubkey, commitment); + if (validateStateAccount == null) { + return instruction; + } + const validateStateData = splToken.getExtraAccountMetas(validateStateAccount); + + // Check to make sure the provided keys are in the instruction + if (![source, mint, destination, owner].every((key) => instruction.keys.some((meta) => meta.pubkey.equals(key)))) { + throw new Error('Missing required account in instruction'); + } + + const executeInstruction = splToken.createExecuteInstruction( + programId, + source, + mint, + destination, + owner, + validateStatePubkey, + BigInt(amount) + ); + + for (const extraAccountMeta of validateStateData) { + executeInstruction.keys.push( + deEscalateAccountMeta( + await splToken.resolveExtraAccountMeta( + connection, + extraAccountMeta, + executeInstruction.keys, + executeInstruction.data, + executeInstruction.programId + ), + executeInstruction.keys + ) + ); + } + + // Add only the extra accounts resolved from the validation state + instruction.keys.push(...executeInstruction.keys.slice(5)); + + // Add the transfer hook program ID and the validation state account + instruction.keys.push({ pubkey: programId, isSigner: false, isWritable: false }); + instruction.keys.push({ pubkey: validateStatePubkey, isSigner: false, isWritable: false }); +} + +// TODO: delete (see above) +function deEscalateAccountMeta(accountMeta: AccountMeta, accountMetas: AccountMeta[]): AccountMeta { + const maybeHighestPrivileges = accountMetas + .filter((x) => x.pubkey.equals(accountMeta.pubkey)) + .reduce<{ isSigner: boolean; isWritable: boolean } | undefined>((acc, x) => { + if (!acc) return { isSigner: x.isSigner, isWritable: x.isWritable }; + return { isSigner: acc.isSigner || x.isSigner, isWritable: acc.isWritable || x.isWritable }; + }, undefined); + if (maybeHighestPrivileges) { + const { isSigner, isWritable } = maybeHighestPrivileges; + if (!isSigner && isSigner !== accountMeta.isSigner) { + accountMeta.isSigner = false; + } + if (!isWritable && isWritable !== accountMeta.isWritable) { + accountMeta.isWritable = false; + } + } + return accountMeta; +}