Organization
- @Eco-Inc
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Informational
5 findings
4 fixed
1 acknowledged
Informational5 findings
flash_fulfill refunds buffer rent to caller-supplied payer instead of recorded write
Finding Description
set_flash_fulfill_intentlets a writer publish a (route, reward) buffer at a PDA keyed only byintent_hash, paying the rent and recording themselves as writer. When flash_fulfill consumes that buffer via theIntentHashvariant, it closes the account withdestination = payer, never reading or checkingflash_fulfill_intent.writer. Because payer is supplied by the caller and consumption isn't gated on writer, any solver who watches the mempool and submitsflash_fulfillagainst a buffered intent collects the writer's rent on close. The reward path is unaffected, but the writer loses the rent (~0.04 SOL per buffer) every time someone other than them fulfills the intent, which breaks the third-party-bootstrap use case the buffer was added to support.Recommendation
Close the buffer to writer instead of payer:
if close_flash_fulfill_intent { let intent = ctx.accounts.flash_fulfill_intent.as_ref().unwrap(); let writer = ctx .accounts .writer .as_ref() .ok_or(FlashFulfillerError::InvalidWriter)?; require_keys_eq!(writer.key(), intent.writer, FlashFulfillerError::InvalidWriter); ctx.accounts .flash_fulfill_intent .close(writer.to_account_info())?;}This requires adding an optional writer:
Option<UncheckedAccount<'info>> (mut)toFlashFulfilland an InvalidWriter error variant.If the team chooses to keep close-to-payer, document the trade-off in NatSpec on
set_flash_fulfill_intentandflash_fulfillso writers know any third party who consumes their buffer claims the rent, and the buffer should only be used whenwriter == payer.Native fee can be over-reported
Severity
- Severity: Informational
Submitted by
Rikard Hjort
Summary
The
FlashFulfilledevent includes anative_feefield, which according to documentation should representreward.native_amount - route.native_amount. However, in practice, any lamports in the contract get swept and reported as fee.This mean that a solver can inflate their own reported fee arbitrarily, and the reported fee can not be relied on.
Description
sweep_leftover_native()forwardsctx.accounts.flash_vault.lamports()-- the entire PDA balance -- to the caller-suppliedclaimant, rather than the precise reward-minus-route delta.flash_vaultis a System-owned PDA that anyone can send SOL to at any time. Any pre-existing donation is silently picked up by the first subsequentflash_fulfill(). TheFlashFulfilled.native_feeevent field inherits this imprecision and over-reports solver profit by the donated amount.Importantly, a solver can send any amount of lamports of their own to the PDA as part of their transaction, knowing that it will be returned to them at no extra risk or cost.
let native_fee = sweep_leftover_native(&ctx, flash_vault_seeds)?; if close_flash_fulfill_intent { ctx.accounts .flash_fulfill_intent .close(ctx.accounts.payer.to_account_info())?;} emit_cpi!(FlashFulfilled { intent_hash, claimant: ctx.accounts.claimant.key(), native_fee,});Impact explanation
Previous audits have made clear that it is fine and intended that a solver can easily sweep any leftover funds, as those would be in the contract due to user error and are fair game, so this is not an economic vulnerability.
Recommendation
Check balance on entry into
flash_fulfill()and subtract it from the finalnative_fee. If necessary, emit the amount that was swept out of the router, preexisting.flash_fulfill excludes intents whose route or token transfers need any non-trivial nested CPI
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Rikard Hjort
Description
Solana caps the CPI call stack at 4 frames.
flash_fulfill()adds one frame on top of the standard fulfill path because it sits between the transaction root andportal::fulfill(). Every downstream consumer -- token transfers (with or without Token-2022 hooks), executor route calls, and the targets those calls invoke -- has one less frame of budget than it would under a plainportal::fulfill(). Intents whose execution requires a CPI chain >1 levels deep at any of those downstream points run via the normal path but revert viaflash_fulfill().The above diagram (courtesy of the Eco team) shows the call sequence. At the right end, there is a "transfer" described, which is its own external call.
This creates two limitations:
- Transferred Token-2022s cannot have hooks which perform external calls.
- Route calls have a single frame of call budget: they can perform one external call, whereas they would otherwise be able to perform two.
Impact Explanation
Solvers cannot use
flash_fulfill()whose route, reward, or route tokens require any CPI chain > 1 level deep at the target program (case 2) or whose Token-2022 hook performs any nested CPI (case 1). No funds are lost. No third party is harmed. The same intent remains fulfillable via standardportal::fulfill().Likelihood Explanation
The Token-2022 hook subset depends on hook-mint adoption patterns, which today are limited but growing. The route-calls subset depends on exactly the users of the Eco protocol, and there is little reason to expect them to create intents which could not be reasonably fulfilled. However any intent that uses a popular DeFi aggregator already exceeds the budget. It limits usefulness of the
flash_fulfill()enpoint.Recommendation
Document prominently in the flash-fulfiller crate-level docstring and README. Make it clear that
flash_fulfill()works only on a subset of portal-fulfillable intents, and name the conditions: any route call whose target performs 2 levels of nested CPI, or any reward/route token whose Token-2022 transfer hook performs any nested CPI.Undocumented requirements on flash fulfiller transaction sequence
Severity
- Severity: Informational
Submitted by
Rikard Hjort
Description
flash-fulfillerinstalls a 256 KBBumpAllocator. TheBumpAllocatorimpl bumps down fromstart + lenon each allocation. Without an explicitComputeBudgetInstruction::request_heap_frame(256 * 1024)in the same transaction the VM heap defaults to 32 KB. The allocator's first allocation accesses memory beyond that region, which leads to access violation and tx failure.In the PR description for PR 51, the behavior is documented:
Test helper prepends
ComputeBudgetInstruction::request_heap_frame(256 * 1024)to every flash-fulfiller tx — the 256 KB allocator bumps DOWN from start+len, so the first alloc access-violates against the default 32 KB VM heap region without it.This behavior is indeed in the test. However, it is not documented, apart from being present in the tests and in the PR description. Since this is a hard requirement for certain protocol participants (solvers) to fulfill, it should be more clearly documented.
The test helper enforces this discipline silently. Solvers that don't replicate this behavior produce broken integrations.
Recommendation
Add a paragraph in flash-fulfiller's crate-level
lib.rsrustdoc explaining that any client building a flash-fulfiller tx MUST prependComputeBudgetInstruction::request_heap_frame(256 * 1024).resolve_intent can be invoked on a buffer whose address is not derived from the actual intent hash
Severity
- Severity: Informational
Submitted by
Rikard Hjort
Description
flash_fulfill()'sIntentHash(intent_hash)variant treats the suppliedintent_hashas a storage index rather than a commitment to the buffer's content.resolve_intentonly checks that the buffer's address matchespda(writer.key(), intent_hash). It does not verify thatintent_hash == keccak(CHAIN_ID, stored_route.hash(), stored_reward.hash()). A writer usingappend_flash_fulfill_intent_chunk()can publish a buffer at PDA seed(writer, H_advertised)whose stored content actually hashes toH_real, withH_real != H_advertised. A solver who consumes the buffer thinking it representsH_advertisedactually fulfills the real intent atH_real, which may have different (and possibly hostile) economics, route calls, or reward.This differs from
set_flash_fulfill_intent(), which computesintent_hash = keccak(CHAIN_ID, route.hash(), reward.hash())from the typed args and derivespda(writer, intent_hash). Theflash_fulfill_intent()account passed in must match this address.require!( ctx.accounts.flash_fulfill_intent.key() == expected_pda, FlashFulfillerError::InvalidFlashFulfillIntentAccount);Solvers are responsible for their own security and are generally considered competent, and are assumed to run simulations. However, upholding the invariant that a consumed buffer address is consistently hashed from the intent it represents, and checking it is cheap.
Recommendation
Add the following check in
programs/flash-fulfiller/src/instructions/flash_fulfill.rs, inresolve_intent():require!( intent_hash == types::intent_hash( CHAIN_ID, &intent.route.hash(), &intent.reward.hash(), ), FlashFulfillerError::InvalidFlashFulfillIntentAccount);