A liquidity position is long the token it holds, whether you want that exposure or not. HedgedLPVault is an LPVault paired with a short perp on Lighter, sized to the position's delta, so you keep the fee income and shed most of the price risk. The short lives on a venue this chain cannot read, so the vault takes the hedge's value from reports sent by one key. This chapter covers what that key can and cannot do, how reports are checked, and where the margin lives: in a Lighter account owned by the vault's funder key, which is the one place Levee is not non-custodial.
How the hedge is counted
On chain, the hedge is two numbers. hedgeCollateralUsd() is the USDG the vault has ever sent to Lighter. The stored equityUsd (6 decimals, like USDG) is what the last report said the hedge is worth, plus the USDG funded and less the USDG returned since (see Margin in transit). The share price adds the second one to the plain vault's figure:
totalAssets() = LP position + idle balances (as in LPVault)
+ equityUsd converted to WETH through Chainlink ETH/USD
The conversion goes through ChainlinkGuard. If the feed reverts, answers zero or less, reports an unfinished round or has not updated for 25 h, the hedge counts as zero rather than reverting. Reverting would freeze withdrawals during an oracle outage; under-counting only makes exits cheaper, and the staleness pause below is what stops trading on a broken hedge.
Two keys, two jobs
| Key | What it can do | What it cannot do |
|---|---|---|
| Hedger (hot, on the API server) | Call reportHedge | Move any funds |
| Funder (cold, operator-held, one per vault) | Call fundHedge, within its caps, and returnHedge. Own the vault's Lighter account, so withdraw the margin there | Make the vault pay any address but the Lighter deposit contract fixed at creation |
| Signer's order key | Open, close and resize the perp in the funder's Lighter account | Withdraw from the account |
initializeHedged reverts BadFunder if the funder is unset or is the hedger. The order key lives in the signer, which has no code path that signs a withdrawal.
Where the margin lives
Trading a Lighter account needs an API key bound by a personal_sign from the account's owner, and Lighter has no accounts owned by a contract and no delegated owners. A vault contract can deposit into Lighter but could never trade the account its own address would own. So fundHedge credits the margin to the Lighter account of the vault's funder instead, and that account is the vault's hedge account: the signer holds its order key, the hedger trades and reports it, and fundingConfig().funderKey names it on chain.
That is a custody exception, and it is stated rather than hidden: whoever holds the funder key can withdraw the margin on Lighter. What bounds it is on chain: the funder can send at most 10 % of the vault's on-chain assets per call and 25 % per rolling day to Lighter, measured against the position and idle balance, never against reported equity. The funder key is cold, held by the operator and used for that vault alone.
Lighter accounts are one per address, so one account shared by two vaults would mix their equity and net their shorts. Each hedged vault therefore gets its own funder: the deploy takes FUNDER_NVDA_HEDGED and FUNDER_SPY_HEDGED (each defaulting to FUNDER), and DeployRobinhood refuses to run if the two are one key, or if either is the deployer, the owner, the hedger or the keeper. The manifest records them under funders, and the hedger skips any two vaults that resolve to the same account.
Funding the hedge
fundHedge(assetAmount) turns vault WETH into USDG margin in the funder's Lighter account. Only the funder calls it; nothing in the API does.
- 01Refuse while paused
EquityJumpafter a jump,HedgePaused_after a stale report, so the caller can tell the two apart. - 02Check the capsAt most
maxFundBpsPerCallof the on-chain assets (LP position plus idle WETH) per call, andmaxFundBpsPerPeriodin total per one-day window, which starts at the first funding. Both are measured against on-chain value, never the reported equity. Over either revertsFundCapExceeded. - 03Free the WETHIf idle WETH is short, the vault pulls it from the LP position as a withdrawal would, with that swap floored at the LP pool's spot quote less
maxSlippageBps. - 04Swap at a TWAP floor
HedgeFunding.moveMarginrunsTwapGuard.checkon the WETH/USDG pool and sells with a floor of the TWAP quote lessmaxSlippageBps. - 05Deposit to LighterUnder the bridge's
1 USDGminimum it revertsBelowLighterMinimum. Otherwise it approves exactly the USDG received, calls the bridge'sdeposit(funder, 3, 0, usdgOut)(asset index3is USDG, route0the perp balance, credited to the funder's Lighter account, which the first deposit opens) and clears the approval. - 06Count it at onceThe USDG delivered is added to
hedgeCollateralUsdand to the stored equity, the crossing time is recorded, andHedgeFundedis emitted. The share price loses the swap's cost and nothing else.
Bringing margin back
The funder withdraws USDG on Lighter (with its own key; Levee's signer never signs a withdrawal). When it reaches the funder's wallet, the funder approves the vault for it and calls returnHedge(usdgAmount), which works even while the vault is paused, because bringing margin home is the emergency procedure. The vault takes the USDG, sells it for WETH behind the same TWAP floor as the funding swap, keeps the WETH as idle asset and lowers the stored equity by the same USDG, all in one transaction. The share price moves by the swap's cost only, and the next report, which no longer sees that USDG anywhere, matches what the vault already stores.
Never transfer USDG to the vault. It is neither the vault's asset nor a token of its pool, so totalAssets() does not count it, and no function can move it again. WETH transferred directly is counted as idle asset at once, but the last report still counts it in the funder's wallet until the next one, so the share price reads high in between: use returnHedge.
Margin in transit
Margin takes minutes to cross either way. Lighter credits a deposit some time after fundHedge, and a withdrawal leaves the Lighter account well before its USDG is back on chain. If only reports counted it, the share price would dip by up to a tenth while margin travelled, and anyone could deposit in the dip and redeem after the next report, taking the difference from the other holders. So the margin is counted wherever it is:
- The two crossings the chain sees move the stored equity in the same transaction, in the units reports use:
fundHedgeadds the USDG it delivered,returnHedgetakes off the USDG it received. A report dated at or before the last crossing revertsStaleReport, because it was read before the crossing and would undo it. - Everything in between is the hedger's. The report is the Lighter account's equity, plus each
fundHedgedeposit Lighter has not credited, plus withdrawals Lighter has debited but not paid out, plus the bridge's pending balance for the funder (a secure withdrawal executed on chain, not yet paid), plus the USDG and WETH in the funder's wallet (WETH at the vault's own ETH/USD feed; native ETH is gas and not counted). The funder wallet belongs to its vault by construction: one per vault, used for nothing else. - Credited or in transit is decided per deposit, by its own transaction. The vault's
HedgeFundedlogs give each deposit's L1 transaction and amount; Lighter's publictxFromL1TxHashanswers with the L2 deposit that transaction produced, or "not found" before the sequencer has executed it. The L2 deposit must name the funder, USDG and the logged amount, or the hedger stops reporting rather than guess. Lighter executes deposits in order, so once one is credited every older one is. - When pieces move during a read, it is counted twice rather than not at all. The hedger reads deposits before the Lighter account, the account before the withdrawals, and the chain last in one block, so a deposit credited or a withdrawal paid out mid-read appears in two places for one report. That makes the share price briefly high, which nobody can buy cheaply; never low, which is the sandwich. A piece that cannot be read at all (Lighter or the signer down, a withdrawal in another asset, WETH in the wallet with no feed price) stops the report: the last one stands, and the vault pauses after
1 hif it lasts.
Onboarding the hedge account
A Lighter account exists only after the first deposit for its address, so the order is: the vault's first fundHedge (at least 1 USDG worth), then the funder runs apps/api/scripts/onboard-hedge-account.ts with its key piped on stdin. The script does what the web's Onboard button does: a signed POST /perp/onboard, a personal_sign of the ChangePubKey the signer prepared, and a signed POST /perp/bind. Until that is done the hedger skips the vault with an alert, and the app takes no deposits into a hedged vault that has never been reported on.
The hedger bot
The hedger runs inside the API process when BOTS includes it, once every HEDGER_INTERVAL_MS (30 s). It works on every manifest vault whose name contains "Hedged", plus any address in HEDGED_VAULTS.
For each vault, one tick does this:
- Read the position:
positionTokenId(),pool()andasset()on the vault, thenpositions(tokenId)andslot0(). A vault without liquidity is skipped. - Size the target short: the vault's asset, WETH, is the numeraire, and the other pool token is the risk asset. Address order does not decide it: on the seed WETH/NVDA and WETH/SPY pools WETH sorts first, so the risk asset is token1. Core's
lpDeltareturns the position's current balance of the risk asset; shorting that much cancels the price exposure for now, and the balance changes as the price moves, so the bot re-sizes. - Find the current short on the vault's hedge account,
fundingConfig().funderKey, read from chain once (the funder never changes). The market comes fromVAULT_MARKETSor the Lighter symbol for the risk asset. A long position on the venue counts as zero; the bot never opens longs. An account with no API key bound reads as empty on the venue, which is not the same as holding no short, so it is skipped with thehedger.account_not_onboardedalert: no order and no report. - Decide: drift is the gap between target and current short, in bps of the target. The tick acts if a report is due (
HEDGE_REPORT_INTERVAL_MS,15 min) or drift exceedsHEDGE_DRIFT_BPS(200); otherwise it does nothing. - Trade: core's
hedgeOrdersreturns at most one market order. It skips differences underHEDGE_MIN_NOTIONAL_USD($50at the mark) and rounds down toHEDGE_LOT_SIZE(0.001). Sells grow the short; buys shrink it and are alwaysreduceOnly, so the account cannot flip long by accident. - Report: it reads the hedge account back and sends
reportHedgewith signed notional (negative for a short), summed pnl, the account's equity plus the margin in transit (above), floored at zero, and its own clock less10 sasasOf, taken before the first read (the simulation runs against the latest block, which trails the clock, and a laterasOfrevertsStaleReport; an earlier one keeps a report read before a crossing from landing after it). Reporting pays no bounty; it is sent whenever the simulation passes.
Three rails sit on top of the tick. HEDGER_MODE is the kill switch: report-only places no order but keeps reporting, and off does nothing, so the vaults pause after 1 h. HEDGE_MAX_ORDER_USD ($25,000, at most the signer's MAX_ORDER_NOTIONAL_USD, also $25,000) splits a large re-hedge into one order per tick. HEDGE_DAILY_LOSS_USD ($2,500) stops orders for the rest of the UTC day once the hedges have lost that much, net of margin moved in or out; reports continue. A per-vault ceiling on the short, HEDGE_MAX_NOTIONAL_USD, is off by default, so a vault is hedged in full whatever its size.
On the vault card, the delta chip shows the residual exposure: (LP value + notional) / LP value, green at 5 % or less.
When the vault pauses
reportHedge has two reverts and never rejects a number: NotHedger for any other caller, and StaleReport for an asOf in the future, not after the previous report, or not after the last fundHedge/returnHedge. It is also nonReentrant, so a report can never land in the middle of a deposit, withdrawal or funding and move the share price under it. Every other report is stored and emits HedgeReported, even one that trips the band, because hiding a real loss would be worse than showing it.
The band compares each report with the previous stored equity:
previous = the stored equity: last report + USDG funded − USDG returned since
jump = (a previous report exists or margin was funded before the first)
and |equityUsd − previous| × 10 000 > previous × maxEquityJumpBps
paused() = jump not yet followed by an in-band report
or (a report exists and now − lastReportAt > maxHedgeAge)
Whether a previous report exists is read from its timestamp, not its value. A report of zero equity, after a close or a liquidation, is a real report, so a hedger cannot disarm the band by reporting zero and then any number: against zero, every non-zero equity is a jump. Because previous carries the crossings, a report that counts margin in transit is not a jump, and one that leaves funded margin out is measured as the drop it is. A first report is checked only against margin funded before it; with none, it never pauses.
A jump sets the pause and emits HedgePaused("jump") once; the next in-band report clears it with HedgeResumed. Staleness needs no transaction: the vault is paused the moment the last report is older than maxHedgeAge. A vault that was never reported on is not stale.
While paused, both _deposit and _withdraw revert HedgePaused_. Stopping exits is deliberate: a vault that cannot price its hedge cannot say what a share is worth, and paying exits at a made-up price robs whoever is left. A paused SPY Hedged also stops the Protected SPY note's entries and exits, because 15 % of the note sits in it. maxDeposit() still reports room while paused; the vault card's status chip says "Paused: stale report" or "Paused: hedge report out of bounds".
Limits and open issues
Exits are bounded by on-chain holdings. The hedge equity sits in a Lighter account the vault cannot unwind, so a withdrawal is sized against the LP position and idle balance alone. While a large part of the value is in the hedge, an exit bigger than the on-chain holdings reverts Shortfall until the funder returns margin with returnHedge.
The band bounds steps, not a walk. Each report may move equity by up to 15 % without pausing, and reports only need a later asOf. A dishonest or broken reporter can therefore walk the share price a long way in small steps. The contract does not try to stop that, and the seed hedged vaults have no deposit cap to limit how much can be mispriced.
The margin is in the operator's account. The deposit uses the bridge's own deposit(address _to, uint16 _assetIndex, uint8 _routeType, uint256 _amount), checked against the deployed bridge on Robinhood Chain, and a fork test (LighterBridgeFork.t.sol) moves USDG from a vault into the funder's account, which the deposit opens, checks the bridge's own record of it, and brings margin back through returnHedge. The contract bounds how much can leave for Lighter, not what happens to it there: a lost, stolen or misused funder key, or a Lighter failure, can cost the vault the margin on Lighter, up to a quarter of its on-chain assets per day.
Margin in transit leans high, by design. Where two sources overlap for a moment (a deposit credited mid-read, a withdrawal Lighter marks paid a little after the chain shows it), one report counts that margin twice, until the next report. A plain WETH transfer instead of returnHedge does the same. The deposit match assumes one fundHedge per transaction, which an EOA funder always has; two in one transaction would not match Lighter's single L2 deposit, and reports would stop until an operator looks.
Reference
Settings
| Name | Allowed | Default | Effect |
|---|---|---|---|
base | LPVault.Init | - | Everything a plain vault needs. base.asset must be WETH, because the hedge is priced through ETH/USD. |
hedger | address | env HEDGER | The only caller of reportHedge. |
funder | address, not the hedger | env FUNDER_NVDA_HEDGED / FUNDER_SPY_HEDGED (default FUNDER) | The only caller of fundHedge, and the owner of the vault's Lighter account. One per vault. |
maxFundBpsPerCall | 1 - maxFundBpsPerPeriod bps | 1000 | Largest single funding, as a share of on-chain assets. |
maxFundBpsPerPeriod | up to 10 000 bps | 2500 | Total funding allowed in one day-long window. |
usdg / usdgPool | token / WETH-USDG pool | WETH/USDG 0.05 % | Margin token and the pool fundHedge sells through. |
lighterDeposit | address | Lighter bridge 0x94bA…fF9d | The only contract fundHedge can pay. USDG goes in as asset 3 on the perp route. |
ethUsdFeed | Chainlink feed | - | Converts equityUsd into WETH. |
maxHedgeAge | uint32 seconds | 1 h | Report age after which paused() is true. |
maxEquityJumpBps | uint16 bps | 1500 | Largest equity move one report may make without pausing. |
hedgeConfig() returns a vault's live staleness and jump bounds and its addresses; fundingConfig() returns the funder, its caps and the current window. The seed deploy creates Levee NVDA Hedged (lvNVDAh) and Levee SPY Hedged (lvSPYh), each on the deepest WETH pool for its token across the four fee tiers, with the plain vaults' policy and no deposit cap (NO_CAP). A token or pool missing at deploy time is skipped and left out of the manifest.
Reverts and events
| Revert | Raised by | Meaning |
|---|---|---|
NotHedger | reportHedge | The caller is not the hedger. |
NotFunder | fundHedge, returnHedge | The caller is not the funder. |
FundCapExceeded | fundHedge | Over the per-call or per-day cap. |
BelowLighterMinimum | fundHedge (HedgeFunding) | The swap returned less than the bridge's 1 USDG minimum. |
TwapWindowTooShort / PriceDeviation | fundHedge, returnHedge (TwapGuard) | The WETH/USDG pool has under ten minutes of history, or spot is off its TWAP. |
EquityJump / HedgePaused_ | fundHedge | Paused by a jump, or by a stale report. |
HedgePaused_ | deposit, withdraw | paused() is true. |
CapExceeded | deposit, mint | Over the cap. The seed hedged vaults have none. |
StaleReport | reportHedge | asOf is in the future, not after the last report, or not after the last fundHedge/returnHedge. |
Shortfall | withdraw | The exit needs more than the on-chain holdings can raise. |
NotAsset, BadFunder, BadFundCap | initializeHedged | Wrong asset or pool, a bad funder, or caps that are zero, unordered or above 100 %. |
The plain vault's reverts also apply; see Vaults. Events: HedgeFunded(usdgAmount), HedgeReturned(usdgAmount), HedgeReported(notionalUsd, unrealizedPnlUsd, equityUsd, asOf) on every stored report, HedgePaused(reason) on the first jump (staleness emits nothing, since it is a view), and HedgeResumed().
contracts/src/HedgedLPVault.solthe header on what the hedge is on chain and on margin in transit, fundHedge, returnHedge, reportHedge, paused, hedgeEquityInAssetcontracts/src/libraries/HedgeFunding.solthe TWAP-floored swaps both ways and the Lighter depositcontracts/src/libraries/ChainlinkGuard.solwhen a feed read counts as badcontracts/src/interfaces/lighter/ILighterDeposit.solthe one Lighter call the vault makescontracts/script/LeveeWiring.solMAX_HEDGE_AGE, MAX_EQUITY_JUMP_BPS, the fund caps, one funder per seed hedged vaultcontracts/test/fork/LighterBridgeFork.t.solfundHedge into the funder's account on the real Lighter bridge, and returnHedge on the live poolapps/api/src/bots/hedger.tsone tick on the funder's account: read, size, trade, reportapps/api/src/bots/hedgerFlows.tsmargin in transit: deposits matched by L1 tx, withdrawals, the bridge balance, the funder wallet, and the read orderapps/api/src/venue/transfers.tsLighter's txFromL1TxHash answer, recorded, and the signer's pending withdrawalsapps/api/scripts/onboard-hedge-account.tsbinding the order key to a funder account, key on stdinpackages/core/src/hedge.tslpDelta and hedgeOrdersapps/api/src/config.tsthe HEDGE_* settings, HEDGED_VAULTS and VAULT_MARKETSapps/web/lib/earn/view.tsHEDGE_CUSTODY_NOTE, the first-report gate, hedgeStatus and hedgeDeltacontracts/test/HedgedLPVault.t.solthe funding caps, the pause cases, the zero-equity report, the broken feed, the transit sandwich and the return round trip