diff --git a/programs/svm-spoke/src/error.rs b/programs/svm-spoke/src/error.rs index acf794bc9..02fac19f7 100644 --- a/programs/svm-spoke/src/error.rs +++ b/programs/svm-spoke/src/error.rs @@ -7,7 +7,7 @@ pub enum CommonError { DisabledRoute, #[msg("Invalid quote timestamp!")] InvalidQuoteTimestamp, - #[msg("Ivalid fill deadline!")] + #[msg("Invalid fill deadline!")] InvalidFillDeadline, #[msg("Caller is not the exclusive relayer and exclusivity deadline has not passed!")] NotExclusiveRelayer, @@ -74,6 +74,8 @@ pub enum SvmError { InvalidProductionSeed, #[msg("Invalid remaining accounts for ATA creation!")] InvalidATACreationAccounts, + #[msg("Invalid delegate PDA!")] + InvalidDelegatePda, } // CCTP specific errors. diff --git a/programs/svm-spoke/src/instructions/deposit.rs b/programs/svm-spoke/src/instructions/deposit.rs index 8f84baee1..55dae6e08 100644 --- a/programs/svm-spoke/src/instructions/deposit.rs +++ b/programs/svm-spoke/src/instructions/deposit.rs @@ -11,7 +11,9 @@ use crate::{ error::{CommonError, SvmError}, event::FundsDeposited, state::{Route, State}, - utils::{get_current_time, get_unsafe_deposit_id, transfer_from}, + utils::{ + derive_seed_hash, get_current_time, get_unsafe_deposit_id, transfer_from, DepositNowSeedData, DepositSeedData, + }, }; #[event_cpi] @@ -23,7 +25,7 @@ use crate::{ output_token: Pubkey, input_amount: u64, output_amount: u64, - destination_chain_id: u64, + destination_chain_id: u64 )] pub struct Deposit<'info> { #[account(mut)] @@ -36,6 +38,9 @@ pub struct Deposit<'info> { )] pub state: Account<'info, State>, + /// CHECK: PDA derived with seeds ["delegate", seed_hash]; used as a CPI signer. + pub delegate: UncheckedAccount<'info>, + #[account( seeds = [b"route", input_token.as_ref(), state.seed.to_le_bytes().as_ref(), destination_chain_id.to_le_bytes().as_ref()], bump, @@ -83,15 +88,14 @@ pub fn _deposit( fill_deadline: u32, exclusivity_parameter: u32, message: Vec, + delegate_seed_hash: [u8; 32], ) -> Result<()> { let state = &mut ctx.accounts.state; - let current_time = get_current_time(state)?; if current_time.checked_sub(quote_timestamp).unwrap_or(u32::MAX) > state.deposit_quote_time_buffer { return err!(CommonError::InvalidQuoteTimestamp); } - if fill_deadline > current_time + state.fill_deadline_buffer { return err!(CommonError::InvalidFillDeadline); } @@ -101,21 +105,20 @@ pub fn _deposit( if exclusivity_deadline <= MAX_EXCLUSIVITY_PERIOD_SECONDS { exclusivity_deadline += current_time; } - if exclusive_relayer == Pubkey::default() { return err!(CommonError::InvalidExclusiveRelayer); } } - // Depositor must have delegated input_amount to the state PDA. + // Depositor must have delegated input_amount to the delegate PDA transfer_from( &ctx.accounts.depositor_token_account, &ctx.accounts.vault, input_amount, - state, - ctx.bumps.state, + &ctx.accounts.delegate, &ctx.accounts.mint, &ctx.accounts.token_program, + delegate_seed_hash, )?; let mut applied_deposit_id = deposit_id; @@ -159,6 +162,22 @@ pub fn deposit( exclusivity_parameter: u32, message: Vec, ) -> Result<()> { + let seed_hash = derive_seed_hash( + &(DepositSeedData { + depositor, + recipient, + input_token, + output_token, + input_amount, + output_amount, + destination_chain_id, + exclusive_relayer, + quote_timestamp, + fill_deadline, + exclusivity_parameter, + message: &message, + }), + ); _deposit( ctx, depositor, @@ -174,6 +193,7 @@ pub fn deposit( fill_deadline, exclusivity_parameter, message, + seed_hash, )?; Ok(()) @@ -195,7 +215,22 @@ pub fn deposit_now( ) -> Result<()> { let state = &mut ctx.accounts.state; let current_time = get_current_time(state)?; - deposit( + let seed_hash = derive_seed_hash( + &(DepositNowSeedData { + depositor, + recipient, + input_token, + output_token, + input_amount, + output_amount, + destination_chain_id, + exclusive_relayer, + fill_deadline_offset, + exclusivity_period, + message: &message, + }), + ); + _deposit( ctx, depositor, recipient, @@ -205,10 +240,12 @@ pub fn deposit_now( output_amount, destination_chain_id, exclusive_relayer, + ZERO_DEPOSIT_ID, // ZERO_DEPOSIT_ID informs internal function to use state.number_of_deposits as id. current_time, current_time + fill_deadline_offset, exclusivity_period, message, + seed_hash, )?; Ok(()) @@ -232,6 +269,22 @@ pub fn unsafe_deposit( ) -> Result<()> { // Calculate the unsafe deposit ID as a [u8; 32] let deposit_id = get_unsafe_deposit_id(ctx.accounts.signer.key(), depositor, deposit_nonce); + let seed_hash = derive_seed_hash( + &(DepositSeedData { + depositor, + recipient, + input_token, + output_token, + input_amount, + output_amount, + destination_chain_id, + exclusive_relayer, + quote_timestamp, + fill_deadline, + exclusivity_parameter, + message: &message, + }), + ); _deposit( ctx, depositor, @@ -247,6 +300,7 @@ pub fn unsafe_deposit( fill_deadline, exclusivity_parameter, message, + seed_hash, )?; Ok(()) diff --git a/programs/svm-spoke/src/instructions/fill.rs b/programs/svm-spoke/src/instructions/fill.rs index 248947388..d8f965d81 100644 --- a/programs/svm-spoke/src/instructions/fill.rs +++ b/programs/svm-spoke/src/instructions/fill.rs @@ -11,7 +11,7 @@ use crate::{ error::{CommonError, SvmError}, event::{FillType, FilledRelay, RelayExecutionEventInfo}, state::{FillRelayParams, FillStatus, FillStatusAccount, State}, - utils::{get_current_time, hash_non_empty_message, invoke_handler, transfer_from}, + utils::{derive_seed_hash, get_current_time, hash_non_empty_message, invoke_handler, transfer_from, FillSeedData}, }; #[event_cpi] @@ -25,13 +25,12 @@ pub struct FillRelay<'info> { #[account(mut, seeds = [b"instruction_params", signer.key().as_ref()], bump, close = signer)] pub instruction_params: Option>, - #[account( - seeds = [b"state", state.seed.to_le_bytes().as_ref()], - bump, - constraint = !state.paused_fills @ CommonError::FillsArePaused - )] + #[account(seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] pub state: Account<'info, State>, + /// CHECK: PDA derived with seeds ["delegate", seed_hash]; used as a CPI signer. + pub delegate: UncheckedAccount<'info>, + #[account( mint::token_program = token_program, address = relay_data @@ -81,10 +80,15 @@ pub struct FillRelay<'info> { pub fn fill_relay<'info>( ctx: Context<'_, '_, '_, 'info, FillRelay<'info>>, + relay_hash: [u8; 32], relay_data: Option, repayment_chain_id: Option, repayment_address: Option, ) -> Result<()> { + // This type of constraint normally would be checked in the context, but had to move it here in the handler to avoid + // exceeding maximum stack offset. + require!(!ctx.accounts.state.paused_fills, CommonError::FillsArePaused); + let FillRelayParams { relay_data, repayment_chain_id, repayment_address } = unwrap_fill_relay_params(relay_data, repayment_chain_id, repayment_address, &ctx.accounts.instruction_params); @@ -114,15 +118,17 @@ pub fn fill_relay<'info>( _ => FillType::FastFill, }; - // Relayer must have delegated output_amount to the state PDA + let seed_hash = derive_seed_hash(&(FillSeedData { relay_hash, repayment_chain_id, repayment_address })); + + // Relayer must have delegated output_amount to the delegate PDA transfer_from( &ctx.accounts.relayer_token_account, &ctx.accounts.recipient_token_account, relay_data.output_amount, - state, - ctx.bumps.state, + &ctx.accounts.delegate, &ctx.accounts.mint, &ctx.accounts.token_program, + seed_hash, )?; // Update the fill status to Filled, set the relayer and fill deadline diff --git a/programs/svm-spoke/src/lib.rs b/programs/svm-spoke/src/lib.rs index efcfc2edb..9031ad26b 100644 --- a/programs/svm-spoke/src/lib.rs +++ b/programs/svm-spoke/src/lib.rs @@ -235,6 +235,7 @@ pub mod svm_spoke { /// Authority must be the state. /// - mint (Account): The mint account for the input token. /// - token_program (Interface): The token program. + /// - delegate (Account): The account used to delegate the input amount of the input token. /// /// ### Parameters /// - depositor: The account credited with the deposit. Can be different from the signer. @@ -411,9 +412,10 @@ pub mod svm_spoke { /// - token_program (Interface): The token program. /// - associated_token_program (Interface): The associated token program. /// - system_program (Interface): The system program. + /// - delegate (Account): The account used to delegate the output amount of the output token. /// /// ### Parameters: - /// - _relay_hash: The hash identifying the deposit to be filled. Caller must pass this in. Computed as hash of + /// - relay_hash: The hash identifying the deposit to be filled. Caller must pass this in. Computed as hash of /// the flattened relay_data & destination_chain_id. /// - relay_data: Struct containing all the data needed to identify the deposit to be filled. Should match /// all the same-named parameters emitted in the origin chain FundsDeposited event. @@ -440,12 +442,12 @@ pub mod svm_spoke { /// is passed, the caller must load them via the instruction_params account. pub fn fill_relay<'info>( ctx: Context<'_, '_, '_, 'info, FillRelay<'info>>, - _relay_hash: [u8; 32], + relay_hash: [u8; 32], relay_data: Option, repayment_chain_id: Option, repayment_address: Option, ) -> Result<()> { - instructions::fill_relay(ctx, relay_data, repayment_chain_id, repayment_address) + instructions::fill_relay(ctx, relay_hash, relay_data, repayment_chain_id, repayment_address) } /// Closes the FillStatusAccount PDA to reclaim relayer rent. diff --git a/programs/svm-spoke/src/utils/delegate_utils.rs b/programs/svm-spoke/src/utils/delegate_utils.rs new file mode 100644 index 000000000..550e09bfc --- /dev/null +++ b/programs/svm-spoke/src/utils/delegate_utils.rs @@ -0,0 +1,45 @@ +use anchor_lang::{prelude::*, solana_program::keccak}; + +pub fn derive_seed_hash(seed: &T) -> [u8; 32] { + let mut data = Vec::new(); + AnchorSerialize::serialize(seed, &mut data).unwrap(); + keccak::hash(&data).to_bytes() +} + +#[derive(AnchorSerialize)] +pub struct DepositSeedData<'a> { + pub depositor: Pubkey, + pub recipient: Pubkey, + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: u64, + pub output_amount: u64, + pub destination_chain_id: u64, + pub exclusive_relayer: Pubkey, + pub quote_timestamp: u32, + pub fill_deadline: u32, + pub exclusivity_parameter: u32, + pub message: &'a Vec, +} + +#[derive(AnchorSerialize)] +pub struct DepositNowSeedData<'a> { + pub depositor: Pubkey, + pub recipient: Pubkey, + pub input_token: Pubkey, + pub output_token: Pubkey, + pub input_amount: u64, + pub output_amount: u64, + pub destination_chain_id: u64, + pub exclusive_relayer: Pubkey, + pub fill_deadline_offset: u32, + pub exclusivity_period: u32, + pub message: &'a Vec, +} + +#[derive(AnchorSerialize)] +pub struct FillSeedData { + pub relay_hash: [u8; 32], + pub repayment_chain_id: u64, + pub repayment_address: Pubkey, +} diff --git a/programs/svm-spoke/src/utils/mod.rs b/programs/svm-spoke/src/utils/mod.rs index 4792201f2..c2ed59ca1 100644 --- a/programs/svm-spoke/src/utils/mod.rs +++ b/programs/svm-spoke/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod bitmap_utils; pub mod cctp_utils; +pub mod delegate_utils; pub mod deposit_utils; pub mod merkle_proof_utils; pub mod message_utils; @@ -8,6 +9,7 @@ pub mod transfer_utils; pub use bitmap_utils::*; pub use cctp_utils::*; +pub use delegate_utils::*; pub use deposit_utils::*; pub use merkle_proof_utils::*; pub use message_utils::*; diff --git a/programs/svm-spoke/src/utils/transfer_utils.rs b/programs/svm-spoke/src/utils/transfer_utils.rs index c8c9f5c76..4e1b24a88 100644 --- a/programs/svm-spoke/src/utils/transfer_utils.rs +++ b/programs/svm-spoke/src/utils/transfer_utils.rs @@ -1,28 +1,28 @@ +use crate::{error::SvmError, program::SvmSpoke}; use anchor_lang::prelude::*; use anchor_spl::token_interface::{transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked}; -use crate::State; - pub fn transfer_from<'info>( from: &InterfaceAccount<'info, TokenAccount>, to: &InterfaceAccount<'info, TokenAccount>, amount: u64, - state: &Account<'info, State>, - state_bump: u8, + delegate: &UncheckedAccount<'info>, mint: &InterfaceAccount<'info, Mint>, token_program: &Interface<'info, TokenInterface>, + delegate_seed_hash: [u8; 32], ) -> Result<()> { + let (pda, bump) = Pubkey::find_program_address(&[b"delegate", &delegate_seed_hash], &SvmSpoke::id()); + if pda != delegate.key() { + return err!(SvmError::InvalidDelegatePda); + } + let seeds: &[&[u8]] = &[b"delegate".as_ref(), &delegate_seed_hash, &[bump]]; + let signer_seeds: &[&[&[u8]]] = &[seeds]; let transfer_accounts = TransferChecked { from: from.to_account_info(), mint: mint.to_account_info(), to: to.to_account_info(), - authority: state.to_account_info(), + authority: delegate.to_account_info(), }; - - let state_seed_bytes = state.seed.to_le_bytes(); - let seeds = &[b"state", state_seed_bytes.as_ref(), &[state_bump]]; - let signer_seeds = &[&seeds[..]]; - let cpi_context = CpiContext::new_with_signer(token_program.to_account_info(), transfer_accounts, signer_seeds); transfer_checked(cpi_context, amount, mint.decimals) diff --git a/scripts/svm/fakeFillWithRandomDistribution.ts b/scripts/svm/fakeFillWithRandomDistribution.ts index 4d1b433c0..a734283e2 100644 --- a/scripts/svm/fakeFillWithRandomDistribution.ts +++ b/scripts/svm/fakeFillWithRandomDistribution.ts @@ -18,6 +18,7 @@ import { AcrossPlusMessageCoder, MulticallHandlerCoder, calculateRelayHashUint8Array, + getFillRelayDelegatePda, getSpokePoolProgram, loadFillRelayParams, sendTransactionWithLookupTable, @@ -198,6 +199,7 @@ async function fillV3RelayToRandom(): Promise { const fillAccounts = { state: statePda, signer: signer.publicKey, + delegate: getFillRelayDelegatePda(relayHashUint8Array, repaymentChain, repaymentAddress, program.programId).pda, instructionParams, mint: outputToken, relayerTokenAccount, diff --git a/scripts/svm/simpleFill.ts b/scripts/svm/simpleFill.ts index b301fc28b..fb78ef98b 100644 --- a/scripts/svm/simpleFill.ts +++ b/scripts/svm/simpleFill.ts @@ -14,7 +14,12 @@ import { import { PublicKey, SystemProgram, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { calculateRelayHashUint8Array, getSpokePoolProgram, intToU8Array32 } from "../../src/svm/web3-v1"; +import { + calculateRelayHashUint8Array, + getFillRelayDelegatePda, + getSpokePoolProgram, + intToU8Array32, +} from "../../src/svm/web3-v1"; import { FillDataValues } from "../../src/types/svm"; // Set up the provider @@ -161,6 +166,7 @@ async function fillRelay(): Promise { const fillAccounts = { state: statePda, signer: signer.publicKey, + delegate: getFillRelayDelegatePda(relayHashUint8Array, chainId, signer.publicKey, program.programId).pda, instructionParams: program.programId, mint: outputToken, relayerTokenAccount: relayerTokenAccount, diff --git a/src/svm/web3-v1/helpers.ts b/src/svm/web3-v1/helpers.ts index 7b737280d..0f61fe035 100644 --- a/src/svm/web3-v1/helpers.ts +++ b/src/svm/web3-v1/helpers.ts @@ -1,6 +1,10 @@ -import { AnchorProvider } from "@coral-xyz/anchor"; +import { AnchorProvider, BN } from "@coral-xyz/anchor"; import { BigNumber } from "@ethersproject/bignumber"; import { ethers } from "ethers"; +import { DepositData } from "../../types/svm"; +import { PublicKey } from "@solana/web3.js"; +import { serialize } from "borsh"; +import { keccak256 } from "ethers/lib/utils"; /** * Returns the chainId for a given solana cluster. @@ -20,3 +24,278 @@ export const isSolanaDevnet = (provider: AnchorProvider): boolean => { else if (solanaRpcEndpoint.includes("mainnet")) return false; else throw new Error(`Unsupported solanaCluster endpoint: ${solanaRpcEndpoint}`); }; + +/** + * Generic helper: serialize + keccak256 → 32‑byte Uint8Array + */ +function deriveSeedHash(schema: Map, seedObj: T): Uint8Array { + const serialized = serialize(schema, seedObj); + const hashHex = keccak256(serialized); + return Buffer.from(hashHex.slice(2), "hex"); +} + +/** + * “Absolute‐deadline” deposit data + */ +export class DepositSeedData { + depositor!: Uint8Array; + recipient!: Uint8Array; + inputToken!: Uint8Array; + outputToken!: Uint8Array; + inputAmount!: BN; + outputAmount!: BN; + destinationChainId!: BN; + exclusiveRelayer!: Uint8Array; + quoteTimestamp!: BN; + fillDeadline!: BN; + exclusivityParameter!: BN; + message!: Uint8Array; + + constructor(fields: { + depositor: Uint8Array; + recipient: Uint8Array; + inputToken: Uint8Array; + outputToken: Uint8Array; + inputAmount: BN; + outputAmount: BN; + destinationChainId: BN; + exclusiveRelayer: Uint8Array; + quoteTimestamp: BN; + fillDeadline: BN; + exclusivityParameter: BN; + message: Uint8Array; + }) { + Object.assign(this, fields); + } +} + +const depositSeedSchema = new Map([ + [ + DepositSeedData, + { + kind: "struct", + fields: [ + ["depositor", [32]], + ["recipient", [32]], + ["inputToken", [32]], + ["outputToken", [32]], + ["inputAmount", "u64"], + ["outputAmount", "u64"], + ["destinationChainId", "u64"], + ["exclusiveRelayer", [32]], + ["quoteTimestamp", "u32"], + ["fillDeadline", "u32"], + ["exclusivityParameter", "u32"], + ["message", ["u8"]], + ], + }, + ], +]); + +/** + * Hash for the standard `deposit(...)` flow + */ +export function getDepositSeedHash(depositData: { + depositor: PublicKey; + recipient: PublicKey; + inputToken: PublicKey; + outputToken: PublicKey; + inputAmount: BN; + outputAmount: BN; + destinationChainId: BN; + exclusiveRelayer: PublicKey; + quoteTimestamp: BN; + fillDeadline: BN; + exclusivityParameter: BN; + message: Uint8Array; +}): Uint8Array { + const ds = new DepositSeedData({ + depositor: depositData.depositor.toBuffer(), + recipient: depositData.recipient.toBuffer(), + inputToken: depositData.inputToken.toBuffer(), + outputToken: depositData.outputToken.toBuffer(), + inputAmount: depositData.inputAmount, + outputAmount: depositData.outputAmount, + destinationChainId: depositData.destinationChainId, + exclusiveRelayer: depositData.exclusiveRelayer.toBuffer(), + quoteTimestamp: depositData.quoteTimestamp, + fillDeadline: depositData.fillDeadline, + exclusivityParameter: depositData.exclusivityParameter, + message: depositData.message, + }); + + return deriveSeedHash(depositSeedSchema, ds); +} + +/** + * Returns the delegate PDA for `deposit(...)` + */ +export function getDepositPda(depositData: Parameters[0], programId: PublicKey): PublicKey { + const seedHash = getDepositSeedHash(depositData); + const [pda] = PublicKey.findProgramAddressSync([Buffer.from("delegate"), seedHash], programId); + return pda; +} + +/** + * “Offset/now” deposit data + */ +export class DepositNowSeedData { + depositor!: Uint8Array; + recipient!: Uint8Array; + inputToken!: Uint8Array; + outputToken!: Uint8Array; + inputAmount!: BN; + outputAmount!: BN; + destinationChainId!: BN; + exclusiveRelayer!: Uint8Array; + fillDeadlineOffset!: BN; + exclusivityPeriod!: BN; + message!: Uint8Array; + + constructor(fields: { + depositor: Uint8Array; + recipient: Uint8Array; + inputToken: Uint8Array; + outputToken: Uint8Array; + inputAmount: BN; + outputAmount: BN; + destinationChainId: BN; + exclusiveRelayer: Uint8Array; + fillDeadlineOffset: BN; + exclusivityPeriod: BN; + message: Uint8Array; + }) { + Object.assign(this, fields); + } +} + +const depositNowSeedSchema = new Map([ + [ + DepositNowSeedData, + { + kind: "struct", + fields: [ + ["depositor", [32]], + ["recipient", [32]], + ["inputToken", [32]], + ["outputToken", [32]], + ["inputAmount", "u64"], + ["outputAmount", "u64"], + ["destinationChainId", "u64"], + ["exclusiveRelayer", [32]], + ["fillDeadlineOffset", "u32"], + ["exclusivityPeriod", "u32"], + ["message", ["u8"]], + ], + }, + ], +]); + +/** + * Hash for the `deposit_now(...)` flow + */ +export function getDepositNowSeedHash(depositData: { + depositor: PublicKey; + recipient: PublicKey; + inputToken: PublicKey; + outputToken: PublicKey; + inputAmount: BN; + outputAmount: BN; + destinationChainId: BN; + exclusiveRelayer: PublicKey; + fillDeadlineOffset: BN; + exclusivityPeriod: BN; + message: Uint8Array; +}): Uint8Array { + const dns = new DepositNowSeedData({ + depositor: depositData.depositor.toBuffer(), + recipient: depositData.recipient.toBuffer(), + inputToken: depositData.inputToken.toBuffer(), + outputToken: depositData.outputToken.toBuffer(), + inputAmount: depositData.inputAmount, + outputAmount: depositData.outputAmount, + destinationChainId: depositData.destinationChainId, + exclusiveRelayer: depositData.exclusiveRelayer.toBuffer(), + fillDeadlineOffset: depositData.fillDeadlineOffset, + exclusivityPeriod: depositData.exclusivityPeriod, + message: depositData.message, + }); + + return deriveSeedHash(depositNowSeedSchema, dns); +} + +/** + * Returns the delegate PDA for `deposit_now(...)` + */ +export function getDepositNowPda( + depositData: Parameters[0], + programId: PublicKey +): PublicKey { + const seedHash = getDepositNowSeedHash(depositData); + const [pda] = PublicKey.findProgramAddressSync([Buffer.from("delegate"), seedHash], programId); + return pda; +} + +/** + * Fill Delegate Seed Data + */ +class FillDelegateSeedData { + relayHash: Uint8Array; + repaymentChainId: BN; + repaymentAddress: Uint8Array; + constructor(fields: { relayHash: Uint8Array; repaymentChainId: BN; repaymentAddress: Uint8Array }) { + this.relayHash = fields.relayHash; + this.repaymentChainId = fields.repaymentChainId; + this.repaymentAddress = fields.repaymentAddress; + } +} + +/** + * Borsh schema for FillDelegateSeedData + */ +const fillDelegateSeedSchema = new Map([ + [ + FillDelegateSeedData, + { + kind: "struct", + fields: [ + ["relayHash", [32]], + ["repaymentChainId", "u64"], + ["repaymentAddress", [32]], + ], + }, + ], +]); + +/** + * Returns the fill delegate seed hash. + */ + +export function getFillRelayDelegateSeedHash( + relayHash: Uint8Array, + repaymentChainId: BN, + repaymentAddress: PublicKey +): Uint8Array { + const ds = new FillDelegateSeedData({ + relayHash, + repaymentChainId, + repaymentAddress: repaymentAddress.toBuffer(), + }); + + return deriveSeedHash(fillDelegateSeedSchema, ds); +} + +/** + * Returns the fill delegate PDA. + */ +export function getFillRelayDelegatePda( + relayHash: Uint8Array, + repaymentChainId: BN, + repaymentAddress: PublicKey, + programId: PublicKey +): { seedHash: Uint8Array; pda: PublicKey } { + const seedHash = getFillRelayDelegateSeedHash(relayHash, repaymentChainId, repaymentAddress); + const [pda] = PublicKey.findProgramAddressSync([Buffer.from("delegate"), seedHash], programId); + + return { seedHash, pda }; +} diff --git a/test/svm/SvmSpoke.Deposit.ts b/test/svm/SvmSpoke.Deposit.ts index 2290ff84a..bfb258298 100644 --- a/test/svm/SvmSpoke.Deposit.ts +++ b/test/svm/SvmSpoke.Deposit.ts @@ -28,22 +28,47 @@ import { Keypair, PublicKey, Transaction, sendAndConfirmTransaction } from "@sol import { BigNumber, ethers } from "ethers"; import { SvmSpokeClient } from "../../src/svm"; import { DepositInput } from "../../src/svm/clients/SvmSpoke"; -import { intToU8Array32, readEventsUntilFound, u8Array32ToBigNumber, u8Array32ToInt } from "../../src/svm/web3-v1"; -import { DepositDataValues } from "../../src/types/svm"; +import { + getDepositNowPda, + getDepositNowSeedHash, + getDepositPda, + getDepositSeedHash, + intToU8Array32, + readEventsUntilFound, + u8Array32ToBigNumber, + u8Array32ToInt, +} from "../../src/svm/web3-v1"; +import { DepositData, DepositDataValues } from "../../src/types/svm"; import { MAX_EXCLUSIVITY_OFFSET_SECONDS } from "../../test-utils"; import { common } from "./SvmSpoke.common"; import { createDefaultSolanaClient, createDefaultTransaction, signAndSendTransaction } from "./utils"; -const { provider, connection, program, owner, seedBalance, initializeState, depositData } = common; -const { createRoutePda, getVaultAta, assertSE, assert, getCurrentTime, depositQuoteTimeBuffer, fillDeadlineBuffer } = - common; +const { + createRoutePda, + getVaultAta, + assertSE, + assert, + getCurrentTime, + depositQuoteTimeBuffer, + fillDeadlineBuffer, + provider, + connection, + program, + owner, + seedBalance, + initializeState, + depositData, +} = common; const maxExclusivityOffsetSeconds = new BN(MAX_EXCLUSIVITY_OFFSET_SECONDS); // 1 year in seconds +type DepositDataSeed = Parameters[0]; +type DepositNowDataSeed = Parameters[0]; + describe("svm_spoke.deposit", () => { anchor.setProvider(provider); const depositor = Keypair.generate(); - const payer = (anchor.AnchorProvider.env().wallet as anchor.Wallet).payer; + const { payer } = anchor.AnchorProvider.env().wallet as anchor.Wallet; const tokenDecimals = 6; let state: PublicKey, inputToken: PublicKey, depositorTA: PublicKey, vault: PublicKey, tokenProgram: PublicKey; @@ -52,6 +77,7 @@ describe("svm_spoke.deposit", () => { // Re-used between tests to simplify props. type DepositAccounts = { state: PublicKey; + delegate: PublicKey; route: PublicKey; signer: PublicKey; depositorTokenAccount: PublicKey; @@ -107,6 +133,7 @@ describe("svm_spoke.deposit", () => { depositAccounts = { state, + delegate: getDepositPda(depositData as DepositDataSeed, program.programId), route, signer: depositor.publicKey, depositorTokenAccount: depositorTA, @@ -118,14 +145,17 @@ describe("svm_spoke.deposit", () => { }; const approvedDeposit = async ( - depositDataValues: DepositDataValues, + depositData: DepositData, calledDepositAccounts: DepositAccounts = depositAccounts ) => { - // Delegate state PDA to pull depositor tokens. + const delegatePda = getDepositPda(depositData as DepositDataSeed, program.programId); + calledDepositAccounts.delegate = delegatePda; + + // Delegate delegate PDA to pull depositor tokens. const approveIx = await createApproveCheckedInstruction( calledDepositAccounts.depositorTokenAccount, calledDepositAccounts.mint, - calledDepositAccounts.state, + delegatePda, depositor.publicKey, BigInt(depositData.inputAmount.toString()), tokenDecimals, @@ -133,12 +163,24 @@ describe("svm_spoke.deposit", () => { tokenProgram ); const depositIx = await program.methods - .deposit(...depositDataValues) + .deposit( + depositData.depositor!, + depositData.recipient, + depositData.inputToken!, + depositData.outputToken, + depositData.inputAmount, + depositData.outputAmount, + depositData.destinationChainId, + depositData.exclusiveRelayer, + depositData.quoteTimestamp.toNumber(), + depositData.fillDeadline.toNumber(), + depositData.exclusivityParameter.toNumber(), + depositData.message + ) .accounts(calledDepositAccounts) .instruction(); const depositTx = new Transaction().add(approveIx, depositIx); - const tx = await sendAndConfirmTransaction(connection, depositTx, [payer, depositor]); - return tx; + return sendAndConfirmTransaction(connection, depositTx, [payer, depositor]); }; beforeEach(async () => { @@ -146,7 +188,6 @@ describe("svm_spoke.deposit", () => { tokenProgram = TOKEN_PROGRAM_ID; // Some tests might override this. await setupInputToken(); - await enableRoute(); }); it("Deposits tokens via deposit function and checks balances", async () => { @@ -155,8 +196,7 @@ describe("svm_spoke.deposit", () => { assertSE(vaultAccount.amount, "0", "Vault balance should be zero before the deposit"); // Execute the deposit call - let depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); // Verify tokens leave the depositor's account let depositorAccount = await getAccount(connection, depositorTA); @@ -174,9 +214,7 @@ describe("svm_spoke.deposit", () => { const secondInputAmount = new BN(300000); // Execute the second deposit call - - depositDataValues = Object.values({ ...depositData, inputAmount: secondInputAmount }) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit({ ...depositData, inputAmount: secondInputAmount }); // Verify tokens leave the depositor's account again depositorAccount = await getAccount(connection, depositorTA); @@ -199,8 +237,7 @@ describe("svm_spoke.deposit", () => { depositData.inputAmount = depositData.inputAmount.add(new BN(69)); // Execute the first deposit call - let depositDataValues = Object.values(depositData) as DepositDataValues; - const tx = await approvedDeposit(depositDataValues); + const tx = await approvedDeposit(depositData); let events = await readEventsUntilFound(connection, tx, [program]); let event = events[0].data; // 0th event is the latest event @@ -214,8 +251,8 @@ describe("svm_spoke.deposit", () => { assertSE(u8Array32ToInt(event.depositId), 1, `depositId should recover to 1`); assertSE(u8Array32ToBigNumber(event.depositId), BigNumber.from(1), `depositId should recover to 1`); - // Execute the second deposit_v3 call - const tx2 = await approvedDeposit(depositDataValues); + // Execute the second deposit call + const tx2 = await approvedDeposit(depositData); events = await readEventsUntilFound(connection, tx2, [program]); event = events[0].data; // 0th event is the latest event. @@ -238,8 +275,7 @@ describe("svm_spoke.deposit", () => { depositData.fillDeadline = new BN(fillDeadline); depositData.quoteTimestamp = new BN(currentTime - 1); // 1 second before current time on the contract to reset. - const depositDataValues = Object.values(depositData) as DepositDataValues; - const tx = await approvedDeposit(depositDataValues); + const tx = await approvedDeposit(depositData); const events = await readEventsUntilFound(connection, tx, [program]); const event = events[0].data; // 0th event is the latest event. @@ -256,11 +292,10 @@ describe("svm_spoke.deposit", () => { depositAccounts.route = differentRoutePda; try { - const depositDataValues = Object.values({ + await approvedDeposit({ ...depositData, destinationChainId: differentChainId, - }) as DepositDataValues; - await approvedDeposit(depositDataValues); + }); assert.fail("Deposit should have failed for a route that is not initialized"); } catch (err: any) { assert.include(err.toString(), "AccountNotInitialized", "Expected AccountNotInitialized error"); @@ -275,8 +310,7 @@ describe("svm_spoke.deposit", () => { .rpc(); try { - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); assert.fail("Deposit should have failed for a route that is explicitly disabled"); } catch (err: any) { assert.include(err.toString(), "DisabledRoute", "Expected DisabledRoute error"); @@ -292,8 +326,7 @@ describe("svm_spoke.deposit", () => { // Try to deposit. This should fail because deposits are paused. try { - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); assert.fail("Should not be able to process deposit when deposits are paused"); } catch (err: any) { assert.include(err.toString(), "Error Code: DepositsArePaused", "Expected DepositsArePaused error"); @@ -307,8 +340,7 @@ describe("svm_spoke.deposit", () => { depositData.quoteTimestamp = futureQuoteTimestamp; try { - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); assert.fail("Deposit should have failed due to InvalidQuoteTimestamp"); } catch (err: any) { assert.include(err.toString(), "Error Code: InvalidQuoteTimestamp", "Expected InvalidQuoteTimestamp error"); @@ -322,8 +354,7 @@ describe("svm_spoke.deposit", () => { depositData.quoteTimestamp = futureQuoteTimestamp; try { - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); assert.fail("Deposit should have failed due to InvalidQuoteTimestamp"); } catch (err: any) { assert.include(err.toString(), "Error Code: InvalidQuoteTimestamp", "Expected InvalidQuoteTimestamp error"); @@ -339,8 +370,7 @@ describe("svm_spoke.deposit", () => { depositData.quoteTimestamp = new BN(currentTime); try { - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); assert.fail("Deposit should have failed due to InvalidFillDeadline (future deadline)"); } catch (err: any) { assert.include(err.toString(), "InvalidFillDeadline", "Expected InvalidFillDeadline error for future deadline"); @@ -360,8 +390,7 @@ describe("svm_spoke.deposit", () => { const malformedDepositData = { ...depositData, inputToken: firstInputToken }; const malformedDepositAccounts = { ...depositAccounts, route: firstDepositAccounts.route }; try { - const depositDataValues = Object.values(malformedDepositData) as DepositDataValues; - await approvedDeposit(depositDataValues, malformedDepositAccounts); + await approvedDeposit(malformedDepositData, malformedDepositAccounts); assert.fail("Should not be able to process deposit for inconsistent mint"); } catch (err: any) { assert.include(err.toString(), "Error Code: InvalidMint", "Expected InvalidMint error"); @@ -394,6 +423,7 @@ describe("svm_spoke.deposit", () => { const fakeDepositAccounts = { state: fakeState.state, + delegate: getDepositPda(depositData as DepositDataSeed, program.programId), route: fakeRoutePda, signer: depositor.publicKey, depositorTokenAccount: depositorTA, @@ -404,11 +434,13 @@ describe("svm_spoke.deposit", () => { }; // Deposit with the fake state and route PDA should succeed. - const depositDataValues = Object.values({ - ...depositData, - destinationChainId: fakeRouteChainId, - }) as DepositDataValues; - const tx = await approvedDeposit(depositDataValues, fakeDepositAccounts); + const tx = await approvedDeposit( + { + ...depositData, + destinationChainId: fakeRouteChainId, + }, + fakeDepositAccounts + ); let events = await readEventsUntilFound(connection, tx, [program]); let event = events[0].data; // 0th event is the latest event. @@ -431,10 +463,15 @@ describe("svm_spoke.deposit", () => { // Deposit with the fake route in the original program state should fail. try { - const depositDataValues = Object.values({ - ...{ ...depositData, destinationChainId: fakeRouteChainId }, - }) as DepositDataValues; - await approvedDeposit(depositDataValues, { ...depositAccounts, route: fakeRoutePda }); + await approvedDeposit( + { + ...{ ...depositData, destinationChainId: fakeRouteChainId }, + }, + { + ...depositAccounts, + route: fakeRoutePda, + } + ); assert.fail("Deposit should have failed for a fake route PDA"); } catch (err: any) { assert.include(err.toString(), "A seeds constraint was violated"); @@ -451,11 +488,18 @@ describe("svm_spoke.deposit", () => { // Equally, depositV3Now does not have `quoteTimestamp`. this is set to the current time from the program. const fillDeadlineOffset = 60; // 60 seconds offset + const depositNowData = { + ...depositData, + fillDeadlineOffset: new BN(fillDeadlineOffset), + exclusivityPeriod: new BN(0), + }; + + const delegatePda = getDepositNowPda(depositNowData as DepositNowDataSeed, program.programId); // Delegate state PDA to pull depositor tokens. const approveIx = await createApproveCheckedInstruction( depositAccounts.depositorTokenAccount, depositAccounts.mint, - depositAccounts.state, + delegatePda, depositor.publicKey, BigInt(depositData.inputAmount.toString()), tokenDecimals, @@ -466,19 +510,19 @@ describe("svm_spoke.deposit", () => { // Execute the deposit_now call. Remove the quoteTimestamp from the depositData as not needed for this method. const depositIx = await program.methods .depositNow( - depositData.depositor!, - depositData.recipient!, - depositData.inputToken!, - depositData.outputToken!, - depositData.inputAmount, - depositData.outputAmount, - depositData.destinationChainId, - depositData.exclusiveRelayer!, + depositNowData.depositor!, + depositNowData.recipient!, + depositNowData.inputToken!, + depositNowData.outputToken!, + depositNowData.inputAmount, + depositNowData.outputAmount, + depositNowData.destinationChainId, + depositNowData.exclusiveRelayer!, fillDeadlineOffset, 0, - depositData.message + depositNowData.message ) - .accounts(depositAccounts) + .accounts({ ...depositAccounts, delegate: delegatePda }) .instruction(); const depositTx = new Transaction().add(approveIx, depositIx); const tx = await sendAndConfirmTransaction(connection, depositTx, [payer, depositor]); @@ -508,8 +552,7 @@ describe("svm_spoke.deposit", () => { depositData.exclusiveRelayer = new PublicKey("11111111111111111111111111111111"); depositData.exclusivityParameter = new BN(1); try { - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); assert.fail("Should have failed due to InvalidExclusiveRelayer"); } catch (err: any) { assert.include(err.toString(), "InvalidExclusiveRelayer"); @@ -526,8 +569,7 @@ describe("svm_spoke.deposit", () => { for (const exclusivityDeadline of invalidExclusivityDeadlines) { depositData.exclusivityParameter = exclusivityDeadline; try { - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); assert.fail("Should have failed due to InvalidExclusiveRelayer"); } catch (err: any) { assert.include(err.toString(), "InvalidExclusiveRelayer"); @@ -536,8 +578,7 @@ describe("svm_spoke.deposit", () => { // Test with exclusivityDeadline set to 0 depositData.exclusivityParameter = new BN(0); - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); }); it("Exclusivity param is used as an offset", async () => { @@ -547,8 +588,7 @@ describe("svm_spoke.deposit", () => { depositData.exclusiveRelayer = depositor.publicKey; depositData.exclusivityParameter = maxExclusivityOffsetSeconds; - const depositDataValues = Object.values(depositData) as DepositDataValues; - const tx = await approvedDeposit(depositDataValues); + const tx = await approvedDeposit(depositData); const events = await readEventsUntilFound(connection, tx, [program]); const event = events[0].data; // 0th event is the latest event @@ -567,8 +607,7 @@ describe("svm_spoke.deposit", () => { depositData.exclusiveRelayer = depositor.publicKey; depositData.exclusivityParameter = exclusivityDeadlineTimestamp; - const depositDataValues = Object.values(depositData) as DepositDataValues; - const tx = await approvedDeposit(depositDataValues); + const tx = await approvedDeposit(depositData); const events = await readEventsUntilFound(connection, tx, [program]); const event = events[0].data; // 0th event is the latest event; @@ -584,8 +623,7 @@ describe("svm_spoke.deposit", () => { depositData.exclusiveRelayer = depositor.publicKey; depositData.exclusivityParameter = zeroExclusivity; - const depositDataValues = Object.values(depositData) as DepositDataValues; - const tx = await approvedDeposit(depositDataValues); + const tx = await approvedDeposit(depositData); const events = await readEventsUntilFound(connection, tx, [program]); const event = events[0].data; // 0th event is the latest event; @@ -619,7 +657,7 @@ describe("svm_spoke.deposit", () => { const approveIx = await createApproveCheckedInstruction( depositAccounts.depositorTokenAccount, depositAccounts.mint, - depositAccounts.state, + getDepositPda(depositData as DepositDataSeed, program.programId), depositor.publicKey, BigInt(depositData.inputAmount.toString()), tokenDecimals, @@ -682,8 +720,7 @@ describe("svm_spoke.deposit", () => { assertSE(vaultAccount.amount, "0", "Vault balance should be zero before the deposit"); // Execute the deposit call - const depositDataValues = Object.values(depositData) as DepositDataValues; - await approvedDeposit(depositDataValues); + await approvedDeposit(depositData); // Verify tokens leave the depositor's account const depositorAccount = await getAccount(connection, depositorTA, undefined, tokenProgram); @@ -738,7 +775,7 @@ describe("svm_spoke.deposit", () => { const approveIx = getApproveCheckedInstruction({ source: address(depositAccounts.depositorTokenAccount.toString()), mint: address(depositAccounts.mint.toString()), - delegate: address(depositAccounts.state.toString()), + delegate: address(getDepositPda(depositData as DepositDataSeed, program.programId).toString()), owner: address(depositor.publicKey.toString()), amount: BigInt(depositData.inputAmount.toString()), decimals: tokenDecimals, @@ -761,6 +798,7 @@ describe("svm_spoke.deposit", () => { const formattedAccounts = { state: address(depositAccounts.state.toString()), + delegate: address(getDepositPda(depositData as DepositDataSeed, program.programId).toString()), route: address(depositAccounts.route.toString()), depositorTokenAccount: address(depositAccounts.depositorTokenAccount.toString()), mint: address(depositAccounts.mint.toString()), diff --git a/test/svm/SvmSpoke.Fill.AcrossPlus.ts b/test/svm/SvmSpoke.Fill.AcrossPlus.ts index dc858cedf..68ee7c2c0 100644 --- a/test/svm/SvmSpoke.Fill.AcrossPlus.ts +++ b/test/svm/SvmSpoke.Fill.AcrossPlus.ts @@ -29,16 +29,16 @@ import { sendTransactionWithLookupTable, loadFillRelayParams, intToU8Array32, + getFillRelayDelegatePda, } from "../../src/svm/web3-v1"; import { MulticallHandler } from "../../target/types/multicall_handler"; import { common } from "./SvmSpoke.common"; import { FillDataParams, FillDataValues } from "../../src/types/svm"; -const { provider, connection, program, owner, chainId, seedBalance } = common; -const { initializeState, assertSE } = common; +const { provider, connection, program, owner, chainId, seedBalance, initializeState, assertSE } = common; describe("svm_spoke.fill.across_plus", () => { anchor.setProvider(provider); - const payer = (anchor.AnchorProvider.env().wallet as anchor.Wallet).payer; + const { payer } = anchor.AnchorProvider.env().wallet as anchor.Wallet; const relayer = Keypair.generate(); const handlerProgram = anchor.workspace.MulticallHandler as Program; @@ -49,14 +49,15 @@ describe("svm_spoke.fill.across_plus", () => { finalRecipientATA: PublicKey, state: PublicKey, mint: PublicKey, - relayerATA: PublicKey; + relayerATA: PublicKey, + seed: BN; const relayAmount = 500000; const mintDecimals = 6; let relayData: any; // reused relay data for all tests. let accounts: any; // Store accounts to simplify contract interactions. - function updateRelayData(newRelayData: any) { + const updateRelayData = (newRelayData: any) => { relayData = newRelayData; const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); const [fillStatusPDA] = PublicKey.findProgramAddressSync( @@ -66,6 +67,7 @@ describe("svm_spoke.fill.across_plus", () => { accounts = { state, + delegate: getFillRelayDelegatePda(relayHashUint8Array, new BN(1), relayer.publicKey, program.programId).pda, signer: relayer.publicKey, instructionParams: program.programId, mint: mint, @@ -76,14 +78,17 @@ describe("svm_spoke.fill.across_plus", () => { associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: anchor.web3.SystemProgram.programId, }; - } + }; + + const createApproveAndFillIx = async (multicallHandlerCoder: MulticallHandlerCoder, bufferParams = false) => { + const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + const relayHash = Array.from(relayHashUint8Array); - async function createApproveAndFillIx(multicallHandlerCoder: MulticallHandlerCoder, bufferParams = false) { // Delegate state PDA to pull relayer tokens. const approveIx = await createApproveCheckedInstruction( accounts.relayerTokenAccount, accounts.mint, - accounts.state, + getFillRelayDelegatePda(relayHashUint8Array, new BN(1), relayer.publicKey, program.programId).pda, accounts.signer, BigInt(relayAmount), mintDecimals @@ -94,8 +99,6 @@ describe("svm_spoke.fill.across_plus", () => { ...multicallHandlerCoder.compiledKeyMetas, ]; - const relayHash = Array.from(calculateRelayHashUint8Array(relayData, chainId)); - // Prepare fill instruction. const fillV3RelayValues: FillDataValues = [relayHash, relayData, new BN(1), relayer.publicKey]; if (bufferParams) { @@ -115,7 +118,7 @@ describe("svm_spoke.fill.across_plus", () => { .instruction(); return { approveIx, fillIx }; - } + }; before("Creates token mint and associated token accounts", async () => { mint = await createMint(connection, payer, owner, owner, mintDecimals); @@ -133,7 +136,7 @@ describe("svm_spoke.fill.across_plus", () => { finalRecipient = Keypair.generate().publicKey; finalRecipientATA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, finalRecipient)).address; - ({ state } = await initializeState()); + ({ state, seed } = await initializeState()); const initialRelayData = { depositor: finalRecipient, diff --git a/test/svm/SvmSpoke.Fill.ts b/test/svm/SvmSpoke.Fill.ts index d99312a3c..8c7612b5a 100644 --- a/test/svm/SvmSpoke.Fill.ts +++ b/test/svm/SvmSpoke.Fill.ts @@ -31,6 +31,7 @@ import { SvmSpokeClient } from "../../src/svm"; import { FillRelayAsyncInput } from "../../src/svm/clients/SvmSpoke"; import { calculateRelayHashUint8Array, + getFillRelayDelegatePda, hashNonEmptyMessage, intToU8Array32, readEventsUntilFound, @@ -44,12 +45,23 @@ import { signAndSendTransaction, testAcrossPlusMessage, } from "./utils"; -const { provider, connection, program, owner, chainId, seedBalance } = common; -const { recipient, initializeState, setCurrentTime, assertSE, assert } = common; +const { + provider, + connection, + program, + owner, + chainId, + seedBalance, + recipient, + initializeState, + setCurrentTime, + assertSE, + assert, +} = common; describe("svm_spoke.fill", () => { anchor.setProvider(provider); - const payer = (anchor.AnchorProvider.env().wallet as anchor.Wallet).payer; + const { payer } = anchor.AnchorProvider.env().wallet as anchor.Wallet; const relayer = Keypair.generate(); const otherRelayer = Keypair.generate(); const { encodedMessage, fillRemainingAccounts } = testAcrossPlusMessage(); @@ -60,13 +72,15 @@ describe("svm_spoke.fill", () => { relayerTA: PublicKey, recipientTA: PublicKey, otherRelayerTA: PublicKey, - tokenProgram: PublicKey; + tokenProgram: PublicKey, + seed: BN; const relayAmount = 500000; let relayData: RelayData; // reused relay data for all tests. type FillAccounts = { state: PublicKey; + delegate: PublicKey; signer: PublicKey; instructionParams: PublicKey; mint: PublicKey; @@ -81,7 +95,7 @@ describe("svm_spoke.fill", () => { let accounts: FillAccounts; // Store accounts to simplify contract interactions. - function updateRelayData(newRelayData: RelayData) { + const updateRelayData = (newRelayData: RelayData) => { relayData = newRelayData; const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); const [fillStatusPDA] = PublicKey.findProgramAddressSync( @@ -89,11 +103,19 @@ describe("svm_spoke.fill", () => { program.programId ); + const { pda: delegatePda } = getFillRelayDelegatePda( + relayHashUint8Array, + new BN(1), + relayer.publicKey, + program.programId + ); + accounts = { state, + delegate: delegatePda, signer: relayer.publicKey, instructionParams: program.programId, - mint: mint, + mint, relayerTokenAccount: relayerTA, recipientTokenAccount: recipientTA, fillStatus: fillStatusPDA, @@ -102,32 +124,39 @@ describe("svm_spoke.fill", () => { systemProgram: anchor.web3.SystemProgram.programId, program: program.programId, }; - } + }; const approvedFillRelay = async ( fillDataValues: FillDataValues, calledFillAccounts: FillAccounts = accounts, callingRelayer: Keypair = relayer ): Promise => { - // Delegate state PDA to pull relayer tokens. + const relayHash = Uint8Array.from(fillDataValues[0]); + const { seedHash, pda: delegatePda } = getFillRelayDelegatePda( + relayHash, + fillDataValues[2], + fillDataValues[3], + program.programId + ); + const approveIx = await createApproveCheckedInstruction( calledFillAccounts.relayerTokenAccount, calledFillAccounts.mint, - calledFillAccounts.state, + delegatePda, calledFillAccounts.signer, BigInt(fillDataValues[1].outputAmount.toString()), tokenDecimals, undefined, tokenProgram ); + const fillIx = await program.methods .fillRelay(...fillDataValues) - .accounts(calledFillAccounts) + .accounts({ ...calledFillAccounts, delegate: delegatePda }) .remainingAccounts(fillRemainingAccounts) .instruction(); - const fillTx = new Transaction().add(approveIx, fillIx); - const tx = await sendAndConfirmTransaction(connection, fillTx, [payer, callingRelayer]); - return tx; + + return sendAndConfirmTransaction(connection, new Transaction().add(approveIx, fillIx), [payer, callingRelayer]); }; before("Funds relayer wallets", async () => { @@ -149,7 +178,7 @@ describe("svm_spoke.fill", () => { }); beforeEach(async () => { - ({ state } = await initializeState()); + ({ state, seed } = await initializeState()); tokenProgram = TOKEN_PROGRAM_ID; // Some tests might override this. const initialRelayData = { @@ -180,7 +209,7 @@ describe("svm_spoke.fill", () => { assertSE(relayerAccount.amount, seedBalance, "Relayer's balance should be equal to seed balance before the fill"); const relayHash = Array.from(calculateRelayHashUint8Array(relayData, chainId)); - await approvedFillRelay([relayHash, relayData, new BN(1), relayer.publicKey]); + await approvedFillRelay([relayHash, relayData, chainId, relayer.publicKey]); // Verify relayer's balance after the fill relayerAccount = await getAccount(connection, relayerTA); @@ -208,7 +237,9 @@ describe("svm_spoke.fill", () => { Object.entries(relayData).forEach(([key, value]) => { if (key === "message") { assertSE(event.messageHash, hashNonEmptyMessage(value as Buffer), `MessageHash should match`); - } else assertSE(event[key], value, `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + } else { + assertSE(event[key], value, `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + } }); // RelayExecutionInfo should match. assertSE(event.relayExecutionInfo.updatedRecipient, relayData.recipient, "UpdatedRecipient should match"); @@ -449,7 +480,8 @@ describe("svm_spoke.fill", () => { }; updateRelayData(newRelayData); accounts.recipientTokenAccount = newRecipientATA; - const relayHash = Array.from(calculateRelayHashUint8Array(newRelayData, chainId)); + const relayHashUint8Array = calculateRelayHashUint8Array(newRelayData, chainId); + const relayHash = Array.from(relayHashUint8Array); try { await approvedFillRelay([relayHash, newRelayData, new BN(1), relayer.publicKey]); @@ -468,11 +500,18 @@ describe("svm_spoke.fill", () => { ]) .instruction(); + const { pda: delegatePda } = getFillRelayDelegatePda( + relayHashUint8Array, + new BN(1), + relayer.publicKey, + program.programId + ); + // Fill the deposit in the same transaction const approveInstruction = await createApproveCheckedInstruction( accounts.relayerTokenAccount, accounts.mint, - accounts.state, + delegatePda, accounts.signer, BigInt(newRelayData.outputAmount.toString()), tokenDecimals, @@ -481,7 +520,7 @@ describe("svm_spoke.fill", () => { ); const fillInstruction = await program.methods .fillRelay(relayHash, newRelayData, new BN(1), relayer.publicKey) - .accounts(accounts) + .accounts({ ...accounts, delegate: delegatePda }) .remainingAccounts(fillRemainingAccounts) .instruction(); @@ -517,7 +556,7 @@ describe("svm_spoke.fill", () => { // Build instructions for all fills let totalFillAmount = new BN(0); - const fillInstructions: TransactionInstruction[] = []; + const approveAndfillInstructions: TransactionInstruction[] = []; for (let i = 0; i < numberOfFills; i++) { const newRelayData = { ...relayData, @@ -527,30 +566,40 @@ describe("svm_spoke.fill", () => { totalFillAmount = totalFillAmount.add(newRelayData.outputAmount); updateRelayData(newRelayData); accounts.recipientTokenAccount = recipientAssociatedTokens[i]; - const relayHash = Array.from(calculateRelayHashUint8Array(newRelayData, chainId)); + const relayHashUint8Array = calculateRelayHashUint8Array(newRelayData, chainId); + const relayHash = Array.from(relayHashUint8Array); + + const { pda: delegatePda } = getFillRelayDelegatePda( + relayHashUint8Array, + new BN(1), + relayer.publicKey, + program.programId + ); + + const approveInstruction = await createApproveCheckedInstruction( + accounts.relayerTokenAccount, + accounts.mint, + delegatePda, + accounts.signer, + BigInt(totalFillAmount.toString()), + tokenDecimals, + undefined, + tokenProgram + ); + approveAndfillInstructions.push(approveInstruction); + const fillInstruction = await program.methods .fillRelay(relayHash, newRelayData, new BN(1), relayer.publicKey) - .accounts(accounts) + .accounts({ ...accounts, delegate: delegatePda }) .remainingAccounts(fillRemainingAccounts) .instruction(); - fillInstructions.push(fillInstruction); + approveAndfillInstructions.push(fillInstruction); } - const approveInstruction = await createApproveCheckedInstruction( - accounts.relayerTokenAccount, - accounts.mint, - accounts.state, - accounts.signer, - BigInt(totalFillAmount.toString()), - tokenDecimals, - undefined, - tokenProgram - ); - // Fill using the ALT. await sendTransactionWithLookupTable( connection, - [createTokenAccountsInstruction, approveInstruction, ...fillInstructions], + [createTokenAccountsInstruction, ...approveAndfillInstructions], relayer ); @@ -651,7 +700,7 @@ describe("svm_spoke.fill", () => { }); describe("codama client and solana kit", () => { - it("Fills a V3 relay and verifies balances with codama client and solana kit", async () => { + it("Fills a relay and verifies balances with codama client and solana kit", async () => { const rpcClient = createDefaultSolanaClient(); const signer = await createSignerFromKeyPair(await createKeyPairFromBytes(relayer.secretKey)); @@ -666,10 +715,19 @@ describe("svm_spoke.fill", () => { let relayerAccount = await getAccount(connection, relayerTA); assertSE(relayerAccount.amount, seedBalance, "Relayer's balance should be equal to seed balance before the fill"); - const relayHash = Array.from(calculateRelayHashUint8Array(relayData, chainId)); + const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + const relayHash = Array.from(relayHashUint8Array); + + const { pda: delegatePda } = getFillRelayDelegatePda( + relayHashUint8Array, + new BN(1), + relayer.publicKey, + program.programId + ); const formattedAccounts = { state: address(accounts.state.toString()), + delegate: address(delegatePda.toString()), instructionParams: address(program.programId.toString()), mint: address(mint.toString()), relayerTokenAccount: address(relayerTA.toString()), @@ -706,7 +764,7 @@ describe("svm_spoke.fill", () => { const approveIx = getApproveCheckedInstruction({ source: address(accounts.relayerTokenAccount.toString()), mint: address(accounts.mint.toString()), - delegate: address(accounts.state.toString()), + delegate: address(delegatePda.toString()), owner: address(accounts.signer.toString()), amount: BigInt(relayData.outputAmount.toString()), decimals: tokenDecimals, diff --git a/test/svm/SvmSpoke.SlowFill.ts b/test/svm/SvmSpoke.SlowFill.ts index 0b5116533..ab78cb8a9 100644 --- a/test/svm/SvmSpoke.SlowFill.ts +++ b/test/svm/SvmSpoke.SlowFill.ts @@ -15,6 +15,7 @@ import { MerkleTree } from "@uma/common/dist/MerkleTree"; import { SlowFillLeaf } from "../../src/types/svm"; import { calculateRelayHashUint8Array, + getFillRelayDelegatePda, hashNonEmptyMessage, intToU8Array32, readEventsUntilFound, @@ -22,12 +23,23 @@ import { } from "../../src/svm/web3-v1"; import { testAcrossPlusMessage } from "./utils"; -const { provider, connection, program, owner, chainId, seedBalance, initializeState } = common; -const { recipient, setCurrentTime, assertSE, assert } = common; +const { + provider, + connection, + program, + owner, + chainId, + seedBalance, + initializeState, + recipient, + setCurrentTime, + assertSE, + assert, +} = common; describe("svm_spoke.slow_fill", () => { anchor.setProvider(provider); - const payer = (anchor.AnchorProvider.env().wallet as anchor.Wallet).payer; + const { payer } = anchor.AnchorProvider.env().wallet as anchor.Wallet; const relayer = Keypair.generate(); const otherRelayer = Keypair.generate(); const { encodedMessage, fillRemainingAccounts } = testAcrossPlusMessage(); @@ -49,7 +61,7 @@ describe("svm_spoke.slow_fill", () => { const initialMintAmount = 10_000_000_000; - async function updateRelayData(newRelayData: SlowFillLeaf["relayData"]) { + const updateRelayData = async (newRelayData: SlowFillLeaf["relayData"]) => { relayData = newRelayData; const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); [fillStatus] = PublicKey.findProgramAddressSync([Buffer.from("fills"), relayHashUint8Array], program.programId); @@ -68,6 +80,7 @@ describe("svm_spoke.slow_fill", () => { }; fillAccounts = { state, + delegate: getFillRelayDelegatePda(relayHashUint8Array, new BN(1), relayer.publicKey, program.programId).pda, signer: relayer.publicKey, instructionParams: program.programId, mint: mint, @@ -78,7 +91,7 @@ describe("svm_spoke.slow_fill", () => { associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, systemProgram: anchor.web3.SystemProgram.programId, }; - } + }; const relaySlowFillRootBundle = async ( slowRelayLeafRecipient = recipient, @@ -117,7 +130,7 @@ describe("svm_spoke.slow_fill", () => { const leaf = slowRelayLeafs[0]; let stateAccountData = await program.account.state.fetch(state); - const rootBundleId = stateAccountData.rootBundleId; + const { rootBundleId } = stateAccountData; const rootBundleIdBuffer = Buffer.alloc(4); rootBundleIdBuffer.writeUInt32LE(rootBundleId); @@ -212,18 +225,21 @@ describe("svm_spoke.slow_fill", () => { Object.entries(relayData).forEach(([key, value]) => { if (key === "message") { assertSE(event.messageHash, hashNonEmptyMessage(value as Buffer), `MessageHash should match`); - } else assertSE(event[key], value, `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + } else { + assertSE(event[key], value, `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + } }); }); it("Fails to request a V3 slow fill if the relay has already been filled", async () => { - const relayHash = Array.from(calculateRelayHashUint8Array(relayData, chainId)); + const relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + const relayHash = Array.from(relayHashUint8Array); // Fill the relay first const approveIx = await createApproveCheckedInstruction( fillAccounts.relayerTokenAccount, fillAccounts.mint, - fillAccounts.state, + getFillRelayDelegatePda(relayHashUint8Array, new BN(1), relayer.publicKey, program.programId).pda, fillAccounts.signer, BigInt(relayData.outputAmount.toString()), tokenDecimals @@ -384,7 +400,9 @@ describe("svm_spoke.slow_fill", () => { Object.entries(relayData).forEach(([key, value]) => { if (key === "message") { assertSE(event.messageHash, hashNonEmptyMessage(value as Buffer), `MessageHash should match`); - } else assertSE(event[key], value, `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + } else { + assertSE(event[key], value, `${key.charAt(0).toUpperCase() + key.slice(1)} should match`); + } }); // RelayExecutionInfo should match. assertSE(event.relayExecutionInfo.updatedRecipient, relayData.recipient, "UpdatedRecipient should match");