Summary
LP-vault share pricing does not see the vault's own accrued fee pool, so fees earned while earlier depositors carried the vault are paid out to whoever happens to hold shares when someone next cranks.
handle_deposit_to_lp_vault prices new shares with lp_vault_combined_nav_atoms → lp_vault_domain_nav_atoms (src/v16_program.rs:9980), which derives NAV entirely from the backing-domain ledger — total_principal_atoms, total_earnings_atoms, and impairment. It never reads cfg.lp_fee_accrued_atoms, and nothing on the deposit path harvests the pool before pricing.
handle_lp_vault_crank_fees (src/v16_program.rs:16056) is the path that moves that pool into NAV. It computes available = lp_fee_accrued_atoms - lp_fee_withdrawn_atoms, clamps it, and does ledger.total_principal_atoms += available. It takes any signer, requires no authority, and pays the cranker nothing — so the pool accumulates until an arbitrary party decides to move it, and the timing is not controlled by the LPs who earned it.
The result is that a deposit landing immediately before a crank buys into fees it did not earn.
Reproduction
tests/lp_vault_jit_crank.rs, using the same engine pricing functions the handlers call (lp_vault_nav_atoms, lp_shares_for_deposit, lp_atoms_for_redemption), so the arithmetic is the arithmetic that runs on-chain. Passes against main at 19d5d93:
cargo test --test lp_vault_jit_crank
running 3 tests
test deposit_priced_before_the_crank_captures_previously_earned_fees ... ok
test newcomer_share_of_the_fee_pool_scales_with_deposit_size ... ok
test round_trip_is_value_neutral_when_no_fees_are_pending ... ok
test result: ok. 3 passed; 0 failed
An established vault holds 1,000,000 principal against 1,000,000 shares, with 100,000 in fees accrued but not yet cranked. A depositor arrives with 1,000,000, mints at the pre-crank price, and the crank then runs:
| party |
before |
redeemable after crank |
delta |
| LP who earned the fees |
1,000,000 |
1,050,000 |
+50,000 |
| depositor who arrived first |
1,000,000 |
1,050,000 |
+50,000 |
Half the fee pool goes to a party that was not in the vault when it was earned. The share taken scales with deposit size and approaches the whole pool:
| deposit relative to vault |
fees captured (of 100,000) |
| 10% |
9,090 |
| 100% |
50,000 |
| 10× |
90,909 |
The third test is a control: the identical deposit-then-redeem round trip with no fees pending returns exactly the amount deposited, which isolates the fee pool as the source of the gain rather than any rounding or share-math artefact.
You have already fixed this bug once, in the sibling program
This is not a novel class for this codebase. The identical vector was found, classified Critical, and fixed in percolator-stake — specified in the same planning document that introduced cfg.lp_fee_accrued_atoms:
docs/superpowers/plans/2026-07-19-fee-collection-split-programs.md
"Review of the first two edits found a Critical front-running/dilution vector that those edits themselves arm."
"Attack: deposit while a surplus is pending (priced at the stale, lower share value) → self-call AccrueFees in the same transaction → the surplus distributes pro-rata over the post-deposit LP supply → the attacker captures fees that accrued before they staked, diluting every existing LP. Mirror case: an honest LP withdrawing in that window forfeits their share."
"Therefore it must be fixed before Task 8, not deferred."
"Fix: … crystallize any pending vault surplus BEFORE deposit/withdraw pricing"
That fix shipped. percolator-stake/src/processor.rs defines pre_accrue_mode1 (:2348) and calls it at every site that prices shares — deposit (:773), withdraw (:1166), and junior deposit (:2826) — each with the comment "crystallize pending trading-fee surplus into share price BEFORE pricing this."
handle_deposit_to_lp_vault has no equivalent. cfg.lp_fee_accrued_atoms appears nowhere in it; its only references in the wrapper are the field declaration, init, the two accrual sites (:8124, :8491), and the crank handler. The LP vault appears to be the one share-pricing surface in the program family where the guard was never applied.
Assessment
We are filing this as high, and the precedent above is part of why: you classified the structurally identical bug as Critical and treated it as ship-blocking.
It is structural rather than configuration-dependent — it does not need a misconfigured vault, a malicious admin, or a privileged role. It needs only a vault with fees accrued and a depositor willing to supply capital, and both the deposit and the crank are permissionless. Nothing is stolen from vault principal; what moves is yield, from the LPs who were exposed while it accrued to one who was not.
What keeps it below critical is that the loss is provably capped at lp_fee_accrued_atoms - lp_fee_withdrawn_atoms. Incumbent LPs never lose principal — post-crank NAV is L + D + P and their share of it is at least L — and the crank's conservation identity is unchanged, so there is no solvency or trader-funds exposure.
What pushes it above medium is that the friction is thinner than it first appears, in three ways.
The cooldown is the only real deterrent, and it has no validated floor. handle_create_lp_vault (src/v16_program.rs:14435) checks fee_share_bps and oi_reservation_threshold_bps against 10_000 but does not bound redemption_cooldown_slots. lp_redemption_cooldown_elapsed is current_slot >= request_slot + cooldown_slots, trivially true at zero — and the engine documents zero as "immediate redemption". At a zero cooldown the deposit, crank, request and execute all fit in one transaction, which makes the capital flash-loanable and the sequence risk-free rather than merely profitable. Your own tests create vaults this way.
The attacker is also the cranker. Nothing rate-limits the crank, nothing rewards it, and no keeper is specified for it, so the pool grows until someone chooses to move it — and the party with the strongest incentive to choose the moment is the one profiting from the timing.
After a full LP exit the capture approaches the whole pool at negligible capital. The orphaned-atoms guard blocks the crank while total_lp_shares_outstanding <= LP_VAULT_MINIMUM_LIQUIDITY, so once the last real LP redeems, fees keep accruing with no way to harvest them while L collapses to the dust backing the dead shares. The next depositor lifts outstanding above the floor, unblocks the crank, and takes P·D/(L+D) with L near zero. That variant is not deterred by any cooldown.
The practical consequence is that the vault's yield proposition does not survive contact with a single well-capitalised bot: the fee stream can be redirected indefinitely by whoever is willing to arrive last and crank.
Fix direction
We think the answer is the one you already reached on the stake side: crystallize before pricing, at every site that prices shares. Move the pending atoms into the ledger first, then price against a pool that is provably empty. That removes the timing incentive rather than repricing around it, and it keeps the priced NAV and the physically payable NAV identical by construction.
Both pricing sites must move together. There are exactly two in the wrapper — handle_deposit_to_lp_vault (:14735) and handle_execute_redemption (:15403) — and handle_request_redeem_lp_shares does not price at all, it only escrows and stamps request_slot, so the exploitable state is the crank state at execute time. Fixing deposits alone would leave the mirror your plan document already names: an LP exiting before a crank forfeits their slice to whoever stays.
Worth noting that the obvious alternative — making NAV fee-aware without moving the atoms — does not work here, and we would advise against it. The fee pool lives in header.insurance, while the ledger's earnings term comes only from bucket.utilization_fee_earnings. Inflating NAV by the pool inflates earnings_portion past the gate if earnings_portion > bucket.utilization_fee_earnings in handle_execute_redemption, so a symmetric version would return EngineLockActive and brick redemptions. You cannot price against those atoms until they have been moved.
Two implementation details that look like they matter:
- The harvest should treat "no fees pending" as success rather than returning
LpVaultNoFeesToCrank, or every fee-free deposit reverts.
- On the genesis and post-full-exit paths, the orphaned-atoms guard skips the harvest while outstanding shares are at or below
LP_VAULT_MINIMUM_LIQUIDITY — which is exactly the case that captures the largest share. Running the harvest after the mint and the outstanding bump would close that.
The deposit instruction already receives every account the harvest needs (market, registry, own and sibling ledgers, system program, and a writable signer), so this does not appear to require an account-list change on that path.
Separately and much cheaper: adding a floor to redemption_cooldown_slots at :14460 would restore the economic bound even if the harvest were ever bypassed, and it matches the rule you already apply elsewhere that nonzero policies require a nonzero cooldown.
We are happy to open a PR implementing the bilateral crystallize-before-price change, with regression tests asserting that an interposed crank cannot change any holder's redeemable claim in either direction.
Environment
dcccrypto/percolator-prog main at 19d5d93
- Engine
dcccrypto/percolator main at b5ddba2
- rustc 1.95.0
Summary
LP-vault share pricing does not see the vault's own accrued fee pool, so fees earned while earlier depositors carried the vault are paid out to whoever happens to hold shares when someone next cranks.
handle_deposit_to_lp_vaultprices new shares withlp_vault_combined_nav_atoms→lp_vault_domain_nav_atoms(src/v16_program.rs:9980), which derives NAV entirely from the backing-domain ledger —total_principal_atoms,total_earnings_atoms, and impairment. It never readscfg.lp_fee_accrued_atoms, and nothing on the deposit path harvests the pool before pricing.handle_lp_vault_crank_fees(src/v16_program.rs:16056) is the path that moves that pool into NAV. It computesavailable = lp_fee_accrued_atoms - lp_fee_withdrawn_atoms, clamps it, and doesledger.total_principal_atoms += available. It takes any signer, requires no authority, and pays the cranker nothing — so the pool accumulates until an arbitrary party decides to move it, and the timing is not controlled by the LPs who earned it.The result is that a deposit landing immediately before a crank buys into fees it did not earn.
Reproduction
tests/lp_vault_jit_crank.rs, using the same engine pricing functions the handlers call (lp_vault_nav_atoms,lp_shares_for_deposit,lp_atoms_for_redemption), so the arithmetic is the arithmetic that runs on-chain. Passes againstmainat19d5d93:An established vault holds 1,000,000 principal against 1,000,000 shares, with 100,000 in fees accrued but not yet cranked. A depositor arrives with 1,000,000, mints at the pre-crank price, and the crank then runs:
Half the fee pool goes to a party that was not in the vault when it was earned. The share taken scales with deposit size and approaches the whole pool:
The third test is a control: the identical deposit-then-redeem round trip with no fees pending returns exactly the amount deposited, which isolates the fee pool as the source of the gain rather than any rounding or share-math artefact.
You have already fixed this bug once, in the sibling program
This is not a novel class for this codebase. The identical vector was found, classified Critical, and fixed in
percolator-stake— specified in the same planning document that introducedcfg.lp_fee_accrued_atoms:That fix shipped.
percolator-stake/src/processor.rsdefinespre_accrue_mode1(:2348) and calls it at every site that prices shares — deposit (:773), withdraw (:1166), and junior deposit (:2826) — each with the comment "crystallize pending trading-fee surplus into share price BEFORE pricing this."handle_deposit_to_lp_vaulthas no equivalent.cfg.lp_fee_accrued_atomsappears nowhere in it; its only references in the wrapper are the field declaration, init, the two accrual sites (:8124,:8491), and the crank handler. The LP vault appears to be the one share-pricing surface in the program family where the guard was never applied.Assessment
We are filing this as high, and the precedent above is part of why: you classified the structurally identical bug as Critical and treated it as ship-blocking.
It is structural rather than configuration-dependent — it does not need a misconfigured vault, a malicious admin, or a privileged role. It needs only a vault with fees accrued and a depositor willing to supply capital, and both the deposit and the crank are permissionless. Nothing is stolen from vault principal; what moves is yield, from the LPs who were exposed while it accrued to one who was not.
What keeps it below critical is that the loss is provably capped at
lp_fee_accrued_atoms - lp_fee_withdrawn_atoms. Incumbent LPs never lose principal — post-crank NAV isL + D + Pand their share of it is at leastL— and the crank's conservation identity is unchanged, so there is no solvency or trader-funds exposure.What pushes it above medium is that the friction is thinner than it first appears, in three ways.
The cooldown is the only real deterrent, and it has no validated floor.
handle_create_lp_vault(src/v16_program.rs:14435) checksfee_share_bpsandoi_reservation_threshold_bpsagainst10_000but does not boundredemption_cooldown_slots.lp_redemption_cooldown_elapsediscurrent_slot >= request_slot + cooldown_slots, trivially true at zero — and the engine documents zero as "immediate redemption". At a zero cooldown the deposit, crank, request and execute all fit in one transaction, which makes the capital flash-loanable and the sequence risk-free rather than merely profitable. Your own tests create vaults this way.The attacker is also the cranker. Nothing rate-limits the crank, nothing rewards it, and no keeper is specified for it, so the pool grows until someone chooses to move it — and the party with the strongest incentive to choose the moment is the one profiting from the timing.
After a full LP exit the capture approaches the whole pool at negligible capital. The orphaned-atoms guard blocks the crank while
total_lp_shares_outstanding <= LP_VAULT_MINIMUM_LIQUIDITY, so once the last real LP redeems, fees keep accruing with no way to harvest them whileLcollapses to the dust backing the dead shares. The next depositor lifts outstanding above the floor, unblocks the crank, and takesP·D/(L+D)withLnear zero. That variant is not deterred by any cooldown.The practical consequence is that the vault's yield proposition does not survive contact with a single well-capitalised bot: the fee stream can be redirected indefinitely by whoever is willing to arrive last and crank.
Fix direction
We think the answer is the one you already reached on the stake side: crystallize before pricing, at every site that prices shares. Move the pending atoms into the ledger first, then price against a pool that is provably empty. That removes the timing incentive rather than repricing around it, and it keeps the priced NAV and the physically payable NAV identical by construction.
Both pricing sites must move together. There are exactly two in the wrapper —
handle_deposit_to_lp_vault(:14735) andhandle_execute_redemption(:15403) — andhandle_request_redeem_lp_sharesdoes not price at all, it only escrows and stampsrequest_slot, so the exploitable state is the crank state at execute time. Fixing deposits alone would leave the mirror your plan document already names: an LP exiting before a crank forfeits their slice to whoever stays.Worth noting that the obvious alternative — making NAV fee-aware without moving the atoms — does not work here, and we would advise against it. The fee pool lives in
header.insurance, while the ledger's earnings term comes only frombucket.utilization_fee_earnings. Inflating NAV by the pool inflatesearnings_portionpast the gateif earnings_portion > bucket.utilization_fee_earningsinhandle_execute_redemption, so a symmetric version would returnEngineLockActiveand brick redemptions. You cannot price against those atoms until they have been moved.Two implementation details that look like they matter:
LpVaultNoFeesToCrank, or every fee-free deposit reverts.LP_VAULT_MINIMUM_LIQUIDITY— which is exactly the case that captures the largest share. Running the harvest after the mint and the outstanding bump would close that.The deposit instruction already receives every account the harvest needs (market, registry, own and sibling ledgers, system program, and a writable signer), so this does not appear to require an account-list change on that path.
Separately and much cheaper: adding a floor to
redemption_cooldown_slotsat:14460would restore the economic bound even if the harvest were ever bypassed, and it matches the rule you already apply elsewhere that nonzero policies require a nonzero cooldown.We are happy to open a PR implementing the bilateral crystallize-before-price change, with regression tests asserting that an interposed crank cannot change any holder's redeemable claim in either direction.
Environment
dcccrypto/percolator-progmainat19d5d93dcccrypto/percolatormainatb5ddba2