Safety

The safety model

One property carries the whole product: this program has no instruction that pays an address the agent chooses. It is a property of the program, and it stops at the program's edge. Once value reaches a trading venue, the venue's own key model applies, and nobody has yet probed whether a Hyperliquid agent key can withdraw. This page lists every path money can take out of a policy, how each of the two instructions that hand an outside program a signature is contained, and where the guarantee becomes trust.

The property, and where it stops#

The claim has two halves and they are not equally strong. The first is a property of this program and is unchanged. The second belongs to whichever venue the money reaches, and is weaker. Both are stated here, because only the pair of them is true.

On Solana, the program can never pay an address the agent chooses

There is no instruction in the program that sends a policy's funds to an address the agent chooses. Not one. The agent's only outbound instruction is agent_fund_venue, whose destination must be a token account the policy authority registered in advance, and the only other exit is sweep_profit, whose two destinations are the lp_quote and holder_rewards accounts, both owned by program addresses and neither supplied by the caller. Any new instruction that let the agent name a destination would break the product, which is why the constraint is written down in the program source itself.

At a venue, the venue's key model applies, and on Hyperliquid nobody has checked it

Once value reaches a venue it is outside this program, and what the trading key may do there is the venue's rule rather than ours.

On Hyperliquid, which is the venue that works today, this is unverified. An agent key was approved on mainnet on 2026-09-23 and filled a real order on the master account, so the trading half is proven. The withdrawal half is not: the probe that would attempt usd_transfer, withdraw_from_bridge, sub_account_transfer and vault_transfer with that key has still never been run. Hyperliquid's own SDK states twice that an agent cannot transfer or withdraw, and its validator source is closed, so that is a vendor statement rather than inspectable enforcement.

On Derive the question does not arise yet, because no Stags options vault exists. v3 does carry real scoped session keys, and a key scoped to options trading was refused withdraw and every transfer put to it against a control. What is missing is the ability to create such a key per launched token: private/set_session_key returns an internal error for every valid payload. See venues.

What this program does NOT rely on

None of the Solana-side property depends on a venue behaving. The bankroll can only ever reach a token account the policy authority registered in advance, and what is at a venue is reported back as an attestation bounded by staleness, deviation and the pause. A venue key that misbehaved would lose money that was already deployed; it could not reach the vault, the escrows or the holder rewards.

A boundary diagram in two regions, separated by a line that value crosses only through an operator-run relayer. Above the line, on Solana, the program enforces that the agent key can move funds only to a venue account the policy authority registered in advance, that every program-owned account is a derived address rather than a wallet, and that no instruction pays an address a caller chooses. Below the line, at the venues, the same property depends on each venue's own key model: a Hyperliquid agent key has traded on mainnet and its withdrawal permissions are still untested, and Derive version three has scoped keys that were tested against a control and hold, but no key can be minted per launched token today because the venue's own endpoint is failing.SOLANA: ENFORCED ON CHAINAgent keysigns, cannot chooseAuthorityregisters the venuesPre-registered venue accounts onlythe allowlist is an authority actionNo instruction pays a chosen addressevery destination is derived or pre-registeredcrossed by an operator-run relayerVENUES: TRUSTED, NOT ENFORCEDHyperliquid agent wallettraded on mainnet, withdrawal never probedUNTESTEDDerive v3 scoped keyrefusals tested against a controlSCOPES HOLDMinting one per tokenset_session_key answers -32603NOT AVAILABLE TODAY

The property holds above the line because the program enforces it. Below the line it holds only as far as each venue's own key model: on Hyperliquid that is a vendor statement nobody has tested, and on Derive the key model is the right one but no key can be minted per token today.

Every outbound path#

These are all of them. Each row is an instruction that can reduce a policy-owned balance.

InstructionWho signsWhere it can sendCan it reach a chosen address?
routeanyonethe associated token account of Config.protocol_fee_dest, the bankroll, and lp_quoteNo. The destination wallet is protocol config, not a caller argument.
compound_lpanyonethe pinned AMM program, via a passthrough signed only by lp_authNo. lp_auth owns lp_quote and lp_base and nothing else, so that pair is the whole set the forwarded instruction can debit.
passive_deployanyoneonly the registered venue account whose asset_mint equals target_assetNo.
agent_fund_venueonly Policy.agentany registered venue accountNo. Registration is an authority action.
sweep_profitanyonelp_quote and holder_rewardsNo. Both are program-owned and neither can pay a wallet.
buyback_and_burnanyonethe pinned AMM program, via a passthrough signed only by holder_auth, then the token program's burnNo. holder_auth owns only holder_rewards, and the bought tokens are burned in the same instruction rather than left anywhere.
burn_base_leganyonenowhere. It only burns.No.

Two corrections worth making precisely

sweep_profit is sometimes described as moving funds only toward the LP. It has two destinations: lp_quote, which feeds the LP, and the holder rewards account, which feeds the buyback. Neither is a wallet and neither can be chosen by the caller, so the safety property holds either way, but the accurate statement names both.

Its LP leg pays lp_quote rather than quote_escrow, and that is deliberate rather than incidental. quote_escrow is what route splits, so profit landing there would be taxed a second time on the next route: the protocol cut and the bankroll share would both come out of money that had already been through the split.

bankroll ─┬─▶ registered venue account   (agent_fund_venue / passive_deploy)
          ├─▶ lp_quote       ──▶ LP    (sweep_profit ──▶ compound_lp)
          └─▶ holder_rewards ──▶ burn  (sweep_profit ──▶ buyback_and_burn)

no arrow from this diagram ends at an address a caller supplies.

The venue boundary#

Three instructions forward an instruction supplied by the caller to an external program: claim_fees to the pinned LaunchLab program, and compound_lp and buyback_and_burn to the pinned AMM program. Handing an outside program one of this program's signatures is the single most dangerous thing it does, and on 2026-09-23 two defects in exactly that were found, fixed and deployed to mainnet. The shape below is the fixed shape.

What was wrong, stated plainly

compound_lp signed its passthrough with the policy seeds. After the move to associated token accounts the policy is the authority on both quote_escrow and base_escrow, so a single signature reached both. Its only bound was max_in, which was an argument the caller supplied on a permissionless instruction, so an attacker passed u64::MAX and it bounded nothing. The remaining constraint, that the target equals Config.amm_program, would not have stopped the AMM swapping either escrow into a pool of the caller's choosing.

claim_fees had to keep the policy signature, because LaunchLab requires the pool's creator to sign. But the instruction data was the caller's to choose, so any LaunchLab instruction gated on nothing but the creator's signature was reachable: create_vesting_account above all, which assigns the creator's vesting share to a beneficiary the caller names, and also migrate_to_amm, migrate_to_cpswap and claim_vested_token.

Both are fixed in the bytecode deployed at 4fhFQ8hgbswQq7PNsrwCcxkpE1oae6fsFcLvAjWEnPhD. Nothing was ever at risk, because no policy existed when the hole was open.

Two shapes of containment, because the constraints differ#

There are only two ways to contain a forwarded instruction: withhold the signature, or bound which instruction it can be. Each of the two call sites uses the one available to it.

InstructionHow it is containedWhat the forwarded instruction can reach
compound_lpWithholds the signature. It signs as lp_auth, a dedicated program address at ["compound_auth", policy], and nothing else signs.lp_quote and lp_base, the two accounts lp_auth owns. The escrows are the policy's, the bankroll is bankroll_auth's and the holder rewards are holder_auth's, and none of those four signs here, so no instruction sent to the AMM can debit them however it is shaped.
buyback_and_burnWithholds the signature the same way, as holder_auth. This call site was always built this way, and it is the pattern the fix copied.holder_rewards only. The bought tokens land in base_escrow, which is a destination and never has to sign, and they are burned in the same instruction.
claim_feesCannot withhold it, so it bounds which instruction: the forwarded data must equal LaunchLab's claim_creator_fee discriminator exactly.Only that one instruction, which takes no arguments, so there is no argument surface left to steer. Both escrows are still measured, and a forwarded call that made either one fall is refused with VenueSpentEscrow.
the discriminator bound, from claim_fees
require!(
    data.as_slice() == LAUNCHLAB_CLAIM_CREATOR_FEE_DISCRIMINATOR,
    BankrollError::NotTheClaimInstruction
);

The eight bytes are [26, 97, 138, 203, 132, 171, 141, 252], read from LaunchLab's own IDL and independently equal to Anchor's derivation, sha256("global:claim_creator_fee")[..8]. The check is equality and deliberately not a prefix match, because a prefix match would accept appended bytes, and the point of the check is that the caller chooses nothing at all.

max_in is gone rather than ignored

compound_lp no longer takes a max_in argument. Leaving a discredited bound in the signature invites somebody to believe it, and the real bound is now a balance the program itself put there: route physically moves the LP share into lp_quote, which is the only quote account compound_lp can spend.

Moving the share fixed a second leak found in passing. The LP share used to be left in quote_escrow, which also holds freshly arrived unrouted fees, so a compound_lp call 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.

Three rules hold at every one of the three call sites:

RuleWhat it prevents
The CPI target must equal the one program pinned in Config for that instructionOur signer seeds can never reach an arbitrary program. Each call site pins a single target, never either-of-two and never a caller-supplied id.
Every account the passthrough could touch is measured before and after, and only the measured delta is creditedA claim that moves nothing credits nothing, and a compound counts what actually left. The venue's instruction layout does not have to be trusted for the accounting to be right.
Exactly one account is marked as a signer by the forwarder, and it is oursEvery other account keeps the flags it arrived with, so the call cannot escalate someone else's privileges, and which of our addresses signs decides what is reachable.

The measured-delta rule matters even now the venues are known, because a transfer-fee mint delivers less than was sent. On Token-2022 a measured delta is the only honest credit.

the check, from the program's venue module
require_keys_eq!(*target.key, *venue_program, BankrollError::VenueNotWired);
let ix = Instruction { program_id: *venue_program, accounts: accounts.to_vec(), data };
invoke_signed(&ix, account_infos, seeds)

What the venue boundary does not do#

It guarantees honest accounting and a bounded blast radius, not a good trade

The program does not know either venue's instruction layout, so it cannot check the price, the route or the destination of the swap it is forwarding. It can only check which program is being called, decide which of its own addresses signs, and measure what moved. A caller who constructs a bad instruction gets a bad outcome, recorded accurately.

So the honest statement is about blast radius rather than correctness. The worst a forwarded instruction can do is spend the accounts its one signer owns: the staged LP budget in compound_lp, the staged holder rewards in buyback_and_burn, and nothing at all in claim_fees, which can only be one argument-free instruction. The bankroll and both escrows are never an input to a passthrough.

The specification's own plan narrows this further: concrete instruction builders for LaunchLab and for Meteora belong in the program, at src/venue/launchlab.rs and src/venue/meteora.rs, so callers stop constructing venue instructions by hand. Those files are not written yet, and this is listed on what is not built yet.

The three keys#

KeySet byMayMay not
authorityinit_policy, from the signerchange an unlocked policy, lock it, rotate the agent, register and revoke venue accounts, pause and unpausewithdraw anything, change the mode, unlock a locked policy
agentinit_policy, rotatable by set_agentcall agent_fund_venue to registered venues, call note_creditchoose a destination, withdraw, change any policy field, run at all in Passive mode
guardianinit_policy, and never changed afterwardspause the policyunpause it, or anything else at all

A fourth key lives on Config: the reporter, which may attest Remote venue equity and should not be the agent. See the attestation page. The key the agent uses at Hyperliquid is not in this table at all: it is approved at the venue, and what it may do is the venue's rule rather than this program's.

There is no set_guardian

set_agent exists so a compromised agent key can be replaced. There is no equivalent for the guardian: whatever key was passed at init_policy is the guardian for the life of the policy. The authority can always pause too, so a lost guardian key does not remove the ability to stop the policy.

The guardian can stop and cannot start#

set_paused(true)   ──▶ signer must be the guardian OR the authority
set_paused(false)  ──▶ signer must be the authority

The asymmetry is deliberate. A guardian is a circuit breaker you can hand to someone you trust to stop things, without handing them the ability to restart the money moving. Only the authority restarts.

A paused policy fails every money instruction with PolicyPaused: claim_fees, route, compound_lp, passive_deploy, agent_fund_venue, sweep_profit, buyback_and_burn, burn_base_leg and note_credit. The protocol-wide Config.paused does the same thing to every policy at once, with ProtocolPaused.

The automatic pause#

A policy can also pause itself. report_remote_equity sets Policy.paused = true when an attestation moves further from the previous one than Config.max_deviation_bps allows. The report is still recorded, so a real loss is never hidden, but the money stops moving until a human looks at it. Only the authority can restart it. See remote equity and attestation.

Leverage, capped three times#

MAX_LEVERAGE = 5, and one place would not be enough.

WhereWhat it checksEnforced by
The policytarget_leverage_x <= 5 wherever it is writtenon chain, init_policy and set_policy, error LeverageTooHigh
The attestationa report carrying notional > 5 * equity is rejected outrighton chain, report_remote_equity, error NotionalTooHigh
The venuethe per-asset leverage setting on the sub-account, set to at most 5 when it is createdoff chain, and therefore trusted

The 5x multiplier in the attestation check is a literal in the instruction, not a config field, so no admin can widen it.

The risk slider is a fourth, different constraint: it caps how much of the vault may be deployed at all, which bounds the size of the position rather than its leverage. See risk, origins and asset classes.

For options, the 5x equivalent is two rules rather than one: defined risk only, no naked short options at any risk setting, and delta-adjusted notional at most 5x. Those are described on venues, they are enforced off chain by the agent runner rather than by the program, and no Stags options vault exists today to enforce them against.

The properties, and their status#

PropertyStatus
The program can never pay an address the agent choosesEnforced on chain. No instruction lets the agent name a destination.
The agent cannot withdraw at the venue eitherNot claimed. On Hyperliquid an agent key has been approved and has traded, and whether it can withdraw has never been probed. On Derive no Stags vault exists to test.
A locked policy's splits and risk can never changeEnforced on chain. lock_policy is one way, set_policy refuses afterwards.
The guardian can stop the fund but not restart itEnforced on chain, in set_paused.
Fee claims credit only the measured balance delta, and never debit an escrowEnforced on chain. A forwarded call that made either escrow fall is refused with VenueSpentEscrow.
A forwarded venue instruction can only spend the accounts its one signer ownsEnforced on chain since 2026-09-23. compound_lp signs as lp_auth, buyback_and_burn as holder_auth, and claim_fees may only be claim_creator_fee.
Leverage is capped at 5x in three independent placesTwo on chain, one at the venue and therefore trusted.
Remote equity is bounded by staleness, deviation and the pause, and is independently checkable against public APIsBounds enforced on chain; the number itself is an attestation, not a proof. See the attestation page.