Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
19c6ac6
feat(svm): enable V5 destination fills
Reinis-FRP Sep 1, 2026
7bef6a4
refactor(svm): share fill validation core
Reinis-FRP Sep 1, 2026
7874d99
test(svm): drop obsolete reserved fill assertion
Reinis-FRP Sep 1, 2026
6bf1575
docs(svm): explain fill stack boundary
Reinis-FRP Sep 1, 2026
3b5dfa0
refactor(svm): centralize fill delivery
Reinis-FRP Sep 1, 2026
2f754d0
refactor(svm): centralize fill status transition
Reinis-FRP Sep 1, 2026
674459b
refactor(svm): preserve fill transition ordering
Reinis-FRP Sep 1, 2026
c1be40b
refactor(svm): align fill message execution
Reinis-FRP Sep 1, 2026
ce1c029
refactor(svm): keep fill callbacks branch-specific
Reinis-FRP Sep 7, 2026
9fd3789
refactor(svm): preserve legacy fill structure
Reinis-FRP Sep 7, 2026
9184df2
refactor(svm): simplify token transfer helper
Reinis-FRP Sep 7, 2026
e93d2df
docs(svm): clarify V5 fill status validation
Reinis-FRP Sep 7, 2026
103240c
refactor(svm): reuse V5 fill status finalization
Reinis-FRP Sep 7, 2026
24df2da
docs(svm): clarify in-place fill invariant
Reinis-FRP Sep 7, 2026
3bfca57
refactor(svm): clarify shared fill inputs
Reinis-FRP Sep 7, 2026
a9fa341
refactor(svm): simplify v5 fill account handling
Reinis-FRP Sep 7, 2026
eeaec31
docs(svm): clarify in-place fill invariant
Reinis-FRP Sep 8, 2026
1c2d92e
test(svm): cover unconsumed in-place fills
Reinis-FRP Sep 8, 2026
28c57a4
refactor(svm): reuse fill status PDA derivations
Reinis-FRP Sep 8, 2026
747b655
chore(svm): restore V5 fill dead-code checks
Reinis-FRP Sep 8, 2026
9b843f6
refactor(svm): make fill delivery explicit
Reinis-FRP Sep 8, 2026
f4dc38b
test(svm): document in-place double-fill coverage
Reinis-FRP Sep 8, 2026
4558a8a
style(svm): format V5 fill authorities
Reinis-FRP Sep 11, 2026
a5c75a4
refactor(svm): rely on fill transfer allowance check
Reinis-FRP Sep 15, 2026
3683986
docs(svm): clarify fill status rent recipient account
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
148 changes: 148 additions & 0 deletions programs/mock-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,105 @@ pub mod mock_gateway {
require!(!fail_after, MockGatewayError::ForcedFailure);
Ok(())
}

pub fn execute_fill_adapter(
ctx: Context<ExecuteFillAdapter>,
ctx_values: MockGatewayContext,
input: Vec<u8>,
jit_data: Vec<u8>,
approval_amount: Option<u64>,
consume_amount: u64,
fail_after: bool,
) -> Result<()> {
let vault_seeds: &[&[u8]] = &[VAULT_AUTHORITY_SEED, &[ctx.bumps.vault_authority]];
if let Some(amount) = approval_amount {
token_interface::approve_checked(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
ApproveChecked {
to: ctx.accounts.gateway_vault.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
delegate: ctx.accounts.fill_delegate.to_account_info(),
authority: ctx.accounts.vault_authority.to_account_info(),
},
&[vault_seeds],
),
amount,
ctx.accounts.mint.decimals,
)?;
}

let svm_spoke_program = ctx.accounts.svm_spoke_program.key();
let dispatch_seeds: &[&[u8]] = &[
DISPATCH_AUTHORITY_SEED,
svm_spoke_program.as_ref(),
&[ctx.bumps.dispatch_authority],
];
let accounts = vec![
ctx.accounts.dispatch_authority.to_account_info(),
ctx.accounts.state.to_account_info(),
ctx.accounts.event_authority.to_account_info(),
ctx.accounts.svm_spoke_program.to_account_info(),
ctx.accounts.gateway_vault.to_account_info(),
ctx.accounts.recipient_token_account.to_account_info(),
ctx.accounts.mint.to_account_info(),
ctx.accounts.token_program.to_account_info(),
ctx.accounts.fill_delegate.to_account_info(),
ctx.accounts.fill_payer.to_account_info(),
ctx.accounts.fill_status.to_account_info(),
ctx.accounts.system_program.to_account_info(),
];
invoke_signed(
&Instruction {
program_id: svm_spoke::ID,
accounts: vec![
AccountMeta::new_readonly(ctx.accounts.dispatch_authority.key(), true),
AccountMeta::new_readonly(ctx.accounts.state.key(), false),
AccountMeta::new_readonly(ctx.accounts.event_authority.key(), false),
AccountMeta::new_readonly(ctx.accounts.svm_spoke_program.key(), false),
AccountMeta::new(ctx.accounts.gateway_vault.key(), false),
AccountMeta::new(ctx.accounts.recipient_token_account.key(), false),
AccountMeta::new_readonly(ctx.accounts.mint.key(), false),
AccountMeta::new_readonly(ctx.accounts.token_program.key(), false),
AccountMeta::new_readonly(ctx.accounts.fill_delegate.key(), false),
AccountMeta::new(ctx.accounts.fill_payer.key(), false),
AccountMeta::new(ctx.accounts.fill_status.key(), false),
AccountMeta::new_readonly(ctx.accounts.system_program.key(), false),
],
data: svm_spoke::instruction::AdapterExecuteAcrossV5 {
ctx_values: svm_spoke::v5::codec::GatewayContextV1 {
step_id: ctx_values.step_id,
path_id: ctx_values.path_id,
submitter: ctx_values.submitter,
},
input,
jit_data,
}
.data(),
},
&accounts,
&[dispatch_seeds],
)?;

if consume_amount > 0 {
token_interface::transfer_checked(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
token_interface::TransferChecked {
from: ctx.accounts.gateway_vault.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.consumption_account.to_account_info(),
authority: ctx.accounts.vault_authority.to_account_info(),
},
&[vault_seeds],
),
consume_amount,
ctx.accounts.mint.decimals,
)?;
}
require!(!fail_after, MockGatewayError::ForcedFailure);
Ok(())
}
}

#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy)]
Expand Down Expand Up @@ -136,6 +235,55 @@ pub struct ExecuteAdapter<'info> {
pub svm_spoke_program: Program<'info, SvmSpoke>,
}

#[derive(Accounts)]
pub struct ExecuteFillAdapter<'info> {
pub submitter: Signer<'info>,

/// CHECK: Test-only PDA that mirrors Gateway's canonical vault authority.
#[account(seeds = [VAULT_AUTHORITY_SEED], bump)]
pub vault_authority: UncheckedAccount<'info>,

/// CHECK: Test-only PDA that mirrors Gateway's per-callee dispatch authority.
#[account(
seeds = [DISPATCH_AUTHORITY_SEED, svm_spoke_program.key().as_ref()],
bump
)]
pub dispatch_authority: UncheckedAccount<'info>,

#[account(mut, token::mint = mint, token::authority = vault_authority, token::token_program = token_program)]
pub gateway_vault: InterfaceAccount<'info, TokenAccount>,

#[account(mut, token::mint = mint, token::token_program = token_program)]
pub recipient_token_account: InterfaceAccount<'info, TokenAccount>,

#[account(mut, token::mint = mint, token::token_program = token_program)]
pub consumption_account: InterfaceAccount<'info, TokenAccount>,

#[account(mint::token_program = token_program)]
pub mint: InterfaceAccount<'info, Mint>,

/// CHECK: The svm-spoke adapter authenticates the static fill-delegate key for external delivery.
pub fill_delegate: UncheckedAccount<'info>,

/// CHECK: Validated by svm-spoke against the context submitter.
#[account(mut)]
pub fill_payer: UncheckedAccount<'info>,

/// CHECK: Validated by svm-spoke against the relay hash.
#[account(mut)]
pub fill_status: UncheckedAccount<'info>,

/// CHECK: Validated by the svm-spoke CPI account constraints.
pub state: UncheckedAccount<'info>,

/// CHECK: Validated by svm-spoke's event CPI machinery.
pub event_authority: UncheckedAccount<'info>,

pub token_program: Interface<'info, TokenInterface>,
pub system_program: Program<'info, System>,
pub svm_spoke_program: Program<'info, SvmSpoke>,
}

#[error_code]
pub enum MockGatewayError {
#[msg("Forced failure after adapter execution")]
Expand Down
23 changes: 21 additions & 2 deletions programs/svm-spoke/V5_ADAPTER_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This document freezes the compatibility surface for the Gateway-facing `svm_spoke` V5 adapter. `V5` identifies the
Across protocol generation, while `V1` identifies the first SVM wire-schema revision of a context or input variant.
`DepositV1` is callable; the reserved `FillV1` variant returns `UnsupportedMode` until destination behavior lands.
Both `DepositV1` and `FillV1` are callable.

## Dispatch ABI and accounts

Expand Down Expand Up @@ -123,7 +123,10 @@ hash with the committed authority. ERC-1271, Ed25519, EIP-2098, high-`s`, and `v

An external delivery targets the canonical ATA of committed `recipient`, output mint, and token program. A canonical
Gateway-vault delivery validates that same live vault in place and its amount, records the fill, and performs no token
self-transfer or approval. The continuing atomic tape must consume the output.
self-transfer or approval. This asserts available balance rather than debiting it. A step root may be reused across
source deposits, but canonical builders must either allow at most one in-place fill before a post-fill floor and
full-balance terminal consumption, or enforce a cumulative floor covering every in-place fill recorded before that
consumption. A fixed minimum for one fill does not prove aggregate delivery.

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. Partial withdrawals remain subject to Solana's
Expand Down Expand Up @@ -153,6 +156,22 @@ identity and live context, and emits the standard `FundsDeposited` event with
`message = V5_MAGIC_PREFIX || dst_step_id`. Any later failure in the same transaction rolls back the approval,
transfer, and event atomically.

## Enabled destination-fill behavior

Fill mode strictly decodes the JIT relay and repayment data, binds the recipient, output mint, minimum amount, and
exact `V5_MAGIC_PREFIX || step_id` witness to the committed input, and evaluates exclusivity against the
Gateway-attested submitter. It derives the canonical relay hash on-chain, creates the shared fill-status PDA from the
submitter's payer float, and emits the standard `FilledRelay` event with the original witness hash and an empty updated
message hash. The legacy and V5 entrypoints share the same internal `_fill` core for pause, exclusivity, deadline,
replay protection and status transition, token delivery, fill-type and callback-mode event fields, and canonical event
construction. Each handler retains only its branch-specific account loading, callback, and event-emission mechanics.

External delivery requires a sufficient approval to `["v5_fill_delegate"]` and pulls exactly the JIT output amount
from the canonical Gateway vault into the committed recipient's ATA. When that recipient ATA is the canonical Gateway
vault itself, the adapter instead authenticates its live balance, records the fill in place, and performs no approval
or self-transfer. Its safety therefore depends on the proportional or aggregate continuing-path rule above. Any later
failure rolls back token, fill-status, and payer-float changes together.

Golden values in `fixtures/v5_adapter_v1.json` are independently re-derived from Rust, TypeScript, and Solidity to
catch byte-width, packing, and endianness drift. These are cross-language self-consistency vectors, not an invocation
of the EVM adapter. The JIT digest layout matches `AcrossDepositDelegateAdapter`, while SVM deposit identity
Expand Down
8 changes: 6 additions & 2 deletions programs/svm-spoke/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ pub enum V5Error {
InvalidAccountMutability,
#[msg("Resolved Across V5 input amount is below the committed floor!")]
ResolvedInputAmountBelowCommitted,
#[msg("Across V5 delegate allowance is insufficient!")]
InsufficientDelegateAllowance,
#[msg("Across V5 parameter modification is not an improvement!")]
ParamModificationNotAnImprovement,
#[msg("Across V5 input amount bips exceed the denominator!")]
Expand All @@ -113,6 +111,12 @@ pub enum V5Error {
InvalidFillPayer,
#[msg("Invalid Across V5 fill status account!")]
InvalidFillStatusAccount,
#[msg("Across V5 fill data does not match the committed input!")]
FillCommitmentMismatch,
#[msg("Across V5 fill output amount is below the committed floor!")]
FillOutputAmountTooLow,
#[msg("Across V5 Gateway vault balance is insufficient!")]
InsufficientVaultBalance,
}

// CCTP specific errors.
Expand Down
12 changes: 3 additions & 9 deletions programs/svm-spoke/src/instructions/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ use crate::{
event::FundsDeposited,
state::State,
utils::{
derive_seed_hash, get_current_time, get_unsafe_deposit_id, transfer_from_with_delegate, DelegatePda,
DepositNowSeedData, DepositSeedData,
derive_seed_hash, get_current_time, get_unsafe_deposit_id, transfer_from, DelegatePda, DepositNowSeedData,
DepositSeedData,
},
};

Expand Down Expand Up @@ -143,13 +143,7 @@ pub fn _deposit(
}

// Depositor must have delegated input_amount to the delegate PDA
transfer_from_with_delegate(
accounts.transfer,
accounts.token_program,
input_amount,
accounts.mint_decimals,
delegate_pda,
)?;
transfer_from(accounts.transfer, accounts.token_program, input_amount, accounts.mint_decimals, delegate_pda)?;

let applied_deposit_id = match deposit_id {
DepositId::Next(state) => {
Expand Down
Loading
Loading