feat(sealevel): sealevel token program fee support [IGNORE FOR NOW]#8642
feat(sealevel): sealevel token program fee support [IGNORE FOR NOW]#8642
Conversation
paulbalaji
left a comment
There was a problem hiding this comment.
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
InitFeehas no authorization — anyone can seize a salt + set attacker-chosen owner/beneficiary/fee_data. Inline atprocessor.rsL145.- Signed quote does not commit to the curve variant (only
max_fee+half_amount); anUpdateFeeParamsthat swaps Linear → Progressive retroactively changes the semantics of already-signed quotes. Inline atquote-verifier/src/lib.rsL95 andprocessor.rsL446. min_issued_atemergency revocation is a no-op for transient quotes (TransientQuoteinherits the trait-defaultissued_at() -> None). Inline ataccounts.rsL370.SetMinIssuedAtis not monotonic — owner can move the threshold backward and un-revoke. Inline atprocessor.rsL978.- Unbounded
BTreeMap/BTreeSetgrowth on accounts deserialized on hot paths (FeeStandingQuotePda.quotes,FeeAccount.standing_quote_domains,LeafFeeConfig.signers,RouteDomain.signers, CC-routesigners). NoMAX_*constants exist besidesMAX_ISSUED_AT_SKEW. Inline atprocessor.rsL1374.
Medium
SubmitQuotedoes not checkmin_issued_atat ingest — revoked-but-unexpired quotes get written and strand rent. Inline atprocessor.rsL1057.- 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.rsL91. .map_err(|_| Error::InvalidQuoteSignature)collapsesInvalidSignatureandUnauthorizedSignerinto one error. Inline atprocessor.rsL1211.- Semantic split on unconfigured routes: Routing-mode unconfigured domain → fee = 0 (
processor.rs~L272); CC-mode unconfigured →RouteNotFoundrevert (processor.rs~L601). Either document the divergence or unify. - Error-code inconsistency: PDA-mismatch returns
TransientPdaMismatchatprocessor.rsL431-434 and L1477-1479 butInvalidArgumenteverywhere else (per PR description). SetWildcardQuoteSignersreplaces 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_recoverdoes not reject high-s). Not exploitable today (scoped-salt + uninit-check dedupe) but trivial to add inEcdsaSignature::from_bytes. - No validation on
FeeParamson write:half_amount = 0silently returnsfee = 0;max_fee = 0same. Owner-gated, but silent misconfig is a footgun.process_init_fee+process_update_fee_params+ route creators. RemoveQuoteSignersilently no-ops when the target is not in the set — returnSignerNotFoundfor auditability.- Single-step
TransferOwnership(no propose/accept); consider two-step given this governs fee revenue routing. - Transient PDA is not auto-closed on
QuoteFeeerror paths — survives the tx, must be cleaned viaCloseTransientQuote. Document for relayers. FeeParams::max_feedocstring 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 = 0by 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 overfee_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
QuoteFeeerrors after the transient is loaded. - No exact-boundary test for
now == expiryon standing quotes. - No test for
GetProgramVersiondiscriminator-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]vsH256::zero()sentinel; no collisions. - Owner / discriminator / signer checks on mutating handlers are consistently present.
- CC DEFAULT_ROUTER fallback invalidation (
auth_scope: DirectvsCcDefaultFallback) 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>→FeeComputationOverflowis dead defensive code. - Cross-Hyperlane-domain replay:
fee_accountpubkey +domain_idin the signed hash prevent this. - Transient payer binding via
scoped_salt = keccak(payer || client_salt)— can't be hijacked.
Recommended fix order
- Add authorization to
InitFee(deployer signer or upgrade authority). - Bind curve variant into signed quotes (either in the signed hash or by storing+enforcing the variant at consumption).
- Fix transient
min_issued_atbypass + gateSubmitQuoteonmin_issued_at. - Enforce
SetMinIssuedAtmonotonicity. - Introduce
MAX_*caps on signer sets, standing-quote recipients, andstanding_quote_domains+ add stress tests. - 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); |
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
- Store
issued_atonTransientQuote(currently discarded — seeprocessor.rs:1238). - Implement
issued_at()on this trait impl to returnSome(self.issued_at). - Also gate
process_submit_quoteonissued_at >= fee_account.min_issued_atso stale quotes don't even get written.
|
|
||
| ensure_no_extraneous_accounts(accounts_iter)?; | ||
|
|
||
| fee_account.min_issued_at = min_issued_at; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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-domainSubmitQuote(:1403).- Signer sets:
LeafFeeConfig.signers,RouteDomain.signers, CCsigners, 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 { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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)?; |
There was a problem hiding this comment.
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.
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.
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.
44d5f52 to
49e85e8
Compare
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):Leaf(direct curve),Routing(per-destination domain),CrossCollateralRouting(per destination + target router)max_fee/half_amountparametersFeeData, route signers onRouteDomain/CrossCollateralRoutePDAs, wildcard-domain signers on routedFeeDatavariantsDirectvsCcDefaultFallback) — quotes authorized via DEFAULT_ROUTER fallback are invalidated once a router-specific route is createdmin_issued_atglobal revocation threshold for emergency signer compromiseValidatableQuotetrait unifies expiry +min_issued_atvalidation across quote typesGetQuoteAccountMetas,GetSubmitQuoteAccountMetas) for SDK account discoveryGetProgramVersionuses a universal hash-based discriminator (sha256("hyperlane:get-program-version")[:8]) in the sharedpackage-versionedcrate, enabling consistent version queries across all Hyperlane SVM programs withoutcoupling to any program's instruction enum
Supporting crates:
package-versioned— shared versioning trait + universalGetProgramVersiondiscriminatorquote-verifier— secp256k1 signature verification for SVM signed quotes, compiled into the fee programSigner authority matrix:
LeafFeeConfig.signersRouteDomain.signersRoutingFeeConfig.wildcard_signersCrossCollateralRoute.signersCrossCollateralRoutingFeeConfig.wildcard_signersDrive-by changes
build-programs.shtoken paths and SVM SDKgenerate-program-bytes/setup.tsInvalidArgument(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
GetProgramVersionuniversal discriminator inpackage-versionedis additive and can be adoptedby other programs independently.
Testing
Unit Tests + Functional Tests — 63 unit tests + 144 functional tests (207 total), all passing.