From 9b62bf25c635297f669a07aef23641998350c208 Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Tue, 2 Jun 2026 13:02:13 +0000 Subject: [PATCH 1/3] fix: validate SVM optional parameter presence Signed-off-by: Reinis Martinsons --- programs/svm-spoke/src/constraints.rs | 11 + programs/svm-spoke/src/error.rs | 2 + programs/svm-spoke/src/instructions/fill.rs | 15 +- .../svm-spoke/src/instructions/slow_fill.rs | 27 +- programs/svm-spoke/src/lib.rs | 8 +- src/types/svm.ts | 47 ++ test/svm/SvmSpoke.Fill.ts | 17 +- test/svm/SvmSpoke.OptionalParams.ts | 412 ++++++++++++++++++ 8 files changed, 511 insertions(+), 28 deletions(-) create mode 100644 test/svm/SvmSpoke.OptionalParams.ts diff --git a/programs/svm-spoke/src/constraints.rs b/programs/svm-spoke/src/constraints.rs index 7c9539fc5..aed0b22ca 100644 --- a/programs/svm-spoke/src/constraints.rs +++ b/programs/svm-spoke/src/constraints.rs @@ -31,3 +31,14 @@ pub fn is_valid_associated_token_account( && token_account.key() == get_associated_token_address_with_program_id(authority, &mint.key(), &token_program.key()) } + +// Validates if the optional parameter presence is consistent with each other and counter to the instruction_params +// account presence. +pub fn has_valid_params_presence(params_presence: &[bool], account_present: bool) -> bool { + let Some((&first_present, remaining_presence)) = params_presence.split_first() else { + return false; + }; + + // All remaining param presence must match the first param presence and the account presence must not match it. + remaining_presence.iter().all(|&p| p == first_present) && account_present != first_present +} diff --git a/programs/svm-spoke/src/error.rs b/programs/svm-spoke/src/error.rs index b02c993f4..4a04684fe 100644 --- a/programs/svm-spoke/src/error.rs +++ b/programs/svm-spoke/src/error.rs @@ -76,6 +76,8 @@ pub enum SvmError { InvalidATACreationAccounts, #[msg("Invalid delegate PDA!")] InvalidDelegatePda, + #[msg("Inconsistent optional parameters!")] + InconsistentOptionalParameters, } // CCTP specific errors. diff --git a/programs/svm-spoke/src/instructions/fill.rs b/programs/svm-spoke/src/instructions/fill.rs index d8f965d81..f6aa2de2c 100644 --- a/programs/svm-spoke/src/instructions/fill.rs +++ b/programs/svm-spoke/src/instructions/fill.rs @@ -7,7 +7,7 @@ use anchor_spl::{ use crate::{ common::RelayData, constants::DISCRIMINATOR_SIZE, - constraints::is_relay_hash_valid, + constraints::{has_valid_params_presence, is_relay_hash_valid}, error::{CommonError, SvmError}, event::{FillType, FilledRelay, RelayExecutionEventInfo}, state::{FillRelayParams, FillStatus, FillStatusAccount, State}, @@ -16,7 +16,12 @@ use crate::{ #[event_cpi] #[derive(Accounts)] -#[instruction(relay_hash: [u8; 32], relay_data: Option)] +#[instruction( + relay_hash: [u8; 32], + relay_data: Option, + repayment_chain_id: Option, + repayment_address: Option +)] pub struct FillRelay<'info> { #[account(mut)] pub signer: Signer<'info>, @@ -66,6 +71,10 @@ pub struct FillRelay<'info> { space = DISCRIMINATOR_SIZE + FillStatusAccount::INIT_SPACE, seeds = [b"fills", relay_hash.as_ref()], bump, + // Validate optional parameters before they are unwrapped in other constraints by Anchor. + constraint = has_valid_params_presence( + &[relay_data.is_some(), repayment_chain_id.is_some(), repayment_address.is_some()], + instruction_params.is_some()) @ SvmError::InconsistentOptionalParameters, constraint = is_relay_hash_valid( &relay_hash, &relay_data.clone().unwrap_or_else(|| instruction_params.as_ref().unwrap().relay_data.clone()), @@ -187,7 +196,7 @@ fn unwrap_fill_relay_params( repayment_chain_id: account.repayment_chain_id, repayment_address: account.repayment_address, }) - .unwrap(), // We do not expect this to panic here as missing instruction_params is unwrapped in context. + .unwrap(), // We do not expect this to panic here as optional parameters are validated in context. } } diff --git a/programs/svm-spoke/src/instructions/slow_fill.rs b/programs/svm-spoke/src/instructions/slow_fill.rs index 7c3ae336c..274b85c81 100644 --- a/programs/svm-spoke/src/instructions/slow_fill.rs +++ b/programs/svm-spoke/src/instructions/slow_fill.rs @@ -5,7 +5,7 @@ use crate::event::{FillType, FilledRelay, RelayExecutionEventInfo, RequestedSlow use crate::{ common::RelayData, constants::DISCRIMINATOR_SIZE, - constraints::is_relay_hash_valid, + constraints::{has_valid_params_presence, is_relay_hash_valid}, error::{CommonError, SvmError}, state::{ExecuteSlowRelayLeafParams, FillStatus, FillStatusAccount, RequestSlowFillParams, RootBundle, State}, utils::{get_current_time, hash_non_empty_message, invoke_handler, verify_merkle_proof}, @@ -35,6 +35,10 @@ pub struct RequestSlowFill<'info> { space = DISCRIMINATOR_SIZE + FillStatusAccount::INIT_SPACE, seeds = [b"fills", _relay_hash.as_ref()], bump, + // Validate optional parameters before they are unwrapped in other constraints by Anchor. + constraint = has_valid_params_presence( + &[relay_data.is_some()], + instruction_params.is_some()) @ SvmError::InconsistentOptionalParameters, constraint = is_relay_hash_valid( &_relay_hash, &relay_data.clone().unwrap_or_else(|| instruction_params.as_ref().unwrap().relay_data.clone()), @@ -101,7 +105,7 @@ fn unwrap_request_slow_fill_params( _ => account .as_ref() .map(|account| RequestSlowFillParams { relay_data: account.relay_data.clone() }) - .unwrap(), // We do not expect this to panic here as missing instruction_params is unwrapped in context. + .unwrap(), // We do not expect this to panic here as optional parameters are validated in context. } } @@ -133,7 +137,12 @@ impl SlowFill { #[event_cpi] #[derive(Accounts)] -#[instruction(_relay_hash: [u8; 32], slow_fill_leaf: Option, _root_bundle_id: Option)] +#[instruction( + _relay_hash: [u8; 32], + slow_fill_leaf: Option, + _root_bundle_id: Option, + proof: Option> +)] pub struct ExecuteSlowRelayLeaf<'info> { pub signer: Signer<'info>, @@ -141,7 +150,15 @@ pub struct ExecuteSlowRelayLeaf<'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)] + #[account( + seeds = [b"state", state.seed.to_le_bytes().as_ref()], + bump, + // This check is not directly relevant to the state account, but the validation must be performed before any of + // the optional params are unwrapped by Anchor. + constraint = has_valid_params_presence( + &[slow_fill_leaf.is_some(), _root_bundle_id.is_some(), proof.is_some()], + instruction_params.is_some()) @ SvmError::InconsistentOptionalParameters + )] pub state: Account<'info, State>, #[account( @@ -310,6 +327,6 @@ fn unwrap_execute_slow_relay_leaf_params( root_bundle_id: account.root_bundle_id, proof: account.proof.clone(), }) - .unwrap(), // We do not expect this to panic here as missing instruction_params is unwrapped in context. + .unwrap(), // We do not expect this to panic here as optional parameters are validated in context. } } diff --git a/programs/svm-spoke/src/lib.rs b/programs/svm-spoke/src/lib.rs index e4524fefb..c9335cc85 100644 --- a/programs/svm-spoke/src/lib.rs +++ b/programs/svm-spoke/src/lib.rs @@ -406,8 +406,8 @@ pub mod svm_spoke { /// - repayment_chain_id: Chain of SpokePool where relayer wants to be refunded after the challenge window has /// passed. Will receive input_amount of the equivalent token to input_token on the repayment chain. /// - repayment_address: The address of the recipient on the repayment chain that they want to be refunded to. - /// Note: relay_data, repayment_chain_id, and repayment_address are optional parameters. If None for any of these - /// is passed, the caller must load them via the instruction_params account. + /// Note: relay_data, repayment_chain_id, and repayment_address are optional parameters, but their presence must be + /// consistent. If None for these 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], @@ -715,8 +715,8 @@ pub mod svm_spoke { /// this will be set higher to reimburse the recipient for waiting for the slow fill. /// - _root_bundle_id: Unique ID of root bundle containing slow relay root that this leaf is contained in. /// - proof: Inclusion proof for this leaf in slow relay root in root bundle. - /// Note: slow_fill_leaf, _root_bundle_id, and proof are optional parameters. If None for any of these is passed, - /// the caller must load them via the instruction_params account. + /// Note: slow_fill_leaf, _root_bundle_id, and proof are optional parameters, but their presence must be consistent. + /// If None for these parameters is passed, the caller must load them via the instruction_params account. /// Note: When verifying the slow fill leaf, the relay data is hashed using AnchorSerialize::serialize that encodes /// output token amounts to little-endian format while input token amount preserves its big-endian encoding as it /// is passed as [u8; 32] array. diff --git a/src/types/svm.ts b/src/types/svm.ts index b3fd17ee7..7a86d606d 100644 --- a/src/types/svm.ts +++ b/src/types/svm.ts @@ -136,3 +136,50 @@ export interface EventType { * Supported Networks */ export type SupportedNetworks = "mainnet" | "devnet"; + +/** + * Fill instruction accounts + */ +export type FillAccounts = { + signer: PublicKey; + instructionParams: PublicKey; + state: PublicKey; + delegate: PublicKey; + mint: PublicKey; + relayerTokenAccount: PublicKey; + recipientTokenAccount: PublicKey; + fillStatus: PublicKey; + tokenProgram: PublicKey; + associatedTokenProgram: PublicKey; + systemProgram: PublicKey; + program: PublicKey; +}; + +/** + * Request Slow Fill Accounts + */ +export type RequestSlowFillAccounts = { + signer: PublicKey; + instructionParams: PublicKey; + state: PublicKey; + fillStatus: PublicKey; + systemProgram: PublicKey; + program: PublicKey; +}; + +/** + * Execute Slow Relay Leaf Accounts + */ +export type ExecuteSlowRelayLeafAccounts = { + signer: PublicKey; + instructionParams: PublicKey; + state: PublicKey; + rootBundle: PublicKey; + fillStatus: PublicKey; + mint: PublicKey; + recipientTokenAccount: PublicKey; + vault: PublicKey; + tokenProgram: PublicKey; + systemProgram: PublicKey; + program: PublicKey; +}; diff --git a/test/svm/SvmSpoke.Fill.ts b/test/svm/SvmSpoke.Fill.ts index baea901b7..e42aacb04 100644 --- a/test/svm/SvmSpoke.Fill.ts +++ b/test/svm/SvmSpoke.Fill.ts @@ -45,7 +45,7 @@ import { readEventsUntilFound, sendTransactionWithLookupTable as sendTransactionWithLookupTableV1, } from "../../src/svm/web3-v1"; -import { FillDataValues, RelayData } from "../../src/types/svm"; +import { FillDataValues, RelayData, FillAccounts } from "../../src/types/svm"; import { common } from "./SvmSpoke.common"; import { createDefaultSolanaClient, testAcrossPlusMessage } from "./utils"; const { @@ -81,21 +81,6 @@ describe("svm_spoke.fill", () => { const relayAmount = 500000; let relayData: RelayData; // reused relay data for all tests. - type FillAccounts = { - state: PublicKey; - delegate: PublicKey; - signer: PublicKey; - instructionParams: PublicKey; - mint: PublicKey; - relayerTokenAccount: PublicKey; - recipientTokenAccount: PublicKey; - fillStatus: PublicKey; - tokenProgram: PublicKey; - associatedTokenProgram: PublicKey; - systemProgram: PublicKey; - program: PublicKey; - }; - let accounts: FillAccounts; // Store accounts to simplify contract interactions. const updateRelayData = (newRelayData: RelayData) => { diff --git a/test/svm/SvmSpoke.OptionalParams.ts b/test/svm/SvmSpoke.OptionalParams.ts new file mode 100644 index 000000000..f2365d72e --- /dev/null +++ b/test/svm/SvmSpoke.OptionalParams.ts @@ -0,0 +1,412 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import * as crypto from "crypto"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createApproveCheckedInstruction, + createMint, + getOrCreateAssociatedTokenAccount, + mintTo, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { Keypair, PublicKey, sendAndConfirmTransaction, Transaction } from "@solana/web3.js"; +import { MerkleTree } from "@uma/common/dist/MerkleTree"; +import { + calculateRelayHashUint8Array, + getFillRelayDelegatePda, + intToU8Array32, + loadExecuteSlowRelayLeafParams, + loadFillRelayParams, + loadRequestSlowFillParams, + slowFillHashFn, +} from "../../src/svm/web3-v1"; +import { + RelayData, + FillAccounts, + RequestSlowFillAccounts, + ExecuteSlowRelayLeafAccounts, + SlowFillLeaf, +} from "../../src/types/svm"; +import { common } from "./SvmSpoke.common"; +const { provider, connection, program, owner, chainId, recipient, seedBalance, initializeState, assert } = common; + +describe("svm_spoke.optional_params", () => { + anchor.setProvider(provider); + const { payer } = anchor.AnchorProvider.env().wallet as anchor.Wallet; + const relayer = Keypair.generate(); + + let recipientATA: PublicKey, + state: PublicKey, + seed: BN, + mint: PublicKey, + relayerATA: PublicKey, + vault: PublicKey, + instructionParams: PublicKey, + fillStatusPDA: PublicKey, + relayData: RelayData, + relayHashUint8Array: Uint8Array, + relayHash: number[], + slowRelayLeaf: SlowFillLeaf, + rootBundleId: number, + proofAsNumbers: number[][], + fillAccounts: FillAccounts, + requestSlowFillAccounts: RequestSlowFillAccounts, + executeSlowRelayLeafAccounts: ExecuteSlowRelayLeafAccounts; + + const relayAmount = 500000; + const mintDecimals = 6; + const originChainId = new BN(1); + const repaymentChainId = new BN(1); + const repaymentAddress = relayer.publicKey; + + type BooleanTuple = T["length"] extends N + ? T + : BooleanTuple; + + const allBooleanCombos = (count: N): BooleanTuple[] => { + if (!Number.isInteger(count) || count < 0) { + throw new RangeError("count must be a non-negative integer"); + } + if (count > 10) { + throw new RangeError("count must be <= 10 to avoid out of memory issues"); + } + + return Array.from({ length: 1 << count }, (_, i) => { + const row = Array.from({ length: count }, (_, bit) => ((i >> (count - 1 - bit)) & 1) === 1); + return row as BooleanTuple; + }); + }; + + const validOptionalFillParamPresence = ( + nullRelayData: boolean, + nullRepaymentChainId: boolean, + nullRepaymentAddress: boolean, + nullInstructionParams: boolean + ): boolean => { + return ( + (nullRelayData && nullRepaymentChainId && nullRepaymentAddress && !nullInstructionParams) || + (!nullRelayData && !nullRepaymentChainId && !nullRepaymentAddress && nullInstructionParams) + ); + }; + + const validOptionalRequestSlowFillParamPresence = ( + nullRelayData: boolean, + nullInstructionParams: boolean + ): boolean => { + return nullRelayData !== nullInstructionParams; + }; + + const validOptionalExecuteSlowFillParamPresence = ( + nullSlowFillLeaf: boolean, + nullRootBundleId: boolean, + nullProof: boolean, + nullInstructionParams: boolean + ): boolean => { + return ( + (nullSlowFillLeaf && nullRootBundleId && nullProof && !nullInstructionParams) || + (!nullSlowFillLeaf && !nullRootBundleId && !nullProof && nullInstructionParams) + ); + }; + + const updateRelayData = async () => { + relayData = { + depositor: recipient, + recipient: recipient, + exclusiveRelayer: anchor.web3.SystemProgram.programId, // No exclusivity. + inputToken: mint, // This is lazy. it should be an encoded token from a separate domain most likely. + outputToken: mint, + inputAmount: intToU8Array32(relayAmount), + outputAmount: new BN(relayAmount), + originChainId, + depositId: intToU8Array32(Math.floor(Math.random() * 1000000)), // force that we always have a new deposit id. + fillDeadline: Math.floor(Date.now() / 1000) + 60, // 1 minute from now + exclusivityDeadline: 0, // Exclusivity is not used in this test. + message: Buffer.from(""), + }; + relayHashUint8Array = calculateRelayHashUint8Array(relayData, chainId); + relayHash = Array.from(relayHashUint8Array); + [fillStatusPDA] = PublicKey.findProgramAddressSync([Buffer.from("fills"), relayHashUint8Array], program.programId); + }; + + const setUpFillTest = async () => { + await updateRelayData(); + + // Prepare instruction_params account, but some test iterations would not need it + await loadFillRelayParams(program, relayer, relayData, repaymentChainId, repaymentAddress); + + fillAccounts = { + signer: relayer.publicKey, + instructionParams, // Can be overriden to program.programId in a test iteration + state, + delegate: getFillRelayDelegatePda(relayHashUint8Array, repaymentChainId, repaymentAddress, program.programId).pda, + mint, + relayerTokenAccount: relayerATA, + recipientTokenAccount: recipientATA, + fillStatus: fillStatusPDA, + tokenProgram: TOKEN_PROGRAM_ID, + associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + program: program.programId, + }; + }; + + const setUpRequestSlowFillTest = async () => { + await updateRelayData(); + + // Prepare instruction_params account, but some test iterations would not need it + const loadInstructions = await loadRequestSlowFillParams(program, relayer, relayData); + for (let i = 0; i < loadInstructions.length; i += 1) { + await sendAndConfirmTransaction(program.provider.connection, new Transaction().add(loadInstructions[i]), [ + relayer, + ]); + } + + requestSlowFillAccounts = { + signer: relayer.publicKey, + instructionParams, // Can be overriden to program.programId in a test iteration + state, + fillStatus: fillStatusPDA, + systemProgram: anchor.web3.SystemProgram.programId, + program: program.programId, + }; + }; + + const setUpExecuteSlowFillTest = async () => { + await updateRelayData(); + + // Make slow fill request first + requestSlowFillAccounts = { + signer: relayer.publicKey, + instructionParams: program.programId, // We test execution here, so no instruction_params account for the request. + state, + fillStatus: fillStatusPDA, + systemProgram: anchor.web3.SystemProgram.programId, + program: program.programId, + }; + const requestSlowFillIx = await program.methods + .requestSlowFill(relayHash, relayData) + .accounts(requestSlowFillAccounts) + .instruction(); + await sendAndConfirmTransaction(connection, new Transaction().add(requestSlowFillIx), [relayer]); + + // Prepare and relay slow fill root bundle + slowRelayLeaf = { + relayData, + chainId, + updatedOutputAmount: relayData.outputAmount, + }; + const merkleTree = new MerkleTree([slowRelayLeaf], slowFillHashFn); + const slowRelayRoot = merkleTree.getRoot(); + const proof = merkleTree.getProof(slowRelayLeaf); + proofAsNumbers = proof.map((p) => Array.from(p)); + const stateAccountData = await program.account.state.fetch(state); + rootBundleId = stateAccountData.rootBundleId; + const rootBundleIdBuffer = Buffer.alloc(4); + rootBundleIdBuffer.writeUInt32LE(rootBundleId); + const seeds = [Buffer.from("root_bundle"), seed.toArrayLike(Buffer, "le", 8), rootBundleIdBuffer]; + const [rootBundle] = PublicKey.findProgramAddressSync(seeds, program.programId); + const relayerRefundRoot = crypto.randomBytes(32); + const relayRootBundleAccounts = { state, rootBundle, signer: owner, payer: owner, program: program.programId }; + await program.methods + .relayRootBundle(Array.from(relayerRefundRoot), Array.from(slowRelayRoot)) + .accounts(relayRootBundleAccounts) + .rpc(); + + // Prepare instruction_params account, but some test iterations would not need it + const loadInstructions = await loadExecuteSlowRelayLeafParams( + program, + relayer, + slowRelayLeaf, + rootBundleId, + proofAsNumbers + ); + for (let i = 0; i < loadInstructions.length; i += 1) { + await sendAndConfirmTransaction(program.provider.connection, new Transaction().add(loadInstructions[i]), [ + relayer, + ]); + } + + executeSlowRelayLeafAccounts = { + signer: relayer.publicKey, + instructionParams, // Can be overriden to program.programId in a test iteration + state, + rootBundle, + fillStatus: fillStatusPDA, + mint, + recipientTokenAccount: recipientATA, + vault, + tokenProgram: TOKEN_PROGRAM_ID, + systemProgram: anchor.web3.SystemProgram.programId, + program: program.programId, + }; + }; + + const testApproveAndFill = async ( + nullRelayData: boolean, + nullRepaymentChainId: boolean, + nullRepaymentAddress: boolean, + nullInstructionParams: boolean + ) => { + // Set null values based on the tested parameter combinations + const relayDataToUse = nullRelayData ? null : relayData; + const repaymentChainIdToUse = nullRepaymentChainId ? null : repaymentChainId; + const repaymentAddressToUse = nullRepaymentAddress ? null : repaymentAddress; + const fillAccountsToUse = nullInstructionParams + ? { ...fillAccounts, instructionParams: program.programId } + : fillAccounts; + + const approveIx = await createApproveCheckedInstruction( + fillAccountsToUse.relayerTokenAccount, + fillAccountsToUse.mint, + fillAccountsToUse.delegate, + fillAccountsToUse.signer, + BigInt(relayData.outputAmount.toString()), + mintDecimals, + undefined, + fillAccountsToUse.tokenProgram + ); + + const fillIx = await program.methods + .fillRelay(relayHash, relayDataToUse, repaymentChainIdToUse, repaymentAddressToUse) + .accounts(fillAccountsToUse) + .instruction(); + + if ( + validOptionalFillParamPresence(nullRelayData, nullRepaymentChainId, nullRepaymentAddress, nullInstructionParams) + ) { + await sendAndConfirmTransaction(connection, new Transaction().add(approveIx, fillIx), [relayer]); + + // Since the transaction was successful, prepare for the next test iteration + await setUpFillTest(); + } else { + try { + await sendAndConfirmTransaction(connection, new Transaction().add(approveIx, fillIx), [relayer]); + assert.fail("Fill should have failed due to inconsistent optional params"); + } catch (err: any) { + assert.include( + err.toString(), + "InconsistentOptionalParameters", + "Expected InconsistentOptionalParameters error" + ); + } + } + }; + + const testRequestSlowFill = async (nullRelayData: boolean, nullInstructionParams: boolean) => { + // Set null values based on the tested parameter combinations + const relayDataToUse = nullRelayData ? null : relayData; + const requestSlowFillAccountsToUse = nullInstructionParams + ? { ...requestSlowFillAccounts, instructionParams: program.programId } + : requestSlowFillAccounts; + + const requestSlowFillIx = await program.methods + .requestSlowFill(relayHash, relayDataToUse) + .accounts(requestSlowFillAccountsToUse) + .instruction(); + + if (validOptionalRequestSlowFillParamPresence(nullRelayData, nullInstructionParams)) { + await sendAndConfirmTransaction(connection, new Transaction().add(requestSlowFillIx), [relayer]); + + // Since the transaction was successful, prepare for the next test iteration + await setUpRequestSlowFillTest(); + } else { + try { + await sendAndConfirmTransaction(connection, new Transaction().add(requestSlowFillIx), [relayer]); + assert.fail("Request slow fill should have failed due to inconsistent optional params"); + } catch (err: any) { + assert.include( + err.toString(), + "InconsistentOptionalParameters", + "Expected InconsistentOptionalParameters error" + ); + } + } + }; + + const testExecuteSlowFill = async ( + nullSlowFillLeaf: boolean, + nullRootBundleId: boolean, + nullProof: boolean, + nullInstructionParams: boolean + ) => { + // Set null values based on the tested parameter combinations + const slowFillLeafToUse = nullSlowFillLeaf ? null : slowRelayLeaf; + const rootBundleIdToUse = nullRootBundleId ? null : rootBundleId; + const proofToUse = nullProof ? null : proofAsNumbers; + const executeSlowFillAccountsToUse = nullInstructionParams + ? { ...executeSlowRelayLeafAccounts, instructionParams: program.programId } + : executeSlowRelayLeafAccounts; + + const executeSlowFillIx = await program.methods + .executeSlowRelayLeaf(relayHash, slowFillLeafToUse, rootBundleIdToUse, proofToUse) + .accounts(executeSlowFillAccountsToUse) + .instruction(); + + if ( + validOptionalExecuteSlowFillParamPresence(nullSlowFillLeaf, nullRootBundleId, nullProof, nullInstructionParams) + ) { + await sendAndConfirmTransaction(connection, new Transaction().add(executeSlowFillIx), [relayer]); + + // Since the transaction was successful, prepare for the next test iteration + await setUpExecuteSlowFillTest(); + } else { + try { + await sendAndConfirmTransaction(connection, new Transaction().add(executeSlowFillIx), [relayer]); + assert.fail("Execute slow fill should have failed due to inconsistent optional params"); + } catch (err: any) { + assert.include( + err.toString(), + "InconsistentOptionalParameters", + "Expected InconsistentOptionalParameters error" + ); + } + } + }; + + before(async () => { + await connection.requestAirdrop(relayer.publicKey, 10_000_000_000); // 10 SOL + + [instructionParams] = PublicKey.findProgramAddressSync( + [Buffer.from("instruction_params"), relayer.publicKey.toBuffer()], + program.programId + ); + }); + + beforeEach(async () => { + ({ state, seed } = await initializeState()); + + // Creates token mint and associated token accounts + mint = await createMint(connection, payer, owner, owner, mintDecimals); + recipientATA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, recipient)).address; + relayerATA = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, relayer.publicKey)).address; + vault = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, state, true)).address; + await mintTo(connection, payer, mint, relayerATA, owner, seedBalance); + await mintTo(connection, payer, mint, vault, owner, seedBalance); + }); + + it("All fill_relay optional param combinations", async () => { + await setUpFillTest(); + + for (const [nullRelayData, nullRepaymentChainId, nullRepaymentAddress, nullInstructionParams] of allBooleanCombos( + 4 + )) { + await testApproveAndFill(nullRelayData, nullRepaymentChainId, nullRepaymentAddress, nullInstructionParams); + } + }); + + it("All request_slow_fill optional param combinations", async () => { + await setUpRequestSlowFillTest(); + + for (const [nullRelayData, nullInstructionParams] of allBooleanCombos(2)) { + await testRequestSlowFill(nullRelayData, nullInstructionParams); + } + }); + + it("All execute_slow_fill optional param combinations", async () => { + await setUpExecuteSlowFillTest(); + + for (const [nullSlowFillLeaf, nullRootBundleId, nullProof, nullInstructionParams] of allBooleanCombos(4)) { + await testExecuteSlowFill(nullSlowFillLeaf, nullRootBundleId, nullProof, nullInstructionParams); + } + }); +}); From 6e705fb5726282e9dd75a9edd3d6a9d84236614b Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Tue, 2 Jun 2026 13:46:56 +0000 Subject: [PATCH 2/3] fix: use local MerkleTree in SVM optional params test Signed-off-by: Reinis Martinsons --- test/svm/SvmSpoke.OptionalParams.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/svm/SvmSpoke.OptionalParams.ts b/test/svm/SvmSpoke.OptionalParams.ts index f2365d72e..f29c3a0bd 100644 --- a/test/svm/SvmSpoke.OptionalParams.ts +++ b/test/svm/SvmSpoke.OptionalParams.ts @@ -10,7 +10,7 @@ import { TOKEN_PROGRAM_ID, } from "@solana/spl-token"; import { Keypair, PublicKey, sendAndConfirmTransaction, Transaction } from "@solana/web3.js"; -import { MerkleTree } from "@uma/common/dist/MerkleTree"; +import { MerkleTree } from "../../utils/MerkleTree"; import { calculateRelayHashUint8Array, getFillRelayDelegatePda, From acc7a8bf55b365990fc7fcec758640f3331e89e7 Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Tue, 2 Jun 2026 13:57:52 +0000 Subject: [PATCH 3/3] chore: bump beta prerelease version Signed-off-by: Reinis Martinsons --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c50082baa..9ad188dcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@across-protocol/contracts", - "version": "5.0.11", + "version": "5.0.12-beta.0", "author": "UMA Team", "license": "AGPL-3.0-only", "repository": {