diff --git a/solana/programs/example-native-token-transfers/src/error.rs b/solana/programs/example-native-token-transfers/src/error.rs index 0002a12c1..0c4bc8f17 100644 --- a/solana/programs/example-native-token-transfers/src/error.rs +++ b/solana/programs/example-native-token-transfers/src/error.rs @@ -53,6 +53,8 @@ pub enum NTTError { BitmapIndexOutOfBounds, #[msg("NoRegisteredTransceivers")] NoRegisteredTransceivers, + #[msg("InvalidMultisig")] + InvalidMultisig, } 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 bcc37de0d..dfa889bb9 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/initialize.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/initialize.rs @@ -10,10 +10,10 @@ use crate::{ bitmap::Bitmap, error::NTTError, queue::{outbox::OutboxRateLimit, rate_limit::RateLimitState}, + spl_multisig::SplMultisig, }; #[derive(Accounts)] -#[instruction(args: InitializeArgs)] pub struct Initialize<'info> { #[account(mut)] pub payer: Signer<'info>, @@ -37,12 +37,8 @@ pub struct Initialize<'info> { )] pub config: Box>, - #[account( - constraint = - args.mode == Mode::Locking - || mint.mint_authority.unwrap() == token_authority.key() - @ NTTError::InvalidMintAuthority, - )] + // NOTE: this account is unconstrained and is the responsibility of the + // handler to constrain it pub mint: Box>, #[account( @@ -76,7 +72,7 @@ pub struct Initialize<'info> { /// The custody account that holds tokens in locking mode and temporarily /// holds tokens in burning mode. /// CHECK: Use init_if_needed here to prevent a denial-of-service of the [`initialize`] - /// function if the token account has already been created. + /// function if the token account has already been created. pub custody: InterfaceAccount<'info, token_interface::TokenAccount>, /// CHECK: checked to be the appropriate token program when initialising the @@ -96,24 +92,77 @@ pub struct InitializeArgs { } pub fn initialize(ctx: Context, args: InitializeArgs) -> Result<()> { - ctx.accounts.config.set_inner(crate::config::Config { - bump: ctx.bumps.config, - mint: ctx.accounts.mint.key(), - token_program: ctx.accounts.token_program.key(), - mode: args.mode, - chain_id: ChainId { id: args.chain_id }, - owner: ctx.accounts.deployer.key(), + // NOTE: this check was moved into the function body to reuse the `Initialize` struct + // in the multisig variant while preserving ABI + if args.mode == Mode::Burning + && ctx.accounts.mint.mint_authority.unwrap() != ctx.accounts.token_authority.key() + { + return Err(NTTError::InvalidMintAuthority.into()); + } + + initialize_config_and_rate_limit( + ctx.accounts, + ctx.bumps.config, + args.chain_id, + args.limit, + args.mode, + ) +} + +#[derive(Accounts)] +#[instruction(args: InitializeArgs)] +pub struct InitializeMultisig<'info> { + #[account( + constraint = + args.mode == Mode::Locking + || common.mint.mint_authority.unwrap() == multisig.key() + @ NTTError::InvalidMintAuthority, + )] + pub common: Initialize<'info>, + + #[account( + constraint = + multisig.m == 1 && multisig.signers.contains(&common.token_authority.key()) + @ NTTError::InvalidMultisig, + )] + pub multisig: InterfaceAccount<'info, SplMultisig>, +} + +pub fn initialize_multisig(ctx: Context, args: InitializeArgs) -> Result<()> { + initialize_config_and_rate_limit( + &mut ctx.accounts.common, + ctx.bumps.common.config, + 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(), pending_owner: None, paused: false, next_transceiver_id: 0, // NOTE: can't be changed for now threshold: 1, enabled_transceivers: Bitmap::new(), - custody: ctx.accounts.custody.key(), + custody: common.custody.key(), }); - ctx.accounts.rate_limit.set_inner(OutboxRateLimit { - rate_limit: RateLimitState::new(args.limit), + common.rate_limit.set_inner(OutboxRateLimit { + rate_limit: RateLimitState::new(limit), }); 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 d56a51e70..623c3b509 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 @@ -7,6 +7,7 @@ use crate::{ config::*, error::NTTError, queue::inbox::{InboxItem, ReleaseStatus}, + spl_multisig::SplMultisig, }; #[derive(Accounts)] @@ -77,18 +78,11 @@ pub fn release_inbound_mint<'info>( ctx: Context<'_, '_, '_, 'info, ReleaseInboundMint<'info>>, args: ReleaseInboundArgs, ) -> Result<()> { - let inbox_item = &mut ctx.accounts.common.inbox_item; - - let released = inbox_item.try_release()?; - - if !released { - if args.revert_on_delay { - return Err(NTTError::CantReleaseYet.into()); - } else { - return Ok(()); - } + let inbox_item = release_inbox_item(&mut ctx.accounts.common.inbox_item, args.revert_on_delay)?; + if inbox_item.is_none() { + return Ok(()); } - + let inbox_item = inbox_item.unwrap(); assert!(inbox_item.release_status == ReleaseStatus::Released); // NOTE: minting tokens is a two-step process: @@ -106,6 +100,11 @@ pub fn release_inbound_mint<'info>( // The [`transfer_burn`] function operates in a similar way // (transfer to custody from sender, *then* burn). + let token_authority_sig: &[&[&[u8]]] = &[&[ + crate::TOKEN_AUTHORITY_SEED, + &[ctx.bumps.common.token_authority], + ]]; + // Step 1: mint tokens to the custody account token_interface::mint_to( CpiContext::new_with_signer( @@ -115,10 +114,7 @@ pub fn release_inbound_mint<'info>( to: ctx.accounts.common.custody.to_account_info(), authority: ctx.accounts.common.token_authority.to_account_info(), }, - &[&[ - crate::TOKEN_AUTHORITY_SEED, - &[ctx.bumps.common.token_authority], - ]], + token_authority_sig, ), inbox_item.amount, )?; @@ -133,10 +129,87 @@ pub fn release_inbound_mint<'info>( ctx.remaining_accounts, inbox_item.amount, ctx.accounts.common.mint.decimals, - &[&[ - crate::TOKEN_AUTHORITY_SEED, - &[ctx.bumps.common.token_authority], - ]], + token_authority_sig, + )?; + Ok(()) +} + +#[derive(Accounts)] +pub struct ReleaseInboundMintMultisig<'info> { + #[account( + constraint = common.config.mode == Mode::Burning @ NTTError::InvalidMode, + )] + common: ReleaseInbound<'info>, + + #[account( + constraint = + multisig.m == 1 && multisig.signers.contains(&common.token_authority.key()) + @ NTTError::InvalidMultisig, + )] + pub multisig: InterfaceAccount<'info, SplMultisig>, +} + +pub fn release_inbound_mint_multisig<'info>( + ctx: Context<'_, '_, '_, 'info, ReleaseInboundMintMultisig<'info>>, + args: ReleaseInboundArgs, +) -> Result<()> { + let inbox_item = release_inbox_item(&mut ctx.accounts.common.inbox_item, args.revert_on_delay)?; + if inbox_item.is_none() { + return Ok(()); + } + let inbox_item = inbox_item.unwrap(); + assert!(inbox_item.release_status == ReleaseStatus::Released); + + // NOTE: minting tokens is a two-step process: + // 1. Mint tokens to the custody account + // 2. Transfer the tokens from the custody account to the recipient + // + // This is done to ensure that if the token has a transfer hook defined, it + // will be called after the tokens are minted. + // Unfortunately the Token2022 program doesn't trigger transfer hooks when + // minting tokens, so we have to do it "manually" via a transfer. + // + // If we didn't do this, transfer hooks could be bypassed by transferring + // the tokens out through NTT first, then back in to the intended recipient. + // + // The [`transfer_burn`] function operates in a similar way + // (transfer to custody from sender, *then* burn). + + let token_authority_sig: &[&[&[u8]]] = &[&[ + crate::TOKEN_AUTHORITY_SEED, + &[ctx.bumps.common.token_authority], + ]]; + + // Step 1: mint tokens to the custody account + solana_program::program::invoke_signed( + &spl_token_2022::instruction::mint_to( + &ctx.accounts.common.token_program.key(), + &ctx.accounts.common.mint.key(), + &ctx.accounts.common.custody.key(), + &ctx.accounts.multisig.key(), + &[&ctx.accounts.common.token_authority.key()], + inbox_item.amount, + )?, + &[ + ctx.accounts.common.custody.to_account_info(), + ctx.accounts.common.mint.to_account_info(), + ctx.accounts.common.token_authority.to_account_info(), + ctx.accounts.multisig.to_account_info(), + ], + token_authority_sig, + )?; + + // Step 2: transfer the tokens from the custody account to the recipient + 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.to_account_info(), + ctx.remaining_accounts, + inbox_item.amount, + ctx.accounts.common.mint.decimals, + token_authority_sig, )?; Ok(()) } @@ -162,17 +235,12 @@ pub fn release_inbound_unlock<'info>( ctx: Context<'_, '_, '_, 'info, ReleaseInboundUnlock<'info>>, args: ReleaseInboundArgs, ) -> Result<()> { - let inbox_item = &mut ctx.accounts.common.inbox_item; - - let released = inbox_item.try_release()?; - - if !released { - if args.revert_on_delay { - return Err(NTTError::CantReleaseYet.into()); - } else { - return Ok(()); - } + let inbox_item = release_inbox_item(&mut ctx.accounts.common.inbox_item, args.revert_on_delay)?; + if inbox_item.is_none() { + return Ok(()); } + let inbox_item = inbox_item.unwrap(); + assert!(inbox_item.release_status == ReleaseStatus::Released); onchain::invoke_transfer_checked( &ctx.accounts.common.token_program.key(), @@ -190,3 +258,15 @@ pub fn release_inbound_unlock<'info>( )?; Ok(()) } +fn release_inbox_item( + inbox_item: &mut InboxItem, + revert_on_delay: bool, +) -> Result> { + if inbox_item.try_release()? { + Ok(Some(inbox_item)) + } else if revert_on_delay { + Err(NTTError::CantReleaseYet.into()) + } else { + Ok(None) + } +} diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index 1cf9cdf4b..e581648ca 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -20,6 +20,7 @@ pub mod messages; pub mod peer; pub mod queue; pub mod registered_transceiver; +pub mod spl_multisig; pub mod transceivers; pub mod transfer; @@ -74,6 +75,13 @@ pub mod example_native_token_transfers { instructions::initialize(ctx, args) } + pub fn initialize_multisig( + ctx: Context, + args: InitializeArgs, + ) -> Result<()> { + instructions::initialize_multisig(ctx, args) + } + pub fn initialize_lut(ctx: Context, recent_slot: u64) -> Result<()> { instructions::initialize_lut(ctx, recent_slot) } @@ -107,6 +115,13 @@ pub mod example_native_token_transfers { instructions::release_inbound_mint(ctx, args) } + pub fn release_inbound_mint_multisig<'info>( + ctx: Context<'_, '_, '_, 'info, ReleaseInboundMintMultisig<'info>>, + args: ReleaseInboundArgs, + ) -> Result<()> { + instructions::release_inbound_mint_multisig(ctx, args) + } + pub fn release_inbound_unlock<'info>( ctx: Context<'_, '_, '_, 'info, ReleaseInboundUnlock<'info>>, args: ReleaseInboundArgs, diff --git a/solana/programs/example-native-token-transfers/src/spl_multisig.rs b/solana/programs/example-native-token-transfers/src/spl_multisig.rs new file mode 100644 index 000000000..9d5cdb918 --- /dev/null +++ b/solana/programs/example-native-token-transfers/src/spl_multisig.rs @@ -0,0 +1,35 @@ +use anchor_lang::{prelude::*, solana_program::program_pack::Pack, Ids, Owners}; +use anchor_spl::token_interface::TokenInterface; +use std::ops::Deref; + +/// Anchor does not have a SPL Multisig wrapper as a part of the token interface: +/// https://docs.rs/anchor-spl/0.29.0/src/anchor_spl/token_interface.rs.html +/// Thus, we have to write our own wrapper to use with `InterfaceAccount` + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SplMultisig(spl_token_2022::state::Multisig); + +impl AccountDeserialize for SplMultisig { + fn try_deserialize_unchecked(buf: &mut &[u8]) -> anchor_lang::Result { + Ok(SplMultisig(spl_token_2022::state::Multisig::unpack(buf)?)) + } +} + +impl AccountSerialize for SplMultisig {} + +impl Owners for SplMultisig { + fn owners() -> &'static [Pubkey] { + TokenInterface::ids() + } +} + +impl Deref for SplMultisig { + type Target = spl_token_2022::state::Multisig; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(feature = "idl-build")] +impl anchor_lang::IdlBuild for SplMultisig {} diff --git a/solana/tests/anchor.test.ts b/solana/tests/anchor.test.ts index 16ba7319b..a582890b5 100644 --- a/solana/tests/anchor.test.ts +++ b/solana/tests/anchor.test.ts @@ -24,12 +24,6 @@ import { import { SolanaWormholeCore } from "@wormhole-foundation/sdk-solana-core"; import * as fs from "fs"; -import { - PublicKey, - SystemProgram, - Transaction, - sendAndConfirmTransaction, -} from "@solana/web3.js"; import { DummyTransferHook } from "../ts/idl/1_0_0/ts/dummy_transfer_hook.js"; import { getTransceiverProgram, IdlVersion, NTT } from "../ts/index.js"; import { derivePda } from "../ts/lib/utils.js"; @@ -38,12 +32,13 @@ import { SolanaNtt } from "../ts/sdk/index.js"; const solanaRootDir = `${__dirname}/../`; const VERSION: IdlVersion = "3.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: PublicKey = +const NTT_ADDRESS: anchor.web3.PublicKey = anchor.workspace.ExampleNativeTokenTransfers.programId; -const WH_TRANSCEIVER_ADDRESS: PublicKey = +const WH_TRANSCEIVER_ADDRESS: anchor.web3.PublicKey = anchor.workspace.NttTransceiver.programId; async function signSendWait( @@ -102,12 +97,12 @@ const mint = anchor.web3.Keypair.generate(); const dummyTransferHook = anchor.workspace .DummyTransferHook as anchor.Program; -const [extraAccountMetaListPDA] = PublicKey.findProgramAddressSync( +const [extraAccountMetaListPDA] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()], dummyTransferHook.programId ); -const [counterPDA] = PublicKey.findProgramAddressSync( +const [counterPDA] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from("counter")], dummyTransferHook.programId ); @@ -129,12 +124,11 @@ const nttTransceivers = { ), }; -const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; - describe("example-native-token-transfers", () => { let ntt: SolanaNtt<"Devnet", "Solana">; let signer: Signer; let sender: AccountAddress<"Solana">; + let multisig: anchor.web3.PublicKey; let tokenAddress: string; beforeAll(async () => { @@ -150,8 +144,8 @@ describe("example-native-token-transfers", () => { mintLen ); - const transaction = new Transaction().add( - SystemProgram.createAccount({ + const transaction = new anchor.web3.Transaction().add( + anchor.web3.SystemProgram.createAccount({ fromPubkey: payer.publicKey, newAccountPubkey: mint.publicKey, space: mintLen, @@ -178,9 +172,10 @@ describe("example-native-token-transfers", () => { transaction.feePayer = payer.publicKey; transaction.recentBlockhash = blockhash; - await sendAndConfirmTransaction(connection, transaction, [payer, mint], { - commitment: "confirmed", - }); + await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ + payer, + mint, + ]); tokenAccount = await spl.createAssociatedTokenAccount( connection, @@ -231,13 +226,22 @@ describe("example-native-token-transfers", () => { describe("Burning", () => { beforeAll(async () => { try { + multisig = await spl.createMultisig( + connection, + payer, + [owner.publicKey, ntt.pdas.tokenAuthority()], + 1, + anchor.web3.Keypair.generate(), + undefined, + TOKEN_PROGRAM + ); await spl.setAuthority( connection, payer, mint.publicKey, owner, spl.AuthorityType.MintTokens, - ntt.pdas.tokenAuthority(), + multisig, [], undefined, TOKEN_PROGRAM @@ -248,6 +252,7 @@ describe("example-native-token-transfers", () => { mint: mint.publicKey, outboundLimit: 1000000n, mode: "burning", + multisig, }); await signSendWait(ctx, initTxs, signer); @@ -285,11 +290,11 @@ describe("example-native-token-transfers", () => { extraAccountMetaList: extraAccountMetaListPDA, tokenProgram: TOKEN_PROGRAM, associatedTokenProgram: spl.ASSOCIATED_TOKEN_PROGRAM_ID, - systemProgram: SystemProgram.programId, + systemProgram: anchor.web3.SystemProgram.programId, }) .instruction(); - const transaction = new Transaction().add( + const transaction = new anchor.web3.Transaction().add( initializeExtraAccountMetaListInstruction ); transaction.feePayer = payer.publicKey; @@ -297,9 +302,9 @@ describe("example-native-token-transfers", () => { transaction.recentBlockhash = blockhash; transaction.sign(payer); - await sendAndConfirmTransaction(connection, transaction, [payer], { - commitment: "confirmed", - }); + await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ + payer, + ]); }); test("Can send tokens", async () => { @@ -391,7 +396,7 @@ describe("example-native-token-transfers", () => { const published = emitter.publishMessage(0, serialized, 200); const rawVaa = guardians.addSignatures(published, [0]); const vaa = deserialize("Ntt:WormholeTransfer", serialize(rawVaa)); - const redeemTxs = ntt.redeem([vaa], sender); + const redeemTxs = ntt.redeem([vaa], sender, multisig); try { await signSendWait(ctx, redeemTxs, signer); } catch (e) { @@ -401,6 +406,32 @@ describe("example-native-token-transfers", () => { expect((await counterValue()).toString()).toEqual("2"); }); + + it("Can mint independently", async () => { + const dest = await spl.getOrCreateAssociatedTokenAccount( + connection, + payer, + mint.publicKey, + anchor.web3.Keypair.generate().publicKey, + false, + undefined, + undefined, + TOKEN_PROGRAM + ); + await spl.mintTo( + connection, + payer, + mint.publicKey, + dest.address, + multisig, + 1, + [owner], + undefined, + TOKEN_PROGRAM + ); + const balance = await connection.getTokenAccountBalance(dest.address); + expect(balance.value.amount.toString()).toBe("1"); + }); }); describe("Static Checks", () => { diff --git a/solana/ts/idl/3_0_0/json/example_native_token_transfers.json b/solana/ts/idl/3_0_0/json/example_native_token_transfers.json index cdc046fa1..78d929714 100644 --- a/solana/ts/idl/3_0_0/json/example_native_token_transfers.json +++ b/solana/ts/idl/3_0_0/json/example_native_token_transfers.json @@ -54,7 +54,7 @@ "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." + "function if the token account has already been created." ] }, { @@ -90,6 +90,104 @@ } ] }, + { + "name": "initializeMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "deployer", + "isMut": false, + "isSigner": true + }, + { + "name": "programData", + "isMut": false, + "isSigner": false + }, + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "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.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "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": "bpfLoaderUpgradeableProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "multisig", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, { "name": "initializeLut", "accounts": [ @@ -550,6 +648,77 @@ } ] }, + { + "name": "releaseInboundMintMultisig", + "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, + "docs": [ + "CHECK The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisig", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, { "name": "releaseInboundUnlock", "accounts": [ @@ -2019,6 +2188,11 @@ "code": 6023, "name": "NoRegisteredTransceivers", "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" } ] } diff --git a/solana/ts/idl/3_0_0/ts/example_native_token_transfers.ts b/solana/ts/idl/3_0_0/ts/example_native_token_transfers.ts index 6c8a7fce3..e49f0d282 100644 --- a/solana/ts/idl/3_0_0/ts/example_native_token_transfers.ts +++ b/solana/ts/idl/3_0_0/ts/example_native_token_transfers.ts @@ -54,7 +54,7 @@ export type ExampleNativeTokenTransfers = { "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." + "function if the token account has already been created." ] }, { @@ -90,6 +90,104 @@ export type ExampleNativeTokenTransfers = { } ] }, + { + "name": "initializeMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "deployer", + "isMut": false, + "isSigner": true + }, + { + "name": "programData", + "isMut": false, + "isSigner": false + }, + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "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.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "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": "bpfLoaderUpgradeableProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "multisig", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, { "name": "initializeLut", "accounts": [ @@ -550,6 +648,77 @@ export type ExampleNativeTokenTransfers = { } ] }, + { + "name": "releaseInboundMintMultisig", + "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, + "docs": [ + "CHECK The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisig", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, { "name": "releaseInboundUnlock", "accounts": [ @@ -2019,6 +2188,11 @@ export type ExampleNativeTokenTransfers = { "code": 6023, "name": "NoRegisteredTransceivers", "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" } ] } @@ -2078,7 +2252,7 @@ export const IDL: ExampleNativeTokenTransfers = { "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." + "function if the token account has already been created." ] }, { @@ -2114,6 +2288,104 @@ export const IDL: ExampleNativeTokenTransfers = { } ] }, + { + "name": "initializeMultisig", + "accounts": [ + { + "name": "common", + "accounts": [ + { + "name": "payer", + "isMut": true, + "isSigner": true + }, + { + "name": "deployer", + "isMut": false, + "isSigner": true + }, + { + "name": "programData", + "isMut": false, + "isSigner": false + }, + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "mint", + "isMut": false, + "isSigner": false + }, + { + "name": "rateLimit", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenAuthority", + "isMut": false, + "isSigner": false, + "docs": [ + "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.", + "", + "TODO: Using `UncheckedAccount` here leads to \"Access violation in stack frame ...\".", + "Could refactor code to use `Box<_>` to reduce stack size." + ] + }, + { + "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": "bpfLoaderUpgradeableProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + } + ] + }, + { + "name": "multisig", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "InitializeArgs" + } + } + ] + }, { "name": "initializeLut", "accounts": [ @@ -2574,6 +2846,77 @@ export const IDL: ExampleNativeTokenTransfers = { } ] }, + { + "name": "releaseInboundMintMultisig", + "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, + "docs": [ + "CHECK The seeds constraint ensures that this is the correct address" + ] + }, + { + "name": "mint", + "isMut": true, + "isSigner": false + }, + { + "name": "tokenProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "custody", + "isMut": true, + "isSigner": false + } + ] + }, + { + "name": "multisig", + "isMut": false, + "isSigner": false + } + ], + "args": [ + { + "name": "args", + "type": { + "defined": "ReleaseInboundArgs" + } + } + ] + }, { "name": "releaseInboundUnlock", "accounts": [ @@ -4043,6 +4386,11 @@ export const IDL: ExampleNativeTokenTransfers = { "code": 6023, "name": "NoRegisteredTransceivers", "msg": "NoRegisteredTransceivers" + }, + { + "code": 6024, + "name": "InvalidMultisig", + "msg": "InvalidMultisig" } ] } diff --git a/solana/ts/lib/ntt.ts b/solana/ts/lib/ntt.ts index c3507c1c4..379ee5fed 100644 --- a/solana/ts/lib/ntt.ts +++ b/solana/ts/lib/ntt.ts @@ -274,6 +274,53 @@ export namespace NTT { .instruction(); } + export async function createInitializeMultisigInstruction( + program: Program>, + args: { + payer: PublicKey; + owner: PublicKey; + chain: Chain; + mint: PublicKey; + outboundLimit: bigint; + tokenProgram: PublicKey; + mode: "burning" | "locking"; + multisig: PublicKey; + }, + pdas?: Pdas + ) { + 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()); + return await program.methods + .initializeMultisig({ chainId, limit: limit, mode }) + .accountsStrict({ + common: { + payer: args.payer, + deployer: args.owner, + programData: programDataAddress(program.programId), + config: pdas.configAccount(), + mint: args.mint, + rateLimit: pdas.outboxRateLimitAccount(), + tokenAuthority: pdas.tokenAuthority(), + custody: await NTT.custodyAccountAddress( + pdas, + args.mint, + args.tokenProgram + ), + tokenProgram: args.tokenProgram, + associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, + bpfLoaderUpgradeableProgram: BPF_LOADER_UPGRADEABLE_PROGRAM_ID, + systemProgram: SystemProgram.programId, + }, + multisig: args.multisig, + }) + .instruction(); + } + // 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. export async function initializeOrUpdateLUT( @@ -611,6 +658,90 @@ export namespace NTT { return transferIx; } + // TODO: document that if recipient is provided, then the instruction can be + // created before the inbox item is created (i.e. they can be put in the same tx) + export async function createReleaseInboundMintMultisigInstruction( + program: Program>, + config: NttBindings.Config, + args: { + payer: PublicKey; + chain: Chain; + nttMessage: Ntt.Message; + revertOnDelay: boolean; + multisig: PublicKey; + recipient?: PublicKey; + }, + pdas?: Pdas + ): Promise { + pdas = pdas ?? NTT.pdas(program.programId); + + const recipientAddress = + args.recipient ?? + (await getInboxItem(program, args.chain, args.nttMessage)) + .recipientAddress; + + const transferIx = await program.methods + .releaseInboundMintMultisig({ + revertOnDelay: args.revertOnDelay, + }) + .accountsStrict({ + common: { + payer: args.payer, + config: { config: pdas.configAccount() }, + inboxItem: pdas.inboxItemAccount(args.chain, args.nttMessage), + recipient: getAssociatedTokenAddressSync( + config.mint, + recipientAddress, + true, + config.tokenProgram + ), + mint: config.mint, + tokenAuthority: pdas.tokenAuthority(), + tokenProgram: config.tokenProgram, + custody: await custodyAccountAddress(pdas, config), + }, + multisig: args.multisig, + }) + .instruction(); + + const mintInfo = await splToken.getMint( + program.provider.connection, + config.mint, + undefined, + config.tokenProgram + ); + const transferHook = splToken.getTransferHook(mintInfo); + + if (transferHook) { + const source = await custodyAccountAddress(pdas, config); + const mint = config.mint; + const destination = getAssociatedTokenAddressSync( + mint, + recipientAddress, + true, + config.tokenProgram + ); + const owner = pdas.tokenAuthority(); + await addExtraAccountMetasForExecute( + 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; + } + export async function createReleaseInboundUnlockInstruction( program: Program>, config: NttBindings.Config, diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index 3e8982d2b..5a4577de0 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -630,6 +630,7 @@ export class SolanaNtt mint: PublicKey; mode: Ntt.Mode; outboundLimit: bigint; + multisig?: PublicKey; } ) { const mintInfo = await this.connection.getAccountInfo(args.mint); @@ -640,17 +641,30 @@ export class SolanaNtt const payer = new SolanaAddress(sender).unwrap(); - const ix = await NTT.createInitializeInstruction( - this.program, - { - ...args, - payer, - owner: payer, - chain: this.chain, - tokenProgram: mintInfo.owner, - }, - this.pdas - ); + const ix = args.multisig + ? await NTT.createInitializeMultisigInstruction( + this.program, + { + ...args, + payer, + owner: payer, + chain: this.chain, + tokenProgram: mintInfo.owner, + multisig: args.multisig, + }, + this.pdas + ) + : await NTT.createInitializeInstruction( + this.program, + { + ...args, + payer, + owner: payer, + chain: this.chain, + tokenProgram: mintInfo.owner, + }, + this.pdas + ); const tx = new Transaction(); tx.feePayer = payer; @@ -930,7 +944,11 @@ export class SolanaNtt } } - async *redeem(attestations: Ntt.Attestation[], payer: AccountAddress) { + async *redeem( + attestations: Ntt.Attestation[], + payer: AccountAddress, + multisig?: PublicKey + ) { const config = await this.getConfig(); if (config.paused) throw new Error("Contract is paused"); @@ -987,12 +1005,19 @@ export class SolanaNtt chain: emitterChain, revertOnDelay: false, }; - const releaseIx = + let releaseIx = config.mode.locking != null - ? NTT.createReleaseInboundUnlockInstruction( + ? NTT.createReleaseInboundUnlockInstruction(this.program, config, { + ...releaseArgs, + }) + : multisig + ? NTT.createReleaseInboundMintMultisigInstruction( this.program, config, - releaseArgs + { + ...releaseArgs, + multisig, + } ) : NTT.createReleaseInboundMintInstruction( this.program,