Internal security review · Report · 13 October 2026
Parabol² mainnet contracts: full findings
A plain-language summary is in the article. This page lists every finding with its location in the code, its status and the fix.
1. Scope
Code. Every Solidity file under contracts/src (about 4,800 lines; Solidity 0.8.28, OpenZeppelin 5.4.0), as deployed. No contract source has changed since the deployment commit 484aaaf. This covers:
- Bull²:
PowerEngine,EngineHelper,PowerToken,PowerTokenFactory,CrabVault,UniswapV3HedgeAdapter,UniswapV3TwapReferenceand the maths libraries. - Bear²:
BearEngine,BearVault. - Shared:
MainnetStrategyRouter(VOL² and Protect),MarketHours, the roles and the deployment scripts.
Deployment. Robinhood Chain mainnet (chain 4663). On-chain state was checked at block 87,456,181.
| Contract | Address |
|---|---|
| Bull² engine | 0x04CB356082c42088DcfF5f0885Cd3f6227828Cb5 |
| Bull² vault | 0x8d7E3Fb46F250700466997515Da34FE56906D2A3 |
| Bear² engine | 0x984666F5ec906e2dCd1C1A2a0d15d47ff8Bf852b |
| Bear² vault | 0x716F4fF2F82BCD7BC110a7be2A1f8725d81ec7A7 |
| Strategy router | 0xf12391652392AAF5efF478f530c4F58A16e652Ec |
| Market hours | 0x46e52cB85013dd84ea1F25B7Ea1c2CB2937a7662 |
| Governance (Safe, 2-of-3) | 0x9a6Def9dBE1277b6Dde98968691cFA1C2B1fA2f1 |
Out of scope: the website and wallet code, the keeper service, and third-party contracts (Chainlink feeds, Uniswap, USDG, the tokenized stocks, Safe). We relied on these and did not review them.
2. Method
- Four review tracks, run in parallel: the Bull² engine; the Bull² vault, hedging and TWAP; Bear²; and the router, calendar, roles and deployment. Each was a line-by-line manual read focused on solvency, pricing, rounding, access control, reentrancy, oracle handling and upgrade safety.
- Proof-of-concept tests. Findings that could cost money were reproduced as Foundry tests on a local chain (never on mainnet). Four Bear² tests and one Bull² vault test reproduce PRB-M1, M6 and M7.
- On-chain checks. Every finding about the live configuration was confirmed by reading the deployed contracts.
- Severity. Severity reflects the mechanism and how it grows with deposits. With about 24 USDG in each vault and a 50 USDG limit per trade, the money at risk today is cents to a few USDG for every finding.
3. Summary of findings
| ID | Severity | Title | Status |
|---|---|---|---|
| PRB-M1 | Medium | The TWAP floor for forced hedge sales is not switched on for the Bull² vault | Open · config fix |
| PRB-M2 | Medium | Weekend and holiday sells settle at the held closing price (a free option) | Open · upgrade |
| PRB-M3 | Medium | Bull² buys during market hours accept prices up to 26 hours old | Open · upgrade |
| PRB-M4 | Medium | The guardian key can reopen Bull² buys after a governance pause | Open · upgrade |
| PRB-M5 | Medium | Bull² carry and price-age settings have no upper limit | Open · upgrade |
| PRB-M6 | Medium | The Bear² reserve can be exceeded when the market regime changes | Open · latent |
| PRB-M7 | Medium | Liquidity-provider exits are priced on a held or paused price | Open · latent |
| PRB-L1 | Low | The deployer wallet still holds the keeper role on the Bull² vault | Fix ready · awaiting Safe |
| PRB-L2 | Low | The Bull² paused-sell budget is too large to limit anything at current vault size | Open · config fix |
| PRB-L3 | Low | A misbehaving price feed or stock token can block sells, with no way to replace it | Open · upgrade |
| PRB-L4 | Low | Bull² liquidity exits have no minimum-out, and the previews leave out the sale cost | Open · upgrade |
| PRB-L5 | Low | The market calendar allows sessions of up to 5 days | Open · upgrade |
| PRB-L6 | Low | Bear² listing does not require a fresh price | Open · upgrade |
Status meanings:
- Config fix: the governance Safe can fix it without new contract code.
- Upgrade: needs a contract upgrade.
- Latent: cannot hurt anyone under today's settings, but must be fixed before the trigger condition becomes possible.
4. Medium findings
The TWAP floor for forced hedge sales is not switched on for the Bull² vault
When a seller needs more cash than the Bull² vault holds, the vault sells some of its stock hedge on Uniswap. That sale has a price floor. The code can raise the floor to the pool's 30-minute average (TWAP) so the sale cannot be pushed far below the market. The live vault has no TWAP reference configured, so the floor falls back to the oracle price minus 1%.
The attack. Over a weekend the oracle holds Friday's close while the stock pool keeps trading. Suppose the pool has moved more than 1% above Friday's close. A Bull² holder can do all of this in one transaction:
- Push the pool down to the floor.
- Sell Bull², which forces the vault to sell its hedge at the floor price.
- Buy the stock back.
The vault's liquidity providers lose the gap.
- Where
CrabVault.sol:653-661(_salePricefalls back to spot whenpriceReference == 0). The deployment script and the Safe activation batch never callsetPriceReference.- Evidence
-
- On chain:
priceReference()returns0x0. - Proof of concept: a 1,000,000 USDG test vault, with the pool 10% above the held price.
- Without the floor, one transaction cost the providers 28,112 USDG (2.8% of the vault). The attacker made 21,854 USDG more than an honest sell.
- With the floor, the providers lost 0 and the attack lost money.
- On chain:
- Impact today
- Cents to about 1 USDG per event. It grows in proportion to vault size and to the weekend price gap.
- Fix
-
- Deploy
UniswapV3TwapReference. - Raise the observation cardinality on every hedge pool.
- Have the Safe call
setPriceReference. - Make the post-deploy check fail, not just warn, when no reference is set.
- Optionally, allow keeper rebalances only during market hours.
- Deploy
Weekend and holiday sells settle at the held closing price (a free option)
The stock price feeds update Sunday 20:00 to Friday 20:00 ET and then hold their last value. Sells are deliberately never blocked, so over a weekend a holder can sell at Friday's price even when the stock has clearly moved on 24/7 venues.
If the move goes against the holder, they sell at the old price. If it goes in their favour, they wait for Monday. Holding both Bull² and Bear² (VOL²) makes this close to risk-free: sell the losing side at the stale price and keep the winning side.
- Where
-
PowerEngine.sol:308-342(no price-age check on sells) and:711-713(prices up to 4 days old stay valid off-hours).- The buy-only age check is at
PowerEngine.sol:768-773. - The same pattern is in
BearEngine.sol:273-298, 539-541.
- Example
-
- NVDA falls 8% over the weekend.
- A 10 USDG Bull² position sells for about 9.79 USDG.
- Its true value is about 8.46 USDG.
- The cost to the vault is about 1.3 USDG, roughly 5% of today's vault.
- Fix
- Keep sells open, but price them defensively once the price is older than a few hours. Options: settle at the lower of oracle and pool TWAP, apply the paused-market rules (lower price, wider spread, hourly budget), or widen the spread as the price ages.
Bull² buys during market hours accept prices up to 26 hours old
During market hours a price is considered fresh for up to 26 hours. Nothing on chain notices if the feed stops updating while the market is moving. A trader who sees a jump before the feed reports it can buy at the old price and sell after the update. A feed outage of a few hours during a 3% move is worth about +5% per round trip, up to the market's exposure limit. After a sequencer outage shorter than 26 hours, every market reopens at pre-outage prices.
- Where
PowerEngine.sol:711-713, 768-779, 788-790.- Economics
-
- A round trip costs about 1.0% (2 × (40 bps spread + 10 bps fee)).
- The squared payoff of one 0.5% feed step is also about 1.0%, so normal feed lag breaks even.
- A sudden move or a stalled feed does not break even. It is profitable.
- Fix
- Block buys when the oracle and the pool TWAP disagree by more than a set amount, or have the keeper pause buys automatically when the feed lags. Longer term, use a low-latency oracle.
The guardian key can reopen Bull² buys after a governance pause
The guardian is a single key kept outside the Safe so that buys can be paused fast in an emergency. On Bear², the guardian can only pause; unpausing needs the Safe. On Bull², the guardian can also unpause, including undoing a pause the Safe made. Whoever controls that one key can therefore reopen buys against the vault during an incident. That contradicts our own documentation, which says the guardian "can only reduce risk".
- Where
PowerEngine.sol:245-258(setBuysPausedandsetGlobalBuysPausedacceptfalsefromGUARDIAN_ROLE). CompareBearEngine.sol:207-219.- Evidence
- On chain, guardian
0xb417…F3FAholdsGUARDIAN_ROLEon the Bull² engine. - Fix
- Use Bear²'s rule: guardian or admin may pause, only the admin may unpause (upgrade). Until then the guardian key stays in cold storage.
Bull² carry and price-age settings have no upper limit
The Safe can set Bull²'s daily carry to any value. A single transaction setting it to 100% per day, whether malicious or a typo, would wipe out the value of every Bull² position within hours. Bear² caps carry at 5% per day; Bull² does not. The price-age limits are also uncapped, so stale prices can be declared fresh. This is an admin power (2-of-3 Safe), but it needs no new code to review. That contradicts our documentation, which says the admin can only move user funds through an upgrade.
- Where
EngineHelper.sol:96-108(carry is only checked to be ≥ 0 and ordered);PowerEngine.sol:188-211, 645-646.- Fix
- Add hard caps to
validate(carry at most a few percent per day, price age at most a few days). Limit how fast settings can change, and put the admin behind the planned 48-hour timelock.
The Bear² reserve can be exceeded when the market regime changes
Bear²'s safety rule is that the vault always holds cash for the worst-case payout of every open position. Carry (which reduces the payout) is projected with the rate of the current regime. Bear² carry during market hours is about 10× the off-hours rate. If nobody records the carry for a while and the market then closes, the projection jumps back up. The payout the vault has to cover rises with no trade at all, and buys or withdrawals that were allowed a moment earlier now break the reserve.
- Where
BearEngine.sol:551-565(_mark/_previewNormFactor), used by_totalMaxPayout(:455-461),reserveView(:400-405) andBearVault._freeCash(:287-292).- Evidence (proof of concept)
-
- Deployed NVDA settings, one hour without carry being recorded: maximum payout rises from 99,042.03 to 99,106.55 against a limit of 99,042.03. That is a 0.065% breach, and the vault stays solvent.
- Settings the admin is allowed to choose (5% per day carry, 7 days unrecorded): maximum payout is 99,109 against cash of 80,526. After a crash, a full sell reverts.
- Impact today
- A tiny breach of the reserve rule, with the vault still solvent. The keeper records carry every hour.
- Fix
- Calculate the reserve from the stored carry factor, which can only fall, instead of a regime-dependent projection. Alternatively, record carry on every market before buys and withdrawals.
Liquidity-provider exits are priced on a held or paused price
When a market is paused, or the price is held over a weekend, both vaults value their liabilities at a price chosen to protect traders. For a liquidity provider who exits, the same choice makes the vault look richer than it is, so the exiting provider takes value from those who stay.
- Where
-
- Bear²:
BearEngine.sol:544-547,BearVault.sol:226-250, 283. - Bull²:
CrabVault.sol:281-336, 487andPowerEngine.sol:716-726. - Deposits and exits never check the market regime.
- Bear²:
- Evidence
- Proof of concept: with a market paused while the feed shows −20%, one provider's position was valued at 99,419 USDG instead of 93,470 USDG. That is 6.4% taken from the providers who stay.
- Impact today
- None. Deposits are allow-listed and the only liquidity provider is Parabol's own funding wallet.
- Fix
- Block provider exits while any market with open positions is paused, and price exits with the worst-case liability. This must be fixed before vaults open to outside depositors.
5. Low findings
The deployer wallet still holds the keeper role on the Bull² vault
The deployer was meant to give up every right after deployment. It gave up admin everywhere, but the Bull² vault's initializer also grants the keeper role to its first admin, and that role was never dropped. A keeper cannot move funds or change settings. It can trigger hedge rebalances, which could be timed for a small, bounded profit (each swap is floored at oracle −1%).
- Where
CrabVault.sol:107-108;DeployMainnetCandidate.s.sol:93, 119.- Evidence
- On chain,
hasRole(KEEPER_ROLE, 0x2217…0AE6)on the Bull² vault returnstrue. - Fix
-
- The deploy script now drops the role, and a test checks it (commit
f9e0dee). - A Safe transaction calling
revokeRoleis prepared and waiting for signatures. - This report will be updated when it executes.
- The deploy script now drops the role, and a test checks it (commit
The Bull² paused-sell budget is too large to limit anything at current vault size
While a market is paused, sells settle at the last good price, and an hourly budget limits how much can leave. The budget is set to 10 × the maximum trade, which is 500 USDG per hour, about 20 times the whole vault. In a crash during a pause, every holder could exit at the pre-crash price. Our documentation still quotes older, smaller figures.
- Where
BuildMainnetRelease.s.sol:80;PowerEngine.sol:716-726.- Fix
- Size the budget from vault value (a set fraction of capacity per hour) and update the documentation.
A misbehaving price feed or stock token can block sells, with no way to replace it
The engines call the price feed and the stock token inside try/catch. That does not catch a reply that is empty or malformed. Every sell, buy and vault valuation reads every market, so one broken feed or token would block the whole engine, and settings do not allow the feed to be replaced.
- Where
PowerEngine.sol:680-688, 741, 758, 194-200;BearEngine.sol:530, 568, 584, 592, 161-164.- Fix
- Use low-level calls with a length check and treat a failure as a pause. Add an admin path to freeze a market or replace its feed.
Bull² liquidity exits have no minimum-out, and the previews leave out the sale cost
When an exit needs the vault to sell hedge stock, the sale cost (up to 1% of the hedge sold) is charged to the exiting provider. There is no minimum-amount parameter, and the ERC-4626 preview functions leave this cost out, which breaks the standard's preview rules.
- Where
CrabVault.sol:270-278, 454-458, 626.- Fix
- Add exit functions with a minimum-out (or route exits through a router) and document the difference from the standard.
The market calendar allows sessions of up to 5 days
The keeper sets NYSE session times on chain, and the contract accepts a session of up to 5 days and 1 hour. A compromised keeper key could mark a whole weekend as open, which applies market-hours pricing and the 26-hour price age. Separately, an expired calendar makes every market "off-hours", which lets sells use prices up to 4 days old during real trading hours.
- Where
MarketHours.sol:15, 47.- Fix
- Cap sessions at about 17 hours, and have the keeper alert well before the calendar runs out.
Bear² listing does not require a fresh price
When a Bear² market is listed, its payoff constant and price floor are set from the current oracle price, but the price's age is not checked. A listing over a weekend could anchor them to a price that is days old. All five live markets are already listed, so this only affects future listings.
- Where
BearEngine.sol:129-135.- Fix
- Require a price younger than the market-hours age limit at listing.
6. Informational
- A Bull² sell that the vault could pay can revert if the vault cannot also pay the treasury fee while hedge sales are failing.
PowerEngine.sol:336-337. - Bull²'s protection against stock-split misprints applies only once a market is already paused. Pending split data on the stock token is not used.
PowerEngine.sol:721. - Anyone can deploy look-alike Bull² tokens through
PowerTokenFactory.deployToken. They cannot be minted, so the only risk is phishing. Always use the addresses above. - A VOL²/Protect receipt transferred to the router contract itself is locked forever.
MainnetStrategyRouter.sol:431-433. - The router's VOL² quote ignores Bull² pause flags, so the quote succeeds and the buy reverts. This affects the front end only.
- There is no timelock yet. The 2-of-3 Safe can upgrade all five contracts immediately. A 48-hour timelock is planned.
SECURITY.mdand the README describe an earlier deployment, and Bear² has no documented roles or invariants. Being rewritten.- The Bull² deposit cap compares against vault value, not net deposits, so profit or a donation can close deposits early.
- Keeper hedge swaps can be sandwiched within ±1% of the oracle price. The sequencer has no public mempool, which makes this harder.
- The TWAP floor turns off silently if a pool stores too few observations. Pushing the TWAP up can temporarily block forced sales (no funds lost).
- Bull² liquidity exits always start by selling the NVDA hedge, which skews hedges until the next rebalance.
- Bear² has unchecked
uint128downcasts that cannot be reached today. Use SafeCast. BearVault.maxDeposit/maxMintdo not match ERC-4626 when receiver ≠ sender.- Bear² buys read the current market's feed twice. This costs gas but does no harm.
- The admin can raise spreads to 10% and fees to 1% without delay in both engines. This will be covered by the timelock.
- Anyone calling Bear²
accrue()right at the 16:00 close charges the last open hour at the off-hours rate. Same root cause as PRB-M6. - The base-carry step limit can never step a positive base carry down to exactly zero. Harmless.
7. Checked and found sound
- Rounding favours the vault in every buy, sell, deposit and exit. A round trip at the same price always costs at least twice the spread and fee, so trading back and forth cannot drain a vault.
- Sells are never blocked by pauses, size limits or regime. Bear²'s worst-case payout is checked against reserved cash on every buy and every provider exit.
- Exposure caps (per market and overall) cannot be bypassed by splitting trades, using several markets or transferring tokens.
- Vaults: first-depositor and inflation attacks are uneconomic (virtual shares plus a seeded vault). Vault value cannot be moved through Uniswap pools, because hedges are valued at the oracle. Deposit locks cannot be bypassed or forced onto someone else.
- Swaps are checked by balance changes, and approvals are reset after every swap.
- Reentrancy: every state-changing entry point has a reentrancy guard, and the code follows checks-effects-interactions.
- Access control: only the engines can mint, burn or pay out. Upgrades are admin-only (the Safe). Implementations cannot be initialized directly, and proxies were initialized in the same transaction that created them.
- Roles: the Safe holds admin on all five contracts. Apart from PRB-L1, the deployer gave up every role.
- Router: nobody can sell or unwrap someone else's receipt. No approvals or dust are left behind, slippage checks cannot be bypassed, and
unwrapalways works as an escape hatch. - Oracle validation: every price is checked for round id, sign, timestamps (including future timestamps) and overflow. Stale prices pause buys.
8. Limitations
This review was time-boxed and done by the same team that built the protocol, with AI assistance. It is not an independent audit and it is not formal verification. It cannot prove there are no other bugs. Operational key-handling items found during the review are tracked privately and will be disclosed once fixed. We will update this page as each finding is fixed, and link the independent audit when it is published.
Found something? Contact @ParabolNow by DM. Please do not test on mainnet.