Internal security review · Report · 13 October 2026

Parabol² mainnet contracts: full findings

This is an internal review, not an independent audit. It was done by the Parabol team with AI-assisted code review (Claude). Nobody outside the project has checked it. An independent audit is the next step. Until then, only trade or provide liquidity with money you can afford to lose.
Critical0
High0
Medium7
Low6
Info17

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:

Deployment. Robinhood Chain mainnet (chain 4663). On-chain state was checked at block 87,456,181.

ContractAddress
Bull² engine0x04CB356082c42088DcfF5f0885Cd3f6227828Cb5
Bull² vault0x8d7E3Fb46F250700466997515Da34FE56906D2A3
Bear² engine0x984666F5ec906e2dCd1C1A2a0d15d47ff8Bf852b
Bear² vault0x716F4fF2F82BCD7BC110a7be2A1f8725d81ec7A7
Strategy router0xf12391652392AAF5efF478f530c4F58A16e652Ec
Market hours0x46e52cB85013dd84ea1F25B7Ea1c2CB2937a7662
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

3. Summary of findings

IDSeverityTitleStatus
PRB-M1MediumThe TWAP floor for forced hedge sales is not switched on for the Bull² vaultOpen · config fix
PRB-M2MediumWeekend and holiday sells settle at the held closing price (a free option)Open · upgrade
PRB-M3MediumBull² buys during market hours accept prices up to 26 hours oldOpen · upgrade
PRB-M4MediumThe guardian key can reopen Bull² buys after a governance pauseOpen · upgrade
PRB-M5MediumBull² carry and price-age settings have no upper limitOpen · upgrade
PRB-M6MediumThe Bear² reserve can be exceeded when the market regime changesOpen · latent
PRB-M7MediumLiquidity-provider exits are priced on a held or paused priceOpen · latent
PRB-L1LowThe deployer wallet still holds the keeper role on the Bull² vaultFix ready · awaiting Safe
PRB-L2LowThe Bull² paused-sell budget is too large to limit anything at current vault sizeOpen · config fix
PRB-L3LowA misbehaving price feed or stock token can block sells, with no way to replace itOpen · upgrade
PRB-L4LowBull² liquidity exits have no minimum-out, and the previews leave out the sale costOpen · upgrade
PRB-L5LowThe market calendar allows sessions of up to 5 daysOpen · upgrade
PRB-L6LowBear² listing does not require a fresh priceOpen · upgrade

Status meanings:

4. Medium findings

PRB-M1MediumOpen · config fix

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:

  1. Push the pool down to the floor.
  2. Sell Bull², which forces the vault to sell its hedge at the floor price.
  3. Buy the stock back.

The vault's liquidity providers lose the gap.

Where
CrabVault.sol:653-661 (_salePrice falls back to spot when priceReference == 0). The deployment script and the Safe activation batch never call setPriceReference.
Evidence
  • On chain: priceReference() returns 0x0.
  • 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.
Impact today
Cents to about 1 USDG per event. It grows in proportion to vault size and to the weekend price gap.
Fix
  1. Deploy UniswapV3TwapReference.
  2. Raise the observation cardinality on every hedge pool.
  3. Have the Safe call setPriceReference.
  4. Make the post-deploy check fail, not just warn, when no reference is set.
  5. Optionally, allow keeper rebalances only during market hours.
This must be done before vault capacity is raised.
PRB-M2MediumOpen · upgrade

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.
PRB-M3MediumOpen · upgrade

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.
PRB-M4MediumOpen · upgrade

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 (setBuysPaused and setGlobalBuysPaused accept false from GUARDIAN_ROLE). Compare BearEngine.sol:207-219.
Evidence
On chain, guardian 0xb417…F3FA holds GUARDIAN_ROLE on 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.
PRB-M5MediumOpen · upgrade

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.
PRB-M6MediumOpen · latent

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) and BearVault._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.
PRB-M7MediumOpen · latent

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, 487 and PowerEngine.sol:716-726.
  • Deposits and exits never check the market regime.
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

PRB-L1LowFix ready · awaiting Safe signatures

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 returns true.
Fix
  • The deploy script now drops the role, and a test checks it (commit f9e0dee).
  • A Safe transaction calling revokeRole is prepared and waiting for signatures.
  • This report will be updated when it executes.
PRB-L2LowOpen · config fix

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.
PRB-L3LowOpen · upgrade

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.
PRB-L4LowOpen · upgrade

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.
PRB-L5LowOpen · upgrade

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.
PRB-L6LowOpen · upgrade

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

  1. 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.
  2. 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.
  3. 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.
  4. A VOL²/Protect receipt transferred to the router contract itself is locked forever. MainnetStrategyRouter.sol:431-433.
  5. The router's VOL² quote ignores Bull² pause flags, so the quote succeeds and the buy reverts. This affects the front end only.
  6. There is no timelock yet. The 2-of-3 Safe can upgrade all five contracts immediately. A 48-hour timelock is planned.
  7. SECURITY.md and the README describe an earlier deployment, and Bear² has no documented roles or invariants. Being rewritten.
  8. The Bull² deposit cap compares against vault value, not net deposits, so profit or a donation can close deposits early.
  9. Keeper hedge swaps can be sandwiched within ±1% of the oracle price. The sequencer has no public mempool, which makes this harder.
  10. 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).
  11. Bull² liquidity exits always start by selling the NVDA hedge, which skews hedges until the next rebalance.
  12. Bear² has unchecked uint128 downcasts that cannot be reached today. Use SafeCast.
  13. BearVault.maxDeposit/maxMint do not match ERC-4626 when receiver ≠ sender.
  14. Bear² buys read the current market's feed twice. This costs gas but does no harm.
  15. The admin can raise spreads to 10% and fees to 1% without delay in both engines. This will be covered by the timelock.
  16. 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.
  17. The base-carry step limit can never step a positive base carry down to exactly zero. Harmless.

7. Checked and found sound

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.