Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3ad6197
feat(svm): add V5 fill-status payer float
Reinis-FRP Aug 28, 2026
43b1ea7
refactor(svm): unify fill status reclaim
Reinis-FRP Aug 31, 2026
d81b285
chore(svm): align Rust formatting with CI
Reinis-FRP Aug 31, 2026
e2b22a1
refactor(svm): simplify fill payer withdrawal
Reinis-FRP Aug 31, 2026
557ebda
refactor(svm): align fill payer with Anchor rent handling
Reinis-FRP Aug 31, 2026
35ebe84
refactor(svm): simplify V5 fill-status creation
Reinis-FRP Aug 31, 2026
1612213
fix(svm): address V5 fill-status review
Reinis-FRP Aug 31, 2026
1c6fb97
test(svm): pin V5 payer rent behavior
Reinis-FRP Aug 31, 2026
138fd1c
fix(svm): align V5 fill status and CI
Reinis-FRP Aug 31, 2026
e1199eb
fix(svm): isolate test IDL generation
Reinis-FRP Aug 31, 2026
8ed954a
refactor(svm): separate fill status creation
Reinis-FRP Sep 7, 2026
5e0003b
refactor(svm): share V5 fill status finalization
Reinis-FRP Sep 7, 2026
d0a312d
refactor(svm): clarify fill status rent recipient
Reinis-FRP Sep 7, 2026
7b0c7d1
refactor(svm): reuse canonical fill status PDAs
Reinis-FRP Sep 8, 2026
505a5e5
fix(svm): gate V5 PDA bundle constructor dead code
Reinis-FRP Sep 8, 2026
7b74298
refactor(svm): bind fill status PDA seeds
Reinis-FRP Sep 8, 2026
1acc28d
refactor(svm): bind fill status finalization
Reinis-FRP Sep 8, 2026
1e0111c
refactor(svm): bind fill status account finalization
Reinis-FRP Sep 8, 2026
2839d8c
fix(svm): gate pending fill status dead code
droplet-rl Sep 8, 2026
721b5c5
refactor(svm): drop redundant executable check
Reinis-FRP Sep 18, 2026
0689566
test(svm): require expected V5 failures
Reinis-FRP Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
11 changes: 8 additions & 3 deletions programs/svm-spoke/V5_ADAPTER_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions programs/svm-spoke/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions programs/svm-spoke/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion programs/svm-spoke/src/instructions/fill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Comment on lines +213 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This relaxes an authorization check on an instruction that is live on mainnet today, and it's the one change here that reaches beyond V5.

I think it's safe: close = signer is still bound by address = fill_status.relayer, and every writer of that field records a real beneficiary — fill_relay the signing relayer, request_slow_fill the requester (slow_fill.rs:75), create_v5_fill_status the payer PDA. The deadline check still gates closure, fill_relay cannot re-fill past the deadline, so permissionless close opens no replay window. Rent cannot be redirected. I checked the in-repo callers (SvmSpoke.Fill.ts:371, SvmSpoke.SlowFill.AcrossPlus.ts:482, scripts/svm/closeRelayerPdas.ts) and they all still pass the relayer as a signer, which the runtime accepts as a redundant signature.

Two things I'd still want before this merges:

  1. Call it out in the PR description. "Legacy close_fill_pda becomes permissionless" is a security-relevant change to a deployed program and shouldn't be discovered by reading the diff of a PR titled "add V5 fill-status payer float." It also warrants a note for whoever signs off on the next deploy.
  2. Confirm the IDL change downstream. The signer account flips to isSigner: false in the published IDL. Clients built against the old IDL keep working, but anything that introspects the IDL to decide which keypairs a closeFillPda transaction needs — or that asserts on the account-meta shape — will see a different value. Worth a quick check against the relayer repo and a note in the release notes for @across-protocol/contracts.

Minor: the field is now named signer while explicitly not being one. Renaming would break the IDL account name, so keeping it is the right call, but the /// CHECK line and the updated lib.rs doc comment are now the only thing preventing a misreading — worth a brief inline note that the name is retained for IDL compatibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in ece3f332 and the PR description. The description and V5 adapter spec now explicitly call out that existing close_fill_pda accounts become permissionless after expiry, that the immutable recorded relayer remains the destination, and that regenerated IDLs flip only the signer requirement while retaining the account name.

I also checked downstream usage: the SDK helper currently accepts a TransactionSigner and passes it to the generated close instruction, while the in-repo scripts/tests still sign; those old-IDL callers remain valid because the signature is merely redundant. I found no direct close caller in the relayer repositories checked locally. The compatibility note is now visible for release/deploy signoff.


Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Follow-up after exercising the regenerated IDL in CI: the on-chain compatibility conclusion stands, but the client wording needed one distinction. Old-IDL clients keep sending the signer meta/signature and remain compatible. Anchor clients generated from the new IDL must omit the explicit .signers([relayer]); otherwise Web3 rejects it locally as an unknown signer because no instruction account is marked signing.

Fixed in b2474f10: the in-repository close callers now omit that explicit signer, verified-SVM CI regenerates the test-feature svm_spoke IDL, and the PR compatibility note documents both client cases.


Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖


#[account(seeds = [b"state", state.seed.to_le_bytes().as_ref()], bump)]
pub state: Account<'info, State>,
Expand Down
2 changes: 2 additions & 0 deletions programs/svm-spoke/src/instructions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand All @@ -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::*;
204 changes: 204 additions & 0 deletions programs/svm-spoke/src/instructions/v5_fill_status.rs
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit, carried over and downgraded: the layout assertion deleted in 3594622c is still gone.

assert_eq!(V5_FILL_STATUS_SPACE, 8 + 1 + 32 + 4);

Correcting the Validation section to "11 passed" resolves my actual complaint — the description no longer claims coverage that isn't there — so this is no longer a blocker. But the invariant itself is still unguarded: V5_FILL_STATUS_SPACE derives from INIT_SPACE, so it silently tracks any field added to FillStatus or FillStatusAccount, and a change there would shift the on-chain layout and rent math on both the legacy and V5 paths with nothing failing. Given "without changing its account layout" is a bullet in this PR's summary, a three-line #[test] pinning the constant seems worth keeping around.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged. I am leaving the duplicate layout assertion out of this narrowly scoped change: the V5 path intentionally uses the shared FillStatusAccount type and its shared INIT_SPACE, so a V5-local assertion would not independently protect the legacy layout and could imply stronger coverage than it provides. If we add a golden byte-layout invariant, it should live centrally with the shared account definition and cover serialization, not only repeat the derived size expression.


Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖


pub struct V5FillStatusPdas<'a> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should this have pdas in the name given that submitter and payer are not PDAs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I lean toward keeping V5FillStatusPdas as both payer and fill_status are PDAs, while the other fields hold their seed inputs and cached bumps. V5FillStatusPdaDerivations might be technically more precise, but it feels unnecessarily long.

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<PendingV5FillStatus<'a, 'info>> {
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<TestCreateV5FillStatus>,
relay_hash: [u8; 32],
fill_deadline: u32,
) -> Result<()> {
Comment on lines +154 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

submitter is an unauthenticated instruction argument, so anyone holding this entrypoint can spend an arbitrary submitter's float and permanently mark any relay hash as Filled, blocking a legitimate fill.

The gating is correct — buildSolanaVerify.sh only adds --features test under IS_TEST=true, and publish.yml builds it as a separate svm-verified-test-binaries artifact, distinct from the production one. But this is a step up from the existing test-gated code in utils/testable_utils.rs, which only mocks time; this is the first one that can burn another account's lamports, and the test binaries are published as a public release artifact.

Cheap hardening that costs nothing in coverage: make submitter a Signer in TestCreateV5FillStatus and drop the argument. create_v5_fill_status still receives no signature for the payer PDA, so the signerless property under test is fully preserved — the wrapper just can't be pointed at a third party's float if a test build is ever deployed somewhere it shouldn't be. The TS test would only need to retain the generated keypair and add .signers([submitter]).

Also worth a # Safety-style line on create_v5_fill_status stating the precondition Step 4 must honour: the caller is responsible for supplying a Gateway-attested submitter, since the helper authenticates the derivation but not the value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ece3f332: submitter is now a Signer account in the test-only wrapper, the unauthenticated instruction argument is gone, and the TS caller signs with that keypair. The helper also has a # Safety precondition stating that Step 4 must source submitter from Gateway-attested context.


Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖

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<WithdrawV5FillPayer>, 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]];
Comment on lines +190 to +192

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

13821579 removed the rent guard that used to sit here:

-    require_fill_payer_spend(balance, amount, Rent::get()?.minimum_balance(0))?;

and the matching spec sentence ("a nonzero remainder must be rent-exempt") was dropped from V5_ADAPTER_SPEC.md in this PR. The behaviour is still safe, but a partial withdrawal leaving a nonzero remainder below 890,880 lamports now fails at the runtime rent-state check with InsufficientFundsForRent — a bare transaction-level error with no program context — instead of the previous FillPayerRemainderNotRentExempt.

Note the new test never exercises this: it withdraws exactly rentMinimum from rentMinimum * 2, landing precisely on the rent-exempt floor, then drains via u64::MAX. Every value strictly between those two fails opaquely. Since this is a submitter-facing instruction and "withdraw most of my float" is the obvious usage, please either restore the guard or document the constraint in the spec and add a test pinning the failure mode.

Minor, same function: amount == 0 is accepted and emits a V5FillFloatWithdrawn with amount: 0, which will show up as noise for indexers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed without restoring the custom guard. The program and V5 spec now explicitly document that partial withdrawals are subject to the runtime rent-state rule, keeping this path aligned with the System Program / Anchor behavior. 1dfa69f9 pins the failure atomically when a withdrawal would leave a nonzero rent-unsafe remainder; the same test covers an exact rent-floor remainder and the u64::MAX drain. I left zero withdrawals unchanged because the submitter can already emit events for its own float, so rejecting zero would not provide an integrity or anti-spam boundary.


Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖

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(())
}
21 changes: 18 additions & 3 deletions programs/svm-spoke/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CloseFillPda>) -> 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<WithdrawV5FillPayer>, amount: u64) -> Result<()> {
instructions::withdraw_v5_fill_payer(ctx, amount)
}

#[cfg(feature = "test")]
pub fn test_create_v5_fill_status(
ctx: Context<TestCreateV5FillStatus>,
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
Expand Down
2 changes: 1 addition & 1 deletion programs/svm-spoke/src/state/fill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
2 changes: 1 addition & 1 deletion programs/svm-spoke/src/v5/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ fn assert_error_name<T>(result: Result<T>, 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]
Expand Down
14 changes: 14 additions & 0 deletions scripts/svm/buildHelpers/buildTestIdls.sh
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions test/svm/SvmSpoke.Fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion test/svm/SvmSpoke.SlowFill.AcrossPlus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading