Finding G's terminal interpretation is superseded (2026-06-12). tokenInfo(0).evmContract
(0x6B9E…0A24) is not a broken ERC-20 linkage: it is Circle's CoreDepositWallet, the
official USDC EVM<->Core bridge, live since 2025-12-08 and holding the EVM-side reserve that
backs all Core USDC (Hyperliquid's own published backing accounting cites it). It is not an
ERC-20 — all ERC-20 views revert on it by design. Circle blacklisted the system address
0x2000…0000 on the USDC token precisely to force the wallet path (a direct transfer would
be a one-way burn).
- Push (EVM->Core):
pushToCorenow runsforceApprove + deposit(amount, CORE_SPOT_DEX_ID)against the wallet (credit lands on the vault's Core SPOT balance — thecoreSpotUsdc()NAV leg), then zeroes the allowance.coreDepositWalletis a per-vault immutable validated three ways at deploy:wallet.token() == asset(),wallet.tokenSystemAddress() == forToken(coreUsdcIndex), andtokenInfo.evmContract == walletwhen the precompile resolves (success emitsCoreLinkVerified; mismatch revertsCoreLinkMismatch).address(0)= legacy HIP-1 mode for genuinely direct-linked assets (warn-onlyCoreLinkUnverifiedkept). - Pull (Core->EVM): the Core-side action is CoreWriter
send_asset(action 13) to the USDC system address — NOT the legacyspot_send(action 6), which unified HyperCore accounts silently drop (proven live 2026-06-15). HyperCore debits the vault's Core spot and triggers the wallet's system-guardedtransfer(), paying native USDC from its reserve to the caller (the vault).operatorRecoverSpot/emergencyRepatriateuse the same primitive. Operational guard: a ~0.00134 USDC withdrawal fee is taken from Core on top of the amount, so the keeper must never request the exact full balance (it would be dropped) — pull under it. - EIP-170 (size): the trade gate (whitelist + slippage band + leverage cap) and
emergency-close loop live in an external
VaultTradeLib(delegatecall) so the vault fits the 24576-byte limit HyperEVM enforces. The library is pure logic invoked under delegatecall (address(this)is the vault); it holds no storage and adds no privileged surface — events and errors are re-declared to keep log topics and revert selectors identical to the inlined version (fork 54/0/4, unit 10/10 unchanged). - Trust model: the wallet is Circle-operated, EIP-1967-upgradeable, and pausable —
issuer-trust class (the same trust as holding USDC at all). Both directions stop while
the wallet is paused (
depositand the payout hook arewhenNotPaused): monitorpaused()+ the implementation slot;operatorRecoverSpot/emergencyRepatriateremain the contingency (Path B demotes from primary route to fallback). - Proofs: the EVM half runs against the REAL wallet bytecode on fork
(
test/fork/HyperVaultCoreDepositWallet.fork.t.sol); the Core credit + payout are live-only and proven by Scenario C (REDEMPTION_LIVE_RUNBOOK.md), recorded inFORK_PROOFS.md§"v1.5 G2".
The pre-audit review (vs the audited Gauge4626.sol) produced 11 findings, all
remediated as one stacked branch per finding with a green fork test each
(see FORK_PROOFS.md §"v1.4 Audit Remediation"). Summary of what
changed and which claims below are now superseded:
- C1/M5 (NAV linkage). The shipped asset (
0xb883…630f) is NOT Core token 0's linked EVM contract (0x6b9e…0a24) — confirmed live.coreSpotUsdc()therefore measures an unrelated token and the canonical bridge is blacklisted (pushToCore/pullFromCorerevert). Fix: Core-USDC index/decimals are now per-vault immutables validated against the livetokenInfoat deploy (decimals-mismatch reverts; anevmContract != asset()mismatch is non-fatal but emitsCoreLinkUnverified). Realising Core value uses Path B (operatorRecoverSpot → treasury → re-deposit), socoreSpotUsdc()is operator-recoverable NAV carrying an explicit ~1:1 cross-token assumption — NOT trustless bridge value. (Superseded by v1.5 G2 above:0x6b9e…0a24turned out to be the official bridge itself;coreSpotUsdc()is bridgeable NAV again and Path B is the contingency.) - H1 (strict NAV). Strict precompile reads are now the default for a live
vault, behind a one-way
navBootstrapgrace the admin clears (endNavBootstrap()) after Core init. Once strict, a precompile revert fails NAV closed (no silent zeroing). Redemption liveness is then coupled to precompile liveness — monitor. - H2 (liveness).
pullFromCore/usdPerpToSpot/operatorRecoverSpotare no longerwhenNotPaused(they only move funds toward idle), andEMERGENCY_ROLEgetsemergencyRepatriate(...)— a paused / operator-dark vault can still drain Core→idle. Overdue withdrawal requests can beprioritizeOverdue-reserved against racing redeems (on-chain SLA). Honest scope (revised by G2): repatriation is contract-to-contract again viapullFromCore, but it remains OPERATOR-gated; no permissionless Core→EVM pull exists yet. The SLA enforces fairness over existing idle and surfaces operator stalls — a permissionless escape-gated pull is scoped inESCAPE_HATCH_SCOPE.md(leg 4a), unblocked by the G2 route. - H3 (governance). Factory +
Deploy.s.solreject a sub-24h timelock delay and shared operator/emergency/feeRecipient keys on mainnet. The tier configs ship three distinct roles + 86400s — production MUST replace the placeholder role addresses with a real multisig and hand the timelock proposer/executor to it post-deploy. - M1 (perf fee). An inter-LP
transferis now a realization event: the transferor's perf fee on the transferred shares is crystallized by diverting fee-equivalent shares tofeeRecipient(redirect, not mint — no dilution). This closes the loss-netting evasion. - M2. Deposits/mints into an LP with an open withdrawal request revert
(
PendingRequestBlocksDeposit) — closes the cost-basis double-count. - M3.
maxRedeemis capped to idle-backed shares sopreviewRedeem(maxRedeem)is honored (no silent partial). M4.emergencyClosePositionsgains a wide markPx sanity band (emergencyCloseBandBps), deploy-required non-zero in(0, 5000](live-spike RUN-1 finding — a vault can never ship with the band silently off);emergencyClosePositionsForceis the explicit oracle-down / band-free override. M6. The spot slippage band now requires a calibratedspotPxScaleFactor(else it gave false protection). L1–L4. deposit balance-delta (FOT) guard; dormancy mgmt-fee cap (one annual period, not nav/2);int64.minabs guard; factory+registryOwnable2Step.
Two integrator notes (ERC-20 / ERC-4626 surprises — by design):
transferis a taxable event (M1). With a non-zero perf fee, transferring a gaining LP's shares delivers fewer thanvalueto the recipient (the haircut funds the fee) and emits a secondTransfertofeeRecipient. Escrow moves (requestWithdraw/cancel) and mint/burn are exempt.previewRedeem/previewWithdraware GROSS.redeem/withdraw/fulfillWithdrawpaygross − perfFee(owner), which is less than the owner-agnostic preview when the owner has a gain (the per-LP C-3 fee can't appear in an owner-agnostic preview). QuotepreviewRedeem(shares) − perfFeefor an exact figure.- H2 priority is not durable across a partial fulfill. A partial
fulfillWithdrawreleases the whole reserve and zeroes the remainder's reservation; a keeper mustprioritizeOverduethe remainder again. No fund loss — only relevant when NAV>idle. - L5 — perf-fee rate is retroactive.
setFeesapplies the newperfFeeBpsto all LPs' existing unrealized gains at their next realization, not just gains accrued after the change.
| 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 |
OPERATOR_ROLE |
whenNotPaused (deploys idle→Core); cannot deploy reserved idle (H2); wallet mode = approve+deposit on the CoreDepositWallet, zero residual allowance (G2) |
pullFromCore |
OPERATOR_ROLE |
NOT whenNotPaused (H2 — Core→idle refill must survive a pause) |
usdSpotToPerp |
OPERATOR_ROLE |
whenNotPaused (deploys into the market) |
usdPerpToSpot |
OPERATOR_ROLE |
NOT whenNotPaused (H2 — moves equity toward idle) |
operatorRecoverSpot |
OPERATOR_ROLE |
NOT whenNotPaused (H2); C-2 allowlisted dest |
emergencyRepatriate |
EMERGENCY_ROLE |
H2 escape hatch; works while paused; C-2 allowlisted dest |
pause, unpause |
EMERGENCY_ROLE |
|
emergencyCancelByCloid, emergencyCancelByOid, emergencyClosePositions |
EMERGENCY_ROLE |
emergencyClosePositions honors the M4 markPx band (…Force skips it) |
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. (C1/M5 update) Core-USDC index/decimals are now per-vault immutables (
coreUsdcIndex/coreUsdcDecimals) set from config and validated against the livetokenInfoprecompile at deploy — a decimals mismatch now reverts (CoreUsdcDecimalsMismatch), both in the vault constructor and the factory'sstrictAssetValidationpath. A wrong decimal value can no longer silently put NAV off by 10^|Δ|. -
receive()is omitted. Native HYPE sent to the vault address reverts. Intentional. -
Cost basis carry on transfer — UPDATED by M1. An inter-LP
transferis now a realization event: the transferor's perf fee on the transferred shares is crystallized (fee-equivalent shares diverted tofeeRecipient, no mint/dilution) and the receiver's transferred shares enter at the current price-per-share. This closes the loss-netting evasion (a gain could previously be routed into an underwater LP and netted away). Senders keep their basis on remaining shares; escrow moves (vault as counterparty) and mint/burn remain exempt. See integrator note 1 above —transferof a gaining LP's shares delivers fewer thanvalueto the recipient. -
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