Where the money goes

The fee split

One number decides whether a launch is building depth or building a trading vault. It is set once by the deployer, bounded by a protocol ceiling, and can be frozen forever.

Where the money being split comes from#

A Stags launch is StonkFun's standard mode on LaunchLab, and that mode has no creator position to claim from. StonkFun collects the pool's 1% fee and forwards the creator's share itself. The policy PDA is that creator, proven by decoding a real launch where creator equals the policy PDA, so the share is forwarded to the policy PDA and the split below operates on it.

Observed, on five real launches, landing in the right account

The forwarder is 5CEbueQnq1Ym2uSSx2xXds3jQAqT1BDnkA59RZobSPAG, StonkFun's own platform_fee_wallet read from its live platform config. A forwarding transaction is two instructions: create the creator's quote associated token account, which StonkFun pays for, then transfer into it. The destination is ATA(creator, quote_mint), which is exactly this program's quote_escrow, so forwarded fees land in the account route already reads.

Cadence, read on chain on 2026-09-23: 35 of 35 credits on one migrated launch over 3h19m with a median gap of 299 seconds, 25 of 25 on another every 15 to 50 minutes, and 8 of 8 on a still-bonding SPYX launch with a median gap of 1035 seconds. Both before and after graduation, and on Token-2022 tokenized stock quotes, which is this product's case.

None of that is a commitment, and the counter does not show it

A cadence is an observation of the recent past. Nothing on chain commits StonkFun to forwarding, to a share of the 1%, or to an interval, and a launch they do not adopt earns nothing.

And because nothing is ever claimed, Policy.fees_claimed stays at zero on this launch type while real fees flow. claim_fees forwards LaunchLab's claim_creator_fee, which drains a creator fee vault this launch type does not have, so it finds nothing by construction. route is the instruction that does the work, and it increments protocol_fees and routed_to_bankroll rather than fees_claimed. See what is not built yet.

A fee path in six steps. A trade on the LaunchLab pool pays a one percent fee, which StonkFun collects in full on chain. StonkFun then forwards the creator share off chain to the pool creator, which is the policy program address, where it arrives as an ordinary transfer rather than something the program claims. That address is the associated token account the program calls its quote escrow, so nothing has to move it. Route then splits the escrow three ways and empties it: the protocol cut, the bankroll share, and the LP share, which goes to a staging account rather than staying put.A trade on the LaunchLab poolStonkFun collects the pool's 1%on chain, platform_scale = 100%It forwards the creator's shareoff chain, on their scheduleIt arrives at the policy addressan ordinary transfer, nothing is claimedThat address is the escrowATA(creator, quote_mint), nothing to sweepquote_escrowrouteProtocol cutprotocol_fee_bpsfirst, off the topBankrollto_bankroll_bpsthe trading vaultLP shareto_lp_bpsmoved to lp_quotethe two shares sum to 10000 bps

Gold is off chain and StonkFun's: real, measured across five launches, and committed to by nothing. Everything from the escrow down is this program, live on mainnet.

The arithmetic#

route is permissionless and operates on whatever is sitting in the policy's quote escrow. It does three things, in this order:

amount        = the quote escrow's whole balance
protocol_cut  = amount * Config.protocol_fee_bps / 10000
rest          = amount - protocol_cut
to_bankroll   = rest * Policy.to_bankroll_bps / 10000
to_lp         = rest - to_bankroll

All three shares are transferred and the escrow is emptied. The protocol cut goes to the associated token account of Config.protocol_fee_dest for this policy's quote mint, created idempotently on the first call. The bankroll share goes to the bankroll. The LP share goes to lp_quote, the staging account that compound_lp later spends. route never needs a venue and can always make progress.

The LP share used to be left in the escrow, and moving it fixed a leak

Leaving it there meant compound_lp read a balance that also held freshly arrived, unrouted fees, so a compound before a route pushed the protocol's cut and the bankroll's share into the pool as well. The keeper's normal path did exactly that. Moving the share is what makes the split final at route time, and it is also what lets the compound authority own its budget outright rather than sharing an account with the escrow. See the safety model.

to_lp is a remainder, not a second multiplication

The LP share is computed as rest - to_bankroll rather than as its own basis-point multiplication, so integer truncation can never leave a stray unit unaccounted for. The two shares always add back to rest exactly.

What is enforced#

RuleWhereError
to_lp_bps + to_bankroll_bps == 10000init_policy and set_policySplitNotWhole
to_bankroll_bps <= Config.max_bankroll_bpsinit_policy and set_policyBankrollShareTooHigh
protocol_fee_bps <= 2000initialize and set_protocol_paramsBpsOutOfRange
Escrow must be non-emptyrouteNothingToDo
Neither the protocol nor the policy may be pausedrouteProtocolPaused, PolicyPaused

The 2000 bps ceiling on the protocol fee is a hard constant in the program, checked on both the instruction that sets it initially and the one that changes it. The protocol can therefore never take more than 20% of claimed fees, whatever its admin does.

A worked example#

This is a real policy on the local validator, read from the indexer. Its quote is a fixture stock at 8 decimals, its fee split is 3000 / 7000, and the protocol fee is 500 bps.

StepBase unitsIn quote units (8 dp)
Routed1,000,000,00010.00
Protocol cut, 500 bps50,000,0000.50
Remaining950,000,0009.50
To bankroll, 7000 bps of the remainder665,000,0006.65
To LP, the rest285,000,0002.85

The chain recorded protocol_fees 50,000,000 and routed_to_bankroll 665,000,000 on policy ExZAh5fTMWjTFgepEC24HcmyM35ftiZskypRNaPa7GBA, which is this arithmetic applied to 1,000,000,000 base units of its quote.

the same policy, from GET /coins/:mint
"feeSplit": { "toLpBps": 3000, "toBankrollBps": 7000 },
"counters": {
  "protocolFees":     { "value": "50000000",  "source": "chain:Policy account" },
  "routedToBankroll": { "value": "665000000", "source": "chain:Policy account" },
  "routedToLp":       { "value": "0",         "source": "chain:Policy account" }
}

routedToLp reads zero even though 285,000,000 was set aside for the LP. That is not a bug, and it is explained below.

The base leg does not come through here#

route only ever touches the quote escrow. The base leg is the launched token itself, so splitting it would be pointless: there is nothing to buy with it that it is not already. It goes straight to the burn path, unconditionally, with no split applied and no share taken by the protocol.

The claim path does not actually fill the base escrow

LaunchLab's claim_creator_fee moves one mint, and its vault is keyed on (creator, mint) with no pool in the seeds. Pre-migration the creator leg is quote only. The base escrow and both burn instructions are real and work; nothing in the claim path puts anything in them today. See how a launch works.

quote leg ──▶ route ──▶ protocol cut / bankroll / LP
base leg  ──▶ burn_base_leg or buyback_and_burn ──▶ destroyed

The LP share, and when it is counted#

Policy.routed_to_lp is incremented by compound_lp, not by route, and by the measured fall in lp_quote rather than by the amount the caller asked for. So the counter says what actually reached the position, not what was earmarked for it. Until compound_lp runs, the LP share sits visibly in lp_quote and the counter stays at zero.

Two different numbers are both called routedToLp

The API serves the on-chain counter as counters.routedToLp, which is what compound_lp spent. /analytics's revenueByDayTimeSeries serves a different number under the same name: the sum of the to_lp field on indexed Routed events, which is what route earmarked and moved into lp_quote. On the fixture policy above they read 0 and 285,000,000 respectively, and both are correct for what they measure. See the analytics endpoint.

compound_lp no longer takes a max_in bound, and its absence is the point. It was an argument supplied by the same caller that supplied the instruction it was supposed to bound, on a permissionless instruction, so an attacker simply passed u64::MAX. The real bound is lp_quote itself: the balance route put there is the most that can be spent, and the authority signing the call owns nothing else. That is the outbound half of the venue boundary described in the safety model.

Locking, and what it does not freeze#

lock_policy sets one boolean and there is no instruction that clears it. Once locked, set_policy always fails with PolicyLocked, so the fee split, the profit split, the risk, the target asset and the leverage become permanent properties of the token. That is the point: a buyer can verify where fees go and know it cannot be redirected under them.

Locking does not freeze the agent key or the venue allowlist

set_agent, register_venue_account and revoke_venue_account carry no locked check. The policy authority can still rotate the agent key and change where the agent is allowed to send funds after a lock. That is necessary, because a compromised agent key has to be replaceable, but it means "locked" is a statement about the splits and not about the whole policy.

Frozen by lock_policyStill mutable by the authority
to_lp_bps, to_bankroll_bpsagent (via set_agent)
profit_to_lp_bps, profit_to_holders_bps, profit_to_bankroll_bpsthe venue allowlist (via register_venue_account / revoke_venue_account)
riskpaused (via set_paused)
target_asset, target_leverage_x, model_id

mode is in neither column: it is written only by init_policy and no instruction can change it, locked or not.

Who can change what#

ActorCanCannot
AuthorityChange an unlocked policy, lock it, rotate the agent, register and revoke venues, pause and unpauseWithdraw anything, change the mode, unlock
AgentMove bankroll funds to a pre-registered venue account, record inference spendChoose a destination, withdraw, change any policy field
GuardianPause the policyUnpause it, or anything else
AnyoneClaim, route, compound, deploy in Passive mode, sweep, buy back and burnChange where any of it goes

Every row describes what the program permits on Solana. Once value reaches a venue, the venue's own key model decides what the trading key may do there, and on Hyperliquid that has never been probed. See the safety model.

The live protocol values#

These are the mainnet Config account, read through /analytics. They are this deployment's values rather than protocol constants: set_protocol_params can change all of them except minRiskForClass, which no instruction can write after initialize.

GET /analytics, protocolConfig, against mainnet
{
  "paused": false,
  "protocolFeeBps": 500,
  "maxBankrollBps": 9000,
  "maxRemoteStaleness": "120",
  "maxDeviationBps": 2000,
  "minRiskForClass": [0, 0, 0, 10, 80, 80, 0, 0],
  "launchlabProgram": "LanMV9sAd7wArD4vJFi2qDdfnVhFxYSUg6eADduJ3uj",
  "ammProgram": "cpamdpZCGKUy5JxQXB4dcpGPiikHawvSWAd6mEn1sGG",
  "source": "chain:Config account snapshot"
}

So on mainnet the protocol takes 5% of routed quote fees, and a deployer may send at most 90% of what is left to the bankroll. A launch that wanted everything in the vault would be rejected with BankrollShareTooHigh. A local validator is initialised with its own values, so an example captured there can differ; the ceiling in the examples on this site is 8000 for that reason.