Skip to content

feat(sealevel): sealevel token program fee support [IGNORE FOR NOW]#8642

Draft
xeno097 wants to merge 65 commits intomainfrom
xeno/sealevel-token-fee-support
Draft

feat(sealevel): sealevel token program fee support [IGNORE FOR NOW]#8642
xeno097 wants to merge 65 commits intomainfrom
xeno/sealevel-token-fee-support

Conversation

@xeno097
Copy link
Copy Markdown
Contributor

@xeno097 xeno097 commented Apr 20, 2026

Description

Implements the SVM fee quoting program for Hyperlane warp routes — onchain static fee curves with offchain signed quote overrides, matching EVM's fee system functionality.

Fee program (hyperlane-sealevel-fee):

  • Three fee modes: Leaf (direct curve), Routing (per-destination domain), CrossCollateralRouting (per destination + target router)
  • Three curve types: Linear, Regressive, Progressive — all with max_fee / half_amount parameters
  • Offchain quoting via secp256k1 signed quotes (transient and standing), verified against per-route signer sets
  • Per-route signer isolation matching EVM's per-contract signer model: Leaf signers on FeeData, route signers on RouteDomain / CrossCollateralRoute PDAs, wildcard-domain signers on routed FeeData variants
  • Quote cascade: transient → domain standing → wildcard-domain standing → onchain fallback
  • Standing quotes stored in per-domain PDAs with BTreeMap of recipient → quote values
  • Transient quotes created and autoclosed within the same transaction (EIP-1153 pattern)
  • CC standing quotes track auth provenance (Direct vs CcDefaultFallback) — quotes authorized via DEFAULT_ROUTER fallback are invalidated once a router-specific route is created
  • min_issued_at global revocation threshold for emergency signer compromise
  • ValidatableQuote trait unifies expiry + min_issued_at validation across quote types
  • Simulation instructions (GetQuoteAccountMetas, GetSubmitQuoteAccountMetas) for SDK account discovery
  • GetProgramVersion uses a universal hash-based discriminator (sha256("hyperlane:get-program-version")[:8]) in the shared package-versioned crate, enabling consistent version queries across all Hyperlane SVM programs without
    coupling to any program's instruction enum

Supporting crates:

  • package-versioned — shared versioning trait + universal GetProgramVersion discriminator
  • quote-verifier — secp256k1 signature verification for SVM signed quotes, compiled into the fee program

Signer authority matrix:

Fee type Quote destination Auth source
Leaf exact or wildcard LeafFeeConfig.signers
Routing exact domain RouteDomain.signers
Routing wildcard domain RoutingFeeConfig.wildcard_signers
CC exact domain resolved CrossCollateralRoute.signers
CC wildcard domain CrossCollateralRoutingFeeConfig.wildcard_signers

Drive-by changes

  • Fee program added to build-programs.sh token paths and SVM SDK generate-program-bytes / setup.ts
  • PDA key mismatch errors use InvalidArgument (aligns with mailbox convention)

Related issues

Backward compatibility

Yes — this is an entirely new program and two new library crates. No changes to existing deployed programs or their instruction sets. The GetProgramVersion universal discriminator in package-versioned is additive and can be adopted
by other programs independently.

Testing

Unit Tests + Functional Tests — 63 unit tests + 144 functional tests (207 total), all passing.


Open in Devin Review

@xeno097 xeno097 requested review from tkporter and yjamin as code owners April 20, 2026 22:47
@github-project-automation github-project-automation Bot moved this to In Review in Hyperlane Tasks Apr 20, 2026
@xeno097 xeno097 marked this pull request as draft April 20, 2026 22:47
@xeno097 xeno097 removed the request for review from yjamin April 20, 2026 22:47
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

Copy link
Copy Markdown
Collaborator

@paulbalaji paulbalaji left a comment

Choose a reason for hiding this comment

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

Review Summary

Consolidated review of the new SVM fee-quoting program (hyperlane-sealevel-fee + quote-verifier + package-versioned). Accounted for existing discussion: only the Devin bot's "no issues found" comment exists; no inline threads or author replies.

Overall: architecture is coherent (five-cell signer matrix, quote cascade, CC DEFAULT_ROUTER provenance tracking, PDA-scoped salts, per-route signer isolation). The signature-verification library, curve math, PDA seed layout, owner-check plumbing, and the CC default-router invalidation flow are all in good shape. Functional coverage on happy paths and the authority matrix is strong.

The findings below concentrate on three themes: (1) signers cannot express the full scope of what they authorize (curve variant is not bound, emergency revocation is bypassable for transients), (2) state growth is uncapped on hot-path-deserialized accounts, and (3) InitFee has no caller authorization. None are blockers for a draft; all should be resolved before this ships.

Findings by severity

High

  • InitFee has no authorization — anyone can seize a salt + set attacker-chosen owner/beneficiary/fee_data. Inline at processor.rs L145.
  • Signed quote does not commit to the curve variant (only max_fee + half_amount); an UpdateFeeParams that swaps Linear → Progressive retroactively changes the semantics of already-signed quotes. Inline at quote-verifier/src/lib.rs L95 and processor.rs L446.
  • min_issued_at emergency revocation is a no-op for transient quotes (TransientQuote inherits the trait-default issued_at() -> None). Inline at accounts.rs L370.
  • SetMinIssuedAt is not monotonic — owner can move the threshold backward and un-revoke. Inline at processor.rs L978.
  • Unbounded BTreeMap/BTreeSet growth on accounts deserialized on hot paths (FeeStandingQuotePda.quotes, FeeAccount.standing_quote_domains, LeafFeeConfig.signers, RouteDomain.signers, CC-route signers). No MAX_* constants exist besides MAX_ISSUED_AT_SKEW. Inline at processor.rs L1374.

Medium

  • SubmitQuote does not check min_issued_at at ingest — revoked-but-unexpired quotes get written and strand rent. Inline at processor.rs L1057.
  • Signed message hash lacks a version + kind byte (Leaf/Routing/CC × Transient/Standing). Currently safe via fee_account PDA mode-locking, but brittle. Inline at quote-verifier/src/lib.rs L91.
  • .map_err(|_| Error::InvalidQuoteSignature) collapses InvalidSignature and UnauthorizedSigner into one error. Inline at processor.rs L1211.
  • Semantic split on unconfigured routes: Routing-mode unconfigured domain → fee = 0 (processor.rs ~L272); CC-mode unconfigured → RouteNotFound revert (processor.rs ~L601). Either document the divergence or unify.
  • Error-code inconsistency: PDA-mismatch returns TransientPdaMismatch at processor.rs L431-434 and L1477-1479 but InvalidArgument everywhere else (per PR description).
  • SetWildcardQuoteSigners replaces the whole set with no cap and no empty-set guard — a typo can silently disable wildcard quoting.

Low

  • No low-s enforcement on secp256k1 signatures (Solana's secp256k1_recover does not reject high-s). Not exploitable today (scoped-salt + uninit-check dedupe) but trivial to add in EcdsaSignature::from_bytes.
  • No validation on FeeParams on write: half_amount = 0 silently returns fee = 0; max_fee = 0 same. Owner-gated, but silent misconfig is a footgun. process_init_fee + process_update_fee_params + route creators.
  • RemoveQuoteSigner silently no-ops when the target is not in the set — return SignerNotFound for auditability.
  • Single-step TransferOwnership (no propose/accept); consider two-step given this governs fee revenue routing.
  • Transient PDA is not auto-closed on QuoteFee error paths — survives the tx, must be cleaned via CloseTransientQuote. Document for relayers.
  • FeeParams::max_fee docstring says "Maximum fee that can be charged" — but it is only a hard clamp for Linear; for Regressive/Progressive it is an asymptote.
  • Progressive curve + integer-floor rounding: small amounts round to fee = 0 by design (test_progressive_small_amount). Splitting a large transfer into many small ones pays no fee. Likely intentional; worth documenting.

Test-coverage gaps

  • No stress/cap test for the unbounded collections above — pair with the MAX_* fixes.
  • No proptest / fuzz over fee_math::compute_fee (monotonicity, ≤ max_fee, no overflow are ideal property targets).
  • No functional-level cross-fee-account signature-replay test (only covered at the verifier unit level).
  • No explicit test that transient PDAs don't leak when QuoteFee errors after the transient is loaded.
  • No exact-boundary test for now == expiry on standing quotes.
  • No test for GetProgramVersion discriminator-collision path (appears safe given borsh variant indexing, but worth a targeted test).

Verified clear

  • PDA seed design — five distinct prefixes (fee / route / cc_route / transient / standing), length-delimited, DEFAULT_ROUTER = [0xFF;32] vs H256::zero() sentinel; no collisions.
  • Owner / discriminator / signer checks on mutating handlers are consistently present.
  • CC DEFAULT_ROUTER fallback invalidation (auth_scope: Direct vs CcDefaultFallback) is correctly enforced and well-tested (test_cc_default_authorized_standing_quote_invalidated_by_later_specific_route).
  • Quote cascade precedence (transient → domain standing → wildcard-domain standing → onchain) is well-tested.
  • Signer authority matrix — all five cells (Leaf, Routing-exact, Routing-wildcard, CC-exact, CC-wildcard) tested with both correct and wrong signer.
  • Fee-math overflow: U256 intermediates fit comfortably; no reachable panic; try_into::<u64>FeeComputationOverflow is dead defensive code.
  • Cross-Hyperlane-domain replay: fee_account pubkey + domain_id in the signed hash prevent this.
  • Transient payer binding via scoped_salt = keccak(payer || client_salt) — can't be hijacked.

Recommended fix order

  1. Add authorization to InitFee (deployer signer or upgrade authority).
  2. Bind curve variant into signed quotes (either in the signed hash or by storing+enforcing the variant at consumption).
  3. Fix transient min_issued_at bypass + gate SubmitQuote on min_issued_at.
  4. Enforce SetMinIssuedAt monotonicity.
  5. Introduce MAX_* caps on signer sets, standing-quote recipients, and standing_quote_domains + add stress tests.
  6. Add version + kind byte to the signed hash.

let fee_account_info = next_account_info(accounts_iter)?;
verify_account_uninitialized(fee_account_info)?;
let (fee_account_key, fee_account_bump) =
Pubkey::find_program_address(fee_account_pda_seeds!(data.salt), program_id);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High — InitFee has no caller authorization (owner squat).

data.owner, data.beneficiary, data.fee_data, data.salt, and data.domain_id are all attacker-controlled; the only signer check above is on payer_info, and the PDA is derived solely from salt. A front-runner who sees an intended deployment can call InitFee first with the same salt and become owner. Once initialized, verify_account_uninitialized prevents the legitimate caller from ever taking the slot back.

Suggested fix: bind the salt into a deployer-authority seed (seeds = ["fee", deployer_pubkey, salt] with deployer_pubkey required to sign) or gate init behind the program's upgrade authority.

fee_account.as_ref(),
&domain_id.to_le_bytes(),
context_hash.as_ref(),
data_hash.as_ref(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High — signed hash does not commit to the curve variant.

The signed message hashes context_hash || data_hash, where data is just max_fee (u64 LE) + half_amount (u64 LE). The curve variant (Linear / Regressive / Progressive) is absent from both data and context, and the stored quote (FeeStandingQuoteValue / TransientQuote) only records params.

At consumption, processor.rs:446 / :524 clone the current on-chain FeeDataStrategy and swap in the quoted params. If an admin rotates the curve via UpdateFeeParams after a quote is signed, the quote retroactively executes under the new curve — a signer has no way to express "I only authorize this for Linear."

Suggested fix: include a curve-variant discriminator byte in the signed hash (or in data), and at QuoteFee time reject quotes whose recorded variant differs from the on-chain strategy's variant. Also add a 1-byte version and a kind tag (Transient vs Standing, Leaf vs Routing vs CC) for defence-in-depth against future protocol evolution.

let quote_data = FeeQuoteData::try_from(transient.data.as_slice())
.map_err(|_| Error::InvalidTransientData)?;

let mut quoted_strategy = strategy.clone();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High (companion to the signed-hash finding).

let mut quoted_strategy = strategy.clone(); clones whatever curve variant is currently in the on-chain fee_data, then only overwrites the params. Combined with the fact that the signed hash does not commit to the curve variant, an already-authorized quote applies under whichever curve is active at consumption time — not the one the signer saw.

Fix together with the verifier change: when a quote is consumed, refuse if the stored/authorized variant differs from strategy's variant. Same pattern needed at line ~524 for standing-quote consumption.

fn expiry(&self) -> i64 {
self.expiry
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High — min_issued_at emergency revocation does not apply to transient quotes.

This ValidatableQuote for TransientQuote impl overrides expiry() but not issued_at(), so it inherits the trait default which returns None (see line 300). validate_quote's is_some_and(|ia| ia < min_issued_at) then short-circuits to false and the min_issued_at check is silently skipped.

Attack path: a signer key leaks → admin calls SetMinIssuedAt(now) to revoke → the attacker can still SubmitQuote + QuoteFee a transient signed by the compromised key, because both the submit path (:1042-1059) and transient consumption (:436) allow it through.

Fix:

  1. Store issued_at on TransientQuote (currently discarded — see processor.rs:1238).
  2. Implement issued_at() on this trait impl to return Some(self.issued_at).
  3. Also gate process_submit_quote on issued_at >= fee_account.min_issued_at so stale quotes don't even get written.


ensure_no_extraneous_accounts(accounts_iter)?;

fee_account.min_issued_at = min_issued_at;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High — SetMinIssuedAt is not monotonic.

fee_account.min_issued_at = min_issued_at; unconditionally overwrites the threshold, so an owner (compromised or fat-fingering) can move it backward and un-revoke quotes they previously revoked. If the intent is an emergency revocation kill-switch, this should be one-way.

Suggested fix:

if min_issued_at < fee_account.min_issued_at {
    return Err(Error::MinIssuedAtMustBeMonotonic.into());
}
fee_account.min_issued_at = min_issued_at;

}
}

standing_pda.quotes.insert(recipient_key, new_value);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

High — unbounded standing-quote BTreeMap (DoS).

standing_pda.quotes.insert(recipient_key, new_value) has no cap on map size, and SubmitQuote allows any payer holding a single valid signer signature to add entries for an unbounded set of recipient H256 values. The PDA grows via store_with_rent_exempt_realloc at the payer's expense, but it is owned by the program and reachable only by the fee-account owner via PruneExpiredQuotes.

Because this account is deserialized on the hot QuoteFee path, a griefing or compromised signer can inflate it until QuoteFee hits CU limits or pruning becomes too expensive to fit in one tx.

Same pattern applies to:

  • FeeAccount.standing_quote_domains — also hot-path-deserialized; grows on each new-domain SubmitQuote (:1403).
  • Signer sets: LeafFeeConfig.signers, RouteDomain.signers, CC signers, and the wildcard sets (:739, :1011).

Suggested fix: introduce MAX_STANDING_RECIPIENTS_PER_PDA, MAX_STANDING_QUOTE_DOMAINS, MAX_SIGNERS_PER_SET constants (e.g. 32 / 64) enforced at write time, and add stress tests.

}

// Reject issued_at too far in the future (clock skew guard).
if issued_at_ts > clock.unix_timestamp + MAX_ISSUED_AT_SKEW {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — SubmitQuote does not check min_issued_at.

This path validates expiry and future-skew only, then falls through to insert the quote into state. Quotes with issued_at < fee_account.min_issued_at are stored anyway and only fail later at QuoteFee via ValidatableQuote::validate_quote (line 519). Net effect for standing quotes: payer-funded rent stranded on dead-on-arrival entries, and attacker can pollute the BTreeMap with already-revoked entries (compounds with the unbounded-growth finding).

Suggested fix:

if issued_at_ts < fee_account.min_issued_at {
    return Err(Error::StaleQuote.into());
}

Applied after loading fee_account, before writing either transient or standing PDAs.

let data_hash = keccak::hash(&self.data);

let hash = keccak::hashv(&[
DOMAIN_TAG,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — signed hash has no version or kind byte.

The input [DOMAIN_TAG, fee_account, domain_id, context_hash, data_hash, issued_at, expiry, scoped_salt] has no version byte and no kind discriminator (Leaf vs Routing vs CC; Transient vs Standing). Today these are indirectly separated because the fee_account PDA is mode-locked to one variant, and expiry == issued_at distinguishes transient from standing — but both are brittle and make schema evolution risky.

Suggested fix: prepend a version: u8 and a kind: u8 (fee-mode × transient/standing) into the hashv input so any future schema change or kind split cannot be cross-replayed against this encoding.

payer_info.key,
&resolved_signers,
)
.map_err(|_| Error::InvalidQuoteSignature)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — error-code collapse on signature verification.

.map_err(|_| Error::InvalidQuoteSignature)? flattens QuoteVerifyError::InvalidSignature (malformed bytes / recovery failure) and QuoteVerifyError::UnauthorizedSigner (valid sig but signer not in the authorized set) into a single error code. These are very different operational events — the first is a client bug, the second is a potential key-compromise signal — and should be surfaced distinctly in logs/alerts.

Suggested fix: pattern-match the QuoteVerifyError variant and map to distinct Error codes.

xeno097 added 21 commits April 22, 2026 12:30
Single source of truth for SVM program versions. Provides PACKAGE_VERSION
const, PackageVersioned trait, and process_get_program_version helper.
…ation

FeeParams, FeeDataStrategy enum (Linear, Regressive, Progressive) with
compute_fee method using U256 arithmetic. Error type returns ProgramError
on overflow. 34 unit tests covering all curves, edge cases, fee rate
properties (EVM parity), and realistic token amounts.
FeeAccount, FeeData, RouteDomain, CrossCollateralRoute, TransientQuote,
FeeStandingQuotePda with SizedData impls verified against actual Borsh
serialization. PDA seed macros for all account types. Wildcard constants
for recipient/domain/router. SizedData impls on FeeParams, FeeDataStrategy,
FeeData, and FeeStandingQuoteValue.
Full FeeInstruction enum: InitFee, QuoteFee, SetRoute, RemoveRoute,
SetCrossCollateralRoute, RemoveCrossCollateralRoute, UpdateFeeParams
(Leaf-only), SetBeneficiary, TransferOwnership, AddQuoteSigner,
RemoveQuoteSigner, SetMinIssuedAt, SubmitQuote, CloseTransientQuote,
PruneExpiredQuotes, GetQuoteAccountMetas. Builder functions derive
PDAs and construct account metas.
Processor entry point with all instruction arms listed explicitly.
Implements InitFee (creates PDA), SetBeneficiary, TransferOwnership,
and UpdateFeeParams (Leaf-only, rejects non-Leaf with NotLeafFeeData
error). Adds params_mut() on FeeDataStrategy. Remaining instructions
use todo!() placeholders.
SetRoute creates or updates RouteDomain PDAs for destination domains.
RemoveRoute closes PDAs and returns rent to owner. Both enforce Routing
mode. Adds close_pda helper, ensure_no_extraneous_accounts check on all
handlers, and error variants (NotRoutingFeeData, NotCrossCollateral
RoutingFeeData, RouteNotFound, ExtraneousAccount).

Includes 18 functional tests (solana-program-test) grouped by instruction
module: init_fee (5), set_beneficiary (3), transfer_ownership (2),
update_fee_params (3), set_route (3), remove_route (2).
CC route handlers create/update/close CrossCollateralRoute PDAs keyed by
(fee_account, destination, target_router). Both enforce
CrossCollateralRouting mode. Same PDA lifecycle pattern as RouteDomain.

6 new functional tests: create, update, different target_routers are
separate PDAs, wrong fee data type rejected, remove, remove nonexistent.
…ascade

QuoteFee resolves fees through discriminator-based account detection:
optional transient PDA (detected by discriminator peek), domain and
wildcard standing quote PDAs (always present, may be uninitialized),
then on-chain fallback (Leaf/Routing/CC). CC routing tries specific
target_router first; default PDA is optional and only consumed when
specific is uninitialized. Returns fee as u64 LE via set_return_data.

Transient and standing quote resolution are stubbed (TODO) — only
on-chain fallback is active. 10 new functional tests covering all three
FeeData modes, CC specific/default/fallback, unconfigured domain errors,
and extraneous account detection for both CC PDA configurations.
SvmSignedQuote struct with methods: compute_scoped_salt, build_message_hash,
verify_signer, is_transient, issued_at_timestamp, expiry_timestamp. Uses
existing ecdsa-signature crate for secp256k1 recovery. Designed for reuse
by both fee program and future IGP offchain quoting.

22 unit tests including full round-trip sign/verify using k256 SigningKey,
tampered context/data detection, wrong payer/fee_account/domain, multiple
authorized signers, removed signer invalidation, and Borsh round-trip.
AddQuoteSigner inserts H160 into fee account's BTreeSet with realloc via
store_with_rent_exempt_realloc. RemoveQuoteSigner removes from the set.
SetMinIssuedAt updates the emergency revocation threshold. All owner-only.

13 new functional tests: add single/multiple/duplicate signers, remove
existing/nonexistent, non-owner rejected, extraneous account rejected
for all three instructions, min_issued_at increase/decrease.
SubmitQuote validates expiry >= issued_at, checks Clock::unix_timestamp
<= expiry, verifies secp256k1 signature against authorized signers via
quote-verifier crate, then creates a TransientQuote PDA bound to the
payer's scoped salt. Standing quote path is stubbed (todo).

Replaces SubmitQuote instruction struct with SvmSignedQuote from
quote-verifier to avoid duplication. Adds InvalidQuoteSignature,
InvalidQuoteExpiry, and QuoteExpired error variants.

8 new functional tests with real k256 signing: submit transient,
invalid signature, no signers, expiry before issued_at, expired quote
rejected by clock, zero fee params, double submit same salt rejected,
extraneous account.
Payer in QuoteFee must be a signer to prevent unauthorized transient
quote autoclose (attacker could redirect rent lamports). Matches the
design doc requirement for CloseTransientQuote payer_refund.
QuoteFee resolves on-chain curve type first (Leaf/Routing/CC), then
checks transient quote in the cascade. Transient consumption is generic
over QuoteContext trait — FeeQuoteContext (44B) for Leaf/Routing,
CcFeeQuoteContext (76B, includes target_router) for CC routing.

Validates payer binding, PDA derivation from scoped_salt, Clock expiry,
context match via QuoteContext::validate, parses FeeQuoteData (16B),
applies quoted FeeParams to the resolved on-chain curve, then autocloses
the transient PDA.

Offchain quotes work for all fee data types (Leaf, Routing, CC).

9 new functional tests: transient consumed + autoclosed on Leaf,
context mismatch (different amount/destination/recipient), transient on
Routing works, transient on CC works, CC wrong target_router fails,
payer mismatch, zero fee params consumed.
Standing quote submission (expiry > issued_at): parses FeeQuoteContext
to extract destination domain and recipient, validates amount is wildcard
(u64::MAX), creates or updates FeeStandingQuotePda per-domain PDA with
BTreeMap<recipient, FeeStandingQuoteValue>. Handles replacement semantics:
newer issued_at overwrites, equal is no-op, stale is rejected.

Updates standing_quote_domains on fee account when creating new domain PDA.
Reallocs domain PDA on entry insertion via store_with_rent_exempt_realloc.

9 new functional tests: basic standing submit, replacement with newer
issued_at, stale rejected, equal issued_at no-op, non-wildcard amount
rejected, multiple recipients per domain, wildcard recipient, zero fee
params, extraneous account rejected.
QuoteFee now resolves standing quotes: scans domain PDA for exact
recipient then wildcard recipient, then wildcard domain PDA for exact
recipient. Validates issued_at >= min_issued_at and Clock expiry.
Computes fee using on-chain curve with standing params.

Updates set_return_data to use SimulationReturnData<u64> wrapper for
correct simulation return data handling (trailing zero byte bug).

8 new functional tests with simulate_quote_fee fee value assertions:
specific match, no standing falls to on-chain, wildcard recipient,
specific > wildcard priority, min_issued_at skips stale, standing zero
fee, wildcard domain match, transient > standing priority.
CloseTransientQuote: payer-only (must match stored TransientQuote.payer),
closes orphaned transient PDA and returns rent to payer.

PruneExpiredQuotes: owner-only, removes entries where clock > expiry.
Closes domain PDA if empty and removes from standing_quote_domains.
Keeps non-expired entries with realloc.

Uses ProgramTestContext::set_sysvar for clock manipulation in prune tests.

8 new functional tests: close by payer, wrong payer fails, extraneous
rejected, prune closes empty PDA, prune keeps non-expired (partial),
prune nonexistent domain fails, prune non-owner fails, prune extraneous.
Returns required account metas for a QuoteFee call based on fee data
type: transient PDA (if scoped_salt provided, writable), domain and
wildcard standing quote PDAs (always), route PDA (Routing), or specific
+ default CC route PDAs (CrossCollateralRouting).

6 new functional tests with full PDA address verification: Leaf without
and with transient, Routing, CC routing, CC with transient (all 5
accounts verified), extraneous account rejected.
Updates package-versioned trait to return SimulationReturnData<String>
for correct simulation return data handling (trailing zero byte bug).
Implements PackageVersioned on FeeProgram marker struct.

1 new functional test verifies returned version matches PACKAGE_VERSION.
try_resolve_standing_quote now verifies the standing PDA key matches
the expected derivation from (fee_account, domain). Previously it only
checked owner == program_id, allowing an attacker to substitute a
cheaper standing quote PDA from a different fee account or domain.

1 new test: spoofed standing PDA from different fee account rejected.
Previously close_pda used fill(0) which zeroed content but kept the
data allocation length. While the runtime GC reclaims zero-lamport
accounts between transactions (masking the issue), within the same
transaction data_is_empty() would return false, preventing PDA
recreation and leaving zombie accounts.

Now uses resize(0) + assign(system_program), matching both Anchor's
close implementation and SPL Token's delete_account pattern.

1 new test: route PDA recreated successfully after remove.
SubmitQuote standing path now rejects quotes where both destination
domain is WILDCARD_DOMAIN (u32::MAX) and recipient is WILDCARD_RECIPIENT
([0xFF; 32]). Such quotes would create a global fee override for every
transfer, which the design doc explicitly disallows.

1 new test: fully wildcarded standing quote submission rejected.
xeno097 added 28 commits April 22, 2026 12:30
PDA seeds: all macros now use the consistent pattern
["hyperlane_fee", "-", <type>, "-", ...fields separated by "-"].
Previously cc_route, transient_quote, and fee_standing_quote macros
used flat concatenation without separators.

resolve_routing: use verify_optional_pda_owner for the uninitialized
check, consistent with the standing PDA pattern. Rejects accounts
owned by a third-party program instead of silently treating them as
unconfigured.
Added module-level //! docs to error.rs and fee_math.rs. Added ///
doc comments to 26 public items across accounts.rs and instruction.rs:
discriminator constants, type aliases, QuoteContext trait methods,
struct fields on FeeQuoteContext/CcFeeQuoteContext/FeeQuoteData, and
inline enum variant fields on AddQuoteSigner/RemoveQuoteSigner/
SetMinIssuedAt/PruneExpiredQuotes.
Preparation for per-route signer isolation. The signers field becomes
Option to distinguish "offchain quoting enabled" (Some) from "on-chain
fee only" (None). Added require_leaf_signers() helper, OffchainQuotingNotConfigured
error variant, and updated SizedData to account for the Option tag byte.
…teralRoute

RouteDomain and CrossCollateralRoute PDAs gain a `signers: Option<BTreeSet<H160>>`
field. SetRoute and SetCrossCollateralRoute instructions accept initial signers.
Existing callers pass None (no behavioral change). Route PDAs now use
store_with_rent_exempt_realloc for updates since size is dynamic.
InitFee now accepts an optional signers set, stored directly on the
FeeAccount. Callers control whether offchain quoting is enabled at
init time. No enforcement of Routing/CC invariants yet — that comes
with route-scoped signer paths.
…ment

Combines planned commits 4+5: route-scoped signer mutation paths and
route-aware SubmitQuote auth.

AddQuoteSigner/RemoveQuoteSigner now accept an optional RouteKey that
dispatches to the correct PDA. fee_data type is validated against the
route key variant (Leaf→None, Routing→Domain, CC→CrossCollateral).

SubmitQuote resolves signers from route PDAs for Routing/CC modes:
- Routing: reads RouteDomain PDA signers (derived from destination)
- CC: resolves specific→default CC route PDA cascade, reads signers
  from the winning route
- Leaf: unchanged (reads FeeAccount.signers)

InitFee now enforces Routing/CC must have signers=None.

Submit instruction builders gain route_pdas parameter for Routing/CC
account lists. All CC/Routing tests migrated to use route-scoped
signer setup.
…ner removal

QuoteFee must not skip standing quote lookup based on signer config.
Standing quotes remain usable after the signer set is emptied — revocation
is exclusively via min_issued_at. This test confirms the invariant.
New simulation-only instruction returns the exact account list needed
for a SubmitQuote call. Accounts vary by fee_data type and quote kind:
- Leaf: system + payer + fee_account + quote_pda
- Routing: adds RouteDomain PDA for signer lookup
- CC: adds specific + default CC route PDAs for signer lookup
- Transient vs standing determines the quote PDA type

Keeps GetQuoteAccountMetas scoped to QuoteFee (unchanged).
FeeData variants now carry their own signer configuration:
- Leaf(LeafFeeConfig): strategy + signers for all quotes
- Routing(RoutingFeeConfig): wildcard_signers for wildcard-domain quotes
- CrossCollateralRouting(CrossCollateralRoutingFeeConfig): wildcard_signers

FeeAccount no longer has a top-level signers field. InitFee no longer
accepts a separate signers parameter — signers are part of fee_data.

This enforces mode-correct signer usage at the type level: leaf signers
cannot be accidentally used for routed modes, and wildcard signer config
only exists on variants that support routing.
New owner-only instruction sets the wildcard_signers field on Routing
and CrossCollateralRouting fee accounts. Rejects Leaf mode (use
AddQuoteSigner with route=None instead). Supports setting to None to
disable wildcard quoting.
Fixes the critical wildcard domain auth bug: SubmitQuote no longer
accepts arbitrary caller-chosen route PDAs for wildcard signer lookup.

Wildcard domain quotes now use canonical auth sources:
- Routing: FeeData::Routing.wildcard_signers (no route PDA needed)
- CC: FeeData::CrossCollateralRouting.wildcard_signers (no route PDAs)
- Leaf: FeeData::Leaf.signers (unchanged, handles both exact+wildcard)

Exact domain quotes continue using route PDA signers (unchanged).

GetSubmitQuoteAccountMetas updated to match: wildcard domain requests
no longer include route PDAs in returned account lists.

Builder writability fix: AddQuoteSigner/RemoveQuoteSigner builders now
mark fee_account as readonly when route is provided (signers live on
the route PDA, not the fee account).
wildcard_signers on RoutingFeeConfig and CrossCollateralRoutingFeeConfig
is now BTreeSet<H160> (not Option). Empty set disables wildcard quoting.
Removes the kill switch — operators control wildcard behavior via set
contents, not presence.

SetWildcardQuoteSigners now takes BTreeSet directly and returns
WildcardSignersNotApplicable (not NotLeafFeeData) for Leaf accounts.

routing_wildcard_signers() and cc_wildcard_signers() are infallible
for their respective modes — always return Ok(&BTreeSet).
GetSubmitQuoteAccountMetas now marks fee_account as read-only for CC
standing quotes (only non-CC standing quotes update standing_quote_domains).

SubmitQuote CC exact-domain path now calls verify_optional_pda_owner on
both specific and default route PDAs before resolution, matching QuoteFee's
strictness. Rejects PDAs with unexpected owners instead of silently
falling through.
Both transient and standing quotes now record a StrategyTag
(Linear/Regressive/Progressive) at submission time. QuoteFee skips
standing quotes and rejects transient quotes whose tag doesn't match
the current route strategy.

This prevents a quote authorized under one curve type from executing
under a different curve after a route strategy change — unlike EVM
where contract redeployment naturally orphans old quotes, SVM route
PDAs can be updated in-place.

StrategyTag implements From<&FeeDataStrategy> for idiomatic conversion.
TransientQuote and FeeStandingQuoteValue each gain a 1-byte field.

Regression tests:
- standing quote skipped after route strategy change (Routing mode)
- transient quote rejected after route strategy change (Routing mode)
Replace duplicated specific/default CC route resolution blocks with
a single loop over [target_router, DEFAULT_ROUTER]. Same semantics,
less code.
Both TransientQuote and FeeStandingQuoteValue now implement
ValidatableQuote, which provides a single validate_quote() method
checking strategy tag, optional issued_at freshness, and expiry.

Transient quotes propagate validation errors directly. Standing quotes
use is_err() to skip invalid entries in the cascade. This ensures
both paths apply the same checks in the same order.

try_consume_transient_quote now receives min_issued_at from the caller
instead of hardcoding 0, so the trait receives the real threshold even
though TransientQuote::issued_at() currently returns None.
Replace duplicated domain/wildcard standing quote resolution blocks
with a loop over [(domain_pda, destination), (wildcard_pda, WILDCARD_DOMAIN)].
Non-CC standing quote submission updates standing_quote_domains on
the fee account, requiring it to be writable. Added an early check
that rejects the transaction if fee_account is not writable for
Leaf/Routing standing quotes. CC standing quotes don't modify the
fee account so the check is skipped for them.
PDA re-derivation checks now return InvalidArgument (the passed account
is wrong) instead of InvalidSeeds (which means the seeds themselves
can't produce a valid PDA). Aligns with the mailbox convention.
…C builder writability

Routed wildcard transient quotes are now rejected at SubmitQuote time.
Transient context matching requires exact destination equality at
QuoteFee time, so wildcard transients would always fail with
TransientContextMismatch and strand rent until manually closed.

Standing quote instruction builder now accepts fee_account_writable
parameter. CC standing submissions pass false since CC does not update
standing_quote_domains, avoiding unnecessary write locks.

Tests updated to use valid 44-byte contexts and 16-byte data for all
transient quote submissions. Added regression tests:
- Leaf standing submit with readonly fee_account fails
- Routed wildcard transient submit rejected
GetProgramVersion now uses a dedicated 8-byte hash-based discriminator
(sha256("hyperlane:get-program-version")[:8]) instead of being a
variant in the fee program's Instruction enum.

The discriminator is defined in the package-versioned crate so any
Hyperlane SVM program can adopt it independently. The fee program
processor checks for it before dispatching to the instruction enum,
matching the warp route pattern for MessageRecipientInstruction.

This enables universal version queries — the SDK sends identical bytes
to any program regardless of its instruction enum layout.
Added hyperlane-sealevel-fee to:
- build-programs.sh TOKEN_PROGRAM_PATHS
- svm-sdk generate-program-bytes.mjs PROGRAMS map
- svm-sdk testing/setup.ts PROGRAM_BINARIES map
- Regenerated program-bytes.ts with tokenFee binary
…tion, error split

H3: ValidatableQuote::issued_at() now returns i64 (not Option).
TransientQuote returns expiry (== issued_at by construction), making
min_issued_at emergency revocation apply to transient quotes.

M1: SubmitQuote rejects quotes with issued_at < min_issued_at at
ingest time, preventing revoked quotes from stranding rent in PDAs.

H4: SetMinIssuedAt enforces monotonicity — cannot move the threshold
backward, preventing un-revocation of previously revoked quotes.

M3: Split InvalidQuoteSignature into two distinct error codes:
InvalidQuoteSignature (malformed bytes / recovery failure) and
UnauthorizedQuoteSigner (valid sig, signer not in authorized set).
QuoteFee, RemoveRoute, RemoveCrossCollateralRoute, CloseTransientQuote,
and RemoveQuoteSigner never use the system program — close_pda uses
direct AccountInfo ops and store() never reallocs. Dropping the unused
account saves 32 bytes of tx space per call (1 byte with ALT).

Updated instruction builders, GetQuoteAccountMetas return data, and all
manual account lists in tests.
Extract remove_route_pda() and upsert_route_pda() helpers used by both
RouteDomain and CrossCollateralRoute set/remove handlers. Eliminates
duplicated PDA key verification, ownership check, create-or-update, and
close logic.
@xeno097 xeno097 force-pushed the xeno/sealevel-token-fee-support branch from 44d5f52 to 49e85e8 Compare April 22, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

2 participants