

Perpgame
Hardening a leveraged-token basket protocol before mainnet. 26 findings across NAV accounting, rebalance math, and freeze surfaces, 20 fixed and verified.
About the client
Perpgame lets users buy into a launched token by paying USDC, minting ERC-20 shares priced off the net asset value of a basket of Hyperliquid leveraged tokens.
Sellers burn shares to redeem their pro-rata slice of the basket back to USDC, and a rebalancer sets target weights and rebalances the basket through Bounce.tech. The platform takes a 1% fee on each trade, and a scaling buy premium accrues to existing holders.
Every launched token gets its own treasury, deployed by a CREATE2 factory behind a shared beacon, capped at 20 legs and gated by a factory-wide pause.
The team on this audit

Lead Security Researcher at Sherlock. Over 100 audits performed and 500+ bugs found.

6 Top3 finishes in audit contests. Track record in lending, perps, and accounting math.

Top-60 all-time researcher on HackenProof, with 2 Top3 finishes in audit contests.
High severity issues and fixes
Duplicate LT across symbols double-counts NAV and lets the rebalancer drain co-investors
The duplicate guard in _setTargetPortfolio only compared entries inside the array passed in the current call, never against symbols already stored, so one leveraged token could back two live symbols. nav() then counted the same real balance twice, and the sell loop moved LT out of that balance more than once. A token creator could inflate NAV to an arbitrary multiple of the truth and take the treasury's principal from public buyers on their own curve.
A persistent ltRegistered reverse index now rejects any LT already bound to a registered symbol, and the index is cleared when a symbol is pruned.
Plus 12 medium and 13 low severity findings. All documented in the full report.
Medium severity issues and fixes
Async-redeemed rebalance USDC is re-deployed by weight on the next buy, undoing the rebalance
USDC redeemed through prepareRedeem arrived as plain idle balance, so the next buy split it across legs by target weight instead of toward the rebalance targets, re-inflating the leg the rebalancer had just shrunk. Any async rebalance could be silently undone by one intervening buy, burning the Bounce redemption fee for nothing. In-flight redemption proceeds are now reserved out of the weight-based deploy and released only through the balance-aware rebalance path.
Deploy remainder reverts most of the buys
_mintLTs assigned the leftover USDC to the last symbols[] entry without checking that leg's targetBps, so a zeroed leg sitting in the last slot received only rounding dust and lt.mint reverted on minTransactionSize. Since zeroed legs are not removed from the array, this state is reachable through normal basket rotation and bricks every deploy-triggering buy. The remainder leg is now skipped when its weight is zero and the rounding remainder is routed to idle.
Rebalance buy sizes target a stale `navAtStart` after redeems change the rates
executeRebalanceStep snapshotted NAV once at the top, but the sell loop that runs in between pays redemption fees and triggers Bounce's streaming-fee checkpoint, both of which permanently lower the true NAV. The buy loop then sized grow targets as fractions of an inflated snapshot and minted past the intended weights. The NAV reference is now recomputed after the sell loop so both sides are measured at the same post-redeem rates.
Buy premium is priced at `supplyBefore`, so one large buy pays less than many small ones
AgentCurve::buy read the premium once from the pre-buy supply and applied it to the whole trade, so a buyer who concentrated demand into a single transaction paid the low starting premium on every share while incremental buyers paid the premium that rises under their feet. On a 50 AGENT run to the cap that is about 4,562 USDC against 4,799. Acknowledged: the behavior is a property of the pricing formula and was documented rather than changed.
One stuck Bounce redemption freezes all buys and sells for the whole fund
A single global rebalanceInFlight flag, keyed on userCredit, blocked deployUsdc and withdrawLtsTo for the entire basket whenever one leg's redemption could not settle, including legs Bounce had already settled. A credit too small to cover the flat redemption fee never executes, and there was no cancel path and no emergency exit. A cancelRedeem function now pulls the pending LT back into the treasury, zeroes the credit, and clears the flag.
Deploy at low `minBps` leaves buyer capital undeployed as sells drain idle below the threshold
_deployIdle deferred the entire deploy whenever idle USDC sat below minDeployUsdc(), a threshold that scales inversely with the smallest non-zero target weight. Because sells are paid out of idle first, a 0.1% leg pushes the bar to 10,000 USDC and buyer capital stays unlevered while NAV drifts from the target portfolio. Acknowledged: no funds are lost, and the threshold behavior was documented for the rebalancer.
Normal rebalances may revert
The buy step was sized from the portfolio value before Bounce's redemption fee but funded with the cash that arrives after it, so every sell-A-to-fund-B rebalance came up short by exactly the fee and reverted. With idle USDC near zero, the protocol's core operation failed under its natural parameters. The atomic path now mints what the proceeds actually cover instead of reverting.
`executeRebalanceStep` sell path always triggers `lt.redeem` due to a wrong comparison
The atomic-versus-async branch compared a 6-decimal USDC amount against an 18-decimal buffer value, so the buffer read about 1e12 times too large and the guard was always true. The atomic branch was always taken and the async prepareRedeem fallback was dead code. expectedBaseOut is now scaled to 1e18 before the comparison.
`_isFactoryLt` scan over the LT list scales with the Bounce catalog and can DoS portfolio configuration
Every portfolio update copied Bounce's entire LT array into memory and linear-scanned it, even though the factory already exposes an O(1) ltExists mapping returning the same boolean. As the catalog grows, initialize and setTargetPortfolio hit a gas ceiling Perpgame does not control. The scan was replaced with the direct ltExists lookup.
A single held LT's reverting price view freezes the entire treasury with no on-chain recovery
nav() walked every leg with no try/catch, and a Bounce LT prices itself off HyperCore through a read precompile that reverts permanently once the underlying token is delisted or its index goes invalid. One unpriceable leg locked every holder out of buys and sells and blocked the rebalancer from removing it, leaving a 48h timelocked beacon upgrade as the only exit. A recovery path now evicts a stuck leg without pricing it.
`symbols` array grows without bound
Dropped legs were only zeroed, and the sole removal path required both the LT balance and userCredit to be zero, a condition anyone can keep false forever by sending 1 wei of a retired LT. Bounce's redeployLt compounds it by orphaning the old entry while the immutable lt binding blocks a refresh. Every user then overpays gas on dead legs and must pad slippage arrays for them. A sweepDust and a rebalancer-callable LT migration now make retired entries deletable and refreshable.
Unredeemable sell leg: fee skipped or forfeited
On a sell where a leg could not be instantly redeemed, withdrawLtsTo branched on returnLts: either the LT went to the seller untaxed and the 1% on that leg was never collected, or the leg stayed in the treasury and the seller was paid only for what redeemed, silently passing the rest to the other holders. Real instant buffers are small, so this was the common path on a larger sell. The catch branch now always hands the seller the LT for the unredeemable part, minus the 1% fee in kind.
Plus 13 low severity findings. All documented in the full report.
Why Phage Security
Perpgame's basket sits on top of Bounce.tech leveraged tokens, so most of the risk lived at the seam between the two protocols: fee timing, asynchronous redemption, and price reads that can revert on HyperCore. We put researchers on it who had already audited Bounce and knew where those edges are. Twenty of twenty-six findings were fixed inside the remediation window, including the NAV double-count that let a token creator drain co-investors and the freeze class that had no on-chain recovery, and several patches went past the literal bug into recovery surfaces the protocol did not have before.