From d7550b840c874f57ac7a13276d9f263af44ae8c6 Mon Sep 17 00:00:00 2001 From: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:33:38 +0200 Subject: [PATCH] fix: separate instruction for deferred relayer refunds (#715) * fix: separate instruction for deferred relayer refunds Signed-off-by: Reinis Martinsons * fix: comment Signed-off-by: Reinis Martinsons * fix: update relayer refund tests Signed-off-by: Reinis Martinsons * fix Signed-off-by: Reinis Martinsons * test: execute relayer refunds with atomic ata creation Signed-off-by: Reinis Martinsons * fix: update refund script Signed-off-by: Reinis Martinsons * fix Signed-off-by: Reinis Martinsons * fix: relayer can claim refunds to any account Signed-off-by: Reinis Martinsons * fix: check pool balance Signed-off-by: Reinis Martinsons * fix: do not account for token bridge balance Signed-off-by: Reinis Martinsons --------- Signed-off-by: Reinis Martinsons --- programs/svm-spoke/src/error.rs | 2 + programs/svm-spoke/src/instructions/bundle.rs | 151 ++++++---- .../src/instructions/refund_claims.rs | 98 +++++- programs/svm-spoke/src/lib.rs | 23 +- .../svm-spoke/src/state/refund_account.rs | 120 ++------ scripts/svm/simpleFakeRelayerRepayment.ts | 6 +- test/svm/SvmSpoke.Bundle.ts | 280 +++++++++++------- test/svm/SvmSpoke.RefundClaims.ts | 143 +++++++-- test/svm/SvmSpoke.TokenBridge.ts | 2 +- test/svm/utils.ts | 8 +- 10 files changed, 524 insertions(+), 309 deletions(-) diff --git a/programs/svm-spoke/src/error.rs b/programs/svm-spoke/src/error.rs index 7f633c876..d6c10d52c 100644 --- a/programs/svm-spoke/src/error.rs +++ b/programs/svm-spoke/src/error.rs @@ -31,6 +31,8 @@ pub enum CommonError { DepositsArePaused, #[msg("Fills are currently paused!")] FillsArePaused, + #[msg("Insufficient spoke pool balance to execute leaf")] + InsufficientSpokePoolBalanceToExecuteLeaf, } // SVM specific errors. diff --git a/programs/svm-spoke/src/instructions/bundle.rs b/programs/svm-spoke/src/instructions/bundle.rs index 223797854..b0a9af311 100644 --- a/programs/svm-spoke/src/instructions/bundle.rs +++ b/programs/svm-spoke/src/instructions/bundle.rs @@ -1,11 +1,14 @@ use anchor_lang::{prelude::*, solana_program::keccak}; -use anchor_spl::token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}; +use anchor_spl::{ + associated_token, + token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}, +}; use crate::{ constants::DISCRIMINATOR_SIZE, error::{CommonError, SvmError}, event::ExecutedRelayerRefundRoot, - state::{ExecuteRelayerRefundLeafParams, RefundAccount, RootBundle, State, TransferLiability}, + state::{ClaimAccount, ExecuteRelayerRefundLeafParams, RootBundle, State, TransferLiability}, utils::{is_claimed, set_claimed, verify_merkle_proof}, }; @@ -71,7 +74,7 @@ pub struct RelayerRefundLeaf { pub leaf_id: u32, pub mint_public_key: Pubkey, #[max_len(0)] - pub refund_accounts: Vec, + pub refund_addresses: Vec, } impl RelayerRefundLeaf { @@ -85,8 +88,8 @@ impl RelayerRefundLeaf { } bytes.extend_from_slice(&self.leaf_id.to_le_bytes()); bytes.extend_from_slice(self.mint_public_key.as_ref()); - for account in &self.refund_accounts { - bytes.extend_from_slice(account.as_ref()); + for address in &self.refund_addresses { + bytes.extend_from_slice(address.as_ref()); } bytes @@ -100,6 +103,7 @@ impl RelayerRefundLeaf { pub fn execute_relayer_refund_leaf<'c, 'info>( ctx: Context<'_, '_, 'c, 'info, ExecuteRelayerRefundLeaf<'info>>, + deferred_refunds: bool, ) -> Result<()> where 'c: 'info, // TODO: add explaining comments on some of more complex syntax. @@ -129,62 +133,24 @@ where relayer_refund_leaf.leaf_id, ); - // TODO: execute remaining parts of leaf structure such as amountToReturn. - // TODO: emit events. - - if relayer_refund_leaf.refund_accounts.len() != relayer_refund_leaf.refund_amounts.len() { + if relayer_refund_leaf.refund_addresses.len() != relayer_refund_leaf.refund_amounts.len() { return err!(CommonError::InvalidMerkleLeaf); } - // Derive the signer seeds for the state. The vault owns the state PDA so we need to derive this to create the - // signer seeds to execute the CPI transfer from the vault to the refund recipient. - let state_seed_bytes = state.seed.to_le_bytes(); - let seeds = &[b"state", state_seed_bytes.as_ref(), &[ctx.bumps.state]]; - let signer_seeds = &[&seeds[..]]; + if ctx.remaining_accounts.len() < relayer_refund_leaf.refund_addresses.len() { + return err!(ErrorCode::AccountNotEnoughKeys); + } - // Will include in the emitted event at the end if there are any claim accounts. - let mut deferred_refunds = false; + // Check if vault has sufficient balance for all the refunds. + let total_refund_amount: u64 = relayer_refund_leaf.refund_amounts.iter().sum(); + if ctx.accounts.vault.amount < total_refund_amount { + return err!(CommonError::InsufficientSpokePoolBalanceToExecuteLeaf); + } - for (i, amount) in relayer_refund_leaf.refund_amounts.iter().enumerate() { - let amount = *amount as u64; - - // Refund account holds either a regular token account or a claim account. This checks all required constraints. - // TODO: test ordering of the refund accounts and remaining accounts. - let refund_account = RefundAccount::try_from_remaining_account( - ctx.remaining_accounts, - i, - &relayer_refund_leaf.refund_accounts[i], - &ctx.accounts.mint.key(), - &ctx.accounts.token_program.key(), - )?; - - match refund_account { - // Valid token account was passed, transfer the refund atomically. - RefundAccount::TokenAccount(token_account) => { - let transfer_accounts = TransferChecked { - from: ctx.accounts.vault.to_account_info(), - mint: ctx.accounts.mint.to_account_info(), - to: token_account.to_account_info(), - authority: ctx.accounts.state.to_account_info(), - }; - let cpi_context = CpiContext::new_with_signer( - ctx.accounts.token_program.to_account_info(), - transfer_accounts, - signer_seeds, - ); - transfer_checked(cpi_context, amount, ctx.accounts.mint.decimals)?; - } - // Valid claim account was passed, increment the claim account amount. - RefundAccount::ClaimAccount(mut claim_account) => { - claim_account.amount += amount; - - // Indicate in the event at the end that some refunds have been deferred. - deferred_refunds = true; - - // Persist the updated claim account (Anchor handles this only for static accounts). - claim_account.exit(ctx.program_id)?; - } - } + // Depending on the called instruction flavor, we either accrue the refunds to claim accounts or transfer them. + match deferred_refunds { + true => accrue_relayer_refunds(&ctx, &relayer_refund_leaf)?, + false => distribute_relayer_refunds(&ctx, &relayer_refund_leaf)?, } if relayer_refund_leaf.amount_to_return > 0 { @@ -199,10 +165,81 @@ where root_bundle_id, leaf_id: relayer_refund_leaf.leaf_id, l2_token_address: ctx.accounts.mint.key(), - refund_addresses: relayer_refund_leaf.refund_accounts, + refund_addresses: relayer_refund_leaf.refund_addresses, deferred_refunds, caller: ctx.accounts.signer.key(), }); Ok(()) } + +fn distribute_relayer_refunds<'info>( + ctx: &Context<'_, '_, '_, 'info, ExecuteRelayerRefundLeaf<'info>>, + relayer_refund_leaf: &RelayerRefundLeaf, +) -> Result<()> { + // Derive the signer seeds for the state. The vault owns the state PDA so we need to derive this to create the + // signer seeds to execute the CPI transfer from the vault to the refund recipient's token account. + let state_seed_bytes = ctx.accounts.state.seed.to_le_bytes(); + let seeds = &[b"state", state_seed_bytes.as_ref(), &[ctx.bumps.state]]; + let signer_seeds = &[&seeds[..]]; + + for (i, amount) in relayer_refund_leaf.refund_amounts.iter().enumerate() { + // We only need to check the refund account matches the associated token address for the relayer. + // All other required checks are performed within the transfer CPI. We do not check the token account authority + // as the relayer might have transferred it to a multisig or any other wallet. + // It should be safe to access elements of refund_addresses and remaining_accounts as their lengths are checked + // before calling this internal function. + let refund_token_account = &ctx.remaining_accounts[i]; + let associated_token_address = associated_token::get_associated_token_address_with_program_id( + &relayer_refund_leaf.refund_addresses[i], + &ctx.accounts.mint.key(), + &ctx.accounts.token_program.key(), + ); + if refund_token_account.key() != associated_token_address { + return Err(Error::from(SvmError::InvalidRefund).with_account_name(&format!("remaining_accounts[{}]", i))); + } + + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + to: refund_token_account.to_account_info(), + authority: ctx.accounts.state.to_account_info(), + }; + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + signer_seeds, + ); + transfer_checked(cpi_context, amount.to_owned(), ctx.accounts.mint.decimals)?; + } + + Ok(()) +} + +fn accrue_relayer_refunds<'c, 'info>( + ctx: &Context<'_, '_, 'c, 'info, ExecuteRelayerRefundLeaf<'info>>, + relayer_refund_leaf: &RelayerRefundLeaf, +) -> Result<()> +where + 'c: 'info, +{ + for (i, amount) in relayer_refund_leaf.refund_amounts.iter().enumerate() { + // It should be safe to access elements of refund_addresses and remaining_accounts as their lengths are checked + // before calling this internal function. + let mut claim_account = ClaimAccount::try_from( + &ctx.remaining_accounts[i], + &relayer_refund_leaf.mint_public_key, + &relayer_refund_leaf.refund_addresses[i], + ) + .map_err(|e| e.with_account_name(&format!("remaining_accounts[{}]", i)))?; + + claim_account.amount += amount; + + // Persist the updated claim account (Anchor handles this only for static accounts). + claim_account + .exit(ctx.program_id) + .map_err(|e| e.with_account_name(&format!("remaining_accounts[{}]", i)))?; + } + + Ok(()) +} diff --git a/programs/svm-spoke/src/instructions/refund_claims.rs b/programs/svm-spoke/src/instructions/refund_claims.rs index 16486f578..09fb0fe56 100644 --- a/programs/svm-spoke/src/instructions/refund_claims.rs +++ b/programs/svm-spoke/src/instructions/refund_claims.rs @@ -9,7 +9,7 @@ use crate::{ }; #[derive(Accounts)] -#[instruction(mint: Pubkey, token_account: Pubkey)] +#[instruction(mint: Pubkey, refund_address: Pubkey)] pub struct InitializeClaimAccount<'info> { #[account(mut)] pub signer: Signer<'info>, @@ -18,7 +18,7 @@ pub struct InitializeClaimAccount<'info> { init, payer = signer, space = DISCRIMINATOR_SIZE + ClaimAccount::INIT_SPACE, - seeds = [b"claim_account", mint.as_ref(), token_account.as_ref()], + seeds = [b"claim_account", mint.as_ref(), refund_address.as_ref()], bump )] pub claim_account: Account<'info, ClaimAccount>, @@ -57,14 +57,15 @@ pub struct ClaimRelayerRefund<'info> { #[account(mint::token_program = token_program)] pub mint: InterfaceAccount<'info, Mint>, - // Token address has been checked when executing the relayer refund leaf and it is part of claim account derivation. + // This method allows relayer to claim refunds on any custom token account. #[account(mut, token::mint = mint, token::token_program = token_program)] pub token_account: InterfaceAccount<'info, TokenAccount>, + // Only relayer can claim the refund with this method as the claim account is derived from the relayer's address. #[account( mut, close = initializer, - seeds = [b"claim_account", mint.key().as_ref(), token_account.key().as_ref()], + seeds = [b"claim_account", mint.key().as_ref(), signer.key().as_ref()], bump )] pub claim_account: Account<'info, ClaimAccount>, @@ -102,7 +103,89 @@ pub fn claim_relayer_refund(ctx: Context) -> Result<()> { emit_cpi!(ClaimedRelayerRefund { l2_token_address: ctx.accounts.mint.key(), claim_amount, - refund_address: ctx.accounts.token_account.key(), + refund_address: ctx.accounts.signer.key(), + }); + + // There is no need to reset the claim amount as the account will be closed at the end of instruction. + + Ok(()) +} + +#[event_cpi] +#[derive(Accounts)] +#[instruction(refund_address: Pubkey)] +pub struct ClaimRelayerRefundFor<'info> { + pub signer: Signer<'info>, + + /// CHECK: We don't need any additional checks as long as this is the same account that initialized the claim account. + #[account(mut, address = claim_account.initializer @ SvmError::InvalidClaimInitializer)] + pub initializer: UncheckedAccount<'info>, + + #[account(seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] + pub state: Account<'info, State>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = state, + associated_token::token_program = token_program + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + // Mint address has been checked when executing the relayer refund leaf and it is part of claim account derivation. + #[account(mint::token_program = token_program)] + pub mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = refund_address, + associated_token::token_program = token_program + )] + pub token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + close = initializer, + seeds = [b"claim_account", mint.key().as_ref(), refund_address.as_ref()], + bump + )] + pub claim_account: Account<'info, ClaimAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn claim_relayer_refund_for(ctx: Context, refund_address: Pubkey) -> Result<()> { + // Ensure the claim account holds a non-zero amount. + let claim_amount = ctx.accounts.claim_account.amount; + if claim_amount == 0 { + return err!(SvmError::ZeroRefundClaim); + } + + // Derive the signer seeds for the state required for the transfer form vault. + let state_seed_bytes = ctx.accounts.state.seed.to_le_bytes(); + let seeds = &[b"state", state_seed_bytes.as_ref(), &[ctx.bumps.state]]; + let signer_seeds = &[&seeds[..]]; + + // Transfer the claim amount from the vault to the relayer token account. + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.token_account.to_account_info(), + authority: ctx.accounts.state.to_account_info(), + }; + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + signer_seeds, + ); + transfer_checked(cpi_context, claim_amount, ctx.accounts.mint.decimals)?; + + // Emit the ClaimedRelayerRefund event. + emit_cpi!(ClaimedRelayerRefund { + l2_token_address: ctx.accounts.mint.key(), + claim_amount, + refund_address, }); // There is no need to reset the claim amount as the account will be closed at the end of instruction. @@ -114,7 +197,7 @@ pub fn claim_relayer_refund(ctx: Context) -> Result<()> { // relayer refunds were executed with ATA after initializing the claim account. In such cases, the initializer should be // able to close the claim account manually. #[derive(Accounts)] -#[instruction(mint: Pubkey, token_account: Pubkey)] +#[instruction(mint: Pubkey, refund_address: Pubkey)] pub struct CloseClaimAccount<'info> { #[account(mut, address = claim_account.initializer @ SvmError::InvalidClaimInitializer)] pub signer: Signer<'info>, @@ -122,8 +205,7 @@ pub struct CloseClaimAccount<'info> { #[account( mut, close = signer, - // TODO: We can remove mint from seed derivation as token_account itself is derived from the mint. - seeds = [b"claim_account", mint.key().as_ref(), token_account.key().as_ref()], + seeds = [b"claim_account", mint.key().as_ref(), refund_address.key().as_ref()], bump )] pub claim_account: Account<'info, ClaimAccount>, diff --git a/programs/svm-spoke/src/lib.rs b/programs/svm-spoke/src/lib.rs index c7a913d1c..93b1cab4c 100644 --- a/programs/svm-spoke/src/lib.rs +++ b/programs/svm-spoke/src/lib.rs @@ -75,7 +75,16 @@ pub mod svm_spoke { where 'c: 'info, { - instructions::execute_relayer_refund_leaf(ctx) + instructions::execute_relayer_refund_leaf(ctx, false) + } + + pub fn execute_relayer_refund_leaf_deferred<'c, 'info>( + ctx: Context<'_, '_, 'c, 'info, ExecuteRelayerRefundLeaf<'info>>, + ) -> Result<()> + where + 'c: 'info, + { + instructions::execute_relayer_refund_leaf(ctx, true) } pub fn pause_fills(ctx: Context, pause: bool) -> Result<()> { @@ -209,7 +218,7 @@ pub mod svm_spoke { Ok(()) } - pub fn initialize_instruction_params(_ctx: Context, total_size: u32) -> Result<()> { + pub fn initialize_instruction_params(_ctx: Context, _total_size: u32) -> Result<()> { Ok(()) } @@ -228,7 +237,7 @@ pub mod svm_spoke { pub fn initialize_claim_account( ctx: Context, _mint: Pubkey, - _token_account: Pubkey, + _refund_address: Pubkey, ) -> Result<()> { instructions::initialize_claim_account(ctx) } @@ -237,10 +246,14 @@ pub mod svm_spoke { instructions::claim_relayer_refund(ctx) } + pub fn claim_relayer_refund_for(ctx: Context, refund_address: Pubkey) -> Result<()> { + instructions::claim_relayer_refund_for(ctx, refund_address) + } + pub fn close_claim_account( ctx: Context, - _mint: Pubkey, // Only used in account constraints. - _token_account: Pubkey, // Only used in account constraints. + _mint: Pubkey, // Only used in account constraints. + _refund_address: Pubkey, // Only used in account constraints. ) -> Result<()> { instructions::close_claim_account(ctx) } diff --git a/programs/svm-spoke/src/state/refund_account.rs b/programs/svm-spoke/src/state/refund_account.rs index aeb5fccfa..144f52a02 100644 --- a/programs/svm-spoke/src/state/refund_account.rs +++ b/programs/svm-spoke/src/state/refund_account.rs @@ -1,7 +1,4 @@ use anchor_lang::prelude::*; -use anchor_spl::token_interface::TokenAccount; - -use crate::error::SvmError; #[account] #[derive(InitSpace)] @@ -10,114 +7,35 @@ pub struct ClaimAccount { pub initializer: Pubkey, } -// When executing relayer refund leaf, refund accounts are passed as remaining accounts and can hold either a regular -// token account or a claim account. This enum is used to differentiate between the two types. -pub enum RefundAccount<'info> { - TokenAccount(InterfaceAccount<'info, TokenAccount>), - ClaimAccount(Account<'info, ClaimAccount>), -} -// TODO: consider if we can avoid this dual account and pass both ATA and claim account arrays. - -impl<'c, 'info> RefundAccount<'info> -where - 'c: 'info, -{ - // This function is used to parse a refund account from the remaining accounts list. It first tries to parse it as - // a token account and if that fails, it tries to parse it as a claim account. - pub fn try_from_remaining_account( - remaining_accounts: &'c [AccountInfo<'info>], - index: usize, - expected_token_account: &Pubkey, - expected_mint: &Pubkey, - token_program: &Pubkey, - ) -> Result { - let refund_account_info = remaining_accounts.get(index).ok_or(ErrorCode::AccountNotEnoughKeys)?; - - Self::try_token_account_from_account_info( - refund_account_info, - expected_token_account, - expected_mint, - token_program, - ) - .map(Self::TokenAccount) - .or_else(|| { - Self::try_claim_account_from_account_info(refund_account_info, expected_mint, expected_token_account) - .map(Self::ClaimAccount) - }) - .ok_or_else(|| { - error::Error::from(SvmError::InvalidRefund).with_account_name(&format!("remaining_accounts[{}]", index)) - }) - } - - // This implements the following Anchor account constraints when parsing remaining account as a token account: - // #[account( - // mut, - // address = expected_token_account @ SvmError::InvalidRefund, - // token::mint = expected_mint, - // token::token_program = token_program - // )] - // pub token_account: InterfaceAccount<'info, TokenAccount>, - // Note: All errors are ignored and Option is returned as we do not log them anyway due to memory constraints. - fn try_token_account_from_account_info( - account_info: &'info AccountInfo<'info>, - expected_token_account: &Pubkey, - expected_mint: &Pubkey, - token_program: &Pubkey, - ) -> Option> { - // Checks ownership on deserialization for the TokenAccount interface. - let token_account: InterfaceAccount<'info, TokenAccount> = InterfaceAccount::try_from(account_info).ok()?; - - // Checks if the token account is writable. - if !account_info.is_writable { - return None; - } - - // Checks the token address matches. - if account_info.key != expected_token_account { - return None; - } - - // Checks if the token account is associated with the expected mint. - if &token_account.mint != expected_mint { - return None; - } - - // Checks ownership by specific token program. - if account_info.owner != token_program { - return None; - } - - Some(token_account) - } - - // This implements the following Anchor account constraints when parsing remaining account as a claim account: - // #[account( - // mut, - // seeds = [b"claim_account", mint.key().as_ref(), token_account.key().as_ref()], - // bump - // )] - // pub claim_account: Account<'info, ClaimAccount>, - // Note: All errors are ignored and Option is returned as we do not log them anyway due to memory constraints. - fn try_claim_account_from_account_info( +// This implements the following Anchor account constraints when parsing remaining account as a claim account: +// #[account( +// mut, +// seeds = [b"claim_account", mint.key().as_ref(), refund_address.key().as_ref()], +// bump +// )] +// pub claim_account: Account<'info, ClaimAccount>, +// Note: Account name should be appended to any possible errors by the caller. +impl<'info> ClaimAccount { + pub fn try_from( account_info: &'info AccountInfo<'info>, mint: &Pubkey, - token_account: &Pubkey, - ) -> Option> { + refund_address: &Pubkey, + ) -> Result> { // Checks ownership on deserialization for the ClaimAccount. - let claim_account: Account<'info, ClaimAccount> = Account::try_from(account_info).ok()?; + let claim_account: Account<'info, ClaimAccount> = Account::try_from(account_info)?; - // Checks the PDA is derived from mint and token account keys. + // Checks the PDA is derived from mint and refund address keys. let (pda_address, _bump) = - Pubkey::find_program_address(&[b"claim_account", mint.as_ref(), token_account.as_ref()], &crate::ID); - if account_info.key != &pda_address { - return None; + Pubkey::find_program_address(&[b"claim_account", mint.as_ref(), refund_address.as_ref()], &crate::ID); + if account_info.key() != pda_address { + return Err(Error::from(ErrorCode::ConstraintSeeds).with_pubkeys((claim_account.key(), pda_address))); } // Checks if the claim account is writable. if !account_info.is_writable { - return None; + return Err(Error::from(ErrorCode::ConstraintMut)); } - Some(claim_account) + Ok(claim_account) } } diff --git a/scripts/svm/simpleFakeRelayerRepayment.ts b/scripts/svm/simpleFakeRelayerRepayment.ts index e663c4198..4d18a1f9c 100644 --- a/scripts/svm/simpleFakeRelayerRepayment.ts +++ b/scripts/svm/simpleFakeRelayerRepayment.ts @@ -112,7 +112,8 @@ async function testBundleLogic(): Promise { .rpc(); console.log(`Deposit transaction sent: ${depositTx}`); - // Create a single repayment leaf with the array of amounts and corresponding refund accounts + // Create a single repayment leaf with the array of amounts and corresponding refund addresses + const refundAddresses: PublicKey[] = []; const refundAccounts: PublicKey[] = []; for (let i = 0; i < amounts.length; i++) { const recipient = Keypair.generate(); @@ -122,6 +123,7 @@ async function testBundleLogic(): Promise { inputToken, recipient.publicKey ); + refundAddresses.push(recipient.publicKey); refundAccounts.push(refundAccount); console.log( `Created refund account for recipient ${ @@ -138,7 +140,7 @@ async function testBundleLogic(): Promise { chainId: new BN((await program.account.state.fetch(statePda)).chainId), // set chainId to svm spoke chainId. amountToReturn: new BN(0), mintPublicKey: inputToken, - refundAccounts: refundAccounts, // Array of refund accounts + refundAddresses, // Array of refund authority addresses refundAmounts: amounts, // Array of amounts }; diff --git a/test/svm/SvmSpoke.Bundle.ts b/test/svm/SvmSpoke.Bundle.ts index ada4800d5..80690a889 100644 --- a/test/svm/SvmSpoke.Bundle.ts +++ b/test/svm/SvmSpoke.Bundle.ts @@ -32,12 +32,6 @@ import { MerkleTree } from "../../utils"; const { provider, program, owner, initializeState, connection, chainId, assertSE } = common; -enum RefundType { - TokenAccounts, - ClaimAccounts, - MixedAccounts, -} - describe("svm_spoke.bundle", () => { anchor.setProvider(provider); @@ -212,7 +206,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn: new BN(69420), mintPublicKey: mint, - refundAccounts: [relayerTA, relayerTB], + refundAddresses: [relayerA.publicKey, relayerB.publicKey], refundAmounts: [relayerARefund, relayerBRefund], }); @@ -276,8 +270,8 @@ describe("svm_spoke.bundle", () => { assertSE(event.rootBundleId, stateAccountData.rootBundleId, "rootBundleId should match"); assertSE(event.leafId, leaf.leafId, "leafId should match"); assertSE(event.l2TokenAddress, mint, "l2TokenAddress should match"); - assertSE(event.refundAddresses[0], relayerTA, "Relayer A address should match"); - assertSE(event.refundAddresses[1], relayerTB, "Relayer B address should match"); + assertSE(event.refundAddresses[0], relayerA.publicKey, "Relayer A address should match"); + assertSE(event.refundAddresses[1], relayerB.publicKey, "Relayer B address should match"); assert.isFalse(event.deferredRefunds, "deferredRefunds should be false"); assertSE(event.caller, owner, "caller should match"); @@ -326,7 +320,7 @@ describe("svm_spoke.bundle", () => { mixLeaves: false, chainId: chainId.toNumber(), mint, - svmRelayers: [relayerTA, relayerTB], + svmRelayers: [relayerA.publicKey, relayerB.publicKey], svmRefundAmounts: [new BN(randomBigInt(2).toString()), new BN(randomBigInt(2).toString())], }); @@ -336,7 +330,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn: new BN(0), mintPublicKey: mint, - refundAccounts: [relayerTA, relayerTB], + refundAddresses: [relayerA.publicKey, relayerB.publicKey], refundAmounts: [new BN(randomBigInt(2).toString()), new BN(randomBigInt(2).toString())], } as RelayerRefundLeafSolana; @@ -390,6 +384,7 @@ describe("svm_spoke.bundle", () => { .accounts(executeRelayerRefundLeafAccounts) .remainingAccounts(wrongRemainingAccounts) .rpc(); + assert.fail("Should not execute to invalid refund address"); } catch (err: any) { assert.include(err.toString(), "Invalid refund address"); } @@ -472,7 +467,7 @@ describe("svm_spoke.bundle", () => { mixLeaves: true, chainId: chainId.toNumber(), mint, - svmRelayers: [relayerTA, relayerTB], + svmRelayers: [relayerA.publicKey, relayerB.publicKey], svmRefundAmounts: [new BN(randomBigInt(2).toString()), new BN(randomBigInt(2).toString())], }); @@ -567,7 +562,7 @@ describe("svm_spoke.bundle", () => { mixLeaves: false, chainId: chainId.toNumber(), mint, - svmRelayers: [relayerTA, relayerTB], + svmRelayers: [relayerA.publicKey, relayerB.publicKey], svmRefundAmounts: [new BN(randomBigInt(2).toString()), new BN(randomBigInt(2).toString())], }); @@ -665,7 +660,7 @@ describe("svm_spoke.bundle", () => { chainId: new BN(1000), amountToReturn: new BN(0), mintPublicKey: mint, - refundAccounts: [relayerTA, relayerTB], + refundAddresses: [relayerA.publicKey, relayerB.publicKey], refundAmounts: [relayerARefund, relayerBRefund], }); @@ -727,7 +722,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn: new BN(0), mintPublicKey: Keypair.generate().publicKey, - refundAccounts: [relayerTA, relayerTB], + refundAddresses: [relayerA.publicKey, relayerB.publicKey], refundAmounts: [relayerARefund, relayerBRefund], }); @@ -790,7 +785,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn: new BN(0), mintPublicKey: mint, - refundAccounts: [relayerTA], + refundAddresses: [relayerA.publicKey], refundAmounts: [relayerRefundAmount], }); } @@ -991,46 +986,43 @@ describe("svm_spoke.bundle", () => { }); describe("Execute Max Refunds", () => { - const executeMaxRefunds = async (refundType: RefundType) => { - // Higher refund count hits inner instruction size limit when doing `emit_cpi` on public devnet. On localnet this is - // not an issue, but we hit out of memory panic above 31 refunds. This should not be an issue as currently Across - // protocol does not expect this to be above 25. - const solanaDistributions = 28; - + const executeMaxRefunds = async (testConfig: { + solanaDistributions: number; + deferredRefunds: boolean; + atomicAccountCreation: boolean; + }) => { + assert.isTrue( + !(testConfig.deferredRefunds && testConfig.atomicAccountCreation), + "Incompatible test configuration" + ); // Add leaves for other EVM chains to have non-empty proofs array to ensure we don't run out of memory when processing. const evmDistributions = 100; // This would fit in 7 proof array elements. const maxExtendedAccounts = 30; // Maximum number of accounts that can be added to ALT in a single transaction. const refundAccounts: web3.PublicKey[] = []; // These would hold either token accounts or claim accounts. - const tokenAccounts: web3.PublicKey[] = []; // These are used in leaf building. + const refundAddresses: web3.PublicKey[] = []; // These are relayer authority addresses used in leaf building. const refundAmounts: BN[] = []; - for (let i = 0; i < solanaDistributions; i++) { + for (let i = 0; i < testConfig.solanaDistributions; i++) { // Will create token account later if needed. const tokenOwner = Keypair.generate().publicKey; const tokenAccount = getAssociatedTokenAddressSync(mint, tokenOwner); - tokenAccounts.push(tokenAccount); + refundAddresses.push(tokenOwner); const [claimAccount] = PublicKey.findProgramAddressSync( - [Buffer.from("claim_account"), mint.toBuffer(), tokenAccount.toBuffer()], + [Buffer.from("claim_account"), mint.toBuffer(), tokenOwner.toBuffer()], program.programId ); - if (refundType === RefundType.TokenAccounts) { + if (!testConfig.deferredRefunds && !testConfig.atomicAccountCreation) { await getOrCreateAssociatedTokenAccount(connection, payer, mint, tokenOwner); refundAccounts.push(tokenAccount); - } else if (refundType === RefundType.ClaimAccounts) { - await program.methods.initializeClaimAccount(mint, tokenAccount).rpc(); + } else if (!testConfig.deferredRefunds && testConfig.atomicAccountCreation) { + refundAccounts.push(tokenAccount); + } else { + await program.methods.initializeClaimAccount(mint, tokenOwner).rpc(); refundAccounts.push(claimAccount); - } else if (refundType === RefundType.MixedAccounts) { - if (i % 2 === 0) { - await getOrCreateAssociatedTokenAccount(connection, payer, mint, tokenOwner); - refundAccounts.push(tokenAccount); - } else { - await program.methods.initializeClaimAccount(mint, tokenAccount).rpc(); - refundAccounts.push(claimAccount); - } } refundAmounts.push(new BN(randomBigInt(2).toString())); @@ -1038,11 +1030,11 @@ describe("svm_spoke.bundle", () => { const { relayerRefundLeaves, merkleTree } = buildRelayerRefundMerkleTree({ totalEvmDistributions: evmDistributions, - totalSolanaDistributions: solanaDistributions, + totalSolanaDistributions: testConfig.solanaDistributions, mixLeaves: false, chainId: chainId.toNumber(), mint, - svmRelayers: tokenAccounts, + svmRelayers: refundAddresses, svmRefundAmounts: refundAmounts, }); @@ -1087,14 +1079,21 @@ describe("svm_spoke.bundle", () => { program: program.programId, }; - const remainingAccounts = refundAccounts.map((account) => ({ + const executeRemainingAccounts = refundAccounts.map((account) => ({ pubkey: account, isWritable: true, isSigner: false, })); + const createTokenAccountsRemainingAccounts = testConfig.atomicAccountCreation + ? refundAddresses.flatMap((authority, index) => [ + { pubkey: authority, isWritable: false, isSigner: false }, + { pubkey: refundAccounts[index], isWritable: true, isSigner: false }, + ]) + : []; + // Consolidate all above addresses into a single array for the Address Lookup Table (ALT). - const lookupAddresses = [...Object.values(staticAccounts), ...refundAccounts]; + const lookupAddresses = [...Object.values(staticAccounts), ...refundAddresses, ...refundAccounts]; // Create instructions for creating and extending the ALT. const [lookupTableInstruction, lookupTableAddress] = await AddressLookupTableProgram.createLookupTable({ @@ -1132,21 +1131,41 @@ describe("svm_spoke.bundle", () => { // Build the instruction to execute relayer refund leaf and write its instruction args to the data account. await loadExecuteRelayerRefundLeafParams(program, owner, stateAccountData.rootBundleId, leaf, proofAsNumbers); - const executeInstruction = await program.methods - .executeRelayerRefundLeaf() - .accounts(staticAccounts) - .remainingAccounts(remainingAccounts) - .instruction(); + const executeInstruction = !testConfig.deferredRefunds + ? await program.methods + .executeRelayerRefundLeaf() + .accounts(staticAccounts) + .remainingAccounts(executeRemainingAccounts) + .instruction() + : await program.methods + .executeRelayerRefundLeafDeferred() + .accounts(staticAccounts) + .remainingAccounts(executeRemainingAccounts) + .instruction(); // Build the instruction to increase the CU limit as the default 200k is not sufficient. const computeBudgetInstruction = ComputeBudgetProgram.setComputeUnitLimit({ units: 1_400_000 }); + // Insert atomic ATA creation if needed. + const instructions = [computeBudgetInstruction]; + if (testConfig.atomicAccountCreation) + instructions.push( + await program.methods + .createTokenAccounts() + .accounts({ mint, tokenProgram: TOKEN_PROGRAM_ID }) + .remainingAccounts(createTokenAccountsRemainingAccounts) + .instruction() + ); + + // Add relay refund leaf execution instruction. + instructions.push(executeInstruction); + // Create the versioned transaction const versionedTx = new VersionedTransaction( new TransactionMessage({ payerKey: owner, recentBlockhash: (await connection.getLatestBlockhash()).blockhash, - instructions: [computeBudgetInstruction, executeInstruction], + instructions, }).compileToV0Message([lookupTableAccount]) ); @@ -1157,15 +1176,11 @@ describe("svm_spoke.bundle", () => { // Verify all refund account balances (either token or claim accounts). await new Promise((resolve) => setTimeout(resolve, 1000)); // Make sure account balances have been synced. const refundBalances = await Promise.all( - refundAccounts.map(async (account, i) => { - if (refundType === RefundType.TokenAccounts) { + refundAccounts.map(async (account) => { + if (!testConfig.deferredRefunds) { return (await connection.getTokenAccountBalance(account)).value.amount; - } else if (refundType === RefundType.ClaimAccounts) { + } else { return (await program.account.claimAccount.fetch(account)).amount.toString(); - } else if (refundType === RefundType.MixedAccounts) { - return i % 2 === 0 - ? (await connection.getTokenAccountBalance(account)).value.amount - : (await program.account.claimAccount.fetch(account)).amount.toString(); } }) ); @@ -1175,15 +1190,25 @@ describe("svm_spoke.bundle", () => { }; it("Execute Max Refunds to Token Accounts", async () => { - await executeMaxRefunds(RefundType.TokenAccounts); + // Higher refund count hits inner instruction size limit when doing `emit_cpi` on public devnet. On localnet this is + // not an issue, but we hit out of memory panic above 32 refunds. This should not be an issue as currently Across + // protocol does not expect this to be above 25. + const solanaDistributions = 28; + + await executeMaxRefunds({ solanaDistributions, deferredRefunds: false, atomicAccountCreation: false }); }); - it("Execute Max Refunds to Claim Accounts", async () => { - await executeMaxRefunds(RefundType.ClaimAccounts); + it("Execute Max Refunds to Token Accounts with atomic ATA creation", async () => { + // Higher refund count hits maximum instruction trace length limit. + const solanaDistributions = 9; + + await executeMaxRefunds({ solanaDistributions, deferredRefunds: false, atomicAccountCreation: true }); }); - it("Execute Max Refunds to Mixed Accounts", async () => { - await executeMaxRefunds(RefundType.MixedAccounts); + it("Execute Max Refunds to Claim Accounts", async () => { + const solanaDistributions = 28; + + await executeMaxRefunds({ solanaDistributions, deferredRefunds: true, atomicAccountCreation: false }); }); }); @@ -1198,7 +1223,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn, mintPublicKey: mint, - refundAccounts: [], + refundAddresses: [], refundAmounts: [], }); const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); @@ -1269,7 +1294,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn: new BN(0), mintPublicKey: mint, - refundAccounts: [relayerTA], + refundAddresses: [relayerA.publicKey], refundAmounts: [relayerRefundAmount], }); } @@ -1342,7 +1367,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn: new BN(0), mintPublicKey: mint, - refundAccounts: [relayerTA, relayerTB], + refundAddresses: [relayerA.publicKey, relayerB.publicKey], refundAmounts: [relayerARefund], }); @@ -1398,44 +1423,31 @@ describe("svm_spoke.bundle", () => { }); describe("Deferred refunds in ExecutedRelayerRefundRoot events", () => { - const executeRelayerRefundLeaf = async (refundType: RefundType) => { + const executeRelayerRefundLeaf = async (testConfig: { deferredRefunds: boolean }) => { // Create new relayer accounts for each sub-test. const relayerA = Keypair.generate(); const relayerB = Keypair.generate(); const relayerARefund = new BN(400000); const relayerBRefund = new BN(100000); - let relayerTA: PublicKey, relayerTB: PublicKey, refundA: PublicKey, refundB: PublicKey; + let refundA: PublicKey, refundB: PublicKey; // Create refund accounts depending on the refund type. - if (refundType === RefundType.TokenAccounts) { - relayerTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayerA.publicKey)).address; - relayerTB = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayerB.publicKey)).address; - refundA = relayerTA; - refundB = relayerTB; - } else if (refundType === RefundType.ClaimAccounts) { - relayerTA = getAssociatedTokenAddressSync(mint, relayerA.publicKey); - relayerTB = getAssociatedTokenAddressSync(mint, relayerB.publicKey); + if (!testConfig.deferredRefunds) { + refundA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayerA.publicKey)).address; + refundB = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayerB.publicKey)).address; + } else { [refundA] = PublicKey.findProgramAddressSync( - [Buffer.from("claim_account"), mint.toBuffer(), relayerTA.toBuffer()], + [Buffer.from("claim_account"), mint.toBuffer(), relayerA.publicKey.toBuffer()], program.programId ); [refundB] = PublicKey.findProgramAddressSync( - [Buffer.from("claim_account"), mint.toBuffer(), relayerTB.toBuffer()], + [Buffer.from("claim_account"), mint.toBuffer(), relayerB.publicKey.toBuffer()], program.programId ); - await program.methods.initializeClaimAccount(mint, relayerTA).rpc(); - await program.methods.initializeClaimAccount(mint, relayerTB).rpc(); - } else if (refundType === RefundType.MixedAccounts) { - relayerTA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayerA.publicKey)).address; - relayerTB = getAssociatedTokenAddressSync(mint, relayerB.publicKey); - refundA = relayerTA; - [refundB] = PublicKey.findProgramAddressSync( - [Buffer.from("claim_account"), mint.toBuffer(), relayerTB.toBuffer()], - program.programId - ); - await program.methods.initializeClaimAccount(mint, relayerTB).rpc(); - } else throw new Error("Invalid refund type"); // Required to infer all accounts have been assigned. + await program.methods.initializeClaimAccount(mint, relayerA.publicKey).rpc(); + await program.methods.initializeClaimAccount(mint, relayerB.publicKey).rpc(); + } // Prepare leaf using token accounts. const relayerRefundLeaves: RelayerRefundLeafType[] = []; @@ -1445,7 +1457,7 @@ describe("svm_spoke.bundle", () => { chainId: chainId, amountToReturn: new BN(0), mintPublicKey: mint, - refundAccounts: [relayerTA, relayerTB], + refundAddresses: [relayerA.publicKey, relayerB.publicKey], refundAmounts: [relayerARefund, relayerBRefund], }); @@ -1488,15 +1500,23 @@ describe("svm_spoke.bundle", () => { const proofAsNumbers = proof.map((p) => Array.from(p)); await loadExecuteRelayerRefundLeafParams(program, owner, stateAccountData.rootBundleId, leaf, proofAsNumbers); - return await program.methods - .executeRelayerRefundLeaf() - .accounts(executeRelayerRefundLeafAccounts) - .remainingAccounts(remainingAccounts) - .rpc(); + if (!testConfig.deferredRefunds) { + return await program.methods + .executeRelayerRefundLeaf() + .accounts(executeRelayerRefundLeafAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + } else { + return await program.methods + .executeRelayerRefundLeafDeferred() + .accounts(executeRelayerRefundLeafAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + } }; it("No deferred refunds in all Token Accounts", async () => { - const tx = await executeRelayerRefundLeaf(RefundType.TokenAccounts); + const tx = await executeRelayerRefundLeaf({ deferredRefunds: false }); await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for event processing const events = await readEvents(connection, tx, [program]); @@ -1505,21 +1525,81 @@ describe("svm_spoke.bundle", () => { }); it("Deferred refunds in all Claim Accounts", async () => { - const tx = await executeRelayerRefundLeaf(RefundType.ClaimAccounts); + const tx = await executeRelayerRefundLeaf({ deferredRefunds: true }); await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for event processing const events = await readEvents(connection, tx, [program]); const event = events.find((event) => event.name === "executedRelayerRefundRoot").data; assert.isTrue(event.deferredRefunds, "deferredRefunds should be true"); }); + }); - it("Deferred refunds in Mixed Accounts", async () => { - const tx = await executeRelayerRefundLeaf(RefundType.MixedAccounts); + it("Cannot execute relayer refund leaf with insufficient pool balance", async () => { + const vaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; - await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for event processing - const events = await readEvents(connection, tx, [program]); - const event = events.find((event) => event.name === "executedRelayerRefundRoot").data; - assert.isTrue(event.deferredRefunds, "deferredRefunds should be true"); + // Create a leaf with relayer refund amount larger than as vault balance. + const relayerRefundLeaves: RelayerRefundLeafType[] = []; + const relayerARefund = new BN(vaultBal).add(new BN(1)); + + relayerRefundLeaves.push({ + isSolana: true, + leafId: new BN(0), + chainId: chainId, + amountToReturn: new BN(0), + mintPublicKey: mint, + refundAddresses: [relayerA.publicKey], + refundAmounts: [relayerARefund], }); + + const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); + + const root = merkleTree.getRoot(); + const proof = merkleTree.getProof(relayerRefundLeaves[0]); + const leaf = relayerRefundLeaves[0] as RelayerRefundLeafSolana; + + const stateAccountData = await program.account.state.fetch(state); + const rootBundleId = stateAccountData.rootBundleId; + + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), state.toBuffer(), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + + // Relay root bundle + const relayRootBundleAccounts = { state, rootBundle, signer: owner, payer: owner, program: program.programId }; + await program.methods.relayRootBundle(Array.from(root), Array.from(root)).accounts(relayRootBundleAccounts).rpc(); + + const remainingAccounts = [{ pubkey: relayerTA, isWritable: true, isSigner: false }]; + + const executeRelayerRefundLeafAccounts = { + state, + rootBundle, + signer: owner, + vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint, + transferLiability, + systemProgram: web3.SystemProgram.programId, + program: program.programId, + }; + const proofAsNumbers = proof.map((p) => Array.from(p)); + await loadExecuteRelayerRefundLeafParams(program, owner, stateAccountData.rootBundleId, leaf, proofAsNumbers); + + // Leaf execution should fail due to insufficient balance. + try { + await program.methods + .executeRelayerRefundLeaf() + .accounts(executeRelayerRefundLeafAccounts) + .remainingAccounts(remainingAccounts) + .rpc(); + assert.fail("Leaf execution should fail due to insufficient pool balance"); + } catch (err: any) { + assert.instanceOf(err, anchor.AnchorError); + assert.strictEqual( + err.error.errorCode.code, + "InsufficientSpokePoolBalanceToExecuteLeaf", + "Expected error code InsufficientSpokePoolBalanceToExecuteLeaf" + ); + } }); }); diff --git a/test/svm/SvmSpoke.RefundClaims.ts b/test/svm/SvmSpoke.RefundClaims.ts index 16348be5c..780da21bf 100644 --- a/test/svm/SvmSpoke.RefundClaims.ts +++ b/test/svm/SvmSpoke.RefundClaims.ts @@ -29,12 +29,24 @@ describe("svm_spoke.refund_claims", () => { vault: PublicKey, transferLiability: PublicKey; + let claimRelayerRefundAccounts: { + signer: PublicKey; + initializer: PublicKey; + state: PublicKey; + vault: PublicKey; + mint: PublicKey; + tokenAccount: PublicKey; + claimAccount: PublicKey; + tokenProgram: PublicKey; + program: PublicKey; + }; + const payer = (AnchorProvider.env().wallet as Wallet).payer; const initialMintAmount = 10_000_000_000; const initializeClaimAccount = async (initializer = claimInitializer) => { const initializeClaimAccountIx = await program.methods - .initializeClaimAccount(mint, tokenAccount) + .initializeClaimAccount(mint, relayer.publicKey) .accounts({ signer: initializer.publicKey }) .instruction(); await web3.sendAndConfirmTransaction(connection, new web3.Transaction().add(initializeClaimAccountIx), [ @@ -59,7 +71,7 @@ describe("svm_spoke.refund_claims", () => { chainId: chainId, amountToReturn: new BN(0), mintPublicKey: mint, - refundAccounts: [tokenAccount], + refundAddresses: [relayer.publicKey], refundAmounts: [relayerRefund], }); @@ -102,7 +114,7 @@ describe("svm_spoke.refund_claims", () => { const proofAsNumbers = proof.map((p) => Array.from(p)); await loadExecuteRelayerRefundLeafParams(program, owner, stateAccountData.rootBundleId, leaf, proofAsNumbers); await program.methods - .executeRelayerRefundLeaf() + .executeRelayerRefundLeafDeferred() .accounts(executeRelayerRefundLeafAccounts) .remainingAccounts(remainingAccounts) .rpc(); @@ -116,33 +128,30 @@ describe("svm_spoke.refund_claims", () => { assertSE(BigInt(fRefundLiability) - BigInt(iRefundLiability), relayerRefund, "Refund liability"); }; - const claimRelayerRefund = async (initializer = claimInitializer.publicKey) => { - const claimRelayerRefundAccounts = { - signer: owner, - initializer, - state, - vault, - mint, - tokenAccount, - claimAccount, - tokenProgram: TOKEN_PROGRAM_ID, - program: program.programId, - }; - await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc(); - }; - beforeEach(async () => { state = await initializeState(); mint = await createMint(connection, payer, owner, owner, 6); tokenAccount = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayer.publicKey)).address; [claimAccount] = PublicKey.findProgramAddressSync( - [Buffer.from("claim_account"), mint.toBuffer(), tokenAccount.toBuffer()], + [Buffer.from("claim_account"), mint.toBuffer(), relayer.publicKey.toBuffer()], program.programId ); vault = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, state, true)).address; + claimRelayerRefundAccounts = { + signer: owner, + initializer: claimInitializer.publicKey, + state, + vault, + mint, + tokenAccount, + claimAccount, + tokenProgram: TOKEN_PROGRAM_ID, + program: program.programId, + }; + const sig = await connection.requestAirdrop(claimInitializer.publicKey, 10_000_000_000); await provider.connection.confirmTransaction(sig); @@ -155,7 +164,7 @@ describe("svm_spoke.refund_claims", () => { ); }); - it("Claim Single Relayer Refund", async () => { + it("Claim on behalf of single relayer", async () => { // Execute relayer refund using claim account. const relayerRefund = new BN(500000); await executeRelayerRefundToClaim(relayerRefund); @@ -164,7 +173,7 @@ describe("svm_spoke.refund_claims", () => { const iRelayerBal = (await connection.getTokenAccountBalance(tokenAccount)).value.amount; // Claim refund for the relayer. - await claimRelayerRefund(); + await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); // The relayer should have received funds from the vault. const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; @@ -178,7 +187,7 @@ describe("svm_spoke.refund_claims", () => { const event = events.find((event) => event.name === "claimedRelayerRefund").data; assertSE(event.l2TokenAddress, mint, "l2TokenAddress should match"); assertSE(event.claimAmount, relayerRefund, "Relayer refund amount should match"); - assertSE(event.refundAddress, tokenAccount, "Relayer refund address should match"); + assertSE(event.refundAddress, relayer.publicKey, "Relayer refund address should match"); }); it("Cannot Double Claim Relayer Refund", async () => { @@ -187,11 +196,11 @@ describe("svm_spoke.refund_claims", () => { await executeRelayerRefundToClaim(relayerRefund); // Claim refund for the relayer. - await claimRelayerRefund(); + await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); // The claim account should have been automatically closed, so repeated claim should fail. try { - await claimRelayerRefund(); + await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); assert.fail("Claiming refund from closed account should fail"); } catch (error: any) { assert.instanceOf(error, AnchorError); @@ -205,7 +214,7 @@ describe("svm_spoke.refund_claims", () => { // After reinitalizing the claim account, the repeated claim should still fail. await initializeClaimAccount(); try { - await claimRelayerRefund(); + await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); assert.fail("Claiming refund from reinitalized account should fail"); } catch (error: any) { assert.instanceOf(error, AnchorError); @@ -224,7 +233,7 @@ describe("svm_spoke.refund_claims", () => { const iRelayerBal = (await connection.getTokenAccountBalance(tokenAccount)).value.amount; // Claim refund for the relayer. - await claimRelayerRefund(); + await await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); // The relayer should have received both refunds. const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; @@ -249,7 +258,7 @@ describe("svm_spoke.refund_claims", () => { // Claiming with default initializer should fail. try { - await claimRelayerRefund(); + await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); } catch (error: any) { assert.instanceOf(error, AnchorError); assert.strictEqual( @@ -260,7 +269,8 @@ describe("svm_spoke.refund_claims", () => { } // Claim refund for the relayer passing the correct initializer account. - await claimRelayerRefund(anotherInitializer.publicKey); + claimRelayerRefundAccounts.initializer = anotherInitializer.publicKey; + await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); // The relayer should have received funds from the vault. const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; @@ -275,7 +285,7 @@ describe("svm_spoke.refund_claims", () => { // Should not be able to close the claim account from default wallet as the initializer was different. try { - await program.methods.closeClaimAccount(mint, tokenAccount).accounts({ signer: payer.publicKey }).rpc(); + await program.methods.closeClaimAccount(mint, relayer.publicKey).accounts({ signer: payer.publicKey }).rpc(); assert.fail("Closing claim account from different initializer should fail"); } catch (error: any) { assert.instanceOf(error, AnchorError); @@ -288,7 +298,7 @@ describe("svm_spoke.refund_claims", () => { // Close the claim account from initializer before executing relayer refunds. await program.methods - .closeClaimAccount(mint, tokenAccount) + .closeClaimAccount(mint, relayer.publicKey) .accounts({ signer: claimInitializer.publicKey }) .signers([claimInitializer]) .rpc(); @@ -310,7 +320,7 @@ describe("svm_spoke.refund_claims", () => { // It should be not possible to close the claim account with non-zero refund liability. try { await program.methods - .closeClaimAccount(mint, tokenAccount) + .closeClaimAccount(mint, relayer.publicKey) .accounts({ signer: claimInitializer.publicKey }) .signers([claimInitializer]) .rpc(); @@ -320,4 +330,75 @@ describe("svm_spoke.refund_claims", () => { assert.strictEqual(error.error.errorCode.code, "NonZeroRefundClaim", "Expected error code NonZeroRefundClaim"); } }); + + it("Cannot claim refund on behalf of relayer to wrong token account", async () => { + // Execute relayer refund using claim account. + const relayerRefund = new BN(500000); + await executeRelayerRefundToClaim(relayerRefund); + + // Claim refund for the relayer to a custom token account. + const wrongOwner = Keypair.generate().publicKey; + const wrongTokenAccount = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, wrongOwner)).address; + claimRelayerRefundAccounts.tokenAccount = wrongTokenAccount; + + try { + await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc(); + assert.fail("Claiming refund to custom token account should fail"); + } catch (error: any) { + assert.instanceOf(error, AnchorError); + assert.strictEqual( + error.error.errorCode.code, + "ConstraintTokenOwner", + "Expected error code ConstraintTokenOwner" + ); + } + }); + + it("Relayer can claim refunds to custom token account", async () => { + // Execute relayer refund using claim account. + const relayerRefund = new BN(500000); + await executeRelayerRefundToClaim(relayerRefund); + + const iVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const iRelayerBal = (await connection.getTokenAccountBalance(tokenAccount)).value.amount; + + // Create custom token account for the relayer (no need to be controlled by the relayer) + const anotherOwner = Keypair.generate().publicKey; + const customTokenAccount = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, anotherOwner)).address; + claimRelayerRefundAccounts.tokenAccount = customTokenAccount; + claimRelayerRefundAccounts.signer = relayer.publicKey; // Only relayer itself should be able to do this. + + // Relayer can claim refund to custom token account. + await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).signers([relayer]).rpc(); + + // The relayer should have received funds from the vault. + const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount; + const fRelayerBal = (await connection.getTokenAccountBalance(customTokenAccount)).value.amount; + assertSE(BigInt(iVaultBal) - BigInt(fVaultBal), relayerRefund, "Vault balance"); + assertSE(BigInt(fRelayerBal) - BigInt(iRelayerBal), relayerRefund, "Relayer balance"); + + // Verify the ClaimedRelayerRefund event + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for event processing + const events = await readProgramEvents(connection, program); + const event = events.find((event) => event.name === "claimedRelayerRefund").data; + assertSE(event.l2TokenAddress, mint, "l2TokenAddress should match"); + assertSE(event.claimAmount, relayerRefund, "Relayer refund amount should match"); + assertSE(event.refundAddress, relayer.publicKey, "Relayer refund address should match"); + }); + + it("Cannot claim relayer refunds with the wrong signer", async () => { + // Execute relayer refund using claim account. + const relayerRefund = new BN(500000); + await executeRelayerRefundToClaim(relayerRefund); + + // Claim refund for the relayer with the default signer should fail as relayer address is part of claim account derivation. + try { + await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc(); + assert.fail("Claiming refund with wrong signer should fail"); + } catch (error: any) { + assert.instanceOf(error, AnchorError); + assert.strictEqual(error.error.errorCode.code, "ConstraintSeeds", "Expected error code ConstraintSeeds"); + assert.strictEqual(error.error.origin, "claim_account", "Expected error on claim_account"); + } + }); }); diff --git a/test/svm/SvmSpoke.TokenBridge.ts b/test/svm/SvmSpoke.TokenBridge.ts index 6ee7bb384..495ace8b2 100644 --- a/test/svm/SvmSpoke.TokenBridge.ts +++ b/test/svm/SvmSpoke.TokenBridge.ts @@ -143,7 +143,7 @@ describe("svm_spoke.token_bridge", () => { chainId, amountToReturn: new BN(amountToReturn), mintPublicKey: mint, - refundAccounts: [], + refundAddresses: [], refundAmounts: [], }); const merkleTree = new MerkleTree(relayerRefundLeaves, relayerRefundHashFn); diff --git a/test/svm/utils.ts b/test/svm/utils.ts index 862c90147..bdf9827a5 100644 --- a/test/svm/utils.ts +++ b/test/svm/utils.ts @@ -68,7 +68,7 @@ export interface RelayerRefundLeafSolana { refundAmounts: BN[]; leafId: BN; mintPublicKey: PublicKey; - refundAccounts: PublicKey[]; + refundAddresses: PublicKey[]; } export type RelayerRefundLeafType = RelayerRefundLeaf | RelayerRefundLeafSolana; @@ -108,7 +108,7 @@ export function buildRelayerRefundMerkleTree({ chainId: new BN(chainId), amountToReturn: new BN(0), mintPublicKey: mint ?? Keypair.generate().publicKey, - refundAccounts: svmRelayers || [Keypair.generate().publicKey, Keypair.generate().publicKey], + refundAddresses: svmRelayers || [Keypair.generate().publicKey, Keypair.generate().publicKey], refundAmounts: svmRefundAmounts || [new BN(randomBigInt(2).toString()), new BN(randomBigInt(2).toString())], }); @@ -159,7 +159,7 @@ export function calculateRelayerRefundLeafHashUint8Array(relayData: RelayerRefun }) ); - const refundAccountsBuffer = Buffer.concat(relayData.refundAccounts.map((account) => account.toBuffer())); + const refundAddressesBuffer = Buffer.concat(relayData.refundAddresses.map((address) => address.toBuffer())); const contentToHash = Buffer.concat([ relayData.amountToReturn.toArrayLike(Buffer, "le", 8), @@ -167,7 +167,7 @@ export function calculateRelayerRefundLeafHashUint8Array(relayData: RelayerRefun refundAmountsBuffer, relayData.leafId.toArrayLike(Buffer, "le", 4), relayData.mintPublicKey.toBuffer(), - refundAccountsBuffer, + refundAddressesBuffer, ]); const relayHash = ethers.utils.keccak256(contentToHash);