| Adversary | Capability | Mitigation |
|---|---|---|
| Random EOA | Can call any external function | Role gating on every state-changing function. DEFAULT_ADMIN_ROLE mutations time-locked. |
| Depositor | Has ERC4626 share token, can call redeem / requestWithdraw |
maxWithdraw correctly capped by idle USDC. Inflation defense via OZ virtual-shares + 6 decimal offset. |
| Operator (compromised key) | Can place orders, bridge funds, transfer USD class | Asset whitelist (admin/timelock), slippage band vs oracle px, leverage cap on incremental notional, cannot withdraw to self, cannot change fees |
| Emergency admin (compromised key) | Can pause, cancel-all, close-positions, emergencyShutdown | Cannot move funds to self. Worst case: vault locked for redeems and operator-trade halted. Recoverable by admin (timelock) granting/revoking roles. |
| Admin (compromised) | Can change any guardrail, sweep non-asset tokens, grant/revoke roles | 24-hour TimelockController delay gives LPs time to redeem before malicious change takes effect. |
| HyperCore protocol bug | Mismarks withdrawable, returns stale precompile data |
NAV uses HL's own conservative withdrawable (not accountValue) — protocol invariants apply. If HL is compromised, the vault is compromised. |
| Function | Caller | Notes |
|---|---|---|
deposit, mint |
anyone | whenNotPaused, blocked under emergencyShutdownActive |
withdraw, redeem |
anyone | Never blocked — even when paused |
requestWithdraw, cancelWithdrawRequest, fulfillWithdraw |
anyone | fulfillWithdraw is keeper-friendly |
placeLimitOrder |
OPERATOR_ROLE |
whenNotPaused, whitelist + slippage + leverage gates |
cancelOrderByCloid |
OPERATOR_ROLE |
No gates |
pushToCore, pullFromCore |
OPERATOR_ROLE |
whenNotPaused |
usdSpotToPerp, usdPerpToSpot |
OPERATOR_ROLE |
whenNotPaused |
pause, unpause |
EMERGENCY_ROLE |
|
emergencyCancelByCloid, emergencyCancelByOid, emergencyClosePositions |
EMERGENCY_ROLE |
|
emergencyShutdown |
EMERGENCY_ROLE |
One-way; deposits permanently blocked |
setWhitelist*, setLeverageCap, setSlippageBand, setFees, setDepositCap, setMaxDepositPerAddress |
DEFAULT_ADMIN_ROLE (timelock) |
24h delay in production |
sweep |
DEFAULT_ADMIN_ROLE |
Cannot sweep asset() |
grantRole, revokeRole |
DEFAULT_ADMIN_ROLE |
Standard OZ AccessControl |
These are real bugs / footguns surfaced by running the vault end-to-end on Hyperliquid mainnet, not theoretical concerns.
-
The "donation to empty vault" trap. If anyone bridges or
spot_sends the vault asset (USDC) to the vault address before the first ERC4626 deposit, OZ's virtual-shares formula leaves those funds permanently stranded — they boost NAV per-share but no LP can claim them sincetotalSupply == 0. We hit this on mainnet when we manually funded the vault's Core account before depositing on EVM. Mitigations: (a) ALWAYS seed the vault with a deployer "lock-in" deposit before opening to LPs; (b) v1.2 shipsoperatorSweepStranded(to)that lets the operator recover EVMasset()balance whentotalSupply == 0. -
Precompile scale ≠ action scale — CONFIRMED ON MAINNET, and v1.2's "×100" fix was itself WRONG. The
oraclePx/markPxprecompiles returnhuman * 10^(6 - szDecimals)(verified on-chain: BTC oracle reads732720for ~$73,272). But thelimit_orderCoreWriter action takeslimitPxandszashuman * 10^8— a UNIFORM scale, NOT10^(8-szDecimals)/10^szDecimals(HL docs: "limitPx and sz should be sent as 10^8 * the human readable value"; HL SDKfloat_to_int_for_hashing = round(x*10^8); proven on mainnet — a10^(8-szDecimals)order is silently dropped, a10^8order rests). v1.2's "multiply oraclePx by 100" was therefore wrong: the correct precompile→action factor is10^(8-(6-szDecimals)) = 10^(2+szDecimals)(×10^7 for BTC), which requires readingszDecimalsper asset. v1.3 fix: slippage-band normalization× 10^(2+szDecimals)viaperpAssetInfoStrict; leverage-cap notional/ 1e10;hl_helpers.encode_px/encode_sz× 10^8. -
Place ≠ accept (silently). Confirmed on mainnet: an order rejected by HL Core (e.g. for being below the $10 minimum) leaves no trace — the EVM tx succeeds, the CoreWriter event fires, and the order simply never appears in
open_ordersorhistoricalOrderson the HL API. Reconcilers MUST query HL post-submission to confirm acceptance. -
HL Core does not process— ROOT CAUSE was the px/sz SCALE (confirmed on mainnet); the TIF off-by-one was real but SECONDARY. Live mainnet testing settled this. Decisive evidence, alllimit_orderfrom contract accountstif=1(correct ALO), placed via rawCoreWriter.sendRawAction: a10^8-scale BTC order rested on the book (limitPx 72596.0, sz 0.0002); the same order at the repo's10^(8-szDecimals)/10^szDecimalsscale was silently dropped (HyperCore read it as $0.73 / 0.0000002 BTC → sub-min dust); and the tier2b vault's perfectly-tif=1-encoded but wrong-scale order also dropped. So orders failed because of the scale, not because the sender is a contract, and not (primarily) because of TIF. The TIF enum was off by one (TIF_ALO=0…; correct1=ALO, 2=GTC, 3=IOC) — fixed in v1.3, necessary (tif=0 still drops once scale is right) but not sufficient.setLeverage/add_api_wallet/user_set_abstractionwere red herrings. Fix (v1.3):encode_px/encode_sz → ×10^8; slippage-band (×10^(2+szDecimals)) and leverage-cap (/1e10) re-derived for the10^8action scale; TIF enum → 1/2/3. Deployed-vault note: v1.2 vaults bake in BOTH the wrong scale (band/cap math) and the wrong TIF, so they cannot place orders — they must be redeployed. Final gate: scale + TIF are unit-tested and the encoding is mainnet-confirmed via the EOA path; the contract-path confirmation lands with the v1.3 redeploy +e2e_runner.pyre-test. -
Unified-account-only
send_assetpath. Personal HL accounts in "unifiedAccount" mode havespot_transfer/usd_class_transfer/usd_transferdisabled. The working call isExchange.send_asset(dest, "spot", "spot", "USDC", amount)(1 USDC fee) for spot-to-spot, orsend_asset(dest, "spot", "", "USDC", amount)(no fee) to route into the recipient's perp account directly. Documented indocs/INTEGRATION.md.
-
Leverage cap is best-effort, not strict. It checks the incremental notional of a new order plus current open-position notional (read from precompiles). It does not account for HL's own margin requirements per-asset, cross-margin offsets, or resting orders not yet filled. An operator can split orders to circumvent. Treat as a guideline, not a hard guarantee. Pair with off-chain monitoring. The open-position notional is summed with the lenient
positionprecompile wrapper by design — a strict read would revert trades whenever any whitelisted perp is flat (HyperCore returns empty for no-position accounts). The residual asymmetry vs the strictmarkPxread (aposition-precompile failure for a held position would under-count it; ultrareview bug_007) is not operator-triggerable and is covered by the same off-chain monitoring; revisit with a strictpositionread only if HyperCore is confirmed to return a populated (non-empty) zero-struct for no-position accounts. -
Slippage band uses
oraclePriceprecompile. HL's oracle is a median across multiple venues and is robust to single-venue manipulation. Still, if HL's oracle infra is degraded, the band can pass a bad order. -
Place ≠ accept ≠ fill. Every order-related event fires on EVM tx success, not on HL acceptance. Reconciliation MUST verify via HL API post-submission (see
docs/INTEGRATION.md). -
CoreWriter is fire-and-forget. A rejected action does not revert the EVM tx. The vault's view of "outstanding orders" relies entirely on off-chain reconciliation.
-
Decimals. USDC EVM 6dp; USDC Core 8dp; bridge scales ×100 across. If HL ever changes Core USDC
weiDecimals, updateConstants.USDC_CORE_DECIMALS. The factory'sstrictAssetValidationmode catches asset address mismatches but does NOT catch decimal mismatches — add at audit time. -
receive()is omitted. Native HYPE sent to the vault address reverts. Intentional. -
Cost basis carry on transfer. ERC20 share transfers weighted-average the receiver's cost basis. Senders keep their cost basis on remaining shares. The vault address (when shares are escrowed via
requestWithdraw) is excluded from cost-basis tracking; the request stores its own snapshot. -
Fee dilution math. The dilutive-mint formula
feeAssets * supply / (nav - feeAssets)is exact in continuous math and approximate under integer rounding. Off-by-one errors favor existing holders (under-charge by ≤ 1 wei).
slither src/HyperCoreVault.sol --filter-paths "lib/"
mythril analyze src/HyperCoreVault.sol --solv 0.8.27- OZ ERC4626 inflation-attack mitigation verified at all entry points
-
_updatecost-basis carry preserves invariant: sum-of-LP-cost-bases-weighted = totalSupply * avgCostBasis - Dilutive fee mint cannot overflow when
nav ≈ feeAssets(sanity cap in_accrueMgmtFee) - Decimal normalization paths (
_coreToEvm) are bidirectionally consistent for USDC - CoreWriter action encoding matches Hyperliquid's reference (px/sz = human×10^8, tif 1/2/3) — verified live via
scripts/python/e2e_runner.py - Precompile struct decoding matches the protocol version deployed at the time of audit (regress against hyper-evm-lib's
PrecompileLib.solper a pinned commit) - Reentrancy on the operator surface (CoreWriter is fire-and-forget; precompiles are staticcall — verify)
- No path lets EMERGENCY_ROLE drain funds
- No path lets a deposit at time T receive shares priced at T-1 NAV (snapshot-then-mint pattern)
- Withdrawal queue cannot double-spend or strand escrowed shares
- Factory CREATE2 salt collision impossible for distinct deployers