The fee split
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.
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.
What is enforced#
| Rule | Where | Error |
|---|---|---|
to_lp_bps + to_bankroll_bps == 10000 | init_policy and set_policy | SplitNotWhole |
to_bankroll_bps <= Config.max_bankroll_bps | init_policy and set_policy | BankrollShareTooHigh |
protocol_fee_bps <= 2000 | initialize and set_protocol_params | BpsOutOfRange |
| Escrow must be non-empty | route | NothingToDo |
| Neither the protocol nor the policy may be paused | route | ProtocolPaused, 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.
| Step | Base units | In quote units (8 dp) |
|---|---|---|
| Routed | 1,000,000,000 | 10.00 |
| Protocol cut, 500 bps | 50,000,000 | 0.50 |
| Remaining | 950,000,000 | 9.50 |
| To bankroll, 7000 bps of the remainder | 665,000,000 | 6.65 |
| To LP, the rest | 285,000,000 | 2.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.
"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.
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.
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.
| Frozen by lock_policy | Still mutable by the authority |
|---|---|
to_lp_bps, to_bankroll_bps | agent (via set_agent) |
profit_to_lp_bps, profit_to_holders_bps, profit_to_bankroll_bps | the venue allowlist (via register_venue_account / revoke_venue_account) |
risk | paused (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#
| Actor | Can | Cannot |
|---|---|---|
| Authority | Change an unlocked policy, lock it, rotate the agent, register and revoke venues, pause and unpause | Withdraw anything, change the mode, unlock |
| Agent | Move bankroll funds to a pre-registered venue account, record inference spend | Choose a destination, withdraw, change any policy field |
| Guardian | Pause the policy | Unpause it, or anything else |
| Anyone | Claim, route, compound, deploy in Passive mode, sweep, buy back and burn | Change 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.
{
"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.