Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions programs/svm-spoke/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ pub enum CustomError {
InvalidRemoteSender,
#[msg("Invalid Merkle proof!")]
InvalidProof,
#[msg("Account not found!")]
AccountNotFound,
#[msg("Fills are currently paused!")]
FillsArePaused,
#[msg("Invalid chain id!")]
Expand All @@ -75,4 +73,8 @@ pub enum CustomError {
InvalidFillDeadline,
#[msg("Overflow writing to parameters account!")]
ParamsWriteOverflow,
#[msg("Invalid refund address!")]
InvalidRefund,
#[msg("Zero relayer refund claim!")]
ZeroRefundClaim,
}
65 changes: 40 additions & 25 deletions programs/svm-spoke/src/instructions/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::{
constants::DISCRIMINATOR_SIZE,
error::CustomError,
event::ExecutedRelayerRefundRoot,
state::{ExecuteRelayerRefundLeafParams, RootBundle, State, TransferLiability},
state::{ExecuteRelayerRefundLeafParams, RefundAccount, RootBundle, State, TransferLiability},
utils::{is_claimed, set_claimed, verify_merkle_proof},
};

Expand Down Expand Up @@ -106,9 +106,12 @@ impl RelayerRefundLeaf {
}
}

pub fn execute_relayer_refund_leaf<'info>(
ctx: Context<'_, '_, '_, 'info, ExecuteRelayerRefundLeaf<'info>>,
) -> Result<()> {
pub fn execute_relayer_refund_leaf<'c, 'info>(
ctx: Context<'_, '_, 'c, 'info, ExecuteRelayerRefundLeaf<'info>>,
) -> Result<()>
where
'c: 'info,
{
Comment on lines +110 to +112
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you help me understand this bit of syntax a bit better? I also see it in the lib.rs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the constraint that lifetime for remaining accounts ('c) is at least as long as 'info for accounts that are referenced by this account array. I think its needed because of manual remaining account processing in try_from_remaining_account method. Without these lifetime constraints the compiler errors.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

o that's interesting. ye, I've had other lifetime issues while implementing other things and was able to address it through other syntax but this is more complex here.

// Get pre-loaded instruction parameters.
let instruction_params = &ctx.accounts.instruction_params;
let root_bundle_id = instruction_params.root_bundle_id;
Expand Down Expand Up @@ -147,29 +150,41 @@ pub fn execute_relayer_refund_leaf<'info>(
let signer_seeds = &[&seeds[..]];

for (i, amount) in relayer_refund_leaf.refund_amounts.iter().enumerate() {
let refund_account = relayer_refund_leaf.refund_accounts[i];
let amount = *amount as u64;

// TODO: we might be able to just use the refund_account and improve this block but it's not clear yet if that's possible.
let refund_account_info = ctx
.remaining_accounts
.iter()
.find(|account| account.key == &refund_account)
.cloned()
.ok_or(CustomError::AccountNotFound)?;

let transfer_accounts = TransferChecked {
from: ctx.accounts.vault.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
to: refund_account_info.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)?;
// Refund account holds either a regular token account or a claim account. This checks all required constraints.
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;

// Persist the updated claim account (Anchor handles this only for static accounts).
claim_account.exit(ctx.program_id)?;
}
}
}

if relayer_refund_leaf.amount_to_return > 0 {
Expand Down
2 changes: 2 additions & 0 deletions programs/svm-spoke/src/instructions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod deposit;
mod fill;
mod handle_receive_message;
mod instruction_params;
mod refund_claims;
mod slow_fill;
mod testable;
mod token_bridge;
Expand All @@ -14,6 +15,7 @@ pub use deposit::*;
pub use fill::*;
pub use handle_receive_message::*;
pub use instruction_params::*;
pub use refund_claims::*;
pub use slow_fill::*;
pub use testable::*;
pub use token_bridge::*;
96 changes: 96 additions & 0 deletions programs/svm-spoke/src/instructions/refund_claims.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use anchor_lang::prelude::*;
use anchor_spl::token_interface::{
transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked,
};

use crate::{
constants::DISCRIMINATOR_SIZE,
error::CustomError,
state::{ClaimAccount, State},
};

#[derive(Accounts)]
#[instruction(mint: Pubkey, token_account: Pubkey)]
pub struct InitializeClaimAccount<'info> {
#[account(mut)]
pub signer: Signer<'info>,

#[account(
init,
payer = signer,
space = DISCRIMINATOR_SIZE + ClaimAccount::INIT_SPACE,
seeds = [b"claim_account", mint.as_ref(), token_account.as_ref()],
bump
)]
pub claim_account: Account<'info, ClaimAccount>,

pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct ClaimRelayerRefund<'info> {
#[account(mut)]
pub signer: Signer<'info>,

#[account(mut, 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>,

// Token address has been checked when executing the relayer refund leaf and it is part of claim account derivation.
#[account(
mut,
token::mint = mint,
token::token_program = token_program
)]
pub token_account: InterfaceAccount<'info, TokenAccount>,

#[account(
mut,
seeds = [b"claim_account", mint.key().as_ref(), token_account.key().as_ref()],
bump
)]
pub claim_account: Account<'info, ClaimAccount>,

pub token_program: Interface<'info, TokenInterface>,
}

pub fn claim_relayer_refund(ctx: Context<ClaimRelayerRefund>) -> Result<()> {
// Ensure the claim account holds a non-zero amount.
let claim_amount = ctx.accounts.claim_account.amount;
require!(claim_amount > 0, CustomError::ZeroRefundClaim);

// Reset the claim amount.
ctx.accounts.claim_account.amount = 0;

// 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)
}
21 changes: 18 additions & 3 deletions programs/svm-spoke/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ pub mod svm_spoke {
instructions::relay_root_bundle(ctx, relayer_refund_root, slow_relay_root)
}

pub fn execute_relayer_refund_leaf<'info>(
ctx: Context<'_, '_, '_, 'info, ExecuteRelayerRefundLeaf<'info>>,
) -> Result<()> {
pub fn execute_relayer_refund_leaf<'c, 'info>(
ctx: Context<'_, '_, 'c, 'info, ExecuteRelayerRefundLeaf<'info>>,
) -> Result<()>
where
'c: 'info,
{
instructions::execute_relayer_refund_leaf(ctx)
}

Expand Down Expand Up @@ -206,4 +209,16 @@ pub mod svm_spoke {
pub fn close_instruction_params(ctx: Context<CloseInstructionParams>) -> Result<()> {
instructions::close_instruction_params(ctx)
}

pub fn initialize_claim_account(
_ctx: Context<InitializeClaimAccount>,
mint: Pubkey,
token_account: Pubkey,
) -> Result<()> {
Ok(())
}

pub fn claim_relayer_refund(ctx: Context<ClaimRelayerRefund>) -> Result<()> {
instructions::claim_relayer_refund(ctx)
}
}
2 changes: 2 additions & 0 deletions programs/svm-spoke/src/state/mod.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
pub mod fill;
pub mod instruction_params;
pub mod refund_account;
pub mod root_bundle;
pub mod route;
pub mod state;
pub mod transfer_liability;

pub use fill::*;
pub use instruction_params::*;
pub use refund_account::*;
pub use root_bundle::*;
pub use route::*;
pub use state::*;
Expand Down
131 changes: 131 additions & 0 deletions programs/svm-spoke/src/state/refund_account.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use anchor_lang::prelude::*;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit on this file naming: we call the instruction refund_claims and the instruction refund_accounts. we should think about it makes sense to re-name this state to claim_account? equally, for other files between instruction & state having a consistent name between the two.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RefundAccount can hold either TokenAccount or ClaimAccount and state/refund_account.rs has methods for handling both, so would not want to name it as claim_account. Also the wrapped RefundAccount is used in bundle instruction while individual ClaimAccount is used in refund_claims instruction, so there is no 1-1 relationship between state and instructions like we have in other places.

use anchor_spl::token_interface::TokenAccount;

use crate::error::CustomError;

#[account]
#[derive(InitSpace)]
pub struct ClaimAccount {
pub amount: u64,
}

// When executing relayer refund leaf, refund accounts are passed as remaining accounts and can hold either a regular
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this mean that, in theory, a relayer could close their ATA right before the bundle execution tx lands on-chain, thereby making the execution of the bundle in-valid as the remaining account type is wrong? so when the executor looks at the tx to send they build TokenAccount, expecting that the relayer has an ATA but before the tx lands on-chain the relayer can front run the tx and delete their ATA, which then requires, for this method to work, that the executor passes in the ClaimAccount, not token account, which breaks the bundle execution and the whole thing reverts?

In theory, this means that a relayer who can front run the execution can always "toggle" the status of their ATA (opening it or closing it), thereby blocking the bundle execution?

if the executor sends these execution transactions via private mempool they might be able to protect themselves (you cant close/open the account before the execution) but this is not going to be failsafe?

thoughts on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory that's possible, but might be a bigger effort as there is no public mempool, so the attacker has to run Solana validator co-located close to our bot. We can reduce attack surface by sending the transaction to trusted validator. And if this is still an issue, we can fall-back using claim accounts after couple of reverts.

// 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>),
}

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<Self> {
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(CustomError::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 @ CustomError::InvalidRefund,
// token::mint = expected_mint,
// token::token_program = token_program
// )]
// pub token_account: InterfaceAccount<'info, TokenAccount>,
Comment on lines +59 to +65
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this mean to be commented out?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, left them for auditing purposes as we reimplement what Anchor macro would have done on static account.

// 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<InterfaceAccount<'info, TokenAccount>> {
// 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a meta-comment but this check has a & (borrowing) and the others don't. expected_token_account is passed in & but is not checked against the account_info with borrowing.

point is: is there away to do this more consistently in this file and beyond?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure. owner and key in AccountInfo are borrowed references, while InterfaceAccount::try_from returns token_account with all of its properties owned. That's why we need to handle them differently. And since compiler automatically dereferences I think its more common pattern not to do explicit dereferencing, instead pass borrowed variables to comparison and let the compiler dereference them where needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but you're right that its not fully consistent with comparison in try_claim_account_from_account_info below, so I just pushed commit to use account_info.key != &pda_address instead of account_info.key() != pda_address on line 121.

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>,
Comment on lines +101 to +106
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this meant to be commented out?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is meant to document what Anchor macros this method is reimplementing, so its easier to audit knowing the intention of the implementation.

// 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(
account_info: &'info AccountInfo<'info>,
mint: &Pubkey,
token_account: &Pubkey,
) -> Option<Account<'info, ClaimAccount>> {
// Checks ownership on deserialization for the ClaimAccount.
let claim_account: Account<'info, ClaimAccount> = Account::try_from(account_info).ok()?;

// Checks the PDA is derived from mint and token account 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;
}

// Checks if the claim account is writable.
if !account_info.is_writable {
return None;
}

Some(claim_account)
}
}
Loading
Loading