Skip to content

feat(svm): add V5 fill-status payer float - #1537

Open
Reinis-FRP wants to merge 10 commits into
reinis/acp-184-step-2-source-depositfrom
reinis/acp-184-step-3-fill-status
Open

feat(svm): add V5 fill-status payer float#1537
Reinis-FRP wants to merge 10 commits into
reinis/acp-184-step-2-source-depositfrom
reinis/acp-184-step-3-fill-status

Conversation

@Reinis-FRP

@Reinis-FRP Reinis-FRP commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a submitter-scoped V5 fill-status payer PDA that manually creates the standard fill-status PDA without requiring that PDA to be a transaction signer
  • keep V5-tagged relays outside the slow-fill lifecycle: an existing Filled status returns RelayFilled, while any other program-owned fill status is invalid
  • bind V5 fill-status rent ownership in the existing FillStatusAccount.relayer slot without changing its account layout
  • make post-expiry close_fill_pda permissionless while keeping its recorded relayer as the immutable rent destination; only the V5 submitter may withdraw its payer float
  • handle both empty and prefunded fill-status PDAs using the same create/top-up/allocate/assign sequence generated by Anchor init_if_needed, relying on the System Program to enforce whether the account can be allocated and assigned
  • document the Step 4 safety contract: source the submitter from Gateway-attested context, derive the relay hash and fill deadline from the same validated V5 relay, and reject expired fills before creation
  • generate test-feature IDLs after production artifacts in standard SVM test commands and verified-SVM CI, keeping test-only output confined to target/

The reserved V5 Fill adapter branch remains fail-closed for Step 4.

Part of ACP-184. This is Step 3 of the implementation plan and is stacked on #1536.

Compatibility note

This deliberately relaxes the live close_fill_pda authorization rule for existing fill-status accounts. Old-IDL clients may continue supplying the recorded relayer signer meta and signature and remain compatible on-chain. Clients generated from the new IDL must stop explicitly passing that relayer to .signers(...), because the account is no longer marked as signing. The account name is retained to avoid needless client churn, and the in-repository callers have been updated. The V5 adapter spec records the authorization change for release coordination.

Validation

  • cargo +nightly fmt --all -- --check
  • cargo check -p svm-spoke --features test
  • cargo test -p svm-spoke --lib (11 passed)
  • yarn lint-rust (passed with existing warnings)
  • focused Prettier checks for the workflow, V5 spec, and changed SVM tests
  • target-only test-feature IDL generation for both svm_spoke and mock_gateway; confirmed test_create_v5_fill_status is present only in the test IDL while production package assets remain unchanged
  • yarn build-ts
  • focused validator coverage in SvmSpoke.V5FillStatus.ts (2 passed: empty and prefunded creation/reclaim, plus withdrawal authorization and amounts)
  • repository pre-commit hook

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

@linear

linear Bot commented Aug 28, 2026

Copy link
Copy Markdown

ACP-184

@Reinis-FRP
Reinis-FRP force-pushed the reinis/acp-184-step-3-fill-status branch 2 times, most recently from f35a0dd to c74c70e Compare August 31, 2026 13:50
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP
Reinis-FRP force-pushed the reinis/acp-184-step-3-fill-status branch from c74c70e to e77b3f8 Compare August 31, 2026 13:52
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP
Reinis-FRP requested a review from droplet-rl August 31, 2026 16:07

@droplet-rl droplet-rl left a comment

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.

Assessment

I read the full diff against reinis/acp-184-step-2-source-deposit, built with cargo check -p svm-spoke --features test (clean, pre-existing warnings only), and ran the unit tests.

The core mechanics look right. I specifically checked and could not break:

  • The account-creation sequence correctly mirrors Anchor 0.31.1's generate_create_account. The current_lamports == 0 split is necessary — the System Program's create_account bails with AccountAlreadyInUse when to.lamports() > 0 — so the one-lamport prefunding DoS is genuinely handled, and an attacker can only transfer lamports to the fill-status PDA (allocate/assign need the PDA signature), so there is no way to poison it into a non-empty state.
  • V5_FILL_STATUS_SPACE is 45 bytes → rent-exempt minimum ≈ 1,204,080 lamports, comfortably above the 890,880 floor for the 0-data payer PDA, so permissionless close can never leave the float rent-paying.
  • The front-run vector I went looking for is closed: request_slow_fill rejects V5-tagged messages (slow_fill.rs:52) and the relay hash binds the message, so nobody can squat the fill-status PDA ahead of a V5 fill.
  • Float scoping is sound — create_v5_fill_status records the same PDA it spends from, and WithdrawV5FillPayer's seed constraint binds withdrawal to the signing submitter (the ConstraintSeeds test confirms).

What I'm asking for. My concerns are not exploitable bugs; they're about what the last two commits quietly removed and what the PR body claims.

13821579 refactor(svm): align fill payer with Anchor rent handling and 3594622c refactor(svm): simplify V5 fill-status creation deleted every Rust unit test this PR added, along with the RelayFilled replay error and the rent-remainder guard. Both are labelled as refactors, which is exactly where this kind of removal escapes review. Concretely, on HEAD:

$ cargo test -p svm-spoke --lib
test result: ok. 11 passed

11, not the 12 in the Validation section — and none of them are new. The only test-file change in the whole diff is the error-range assertion in v5.rs. So "add focused unit and validator coverage for signerless creation, replay rejection, …" is no longer accurate: unit coverage is zero, and the branchy creation helper is covered only by the two validator tests (happy path + replay). Please either restore the tests or correct the summary — right now the body over-states what was validated.

The individually actionable items are inline. The highest-value ones: restore the RelayFilled replay error (relayers need to distinguish "already filled, stop" from "malformed account, retry"), restore the layout-invariant assertion (it guards the exact property in the PR title), and cover the prefunded branch.

One item needing explicit sign-off: CloseFillPda relaxes SignerUncheckedAccount on an instruction that is live on mainnet today. I believe it is safe — rent still lands on fill_status.relayer, and both legacy fills and slow-fill requests record a real beneficiary there — but it is an authorization relaxation plus an IDL signer-flag change to a deployed program, folded into a PR titled "add V5 fill-status payer float." It should be called out in the description and confirmed against downstream clients rather than discovered in the diff. Details inline.

The reserved Fill adapter branch is confirmed still fail-closed (v5_adapter.rs:45UnsupportedMode), and the test feature is properly segregated from the production artifact in buildSolanaVerify.sh / publish.yml.

Comment on lines +33 to +34
let (expected_payer, payer_bump) = Pubkey::find_program_address(&[V5_FILL_PAYER_SEED, submitter.as_ref()], &ID);
let (expected_fill_status, fill_status_bump) = Pubkey::find_program_address(&[FILL_STATUS_SEED, relay_hash], &ID);

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.

Both derivations already exist in v5.rs and are re-derived inline here:

pub fn derive_v5_fill_payer(submitter: &Pubkey) -> (Pubkey, u8)  // v5.rs:314
pub fn derive_fill_status(relay_hash: &[u8; 32]) -> (Pubkey, u8)  // v5.rs:318

That matters beyond DRY: pda_domains_match_golden_fixture (v5.rs:487) pins both of those helpers against the golden fixture, including /pdas/fillPayer and /pdas/fillStatus. This copy is outside that safety net, so a future seed-domain change would be caught in v5.rs and silently diverge here.

let (expected_payer, payer_bump) = derive_v5_fill_payer(submitter);
let (expected_fill_status, fill_status_bump) = derive_fill_status(relay_hash);

Separately, worth considering for Step 4: two find_program_address calls cost up to ~25k CU in what will be the fill hot path inside an atomic tape. Taking both bumps as arguments and using create_program_address is safe here — a non-canonical bump derives a different address and fails the require_keys_eq! below — and would recover most of that budget.

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: the helper now calls derive_v5_fill_payer and derive_fill_status, so both derivations remain under the golden-fixture coverage. I left passing bumps / switching to create_program_address for Step 4, when the hot-path account ABI is finalized and its CU tradeoff can be measured.


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

Comment on lines +42 to +43
require_keys_eq!(*fill_status.owner, system_program::ID, V5Error::InvalidFillStatusAccount);
require!(fill_status.data_is_empty(), V5Error::InvalidFillStatusAccount);

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.

3594622c dropped the dedicated replay signal from just above these lines:

-    require_keys_neq!(*fill_status.owner, ID, CommonError::RelayFilled);

The replay is still rejected — the owner == system_program check catches it — but it now surfaces as InvalidFillStatusAccount, which is the same error as "wrong PDA" and "foreign-owned account." Those need different off-chain handling: RelayFilled means stop retrying and drop the relay, whereas a malformed-account error means retry or alert. Legacy fill_relay returns CommonError::RelayFilled for exactly this case (fill.rs:121), so V5 fills would report a different error for identical semantics.

The doc comment above still says "an existing program-owned fill status is rejected as a replay," which no longer matches the error the caller actually observes. Please restore the require_keys_neq! so the classification stays consistent with the legacy path — the new TS assertion on InvalidFillStatusAccount would need updating to RelayFilled alongside it.

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, including the slow-fill distinction. A program-owned account is now deserialized: Filled returns CommonError::RelayFilled, RequestedSlowFill is accepted and replaced as ReplacedSlowFill, and Unfilled is accepted as FastFill. The validator test creates a real requested slow fill and confirms that replacement succeeds without debiting the V5 payer float.


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

Comment on lines +85 to +86
FillStatusAccount { status: FillStatus::Filled, relayer: expected_payer, fill_deadline }
.try_serialize(&mut &mut fill_status.try_borrow_mut_data()?[..])

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.

3594622c removed write_v5_fill_status and, with it, the only test asserting the layout invariant this PR is built on:

assert_eq!(V5_FILL_STATUS_SPACE, 8 + 1 + 32 + 4);
assert_eq!(FillStatusAccount::DISCRIMINATOR.len(), DISCRIMINATOR_SIZE);

Inlining the struct literal is fine, but that assertion is the guard for "preserving the legacy account layout" in the PR title. V5_FILL_STATUS_SPACE is computed from INIT_SPACE, so it tracks the struct automatically and never trips — but a field added to FillStatus or FillStatusAccount would silently change the on-chain layout and the rent math on both the legacy and V5 paths, with nothing failing. It costs nothing to keep as a standalone #[test] against the constant; please restore it.

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 kept this test removed intentionally. The prior assertion only pinned the current type against a duplicated numeric size while round-tripping that same type; it did not prove compatibility with historical bytes. V5 now directly uses the shared FillStatusAccount, INIT_SPACE, discriminator, and serializer, so there is no separate V5 layout implementation to drift. A true legacy ABI invariant would be better added centrally as a golden-byte fixture rather than as a V5 helper test. I also removed the inaccurate unit-test claim from the PR description.


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

Comment on lines +53 to +83
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],
)?;
}

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.

The prefunded branch — the transfer / allocate / assign sequence that the summary calls out as the "one-lamport account-creation denial of service" mitigation — is never executed by any test. SvmSpoke.V5FillStatus.ts funds payer, never fillStatus, so every test run takes the current_lamports == 0 path and lines 64-82 are dead in CI.

I walked through the logic and believe it's correct, but this is the subtlest code in the PR (three chained invoke_signed calls with two different signer-seed sets) and it's the part a reader is least likely to verify by inspection. A one-line addition to the existing validator test — transfer 1 lamport to the fill-status PDA before calling testCreateV5FillStatus, then assert the account is created and the float is debited by rentExemption(45) - 1 — would cover it and directly demonstrate the DoS resistance the summary claims.

The InvalidFillPayer, InvalidAccountMutability, and MissingAccount branches (lines 35-41) are likewise untested.

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.

Covered in ece3f332. The validator test now exercises both the zero-balance create_account branch and the prefunded transfer / allocate / assign branch, checks the payer delta, and reclaims both accounts. Under Agave 4.1.2, transferring only one lamport to the absent PDA is itself rejected by the runtime rent rule, so the test uses the smallest valid prefund (minimum_balance(0)) and still verifies that creation correctly tops it up. The PR description no longer overclaims coverage for the other validation branches.


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

Comment on lines +141 to +143
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]];

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 🤖

Comment on lines +105 to +110
pub fn test_create_v5_fill_status(
ctx: Context<TestCreateV5FillStatus>,
submitter: Pubkey,
relay_hash: [u8; 32],
fill_deadline: u32,
) -> Result<()> {

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 🤖

Comment on lines +204 to +206
/// 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>,

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 🤖

@droplet-rl

Copy link
Copy Markdown
Contributor

🔎 View trace

Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP
Reinis-FRP requested a review from droplet-rl August 31, 2026 18:58

@droplet-rl droplet-rl left a comment

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.

Assessment

All seven points from my previous review are addressed, and the two follow-up commits are a genuine improvement rather than a minimal patch. Re-verified locally on 1dfa69f9:

$ cargo check -p svm-spoke --features test    # clean, no new warnings
$ cargo test -p svm-spoke --lib
test result: ok. 11 passed

11 matches the corrected Validation section.

Point-by-point:

Previous finding Resolution
Duplicated PDA derivation Now imports derive_v5_fill_payer / derive_fill_status from v5.rs — back under the golden-fixture test
Replay surfaced as InvalidFillStatusAccount CommonError::RelayFilled restored (L56), TS asserts on it
Prefunded branch untested Covered, and the assertions are stronger than I asked for — see below
Rent-remainder guard removed silently Pinned by test ("insufficient funds for rent") and documented in both the spec and the lib.rs doc comment
Test entrypoint took unauthenticated submitter Now Signer, argument dropped
Missing caller precondition on submitter # Safety section added
close_fill_pda relaxation undocumented Dedicated Compatibility note in the description + recorded in the spec for release coordination

The prefunded-path test is worth calling out: rather than just asserting the account exists, it tracks the float across both creation modes and both closes, landing on initialFloat + prefundedLamports. That arithmetic only balances if the donated lamports are correctly absorbed as a top-up rather than double-paid, which is exactly the property the branch exists to guarantee. Good test.

On the new slow-fill replacement path. I checked whether overwriting relayer and fill_deadline when replacing a RequestedSlowFill lets a V5 submitter capture rent that the slow-fill requester paid. It does — but legacy fill_relay already does precisely the same thing (fill.rs:161-163), so this faithfully mirrors existing semantics rather than introducing new behaviour. Documenting it as "preserve standard fill-status semantics" is accurate. try_borrow_data on L50 is scoped to the if block and released before the try_borrow_mut_data on L107, so no borrow conflict.

Approving. Nothing here blocks. Three non-blocking items inline; the first is the one I'd least like to see slip, because it lands in Step 4 rather than here — relay_hash and fill_deadline are independent parameters with nothing binding them, and this helper is the first place in the program where the stored fill_deadline can diverge from the value committed in the relay hash. Worth closing off in the # Safety contract now while it's fresh.

Comment on lines +36 to +38
submitter: &Pubkey,
relay_hash: &[u8; 32],
fill_deadline: u32,

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.

relay_hash and fill_deadline arrive as independent parameters and nothing checks that the deadline is the one committed inside the hash. Every other writer of this field binds them: fill_relay and request_slow_fill both carry is_relay_hash_valid(&relay_hash, &relay_data, &state), so the invariant "stored fill_deadline == the value committed in relay_hash" holds everywhere in the program today. This helper is the first place that can break it, and the new replacement path means it can now overwrite a deadline that request_slow_fill had already validated.

Why it matters downstream: the stored fill_deadline is the sole input to close_fill_pda's expiry check, and that instruction is permissionless as of this PR. A caller passing a too-small deadline writes a fill status that anyone can close immediately, which removes the replay guard and reopens the relay to a second fill.

Relatedly, the helper performs no expiry check of its own (it has no state access, so it can't). Legacy fill_relay rejects relay_data.fill_deadline < current_time. Without that check in the Step 4 caller, an expired-then-closed V5 fill status can simply be re-created for the same relay hash.

Neither is reachable today — the helper is #[allow(dead_code)] and the only caller is test-gated — so this is purely about not losing the constraint between PRs. Two options:

  • Cheapest: extend the # Safety block, which currently covers only submitter, to state that the caller must source fill_deadline from the same RelayData that produced relay_hash, and must reject deadlines already in the past.
  • Sturdier: take &RelayData plus the chain ID and derive the hash internally, mirroring is_relay_hash_valid, so the binding is structural instead of contractual.

Either is fine by me; I'd just rather it be decided here than rediscovered in Step 4 review.

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 b2474f10. The helper's # Safety contract now requires the caller to derive relay_hash from the same validated V5 RelayData that supplies fill_deadline, and to reject an expired deadline before calling the helper. Step 4 should enforce those conditions structurally in its caller.


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

Comment on lines +53 to +57
let fill_type = match existing.status {
FillStatus::Unfilled => FillType::FastFill,
FillStatus::RequestedSlowFill => FillType::ReplacedSlowFill,
FillStatus::Filled => return err!(CommonError::RelayFilled),
};

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.

Is the RequestedSlowFill arm reachable for a genuine V5 relay? As far as I can trace, no:

  • A V5 relay's message is V5_MAGIC_PREFIX || step_id, so is_v5_message returns true.
  • request_slow_fill rejects V5-tagged messages with V5FillOnly (slow_fill.rs:52).
  • The relay hash commits to message, so nobody can request a slow fill under alternate relay data that hashes to a V5 relay's hash.

So no V5 relay hash can ever carry a RequestedSlowFill status. The new test reaches this branch only by constructing a non-V5 relay (message: Buffer.alloc(0)) and then invoking the V5 helper on it — a combination Step 4 would never produce, since the adapter only handles V5-tagged relays.

The spec already acknowledges this ("although new V5-tagged slow-fill requests are disabled"), so I assume it's deliberate symmetry that becomes live if V5 slow fills are ever enabled. Fine by me if so — just flagging that it's currently unreachable surface whose only coverage exercises a shape production can't produce, in case that wasn't the intent.

Same question for the Unfilled arm: fill_relay and request_slow_fill both set a terminal status in the same instruction that initializes the account, so a persisted Unfilled account shouldn't exist either. Harmless as a defensive default, just noting it.

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.

@droplet-rl I checked this against the EVM V5 design before changing it. contracts-v5/docs/SPOKE_V5_FILLS.md explicitly quarantines V5-tagged deposits from slow-fill requests and executions, so RequestedSlowFill is not an intended V5 state; a persisted Unfilled state is likewise unreachable through the existing writers.

Fixed in b2474f10: I removed the speculative replacement behavior and the synthetic non-V5 test. The helper now accepts only an uninitialized PDA, returns RelayFilled for Filled, and rejects either remaining program-owned enum state as InvalidFillStatusAccount. The spec now records the same V5 slow-fill quarantine. This also matches the intended Solana lite-chain direction; removing the general SVM slow-fill instruction remains outside this PR. Please recheck this piece.


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

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.

Rechecked b2474f10. The quarantine change is correct — and I confirmed it independently of the EVM doc rather than taking it on faith:

  • request_slow_fill returns V5FillOnly for is_v5_message (slow_fill.rs:52), and is_v5_message matches on the first 32 bytes being V5_MAGIC_PREFIX.
  • The relay hash commits to message, and is_relay_hash_valid binds the two, so nobody can request a slow fill under alternate relay data that hashes to a V5 relay's hash.

So neither RequestedSlowFill nor Unfilled is reachable for a V5-tagged relay, and rejecting both as InvalidFillStatusAccount is strictly more conservative than replacing them. Fail-closed on a state that can't occur is the right call for this program. Dropping -> Result<FillType> follows correctly, since the only reachable V5 outcome was FastFill. Verified on b2474f10: cargo check -p svm-spoke --features test clean, cargo test -p svm-spoke --lib 11 passed.

One thing the refactor dropped that I don't think was intentional. Flattening the if/else removed both validations that used to guard the non-program-owned path:

require_keys_eq!(*fill_status.owner, system_program::ID, V5Error::InvalidFillStatusAccount);
require!(fill_status.data_is_empty(), V5Error::InvalidFillStatusAccount);

Control now falls straight from the owner == &ID check into the creation sequence, so a fill_status owned by neither svm-spoke nor the System Program reaches create_account/allocate unvalidated.

Not exploitable, and I checked why: assigning or allocating that PDA requires its own signature, which only svm-spoke can produce via invoke_signed, so the owner is always svm-spoke or the System Program, and a system-owned one always has empty data. Even if it were reachable, the System Program's allocate returns AccountAlreadyInUse for a non-empty or non-system-owned account, so it fails safe. It's purely about keeping the validate-then-act shape and a program error instead of a bare System Program one:

if fill_status.owner == &ID {
    // existing replay / invalid-state handling
} else {
    require_keys_eq!(*fill_status.owner, system_program::ID, V5Error::InvalidFillStatusAccount);
    require!(fill_status.data_is_empty(), V5Error::InvalidFillStatusAccount);
}

Your call — I didn't push it, since you're mid-iteration on the branch and I'd rather not race your agent. Happy to if you want it.

Two smaller notes on the rest of the commit:

CI change is correct and was actually needed. The verified-test job deploys a binary built with IS_TEST=true but was regenerating only the mock_gateway IDL with --features test, so target/types/svm_spoke.ts wouldn't have carried testCreateV5FillStatus and the new test would have failed to build the instruction. Overwriting target/idl/svm_spoke.json is scoped to that job, so it doesn't leak into the published artifact.

The .signers([relayer]) removals. I checked whether the regenerated IDL breaks the compatibility note, since Anchor builds the account meta as isSigner: false and the old client still passes the keypair. On the pinned @solana/web3.js 1.98.2 it does not — signing with a key that isn't a required signer is tolerated with a deprecation warning, not an error:

Transaction references a signature that is unnecessary, only the fee payer and
instruction signer accounts should sign a transaction. This behavior is
deprecated and will throw an error in the next major version release.

So the compat note holds as written. Worth knowing that web3.js states it will throw in the next major, and that removing the signer from both SvmSpoke.Fill.ts and SvmSpoke.SlowFill.AcrossPlus.ts leaves the repo with no coverage of the old-client path. Keeping .signers([relayer]) at one of the two call sites and dropping it at the other would exercise both shapes for free.

Separately, the # Safety block now covers the relay_hash/fill_deadline binding and the expiry precondition — that fully addresses the point I flagged as the one I least wanted to see slip into Step 4. My approval stands.

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 🤖

@droplet-rl

Copy link
Copy Markdown
Contributor

🔎 View trace

Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
@Reinis-FRP
Reinis-FRP marked this pull request as ready for review August 31, 2026 20:34
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-31T20:41:01.600821Z b2474f1 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@droplet-rl

Copy link
Copy Markdown
Contributor

🔎 View trace

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b2474f10e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);

await program.methods
.testCreateV5FillStatus([...relayHash], fillDeadline)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Generate the test-feature IDL in the standard test command

When this test is run via yarn test-svm or yarn test-svm-solana-verify, IS_TEST=true applies only to the first build command in package.json; the subsequent yarn generate-svm-artifacts runs buildIdl.sh without that variable and regenerates svm_spoke without --features test. Consequently the generated client has no testCreateV5FillStatus method, so the repository's standard SVM test commands cannot execute this new suite. Export IS_TEST for the whole command chain or explicitly generate the test-feature IDL there as well.

Useful? React with 👍 / 👎.

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 b90ba45a. Both standard SVM test commands now generate normal production artifacts first, then call a shared generate-svm-test-idls helper before anchor test. Verified-SVM CI uses the same helper.

The helper writes only to target/idl and target/types; it never copies test-feature IDLs, mock-gateway artifacts, or generated clients into src/svm/assets or src/svm/clients. I verified that the production svm_spoke IDL excludes test_create_v5_fill_status, the target test IDL includes it, package assets remain unchanged, and yarn build-ts passes.


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

Signed-off-by: Reinis Martinsons <reinis@umaproject.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants