Repository navigation
feat(svm): add V5 fill-status payer float #1537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3ad6197
43b1ea7
d81b285
e2b22a1
557ebda
35ebe84
1612213
1c6fb97
138fd1c
e1199eb
8ed954a
5e0003b
d0a312d
7b0c7d1
505a5e5
7b74298
1acc28d
1e0111c
2839d8c
721b5c5
0689566
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit, carried over and downgraded: the layout assertion deleted in 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:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖 |
||
|
|
||
| pub struct V5FillStatusPdas<'a> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this have
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I lean toward keeping |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The gating is correct — Cheap hardening that costs nothing in coverage: make Also worth a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
- 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 Note the new test never exercises this: it withdraws exactly Minor, same function:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 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(()) | ||
| } | ||
| 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 |
There was a problem hiding this comment.
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 = signeris still bound byaddress = fill_status.relayer, and every writer of that field records a real beneficiary —fill_relaythe signing relayer,request_slow_fillthe requester (slow_fill.rs:75),create_v5_fill_statusthe payer PDA. The deadline check still gates closure,fill_relaycannot 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:
close_fill_pdabecomes 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.signeraccount flips toisSigner: falsein the published IDL. Clients built against the old IDL keep working, but anything that introspects the IDL to decide which keypairs acloseFillPdatransaction 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
signerwhile explicitly not being one. Renaming would break the IDL account name, so keeping it is the right call, but the/// CHECKline and the updatedlib.rsdoc comment are now the only thing preventing a misreading — worth a brief inline note that the name is retained for IDL compatibility.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in
ece3f332and the PR description. The description and V5 adapter spec now explicitly call out that existingclose_fill_pdaaccounts become permissionless after expiry, that the immutable recordedrelayerremains 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
TransactionSignerand 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 🤖
There was a problem hiding this comment.
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-featuresvm_spokeIDL, and the PR compatibility note documents both client cases.Sent from Reinis Martinsons's Codex CLI Agent using gpt-5.6-sol 🤖