diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a1fb2a89c..10ba081f3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -162,9 +162,9 @@ jobs: - name: Archive verified test build (for caching) if: steps.verified-test-build-cache.outputs.cache-hit != 'true' run: tar -cf svm-verified-test-binaries.tar target/deploy - - name: Generate test-only mock IDL + - name: Generate test-feature IDLs if: steps.filter.outputs.svm == 'true' - run: anchor idl build --program-name mock_gateway --out target/idl/mock_gateway.json -- --features test + run: yarn generate-svm-test-idls - name: Test verified SVM build if: steps.filter.outputs.svm == 'true' run: anchor test --skip-build diff --git a/package.json b/package.json index e0a01b5be..1dbf9d1ea 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "clean-fast": "for dir in node_modules dist cache-foundry out zkout; do mv \"${dir}\" \"_${dir}\"; rm -rf \"_${dir}\" &; done", "clean": "rm -rf node_modules dist cache-foundry out zkout", "generate-svm-artifacts": "bash ./scripts/svm/buildHelpers/buildIdl.sh && sh ./scripts/svm/buildHelpers/generateSvmAssets.sh && yarn generate-svm-clients", + "generate-svm-test-idls": "bash ./scripts/svm/buildHelpers/buildTestIdls.sh", "generate-svm-clients": "yarn ts-node ./scripts/svm/buildHelpers/generateSvmClients.ts && yarn ts-node ./scripts/svm/buildHelpers/renameClientsImports.ts", "build-svm": "bash ./scripts/svm/buildHelpers/buildSvmLocalToolchain.sh", "build-svm-solana-verify": "bash ./scripts/svm/buildHelpers/buildSolanaVerify.sh", @@ -46,8 +47,8 @@ "build-evm-foundry": "forge build", "test-evm": "yarn test-evm-foundry", "test-evm-foundry": "FOUNDRY_PROFILE=local-test forge test", - "test-svm": "IS_TEST=true yarn build-svm && yarn generate-svm-artifacts && anchor test --skip-build", - "test-svm-solana-verify": "IS_TEST=true yarn build-svm-solana-verify && yarn generate-svm-artifacts && anchor test --skip-build", + "test-svm": "IS_TEST=true yarn build-svm && yarn generate-svm-artifacts && yarn generate-svm-test-idls && anchor test --skip-build", + "test-svm-solana-verify": "IS_TEST=true yarn build-svm-solana-verify && yarn generate-svm-artifacts && yarn generate-svm-test-idls && anchor test --skip-build", "test": "yarn test-evm && yarn test-svm", "test-verified": "yarn test-evm && yarn test-svm-solana-verify", "evm-contract-sizes": "forge build --sizes", diff --git a/programs/svm-spoke/V5_ADAPTER_SPEC.md b/programs/svm-spoke/V5_ADAPTER_SPEC.md index 898c441a8..31568fa7d 100644 --- a/programs/svm-spoke/V5_ADAPTER_SPEC.md +++ b/programs/svm-spoke/V5_ADAPTER_SPEC.md @@ -126,10 +126,15 @@ Gateway-vault delivery validates that same live vault in place and its amount, r self-transfer or approval. The continuing atomic tape must consume the output. Fill-status expiry reclaim is permissionless and closes back to the submitter-scoped payer PDA, replenishing its -standing float. Only that submitter may withdraw the float to itself; a nonzero remainder must be rent-exempt, and -`u64::MAX` means withdraw the live balance. V5 fills emit the existing `FilledRelay` schema and derive the relay hash +standing float. Only that submitter may withdraw the float to itself. Partial withdrawals remain subject to Solana's +runtime rent-state rules, while `u64::MAX` withdraws the live balance. This also relaxes `close_fill_pda` for existing +fill-status accounts: old clients may continue supplying the recorded relayer signature, but it is no longer required. +The unchanged `FillStatusAccount.relayer` field stores the payer PDA for +V5 fills (legacy fills continue to store their relayer), binding permissionless reclaim to the float that paid the +rent without an account-layout migration. V5 fills emit the existing `FilledRelay` schema and derive the relay hash from the supplied standard `RelayData` and the configured SVM chain ID. Adapter mode uses no callback message; the -relay witness remains exactly `V5_MAGIC_PREFIX || step_id`. +relay witness remains exactly `V5_MAGIC_PREFIX || step_id`. As on EVM, V5-tagged relays are quarantined +from the slow-fill lifecycle, so their fill status can only transition directly from an uninitialized PDA to `Filled`. Token-2022 mint extensions fail closed. Wire version 1 permits only mint-close authority and metadata/group pointer or data extensions. Transfer fees remain excluded until debit/delivery delta semantics are defined; transfer hooks, diff --git a/programs/svm-spoke/src/error.rs b/programs/svm-spoke/src/error.rs index c5f484766..42ef1012d 100644 --- a/programs/svm-spoke/src/error.rs +++ b/programs/svm-spoke/src/error.rs @@ -109,6 +109,10 @@ pub enum V5Error { InvalidTokenAccount, #[msg("Unsupported Across V5 token extension!")] UnsupportedTokenExtension, + #[msg("Invalid Across V5 fill payer!")] + InvalidFillPayer, + #[msg("Invalid Across V5 fill status account!")] + InvalidFillStatusAccount, } // CCTP specific errors. diff --git a/programs/svm-spoke/src/event.rs b/programs/svm-spoke/src/event.rs index 64c9bc655..dfa7a294a 100644 --- a/programs/svm-spoke/src/event.rs +++ b/programs/svm-spoke/src/event.rs @@ -86,6 +86,12 @@ pub struct FilledRelay { pub relay_execution_info: RelayExecutionEventInfo, } +#[event] +pub struct V5FillFloatWithdrawn { + pub submitter: Pubkey, + pub amount: u64, +} + // Slow fill events #[event] pub struct RequestedSlowFill { diff --git a/programs/svm-spoke/src/instructions/fill.rs b/programs/svm-spoke/src/instructions/fill.rs index e629ab99f..697057cad 100644 --- a/programs/svm-spoke/src/instructions/fill.rs +++ b/programs/svm-spoke/src/instructions/fill.rs @@ -210,8 +210,9 @@ fn unwrap_fill_relay_params( #[derive(Accounts)] pub struct CloseFillPda<'info> { + /// CHECK: The address constraint binds this non-signing account to the recorded rent recipient. #[account(mut, address = fill_status.relayer @ SvmError::NotRelayer)] - pub signer: Signer<'info>, + pub signer: UncheckedAccount<'info>, #[account(seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)] pub state: Account<'info, State>, diff --git a/programs/svm-spoke/src/instructions/mod.rs b/programs/svm-spoke/src/instructions/mod.rs index 09a326e58..fb5bd9ebf 100644 --- a/programs/svm-spoke/src/instructions/mod.rs +++ b/programs/svm-spoke/src/instructions/mod.rs @@ -8,6 +8,7 @@ mod instruction_params; mod refund_claims; mod slow_fill; mod v5_adapter; +mod v5_fill_status; pub use admin::*; pub use bundle::*; @@ -19,3 +20,4 @@ pub use instruction_params::*; pub use refund_claims::*; pub use slow_fill::*; pub use v5_adapter::*; +pub use v5_fill_status::*; diff --git a/programs/svm-spoke/src/instructions/v5_fill_status.rs b/programs/svm-spoke/src/instructions/v5_fill_status.rs new file mode 100644 index 000000000..a37cceec5 --- /dev/null +++ b/programs/svm-spoke/src/instructions/v5_fill_status.rs @@ -0,0 +1,204 @@ +use anchor_lang::{ + prelude::*, + solana_program::{program::invoke_signed, system_instruction, system_program}, +}; + +use crate::{ + constants::{DISCRIMINATOR_SIZE, FILL_STATUS_SEED, V5_FILL_PAYER_SEED}, + error::{CommonError, V5Error}, + event::V5FillFloatWithdrawn, + state::{FillStatus, FillStatusAccount}, + v5::pda::{derive_fill_status, derive_v5_fill_payer}, + ID, +}; + +pub const V5_FILL_STATUS_SPACE: usize = DISCRIMINATOR_SIZE + FillStatusAccount::INIT_SPACE; + +pub struct V5FillStatusPdas<'a> { + submitter: &'a Pubkey, + relay_hash: &'a [u8; 32], + payer: Pubkey, + fill_status: Pubkey, + payer_bump: u8, + fill_status_bump: u8, +} + +impl<'a> V5FillStatusPdas<'a> { + #[cfg_attr(not(feature = "test"), allow(dead_code))] + pub fn derive(submitter: &'a Pubkey, relay_hash: &'a [u8; 32]) -> Self { + let (payer, payer_bump) = derive_v5_fill_payer(submitter); + let (fill_status, fill_status_bump) = derive_fill_status(relay_hash); + Self { submitter, relay_hash, payer, fill_status, payer_bump, fill_status_bump } + } + + pub fn payer(&self) -> Pubkey { + self.payer + } + + pub fn fill_status(&self) -> Pubkey { + self.fill_status + } +} + +#[must_use = "V5 fill-status storage must be finalized with write_filled"] +pub struct PendingV5FillStatus<'a, 'info> { + fill_status: AccountInfo<'info>, + pdas: &'a V5FillStatusPdas<'a>, +} + +impl PendingV5FillStatus<'_, '_> { + /// Serializes the terminal V5 fill status after the caller completes semantic validation and token delivery. + #[cfg_attr(not(feature = "test"), allow(dead_code))] + pub fn write_filled(self, fill_deadline: u32) -> Result<()> { + FillStatusAccount { status: FillStatus::Filled, relayer: self.pdas.payer(), fill_deadline } + .try_serialize(&mut &mut self.fill_status.try_borrow_mut_data()?[..]) + } +} + +/// Creates or assigns the zeroed storage for a V5 fill-status PDA using program-derived signers only. The creation +/// sequence intentionally mirrors Anchor 0.31.1's generated `init_if_needed` implementation in +/// `anchor-syn/src/codegen/accounts/constraints.rs::generate_create_account`: create an unfunded account, or top up, +/// allocate, and assign a prefunded account. This must be expanded here because Gateway does not forward the transaction +/// signer and Anchor cannot use the submitter-scoped payer PDA as its payer; `invoke_signed` supplies that signature only +/// to the nested System Program calls. An existing filled account is rejected as a replay; other program-owned states +/// are invalid because V5-tagged relays cannot enter the slow-fill lifecycle. +/// +/// # Safety +/// +/// The caller must derive `pdas` from the Gateway-attested submitter and the relay hash of the validated V5 `RelayData`, +/// then complete semantic validation before calling this helper. Every successful instruction path must then call +/// `PendingV5FillStatus::write_filled` with the unexpired deadline committed in that `RelayData`; failed paths atomically +/// roll back the zeroed intermediate account. +#[allow(dead_code)] // Called when Step 4 enables the reserved Fill adapter branch. +pub fn create_v5_fill_status_account<'a, 'info>( + payer: &AccountInfo<'info>, + fill_status: &AccountInfo<'info>, + system_program_info: &AccountInfo<'info>, + pdas: &'a V5FillStatusPdas<'a>, +) -> Result> { + require_keys_eq!(*payer.key, pdas.payer(), V5Error::InvalidFillPayer); + require_keys_eq!(*fill_status.key, pdas.fill_status(), V5Error::InvalidFillStatusAccount); + require_keys_eq!(*system_program_info.key, system_program::ID, V5Error::MissingAccount); + require!(payer.is_writable && fill_status.is_writable, V5Error::InvalidAccountMutability); + require_keys_eq!(*payer.owner, system_program::ID, V5Error::InvalidFillPayer); + require!(payer.data_is_empty(), V5Error::InvalidFillPayer); + if fill_status.owner == &ID { + let data = fill_status.try_borrow_data()?; + let existing = FillStatusAccount::try_deserialize(&mut &data[..]) + .map_err(|_| error!(V5Error::InvalidFillStatusAccount))?; + match existing.status { + FillStatus::Filled => return err!(CommonError::RelayFilled), + FillStatus::Unfilled | FillStatus::RequestedSlowFill => return err!(V5Error::InvalidFillStatusAccount), + } + } + let current_lamports = fill_status.lamports(); + let required_lamports = Rent::get()? + .minimum_balance(V5_FILL_STATUS_SPACE) + .max(1) + .saturating_sub(current_lamports); + let payer_seeds: &[&[u8]] = &[V5_FILL_PAYER_SEED, pdas.submitter.as_ref(), &[pdas.payer_bump]]; + let fill_status_seeds: &[&[u8]] = &[FILL_STATUS_SEED, pdas.relay_hash, &[pdas.fill_status_bump]]; + if current_lamports == 0 { + invoke_signed( + &system_instruction::create_account( + payer.key, + fill_status.key, + required_lamports, + V5_FILL_STATUS_SPACE as u64, + &ID, + ), + &[payer.clone(), fill_status.clone(), system_program_info.clone()], + &[payer_seeds, fill_status_seeds], + )?; + } else { + if required_lamports > 0 { + invoke_signed( + &system_instruction::transfer(payer.key, fill_status.key, required_lamports), + &[payer.clone(), fill_status.clone(), system_program_info.clone()], + &[payer_seeds], + )?; + } + invoke_signed( + &system_instruction::allocate(fill_status.key, V5_FILL_STATUS_SPACE as u64), + &[fill_status.clone(), system_program_info.clone()], + &[fill_status_seeds], + )?; + invoke_signed( + &system_instruction::assign(fill_status.key, &ID), + &[fill_status.clone(), system_program_info.clone()], + &[fill_status_seeds], + )?; + } + + Ok(PendingV5FillStatus { fill_status: fill_status.clone(), pdas }) +} + +#[cfg(feature = "test")] +#[derive(Accounts)] +pub struct TestCreateV5FillStatus<'info> { + pub submitter: Signer<'info>, + + /// CHECK: Validated by `create_v5_fill_status_account` against the submitter-scoped payer PDA. + #[account(mut)] + pub payer: UncheckedAccount<'info>, + + /// CHECK: Validated by `create_v5_fill_status_account` against the relay-scoped fill-status PDA. + #[account(mut)] + pub fill_status: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +/// Test-only entrypoint for focused coverage of the PDA-signed account-creation lifecycle. +#[cfg(feature = "test")] +pub fn test_create_v5_fill_status( + ctx: Context, + relay_hash: [u8; 32], + fill_deadline: u32, +) -> Result<()> { + let submitter = ctx.accounts.submitter.key(); + let pdas = V5FillStatusPdas::derive(&submitter, &relay_hash); + let pending_fill_status = create_v5_fill_status_account( + &ctx.accounts.payer.to_account_info(), + &ctx.accounts.fill_status.to_account_info(), + &ctx.accounts.system_program.to_account_info(), + &pdas, + )?; + pending_fill_status.write_filled(fill_deadline) +} + +#[derive(Accounts)] +pub struct WithdrawV5FillPayer<'info> { + /// The float owner and the only withdrawal destination. + #[account(mut)] + pub submitter: Signer<'info>, + + /// CHECK: A data-less, system-owned float PDA derived from the signing submitter. + #[account( + mut, + seeds = [V5_FILL_PAYER_SEED, submitter.key().as_ref()], + bump + )] + pub payer: UncheckedAccount<'info>, + + pub system_program: Program<'info, System>, +} + +/// Withdraws from the signing submitter's own fill-status rent float. `u64::MAX` drains the live balance. +pub fn withdraw_v5_fill_payer(ctx: Context, amount: u64) -> Result<()> { + let submitter = ctx.accounts.submitter.key(); + let balance = ctx.accounts.payer.lamports(); + let amount = if amount == u64::MAX { balance } else { amount }; + let seeds: &[&[u8]] = &[V5_FILL_PAYER_SEED, submitter.as_ref(), &[ctx.bumps.payer]]; + invoke_signed( + &system_instruction::transfer(ctx.accounts.payer.key, &submitter, amount), + &[ + ctx.accounts.payer.to_account_info(), + ctx.accounts.submitter.to_account_info(), + ctx.accounts.system_program.to_account_info(), + ], + &[seeds], + )?; + emit!(V5FillFloatWithdrawn { submitter, amount }); + Ok(()) +} diff --git a/programs/svm-spoke/src/lib.rs b/programs/svm-spoke/src/lib.rs index d21b0c9f4..63eb362a9 100644 --- a/programs/svm-spoke/src/lib.rs +++ b/programs/svm-spoke/src/lib.rs @@ -444,17 +444,32 @@ pub mod svm_spoke { /// Closes the FillStatusAccount PDA to reclaim relayer rent. /// /// This function is used to close the FillStatusAccount associated with a specific relay hash, effectively marking - /// the end of its lifecycle. This can only be done once the fill deadline has passed. Relayers should do this for - /// all fills once they expire to reclaim their rent. + /// the end of its lifecycle. This can only be done once the fill deadline has passed. Anyone can trigger closure, + /// but rent is always returned to the recorded relayer. /// /// ### Required Accounts: - /// - signer (Signer): The account that authorizes the closure. Must be the relayer in the fill_status PDA. + /// - signer (Writable): The recorded relayer that receives rent; no signature is required. /// - state (Writable): Spoke state PDA. Seed: ["state",state.seed] where seed is 0 on mainnet. /// - fill_status (Writable): The FillStatusAccount PDA to be closed. pub fn close_fill_pda(ctx: Context) -> Result<()> { instructions::close_fill_pda(ctx) } + /// Withdraws lamports from the signing submitter's V5 fill-status payer float back to that same submitter. Partial + /// withdrawals remain subject to the runtime's rent-state rules; `u64::MAX` withdraws the live balance. + pub fn withdraw_v5_fill_payer(ctx: Context, amount: u64) -> Result<()> { + instructions::withdraw_v5_fill_payer(ctx, amount) + } + + #[cfg(feature = "test")] + pub fn test_create_v5_fill_status( + ctx: Context, + relay_hash: [u8; 32], + fill_deadline: u32, + ) -> Result<()> { + instructions::test_create_v5_fill_status(ctx, relay_hash, fill_deadline) + } + /// Claims a relayer refund for the caller. /// /// In the event a relayer refund was sent to a claim account, then this function enables the relayer to claim it by diff --git a/programs/svm-spoke/src/state/fill.rs b/programs/svm-spoke/src/state/fill.rs index da7fc1c84..c89bc8585 100644 --- a/programs/svm-spoke/src/state/fill.rs +++ b/programs/svm-spoke/src/state/fill.rs @@ -11,6 +11,6 @@ pub enum FillStatus { #[derive(InitSpace)] pub struct FillStatusAccount { pub status: FillStatus, // Tracks the status of the fill between Unfilled, requestedSlowFill, and Filled. - pub relayer: Pubkey, // Address of the relayer that made the fill to control who can close this PDA. + pub relayer: Pubkey, // Rent recipient for closing this PDA; legacy fills store the submitting relayer. pub fill_deadline: u32, // Stores the fill deadline to control when this PDA can be safely closed. } diff --git a/programs/svm-spoke/src/v5/tests.rs b/programs/svm-spoke/src/v5/tests.rs index aea3eb321..a98a0a5b5 100644 --- a/programs/svm-spoke/src/v5/tests.rs +++ b/programs/svm-spoke/src/v5/tests.rs @@ -43,7 +43,7 @@ fn assert_error_name(result: Result, expected: &str) { fn v5_errors_use_dedicated_range() { assert_eq!(u32::from(V5Error::InvalidWireFormat), 7_000); assert_eq!(u32::from(V5Error::InvalidAmountBips), 7_008); - assert_eq!(u32::from(V5Error::UnsupportedTokenExtension), 7_011); + assert_eq!(u32::from(V5Error::InvalidFillStatusAccount), 7_013); } #[test] diff --git a/scripts/svm/buildHelpers/buildTestIdls.sh b/scripts/svm/buildHelpers/buildTestIdls.sh new file mode 100644 index 000000000..16dafeb74 --- /dev/null +++ b/scripts/svm/buildHelpers/buildTestIdls.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Keep test-only artifacts in target so they cannot leak into package assets. +anchor idl build \ + --program-name svm_spoke \ + --out target/idl/svm_spoke.json \ + --out-ts target/types/svm_spoke.ts \ + -- --features test +anchor idl build \ + --program-name mock_gateway \ + --out target/idl/mock_gateway.json \ + --out-ts target/types/mock_gateway.ts \ + -- --features test diff --git a/test/svm/SvmSpoke.Fill.ts b/test/svm/SvmSpoke.Fill.ts index 78bc6d273..b4d1a95fc 100644 --- a/test/svm/SvmSpoke.Fill.ts +++ b/test/svm/SvmSpoke.Fill.ts @@ -353,7 +353,7 @@ describe("svm_spoke.fill", () => { // Attempt to close the fill PDA before the fill deadline should fail. try { - await program.methods.closeFillPda().accounts(closeFillPdaAccounts).signers([relayer]).rpc(); + await program.methods.closeFillPda().accounts(closeFillPdaAccounts).rpc(); assert.fail("Closing fill PDA should have failed before fill deadline"); } catch (err: any) { assert.include( @@ -367,7 +367,7 @@ describe("svm_spoke.fill", () => { await setCurrentTime(program, state, relayer, new BN(relayData.fillDeadline + 1)); // Close the fill PDA - await program.methods.closeFillPda().accounts(closeFillPdaAccounts).signers([relayer]).rpc(); + await program.methods.closeFillPda().accounts(closeFillPdaAccounts).rpc(); // Verify the fill PDA is closed const fillStatusAccountAfter = await connection.getAccountInfo(accounts.fillStatus); diff --git a/test/svm/SvmSpoke.SlowFill.AcrossPlus.ts b/test/svm/SvmSpoke.SlowFill.AcrossPlus.ts index 0cc9dc396..29c61a9f3 100644 --- a/test/svm/SvmSpoke.SlowFill.AcrossPlus.ts +++ b/test/svm/SvmSpoke.SlowFill.AcrossPlus.ts @@ -479,7 +479,7 @@ describe("svm_spoke.slow_fill.across_plus", () => { state, fillStatus: fillStatusPDA, }; - await program.methods.closeFillPda().accounts(closeFillPdaAccounts).signers([relayer]).rpc(); + await program.methods.closeFillPda().accounts(closeFillPdaAccounts).rpc(); // Verify the fill PDA is closed const fillStatusAccountAfter = await connection.getAccountInfo(fillStatusPDA); diff --git a/test/svm/SvmSpoke.V5FillStatus.ts b/test/svm/SvmSpoke.V5FillStatus.ts new file mode 100644 index 000000000..eb6aa39d8 --- /dev/null +++ b/test/svm/SvmSpoke.V5FillStatus.ts @@ -0,0 +1,208 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN, Program } from "@coral-xyz/anchor"; +import { Keypair, PublicKey, SystemProgram, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; +import { assert } from "chai"; +import { randomBytes } from "crypto"; +import { SvmSpoke } from "../../target/types/svm_spoke"; +import { common } from "./SvmSpoke.common"; + +describe("svm_spoke V5 fill-status payer", () => { + anchor.setProvider(common.provider); + const { connection, provider } = common; + const program = common.program as Program; + const providerPayer = (provider.wallet as anchor.Wallet).payer; + + const fillPayer = (submitter: PublicKey) => + PublicKey.findProgramAddressSync([Buffer.from("v5_fill_payer"), submitter.toBuffer()], program.programId)[0]; + const fillStatus = (relayHash: Buffer) => + PublicKey.findProgramAddressSync([Buffer.from("fills"), relayHash], program.programId)[0]; + + const expectError = async (promise: Promise, name: string) => { + try { + await promise; + } catch (error: any) { + const text = [error.toString(), ...(error.logs ?? [])].join("\n"); + if (!text.includes(name)) throw new Error(text); + return; + } + assert.fail(`Expected ${name}`); + }; + + it("creates fill statuses with a PDA payer and permissionlessly reclaims their rent", async () => { + const { state } = await common.initializeState(); + const submitter = Keypair.generate(); + const payer = fillPayer(submitter.publicKey); + const relayHash = randomBytes(32); + const status = fillStatus(relayHash); + const prefundedRelayHash = randomBytes(32); + const prefundedStatus = fillStatus(prefundedRelayHash); + const fillDeadline = Number(await common.getCurrentTime(program, state)) + 10; + const fillStatusRent = await connection.getMinimumBalanceForRentExemption(45); + const prefundedLamports = await connection.getMinimumBalanceForRentExemption(0); + const initialFloat = fillStatusRent * 2; + await sendAndConfirmTransaction( + connection, + new Transaction().add( + SystemProgram.transfer({ + fromPubkey: providerPayer.publicKey, + toPubkey: payer, + lamports: initialFloat, + }) + ), + [providerPayer] + ); + + await program.methods + .testCreateV5FillStatus([...relayHash], fillDeadline) + .accounts({ submitter: submitter.publicKey, payer, fillStatus: status, systemProgram: SystemProgram.programId }) + .signers([submitter]) + .rpc(); + const account = await program.account.fillStatusAccount.fetch(status); + assert.hasAnyKeys(account.status, ["filled"]); + assert.equal(account.relayer.toBase58(), payer.toBase58()); + assert.equal(account.fillDeadline, fillDeadline); + + await expectError( + program.methods + .testCreateV5FillStatus([...relayHash], fillDeadline) + .accounts({ submitter: submitter.publicKey, payer, fillStatus: status, systemProgram: SystemProgram.programId }) + .signers([submitter]) + .rpc(), + "RelayFilled" + ); + + await sendAndConfirmTransaction( + connection, + new Transaction().add( + SystemProgram.transfer({ + fromPubkey: providerPayer.publicKey, + toPubkey: prefundedStatus, + lamports: prefundedLamports, + }) + ), + [providerPayer] + ); + await program.methods + .testCreateV5FillStatus([...prefundedRelayHash], fillDeadline) + .accounts({ + submitter: submitter.publicKey, + payer, + fillStatus: prefundedStatus, + systemProgram: SystemProgram.programId, + }) + .signers([submitter]) + .rpc(); + assert.equal(await connection.getBalance(payer), prefundedLamports); + + await common.setCurrentTime(program, state, Keypair.generate(), new BN(fillDeadline + 1)); + + const wrongRecipient = Keypair.generate().publicKey; + await sendAndConfirmTransaction( + connection, + new Transaction().add( + SystemProgram.transfer({ + fromPubkey: providerPayer.publicKey, + toPubkey: wrongRecipient, + lamports: await connection.getMinimumBalanceForRentExemption(0), + }) + ), + [providerPayer] + ); + await expectError( + program.methods.closeFillPda().accounts({ state, signer: wrongRecipient, fillStatus: status }).rpc(), + "NotRelayer" + ); + + await program.methods.closeFillPda().accounts({ state, signer: payer, fillStatus: status }).rpc(); + await program.methods.closeFillPda().accounts({ state, signer: payer, fillStatus: prefundedStatus }).rpc(); + assert.isNull(await connection.getAccountInfo(status)); + assert.isNull(await connection.getAccountInfo(prefundedStatus)); + assert.equal(await connection.getBalance(payer), initialFloat + prefundedLamports); + }); + + it("rejects noncanonical payer and fill-status accounts", async () => { + const submitter = Keypair.generate(); + const relayHash = randomBytes(32); + const payer = fillPayer(submitter.publicKey); + const status = fillStatus(relayHash); + + await expectError( + program.methods + .testCreateV5FillStatus([...relayHash], 1) + .accounts({ + submitter: submitter.publicKey, + payer: Keypair.generate().publicKey, + fillStatus: status, + systemProgram: SystemProgram.programId, + }) + .signers([submitter]) + .rpc(), + "InvalidFillPayer" + ); + await expectError( + program.methods + .testCreateV5FillStatus([...relayHash], 1) + .accounts({ + submitter: submitter.publicKey, + payer, + fillStatus: Keypair.generate().publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([submitter]) + .rpc(), + "InvalidFillStatusAccount" + ); + }); + + it("binds partial and full withdrawals to the submitter", async () => { + const submitter = Keypair.generate(); + const payer = fillPayer(submitter.publicKey); + const rentMinimum = await connection.getMinimumBalanceForRentExemption(0); + const initialFloat = rentMinimum * 2; + await sendAndConfirmTransaction( + connection, + new Transaction().add( + SystemProgram.transfer({ + fromPubkey: providerPayer.publicKey, + toPubkey: payer, + lamports: initialFloat, + }) + ), + [providerPayer] + ); + + const stranger = Keypair.generate(); + await expectError( + program.methods + .withdrawV5FillPayer(new BN(1)) + .accounts({ submitter: stranger.publicKey, payer, systemProgram: SystemProgram.programId }) + .signers([stranger]) + .rpc(), + "ConstraintSeeds" + ); + + await expectError( + program.methods + .withdrawV5FillPayer(new BN(rentMinimum + 1)) + .accounts({ submitter: submitter.publicKey, payer, systemProgram: SystemProgram.programId }) + .signers([submitter]) + .rpc(), + "insufficient funds for rent" + ); + assert.equal(await connection.getBalance(payer), initialFloat); + + await program.methods + .withdrawV5FillPayer(new BN(rentMinimum)) + .accounts({ submitter: submitter.publicKey, payer, systemProgram: SystemProgram.programId }) + .signers([submitter]) + .rpc(); + assert.equal(await connection.getBalance(payer), rentMinimum); + + await program.methods + .withdrawV5FillPayer(new BN("18446744073709551615")) + .accounts({ submitter: submitter.publicKey, payer, systemProgram: SystemProgram.programId }) + .signers([submitter]) + .rpc(); + assert.equal(await connection.getBalance(payer), 0); + }); +}); diff --git a/test/svm/SvmSpoke.V5Source.ts b/test/svm/SvmSpoke.V5Source.ts index 39a1e06f1..fa3a86f79 100644 --- a/test/svm/SvmSpoke.V5Source.ts +++ b/test/svm/SvmSpoke.V5Source.ts @@ -214,10 +214,11 @@ describe("svm_spoke V5 source deposit", () => { const expectError = async (promise: Promise, name: string) => { try { await promise; - assert.fail(`Expected ${name}`); } catch (error: any) { assert.include(error.toString(), name); + return; } + assert.fail(`Expected ${name}`); }; const setInputMint = async (nextMint: PublicKey, nextTokenProgram: PublicKey, updateDeposit = false) => {