HyperVaults Protocol
v0.2 · Solidity 0.8.26 · HyperEVM Testnet (chain 998) · 416 tests · Unaudited
Overview
27 Solidity files, ~6.8k lines of code, deployed on HyperEVM. The architecture deliberately splits storage, business logic, and extension modules into distinct contracts, enabling modular policy enforcement and clean upgrade paths via a release registry. 407 non-fork tests + 9 fork-based lifecycle tests pass.
Vault shares are 18-decimal ERC-20 tokens (denomination-asset agnostic via OpenZeppelin's virtual-offset pattern, _decimalsOffset = 18 − underlyingDecimals). Inflation attacks are mitigated by virtual shares + virtual assets:+ 10^12 virtual shares for a USDC vault means an attacker would need to donate ~10^12× a victim's deposit to extract a single wei.
Quick Start
Deploy a Vault
VaultFactory.VaultConfig memory config = VaultFactory.VaultConfig({
name: "Alpha Trading Strategy",
symbol: "ALPHA",
managementFeeBps: 100, // 1.00% / yr
managementFeeRecipient: manager,
performanceFeeBps: 2000, // 20.00% above HWM
performanceFeeRecipient: manager,
lockUpDuration: 1 days, // per-depositor rolling lock-up
sharesNonTransferrable: false // immutable after deploy
});
(address vault, address controller) = factory.createVault(config);Deposit
usdc.approve(controller, 1_000e6); // minShares is slippage protection — 0 accepts any. controller.buyShares(1_000e6, 0);
Open a Perpetual Position
Position actions go through the ExternalPositionManager. Args are HyperCore-native: uint32 asset id (HL universe index for canonical, encoded for HIP-3 / HIP-4), uint64 size and price at 1e8 scaling.
bytes memory args = abi.encode(
/* asset */ uint32(0), // BTC = canonical universe index 0
/* isBuy */ true,
/* limitPx */ uint64(70_000 * 1e8),
/* size */ uint64(0.1 * 1e8),
/* tif */ uint8(2) // 2 = GTC
);
externalPositionManager.callOnExternalPosition(
vault, position, ACTION_OPEN_POSITION /* = 0 */, args
);Redeem Shares
// Fast path if vault has idle USDC; otherwise queues a NAV-locked
// claim that settles when idle USDC is replenished.
controller.redeem(
/* recipient */ msg.sender,
/* shares */ 500e18,
/* minUsdcOut*/ 0
);Architecture
VaultFactory ── deploys + configures vaults (CREATE2 deterministic addresses)
│
├── VaultLib ── ERC-20 shares (18-dec via offset) + asset custody
│ └── External Positions ── Hyperliquid perps (canonical, HIP-3)
│ HIP-4 outcome contracts
│
└── VaultController ── deposit / redeem / NAV / extension wiring
├── FeeManager ── mgmt + performance fees as shares
├── PolicyManager ── 5 hooks · per-vault settings
├── ValueInterpreter ── HyperCore oracle pricing (0x803)
├── ExternalPositionManager ── action gating · type registry
└── RedemptionQueue ── NAV-locked claims when idle lowReleaseRegistry versions the implementation set (lib + controller + extensions). New vaults pin to latestReleaseId at deploy; existing vaults are unaffected by future releases.
Contract Inventory
| Contract | Lines | Purpose |
|---|---|---|
VaultLib.sol | 538 | ERC-20 share token, asset custody, position registry |
VaultController.sol | 753 | Deposit / redeem / NAV / extension wiring |
VaultFactory.sol | 347 | Deterministic vault deployment + create-with-policies |
ValueInterpreter.sol | 260 | Asset pricing via HyperCore oracle precompile |
ReleaseRegistry.sol | 181 | Versioned implementation set, latest pin at deploy |
FeeManager.sol | 461 | Mgmt + performance fees, accrued and paid as shares |
PolicyManager.sol | 331 | 5-hook policy engine, per-vault settings registry |
ExternalPositionManager.sol | 470 | Position lifecycle + action gating + parser dispatch |
RedemptionQueue.sol | 329 | FIFO NAV-locked USDC claims when idle is low |
HyperliquidPositionLib.sol | 715 | Perp + spot accounting via HyperCore precompiles |
HyperliquidPositionParser.sol | 237 | Action arg parsing + canonical policy tuple |
OutcomePositionLib.sol | 546 | HIP-4 outcome contract accounting |
OutcomeParser.sol | 228 | HIP-4 action parsing + asset-id encoding |
VaultLens.sol | 361 | Read-side aggregator for FE — summaries, positions, claims |
VaultLib
Storage + ERC-20 share token + custody. Inherits ERC20Upgradeable and overrides decimals() to return underlyingDecimals + offset, standardising shares at 18 decimals across any underlying ≤ 18.convertToShares / convertToAssets use OZ's Math.mulDiv with virtual offsets, both rounding down.
Holds the registry of activeExternalPositions and gates every state mutation behind onlyVaultController. Two-step ownership transfer (offer + accept) prevents accidental ownership loss.
VaultController
Business logic for one vault. Holds the deposit / redeem / position-action surface and coordinates with extensions.
| Function | Effect |
|---|---|
buyShares(amount, minShares) | Pulls USDC, runs PreBuyShares policy hook, mints shares via convertToShares, settles pending fees, runs PostBuyShares. |
redeem(recipient, shares, minUsdcOut) | Burns shares, computes USDC owed via convertToAssets. Fast-path if vault holds enough idle USDC; otherwise pays partial and enqueues a NAV-locked claim in the RedemptionQueue. |
callOnExternalPosition(pos, actionId, args) | Owner-only. Routes through ExternalPositionManager which runs PreCallOnExternalPosition policy hook with the parser's canonical (uint32 asset, bool isLong, uint64 size, uint64 price) tuple before forwarding to the position lib. |
calcGrossShareValue() | Returns convertToAssets(10^shareDecimals) in underlying decimals (1e6 for USDC). Used by VaultLens.sharePrice. |
All share-mutating actions are guarded by timeLockSharesAction (per-account 60s timelock), preventing same-block deposit-then-redeem griefing.
VaultFactory
Deterministic vault deployment via CREATE2. Reads the denomination asset's decimals at construction (validates ≤ 18) and forwards them through to VaultLib.init so the offset is baked in.
function createVault(VaultConfig calldata config)
external returns (address vault, address controller);
function createVaultWithPolicies(
VaultConfig calldata config,
address[] calldata policies,
bytes[] calldata settings
) external returns (address vault, address controller);lockUpDuration defaults to 1 day if 0, capped at 30 days. sharesNonTransferrable is immutable after deploy — set it at create time, not via a follow-up tx.
FeeManager
Two fees, both accrued and paid in vault shares (no USDC movements; depositors are diluted instead):
| Fee | Formula | Cap |
|---|---|---|
| Management | shares × bps / 10000 annualised, accrued per-second. | 10.00% / yr |
| Performance | profit × bps / 10000 where profit = GAV − HWM × supply / oneShare. | 50.00% |
HWM is denominated in "underlying per 1.0 share" and only ratchets up. Performance fee is skipped when the current share price is below HWM, preventing double-charging during recovery.
PolicyManager
Hook-driven policy enforcement. Each enabled policy registers against one or more hooks; the manager iterates the active list and short-circuits on the first failure.
| Hook | When fired |
|---|---|
PreBuyShares | Before share mint in buyShares |
PostBuyShares | After mint — used for cumulative checks (per-user caps). |
PreCallOnExternalPosition | Before any position action; receives the parser's canonical tuple. |
PreTransferShares | Before share transfer — enforces non-transferrable and allowlist on transfer recipient. |
Continuous | Keeper-callable validation for time-window invariants. |
ValueInterpreter
Asset → USD pricing. Reads HyperCore oracle prices via the 0x...0803 precompile, with a manual-price fallback for assets the oracle doesn't cover (used today for testnet USDC = $1.00). The denomination-asset registry gates which assets a vault can be denominated in.
ExternalPositionManager
Lifecycle and dispatch for non-vault asset positions. Position types are registered with a (lib, parser, label) triple. The current registry:
| typeId | Label | Lib |
|---|---|---|
| 1 | Hyperliquid Perps (canonical + HIP-3) | HyperliquidPositionLib |
| 2 | HIP-4 Outcome Contracts | OutcomePositionLib |
callOnExternalPosition first callsparseAssetsForAction on the parser to learn transfer flows, withdraws assets from the vault to the position, runs the PreCallOnExternalPosition policy hook with the canonical tuple from encodePolicyArgs, then executes the action.
RedemptionQueue
FIFO queue of NAV-locked USDC claims. When redeem can't fully settle from idle USDC, the residual is enqueued at the share price at the moment of burn — exiters get NAV protection, but stayers absorb the P&L on the locked-up assets until claims clear.
Settles automatically when the manager closes positions or removes margin. forceCloseForQueue lets keepers unwind the largest perp position when claims have aged past a threshold.
HyperCore Precompiles
The protocol reads HyperCore state via stateless EVM precompiles. All reads are staticcall + abi-decode; no cross-domain trust assumptions.
| Address | Function | Used by |
|---|---|---|
0x...0803 | Oracle Price | ValueInterpreter |
0x...0813 | Position2 (HIP-3 aware) | HyperliquidPositionLib |
| via PrecompileLib | spotBalance / spotPx / markPx / withdrawable / spotInfo | Position libs (spot NAV, sub-account margin, mark for perps). |
position2 is used uniformly so the canonical and HIP-3 paths share code, future-proofing against any canonical asset id eventually crossing 2^16.
CoreWriter
Writes go through @hyper-evm-lib/CoreWriterLib. The position lib calls placeLimitOrder, cancelOrderByCloid, usdClassTransfer (for USDC perp ↔ spot moves), and sendAsset (cross-DEX with non-USDC) — no manual action encoding. Asset routing is encoded into the asset id itself; no separate dex parameter on the write side.
AllowedAssetsPolicy
Restricts opens to a manager-curated uint32[] of asset ids. Encoding is HL-canonical: ids 0..9_999 are canonical perps, 100_000 × dexIdx + marketIdx is HIP-3, and 100_000_000 + 10·outcomeId + side is HIP-4. Ranges don't overlap, so a single uint32[] covers all three classes.
Settings: abi.encode(uint32[]). Hook: PreCallOnExternalPosition.
AllowlistPolicy
Address gate for deposits and share transfers. Vault owner is always implicitly included.
Settings: abi.encode(address[]). Hooks: PreBuyShares, PreTransferShares.
MaxLeveragePolicy
Caps the vault's leverage ratio (notional / GAV). Stored as 1e18-scaled multiplier, e.g. 5e18 = 5×.
Settings: abi.encode(uint256). Hook: PreCallOnExternalPosition.
MaxPositionSizePolicy
Limits any single position's notional as a % of GAV. Setting is bps-scaled (4000 = 40% of GAV).
Settings: abi.encode(uint256). Hook: PreCallOnExternalPosition.
MinMaxInvestmentPolicy
Per-deposit floor and ceiling, denominated in underlying. max = 0 means no ceiling.
Settings: abi.encode(uint256 min, uint256 max). Hook: PreBuyShares.
MaxTotalAumPolicy
Hard cap on total deposits across the vault. Reverts buys when GAV + amount > cap.
Settings: abi.encode(uint256). Hook: PreBuyShares.
MaxUserDepositPolicy
Cap on a single LP's position size, evaluated after the deposit so the policy bites cumulatively.
Settings: abi.encode(uint256). Hook: PostBuyShares.
PerpPriceDivergencePolicy
Blocks opens when the limit price diverges from the HyperCore mark by more than the configured tolerance (in bps × 100). Guards against fat-finger and oracle-staleness opens.
Settings: abi.encode(uint16). Hook: PreCallOnExternalPosition.
Guide: Create a Vault
Use VaultFactory.createVaultWithPolicies to ship the vault and its policies in one tx — atomic, cheaper, and avoids a window where the vault accepts deposits before policies are wired.
address[] memory policies = new address[](2);
bytes[] memory settings = new bytes[](2);
policies[0] = MAX_LEVERAGE_POLICY;
settings[0] = abi.encode(uint256(5e18)); // 5× cap
policies[1] = ALLOWED_ASSETS_POLICY;
uint32[] memory allowed = new uint32[](2);
allowed[0] = 0; // BTC canonical
allowed[1] = 1; // ETH canonical
settings[1] = abi.encode(allowed);
(address vault, address controller) =
factory.createVaultWithPolicies(config, policies, settings);Guide: Deposit & Redeem
Deposit:
usdc.approve(controller, amount); controller.buyShares(amount, 0);
Redeem (fast path, vault has idle USDC):
controller.redeem(msg.sender, shares, 0);
If the vault doesn't hold enough idle USDC, the call still succeeds but pays you partially and enqueues the residual as a NAV-locked claim. Watch for RedemptionPartiallySettled in the receipt and check VaultLens.getUserClaims for status.
Guide: Open a Position
All position actions are owner-only and routed through VaultController.callOnExternalPosition. Action args follow the parser's decode shape — for ACTION_OPEN_POSITION = 0:
bytes memory args = abi.encode(
uint32 asset, // HL asset id (canonical / HIP-3 / HIP-4)
bool isBuy,
uint64 limitPx, // 1e8-scaled
uint64 size, // 1e8-scaled
uint8 tif // 1=ALO, 2=GTC, 3=IOC
);
vaultController.callOnExternalPosition(perpPosition, 0, args);Other action ids on HyperliquidPositionLib: 1 close, 2 cancel, 3 add margin (with uint32 dex selector), 4 remove margin, 10 open spot, 20 transfer USDC perp ↔ spot.
Guide: Collect Fees
Fees are minted as vault shares to the configured recipients during any state-changing action (deposit, redeem, position open / close). Recipients can:
// 1) Hold the shares — they appreciate with vault NAV. // 2) Redeem to USDC, same surface as any LP. controller.redeem(recipient, shareBalance, 0);
Use VaultLens.getVaultSummary to read pendingMgmtFeeShares and pendingPerfFeeShares — fees that have accrued since the last settlement and will mint on the next state change.