From b312d0d388b09bea6a15b4402350e6767fc0d1bd Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Mon, 8 Apr 2024 19:33:23 +0200 Subject: [PATCH 1/9] solana: pass token_program to ATA (support token22) The associated token program instructions default to the legacy SPL token program. We pass the token_program as an argument to support token2022 tokens too. --- .../src/instructions/initialize.rs | 1 + .../src/instructions/release_inbound.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/release_inbound.rs b/solana/programs/example-native-token-transfers/src/instructions/release_inbound.rs index 60cdbb6b5..4d4d3d03e 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 @@ -21,7 +21,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>, From 508af61e6c1845b62cfcae3acd2665274db4faf8 Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Mon, 8 Apr 2024 20:51:33 +0200 Subject: [PATCH 2/9] solana: add dummy transfer hook --- solana/Anchor.toml | 6 +- solana/Cargo.lock | 11 ++ .../programs/dummy-transfer-hook/Cargo.toml | 23 +++ .../programs/dummy-transfer-hook/Xargo.toml | 2 + .../programs/dummy-transfer-hook/src/lib.rs | 139 ++++++++++++++++++ 5 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 solana/programs/dummy-transfer-hook/Cargo.toml create mode 100644 solana/programs/dummy-transfer-hook/Xargo.toml create mode 100644 solana/programs/dummy-transfer-hook/src/lib.rs 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..fee82c049 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" diff --git a/solana/programs/dummy-transfer-hook/Cargo.toml b/solana/programs/dummy-transfer-hook/Cargo.toml new file mode 100644 index 000000000..ceb318312 --- /dev/null +++ b/solana/programs/dummy-transfer-hook/Cargo.toml @@ -0,0 +1,23 @@ +[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 = [] + +[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..86d3f3b8e --- /dev/null +++ b/solana/programs/dummy-transfer-hook/src/lib.rs @@ -0,0 +1,139 @@ +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 = 1; + +#[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 + )?]; + + 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<()> { + // NOTE: for now, the account constraints implement all the restrictions. + 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()), + } + } +} + +#[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>, + 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>, +} From 11234588dce9b1d55da8a8bc461915a05a49e67e Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Mon, 8 Apr 2024 20:52:19 +0200 Subject: [PATCH 3/9] solana/transfer: use hook helper to transfer tokens TODO: the old token program still works -- is this secure? I think so but double check --- .../src/instructions/release_inbound.rs | 31 ++++++++-------- .../src/instructions/transfer.rs | 35 ++++++++++--------- .../example-native-token-transfers/src/lib.rs | 9 +++-- 3 files changed, 40 insertions(+), 35 deletions(-) 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 4d4d3d03e..e4fb201f8 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::*, @@ -119,8 +120,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; @@ -138,22 +139,22 @@ 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(), - }, + Mode::Locking => { + onchain::invoke_transfer_checked( + &ctx.accounts.common.token_program.key(), + ctx.accounts.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], ]], - ), - inbox_item.amount, - ctx.accounts.common.mint.decimals, - ), + )?; + 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..aab502e7f 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, @@ -215,7 +216,10 @@ pub struct TransferLock<'info> { pub custody: InterfaceAccount<'info, token_interface::TokenAccount>, } -pub fn transfer_lock(ctx: Context, args: TransferArgs) -> Result<()> { +pub fn transfer_lock<'info>( + ctx: Context<'_, '_, '_, 'info, TransferLock<'info>>, + args: TransferArgs, +) -> Result<()> { require_eq!( ctx.accounts.common.config.mode, Mode::Locking, @@ -240,24 +244,21 @@ pub fn transfer_lock(ctx: Context, args: TransferArgs) -> Result<( let before = accs.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.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()?; diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index 1d3ebc3bb..8a2704301 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -81,7 +81,10 @@ pub mod example_native_token_transfers { 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) } @@ -96,8 +99,8 @@ pub mod example_native_token_transfers { 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) From 9d49b04f3c12b2e82f71c0894627635184aabde7 Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Tue, 9 Apr 2024 12:34:45 +0200 Subject: [PATCH 4/9] solana/sdk: support transfer hook --- solana/tests/example-native-token-transfer.ts | 108 +++++++++-- solana/ts/sdk/ntt.ts | 178 ++++++++++++++++-- 2 files changed, 253 insertions(+), 33 deletions(-) diff --git a/solana/tests/example-native-token-transfer.ts b/solana/tests/example-native-token-transfer.ts index 21613204b..95939cb8f 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"; @@ -48,25 +51,68 @@ describe("example-native-token-transfers", () => { const user = anchor.web3.Keypair.generate(); let tokenAccount: anchor.web3.PublicKey; - let mint: anchor.web3.PublicKey; + 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 + ); + + it("Initialize mint", async () => { + const extensions = [spl.ExtensionType.TransferHook]; + const mintLen = spl.getMintLen(extensions); + const lamports = await connection.getMinimumBalanceForRentExemption( + mintLen + ); + + 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 + ) + ); - before(async () => { - // airdrop some tokens to payer - mint = await spl.createMint(connection, payer, owner.publicKey, null, 9); + 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.mintTo( connection, payer, - mint, + mint.publicKey, tokenAccount, owner, - BigInt(10000000) + BigInt(10000000), + undefined, + undefined, + spl.TOKEN_2022_PROGRAM_ID ); }); @@ -75,22 +121,46 @@ describe("example-native-token-transfers", () => { expect(version).to.equal("1.0.0"); }); + it("Create ExtraAccountMetaList Account", async () => { + const initializeExtraAccountMetaListInstruction = + await dummyTransferHook.methods + .initializeExtraAccountMetaList() + .accountsStrict({ + payer: payer.publicKey, + mint: mint.publicKey, + 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("Locking", () => { 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", }); diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index 81f38595c..5b0162e2e 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -20,6 +20,7 @@ import { sendAndConfirmTransaction, type TransactionSignature, type Connection, + SystemProgram, TransactionMessage, VersionedTransaction } from '@solana/web3.js' @@ -232,7 +233,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,8 +242,10 @@ 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(); return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.payer, args.owner]); } @@ -298,7 +301,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) @@ -398,7 +403,7 @@ export class NTT { shouldQueue: args.shouldQueue } - return await this.program.methods + const transferIx = await this.program.methods .transferLock(transferArgs) .accounts({ common: { @@ -416,6 +421,39 @@ export class NTT { 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 + } /** @@ -496,14 +534,15 @@ export class NTT { .releaseInboundMint({ revertOnDelay: args.revertOnDelay }) - .accounts({ + .accountsStrict({ common: { payer: args.payer, config: { config: this.configAccountAddress() }, inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), - recipient: getAssociatedTokenAddressSync(mint, recipientAddress), + recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), mint, - tokenAuthority: this.tokenAuthorityAddress() + tokenAuthority: this.tokenAuthorityAddress(), + tokenProgram: config.tokenProgram } }) .instruction() @@ -551,22 +590,50 @@ export class NTT { const mint = await this.mintAccountAddress(config) - return await this.program.methods + const transferIx = await this.program.methods .releaseInboundUnlock({ revertOnDelay: args.revertOnDelay }) - .accounts({ + .accountsStrict({ common: { payer: args.payer, config: { config: this.configAccountAddress() }, inboxItem: this.inboxItemAccountAddress(args.chain, args.nttMessage), - recipient: getAssociatedTokenAddressSync(mint, recipientAddress), + recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), mint, - tokenAuthority: this.tokenAuthorityAddress() + tokenAuthority: this.tokenAuthorityAddress(), + tokenProgram: config.tokenProgram }, custody: await this.custodyAccountAddress(config) }) .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: { @@ -891,11 +958,11 @@ export class NTT { * (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 +970,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; +} From 29fb437c3dbae0e5f018731e655f54f73660a1b5 Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Mon, 15 Apr 2024 19:45:33 +0100 Subject: [PATCH 5/9] solana: add network feature flags to dummy transfer hook program --- solana/programs/dummy-transfer-hook/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/solana/programs/dummy-transfer-hook/Cargo.toml b/solana/programs/dummy-transfer-hook/Cargo.toml index ceb318312..6f78242db 100644 --- a/solana/programs/dummy-transfer-hook/Cargo.toml +++ b/solana/programs/dummy-transfer-hook/Cargo.toml @@ -14,6 +14,10 @@ no-idl = [] no-log-ix-name = [] cpi = ["no-entrypoint"] default = [] +mainnet = [] +solana-devnet = [] +tilt-devnet = [] +tilt-devnet2 = [ "tilt-devnet" ] [dependencies] anchor-lang.workspace = true From 4b55b847dce8ff58a1205804094151d624e4925a Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Thu, 18 Apr 2024 23:23:27 +0100 Subject: [PATCH 6/9] solana: add counter to dummy transfer hook to be able to test if it was executed --- .../programs/dummy-transfer-hook/src/lib.rs | 68 ++++++++++++++----- solana/tests/example-native-token-transfer.ts | 14 ++++ 2 files changed, 64 insertions(+), 18 deletions(-) diff --git a/solana/programs/dummy-transfer-hook/src/lib.rs b/solana/programs/dummy-transfer-hook/src/lib.rs index 86d3f3b8e..a49720bdf 100644 --- a/solana/programs/dummy-transfer-hook/src/lib.rs +++ b/solana/programs/dummy-transfer-hook/src/lib.rs @@ -17,7 +17,7 @@ pub const DESTINATION_TOKEN_ACCOUNT_INDEX: u8 = 2; pub const AUTHORITY_ACCOUNT_INDEX: u8 = 3; /// Number of extra accounts in the ExtraAccountMetaList account -pub const EXTRA_ACCOUNTS_LEN: u8 = 1; +pub const EXTRA_ACCOUNTS_LEN: u8 = 2; #[program] pub mod dummy_transfer_hook { @@ -31,21 +31,30 @@ pub mod dummy_transfer_hook { 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 - )?]; + 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()); @@ -58,8 +67,8 @@ pub mod dummy_transfer_hook { Ok(()) } - pub fn transfer_hook(_ctx: Context, _amount: u64) -> Result<()> { - // NOTE: for now, the account constraints implement all the restrictions. + pub fn transfer_hook(ctx: Context, _amount: u64) -> Result<()> { + ctx.accounts.counter.count += 1; Ok(()) } @@ -87,6 +96,12 @@ pub mod dummy_transfer_hook { } } +#[account] +#[derive(InitSpace)] +pub struct Counter { + pub count: u64, +} + #[derive(Accounts)] pub struct InitializeExtraAccountMetaList<'info> { #[account(mut)] @@ -104,6 +119,16 @@ pub struct InitializeExtraAccountMetaList<'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>, } @@ -136,4 +161,11 @@ pub struct TransferHook<'info> { /// 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/tests/example-native-token-transfer.ts b/solana/tests/example-native-token-transfer.ts index 95939cb8f..4221419e6 100644 --- a/solana/tests/example-native-token-transfer.ts +++ b/solana/tests/example-native-token-transfer.ts @@ -61,6 +61,16 @@ describe("example-native-token-transfers", () => { 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 + } + it("Initialize mint", async () => { const extensions = [spl.ExtensionType.TransferHook]; const mintLen = spl.getMintLen(extensions); @@ -128,6 +138,7 @@ describe("example-native-token-transfers", () => { .accountsStrict({ payer: payer.publicKey, mint: mint.publicKey, + counter: counterPDA, extraAccountMetaList: extraAccountMetaListPDA, tokenProgram: spl.TOKEN_2022_PROGRAM_ID, associatedTokenProgram: spl.ASSOCIATED_TOKEN_PROGRAM_ID, @@ -243,6 +254,7 @@ describe("example-native-token-transfers", () => { // TODO: assert other stuff in the message // console.log(nttManagerMessage); + expect((await counterValue()).toString()).to.be.eq("1") }); it("Can receive tokens", async () => { @@ -300,6 +312,8 @@ describe("example-native-token-transfers", () => { }); expect(released).to.equal(true); + + expect((await counterValue()).toString()).to.be.eq("2") }); }); From 637c03e7f7a6e0fd2cdbbd518ea67278195f6945 Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Fri, 19 Apr 2024 00:57:30 +0100 Subject: [PATCH 7/9] wip: transfer before burn/mint to trigger hooks wip because with this the test tx size is 5 bytes larger (for transfer) than the limit. The way to mitigate is to introduce lookup tables. That mitigation will exist in the tests, but will also have to be done on real deployments (since the transfer hooks might add an arbitrary number of extra acconuts, so we should leave as much headroom as possible) --- .../src/instructions/release_inbound.rs | 105 ++++++++++-------- .../src/instructions/transfer.rs | 97 ++++++++++------ .../example-native-token-transfers/src/lib.rs | 9 +- .../tests/sdk/instructions/transfer.rs | 3 +- solana/tests/example-native-token-transfer.ts | 30 +---- solana/ts/sdk/ntt.ts | 91 ++++++++++++--- 6 files changed, 208 insertions(+), 127 deletions(-) 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 e4fb201f8..7fe5394e8 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 @@ -41,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)] @@ -52,6 +59,9 @@ pub struct ReleaseInboundArgs { #[derive(Accounts)] pub struct ReleaseInboundMint<'info> { + #[account( + constraint = common.config.mode == Mode::Burning @ NTTError::InvalidMode, + )] common: ReleaseInbound<'info>, } @@ -62,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; @@ -79,38 +89,47 @@ 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>, + common: ReleaseInbound<'info>, } /// Release an inbound transfer and unlock the tokens to the recipient. @@ -136,25 +155,19 @@ pub fn release_inbound_unlock<'info>( } } - assert!(inbox_item.release_status == ReleaseStatus::Released); - match ctx.accounts.common.config.mode { - Mode::Burning => Err(NTTError::InvalidMode.into()), - Mode::Locking => { - onchain::invoke_transfer_checked( - &ctx.accounts.common.token_program.key(), - ctx.accounts.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(()) - } - } + 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 aab502e7f..3d32fb7ef 100644 --- a/solana/programs/example-native-token-transfers/src/instructions/transfer.rs +++ b/solana/programs/example-native-token-transfers/src/instructions/transfer.rs @@ -52,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>, } @@ -85,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( @@ -111,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, @@ -136,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()); } @@ -174,7 +206,9 @@ pub fn transfer_burn(ctx: Context, args: TransferArgs) -> Result<( recipient_ntt_manager, recipient_address, should_queue, - ) + )?; + + Ok(()) } // Lock/unlock @@ -182,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( @@ -208,24 +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<'info>( ctx: Context<'_, '_, '_, 'info, TransferLock<'info>>, args: TransferArgs, ) -> Result<()> { - require_eq!( - ctx.accounts.common.config.mode, - Mode::Locking, - NTTError::InvalidMode - ); - let accs = ctx.accounts; let TransferArgs { mut amount, @@ -242,13 +267,13 @@ pub fn transfer_lock<'info>( ) .map_err(NTTError::from)?; - let before = accs.custody.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.custody.to_account_info(), + accs.common.custody.to_account_info(), accs.session_authority.to_account_info(), ctx.remaining_accounts, amount, @@ -261,8 +286,8 @@ pub fn transfer_lock<'info>( ]], )?; - 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 8a2704301..b5bb5fbe5 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -77,7 +77,10 @@ pub mod example_native_token_transfers { 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) } @@ -92,8 +95,8 @@ 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) 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 4221419e6..9d87a84b1 100644 --- a/solana/tests/example-native-token-transfer.ts +++ b/solana/tests/example-native-token-transfer.ts @@ -153,7 +153,7 @@ describe("example-native-token-transfers", () => { await sendAndConfirmTransaction(connection, transaction, [payer]); }); - describe("Locking", () => { + describe("Burning", () => { before(async () => { await spl.setAuthority( connection, @@ -173,7 +173,7 @@ describe("example-native-token-transfers", () => { chain: "solana", mint: mint.publicKey, outboundLimit: new BN(1000000), - mode: "locking", + mode: "burning", }); await ntt.registerTransceiver({ @@ -241,19 +241,6 @@ 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") }); @@ -316,17 +303,4 @@ describe("example-native-token-transfers", () => { 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' - // }) - // }); - // }); }); diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index 5b0162e2e..d2da6a330 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -11,7 +11,6 @@ import { } 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 { getAssociatedTokenAddressSync } from '@solana/spl-token' import { PublicKey, Keypair, @@ -22,7 +21,9 @@ import { type Connection, SystemProgram, TransactionMessage, - VersionedTransaction + VersionedTransaction, + Commitment, + AccountMeta } from '@solana/web3.js' import { Keccak } from 'sha3' import { type ExampleNativeTokenTransfers as RawExampleNativeTokenTransfers } from '../../target/types/example_native_token_transfers' @@ -208,7 +209,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 @@ -354,22 +355,58 @@ 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, 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 } /** @@ -413,11 +450,11 @@ 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() @@ -530,7 +567,7 @@ export class NTT { const mint = await this.mintAccountAddress(config) - return await this.program.methods + const transferIx = await this.program.methods .releaseInboundMint({ revertOnDelay: args.revertOnDelay }) @@ -542,10 +579,38 @@ export class NTT { recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), mint, tokenAuthority: this.tokenAuthorityAddress(), - tokenProgram: config.tokenProgram + tokenProgram: config.tokenProgram, + custody: await this.custodyAccountAddress(config) } }) .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: { @@ -602,9 +667,9 @@ export class NTT { recipient: getAssociatedTokenAddressSync(mint, recipientAddress, true, config.tokenProgram), mint, tokenAuthority: this.tokenAuthorityAddress(), - tokenProgram: config.tokenProgram + tokenProgram: config.tokenProgram, + custody: await this.custodyAccountAddress(config) }, - custody: await this.custodyAccountAddress(config) }) .instruction() From a3ccab328e6cec354b28fd0e590b36368c961044 Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Fri, 19 Apr 2024 14:14:13 +0100 Subject: [PATCH 8/9] solana: populate an address lookup table to reduce tx size --- ci_tests/src/index.ts | 9 + solana/Cargo.lock | 1 + solana/Cargo.toml | 1 + .../example-native-token-transfers/Cargo.toml | 1 + .../src/instructions/luts.rs | 172 ++++++++++++++++++ .../src/instructions/mod.rs | 2 + .../example-native-token-transfers/src/lib.rs | 4 + solana/tests/example-native-token-transfer.ts | 9 + solana/ts/sdk/ntt.ts | 160 ++++++++++++++-- 9 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 solana/programs/example-native-token-transfers/src/instructions/luts.rs 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/Cargo.lock b/solana/Cargo.lock index fee82c049..78cde78b6 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -1568,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/example-native-token-transfers/Cargo.toml b/solana/programs/example-native-token-transfers/Cargo.toml index bc064e81d..9a131c741 100644 --- a/solana/programs/example-native-token-transfers/Cargo.toml +++ b/solana/programs/example-native-token-transfers/Cargo.toml @@ -38,6 +38,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/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..ed009b00c 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,13 @@ pub mod admin; pub mod initialize; +pub mod luts; pub mod redeem; pub mod release_inbound; pub mod transfer; pub use admin::*; pub use initialize::*; +pub use luts::*; pub use redeem::*; pub use release_inbound::*; pub use transfer::*; diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index b5bb5fbe5..6521acf1e 100644 --- a/solana/programs/example-native-token-transfers/src/lib.rs +++ b/solana/programs/example-native-token-transfers/src/lib.rs @@ -73,6 +73,10 @@ 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) + } + pub fn version(_ctx: Context) -> Result { Ok(VERSION.to_string()) } diff --git a/solana/tests/example-native-token-transfer.ts b/solana/tests/example-native-token-transfer.ts index 9d87a84b1..4431a5af3 100644 --- a/solana/tests/example-native-token-transfer.ts +++ b/solana/tests/example-native-token-transfer.ts @@ -176,6 +176,15 @@ describe("example-native-token-transfers", () => { 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, diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index d2da6a330..9c0a650da 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -10,7 +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 { BN, translateError, type IdlAccounts, Program, web3, } from '@coral-xyz/anchor' import { getAssociatedTokenAddressSync } from '@solana/spl-token' import { PublicKey, Keypair, @@ -23,7 +23,9 @@ import { TransactionMessage, VersionedTransaction, Commitment, - AccountMeta + AccountMeta, + AddressLookupTableProgram, + AddressLookupTableAccount } from '@solana/web3.js' import { Keccak } from 'sha3' import { type ExampleNativeTokenTransfers as RawExampleNativeTokenTransfers } from '../../target/types/example_native_token_transfers' @@ -78,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 @@ -107,6 +110,14 @@ export class NTT { return this.derivePda('config') } + lutAccountAddress(): PublicKey { + return this.derivePda('lut') + } + + lutAuthorityAddress(): PublicKey { + return this.derivePda('lut_authority') + } + outboxRateLimitAccountAddress(): PublicKey { return this.derivePda('outbox_rate_limit') } @@ -221,7 +232,7 @@ export class NTT { mint: PublicKey outboundLimit: BN mode: 'burning' | 'locking' - }) { + }): Promise { const mode: any = args.mode === 'burning' ? { burning: {} } @@ -248,7 +259,97 @@ export class NTT { associatedTokenProgram: splToken.ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId, }).instruction(); - return sendAndConfirmTransaction(this.program.provider.connection, new Transaction().add(ix), [args.payer, args.owner]); + 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(); + + 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 transfer(args: { @@ -316,9 +417,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) } @@ -543,7 +662,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 @@ -746,7 +865,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: { @@ -783,7 +902,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: { @@ -816,8 +935,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: { @@ -833,7 +952,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: { @@ -850,7 +969,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: { @@ -1018,6 +1137,21 @@ 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. From 18ebdc136b1a9263315b59d479ad107e5669ea69 Mon Sep 17 00:00:00 2001 From: Csongor Kiss Date: Thu, 18 Apr 2024 23:24:14 +0100 Subject: [PATCH 9/9] solana: add recovery flow --- .../example-native-token-transfers/Cargo.toml | 6 +- .../src/error.rs | 2 + .../src/instructions/mod.rs | 2 + .../src/instructions/recover.rs | 180 ++++++++++++++++++ .../src/instructions/release_inbound.rs | 5 +- .../example-native-token-transfers/src/lib.rs | 26 +++ solana/tests/example-native-token-transfer.ts | 120 +++++++++++- solana/ts/sdk/ntt.ts | 172 ++++++++++++++--- 8 files changed, 469 insertions(+), 44 deletions(-) create mode 100644 solana/programs/example-native-token-transfers/src/instructions/recover.rs diff --git a/solana/programs/example-native-token-transfers/Cargo.toml b/solana/programs/example-native-token-transfers/Cargo.toml index 9a131c741..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 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/mod.rs b/solana/programs/example-native-token-transfers/src/instructions/mod.rs index ed009b00c..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,6 +1,7 @@ pub mod admin; pub mod initialize; pub mod luts; +pub mod recover; pub mod redeem; pub mod release_inbound; pub mod transfer; @@ -8,6 +9,7 @@ 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 7fe5394e8..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 @@ -62,7 +62,7 @@ pub struct ReleaseInboundMint<'info> { #[account( constraint = common.config.mode == Mode::Burning @ NTTError::InvalidMode, )] - common: ReleaseInbound<'info>, + pub common: ReleaseInbound<'info>, } /// Release an inbound transfer and mint the tokens to the recipient. @@ -126,10 +126,11 @@ pub fn release_inbound_mint<'info>( #[derive(Accounts)] pub struct ReleaseInboundUnlock<'info> { + /// CHECK: the token program checks if this indeed the right authority for the mint #[account( constraint = common.config.mode == Mode::Locking @ NTTError::InvalidMode, )] - common: ReleaseInbound<'info>, + pub common: ReleaseInbound<'info>, } /// Release an inbound transfer and unlock the tokens to the recipient. diff --git a/solana/programs/example-native-token-transfers/src/lib.rs b/solana/programs/example-native-token-transfers/src/lib.rs index 6521acf1e..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? @@ -77,6 +79,16 @@ pub mod example_native_token_transfers { 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()) } @@ -113,6 +125,20 @@ pub mod example_native_token_transfers { 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/tests/example-native-token-transfer.ts b/solana/tests/example-native-token-transfer.ts index 4431a5af3..ef4d16768 100644 --- a/solana/tests/example-native-token-transfer.ts +++ b/solana/tests/example-native-token-transfer.ts @@ -50,6 +50,7 @@ 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(); @@ -113,6 +114,16 @@ describe("example-native-token-transfers", () => { 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, @@ -154,6 +165,14 @@ describe("example-native-token-transfers", () => { }); 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, @@ -242,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 }); @@ -254,14 +273,6 @@ describe("example-native-token-transfers", () => { }); 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 > = { @@ -276,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")), @@ -311,5 +322,94 @@ describe("example-native-token-transfers", () => { expect((await counterValue()).toString()).to.be.eq("2") }); + + 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 9c0a650da..f2d40f50b 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -118,6 +118,10 @@ export class NTT { return this.derivePda('lut_authority') } + recoveryAccountAddress(): PublicKey { + return this.derivePda('recovery') + } + outboxRateLimitAccountAddress(): PublicKey { return this.derivePda('outbox_rate_limit') } @@ -352,6 +356,48 @@ export class NTT { 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: { payer: Keypair from: PublicKey @@ -481,6 +527,7 @@ export class NTT { payer: args.payer, config: { config: this.configAccountAddress() }, mint, + tokenProgram: await this.tokenProgram(config), from: args.from, tokenProgram: await this.tokenProgram(config), outboxItem: args.outboxItem, @@ -674,6 +721,7 @@ export class NTT { revertOnDelay: boolean recipient?: PublicKey config?: Config + recover?: Keypair }): Promise { const config = await this.getConfig(args.config) @@ -686,23 +734,45 @@ export class NTT { const mint = await this.mintAccountAddress(config) - const transferIx = 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 }) .accountsStrict({ - 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) - } + 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) @@ -738,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') @@ -752,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) } @@ -762,6 +836,7 @@ export class NTT { revertOnDelay: boolean recipient?: PublicKey config?: Config + recover?: Keypair }): Promise { const config = await this.getConfig(args.config) @@ -774,23 +849,47 @@ export class NTT { const mint = await this.mintAccountAddress(config) - const transferIx = await this.program.methods - .releaseInboundUnlock({ - revertOnDelay: args.revertOnDelay - }) - .accountsStrict({ - 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() + 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 + .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) @@ -826,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') @@ -840,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) } @@ -1051,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) @@ -1090,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) { @@ -1100,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 @@ -1121,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 }