# Levee documentation > Levee is a liquidity venue and an LP, hedging and perps toolkit on Robinhood Chain (chain id 4663): Uniswap v3 and v4 liquidity management, Levee's own pools, ERC-4626 vaults and a swap router. Levee is non-custodial and never asks for a private key, a seed phrase or a session key. The chapters of https://levee.trade/docs as plain text, in reading order. The index is https://levee.trade/llms.txt. --- # Introduction Source: https://levee.trade/docs/overview · Get started What Levee does on Robinhood Chain, the rules it will not bend, and where to read next. Levee is a trading and liquidity app on Robinhood Chain. From one wallet you can swap, trade perps, put liquidity to work in a pool, a range or a vault, and launch a token. This page covers what you can do, the rules the contracts keep, and where each topic is explained. In brief: - Levee runs on Robinhood Chain, chain id 4663, and nowhere else. - No contract is upgradeable, and no admin function can withdraw, move or freeze what you hold. - The one key a Levee server holds for you is your Lighter order key. It places orders and has no way to withdraw. - $LEVEE is the protocol token: protocol fees buy it and burn it. - The vaults Levee deploys, and the vault each graduated token gets, take deposits with no cap. The Income NVDA note is not open yet. ## What you can do | You want to | Use | Read | | --- | --- | --- | | Swap one token for another | Swap, or Buy and Sell on Trade | [Swaps and routing](https://levee.trade/docs/swaps) | | Sell at a price, earning fees while you wait | Limit on Trade, or a DLMM pool | [Limit orders](https://levee.trade/docs/range-orders) | | Trade perps with leverage | Trade | [Perpetuals](https://levee.trade/docs/perps) | | Earn on a deposit without managing a range | Earn | [Vaults](https://levee.trade/docs/vaults), [Hedged vaults](https://levee.trade/docs/hedged-vaults), [Structured notes](https://levee.trade/docs/structured) | | Provide liquidity yourself | Pools, or the Liquidity mode on Trade | [Levee pools](https://levee.trade/docs/pools), [One-token deposits](https://levee.trade/docs/zap) | | Keep a position harvested and centred | Portfolio | [Position automation](https://levee.trade/docs/keeper) | | Launch a token, or trade one before it lists | Launch | [Launch a token](https://levee.trade/docs/launch), [Graduation and pre-market perps](https://levee.trade/docs/graduation) | Swaps route across Levee's own pools (DAMM, DLMM and stock pools), public Uniswap v4 pools and Pons tokens. Perps run on Lighter; a token that no venue lists can trade on Levee's own pre-market perp. [Using the app](https://levee.trade/docs/app-guide) walks through every page. ## Rules that hold everywhere **One chain.** Every contract is deployed on Robinhood Chain (4663), and the app sends transactions there only; development builds also accept a local test chain. **Your funds stay yours.** Contracts cannot be upgraded, and no owner function can withdraw, transfer or freeze a position, a vault share or a deposit. The venue's owner can pause swaps and new liquidity, never withdrawals, and tune fees and limits, most of them inside caps written into the contracts. The DLMM and stock vaults can be re-laid by their keeper or the owner, inside the same on-chain guards. **One key acts for you.** Perp orders on Lighter are signed by an order key that Levee's signer service generates and stores encrypted for your account. It can place and cancel orders and has no code path that signs a withdrawal. Other bot keys keep vaults running within contract limits; [Security model](https://levee.trade/docs/security) lists each key and its bounds. **No deposit caps.** The ETH and USDG vaults, both hedged vaults, the Protected SPY note and every graduated vault accept deposits of any size. A vault's cap is set when it is created and never changes. Anyone can create a vault through the factory, with any cap; the app lists only the vaults in its deployment manifest and those that graduated from Launch. **Fees on earnings, not principal.** A zap or a Uniswap-path spot trade costs 25 bps, a managed position pays 10 % of the fees it earns, and Levee pools send 20 % of their swap fee to the protocol. Every fee and the 20 % referral share are in [Fees and referrals](https://levee.trade/docs/fees). **$LEVEE.** Protocol fees become ETH, the ETH buys $LEVEE, and the $LEVEE is burned, each step by a call anyone can make. The token launch is a separate step the owner approves. See [Buyback and burn](https://levee.trade/docs/protocol). ## How these docs are arranged | Group | Chapters | Read it when | | --- | --- | --- | | Get started | Introduction, [Using the app](https://levee.trade/docs/app-guide) | you are new, or looking for a page | | Trade | [Swaps and routing](https://levee.trade/docs/swaps), [Limit orders](https://levee.trade/docs/range-orders), [Perpetuals](https://levee.trade/docs/perps) | you want to know how an order fills | | Earn | [Vaults](https://levee.trade/docs/vaults), [Hedged vaults](https://levee.trade/docs/hedged-vaults), [Structured notes](https://levee.trade/docs/structured) | you are choosing where to deposit | | Pools | [Levee pools](https://levee.trade/docs/pools), [One-token deposits](https://levee.trade/docs/zap), [Position automation](https://levee.trade/docs/keeper) | you manage liquidity yourself | | Launch | [Launch a token](https://levee.trade/docs/launch), [Graduation and pre-market perps](https://levee.trade/docs/graduation) | you are launching or backing a new token | | Protocol | [Buyback and burn](https://levee.trade/docs/protocol), [Fees and referrals](https://levee.trade/docs/fees), [Security model](https://levee.trade/docs/security) | you want to know where money goes and who can do what | | Reference | [Architecture](https://levee.trade/docs/architecture), [Indexer, API and bots](https://levee.trade/docs/offchain), [Glossary](https://levee.trade/docs/glossary) | you are building on Levee or reviewing it | Diagrams are drawn from data in the repository, so a label in a diagram matches a function, a field or a constant in the code. Where it lives: - `contracts/src/`: every contract, none upgradeable - `contracts/script/LeveeWiring.sol`: the deploy sequence and its values - `contracts/script/venue/VenueWiring.sol`: the venue deploy and its owner hand-over - `docs/superpowers/specs/2026-10-06-levee-design.md`: the fixed design decisions --- # Using the app Source: https://levee.trade/docs/app-guide · Get started Each page of the app in a paragraph: what it is for, what it reads, and what it can sign. This chapter goes through the app page by page: what each page is for, where its numbers come from, and what it can ask you to sign. It describes behaviour rather than layout, which changes more often. Each section ends with the chapter that explains the mechanism underneath. In brief: - Every on-chain write is simulated first. If the simulation reverts, your wallet never opens. - Approvals are always for the exact amount, never unlimited. - A write needs a connected wallet, Robinhood Chain (4663) and deployed contracts. A disabled button says which one is missing. - Outside production, a page whose data fails to load shows sample data under a "Sample data" tag. Production shows an empty state and a retry instead. - Perp orders are messages you sign, not transactions. On Lighter they are executed with your order key, which cannot withdraw. ## Before you start ### Signing in When the build has a Privy app id (`NEXT_PUBLIC_PRIVY_APP_ID`), you sign in with email or a wallet. Signing in by email creates an embedded wallet if you have none. Without Privy, the app offers browser wallets, plus MetaMask, Rainbow and WalletConnect when a WalletConnect project id is configured. The top bar shows the network you are on. On any other network it shows a "Wrong network" button that switches you to Robinhood Chain; pressing an action does not switch for you. Development builds also accept a local test chain (id 31337). ### Sample data Pages read the Levee API. In a development or staging build (`FIXTURES_ALLOWED`), a page whose API call fails falls back to built-in sample data and shows a "Sample data" tag; sample positions belong to one demo address, so any other address sees an empty book. A production build never shows sample data: it shows empty values and a "Data is not loading" notice with a Try again button. Warning: Sample numbers are not live Whether you can sign does not depend on whether the data is live. If a page shows the Sample data tag, check amounts and prices against the chain before you sign anything there. ### How a write is sent 1. Check: The app checks the wallet, the network and that the contract is deployed, and stops with the reason if one is missing. 2. Simulate: It runs the call as an eth_call. A revert stops here, with the reason in a toast, before your wallet opens. 3. Confirm: Your wallet asks you to confirm. Rejecting it shows as "Rejected in the wallet". 4. Send: Once the transaction has a hash, a toast links to it in the explorer. 5. Refresh: The affected data reloads 1.5 s, 4 s and 10 s later, so the indexer has time to catch up. Approvals come first when a token needs one. They are for the exact amount; an existing allowance of a different size is reset to zero first, as some tokens require. Permit2 is used only for the Uniswap Universal Router and the v4 position manager, and its allowance expires after 30 minutes. Approvals are the one step that waits for the receipt before the next button unlocks. Slippage defaults to 50 bps for a spot trade and 100 bps for a zap; above 500 bps you have to tick an acknowledgement first. ### Perp orders Every perp action (set up, place, cancel) is an EIP-712 message you sign, with a fresh nonce and a timestamp. The server decides the venue: `sim`, the default, is an in-memory practice book; `lighter` sends real orders to Lighter. To trade on Lighter, deposit USDG to Lighter from your wallet first. The app then asks for three signatures: one that asks the signer service for your order key, one over the key change Lighter requires, and one that sends it to be registered with your Lighter account. The key can place and cancel orders and cannot withdraw. See [Perpetuals](https://levee.trade/docs/perps). ### The tour The landing page's "Take the tour" opens `/liquidity?tour=1`, a 20-step walk through Liquidity, Trade, Swap, Launch, Portfolio, Markets, Pools, Earn, Stocks, Protocol, Ideas and the Planner. It moves between pages for you; Esc ends it and the arrow keys step through it. Your place is kept in the browser until you finish or end it. The Planner also has its own short guide. ## Managed liquidity ### Liquidity The page the app leads with, for liquidity that is managed for you. Three cards set out the ways in: provide with one token, hand the range to the keeper, or let a vault run it. Under them: - **Choose a market.** The deepest public Uniswap pools from `GET /radar`, one row per asset, with price, pool APR and pool TVL. **Provide** opens Trade on the Liquidity ticket (`/trade/?provide=1`), where you set the range on the chart and the zap mints it from one token. - **Your ranges.** The connected wallet's Uniswap ranges from `GET /portfolio/:address`, the ones that left their band with nobody managing them first. Each row says whether it is in range, what it is worth, the fees waiting and who manages it. **Automate** opens the keeper policy (the same drawer as on Portfolio). On a range the keeper holds, **Re-centre now** calls `PositionKeeper.rebalance` once it is out of range and **Stop keeper** calls `unenroll`. The keeper takes Uniswap v3 positions only; a v4 range links to Portfolio. - **Vaults that run their own range.** The LP, hedged, DLMM and stock vaults, largest first, with APR and TVL. **Deposit** opens that vault's deposit on Earn. Re-centring keeps a range earning; it does not undo the price move. Taking the price risk off is the hedge's job: the Hedge panel on Markets, or a hedged vault. Read [Position automation](https://levee.trade/docs/keeper) and [Vaults](https://levee.trade/docs/vaults). ## Trading pages ### Markets A ranked list of every asset across public Uniswap pools and Lighter perps, for finding where liquidity earns. Rows come from `GET /radar` and show price, 24h change, pool fee APR, funding and **carry**: the pool's fee APR plus a year of the perp funding rate, shown only when the asset has both a pool and a perp market (8 % and up is worth watching, 20 % and up is strong). Tabs narrow the list to Crypto, Tokenized or Seasoned, and a search field filters it. Levee's own pools sit in a separate block under the list. The **Seasoned** label marks a token whose pool is at least 3 days old, with a market cap above $1M and 100 or more trades in the last 24 h (`isEstablished` in the code); it is a label, not a gate. Selecting a row opens its market brief beside the list: carry as one bar split into pool fees, funding received and hedge cost, with pool depth and perp open interest under it. The brief has three actions. **Trade** opens the asset on Trade, **Plan** opens it in the Planner, and **Hedge** opens inside the brief and pairs an LP position with a perp short sized to its delta: it sends you to Trade to provide the liquidity, then signs the short as a perp order. A `?basis=` link selects that asset. Read [Perpetuals](https://levee.trade/docs/perps) and [Hedged vaults](https://levee.trade/docs/hedged-vaults). ### Trade A terminal for one asset. A market bar across the top shows the price, 24h change, funding and open interest; click the asset name or press `/` to switch markets. The ticket has four modes, Spot, Perp, Limit and Liquidity, with a Buy and Sell (Long and Short) toggle; the chart sits beside it, with Positions, Orders, History, Funding and Ideas tabs under it. On a phone the ticket opens from a Trade button. The page reads `GET /radar`, `GET /markets` and `GET /candles`, and before it builds a spot trade it checks the pool's price on chain against the API's. - **Spot.** The default path is the Uniswap Universal Router (approve to Permit2, then `execute`), which pays a 25 bps Levee fee from the output. When the Levee Router pays more on a live quote, the trade goes through `Router.swapExactIn` instead, with one approval and no Levee fee. - **Perp.** Size by notional or by risk budget, leverage from 1x to the market's maximum (3x by default), market or limit, reduce-only, optional stop-loss and take-profit. Market orders carry a 1 % price tolerance. The liquidation price shown is an estimate, not Lighter's figure. - **Liquidity.** One token in, a Uniswap range out, through `LeveeZapV3.zapMint` or `LeveeZapV4.zapMint` (25 bps of the input). Range shapes are Spot, Curve, Wide and Custom; a stock pool's range widens while the market is closed. - **Limit.** `RangeOrders.place` parks a one-sided position that sells as the price crosses it; it works on Uniswap v3 pools. Where the asset has a Levee DLMM pair, the page links to its limit orders. Your positions on the page can be closed, collected (`collect`), removed (v3: `decreaseLiquidity`, then `collect`; v4: one `modifyLiquidities` call), enrolled for automation (`PositionKeeper.enroll`) or cancelled (`RangeOrders.cancel`). Read [Swaps and routing](https://levee.trade/docs/swaps), [One-token deposits](https://levee.trade/docs/zap) and [Limit orders](https://levee.trade/docs/range-orders). ### Swap Any token for any token through the Levee Router, which splits a trade across Levee pools, public Uniswap v4 pools and Pons curves. Quotes come from `Router.quoteExactIn`; without a deployed Router, routes are estimates and cannot be sent. The write is an exact approval to the Router, then `Router.swapExactIn`; native ETH needs no approval. Price impact of 1 % or more shows a warning, 5 % or more needs your acceptance, and 15 % or more is refused. Slippage defaults to 50 bps and the deadline is 20 minutes. You can paste any token address, and the `?in=`, `?out=` and `?amt=` parameters make a swap shareable as a link. No Levee fee is added. Read [Swaps and routing](https://levee.trade/docs/swaps). ## Liquidity, launches and your positions ### Pools Levee's own pools: DAMM (dynamic-fee Uniswap v4 pools), DLMM bin pairs and stock pools banded to a Chainlink price. Data comes from `GET /pools`, `GET /pools/:id` and `GET /stocks`. - **Create a pool**, one permissionless transaction: `DammHook.createPool`, `DlmmFactory.createPair` or `StockHook.createPool`. - **DLMM pairs.** Add liquidity over a chosen number of bins in a Spot, Curve or Bid-Ask shape (`DlmmPositionNFT.mint`), or place, claim and cancel a limit order (`DlmmLimitOrders`). - **DAMM and stock pools.** Add a full-range or custom-band position through the v4 position manager. A stock pool also shows its oracle price, the gap between pool and oracle, the band and the market session. Read [Levee pools](https://levee.trade/docs/pools). ### Earn Every vault in one list, filterable by kind. It reads `GET /vaults`, `GET /amm/vaults`, the graduated vaults from `GET /launch`, and share balances and previews on chain. - **LP vaults:** approve, then `deposit`; withdraw with `redeem`. - **DLMM and stock vaults:** deposit both tokens, or one token into a DLMM vault through `DlmmVaultZap`; withdraw to both tokens or, for DLMM, to one. - **Hedged vaults:** approve, then `deposit`, like an LP vault. The card and the deposit drawer say where the hedge margin is: on Lighter, in an account the vault's funder key controls. - **Protected SPY note:** approve, then `deposit`. It opens once SPY Hedged has sent its first hedge report, and while SPY Hedged is paused the app holds both deposits and exits. Leaving before maturity costs 0.5 %, and the withdraw drawer shows the amount after the fee. - **Income NVDA note** is listed as Coming soon and refuses deposits: it is not open yet. A deposit is also blocked when a vault is paused, its hedge report is stale, a hedged vault has never been reported on, or it is past maturity or settled. A vault's own page shows its range or bins, its keeper and its recent rebalances; only the keeper can rebalance. Read [Vaults](https://levee.trade/docs/vaults), [Hedged vaults](https://levee.trade/docs/hedged-vaults) and [Structured notes](https://levee.trade/docs/structured). ### Launch Three tabs, Launch, Graduation and Pons, fed by `GET /launch`, `GET /pons` and on-chain reads. - **Launch** creates a fixed-supply token and its locked ETH pool in one transaction (`LaunchPools.launch`), with an anti-snipe fee that decays after launch, or opens a pre-launch vault (`LaunchVault.open`) that pools buyers' ETH and refunds it if nobody launches within 3 days. A launch's pool fees can be collected here; they go to its fee recipient, the creator at launch. - **Graduation** tracks a young token and graduates it into a managed vault (`LaunchPipeline.track`, `graduate`, both public); counts across the top show how many tokens are tracked, on the clock, ready to graduate and graduated. It also lists pre-market perps: 3x markets on tokens no venue lists, opened with USDG (`PreMarketPerp.open`, `close`, `createMarket`). - **Pons** lists Pons tokens. "Open a Levee pool" creates a DAMM pool at the live Pons price; buying and selling hands you to Swap. Read [Launch a token](https://levee.trade/docs/launch) and [Graduation and pre-market perps](https://levee.trade/docs/graduation). ### Portfolio Everything an address holds: Uniswap v3 and v4 ranges, DLMM positions and limit orders, vault shares, range orders, perps and automation. It reads `GET /portfolio/:address` and reads DLMM and vault positions on chain. Remove on a v4 range is one `PositionManager.modifyLiquidities` call (`DECREASE_LIQUIDITY`, or `BURN_POSITION` for all of it, then `TAKE_PAIR`) that pays the fees out with the liquidity; its pool key and ticks are read from the position manager and its minimums from the pool's `StateView` price. Add `?as=0x…` to view any address read-only. From here you can harvest, rebalance or unenroll an automated position, enroll a new one, remove liquidity, cancel a range order, withdraw or burn a DLMM position, cancel or claim a DLMM limit order, and close a perp or set its stops. Read [Position automation](https://levee.trade/docs/keeper). ## More pages ### Stocks Levee's tokenized-stock pools, refreshed every 20 s from `GET /stocks`: Chainlink price, pool price, the gap between them, the band, the current fee, the market session and TVL. The open or closed status uses NYSE hours on your device's clock. A stock with no pool offers to create one at the Chainlink price (`StockHook.createPool`). Read [Levee pools](https://levee.trade/docs/pools). ### Protocol Where fees go: fees by token, conversions, buybacks, burned supply and the $LEVEE price, from `GET /protocol` and live chain reads. Each step has a button anyone can press: `FeeRouter.flush`, `FeeCollector.convert`, `unwrapWeth` or `forwardEth`, `BuybackV2.buyback` (with optional ETH of your own) and `poke`. A call that would revert shows its reason in plain words. Read [Buyback and burn](https://levee.trade/docs/protocol). ### Ideas A feed of **ideas**: a real position with the argument for it, its PnL and a leaderboard, from `GET /theses`. To post one, pick one of your own positions and write up to 500 characters; you sign the idea (EIP-712, no gas). **Mirroring** copies an idea: a perp idea becomes an order you confirm and sign, a vault idea opens the deposit, and an LP idea opens Trade with the range filled in. Each idea has its own page with a share link and card. The referral panel registers your code and binds you to a referrer from a `?ref=` link, both on chain. Read [Fees and referrals](https://levee.trade/docs/fees). ### Alerts Watches any address's LP and perp positions. You build a rule in two steps, the wallet and then the rule, from four kinds: nearing a range edge, left its range, funding changed sign, and close to liquidation. Each rule has an on/off switch, and what fired is listed in a history below. Alerts check every 30 s on every page of the app, using live data only, and pause when data is unavailable. Rules and history stay in your browser, and browser notifications are optional. It never trades. ## Tools The four tools share one arrangement: inputs on the left in numbered groups, results on the right as headline figures, a chart, a breakdown table, and a "Use this" row that hands the result to the page that can act on it. None of them signs anything. ### Planner Compares Hold, LP, Hedge and Perp on the same capital, using current market data. Ranges come from the ±5, 25 and 50 % presets or a custom band. "Share scenario" copies the URL, which carries every input. The "Use this" row opens the result: add this range, open the hedge, open the perp, or buy spot. "Guide me" walks through the chart step by step; it only opens when you press it. ### Ranges Compares a tight, a middle and a broad range (±5 %, ±25 % and ±50 % by default) for the same deposit. A calculator: no data, no writes. Each band opens in the Planner. Read [One-token deposits](https://levee.trade/docs/zap). ### Backtest Replays Hold, LP and Hedge over real hourly closes for ETH, BTC or SOL, from the API's candles; there is no generated fallback. You set the fee APR and funding; the period defaults to the last 7 days and reaches back 90 (7, 14, 30, 60 or 90 days, or any dates in between). "Plan this range" carries the band to the Planner, and "Download CSV" saves the series. ### Sizing Works out a position size from your risk budget and stop distance. Leverage changes only the margin estimate. It sends nothing itself: the result opens the perp ticket on Trade, on the side you chose. Read [Perpetuals](https://levee.trade/docs/perps). Where it lives: - `apps/web/components/chrome/nav.ts`: every page and how the nav groups them - `apps/web/lib/tx.ts`: the check, simulate, send and refresh path - `apps/web/lib/writeGate.ts`: when a write is allowed - `apps/web/lib/trade/approvals.ts`: exact approvals and Permit2 - `apps/web/lib/data.ts`: sample-data fallback - `apps/web/lib/env.ts`: FIXTURES_ALLOWED and the Privy switch - `apps/web/lib/providers.privy.tsx`: email login - `apps/web/lib/usePerp.ts`: signed perp actions and Lighter set-up - `apps/web/lib/tour/steps.ts`: the tour --- # Swaps and routing Source: https://levee.trade/docs/swaps · Trade How one swap is split into hops across Levee pools, public v4 pools and Pons, and how a route is chosen. The Swap page trades any token for any other through the Levee `Router`, a contract that chains up to three swaps across Levee pools, public Uniswap v4 pools and Pons launches in one transaction. This chapter covers what the Router does on chain, how the app finds and ranks routes, and the checks that run before you sign. The pools themselves are described in [Levee pools](https://levee.trade/docs/pools). In brief: - A swap is one `Router.swapExactIn` call with an exact input. It allows up to 3 swap hops, charges no fee of its own and reverts with `TooLittleReceived` unless the final output reaches your minimum. - The app quotes every candidate route live with `Router.quoteExactIn` and ranks them by output; a route through a Levee DLMM pair leads while it pays within 15 % of the best. - Price impact from 1 % is flagged, from 5 % you must tick a box to accept it, and from 15 % the swap is refused. - An ERC-20 input needs one exact-amount approval to the Router and no Permit2 signature. ETH goes in as the transaction value. ## A route is a list of hops You hand the Router the hops, the input amount, your minimum output, a recipient and a deadline. Each hop names a venue kind and carries that venue's own data: | Kind | Venue | `data` | Direction flag | | --- | --- | --- | --- | | `0` | any Uniswap v4 pool | `abi.encode(PoolKey, zeroForOne, hookData)` | `zeroForOne`: currency0 goes in | | `1` | a DLMM pair | `abi.encode(pair, swapForY)` | `swapForY`: token X goes in | | `2` | a Pons bonding curve | `abi.encode(curve, isBuy)` | `isBuy`: the pair token goes in | | `3` | WETH | empty | wraps ETH or unwraps WETH; not a swap hop | Diagram: One Router swap, from call to payout. swapExactIn checks the call, takes the input, runs each hop in order and pays out only if the final amount clears minAmountOut. A WETH hop does not count toward the three-swap limit, and nothing stays in the Router afterwards. The Router checks the shape first. `routeTokens` requires each hop to start in the token the previous one ended in, treats ETH and WETH as the same asset, and allows at most `MAX_SWAP_HOPS` (3) hops that are not WETH hops. Then it takes your input: either ETH sent as value, which must equal `amountIn` and must suit the first hop, or an ERC-20 pulled with `transferFrom`. Between hops it wraps or unwraps as the next venue needs. To receive ETH when the last pool pays WETH, the route ends with a WETH hop. Each v4 hop must consume its whole input or the call reverts `PartialFill`; hops carry no price limit of their own. A Pons hop works only before the token graduates (`PonsGraduated` after). The only price protection in a swap is therefore your final minimum, checked once at the end. On success the Router sends the output to the recipient and emits `Swapped`, with the zero address standing for ETH. Warning: The Router trusts the hops it is given It will swap through any v4 pool key, hooked or not, and any contract that answers like a DLMM pair or a Pons curve. The app only builds hops from Levee's own pools, hookless public pools and pools the Pons factory created. If you call the Router from your own code, vet the venues yourself. ## Quoting a route `quoteExactIn(hops, amountIn)` runs the same hops without moving tokens. It is not a view function, so the app calls it with an `eth_call`. A v4 hop runs the real swap inside a `PoolManager` unlock and reverts with `QuoteResult(amountOut)`; a DLMM hop asks the pair's `getSwapOut` and reverts `InsufficientLiquidity` if the bins cannot absorb the whole input; a Pons hop prices the curve from its reserves and fees, launch snipe tax included. A quote reads the chain at that moment. Anything that moves the price before your transaction lands is covered by your minimum, nothing else. ## How the app picks a route The Swap page, the trade terminal and Pons token pages share one search (`lib/amm/route.ts` and `useSwapRoutes`): 1. Gather the pools: Levee's DAMM, DLMM and stock pools from the API, the v4 ones with their exact key from the deployment manifest. The Pons venue of either token, from `getLaunchedToken` on the Pons factory: the curve before graduation, the v4 pool keyed with the factory's `memeHook` after. With a Router deployed, also public hookless v4 pools at the 0.01 %, 0.05 %, 0.3 % and 1 % tiers that hold in-range liquidity. Hookless only, because a hook can run arbitrary code during a swap. 2. Build candidates: Every pool holding both tokens, and every two-hop route through USDG, ETH or a Pons pair token, up to 40. The app does not build three-hop routes. It drops a route whose first pool wants native ETH when you pay WETH, because the Router cannot unwrap your input, and adds a final WETH hop where your output needs it. For the same reason WETH to ETH on its own never goes through the Router: the page unwraps it with WETH's own `withdraw`, 1:1 and with no approval. ETH to WETH is a one-hop Router route. 3. Quote and rank: Each candidate is quoted every 15 s. A failed or zero quote drops the route; the rest are ranked by output. The best route through a Levee DLMM pair moves to the top unless it pays 15 % or more below the leader (`DLMM_FALLBACK`), since that far down the bins the price impact gate would refuse it anyway. 4. Measure the impact: Price impact is the leading route's rate against a quote for 1/1000 of the amount on the same hops, so the pool fees cancel out. With no Router in the manifest the page runs in demo mode: it estimates each route from the pools' own prices net of their fees, labels the result as an estimate and will not send it. ## Before you sign The minimum you sign is the leading quote less your slippage: `minAmountOut = out × (1 − slippage)`. The page shows the rate, the price impact, the pools' compound fee, that minimum and how many routes returned a quote, with up to four other routes and how far each trails the best. Two gates can hold the button: - **Slippage.** Presets of 0.1 %, 0.5 %, 1 % and 3 %, 0.5 % by default, or any custom value up to 50 %. Above 5 % you must acknowledge the risk first. - **Price impact.** Flagged from 1 %. From 5 % you tick a box accepting the impact; from 15 % the swap is refused and you are asked to split the trade. An impact the page cannot measure does not gate. The transaction carries a 20-minute deadline. For an ERC-20 the steps are an exact-amount approval to the Router, then the swap; the Permit2 step is shown as done because the Router never uses it. The page URL follows the form (`?in=`, `?out=`, `?amt=`), so a swap can be shared as a link. ## Reading prices A v4 pool's own price is token1 per token0, in address order, and a DLMM pair's is Y per X. The NVDA stock pool sorts USDG first, so its own price is about 0.004 NVDA per USDG. Every page turns a pool round to read base in quote: the quote is USDG when the pool has it, otherwise ETH or WETH, otherwise token1, and a stock pool's asset is always the base. The hop flags above follow the raw order, never the displayed one. ## Buy and Sell in the trade terminal The terminal's spot Buy and Sell normally trade the asset's Uniswap pool through the Universal Router: approve, Permit2, swap, with a 10-minute deadline. Its quote is an estimate at the pool's on-chain price less the pool fee and Levee's 25 bps fee on the output. The same route search runs beside it. When a live Router quote pays more than that estimate, the trade goes through the Levee Router instead: one exact approval, no Permit2, no 25 bps Levee fee (the pools' own fees still apply), and the route is drawn above the button. The Uniswap estimate ignores price impact, so the Router takes over only when it beats a generous number. The terminal applies the slippage acknowledgement and checks the pool's on-chain price against the API, but not the Swap page's impact gates; for a large trade, use the Swap page. ## Reference Parameters: Thresholds the app applies - `Slippage` (allowed: 0 - 50 %; default: 0.5 %): Becomes minAmountOut. Above 5 % needs an acknowledgement. - `IMPACT_WARN` (allowed: fixed; default: 1 %): Impact shown as a warning. - `IMPACT_CONFIRM` (allowed: fixed; default: 5 %): You must accept the impact to swap. - `IMPACT_BLOCK` (allowed: fixed; default: 15 %): The swap is refused. - `DLMM_FALLBACK` (allowed: fixed; default: 15 %): How far below the best a DLMM route may pay and still lead. - `Deadline` (allowed: fixed; default: 20 min (Swap), 10 min (terminal)): Passed to swapExactIn; Expired after it. | Router error | When | | --- | --- | | `Expired`, `ZeroRecipient`, `ZeroAmount` | past the deadline, no recipient, or a zero input | | `BadRoute`, `BadKind` | an empty route, more than 3 swap hops, hops that do not chain, or an unknown kind | | `ValueMismatch` | ETH sent that differs from `amountIn` or does not suit the first hop, or no ETH for a route that starts in ETH | | `PartialFill` | a v4 hop that could not take its whole input | | `InsufficientLiquidity` | a DLMM hop in a quote that the bins cannot fill | | `PonsGraduated` | a curve hop after the token graduated | | `TooLittleReceived` | the final output is below `minAmountOut` | Where it lives: - `contracts/src/venue/periphery/Router.sol`: swapExactIn, quoteExactIn, routeTokens, the ETH and WETH bridge - `contracts/src/venue/interfaces/IRouter.sol`: hop kinds, their data, errors and the Swapped event - `contracts/src/venue/periphery/PonsAdapter.sol`: curve hops: swap, quote from reserves, graduation check - `apps/web/lib/amm/route.ts`: hop encoding, candidates, dlmmFirst, estimates, price impact - `apps/web/lib/amm/venues.ts`: where legs come from: Levee pools, public v4 pools, Pons - `apps/web/lib/amm/useSwapRoutes.ts`: the live quote loop behind every page - `apps/web/lib/amm/orient.ts`: base and quote for display - `apps/web/app/(app)/swap/SwapClient.tsx`: slippage, the impact gates, the approval and the swap - `apps/web/lib/amm/useLeveeSpot.ts`: when the terminal hands a trade to the Router --- # Limit orders Source: https://levee.trade/docs/range-orders · Trade A one-sided position that sells as the price crosses it and earns swap fees while it waits. A Levee limit order sells one token for another once the price reaches a level you choose, and earns swap fees while it waits. Under the hood it is a narrow Uniswap v3 position placed just beyond the current price, holding only the token you want to sell; as the price moves through it, the pool converts it into the other token. You place one from the Limit mode of the ticket on the Trade page, and the `RangeOrders` contract holds it until it is filled or you cancel. Levee DLMM pairs have a second kind of limit order that rests in a single price bin and fills at exactly that bin's price. Those are described in [Levee pools](https://levee.trade/docs/pools); this chapter covers the Uniswap kind. In brief: - A sell order sits above the price, a buy order below it. The app makes the band one tick spacing wide. - An order can be closed only after the price has crossed the whole band. Until then you may cancel at any time and take back whatever the position holds. - Whoever closes a crossed order keeps 1 % of the converted amount. The protocol takes 10 % of the swap fees the order earned, never of the principal. Placing and cancelling are otherwise free. - The contract never closes an order by itself; someone has to call `fill`. If the price crosses and comes back first, the order converts back. - Payouts are always ERC-20: you receive WETH, not ETH. Orders do not expire and cannot be edited or transferred. ## Setting the price You choose a side, a limit price and an amount, and the app (`limitBand` in `lib/trade/limit.ts`) builds the band: - **Sell** the asset above the market: the asset itself goes into the band. - **Buy** the asset below the market: the other token goes into the band. The near edge of the band is the usable tick closest to your price, moved one spacing outward if rounding left it touching the current price. A sell price at or below the market, or a buy price at or above it, is refused before anything is sent. The band is built from the pool's own `slot0`, and the button stays locked while that price and the API's differ by more than 100 bps. Only v3 pools are supported. The order converts as the price walks across the band, so your average price lies between its two edges: about 0.1 % apart on a 0.05 % pool, 0.6 % on a 0.30 % pool and 2 % on a 1 % pool. The panel shows the near edge as "Fills at" and an estimate of the fees the order would earn per day if the price sat inside it. ## What the contract checks `place` re-checks everything against the pool at the moment the transaction lands. 1. Inputs: A passed `deadline` is `Deadline` and a zero `amountIn` is `ZeroAmount`. `token0` must sort below `token1` and `tickLower` must be below `tickUpper`, or `BadRange`. The pool comes from `factory.getPool(token0, token1, fee)`; none is `NoPool`. 2. Side of the price: Selling token0 (`zeroForOne`) needs `tickLower > tick`. Selling token1 needs `tickUpper ≤ tick`. A band that touches the current price would need both tokens and is `BadRange`. 3. Funding: With ETH attached, the sold token must be WETH and `msg.value` must equal `amountIn` (`ZeroAmount` otherwise); the ETH is wrapped. Without ETH, the token is pulled with `transferFrom`, which needs an exact-amount approval first. The Limit mode pays in ETH automatically when you sell WETH. 4. Position: The contract mints the position to itself with the full amount on the sold side and zero on the other. Minimums are zero because the unused side is zero by construction. Whatever the mint could not use is returned to you at once, as an ERC-20: the contract never pays out ETH, because a fill pays the owner rather than the caller. 5. Record: The order is stored under the next id (ids start at 1) with you as `owner` and status `Open`, and `Placed` is emitted. Diagram: A limit order, placed to closed. A sell-token0 order must sit strictly above the price, a sell-token1 order at or below it. An open order ends by fill, open to anyone once the price has crossed the whole band, or by cancel, the owner's at any time. Neither can be undone. The position NFT stays with the contract, so a fill needs no approval from you. Your claim on it is the `owner` field of the order, which cannot be changed. ## When an order is done A Uniswap position covers ticks from `tickLower` up to, but not including, `tickUpper`. That sets both the placing rule and the closing rule. A **sell-token0** order sits above the price. It must start strictly above the current tick, because at `tickLower` itself the position would already hold some token1. It is fully converted once the tick reaches `tickUpper`: `fillable` is true when `tick ≥ tickUpper`. A **sell-token1** order sits below the price. It may end exactly at the current tick, because a position whose upper tick equals the current tick is already all token1. It is fully converted only once the tick drops strictly below `tickLower`: `fillable` is true when `tick < tickLower`, since at `tickLower` the position is live again. A partly crossed band holds both tokens and is not fillable. Orders have no clock: one the price never reaches stays open, earning fees, until you cancel it. ## Closing an order **Fill** is open to anyone once `fillable` is true. The contract marks the order `Filled`, then: 1. Removes the position's live liquidity, collects and burns the NFT. It reads the liquidity from the position manager rather than the figure stored at placing, because anyone can add liquidity to a position the contract holds. Without that, a few wei of donated liquidity would stop the burn and lock your order; with it, the donation goes to you. 2. Splits what came back into principal and fees: the decrease returns principal only, so the fee portion is what `collect` paid on top. 3. Sends 10 % of the fee portion of each token (`PROTOCOL_FEE_BPS` = 1000) to the `FeeRouter`, booked to you so that your referrer earns 20 % of it. 4. Pays the caller 1 % (`KEEPER_BOUNTY_BPS` = 100) of the converted principal, taken from your share of the bought token. 5. Sends everything else to you and emits `Filled`. **Cancel** is yours alone (`NotOwner` for anyone else) and works whenever the order is `Open`, inside the band or outside it. It runs the same unwind and the same 10 % share of earned fees, pays no bounty, and returns the position as it stands: all principal if the price never touched the band, a mix if it is part-way through, all the other token if it has crossed. Once an order is `Filled` or `Cancelled` it cannot reopen; a second close is `NotOpen`. ## Who closes orders In practice Levee's keeper bot does. Every minute by default, it reads `fillable` for every open order in one Multicall3 batch. For each crossed order it values the bounty at 1 % of the order's size in USD, converts that to wei at the ETH price, and sends `fill` only when the bounty is at least three times the estimated gas (`GAS_BOUNTY_MULTIPLE`). If the order cannot be priced, it skips rather than sending blind. After a success it leaves the order alone for 5 minutes; after a skip or a failure, for 15. A small order may therefore wait for cheaper gas, or for you or anyone else to call `fill`. A filler may also push the price through the band and then close the order. That is allowed by design: it is the same trade any arbitrageur could make against the position, it pays the position fees on the way, and it can only happen at or beyond your price. The filler still cannot act before the cross, take part of an order, or redirect the proceeds: they always go to the owner. Warning: A crossed order is not yet a filled one Until `fill` runs, your order is ordinary liquidity. If the price crosses the band and then returns through it, the pool converts the order back into the token you were selling, and it is no longer fillable. It keeps earning fees and waits for the next cross. If the bot has not closed a crossed order, close it yourself: call `fill` and the 1 % bounty comes back to you, or `cancel`, which after a full cross returns the bought token with no bounty at all. ## Reference ### Call parameters Parameters: PlaceParams (RangeOrders.place) - `token0` (allowed: the lower-sorted pool token): Not reordered for you; the wrong order is BadRange. - `token1` (allowed: the higher-sorted pool token): With token0 and fee, finds the pool. - `fee` (allowed: 100 · 500 · 3000 · 10000 hundredths of a bip): The pool fee tier. - `zeroForOne` (allowed: true · false): true sells token0 into a band above the price; false sells token1 into a band at or below it. - `tickLower` (allowed: above the current tick when selling token0 ticks): Lower edge of the band; one spacing below tickUpper in the app. - `tickUpper` (allowed: at or below the current tick when selling token1 ticks): Upper edge of the band. - `amountIn` (allowed: above 0 wei of the sold token): The principal. Equals msg.value when paying in ETH. - `deadline` (allowed: unix seconds; default: now + 600): Latest block time; also passed to the position manager. `orders(orderId)` returns the stored order, including `owner`, `tokenId` (the NFT the contract holds), `pool`, the ticks, `amountIn` (the principal the position actually took) and `status`. `nextOrderId()` is the id the next order will get. ### Reverts | Revert | Cause | | --- | --- | | `Deadline` | `place` after `deadline`. | | `ZeroAmount` | `amountIn` is zero, ETH was sent for a token that is not WETH, or `msg.value` differs from `amountIn`. | | `BadRange` | Tokens out of order, `tickLower` not below `tickUpper`, or the band on the wrong side of the current tick. | | `NoPool` | No pool for the pair and fee. | | `NotFillable` | `fill` before the price has crossed the whole band. | | `NotOwner` | `cancel` by anyone but the order's owner. | | `NotOpen` | `fill` or `cancel` on an order already closed. | ### Emitted events | Event | Emitted by | Carries | | --- | --- | --- | | `Placed` | `place` | `orderId`, `owner`, `tokenId`, `zeroForOne`, the two ticks, `amountIn` after the leftover was returned | | `Filled` | `fill` | `orderId`, `keeper` (the caller), `amountOut` (converted principal before the bounty), `feesEarned` (bought token only), `bounty` | | `Cancelled` | `cancel` | `orderId`, `amount0` and `amount1` paid to the owner | `feesEarned` counts only the bought token: the swaps that cross an order pay their fee in the token the order is buying, so the other side is dust. The 10 % protocol share still applies to both. The indexer turns these events into the `rangeOrder` table behind the Range orders lists on the Trade page and in Portfolio, and behind the bot's list of open orders. Where it lives: - `contracts/src/RangeOrders.sol`: place, fillable, fill, cancel, the unwind and the fee split - `contracts/test/RangeOrders.t.sol`: both sides, the cross, the split, native ETH and the donated-liquidity case - `apps/web/lib/trade/limit.ts`: limitBand and earnsPerDayUsd - `apps/web/app/(terminal)/trade/[asset]/LimitPanel.tsx`: the Limit mode: ETH or approval, and the price check before placing - `apps/api/src/bots/keeper.ts`: sweepRangeOrders: fillable in one batch and the bounty in wei - `apps/api/src/bots/chain.ts`: GAS_BOUNTY_MULTIPLE and the skip rule --- # Perpetuals Source: https://levee.trade/docs/perps · Trade Perps on Lighter through a per-user order key that can place orders and has no way to withdraw. The perps on the Trade page, and the shorts behind the [hedged vaults](https://levee.trade/docs/hedged-vaults), trade on Lighter. Levee is a client of that venue, not a venue itself. To give you one-click orders without a wallet popup for each one, a Levee service called the signer holds an order key for your Lighter account. This chapter covers what that key can and cannot do, how an order is priced before it is signed, and what runs when no Lighter account is connected. In brief: - Your Lighter account stays under your wallet. The order key can place and cancel orders and set leverage; binding a new key and withdrawing both need your wallet, and the signer has no code that submits a withdrawal. - A trading key can still lose money by signing a bad price, so the signer measures every order against Lighter's current mark: the signed price must sit within 1,000 bps (10 %) of it, one order is capped at $250,000 of notional, and nothing is signed when there is no mark. - A market order goes to Lighter as an immediate-or-cancel limit order at the mark plus or minus 1 %. - Every write to `/perp/*` carries an EIP-712 signature from your wallet over the exact request, a one-time nonce and a timestamp within 300 s. - Unless the API runs with `VENUE=lighter`, perps go to SimVenue: a paper venue priced from real marks, 10,000 USDG per new account, nothing sent to Lighter. ## Who holds which key Lighter is a zk exchange. Orders are signed with an API key separate from your wallet key, which your wallet authorises once with a `ChangePubKey` transaction. In Levee the browser holds only your wallet. The API checks that each write was signed by the wallet that owns the address, then forwards it; it holds no key. The signer (`services/signer`) stores your order key encrypted and signs within fixed bounds, and accepts calls only from the API, which signs each one with a shared secret over a timestamp and a one-time nonce, so a captured call cannot be replayed. Lighter executes. Diagram: Who holds which key. The signer holds the order key, and the top group is what it does on its own. Every step in the lower group needs a signature only your wallet can make; dashed arrows hand something back to the browser. With VENUE=sim no order leaves the API. The order key can place market and limit orders with exits attached, cancel orders, set a market's leverage and read your account. Lighter sets leverage per market on the account, not per order, so the signer applies the slider's value just before the order. Binding a new key needs your wallet's `ChangePubKey` signature. So does a withdrawal: `POST /perp/withdraw-intent` returns an unsigned description for your wallet to sign and submit. ## Connect your Lighter account 1. Open a Lighter account: Lighter creates the account of your address with its first deposit. The Perp form checks Lighter (`GET /perp/lighter-account/:address`) and, when there is no account yet, offers the deposit itself: your wallet approves the exact USDG amount for Lighter's bridge and calls `deposit(yourAddress, 3, 0, amount)`, at least 1 USDG, credited to the perp balance. With ETH instead, it first swaps ETH to USDG on the WETH/USDG pool. The USDG never passes through Levee. Lighter credits the deposit a few minutes later and the form moves on by itself. A smart-contract wallet is refused before any deposit: Lighter cannot verify its signature on the key change, so the account could not trade. Withdraw on Lighter. 2. Request a key: The Bind order key button signs a `POST /perp/onboard`. The signer generates one keypair per address (asking again returns the same key), stores it encrypted, picks a free API key slot from 2 to 254 and prepares a `ChangePubKey`. It returns the text your wallet must sign. 3. Sign and bind: Your wallet signs that text with `personal_sign`, and a signed `POST /perp/bind` carries it. The signer submits the `ChangePubKey` and marks the key bound: 409 if nothing was prepared or the slot changed (onboard again), 400 if Lighter refuses. 4. Trade: `GET /perp/account/:address` reports `apiKeyBound: true` and the set-up box disappears. Binding is three wallet signatures on Lighter, after the approval and the deposit that open the account. On SimVenue the first one completes onboarding and no deposit is asked for. ## How an order is priced The signer takes one write at a time per address, and signs an order only if every check passes: 1. The address is onboarded (404) and its key is bound (409). 2. The request is well formed: side, type, a finite size above zero, leverage of at least 1 (422). 3. Lighter has a current mark for the market. Without one the answer is 503 and nothing is signed: a bound is only as good as the price it is measured from. 4. The price to sign: a limit order uses your price and rests for up to 28 days. A market order uses `mark × (1 ± s)`, sent immediate-or-cancel, where `s` is the requested slippage but never under 100 bps. A request over 1,000 bps is refused (422), not clamped. 5. That price is within 1,000 bps of the mark, whichever order type produced it (422). 6. The size, rounded down to the market's precision, meets the market's minimum unless the order is reduce-only, and size times mark is at most $250,000 (422). The signer then sets leverage if you sent it, submits, and reads your account back. It reports success only when a market order has moved your position, or a limit order has moved it or rests on the book. An order Lighter threw away returns its reason. One whose outcome cannot be read yet returns as unconfirmed: refresh orders and positions before sending it again. The app sends no slippage value today, so every market order from the Perp form is priced at the mark plus or minus 1 %, as the form says beside the button. A tighter bound would turn market orders on a thin book into random misses; a wider one would not help, because Lighter itself rejects orders it flags as accidental prices (it refused a 5 % immediate-or-cancel order on ETH on 2026-09-07). The liquidation price the form shows is Levee's isolated estimate, with maintenance at half the initial margin, not Lighter's own figure. The hedger bot uses the same path for the hedged vaults' shorts: market orders for the vault's hedge account, which is its funder's address (a contract cannot bind a Lighter key, so the margin sits in the funder's account), reduce-only when it buys back. The funder binds the key once with `apps/api/scripts/onboard-hedge-account.ts`, the same onboard and bind calls the web makes. Warning: Cannot withdraw is not the whole guarantee A key that can trade can move value without a withdrawal: a resting sell at a tiny price, crossed from another account, hands your position to the buyer. That is why the band and the notional cap apply to the signed price of every order type, and why the signer will not start with a market-order ceiling below the 100 bps floor or wider than the band. The band limits each order, not how many are sent, so the signer's host and secrets still matter. See [Security model](https://levee.trade/docs/security). ## Exit orders Attach exits when you open a position in the Perp form, or add them later with **SL / TP** on a position in the terminal or in Portfolio. For a long the stop sits below the reference price and the take-profit above it; for a short, the other way round. The reference is your limit price for a limit entry and the mark otherwise, so a position in profit can carry a stop past its entry. The form shows estimated gross PnL at each level, before fees, funding and slippage, and draws the levels on the chart. The exits are Lighter's own conditional orders and trigger with your browser closed: - **With a new entry** they are linked to it: one exit activates when the entry fills, two activate together and cancel each other. Their size is what the entry actually filled, partial fills included. A reduce-only entry cannot carry exits. - **On an existing position** the order must be reduce-only, on the closing side and change no leverage; its size is capped at the position. The signer refuses new levels (409) while exits already rest in that market. Cancel those first, which removes their protection at once. - **Every exit** is reduce-only, triggered by Lighter's mark price, executes no more than 1 % beyond the trigger, and expires after 28 days. A trigger is not a guaranteed fill: a gap or a thin book can leave part of the position open, so check it after one fires. When a reduce-only market order closes a position flat, the signer also cancels the remaining exits in that market, and says so plainly if it could not confirm the cleanup. ## Signed requests A request's `address` names an account, and a plain HTTP request proves nothing about who owns it. So every `POST /perp/onboard`, `/bind`, `/order`, `/cancel` and `/withdraw-intent` carries an `auth` envelope: a `nonce` (`0x` plus 16 to 64 random bytes), a `ts` in unix seconds, and an EIP-712 `signature` by `address` over `PerpAction { string action; address account; bytes32 payloadHash; string nonce; uint256 ts }` under the domain `{ name: 'Levee', version: '1', chainId }`. The route fixes `action`. `payloadHash` is the keccak256 of the body without `auth`, as canonical JSON (keys sorted at every level, no whitespace), so your wallet signs the exact request and a signature cannot be moved to another body. The API and the web build this message from the same file, `packages/core/src/perpAuth.ts`. `requirePerpAuth` checks, in order: a valid body and `address` (400), a well-formed envelope (401 `unauthorized`), `ts` within 300 s of the server clock (401 `stale_ts`), a signature that recovers to `address` over this body (401 `unauthorized`), and a nonce this address has not used (409 `nonce_used`). The nonce is spent only after the signature verifies, so a forged request cannot burn the owner's next nonce. It is remembered for 300 s plus 60 s of slack, after which the timestamp check already fails. The venue receives the address the signature recovered to. Reads carry no envelope. ## Prices and charts Marks, 24 h change, high, low, volume, open interest and funding come from `MarketData` in the API: one view of all 57 markets in the symbol table, cached for 15 s. Lighter's REST API is the source of truth, its WebSocket feed fills gaps between polls (reconnecting with backoff up to 30 s), and funding comes from Lighter's own funding-rate row, as a percent per hour. Where Lighter has no mark, Hyperliquid fills crypto markets and Yahoo Finance fills stocks, indices and commodities. A market no source answers is still listed without a mark, and a row filled by a fallback has no funding or open interest, which the app reads as "no perp leg". Chart candles follow the same order, cached for 60 s. Lighter answers at most 500 bars a request, so the API pages back for older ones once and afterwards fetches only the bars since the newest it holds. Every chart reaches back to the first bar any source keeps: the chart opens on the newest 1 000 bars, and scrolling to its left edge loads the 1 000 before them (`GET /candles?before=`), until an empty page marks the start. Lighter keeps every bar since its markets opened on Robinhood Chain in June 2026, one-minute bars included. Before that, history runs through the market's fallbacks in turn, Hyperliquid for crypto (BTC daily bars from August 2020) and then Yahoo Finance (daily bars from the listing: BTC from 2014, NVDA from its 1999 IPO, SPY from 1993; hourly bars for two years). Each source goes on only where it quotes the same price as the newer one: over the newer source's first 48 bars, the median close of the older one must be within 3 %. OIL, which is the USO fund on Lighter and crude futures on Yahoo, therefore stays Lighter-only, as do the pre-IPO markets, which have no public price. The chart names the sources of those bars ("Before Jun 26, 2026: Hyperliquid and Yahoo Finance prices"). When the API cannot be reached, development and staging builds show fixture data tagged **Sample data**. Production builds never show fixtures; pages stay empty and a status notice says the data is unavailable. ## The simulated venue The API picks its venue from `VENUE`: `lighter` builds a client for the signer, and the default, `sim`, builds `SimVenue`. It is independent of how you sign in. SimVenue is a paper book priced from the real marks, so Trade and Portfolio work without a Lighter account or a key: - Each new account starts with 10,000 USDG. - Market orders fill 5 bps from the mark, against you. Limit orders fill when marketable, or rest until the mark crosses them. - Margin is isolated per market, maintenance is half the initial margin (a 2x long is liquidated about 25 % down), and funding accrues hourly, longs paying a positive rate. - Stop-loss and take-profit work as on Lighter, with the same 28-day expiry. Nothing reaches Lighter, a withdrawal answers 503 `sim_no_withdraw`, and every write is still checked for a valid envelope. ## Keys at rest The signer keeps one file per address under `KEY_STORE_DIR` (`/data` in the Docker image). The private key in it is Fernet-encrypted with a key derived by SHA-256 from `KEY_ENCRYPTION_SECRET`, and the file is written under a temporary name, restricted to its owner, then renamed. The service will not start without `SIGNER_SECRET` and `KEY_ENCRYPTION_SECRET`, compares the shared secret in constant time, runs as an unprivileged user, and never logs key material. `GET /health` is its only open route, and it does not say whether the deployment is live. The key store must be persistent. If it is lost, no funds move, since no stored key could withdraw, but every address must onboard again with a new key and a new `ChangePubKey`. Note: What the tests cover With `DRY_RUN=1` the signer uses an in-memory Lighter stand-in that makes no network call; the test suite and local development run this way. The real client is pinned to `lighter-sdk` 1.1.2. Opt-in live tests read the Robinhood venue and sign a `ChangePubKey` locally, and an offline test checks the exit payloads against the SDK, but no test submits an order, a bind or an exit to Lighter. Response shapes are pinned at both ends: the API answers 502 `signer_bad_response` to anything that is not the recorded shape rather than show a zeroed account. ## Reference ### Signer settings Parameters: services/signer/app/config.py - `MARKET_SLIPPAGE_FLOOR_BPS` (allowed: 100 bps): fixed in code; no market order is priced tighter - `DEFAULT_SLIPPAGE_BPS` (allowed: integer bps; default: 100): when a market order sends none - `MARKET_SLIPPAGE_CEILING_BPS` (allowed: floor to band bps; default: 1,000): more is refused, not clamped - `LIMIT_PRICE_BAND_BPS` (allowed: above 0 bps; default: 1,000): furthest a signed price may sit from the mark - `MAX_ORDER_NOTIONAL_USD` (allowed: above 0 USD; default: 250,000): size × mark per order - `INTEGRATOR_ACCOUNT_INDEX` (allowed: account index; default: unset): sent with orders for Lighter fee-share attribution ### Errors from `/perp/*` | Answer | Meaning | | --- | --- | | 400 `invalid_*` | a malformed field | | 401 `unauthorized` / `stale_ts`, 409 `nonce_used` | the envelope failed a check | | 404, 409, 422, 400 from the signer | not onboarded; not bound or exits already resting; outside a bound; refused by Lighter | | 502 `signer_error` / `signer_unreachable` / `signer_bad_response` | the signer failed (a missing mark included), gave no answer within 10 s, or sent an unexpected shape | | 503 `signer_not_configured` / `sim_no_withdraw` | the API has no signer secret; a withdrawal on SimVenue | Where it lives: - `services/signer/app/main.py`: /onboard, /bind, /order and its checks, /withdraw-intent - `services/signer/app/config.py`: the floor, ceiling, band and notional cap - `services/signer/app/key_store.py`: encrypted key files - `services/signer/app/lighter_client.py`: the dry-run stand-in and the SDK client, exits included - `apps/api/src/routes/perp.ts`: the /perp/* routes and the envelope contract - `apps/api/src/perp/auth.ts`: requirePerpAuth - `packages/core/src/perpAuth.ts`: the EIP-712 message and body hash - `apps/web/lib/usePerp.ts`: onboarding, orders and cancels from the browser - `apps/api/src/venue/sim.ts`: SimVenue - `apps/api/src/marketdata/index.ts`: MarketData and its source order --- # Vaults Source: https://levee.trade/docs/vaults · Earn One-token Uniswap vaults and the DLMM and stock vaults: pricing, the price check, caps, harvest and rebalance. A Levee vault takes your tokens, runs a liquidity position for you and gives you shares that track your part of it. This chapter covers the vaults that do only that: `LPVault`, which manages one Uniswap v3 position and takes a single token, and the two vaults on Levee's own pools, `DlmmVault` and `StockVault`, which take both tokens of their pool. [Hedged vaults](https://levee.trade/docs/hedged-vaults) and [structured notes](https://levee.trade/docs/structured) are built on `LPVault` and have chapters of their own. In brief: - `LPVault` is an ERC-4626 vault: you bring one token (WETH or USDG in the seed vaults) and get shares priced at the pool's spot price, with the other leg valued net of the pool fee. - Every deposit, withdrawal and rebalance first checks that spot sits within `300 bps` of a mean taken over at least `10 min` of pool history, and reverts if not. During a fast move you may have to wait to exit. - A vault's cap is fixed when it is created and no key can change it. The seed vaults and graduated vaults have none, so they take deposits of any size. - Anyone can call `harvest`, at most every `6 h` in the seed vaults: `10 %` of the collected fees goes to the protocol, `0.5 %` to the caller, and the rest is compounded. - After their first deposit, DLMM and stock vaults take both tokens as an exact slice of what they hold, so their shares need no price. Their withdrawals work even while the protocol is paused. ## Choosing a vault The `/earn` page lists every kind in one grid, with a filter per kind. The three in this chapter differ in what you bring and in who keeps the position centred. | | `LPVault` | `DlmmVault` | `StockVault` | | --- | --- | --- | --- | | Position | One Uniswap v3 range | `activeId ± halfWidth` bins of a Levee DLMM pair | A base range and a one-sided limit range in a stock hook pool | | You bring | One token, the vault's `asset` | Both tokens in the vault's mix, or one token through the zap | Both tokens in the vault's mix | | Share price | Holdings valued at pool spot | Active bin price for the first deposit, then none: each deposit is an exact slice | Oracle price for the first deposit, then none | | Re-centred by | Anyone, through `rebalance()` | The factory's keeper or the protocol owner | The factory's keeper or the protocol owner | | Fee to Levee | `10 %` of harvested fees | None of its own | None of its own | | Created by | Anyone, through `VaultFactory` | The protocol owner | The protocol owner | ## Depositing one token You call `deposit(assets, receiver)` with the vault's `asset`. The vault checks its cap, if it has one, works out your shares with `previewDeposit`, runs the price check, takes the tokens and mints. Shares carry three more decimals than the asset, and the first deposit into an empty vault mints `1 000` share units per asset unit, so `2 ether` of WETH buys `2 000 ether` of share units, which a wallet shows as 2 shares. The offset is OpenZeppelin's defence against share inflation: an attacker who donates to a near-empty vault to round the next deposit down loses about a thousand times what they can take. Diagram: One deposit, one withdrawal. A deposit is checked against the cap (skipped when the vault has none, as every seed vault does), priced, guarded and put to work. A withdrawal is guarded, sized against what the vault can reach on chain and paid in the asset. Red chips mark where a step can revert. Your tokens do not go into the position straight away. `_deployIdle` adds the vault's idle balance only once it is worth at least the larger of `0.5 %` of `totalAssets()` and one thousandth of a unit (`0.001 WETH`, or `0.001 USDG`). Below that, the balance waits and still counts in the share price. Above it, a vault with no position opens one centred on the current tick, `policy.widthTicks` wide; a vault that has one adds to it. Both paths swap the part the range cannot use and mint the balanced pair through `ZapExec`. ## What a share is worth `totalAssets()` adds up everything the vault holds, in `asset`, at the pool's current price: ```text held = idle token0 + idle token1 + what the position would return if closed now value = asset leg + other leg × spot price × (1 − pool fee) ``` The other leg is discounted by the pool fee because the vault must sell it through the pool to pay anyone out. Valued gross, the last person to leave would find a shortfall of exactly the fee tier. Uncollected trading fees are left out on purpose: counting them would make a harvest lower the share price by the protocol cut and the bounty. Leaving them out means a harvest can only add value. After a harvest, a new depositor pays more than one unit per share. The fees the vault earned before you arrived belong to the people who were in it. ## Withdrawing `withdraw(assets, ...)` and `redeem(shares, ...)` run the same price check, burn your shares (spending an allowance if you act for someone else) and pull liquidity out of the position. The fraction removed is `assets / _liquidAssets()`, rounded up, so the vault takes slightly too much rather than paying one wei short; the spare stays idle. If the vault still holds too little `asset`, it sells the other leg through the pool with a floor of the spot quote less `policy.maxSlippageBps`. If even that is not enough, the call reverts `Shortfall` rather than pay less than the preview. A withdrawal does not take the protocol's cut. Fees that come out with the principal stay in the vault for the remaining holders; the cut is taken only by `harvest`, so the same fees are never billed twice. Note: The last holder out When the shares you burn are the whole supply, the vault closes the position, burns the NFT, sells the other leg and pays you its entire balance, not the previewed amount. The two differ by the price impact of that final swap. `redeem()` returns what was actually paid. ## The price check A position needs a price to be valued, and the pool's own spot price is the one an attacker can move inside a block. So `LPVault` runs `TwapGuard.check(pool)` before every deposit, withdrawal and rebalance. The same library guards a structured note's SGOV pool and the swaps in a hedged vault's two margin calls, so every Levee vault uses one definition of a sane price. Diagram: The price check. TwapGuard asks two questions and has a revert for each. The history must reach back ten minutes, because a mean over a window the attacker wrote is just spot. Then spot must sit within 3 % of the mean, compared as prices rather than ticks. The check has two parts. First, the pool's observation ring must reach back at least `MIN_TWAP_WINDOW` (`10 min`); otherwise it reverts `TwapWindowTooShort`. Second, spot must sit within `MAX_TWAP_DEVIATION_BPS` (`300`, that is `3 %` of the price) of the mean over `min(30 min, available history)`; otherwise it reverts `PriceDeviation`. The minimum window exists because a new pool's ring has one slot, rewritten in every block, so its oldest observation can be the attacker's own swap. `VaultFactory` grows each pool's ring to `60` slots when it creates a vault. The ring then fills one slot per block that writes to it, so `60` slots cover ten minutes only if the pool sees fewer than one such block every ten seconds. On a busier pool anyone can deepen the ring with the pool's permissionless `increaseObservationCardinalityNext`. The check is skipped in one state: while the vault has no shares. Nobody can be robbed in an empty vault, and without the exception the first depositor into a vault on a fresh pool could never get in. Everyone after them waits until the ring reaches back ten minutes. Warning: A fast market can hold your exit The check guards withdrawals too. If the price jumps by more than `3 %`, spot stays outside the band until the 30-minute mean catches up, and every deposit, withdrawal and rebalance reverts until then. A vault that cannot price itself honestly does not trade at all. ## Harvest and rebalance Two calls keep an `LPVault` working, and anyone may make either. Diagram: Harvest and rebalance. Both calls are open to anyone. harvest() compounds fees and skips the price check; rebalance() moves principal, so it runs the same check as a deposit. The protocol cut and the caller’s bounty come out of fees only. `harvest()` collects every fee the position has earned and splits each token separately: `PROTOCOL_FEE_BPS` (`1000`, `10 %`) goes to the `FeeRouter`, where it all lands on the treasury because a vault never has a referrer, and `HARVEST_BOUNTY_BPS` (`50`, `0.5 %`) goes to the caller. The remainder is added back to the position; idle deposits are not. It reverts `NoPosition` before the vault has opened a range and `TooSoon` within `policy.minInterval` of the last harvest. It runs no price check: it moves no principal, its swap is bounded by `maxSlippageBps`, and a guard here would only let a manipulator stop the vault being paid. Levee's keeper bot simulates `harvest` on each vault in the deployment manifest once per `VAULT_HARVEST_INTERVAL_MS` (`6 h`) and sends it when the simulation passes. `rebalance()` closes the position and mints a new one centred on the current tick. It needs the tick past a range edge by the hysteresis, `tick < tickLower − hysteresisTicks` or `tick ≥ tickUpper + hysteresisTicks` (otherwise `InRange`), `minInterval` since the last rebalance (otherwise `TooSoon`), and a passing price check, because it swaps and re-centres real value. The fee part of what it collects is split exactly like a harvest, and it resets the harvest clock. `shouldRebalance()` answers the same questions without reverting, including the price check, so a caller can ask before paying gas. When the vault mints, two separate bounds apply. `policy.maxSlippageBps` (`100` in the seed vaults) limits value lost in the swap, against a spot quote. `MINT_RATIO_TOLERANCE_BPS` (`500`) limits how far the minted amounts may fall short of the balanced holdings, which drifts further the narrower the range. A narrow range with a large deposit can fail the second bound by design; the fix is a wider range. ## Caps and who can create a vault `cap` is set in `initialize` and has no setter. `CapUpdated` is emitted once, at creation. A mutable cap would be an admin key under another name, so a different cap means deploying a new vault. `type(uint256).max` (`NO_CAP`) means no cap: `maxDeposit()` and `maxMint()` answer `type(uint256).max` and the check is skipped. Any other value is a limit, `maxDeposit()` returns the room left under it, and the deposit drawer on `/earn` refuses an amount over it ("More than the vault cap allows") before you sign. The seed vaults, the hedged vaults and the graduated vaults are created with no cap, and `/earn` shows them as "No cap". `VaultFactory.createV3Vault` is open to anyone, because a vault has no admin and can touch only its depositors' funds. It clones the implementation, initialises the clone in the same transaction so nobody else can, grows the pool's observation ring and emits `VaultCreated` with `kind` `0`. Being in the factory's list is therefore not an endorsement. The app shows the vaults named in the deployment manifest and those that [graduation](https://levee.trade/docs/graduation) created, and treats any other address as unverified. The seed deploy creates `Levee ETH Vault` (`lvETH`) and `Levee USDG Vault` (`lvUSDG`) on the WETH/USDG `0.05 %` pool. ## Vaults on Levee pools `DlmmVault` and `StockVault` sit on [Levee's own pools](https://levee.trade/docs/pools). After the first deposit, both take each deposit as an exact slice of everything the vault already holds, in both tokens and in the vault's current mix, so a share needs no price and moving the pool before you deposit cannot dilute anyone. Neither ever swaps, neither charges a fee of its own (the pool's swap fees compound), and both lock `MIN_SHARES` (`1000`) at the dead address on the first deposit to block share inflation. Deposits and rebalances stop while the protocol is paused; withdrawals never do. **DLMM vaults.** A `DlmmVault` holds bins `activeId ± halfWidth`, filled in one of three shapes: Spot (`0`, even), Curve (`1`, deepest at the active bin) or Bid-Ask (`2`, deepest at the edges). `deposit` takes `activeIdDesired` and `idSlippage` and reverts if the active bin has moved further than that; the slice goes into each bin in the vault's own proportions, and its share of the active bin is cut to that bin's own X:Y mix (what does not fit stays idle in the vault, inside the slice you paid for), so a deposit never pays the pool a composition fee. The first deposit opens the range around the active bin and is valued at that bin's price. `withdraw` burns a slice of every bin and of the idle balance, with your minimum amounts. **Stock vaults.** A `StockVault` centres a base range on the stock hook's oracle price: `openHalfWidth` ticks while the market is open, and the wider `closedHalfWidth` while it is closed and the oracle is frozen. What the base cannot take at the current price waits in a one-sided limit range next to it, on the side where it only fills at or beyond the oracle. The first deposit needs a fresh oracle and a pool price within `maxDeviation` of it, and is valued at the oracle price. A session change makes a rebalance due, so the range is re-laid at least twice per trading day. Diagram: When the pool vaults re-centre. Left: the DLMM keeper waits until a drift past half the range has held for two minutes, then lays every bin out again around the active one. Right: the order of the checks behind StockVault.rebalanceStatus(); only READY lets rebalance() through. Neither vault swaps. Only the factory's `keeper()` or the protocol owner can rebalance either vault, at most once per `MIN_REBALANCE_INTERVAL` (`5 min`). A DLMM vault is due once the active bin is more than `halfWidth / 2` bins from the range centre (`needsRebalance()`); Levee's bot also waits until that drift has held for `DLMM_SUSTAIN_S` (`120 s`) and sends with an `idSlippage` of `2` bins. A stock vault reports its own readiness through `rebalanceStatus()`, and the bot sends only on `READY`. ### One token into a DLMM vault `DlmmVaultZap` turns one token, native ETH included, into a DLMM vault deposit in a single transaction, and a withdrawal back into one token. It has no owner, holds nothing between calls and accepts only vaults the DLMM vault factory made. You choose the swap amount and the route; the contract checks that the route connects the vault's two tokens, swaps through the Levee Router, deposits both sides with your `minShares`, and returns what the vault did not take. `zapOut` redeems your shares (they need an approval), swaps the side you do not want and enforces `minOut`. The app plans the split in `lib/amm/zap.ts`. It swaps `s = A · tO / (tO + tT · r)` of an input `A`, where `tT` and `tO` are the vault's holdings of the input and the other token and `r` is the route's rate at that size, quoting three times to settle `r`. Routes use other Levee pools between the two tokens, never the vault's own pair, whose price the split depends on. Every DLMM and stock vault write from the app carries `0.5 %` minimums and a `20 min` deadline. ## Reference ### LPVault settings Parameters: LPVault.Init - set once by VaultFactory.createV3Vault - `pool` (allowed: Uniswap v3 pool): The one pool the vault provides liquidity to. - `asset` (allowed: token0 or token1 of the pool): What you deposit and what shares are priced in. Anything else reverts NotAsset. - `name / symbol` (allowed: strings; default: Levee ETH Vault / lvETH): The share token. - `policy.widthTicks` (allowed: positive multiple of the spacing ticks; default: 2 × 2231, snapped): Full width of each range the vault mints: 2231 ticks (a 1.25x price factor) each side, snapped down to the spacing. - `policy.hysteresisTicks` (allowed: 0 or more ticks; default: 10 spacings): How far past an edge the tick must sit before rebalance is allowed. - `policy.minInterval` (allowed: any seconds; default: 6 h): Least time between two harvests, and between two rebalances. - `policy.maxSlippageBps` (allowed: 0 - 9999 bps; default: 100): Floor on every swap the vault makes, against a spot quote. - `policy.compound / bountyBps` (allowed: bool / bps; default: true / 50): Recorded for bots; the vault always compounds and always pays HARVEST_BOUNTY_BPS. - `cap` (allowed: asset units, or type(uint256).max; default: none (NO_CAP)): Deposit cap. Cannot change. type(uint256).max means no cap. - `feeRouter, npm, router, weth` (allowed: addresses): Where the protocol cut goes, the position manager, SwapRouter02 and WETH9. Parameters: Pool vault settings - set by the protocol owner at creation - `halfWidth` (allowed: 1 - 50 bins; default: 10 (local seed)): DlmmVault range is activeId ± halfWidth; a rebalance is due past halfWidth / 2. - `shape` (allowed: 0, 1, 2; default: 1, Curve (local seed)): DlmmVault: Spot, Curve or Bid-Ask, for every deposit into the range and every rebalance. - `openHalfWidth` (allowed: tick spacing - 10 000 ticks; default: 120 (local seed)): StockVault half width while the market is open. - `closedHalfWidth` (allowed: tick spacing - 10 000 ticks; default: 300 (local seed)): StockVault half width while the market is closed. - `maxDeviation` (allowed: 1 - half of each half width ticks; default: 50 (local seed)): StockVault waits while the pool is further than this from the oracle. One DLMM vault exists per pair, half width and shape, and one stock vault per stock pool; the stock vault factory refuses a pool that is not the stock hook's or that quotes in native ETH. ### Reverts | Revert | Raised by | Meaning | | --- | --- | --- | | `CapExceeded` | `LPVault` deposit, mint | The deposit would take `totalAssets()` past the cap. Never raised by a vault with no cap. | | `ZeroShares` | `LPVault` deposit, mint | The deposit would mint no shares. | | `TwapWindowTooShort` | `LPVault` deposit, withdraw, `rebalance` | Less than `10 min` of pool history. Not raised while the vault has no shares. | | `PriceDeviation` | `LPVault` deposit, withdraw, `rebalance` | Spot is more than `3 %` from the mean. | | `Shortfall` | `LPVault` withdraw | The unwind raised less than the withdrawal owes. Never on a full exit. | | `NoPosition`, `TooSoon`, `InRange` | `harvest`, `rebalance` | No range opened yet, inside `minInterval`, or the tick has not left the range by the hysteresis. | | `NotAsset`, `BadPolicy`, `AlreadyInitialized` | `initialize` | Wrong asset, a malformed policy, or a second initialisation. | | `DlmmVault__ActiveIdSlippage` | DLMM deposit, rebalance | The active bin moved more than `idSlippage` bins. | | `DlmmVault__InsufficientShares`, `__AmountSlippage` | DLMM deposit, withdraw | Below your `minShares` or minimum amounts. | | `DlmmVault__NotNeeded`, `__TooSoon`, `__NotKeeper` | DLMM rebalance | No drift past half the range, inside `5 min`, or the wrong caller. | | `StockVault__PriceDeviation`, `__StaleOracle` | stock first deposit, rebalance | The pool is too far from the oracle, or the oracle is stale. | | `DlmmVaultZap__BadRoute`, `__TooLittleReceived` | `zapIn`, `zapOut` | The route does not connect the vault's tokens, or the output is under `minOut`. | ### Events `LPVault` emits `CapUpdated(cap)` once, `PositionOpened(tokenId, tickLower, tickUpper)` on each new range, `Harvest(fees0, fees1, protocolShare0, protocolShare1, bounty0, bounty1)` in pool-token units, `Rebalance(tickLower, tickUpper, tokenId)`, and the standard ERC-4626 `Deposit` and `Withdraw`, where `Withdraw` reports the amount actually paid. `VaultFactory` emits `VaultCreated(vault, pool, asset, kind, name)`. Where it lives: - `contracts/src/LPVault.sol`: totalAssets, _deposit, _deployIdle, _withdraw, _liquidate, harvest, rebalance - `contracts/src/libraries/TwapGuard.sol`: the two checks, the constants and why the window has a floor - `contracts/src/libraries/ZapExec.sol`: balanceAndMint and balanceAndIncrease, and the two bounds - `contracts/src/VaultFactory.sol`: createV3Vault, prepare at creation, vaultKind - `contracts/script/LeveeWiring.sol`: the seed policy, NO_CAP and the two plain seed vaults - `contracts/src/venue/vaults/DlmmVault.sol`: deposit, withdraw, rebalance, needsRebalance, the three shapes - `contracts/src/venue/stockvaults/StockVault.sol`: the base and limit ranges, rebalanceStatus, deposit and withdraw - `contracts/src/venue/vaults/DlmmVaultZap.sol`: zapIn, zapOut and the route check - `apps/api/src/bots/keeper.ts`: the vault harvest sweep - `apps/api/src/bots/dlmmVaultRebalancer.ts`: the sustain wait before a DLMM rebalance - `apps/web/lib/amm/zap.ts`: how the app plans a one-token deposit - `apps/web/lib/earn/view.ts`: capPct and depositBlocker, behind the cap bar and the drawer --- # Hedged vaults Source: https://levee.trade/docs/hedged-vaults · Earn An LP vault with a Lighter short: the reporting and funding keys, when it pauses, and what is not live yet. A liquidity position is long the token it holds, whether you want that exposure or not. `HedgedLPVault` is an [`LPVault`](https://levee.trade/docs/vaults) 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. In brief: - Open, with no deposit cap. The short runs on a Lighter account owned by the vault's **funder key**, an ordinary wallet key the operator holds offline, because Lighter only lets an account trade once its owner signs, and a contract cannot sign. Whoever holds that key can withdraw the margin sitting on Lighter. - Two keys with separate jobs. The hot hedger key can only report; the cold funder key moves margin to Lighter, at most `10 %` of the vault's on-chain assets per call and `25 %` per day, and the contract cannot send it anywhere else. Each hedged vault has its own funder, so no two vaults share a Lighter account. - A report that moves the hedge's equity by more than `15 %`, or no report for `1 h`, pauses the vault. While paused, deposits and withdrawals both revert. - The reported equity is part of the share price. Small steps inside the `15 %` band can still walk it anywhere over time, and with no deposit cap nothing limits how much money that reaches. - Margin on its way between the vault and Lighter still counts. `fundHedge` and `returnHedge` move the stored equity in the same transaction, and the hedger reports deposits Lighter has not credited, withdrawals it has not paid out and the USDG and WETH in the funder's wallet, so the share price does not dip while margin travels. - An exit larger than the vault's on-chain holdings reverts until the funder brings margin back with `returnHedge`. ## 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](#margin-in-transit)). The share price adds the second one to the plain vault's figure: ```text 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](https://levee.trade/docs/perps), 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. 1. Refuse while paused: `EquityJump` after a jump, `HedgePaused_` after a stale report, so the caller can tell the two apart. 2. Check the caps: At most `maxFundBpsPerCall` of the on-chain assets (LP position plus idle WETH) per call, and `maxFundBpsPerPeriod` in total per one-day window, which starts at the first funding. Both are measured against on-chain value, never the reported equity. Over either reverts `FundCapExceeded`. 3. Free the WETH: If 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`. 4. Swap at a TWAP floor: `HedgeFunding.moveMargin` runs `TwapGuard.check` on the WETH/USDG pool and sells with a floor of the TWAP quote less `maxSlippageBps`. 5. Deposit to Lighter: Under the bridge's `1 USDG` minimum it reverts `BelowLighterMinimum`. Otherwise it approves exactly the USDG received, calls the bridge's `deposit(funder, 3, 0, usdgOut)` (asset index `3` is USDG, route `0` the perp balance, credited to the funder's Lighter account, which the first deposit opens) and clears the approval. 6. Count it at once: The USDG delivered is added to `hedgeCollateralUsd` and to the stored equity, the crossing time is recorded, and `HedgeFunded` is 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: Diagram: Margin in transit. Where the vault's margin is counted at each step of a round trip. The two crossings the chain sees, fundHedge and returnHedge, move the stored equity in the same transaction; every place in between is in the hedger's report. So the share price does not dip while margin travels, either way. - **The two crossings the chain sees** move the stored equity in the same transaction, in the units reports use: `fundHedge` adds the USDG it delivered, `returnHedge` takes off the USDG it received. A report dated at or before the last crossing reverts `StaleReport`, 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 `fundHedge` deposit 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 `HedgeFunded` logs give each deposit's L1 transaction and amount; Lighter's public `txFromL1TxHash` answers 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 h` if 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`. Diagram: Hedger and funder. Solid arrows are calls, dashed arrows are reads. The hot hedger key only reports. The cold funder key moves margin, capped, to one fixed contract, credited to the funder's own Lighter account, which a contract could not trade. Margin comes back when the funder withdraws USDG on Lighter and hands it to returnHedge. For each vault, one tick does this: 1. **Read the position**: `positionTokenId()`, `pool()` and `asset()` on the vault, then `positions(tokenId)` and `slot0()`. A vault without liquidity is skipped. 2. **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 `lpDelta` returns 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. 3. **Find the current short** on the vault's hedge account, `fundingConfig().funderKey`, read from chain once (the funder never changes). The market comes from `VAULT_MARKETS` or 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 the `hedger.account_not_onboarded` alert: no order and no report. 4. **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 exceeds `HEDGE_DRIFT_BPS` (`200`); otherwise it does nothing. 5. **Trade**: core's `hedgeOrders` returns at most one market order. It skips differences under `HEDGE_MIN_NOTIONAL_USD` (`$50` at the mark) and rounds down to `HEDGE_LOT_SIZE` (`0.001`). Sells grow the short; buys shrink it and are always `reduceOnly`, so the account cannot flip long by accident. 6. **Report**: it reads the hedge account back and sends `reportHedge` with 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 less `10 s` as `asOf`, taken before the first read (the simulation runs against the latest block, which trails the clock, and a later `asOf` reverts `StaleReport`; 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. Diagram: The report gate. A report that clears the two reverts is stored, jump or not. The previous figure includes the margin funded or returned since the last report, so margin in transit is not a jump. A jump pauses the vault until an in-band report follows; silence pauses it with no transaction at all. StructuredVault.reportSleeve follows the same tree without the notional and the crossings. The band compares each report with the previous stored equity: ```text 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](https://levee.trade/docs/structured)'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. Danger: What you are trusting A hedged vault's share price includes a number one key reports, and its hedge margin sits in a Lighter account another key controls. The contract limits each step of the report, pauses when the hedger goes quiet, and lets the funder send at most a quarter of the vault a day to exactly one address. It cannot tell an honest report from a dishonest one that stays in the band, and it cannot stop the funder key from withdrawing what is already on Lighter. If you would not trust the operator with your deposit, do not make it. ## Reference ### Settings Parameters: HedgedLPVault.HedgedInit - deploy values from LeveeWiring - `base` (allowed: LPVault.Init): Everything a plain vault needs. base.asset must be WETH, because the hedge is priced through ETH/USD. - `hedger` (allowed: address; default: env HEDGER): The only caller of reportHedge. - `funder` (allowed: address, not the hedger; default: 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` (allowed: 1 - maxFundBpsPerPeriod bps; default: 1000): Largest single funding, as a share of on-chain assets. - `maxFundBpsPerPeriod` (allowed: up to 10 000 bps; default: 2500): Total funding allowed in one day-long window. - `usdg / usdgPool` (allowed: token / WETH-USDG pool; default: WETH/USDG 0.05 %): Margin token and the pool fundHedge sells through. - `lighterDeposit` (allowed: address; default: Lighter bridge 0x94bA…fF9d): The only contract fundHedge can pay. USDG goes in as asset 3 on the perp route. - `ethUsdFeed` (allowed: Chainlink feed): Converts equityUsd into WETH. - `maxHedgeAge` (allowed: uint32 seconds; default: 1 h): Report age after which paused() is true. - `maxEquityJumpBps` (allowed: uint16 bps; default: 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](https://levee.trade/docs/vaults#reverts). 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()`. Where it lives: - `contracts/src/HedgedLPVault.sol`: the header on what the hedge is on chain and on margin in transit, fundHedge, returnHedge, reportHedge, paused, hedgeEquityInAsset - `contracts/src/libraries/HedgeFunding.sol`: the TWAP-floored swaps both ways and the Lighter deposit - `contracts/src/libraries/ChainlinkGuard.sol`: when a feed read counts as bad - `contracts/src/interfaces/lighter/ILighterDeposit.sol`: the one Lighter call the vault makes - `contracts/script/LeveeWiring.sol`: MAX_HEDGE_AGE, MAX_EQUITY_JUMP_BPS, the fund caps, one funder per seed hedged vault - `contracts/test/fork/LighterBridgeFork.t.sol`: fundHedge into the funder's account on the real Lighter bridge, and returnHedge on the live pool - `apps/api/src/bots/hedger.ts`: one tick on the funder's account: read, size, trade, report - `apps/api/src/bots/hedgerFlows.ts`: margin in transit: deposits matched by L1 tx, withdrawals, the bridge balance, the funder wallet, and the read order - `apps/api/src/venue/transfers.ts`: Lighter's txFromL1TxHash answer, recorded, and the signer's pending withdrawals - `apps/api/scripts/onboard-hedge-account.ts`: binding the order key to a funder account, key on stdin - `packages/core/src/hedge.ts`: lpDelta and hedgeOrders - `apps/api/src/config.ts`: the HEDGE_* settings, HEDGED_VAULTS and VAULT_MARKETS - `apps/web/lib/earn/view.ts`: HEDGE_CUSTODY_NOTE, the first-report gate, hedgeStatus and hedgeDelta - `contracts/test/HedgedLPVault.t.sol`: the funding caps, the pause cases, the zero-equity report, the broken feed, the transit sandwich and the return round trip --- # Structured notes Source: https://levee.trade/docs/structured · Earn Income and Protected: two dated payoffs built from an LP sleeve, a perp sleeve or T-bills. A structured note is a vault with an end date. `StructuredVault` splits each deposit between an [`LPVault`](https://levee.trade/docs/vaults) and a second sleeve, and after maturity it unwinds everything back to its asset. In `Income` mode the second sleeve is a long perp position run off chain, and the upside is capped; in `Protected` mode it is SGOV, a T-bill token, which puts a floor under the downside. This chapter explains how a note is built, what it pays, how you leave it and where the floor stops protecting you. In brief: - Two seed notes, both in WETH with a `90 day` term: `Levee Protected SPY` (`85 %` into SGOV, `15 %` into the SPY Hedged vault), open with no deposit cap, and `Levee Income NVDA` (`80 %` to the LP sleeve, `20 %` kept as collateral for a long), which is not open yet (see below). - Leaving before maturity costs `50 bps`, and the previews already show the amount after the fee. After maturity there is no fee, and anyone can call `settle` once to turn the whole note back into WETH. - The Protected floor is arithmetic on an SGOV sleeve bought through a Uniswap pool, measured in WETH, not a promise from anyone. At the start of the term it is about `0.859` per unit deposited. - The Protected note's `15 %` slice follows SPY Hedged: while that vault is paused, the note's deposits and withdrawals revert. - The Income note's perp sleeve is reported by the hedger key, under the hedged vaults' rules: a `15 %` jump or `1 h` of silence pauses the note, and a paused note takes no deposits and pays no exits. - The Income note takes no deposits and the app lists it as "Coming soon": its long has no way to reach Lighter margin yet, which needs a contract change. ## Two modes, one split `sleeveBps` is the share of every deposit that goes to the mode's own sleeve. The rest goes to the other one. Diagram: Where a deposit goes. Income keeps the non-LP share idle as collateral for a long the hedger reports on; Protected buys SGOV through a guarded pool and puts the rest in the LP vault. Leaving works the same way in both modes, along the bottom. In `Income`, the `sleeveBps` share goes into the LP vault and the rest stays in the note as idle asset, which the contract treats as collateral for a long the hedger runs off chain; nothing moves it to Lighter. In `Protected`, the `sleeveBps` share is swapped to SGOV through the asset/SGOV pool and the rest goes into the LP vault. | Note | Symbol | Mode | `sleeveBps` | LP sleeve | Second sleeve | | --- | --- | --- | --- | --- | --- | | `Levee Income NVDA` | `lvNVDAi` | `Income` | `8000` | `80 %` into `Levee NVDA Hedged` | `20 %` idle WETH, backing a reported long | | `Levee Protected SPY` | `lvSPYp` | `Protected` | `8500` | `15 %` into `Levee SPY Hedged` | `85 %` SGOV from the deepest WETH/SGOV pool | Both seed notes sit on [hedged vaults](https://levee.trade/docs/hedged-vaults) and use their ordinary `deposit` and `redeem`, so the LP vault's price check and pause apply to the note's money too: a pool off its mean or a paused hedge makes the note's deposit or exit revert as well. ### Why the Income note is not open yet The hedged vaults under both notes run: each hedges on a Lighter account owned by its own funder key, because a contract cannot bind a Lighter trading key (see [where the margin lives](https://levee.trade/docs/hedged-vaults#where-the-margin-lives)). - **Protected SPY** needs no Lighter account and no sleeve report of its own: SGOV plus a `15 %` slice in SPY Hedged. It opens at deploy with no deposit cap (`PROTECTED_NOTE_CAP` is `NO_CAP` in `LeveeWiring`). Its slice follows SPY Hedged: a paused SPY Hedged makes the note's entries and exits revert. - **Income NVDA** needs a contract change. Its long is supposed to be backed by the `20 %` kept idle in the note, but nothing moves that collateral to Lighter: `StructuredVault` has no funder and no `fundSleeve`. Opening it means a funded, bounded path like `fundHedge` into the Lighter account of a dedicated funder key (the same custody trade the hedged vaults make), and a hedger leg that trades the long and calls `reportSleeve`. The hedger does not report sleeves today. Until then the deploy creates it with `INCOME_NOTE_CAP`, `0`: `maxDeposit()` reads zero and `deposit` and `mint` revert `CapExceeded`. That value never changes, so opening the Income note means creating a new one through `VaultFactory` and pointing the manifest at it. ## What a note is worth `totalAssets()` is the idle asset, plus the note's LP shares at `lpVault.convertToAssets`, plus the second sleeve: SGOV valued at the pool's TWAP for `Protected`, or the reported sleeve equity for `Income`. A deposit checks, in order, that the note has not matured (`Matured`), that it is not paused (`HedgePaused_`) and, on a `Protected` note, that the SGOV pool passes its price check, and then that the cap, if any, has room (`CapExceeded`). `maxDeposit()` returns zero after maturity and while paused, `type(uint256).max` for a note with no cap, and the room under the cap otherwise. ## The payoff at maturity The chart draws both payoffs per unit deposited, against `s`, the underlying's price at maturity as a multiple of its price at deposit. (An interactive payoff chart is on the web page.) Both curves value the LP sleeve as `√s`, what a full-range position is worth after a price move of `s`. ```text income = sleeve × (√s + feeYield) + (1 − sleeve) × min(s, 1.5) protected = sleeve × (1 + sgovYield) + (1 − sleeve) × √s ``` In `Income`, the long stops gaining above `1.5x` (`PAYOFF_CAP_MULT_X18`); that cap is the price of the fee income. In `Protected`, the first term does not depend on `s` at all, which is where the floor comes from. The contract's `payoffCurve(spotMultiplierX18)` computes the same formulas as `packages/core/src/payoff.ts` with two pins: `feeYield` is zero, because fees already sit in the LP sleeve's share price, and `sgovYield` is `SGOV_ANNUAL_YIELD_X18` (`4.5 %` a year) accrued over the time left to maturity, so a `Protected` curve flattens toward par as the date nears. `contracts/fixtures/payoff.json` pins eight points of each curve for comparing the two. For the seed notes at the start of their 90-day term, the contract's curve gives: | Price multiple | `lvNVDAi` (80 / 20) | `lvSPYp` (85 / 15) | | --- | --- | --- | | `0.25x` | `0.450` | `0.934` | | `0.5x` | `0.666` | `0.965` | | `1x` | `1.000` | `1.009` | | `1.5x` | `1.280` | `1.043` | | `2x` | `1.431` | `1.072` | The `Protected` floor here is `0.85 × (1 + 0.045 × 90 / 365)`, about `0.859`. Note: The chart is a model The chart uses the deployed splits but adds a `6 %` fee yield to Income and gives Protected a full year of SGOV yield, `4.5 %`; a 90-day note earns about `1.1 %`, hence the lower floor in the table. And `√s` is a full-range position, while the seed notes' LP sleeve is a narrower hedged vault. The curve is the model the contract commits to, not a forecast. ## Leaving the note You can withdraw at any time. Before maturity, `EARLY_EXIT_BPS` (`50`, `0.5 %`) goes to the `FeeRouter`, because an early exit unwinds part of a sleeve at that minute's prices and the fee keeps that cost off the holders who stay. The fee is built into the ERC-4626 views: - `previewRedeem(shares)` returns the gross value less `ceil(gross × 50 / 10 000)`. - `previewWithdraw(assets)` returns the shares for `assets + ceil(assets × 50 / 9 950)`, so `withdraw(assets)` really pays `assets` and the fee comes on top. - `maxWithdraw(owner)` is `previewRedeem` of the owner's whole balance. After maturity the fee is zero and the views say so. A partial exit pays from idle asset first, then redeems LP shares and, on a `Protected` note, sells SGOV, each in proportion to the shortfall. The `Income` perp sleeve is off chain, so an exit larger than the rest of the note reverts `Shortfall`. If your shares are the whole supply, the note unwinds everything, takes the fee from the gross balance and pays you the rest; `redeem()` returns what was paid. ### Settling at maturity `settle()` unwinds everything once the date has passed: it redeems every LP share the note holds, sells all its SGOV under the price check and emits `Settled(assetsOut)`. Anyone can call it, once (`NotMatured` before the date, `AlreadySettled` after the first call). The note's own pause does not block it, because it prices no shares. It can still revert, for instance while the LP vault is paused or the SGOV pool is off its mean, and can be retried later. After settlement the note is a pile of WETH, and no exit depends on the SGOV pool or the LP vault again. ## The SGOV pool A `Protected` note buys, sells and values SGOV through one Uniswap pool, so that pool is treated as an oracle. Every deposit, every withdrawal while the note holds SGOV, and every settlement first runs [`TwapGuard.check`](https://levee.trade/docs/vaults#the-price-check) on it: at least `10 min` of history, and spot within `3 %` of the mean. `VaultFactory` grows the pool's observation ring when the note is created. The SGOV sleeve is valued at the TWAP, net of the pool fee it costs to sell, so pushing spot buys nobody a cheaper entry. Each swap's floor is `SGOV_SLIPPAGE_BPS` (`100`, `1 %`) under the TWAP quote, because a floor taken from a spot the caller just set bounds nothing. Once the note holds no SGOV, in practice after `settle`, the check is skipped. An `Income` note has no SGOV pool. ## Reporting the Income sleeve The hedger reports the `Income` sleeve with `reportSleeve(unrealizedPnlUsd, equityUsd, asOf)`. `equityUsd` is the sleeve's excess equity, what the perp has earned on top of the collateral, because the collateral never left the note and counting it again would double it. The value is unsigned: a losing sleeve reports zero, and the loss shows up only when the collateral comes back short. The gate is the [hedged vault's](https://levee.trade/docs/hedged-vaults#when-the-vault-pauses) without the notional: `NotHedger`, `StaleReport`, the jump test against the previous stored equity, and a pause on a jump or on a report older than `maxHedgeAge`. A paused note reverts `deposit`, `mint`, `withdraw` and `redeem` with `HedgePaused_`. On a `Protected` note the call reverts `BadSleeve`, so the hedger key holds no pause over it. Reports are still accepted after maturity, while holders remain. The equity converts to WETH through `assetUsdFeed` (ETH/USD in the seed notes), or one for one when the feed is unset for a 6-decimal USD asset; a bad or stale feed values the sleeve at zero. Nothing in the repository calls `reportSleeve` yet, so a report is an operator action with the hedger key, and a note never reported on never pauses and never counts a sleeve. ## What "protected" does not cover Warning: The floor is not a guarantee The floor assumes the SGOV sleeve can be sold for what SGOV is worth, that the WETH/SGOV pool has depth on the day, and that the token tracks the fund it is named after. Nobody underwrites any of that. `SGOV_SLIPPAGE_BPS` bounds each sale; it does not promise the sale clears. The price check that protects a sale from manipulation also stops it, and every exit with it, while the pool is off its mean. The floor also leaves out the LP sleeve, `15 %` of the seed note, which falls with `√s`. And the note is measured in WETH, so it is protected in ETH terms, not in dollars. ## Reference ### Settings Parameters: StructuredVault.Init - set once by VaultFactory.createStructuredVault - `asset` (allowed: WETH or USDG; default: WETH): Must equal lpVault.asset(). - `mode` (allowed: Income or Protected): Which second sleeve the note runs. - `sleeveBps` (allowed: 1 - 9999 bps; default: 8000 / 8500): Share of each deposit for the mode’s own sleeve: the LP vault for Income, SGOV for Protected. - `maturity` (allowed: in the future unix s; default: deploy + 90 days): After it: no deposits, no fee, settle allowed. - `lpVault` (allowed: LPVault or HedgedLPVault): The LP sleeve. - `sgov / sgovPool` (allowed: token / asset-SGOV pool): Protected only. - `assetUsdFeed` (allowed: Chainlink feed or zero; default: ETH/USD): Converts reported sleeve equity; zero means 1:1 for a USD asset. - `hedger` (allowed: address): The only caller of reportSleeve. - `cap` (allowed: asset units, or type(uint256).max; default: NO_CAP / 0): Deposit cap. Cannot change. Protected SPY has none (NO_CAP); Income NVDA deploys at 0 and takes no deposits. - `maxHedgeAge` (allowed: uint32 seconds; default: 1 h): Report age after which the note pauses. - `maxEquityJumpBps` (allowed: uint16 bps; default: 1500): Largest equity move one report may make without pausing. `sleeveConfig()` returns the live staleness and jump bounds, and `sleeveState()` the last report and whether the note is paused. ### Reverts and events | Revert | Raised by | Meaning | | --- | --- | --- | | `Matured` | `initialize`, `deposit`, `mint` | A maturity not in the future, or a deposit on or after the date. | | `HedgePaused_` | `deposit`, `mint`, `withdraw`, `redeem` | The Income sleeve report jumped or went stale. | | `CapExceeded`, `ZeroShares` | `deposit`, `mint` | Over the cap, or no shares to mint. | | `Shortfall` | `withdraw`, `redeem` | The on-chain sleeves cannot raise the amount. | | `NotMatured`, `AlreadySettled` | `settle` | Before the date, or a second call. | | `NotHedger`, `StaleReport` | `reportSleeve` | Wrong caller, or an `asOf` in the future or not after the last report. | | `BadSleeve` | `initialize`, `reportSleeve` | A `sleeveBps` of 0 or 10 000 and up, or a report on a Protected note. | | `NotAsset`, `AlreadyInitialized` | `initialize` | The LP vault or SGOV pool does not match the asset, or a second initialisation. | | `TwapWindowTooShort`, `PriceDeviation` | Protected entry, exit, `settle` | The SGOV pool failed its price check. | Events: `CapUpdated(cap)` once, `SleeveReported(unrealizedPnlUsd, equityUsd, asOf)`, `HedgePaused(reason)` and `HedgeResumed()` (the hedged vault's signatures, so the indexer tracks every vault's pause from one pair), `Settled(assetsOut)`, and ERC-4626 `Deposit` and `Withdraw`, which reports the net paid. The factory's `VaultCreated` has `kind` `2` and the SGOV pool as `pool`, zero for an Income note. Where it lives: - `contracts/src/StructuredVault.sol`: _deposit, _withdraw, _raise, settle, reportSleeve, payoffCurve, _requireOpen - `packages/core/src/payoff.ts`: incomePayoff, protectedPayoff and the full-range approximation - `contracts/fixtures/payoff.json`: eight pinned points per curve - `contracts/script/LeveeWiring.sol`: INCOME_SLEEVE_BPS, PROTECTED_SLEEVE_BPS, NOTE_TERM, PROTECTED_NOTE_CAP, INCOME_NOTE_CAP and the two seed notes - `apps/web/components/docs/PayoffDemo.tsx`: the chart in this chapter - `apps/web/lib/earn/view.ts`: payoffPointsFor, withdrawFeeNote, countdown - `contracts/test/StructuredVault.t.sol`: both splits, the report gate, the SGOV guard, the fee before and after maturity, settle --- # Levee pools Source: https://levee.trade/docs/pools · Pools DAMM, DLMM and stock pools: how each prices a swap, what it charges, and how bin orders rest. Levee pools are the venue Levee runs itself on Robinhood Chain. DAMM pools and stock pools are Uniswap v4 pools behind a Levee hook; DLMM pairs keep liquidity in fixed-price bins. Anyone can open a pool or provide to one, and how a swap is routed across them is in [Swaps and routing](https://levee.trade/docs/swaps). In brief: - Every swap fee is split: 2 000 bps of it (20 %, `ProtocolConfig.protocolFeeShareBps`) goes to the `FeeCollector`, the rest to liquidity providers. The owner can raise the share to 5 000 bps. - A DAMM fee is a 5-100 bps base, plus a surcharge for recent price movement, capped at 500 bps outside an anti-snipe window. - A stock pool must end each swap inside a Chainlink band, ± 200 bps while the market is open and ± 50 bps otherwise at deploy, unless the swap moved the price toward the oracle. - Only you can take your liquidity out. The venue pause stops swaps and deposits, never a withdrawal. - A DLMM limit order must be executed once the price has crossed its bin, or it trades back. ## Choosing a pool type | Pool | Price moves | Fee per swap | Good for | | --- | --- | --- | --- | | DAMM | along v4 ticks | base fee, optional anti-snipe schedule, volatility surcharge | new tokens, volatile pairs | | DLMM | bin to bin, fixed inside a bin | bin-step preset plus a volatility term | shaped liquidity, limit orders | | Stock | v4 ticks inside an oracle band | set by the market session | tokenized stocks | Each is opened in one permissionless transaction from the create form on the Pools page: `DammHook.createPool`, `DlmmFactory.createPair` or `StockHook.createPool`. ## Where each swap fee goes A hook works out the total fee, gives the LP part to the pool as that swap's LP fee, and charges the protocol part on the input token: in `beforeSwap` for exact-input swaps, in `afterSwap` for exact-output ones. It is paid straight to the `FeeCollector`, or held as an ERC-6909 claim that anyone can `sweep` there when the `PoolManager` is short of that token mid-swap. A DLMM pair charges its fee in each bin it crosses, sends the protocol share to the `FeeCollector` at the end of the swap and leaves the rest in the bins. The share is read on every swap. What the `FeeCollector` does with it is in [Buyback and burn](https://levee.trade/docs/protocol). ## DAMM pools The fee in pips (100 pips = 1 bp) has three parts: 1. **Schedule.** `baseFeeBps`, or during an anti-snipe window a fee that falls in a straight line from `snipeStartFeeBps` to the base over `snipeSeconds`. 2. **Surcharge.** `variableFeeControl × vol² / 100 000`, where `vol` adds up the ticks recent swaps moved the price and decays to zero over 600 s. At a control of 10 000, 100 ticks of movement adds 10 bps. 3. **Cap.** 500 bps, or the schedule itself while it is higher. `currentFee(id)` returns the LP part and the total right now. `createPool(key, sqrtPriceX96, params)` needs the dynamic-fee flag and the `DammHook` in the key; the form offers tick spacings 10, 60 and 200. Launched tokens use the same pools, see [Launch a token](https://levee.trade/docs/launch). Parameters: DammHook.createPool parameters - `baseFeeBps` (allowed: 5 - 100 bps; default: 30 in the form): The fee outside any anti-snipe window. - `snipeStartFeeBps` (allowed: base - 5 000 bps; default: 2 000 in the form): Where the window starts. Ignored when snipeSeconds is 0. - `snipeSeconds` (allowed: 0 - 3 600 s; default: off, or 600): Length of the window. 0 means none. - `variableFeeControl` (allowed: uint32; default: 10 000 in the form): Scales the surcharge. 0 switches it off. ## DLMM pairs Bin `id` trades at `(1 + binStep / 10 000)^(id − 2^23)` of token Y per token X, in raw units. Inside a bin the price is constant, so a swap fills at exactly that price until the bin is empty, then moves on to the next bin with liquidity. Diagram: Bins on either side of the active one. Token X sits in the active bin and above it, token Y in the active bin and below it. A deposit spreads across a range of bins in one of three shapes; a limit order sits in a single bin past the active one. `createPair(tokenX, tokenY, binStep, activeId)` makes one pair per token pair and bin step, ERC-20 only (use WETH for ETH). A pair copies its bin step's fee preset when it is created: | Bin step (bps) | 1 | 5 | 10 | 25 | 50 | 100 | | --- | --- | --- | --- | --- | --- | --- | | Base fee (bps) | 2 | 5 | 10 | 20 | 40 | 80 | A variable fee grows with the square of how far, in bins, the price has recently travelled. With the default presets it tops out near 1 %, and the pair caps the total at 10 %. ### Providing to a pair `DlmmPositionNFT.mint` gives you one ERC-721 over a range of bins, with a weight per bin. The pool page builds the weights from a shape: **Spot** spreads evenly, **Curve** concentrates near the active bin, **Bid-Ask** weights the edges to buy dips and sell rallies. The deposit is guarded: it reverts if the active bin has moved more than `ceil(50 / binStep)` bins (at least 1), if less than 99.5 % of what you typed lands, or after the deadline. A deposit into the active bin with a different X/Y mix pays the swap fee on the part that amounts to a swap. Fees accrue into the bins. `decrease` withdraws a share of every bin and `burn` all of it; both take minimum amounts and never check the pause. ## Stock pools A stock pool pairs an `AssetRegistry` asset with USDG (priced at $1), native ETH or WETH (both priced by Chainlink ETH/USD). `createPool(key, 0)` starts it at the oracle price. Before every swap the hook reads the session's fee and band; these are the deploy values, tunable per asset by the owner up to a 1 000 bps fee: | Session | When | Fee | Band | | --- | --- | --- | --- | | Open | Monday to Friday, 13:30-20:00 UTC, except holidays | 30 bps | ± 200 bps | | Closed | any other time | 150 bps | ± 50 bps | | Stale | the asset or quote feed is over 25 h old | 500 bps | ± 50 bps | The owner shifts the hours for daylight saving and marks holidays. After a swap the price must sit inside the band, or be strictly closer to the oracle than before, so anyone can pull a drifted pool back. While the feed is stale only such toward-oracle swaps pass (`PriceOutOfBand`, `StaleAwayFromOracle`). `oracleState(id)` returns what the Stocks page shows. ## Limit orders in a bin `DlmmLimitOrders.place(pair, id, sellX, amount, to, deadline)` rests your tokens in one bin past the active one: X above it to sell, Y below it to buy, `WrongSide` otherwise. Orders in the same bin and side share a batch. As the price moves through the bin the order fills at that bin's exact price and earns swap fees. Once the price has crossed the whole bin, anyone may `execute` the batch. Levee's limit-order bot executes every batch `readyBooks()` lists, and `claim` executes a filled batch itself. Before execution, `cancel` returns whatever the bin holds for you. The contract has no owner and no fee, takes and returns ETH for a WETH side, and only `place` needs the venue unpaused. Uniswap range orders are a separate product, see [Limit orders](https://levee.trade/docs/range-orders). Warning: Execute a filled order promptly A filled batch still sits in the bin. If the price crosses back before it is executed, the order trades back into the token you started with. The bot acts on its next tick and retries a failed batch after 60 s; you can call `execute` yourself. ## Risks - Liquidity is exposed to the pair's price, and bins or ranges the price has left earn nothing. - Outside the session a stock's oracle stops moving, so a pool can sit far from where the stock reopens. - The `ProtocolConfig` owner can pause swaps and deposits, raise the protocol share, change presets for new pairs and change a stock's feed, fees, band and hours. It cannot move liquidity; see [Security model](https://levee.trade/docs/security). ## Reference | Error | When | | --- | --- | | `OnlyViaCreatePool`, `NotDynamicFee`, `WrongHook` | a hooked pool initialised outside `createPool`, or a key with the wrong fee or hook | | `BadParams` | a DAMM parameter out of range | | `Paused`, `DlmmPair__Paused` | a swap or deposit while the venue is paused | | `NotRegisteredPair` | a stock pool for an asset or quote the registry does not list | | `DlmmFactory__BinStepNotAllowed`, `DlmmFactory__PairExists` | no preset for the bin step, or the pair exists | | `DlmmPair__OutOfLiquidity` | a swap that runs out of bins | | `DlmmPair__ZeroShares` | a bin in the deposit range would get nothing | | `DlmmPositionNFT__ActiveIdSlippage`, `DlmmPositionNFT__AmountSlippage` | the deposit guard | | `DlmmLimitOrders__NotFilled`, `DlmmLimitOrders__AlreadyExecuted` | an execute before the bin is crossed, a cancel after execution | Where it lives: - `contracts/src/venue/hooks/LeveeHookBase.sol`: the fee split, the protocol charge, sweep - `contracts/src/venue/hooks/DammHook.sol`: createPool, the schedule, the surcharge and the cap - `contracts/src/venue/hooks/StockHook.sol`: the oracle start price, the band and the toward-oracle rule - `contracts/src/venue/core/AssetRegistry.sol`: assets, quotes, sessions and riskParams - `contracts/src/venue/core/ProtocolConfig.sol`: the protocol share, the fee collector, the pause - `contracts/src/venue/dlmm/DlmmFactory.sol`: createPair and the presets - `contracts/src/venue/dlmm/DlmmPair.sol`: bins, swap, mint, burn, _feeRate - `contracts/src/venue/dlmm/DlmmPositionNFT.sol`: the position NFT and its deposit guard - `contracts/src/venue/periphery/DlmmLimitOrders.sol`: place, execute, claim, cancel - `contracts/script/venue/VenueWiring.sol`: deploy-time stock fees and bands - `packages/core/src/dlmm.ts`: bin prices, shapes and their weights --- # One-token deposits Source: https://levee.trade/docs/zap · Pools Enter a range with a single token: the swap it makes for you, the checks on it, and the refund. A concentrated position needs both tokens of a pair, in a ratio set by where the price sits inside your range. A one-token deposit does the balancing for you. You send one token or ETH, and Levee's zap contract sells the share your range does not want on the same pool, mints the position to your wallet and sends back whatever is left, all in one transaction. You reach it from the Liquidity mode of the ticket on the Trade page; it works with Uniswap v3 pools (`LeveeZapV3`) and Uniswap v4 pools (`LeveeZapV4`). In brief: - The zap charges 25 bps of what you send, in that token. The pool's own fee applies to the part that is swapped. - One slippage figure (default 100 bps) limits both the swap's output and how far the mint may stray from the balanced amounts. A separate floor, `minLiquidity`, is the least liquidity you accept. - Everything is priced at the pool's spot price, not a TWAP. The app will not build the transaction until the pool's price agrees with the quoted price within your slippage. - Leftovers go back to the wallet that paid, in the same transaction. The contract keeps no balance between calls and has no owner, admin or upgrade path. - A large deposit into a narrow range can revert at the default slippage. Widen the range, deposit less, or raise the slippage. ## One transaction, start to finish You choose the token, the amount and a range. The app turns the range into two ticks, shows the expected split and sends `zapMint`. The contract then runs these steps in order and reverts at the first one that fails, so a failed deposit costs gas and nothing else. 1. Check the request: A passed `deadline` is `Deadline`, a zero `amountIn` is `ZeroAmount`, and a `tickLower` that is not below `tickUpper` is `BadRange`. On v3 the pool is looked up with `factory.getPool(token0, token1, fee)`, in either token order; no pool is `NoPool`. 2. Take the input: An ERC-20 is pulled with `transferFrom`, so the zap needs an approval first; the app asks for exactly the amount. On v3, ETH must arrive as `msg.value` equal to `amountIn` and is wrapped to WETH, which must be one of the pool's tokens. A token outside the pool, or ETH sent alongside an ERC-20, is `BadTokenIn`. 3. Take the fee: `ZAP_FEE_BPS` (25) of `amountIn` goes to the `FeeRouter`, booked against your address so that a referrer you have bound earns 20 % of it. The rest is what gets deposited. 4. Size the swap: `ZapMath` reads the price from `slot0`, decides which token the range has too much of and how much of it to sell. If the answer is zero, the swap is skipped. 5. Swap on the same pool: One `exactInputSingle` on the pool you are entering, with a minimum output `maxSlippageBps` below the spot quote. The router's allowance is set to the exact amount and cleared straight after. 6. Mint to you: `npm.mint` offers the balanced amounts, accepts as little as `maxSlippageBps` below each, and makes `recipient` the owner of the new NFT. 7. Check the result: Less liquidity than `minLiquidity` is `Slippage`. 8. Return the rest: The contract reads its own balance of both tokens and sends all of it to `msg.sender`. On v3, if you paid in ETH, the WETH side is unwrapped first; a wallet that refuses ETH gets `NativeTransferFailed`. Last, `ZapMint` is emitted. Diagram: A one-token deposit, step by step. Everything happens in one transaction, and any revert undoes all of it. The swap and the mint are each checked against your slippage figure, and the result against minLiquidity. `LeveeZapV3.zapIncrease` adds to a v3 position you already hold by the same steps. It reads the pool and the ticks from the position itself, and only the owner, the address approved for that NFT, or an operator may call it (`NotOwner` otherwise). The app does not use it today. ## How the swap is sized Two cases need no search. If your range sits wholly above the price, the position holds only token0, so all token1 is sold; if it sits wholly below, the position holds only token1, so all token0 is sold. When the price is inside the range, the position needs both. Selling more of the excess token lowers the liquidity that leg can support and raises what the other leg can support, and the mint gets the smaller of the two, so the best amount is where they meet. `ZapMath.optimalSwapAmountFrom` finds it with a binary search of 32 halvings (`ITERATIONS`), which pins the amount to better than one part in four billion. The search assumes the whole swap fills at the current price, less the pool fee: `amount × (1 − fee) × price`. A real swap moves the price, so it returns slightly less, the two legs end up slightly out of balance, and the mint leaves a remainder behind. That remainder is the refund in the last step; it comes back in whichever token it sits in and is not swapped again. The Liquidity mode shows its expected size as a share of your input. `previewZap` is a view that returns the exact swap amount `zapMint` would use against the current pool state, with the holdings the model expects after it. The app computes the same split locally with `zapPreview` from `@levee/core`, which is tested against fixtures generated from the Solidity library (`contracts/fixtures/zap.json`). ## Picking a range The contract only sees two ticks. The shapes in the Liquidity mode come from `@levee/core` (`shapes.ts`) and are turned into ticks before anything is signed: | Shape | Band around the current price | | --- | --- | | `SPOT` | 5 % below to 5 % above | | `CURVE` | 25 % below to 25 % above | | `WIDE` | 50 % below to 50 % above | | Custom | the prices you type or drag | For a preset, each edge becomes a tick with `ln(1 ± pct) / ln 1.0001` and is rounded outward to the pool's tick spacing, down for the lower tick and up for the upper, so the band covers at least what the preset promises. Ticks are logarithmic, so ±5 % is about 488 ticks up and 513 down. Custom prices snap to the nearest usable tick instead. Either way the range keeps at least one spacing of width. Diagram: From a shape to two ticks. A preset is a band around the current tick. For a stock token it doubles while the NYSE is closed, then each edge is rounded outward to the pool spacing. Custom prices snap to the nearest tick. The contract receives only the two ticks. For a tokenized stock, presets double both edges (`AFTER_HOURS_MULT` = 2) while the NYSE is closed: outside 09:30 to 16:00 New York time on weekdays, after 13:00 on early-close days, and on the exchange holidays listed for 2026 and 2027. The panel then reads "Widened after hours". The stock token keeps trading on chain while its reference price stands still, then jumps at the next open; a band sized for the trading day would be left behind. Custom ranges are never widened. ## The three checks on your deposit `maxSlippageBps` drives two of the three checks, and they measure different things. - **Swap output.** The router refuses to fill below the spot quote less `maxSlippageBps`. This bounds what your own swap loses to price impact. On v3 it surfaces as the router's "Too little received", on v4 as `Slippage`. - **Mint amounts.** On v3 the mint must take at least the balanced amounts less `maxSlippageBps`; the position manager reverts with "Price slippage check" otherwise. On v4 the same figure caps what the mint may take (see below). This measures how far the holdings sit from the ratio the range wants after the swap has moved the price, and it tightens as the range narrows. - **Liquidity floor.** `minLiquidity` is what the whole deposit must produce. The app sets it to the previewed liquidity less your slippage. Like the contract, the preview takes the 25 bps fee off your input before it splits it, so the whole margin is left for the price moving. Both zaps tie the mint check to the swap figure. A large deposit into a narrow range near the edge of the price can therefore move the price far enough that the mint check fails at 1 %. If that happens, widen the range, deposit less or raise the slippage. Levee's vaults, which mint into narrow ranges for you, keep a separate mint tolerance of 500 bps (`MINT_RATIO_TOLERANCE_BPS`). Both zaps refuse a slippage above 5 000 bps (`MAX_SLIPPAGE_BPS`) with `SlippageTooHigh`, so no call can switch the two checks off. In the app the default is 100 bps (`DEFAULT_SLIPPAGE_BPS.zap`) with presets of 50, 100 and 200. Above 500 bps (`MAX_SLIPPAGE_WITHOUT_ACK_BPS`) you must acknowledge the risk before the button unlocks. The app sets the deadline 600 s ahead. Warning: Spot price only The zap has no TWAP guard. Its swap and mint checks are computed from the pool's `slot0` at the moment your transaction runs, and someone may have moved that price; only `minLiquidity` is fixed when you sign. Before it builds the transaction, the Liquidity mode reads the pool's own `slot0` and refuses while it differs from the quoted price by more than your slippage (`poolWriteBlock` in `lib/trade/poolState.ts`), so a pool that is already off is caught before your wallet opens. If you call the contract directly, derive `minLiquidity` from a price you trust. [Position automation](https://levee.trade/docs/keeper), which moves other people's funds, does run behind a TWAP guard. ## Cost You pay 25 bps of the input, in the input token, and nothing on the way out. The `FeeRouter` passes 20 % of it to your referrer if you have bound a code; the rest goes to the `FeeCollector`, which turns it into ETH for the $LEVEE buyback (see [Fees and referrals](https://levee.trade/docs/fees)). The pool charges its usual fee on the swapped part only: on a 0.30 % pool, a deposit that swaps half its input pays about 0.15 % of the input to the pool. ## On Uniswap v4 `LeveeZapV4.zapMint` gives the same result through v4's plumbing: one `PoolManager` for every pool, swaps inside an `unlock` callback, and a `PositionManager` that pulls ERC-20s through Permit2. Diagram: The v4 path: unlock, then one batch. On v4 the swap happens inside the pool manager's unlock callback, and the mint is a single PositionManager batch: MINT_POSITION and SETTLE_PAIR, plus SWEEP when one side is native ETH. Dashed: the standing Permit2 approvals the batch draws on. - **The pool** is named by its full `PoolKey` (currencies, fee, tick spacing, hooks), and `hookData` is passed to the hooks on both the swap and the mint. The zap does not look the pool up; a key for a pool that does not exist fails inside Uniswap. - **ETH** is never wrapped. It is accepted only when one side of the pool is native ETH, and its fee goes through `feeRouter.takeNative`. - **The fee the model uses** comes from `slot0` when the pool has a dynamic fee, and from the key otherwise. - **The swap** runs inside `poolManager.unlock`. The callback only answers the pool manager (`NotPoolManager`), sells exact input at the widest price limit, and reverts `Slippage` if the output is below the minimum. - **The mint** is a `MINT_POSITION` then `SETTLE_PAIR` batch, plus `SWEEP` when one side is native so unspent ETH comes back. v4 mints a liquidity figure against maximum amounts, so the tolerance works upward: each maximum is the amount that liquidity needs plus `maxSlippageBps` and 1 wei, never more than the contract holds. Holdings that support no liquidity revert `Slippage`. - **Approvals.** `approveOnce(token)` sets a standing ERC-20 approval to Permit2 and a Permit2 allowance to the position manager. Anyone may call it, and calling it twice changes nothing. Standing approvals are safe here only because the contract never holds a balance between calls. The v3 zap sets every allowance per call and clears it afterwards. - **No top-up.** There is no `zapIncrease` on v4. The contract's empty balance is checked by invariant tests: `invariant_zapHoldsNothing` and `invariant_zapKeepsNoStandingApprovals` for v3, `invariant_zapV4HoldsNothing` for v4. ## Reference ### Call parameters Parameters: ZapMintParams (LeveeZapV3.zapMint) - `tokenIn` (allowed: token0, token1 or address(0)): The token you send. address(0) is ETH, wrapped to WETH on the way in. - `amountIn` (allowed: above 0 wei of tokenIn): Before the fee. Must equal msg.value when paying in ETH. - `token0, token1` (allowed: the pool tokens, any order): With fee, find the pool through the factory. - `fee` (allowed: 100 · 500 · 3000 · 10000 hundredths of a bip): The pool fee tier, also paid on the swap. - `tickLower, tickUpper` (allowed: lower below upper, on the spacing ticks): The range. Ticks off the spacing are rejected by the position manager. - `minLiquidity` (allowed: 0 or more liquidity; default: preview less slippage): The least liquidity the mint may produce. - `maxSlippageBps` (allowed: 0 - 10 000 bps; default: 100): Bound on the swap output and on the mint amounts. 10 000 turns both off. - `deadline` (allowed: unix seconds; default: now + 600): Latest block time; also passed to the position manager. - `recipient` (allowed: any address): Owner of the new NFT. Leftovers go to msg.sender, not here. `ZapIncreaseParams` keeps only what the position does not already fix: `tokenId`, `tokenIn`, `amountIn`, `minLiquidity`, `maxSlippageBps`, `deadline`. `ZapMintV4Params` replaces `token0`, `token1` and `fee` with `key` (the `PoolKey`) and adds `hookData`. ### Reverts | Revert | Cause | | --- | --- | | `Deadline` | The block is later than `deadline`. | | `ZeroAmount` | `amountIn` is zero, or ETH was chosen and `msg.value` differs from `amountIn`. | | `BadRange` | `tickLower` is not below `tickUpper`. | | `NoPool` | v3: no pool for the pair and fee. | | `BadTokenIn` | `tokenIn` is not a pool token, or ETH came with an ERC-20 deposit. | | `Slippage` | Less liquidity than `minLiquidity`. On v4 also a swap below its minimum, or holdings that support no liquidity. | | `NotOwner` | v3 `zapIncrease` from an address that is not the owner, approved or an operator. | | `NativeTransferFailed` | Your wallet refused the ETH refund. | | `NotPoolManager` | v4: the unlock callback was called by anything but the pool manager. | Two more come from Uniswap: the router's "Too little received" when the swap check fails, and the position manager's "Price slippage check" when the mint check fails. The app simulates every write before it opens your wallet, so a deposit that would revert normally stops there, at no cost. ### Emitted events `ZapMint(recipient, tokenId, liquidity, amount0, amount1, feePaid)` on v3 and `ZapMintV4` with the same fields on v4; `ZapIncrease(tokenId, liquidity, amount0, amount1, feePaid)` for a top-up. `amount0` and `amount1` are what the mint took, not what you sent, and `feePaid` is the 25 bps in the input token. The indexer does not watch the zap contracts. A zapped position reaches Portfolio through Uniswap's own position events, like any other position. Where it lives: - `contracts/src/LeveeZapV3.sol`: zapMint, zapIncrease, previewZap, the intake and the refund - `contracts/src/LeveeZapV4.sol`: zapMint, approveOnce, unlockCallback and the PositionManager batch - `contracts/src/libraries/ZapExec.sol`: balance, balanceAndMint, balanceAndIncrease and floorBps: the swap and mint checks - `contracts/src/libraries/ZapMath.sol`: optimalSwapAmountFrom, the 32-step search and quoteAtSpot - `packages/core/src/zapMath.ts`: zapPreview, the TypeScript port the app previews with - `packages/core/src/shapes.ts`: SHAPE_PCT, rangeForPct and the after-hours widening - `packages/core/src/slippage.ts`: DEFAULT_SLIPPAGE_BPS, MAX_SLIPPAGE_WITHOUT_ACK_BPS, minOut - `apps/web/lib/trade/provide.ts`: rangeForProvide, rangeFromPrices and providePreview - `contracts/test/invariants/ZapHoldsNothing.t.sol`: the v3 holds-nothing and no-standing-approvals invariants - `contracts/test/invariants/ZapV4HoldsNothing.t.sol`: the v4 holds-nothing invariant --- # Position automation Source: https://levee.trade/docs/keeper · Pools Let anyone harvest or re-centre your position under limits only you set, for a bounty you choose. A concentrated position earns only while the price is inside its range, and its fees sit in the position until someone collects them. `PositionKeeper` lets anyone do both chores on a Uniswap v3 position you own: `harvest` collects the fees, and `rebalance` moves the position to a fresh range around the current price. You set the rules and the reward for whoever does the work; the NFT never leaves your wallet. You enrol a position from Portfolio. In brief: - No custody. The keeper acts through an operator approval you can revoke at any time, and a rebalance mints the new position straight to you. The contract has no owner, admin or upgrade path. - Every cut comes from earned fees: 10 % to the protocol and your chosen bounty (at most 300 bps) to the caller. Principal is never cut. - A rebalance runs only when spot is within 3 % of the 30-minute TWAP, and only if the round trip keeps your value within your slippage, measured at that TWAP. After a sharp move it can wait up to half an hour. - If the NFT changes hands, the enrolment stops working and every action reverts. A new owner enrols again. - A narrow width with a tight slippage may fail to mint, and then the rebalance reverts. ## Enrolling a position 1. Approve the keeper: Call `setApprovalForAll(positionKeeper, true)` on the Uniswap v3 position manager. This operator approval covers every v3 position you own, not just the one you enrol; the keeper only touches enrolled ids. Approving a single NFT is not enough: `enroll` checks for the operator approval and reverts `NotApproved` without it. 2. Enrol with a policy: `enroll(tokenId, policy)` from the NFT's owner (`NotOwner` otherwise). The policy is validated, the enrolment records you as owner and starts its clock, so the first action can come no sooner than `minInterval` later. Enrolling an id again replaces its enrolment and restarts the clock. 3. Change or stop: `updatePolicy` swaps the rules without resetting the clock. `unenroll` stops all actions at once. Both are owner-only. Revoking the operator approval also stops the keeper, with a less readable revert. On Portfolio, the Enroll in keeper drawer offers both steps and skips the first when the approval exists; Harvest, Rebalance now and Unenroll buttons sit beside your positions. Between calls the keeper holds no NFT and no tokens, and it pays only the enrolment's owner, the caller and the `FeeRouter`. ## Your rules The `Policy` struct is the same one Levee's vaults run under, and `@levee/core` mirrors it field for field in `policy.ts`. Parameters: Policy, with the Enroll drawer's defaults - `widthTicks` (allowed: above 0, a multiple of the pool tick spacing ticks; default: 1200): Total width of the range a rebalance mints. Presets: 1000, 4500 and 8100 for SPOT, CURVE and WIDE, rounded to the nearest multiple of the spacing. - `hysteresisTicks` (allowed: 0 or more ticks; default: 120): How far beyond the range edge the tick must be before a rebalance is allowed. - `minInterval` (allowed: 0 or more seconds; default: 3600): Time since enrolment or the last action before the next one. - `maxSlippageBps` (allowed: 0 - 9 999 (app: 1 - 500) bps; default: 100): Bounds the swap, the mint ratio and the rebalance round trip. - `compound` (allowed: true · false; default: true): Harvest puts your share back into the position, or pays it to you. - `bountyBps` (allowed: 0 - 300 bps of fees collected; default: 50): Paid to whoever calls harvest or rebalance. `enroll` and `updatePolicy` reject a bounty above `MAX_BOUNTY_BPS` (300), a slippage of 10 000 or more, negative hysteresis, and a width that is zero, negative or off the pool's spacing, all as `BadPolicy`. The spacing comes from the position's own pool, so the Enroll drawer rounds each preset to the nearest multiple of it: SPOT becomes 1020 on a 0.30 % pool (spacing 60), CURVE and WIDE become 4600 and 8200 on a 1 % pool (spacing 200). A width you type off the grid is flagged before you sign. Hysteresis keeps a position that hovers on its edge from being rebalanced again and again, each time paying a bounty and a swap. Nothing but `minInterval` stops a caller from harvesting crumbs repeatedly to collect bounties. The contract will harvest a single wei once the interval is up, so choose an interval that makes each harvest worth having. ## What every action checks first `harvest` and `rebalance` share three gates. The enrolment must be active (`NotEnrolled`), `minInterval` must have passed (`TooSoon`), and the position's current `ownerOf` must still be the address that enrolled it (`OwnerChanged`). Diagram: Checks before a keeper action. Both actions pass the same three gates. A harvest then splits the collected fees. A rebalance checks the TWAP before unwinding, swaps under the router bound, and compares value at the TWAP before and after. Two views answer the same questions without gas. `shouldHarvest` adds "are there any uncollected fees", including fees already owed to the position. `shouldRebalance` adds non-zero liquidity, a tick outside the range by the hysteresis, and a passing TWAP check, so a bot is never offered a rebalance the guard would refuse. ## Collecting fees `harvest` moves the clock first, then collects every fee the position has earned and none of its liquidity. Each token is split on its own: - 10 % (`PROTOCOL_FEE_BPS` = 1000) to the `FeeRouter`, booked to you so that your referrer earns 20 % of it; - `bountyBps` to the caller; - the rest is yours. With `compound` on, your share is swapped to the range's ratio on the same pool and added back with `increaseLiquidity` (`ZapExec.balanceAndIncrease`); whatever the ratio could not take is sent to you. With it off, your share is sent to you as collected. `Harvested` reports the totals before the split, the two bounties and whether it compounded. Diagram: Where harvested fees go. Each cut is a share of the fees collected, never of the position. What is left goes back into the position or to the owner, as the policy says. Compounding is bounded only by `maxSlippageBps`, on the swap and on the add. A harvest has no TWAP check: one interval's fees are at stake, and a guard that refused on every real move would cost more than it saved. ## Re-centring a position 1. Confirm it is out of range: The tick must be below `tickLower − hysteresisTicks` or at or above `tickUpper + hysteresisTicks` (`Ranges.outOfRange`), or `InRange`. The upper test includes equality because a Uniswap range stops short of `tickUpper`. 2. Check the price: `TwapGuard.check`: spot within 300 bps of the TWAP, or `PriceDeviation`; at least 10 minutes of oracle history, or `TwapWindowTooShort`. The TWAP is kept for the value check. 3. Unwind: All liquidity is removed, everything collected and the old NFT burned. The fee portion is split exactly as in a harvest; the principal is not. What remains is valued in token1 at the TWAP. 4. New range: `Ranges.centred` puts the lower edge about half the width below the current tick, on the spacing, and the upper edge exactly `widthTicks` above it, so the price always sits inside. Both edges stay within the widest ticks the spacing allows. 5. Swap and mint to you: `ZapExec.balanceAndMint` sells the excess side on the same pool and mints the new position with you as owner. Anything left over is sent to you. 6. Value after: The minted amounts plus what was sent back, valued at the same TWAP, must be at least the value before less `maxSlippageBps`. Otherwise the whole transaction reverts with `Slippage`, unwind included. 7. Move the enrolment: Your policy is copied to the new token id with a fresh clock and the old enrolment is deleted. Your operator approval already covers the new NFT. `Rebalanced` names the old id, the new id, the caller and the new ticks. ## Price protection on a rebalance Rebalancing hands a stranger a swap of your principal. Three checks stand between that and a bad price. **Before anything moves**, the TWAP guard refuses while spot is more than 3 % from the mean price over the last 30 minutes, or over the history there is if that is shorter (`TWAP_WINDOW`, `MAX_TWAP_DEVIATION_BPS`). Without it, someone could push the pool, knock your position out of range and rebalance it at the price they created. The guard also needs at least 10 minutes of history (`MIN_TWAP_WINDOW`). A fresh pool's oracle keeps a single observation that every new block overwrites, so its "average" would just be the latest swap. Anyone can deepen it with Uniswap's `increaseObservationCardinalityNext`; 60 slots (`MIN_OBS_CARDINALITY`) cover 10 minutes on a pool that writes less than once every ten seconds. Levee's vault factory does this for its vault pools; the keeper cannot, because it does not know which pools you will enrol. **During the swap**, the router refuses to fill below the spot quote less `maxSlippageBps`, which bounds the swap's own price impact. **After the mint**, the value check compares the round trip at the TWAP, not at spot. Valuing at spot would use the very price the swap filled at, so a manipulated spot would look fine. Inside the 3 % band a wrong spot can still cost you up to half the gap: the tests move spot about 2.7 % above the mean and show a loss near 1.35 % that only a TWAP valuation sees. The same check also covers rebalances that needed no swap, or that minted far less than they were offered. The price of this is delay: after a genuine move, spot runs ahead of the 30-minute mean, so a position can take up to half an hour to qualify, and `shouldRebalance` says no until then. Warning: Narrow widths and tight slippage The keeper also uses `maxSlippageBps` as the mint's ratio tolerance, as the [one-token deposit](https://levee.trade/docs/zap) does. A narrow range may not mint inside a tight tolerance (the tests note that a 400-tick range cannot mint inside 1 %), and then every rebalance reverts. Widen `widthTicks` or raise `maxSlippageBps`, knowing that a higher slippage also loosens the swap and value checks. ## If you sell the position An enrolment pays the address that created it. If you transfer an enrolled NFT without unenrolling, and the new owner has also approved the keeper, a harvest would send their fees to you and a rebalance would mint their whole position to you. So both actions compare the live `ownerOf` with the enrolment and revert `OwnerChanged` on a mismatch, and both views return false. The new owner enrols the position again to resume. ## Who calls the keeper Anyone can. In practice Levee's keeper bot does. Every minute by default it reads `shouldHarvest` and `shouldRebalance` for every active enrolment in one Multicall3 batch. It values the bounty as `bountyBps` of the position's uncollected fees in USD, converts that to wei at the ETH price, and sends only when the bounty is at least three times the estimated gas (`GAS_BOUNTY_MULTIPLE`). A bounty it cannot price is skipped, not sent blind; the fees stay in the position. After a success it leaves that action alone for 5 minutes; after a skip or a failure, for 15 (see [Indexer, API and bots](https://levee.trade/docs/offchain)). ## Reference ### Reverts | Revert | Cause | | --- | --- | | `NotOwner` | `enroll` by anyone but the NFT's owner; `updatePolicy` or `unenroll` by anyone but the enrolment's owner. | | `NotApproved` | `enroll` without `setApprovalForAll(keeper, true)`. | | `BadPolicy` | Bounty above 300, slippage of 10 000 or more, negative hysteresis, or a width that is not a positive multiple of the spacing. | | `NotEnrolled` | Any action, `updatePolicy` or `unenroll` on an inactive enrolment. | | `TooSoon` | `minInterval` has not passed. | | `OwnerChanged` | The NFT's current owner is not the address that enrolled it. | | `InRange` | `rebalance` while the tick is inside the range widened by the hysteresis. | | `TwapWindowTooShort` | `rebalance` while the pool has less than 10 minutes of oracle history. | | `PriceDeviation` | `rebalance` while spot is more than 300 bps from the TWAP. | | `Slippage` | The rebalance round trip lost more than `maxSlippageBps` at the TWAP. | A swap or mint that misses its bound reverts inside Uniswap instead, as "Too little received" or "Price slippage check". ### Emitted events | Event | Emitted by | Carries | | --- | --- | --- | | `Enrolled` | `enroll`, `updatePolicy` | `tokenId`, `owner`, `policy` | | `Unenrolled` | `unenroll` | `tokenId` | | `Harvested` | `harvest` | `tokenId`, `keeper`, `fees0`, `fees1` (before the split), `bounty0`, `bounty1`, `compounded` | | `Rebalanced` | `rebalance` | `oldTokenId`, `newTokenId`, `keeper`, `tickLower`, `tickUpper` | The indexer stores them in `keeperEnrollment` and `keeperAction`, which feed the enrolments shown on Portfolio and the bot's list of positions to check. Where it lives: - `contracts/src/PositionKeeper.sol`: enroll, updatePolicy, unenroll, harvest, rebalance and the two should views - `contracts/src/libraries/Policy.sol`: the Policy struct shared with the vaults - `contracts/src/libraries/Ranges.sol`: centred and outOfRange - `contracts/src/libraries/TwapGuard.sol`: the 30-minute window, the 10-minute minimum, the 300 bps band - `contracts/src/libraries/ZapExec.sol`: balanceAndIncrease and balanceAndMint, with the swap and mint bounds - `contracts/test/PositionKeeper.t.sol`: the split, the interval, the hysteresis, the TWAP cases and OwnerChanged - `packages/core/src/policy.ts`: the mirrored Policy the app uses - `apps/web/app/(app)/portfolio/EnrollDrawer.tsx`: approval then enroll, with DEFAULT_POLICY from lib/portfolio/view.ts - `apps/api/src/bots/keeper.ts`: sweepEnrollments: the views in one batch and the bounty in wei - `apps/api/src/bots/chain.ts`: GAS_BOUNTY_MULTIPLE and the skip rule --- # Launch a token Source: https://levee.trade/docs/launch · Launch A token and its locked pool in one transaction, pooled buying before launch, and Pons tokens. The Launch a token tab on `/launch` creates a new ERC-20 together with its own Levee pool in one transaction, so no one can hold or trade the token before its market exists. If you would rather gather buyers first, a launch vault collects ETH ahead of time and buys at launch for everyone at one price. The Pons tokens tab covers tokens that started on the Pons launchpad and lets anyone open a Levee pool next to them. In brief: - One call to `LaunchPools.launch` deploys a fixed-supply token (no owner, no mint, no transfer tax), creates its ETH/token DAMM pool and seeds part of the supply into it, one-sided. - The seeded liquidity is locked for good. Its LP fees go to a fee recipient, the creator at first: anyone can trigger a payout to the recipient, and only the recipient can hand the right on. - Every launch has an anti-snipe window: the fee starts at up to 5,000 bps and falls linearly to the base fee over up to 3,600 s. - A buy inside the launch transaction gets the LP part of its fee back. Only the protocol share stays charged, 20 % of the fee by default, so a 2,500 bps start fee costs a launch buyer 500 bps. - A launch vault has no owner and charges no fee. If nobody launches within 3 days after deposits close, every depositor can take the whole deposit back. ## What one launch does You choose a name, a symbol, a supply, a start price and a fee schedule, and can attach ETH to buy at launch. `LaunchPools.launch(p)` then does all of this in one transaction: 1. Check: The parameters must pass `preview(p)` (see Reference). The form runs the same checks before you sign. 2. Deploy the token: A `LaunchToken`: a plain 18-decimal ERC-20 whose whole supply is minted once. No owner, no further minting, no fee or limit on transfers, no address treated differently. 3. Create and seed the pool: A DAMM pool of native ETH against the token, tick spacing 60, opened at your start price. The seed is one range from the lowest usable tick up to that price, so it holds only the token and sells it to buyers at every higher price. 4. Buy, if you sent ETH: The ETH buys the token as the pool's first swap, in the same transaction, so nobody can trade ahead of it. 5. Hand out the rest: Tokens bought and any ETH paid back go to you. The supply the pool did not take goes to the creator you named. Diagram: A launch, direct or through a vault. Left: one transaction. Right: a vault gathers ETH first, and its launch() calls the same function with the whole balance as the first buy, so every depositor pays one price. The token's address, and with it the pool id, is unknown until the launch lands: it is deployed with CREATE2 under a salt of the caller, the launch count and the block number. The pool manager accepts any address as a currency, so a predictable address would let someone create the pool first and block the launch. If a same-block front-run still takes it, the launch reverts `LaunchPools__PoolTaken` and works from the next block. A launch cannot go through while the venue is paused, because the pool's hook refuses new liquidity and swaps until it resumes. The pool's tick counts tokens per ETH; the form works in ETH per token. You type a start price or a starting market cap in ETH, and the form rounds it to the nearest multiple of 60 ticks. | Field | Form default | Decides | | --- | --- | --- | | Supply | 1,000,000,000 tokens | the whole supply, minted once | | Seed | 100 % of supply | how much the pool can sell; the rest goes to the creator | | Start | 10 ETH market cap | the price of the first token sold | | Base fee | 100 bps | the fee after the anti-snipe window | | Anti-snipe | 2,500 bps over 600 s | the fee at the first second, and how long it takes to fall to the base | | Volatility control | 10,000 | the size of the volatility surcharge; 0 turns it off | ## The anti-snipe fee and a launch buy A DAMM fee is a schedule plus a surcharge. During the anti-snipe window the schedule falls in a straight line from the start fee to the base fee. The surcharge grows with recent tick movement and fades over 10 min; outside the window the total is capped at 500 bps. [Levee pools](https://levee.trade/docs/pools) covers DAMM pricing in full. Each swap fee has two parts. The protocol share, `protocolFeeShareBps` of the fee (20 % at deploy, at most 50 %), comes off the input and goes to the fee collector, which funds [buyback and burn](https://levee.trade/docs/protocol). The LP part goes to the liquidity the swap trades against. A buy inside the launch transaction pays the full start fee, because no time has passed. But the launch position is then the pool's only liquidity, so the whole LP part is its own: `LaunchPools` collects it right after the swap and pays it back to the buyer with the tokens. Only the protocol share stays charged. With the form's defaults that is 500 bps of the buy. The form quotes your buy this way before you sign. ## Fees from the launch position `LaunchPools` itself holds the seeded position and has no function that removes it, no owner and no upgrade path. It can only collect the position's LP fees: - `collectFees(id)` is open to anyone and pays both currencies straight to the launch's fee recipient, even while the venue is paused. - `setFeeRecipient(id, to)` lets the current recipient, and only them, hand the fees to another address. - `pendingFees(id)` shows what a collect would pay now. The recipient starts as the creator. The tab lists, among the latest 200 launches, those whose fees go to your wallet, with a collect button. ## Pooled buying with a launch vault A launch vault lets a group buy at launch without racing each other: everyone's ETH goes into one first buy, so every depositor pays the same average price. 1. Open: `LaunchVault.open(p, depositEnd)` checks the launch parameters as `preview` does and stores them for good. `depositEnd` must be in the future and at most 7 days away; the form defaults to 24 h. 2. Deposit: Until `depositEnd` anyone can deposit ETH and withdraw any part of their own deposit. 3. Launch: From `depositEnd` until 3 days after, anyone can call `launch(id)`, which launches with the vault's whole balance as the first buy. 4. Claim, or refund: After a launch, each depositor claims a share of the tokens and of the ETH paid back, in proportion to their deposit and rounded down. If nobody launched in time, for example because the venue stayed paused, each depositor refunds the whole deposit. The vault takes nothing. The unseeded supply and the launch position's fees go to the creator named in the parameters, which the opener chooses. A depositor's one cost is the protocol share of the start fee on the vault's buy, which the vault drawer prints before you deposit. The buy has no price limit, so the more ETH the vault holds, the higher up the curve it buys. ## Pons tokens Pons is a third-party launchpad on Robinhood Chain. A Pons token trades on a bonding curve until it graduates, and then in a Uniswap v4 pool keyed by its pair token and the Pons hook. The Pons tokens tab lists graduated and new launches from the indexer (`GET /pons`) and looks up any other token through the Pons factory. Prices, graduation progress and charts are read on chain. Levee adds two things: - **Trading.** The router takes a Pons hop on the curve before graduation and in the v4 pool after. A curve hop sets no minimum of its own; the router enforces your minimum output and deadline over the whole route. A token page's Buy and Sell open `/swap` with the token and amount filled in ([Swaps and routing](https://levee.trade/docs/swaps)). - **A Levee pool.** Open a Levee pool calls `DammHook.createPool` for the token against its pair token, at a start price prefilled from the live Pons price. Defaults: 100 bps base fee, 2,000 bps anti-snipe fee falling over 600 s, volatility control 10,000, tick spacing 200. The pool starts empty and you add liquidity separately. Anyone can open one, and only one per tick spacing can exist. Warning: Pons tokens are community launches Curve trades pay the Pons curve fee and the creator tax, and buys also pay a snipe tax while one applies. Graduated pools charge their own pool fee. Levee routes trades and lets anyone open a pool. It does not list, vet or endorse any token. ## Reference ### Launch checks Parameters: LaunchParams, as preview checks them - `name / symbol` (allowed: 1 to 64 / 1 to 16 bytes): LaunchPools__BadName - `supply` (allowed: 1 wei to 1e33 wei): at most 10^15 whole tokens; LaunchPools__BadSupply - `seedAmount` (allowed: 1 wei to supply): LaunchPools__BadSeed, also when it rounds to zero liquidity - `creator` (allowed: non-zero): gets the unseeded supply and the fee right - `startTick` (allowed: above −887,220, up to 887,220): a multiple of 60; LaunchPools__BadTick - `baseFeeBps` (allowed: 5 to 100 bps): LaunchPools__BadFees for any fee field - `snipeStartFeeBps` (allowed: above base, up to 5,000 bps): the anti-snipe window is required - `snipeSeconds` (allowed: 1 to 3,600 s): linear decay to the base fee - `variableFeeControl` (allowed: 0 to 2^32 − 1): 0 turns the surcharge off ### Reverts | Error | When | | --- | --- | | `LaunchPools__Bad*`, `__ZeroAddress` | a parameter out of range, in `preview`, `launch` or `LaunchVault.open` | | `LaunchPools__PoolTaken` | the pool was created earlier in the same block; retry | | `LaunchPools__NotFeeRecipient` | `setFeeRecipient` from anyone but the current recipient | | `LaunchVault__BadWindow` | `depositEnd` in the past or more than 7 days ahead | | `LaunchVault__DepositsClosed` / `DepositsOpen` | a deposit or withdrawal after `depositEnd`; a launch before it | | `LaunchVault__LaunchExpired` / `AlreadyLaunched` | a launch after the 3-day window, or a second one | | `LaunchVault__NotLaunched` / `NotRefundable` | a claim before the launch; a refund before expiry or after a launch | | `LaunchVault__NothingToClaim` | no deposit, or already claimed or refunded | `Launched` records each launch (token, creator, amounts seeded and bought, ETH spent after the payback); `FeesCollected` and `FeeRecipientSet` track the fee right. The vault emits `VaultOpened`, `Deposited`, `Withdrawn`, `VaultLaunched`, `Claimed` and `Refunded`. The app finds launches through `getLaunches` and these events, never in the deployment manifest, since a launch token's address is not known in advance. Where it lives: - `contracts/src/venue/launch/LaunchPools.sol`: launch, preview, the launch position and its fees - `contracts/src/venue/launch/LaunchToken.sol`: the fixed-supply token - `contracts/src/venue/launch/LaunchVault.sol`: open, deposit, withdraw, launch, claim, refund - `contracts/src/venue/hooks/DammHook.sol`: the fee schedule, its bounds and createPool - `contracts/src/venue/periphery/PonsAdapter.sol`: the Pons curve hop - `apps/web/lib/amm/launch.ts`: startTickFor, quoteFirstBuy, launchProblem, vaultStatus - `apps/web/lib/launch/pons.ts`: the Pons list, live state and price history --- # Graduation and pre-market perps Source: https://levee.trade/docs/graduation · Launch How a token earns a managed vault after three days and real depth, and perps on a virtual AMM for tokens no venue lists. A token that already trades can get two more things on Levee: a managed vault over its deepest pool, and a perpetual market before any order book lists it. `LaunchPipeline` deploys the vault once the token has aged and its pools have held real depth. `PreMarketPerp` runs the perp on a virtual curve, where the contract's own USDG is the only thing that can pay a winner. Both live on the Graduation tab of `/launch`: the graduation queue next to a Start a clock panel, and the pre-market markets next to a List this token panel. Neither contract has an owner, an allow-list or an undo. In brief: - A token graduates when two on-chain facts hold: at least 3 days since someone called `track`, and at least `10 ether` of liquidity, averaged over the last 30 min and summed across its token/WETH pools. - Seasoned (pool at least 3 days old, market cap above $1,000,000, at least 100 trades in 24 h) is a screener label computed off chain. It tells the graduator bot which tokens to work on. No contract reads it. - A pre-market perp takes USDG, isolated per position, at 1x to 3x. It charges 30 bps of notional on entry and on exit, and caps open interest at 5x the market's virtual depth. - A winning close can be paid less than it earned: first cut by any outstanding bad debt, then limited to the USDG the contract holds free, with the rest owed to you as a deferred claim. - `Graduated` and `MarketCreated` mean someone paid gas. They are not endorsements. ## Graduation Graduation is two permissionless calls with a wait between them. The graduator bot makes both for Seasoned tokens; the Graduation tab lets you make them for any token. `track(token)` needs a token/WETH pool on one of the four Uniswap v3 fee tiers (100, 500, 3,000 and 10,000). It grows each such pool's observation ring to 60 slots, so its depth can be averaged later, and stores the block time in `firstSeen[token]`. An empty pool still counts; depth is tested only at graduation. `graduate(token)` then runs the gate: 1. No vault yet: A token graduates once. There is no way to remove its vault. 2. Old enough: At least 3 days (`MIN_AGE`) since `firstSeen`. A token nobody tracked is too young forever, so the first call for a new token is always `track`. 3. Deep enough, over time: For each tier, the pool's harmonic-mean in-range liquidity over the last 30 min, or over as much history as its ring holds. A pool with less than 10 min of history counts as zero. The sum must reach `MIN_LIQUIDITY`. 4. Vault deployed: On the single deepest pool by the same measure, because a vault holds one position. `VaultFactory.createV3Vault` deploys a clone with no admin and `Graduated` is emitted. Diagram: Tracking and graduating a token. Anyone can make both calls. Red chips are reverts, in the order the contract checks them; the dashed arrow is the 3-day wait between the calls. Depth is averaged over 30 minutes, and market cap and trade count are never read. The average is what makes the depth test hard to fake. Liquidity added in the graduation block has existed for no time, so it weighs nothing: a flash-loaned position can neither clear the threshold nor choose the pool. `MIN_LIQUIDITY` is a constructor value and the deploy sets `10 ether`, roughly 10 ETH of full-range depth for an 18-decimal token near parity. It is a floor against dust pools, not a valuation. `canGraduate(token)` runs the same tests as a view and returns the selector of the error `graduate` would raise; the Graduation tab turns it into a short reason on the token's row. The tab's button state is an estimate from indexer data. The contract re-checks everything when you send. ### The vault you get Every graduation produces the configuration the deploy uses for its own ETH and USDG vaults. Only the pool, the range snap and the name differ. Parameters: What _initFor passes to createV3Vault - `asset` (allowed: WETH): deposits and withdrawals are in WETH - `widthTicks` (allowed: curveWidth(spacing) ticks; default: 4,440 at spacing 60): ±25 % (2,231 ticks a side), floored to the spacing - `hysteresisTicks` (allowed: 10 × spacing ticks; default: 600 at spacing 60): how far price must leave the range before a rebalance counts - `minInterval` (allowed: 6 h): minimum gap between two keeper actions - `maxSlippageBps` (allowed: 100 bps): swap bound on every rebalance - `compound / bountyBps` (allowed: true / 50): fees go back into the range; a harvest pays its caller 50 bps of the fees - `cap` (allowed: none; default: type(uint256).max): no deposit cap, fixed once the vault exists - `name / symbol` (allowed: "Levee SYM Vault" / "lvSYM"; default: SYM = TOKEN): from symbol(); a token that reverts or answers empty gets TOKEN The vault starts empty and appears on Earn. How it prices deposits, harvests and rebalances is in [Vaults](https://levee.trade/docs/vaults); the bots that service it are in [Position automation](https://levee.trade/docs/keeper). ## The Seasoned screen The screener's Seasoned label is `isEstablished` in `@levee/core`: pool age of at least 3 days, market cap strictly above $1,000,000, and at least 100 trades. The indexer applies it per pool, counting trades over the trailing 24 h and refreshing the flag on the first swap of each hour. A token with no known supply has no market cap and is never Seasoned. `LaunchPipeline` can check only age, and its clock starts at `track`, not at pool creation. Market cap and trade count need an indexer, so the Graduation tab shows them next to each tracked token as checks that pass or fail. The graduator bot (`apps/api/src/bots/graduator.ts`) joins the two. Each tick (60 s by default) it takes the indexer's Seasoned pools, keeps the side that is not WETH or USDG, skips graduated tokens and reads `firstSeen` for the rest in one batch. It sends `track` when `firstSeen` is zero, `graduate` once 3 days have passed, and nothing in between. Neither call pays a bounty, so each is sent only if it simulates. `ThinPool` and `TooYoung` are the usual answer and are logged as quiet skips; a token whose attempt failed is left alone for 15 min. The tracked list comes from `GET /launch`: the indexer's `graduation` table plus Seasoned token/WETH pools nobody has tracked yet, up to 50 rows, joined with live reads of `firstSeen` and `MIN_LIQUIDITY`. See [Indexer, API and bots](https://levee.trade/docs/offchain). ## Pre-market perps `PreMarketPerp` is a constant-product virtual AMM. It has no liquidity providers and no counterparty: the curve quotes both sides, and the contract's USDG balance pays every winner. Four rules bound what that can cost. Leverage stops at 3x and open interest at 5x the advertised depth. A third of every fee goes to an insurance fund. Bad debt the fund cannot cover is paid by the next profitable closers. And no payout ever touches another open position's collateral or the insurance fund. ### Two prices The **index** is the token's dollar price from the spot market: the mean tick of the market's token/WETH pool over 30 min (less if the ring holds less history), converted to WETH per token, times Chainlink ETH/USD, in 6 decimals. It is unreadable when the ring holds less than 10 min of history, the pool cannot be observed, or the feed reverts, answers zero or less, or has not updated for 25 h. The **mark** is the curve's own price, `quoteReserve × 1e18 / baseReserve`. | Use | Price | | --- | --- | | Every fill, open or close | the curve, so the mark moves with each trade | | Funding | the gap between mark and index, capped at ±1 % an hour | | Liquidation | below maintenance at the mark and at the index; none while the index is unreadable | No trade on this contract moves the index, so pushing the mark cannot liquidate anyone by itself. A manipulated index can push funding to its cap and can liquidate only a position the mark already shows below maintenance. An oracle outage pauses liquidations; `close` keeps working. Diagram: Two prices and funding. The index comes from the spot pool and Chainlink; the mark is the curve. Funding follows the gap between them, capped at ±1 % an hour. A liquidation must hold at both prices, so one trade on the curve cannot trigger it. ### Opening a market `createMarket(token, fee, virtualLiquidityUsd)` is open to anyone. It needs a depth of at least 10,000 USDG, a token/WETH pool on that fee tier whose ring has 60 slots and at least 10 min of history, and a readable index. `prepare(token, fee)` grows the ring as a separate, idempotent transaction anyone can pay for. The curve is seeded at the index, with the depth as its quote reserve, and its `k` is fixed for life. The reserves are virtual and the creator pays nothing for them, which is why open interest (the sum of open positions' entry notionals) is capped at 5x the depth: a market can never owe more than a known multiple of what it advertised. ### A position from open to close `open(token, isLong, collateral, leverageX, limitPrice)` takes a whole-number leverage from 1 to 3 and accrues funding first. The notional, collateral times leverage, must fit under the open interest cap. The entry fee, 30 bps of the notional, comes out of your collateral before the position exists, and only the remainder is stored. The curve then fills the full notional; a short also cannot reach the curve's quote reserve. The average fill price must not cross `limitPrice` (a maximum for a long, a minimum for a short, zero for none). Because the fee comes out first, a 3x open is about 3.03x on the collateral that remains. The trade box on the Graduation tab mirrors that arithmetic and prints a liquidation price from the 6.25 % maintenance margin, without funding: a 3x long is liquidatable roughly 28.6 % below entry, a 3x short roughly 25.2 % above. Its default limit is the mark plus or minus 1 % against you. `close(id, limitPrice)` is for the position's trader only. It accrues funding, pushes the position back through the curve, realises PnL net of funding and checks the exit price against your limit. The exit fee is 30 bps of the exit notional, capped at what the position is worth and zero when it is worth nothing, since a fee charged into bad debt would only deepen the hole. `liquidate(id)` is open to anyone once equity (collateral plus unrealised PnL minus funding owed) is at or below zero, or below 6.25 % of notional, at both prices. It unwinds and charges the exit fee like `close`. The caller gets 1 % of the remaining margin, capped at the USDG the contract holds free, and the insurance fund gets the rest. The liquidated trader keeps nothing: with no order book, liquidating has to be worth someone's gas. Diagram: A position on the vAMM. The entry fee leaves the collateral before the position exists. A close is paid only from USDG the contract holds free, and the rest is owed as a deferred claim. Red chips are reverts. ### Hourly funding `pokeFunding(token)` is public and idempotent, and every open, close and liquidation calls it first. Once a whole hour has passed it adds `(mark − index) / index`, clamped to ±1 %, to the market's running sum once per whole hour, at the rate seen at poke time. One poke charges at most 24 hours; a longer gap is forgiven rather than billed at today's rate. If the index is unreadable it returns without moving the clock, so an outage never blocks an exit. A position owes the change in the running sum since it opened, times its entry notional: longs pay when the sum is positive (the mark has run above the index), shorts receive it. Funding settles at close. ### Bad debt and the insurance fund Every fee is split: 3,333 bps of it, about 10 bps of notional, goes to the insurance fund, and the rest to the `FeeRouter`, which credits a referrer with 20 % when there is one ([Fees and referrals](https://levee.trade/docs/fees)). A close or liquidation that ends below zero is bad debt. The insurance fund pays first, down to zero; anything left becomes `deficit`. Nothing is taken from anyone at that moment. Instead, the next profitable close has its profit cut by the smaller of its profit and the deficit, and the deficit shrinks by the same amount. Only profit is cut; your collateral never pays for someone else's loss. The cost lands in full on whoever closes at a profit first after the loss, until the deficit is gone. `haircutBps()` reports the deficit as a share of open collateral, a gauge of how unfunded the venue is. ### When a profit is paid late A vAMM has no auto-deleveraging, so a profit can also be paid late. The contract pays out only free USDG: its balance minus all open collateral and the insurance fund. Say a long closes at a profit while the short on the other side is still open. The short's loss is real at the mark, but its USDG is still that short's collateral. The long is paid what is free and the rest is recorded in `deferred[trader]`. Claims already recorded come first: a later close is paid only from what is free after `totalDeferred` is set aside, so USDG a realised loss frees goes to the traders already waiting. `claimDeferred()` pays as much of your claim as is free at the time, in as many calls as it takes; among waiting traders it is first come, first served. `invariant_perpCoversItsObligations` states the design in one line: the contract's USDG balance is at least `totalCollateral + insuranceFund − deficit`. The fuzzer drives four traders through random opens, closes, liquidations, claims and time jumps, and a second invariant checks that positions really do close, so the first cannot pass on an idle book. Danger: Before you open a pre-market position The index is a short TWAP of a pool that is new and thin by construction. Fills follow the curve, which anyone with USDG can move. A liquidation that arrives late is paid by the insurance fund first and then by whoever closes at a profit next, and a profit you close may be paid in part as a deferred claim. ## Reference ### Perp constants Parameters: PreMarketPerp - `MAX_LEVERAGE_X` (allowed: 1 to 3 x): whole numbers only - `MAINT_MARGIN_BPS` (allowed: 625 bps): of notional, at the mark and at the index - `TAKER_FEE_BPS` (allowed: 30 bps): of notional, on entry and on exit - `INSURANCE_SHARE_BPS` (allowed: 3,333 bps): of each fee - `LIQ_BOUNTY_BPS` (allowed: 100 bps): of the remaining margin, to the liquidator - `TWAP_WINDOW / MIN_INDEX_WINDOW` (allowed: 30 min / 10 min): index window, and the least history it accepts - `MIN_OBS_CARDINALITY` (allowed: 60): ring size a pool needs to back a market - `MIN_VIRTUAL_LIQ_USD` (allowed: 10,000 USDG): smallest market depth - `OI_MULTIPLE` (allowed: 5 x): open interest cap over depth - `FUNDING_CAP_X18 / MAX_FUNDING_HOURS` (allowed: ±1 % / 24): hourly rate cap, and the most hours one poke charges ### Reverts | Error | Raised when | | --- | --- | | `AlreadyTracked` / `AlreadyGraduated` | a second `track`, or a token that already has a vault | | `NoPool` | no token/WETH pool on any tier (`LaunchPipeline`) or on the given tier (`PreMarketPerp`) | | `TooYoung` | untracked, or less than 3 days since `firstSeen` | | `ThinPool` | summed time-weighted liquidity below `MIN_LIQUIDITY` | | `MarketExists` / `NoMarket` | a second market for a token, or none at all | | `LowCardinality` / `NoHistory` | the ring has under 60 slots, or under 10 min of history | | `BadOracle` | the index cannot be read | | `Leverage` / `ZeroAmount` | leverage of 0 or above 3; zero collateral or a fill that rounds to nothing | | `OiCap` | the open interest cap, or a short as large as the quote reserve | | `TooSmall` | depth under 10,000 USDG, or an entry fee that reaches the collateral | | `Slippage` | the average fill or exit price crossed `limitPrice` | | `NotOpen` / `NotTrader` / `NotLiquidatable` | a closed position, a caller who is not its trader, a position above maintenance at either price | | `NothingToClaim` | no deferred claim, or no free USDG to pay it | ### Events and indexed data `LaunchPipeline` emits `Tracked` and `Graduated`. `PreMarketPerp` emits `MarketCreated`; `Opened`, `Closed` and `Liquidated` over a position's life; `Funding` with each accrual; `InsuranceChanged` on every movement of the fund; and `Socialised` and `DeferredClaimed` for cut or delayed profits. The indexer keeps these in `graduation`, `perpMarket`, `perpPosition`, `perpGlobal` (insurance fund, deficit, total deferred) and `perpEvent`. `GET /launch` adds live mark, index, open interest and insurance reads, your positions' equity, and a `funding1h` column computed off chain from the same formula. Where it lives: - `contracts/src/LaunchPipeline.sol`: track, graduate, canGraduate, _scanOverTime, _initFor - `contracts/script/LeveeWiring.sol`: MIN_LIQUIDITY = 10 ether and the seed vault policy - `contracts/src/PreMarketPerp.sol`: createMarket, open, close, liquidate, pokeFunding, _payOut, claimDeferred - `contracts/src/libraries/ChainlinkGuard.sol`: the 25-hour feed age - `contracts/test/invariants/PerpSolvency.t.sol`: the solvency invariant and its handler - `packages/core/src/established.ts`: isEstablished, the Seasoned test - `apps/api/src/bots/graduator.ts`: the bot that tracks and graduates - `apps/api/src/launch/build.ts`: how GET /launch joins indexer rows and chain reads - `apps/web/lib/launch/view.ts`: launchAction, establishedChecks, tradeEstimate --- # Buyback and burn Source: https://levee.trade/docs/protocol · Protocol Fees become ETH, ETH buys $LEVEE, $LEVEE is burned: each step, its guard and who can call it. Levee has one use for its protocol revenue: buying $LEVEE and burning it. A `FeeCollector` swaps fees to ETH, and `BuybackV2` spends the ETH on $LEVEE and sends it to `0x…dEaD`. Every call on the way is open to anyone, and the [Protocol page](https://levee.trade/protocol) has a button for each. In brief: - Levee pools send 20 % of each swap fee to the FeeCollector. Other fees pass through the FeeRouter, which first credits 20 % to the payer's referrer, if any. - Every step is a public call: `flush`, `convert`, `unwrapWeth`, `forwardEth`, `buyback`, `poke`. - One `buyback()` spends at most 0.05 ETH, runs at most once per block and pays its caller 0.5 %. - A buyback will not buy while $LEVEE trades above a 10 % band over a slow reference price. A falling price never blocks it. - The owner tunes parameters inside hard caps and can redirect revenue before it reaches BuybackV2. It cannot touch user positions or the ETH BuybackV2 holds. ## The path of one fee A fee reaches the FeeCollector by one of two roads. A Levee pool swap pays its protocol share straight in; every other fee is booked in the FeeRouter until someone calls `flush(token)` (see [Fees and referrals](https://levee.trade/docs/fees)). The FeeCollector turns what it holds into ETH for BuybackV2, and `buyback()` spends that ETH on $LEVEE in the ETH/LEVEE Uniswap v4 pool, which pays the tokens directly to `0x…dEaD`. Diagram: From fee to burn. Levee pools pay their protocol share to the FeeCollector directly. Every other fee is reported to the FeeRouter, which credits 20 % to the payer’s referrer. From the FeeCollector on, each arrow is a call anyone can make. The accent arrow is the burn. ## The protocol share of pool fees LPs keep most of a Levee pool's swap fee. `ProtocolConfig.protocolFeeShareBps` (2000, so 20 % of the fee, at most 50 %) goes to the FeeCollector, always in the token you pay with. - DAMM and stock pools are Uniswap v4 hooks and send it with `poolManager.take`. If the PoolManager is short of that token mid-swap, the hook mints itself an ERC-6909 claim, and anyone can call the hook's `sweep(currency)` to pay it out to the FeeCollector. - DLMM pairs transfer it on every swap, and on the composition fee a deposit into the active bin pays. How each pool sets its fee is in [Levee pools](https://levee.trade/docs/pools). ## Converting fees to ETH The owner gives each fee token a route: Router hops ending in native ETH, a cap per call, and an optional oracle floor. Then anyone can convert. - `convert(token, 0)` converts the balance up to the cap. An explicit amount above the cap reverts `OverCap`. - With the floor on, `minEthOut` prices the input from the `AssetRegistry` feed against Chainlink ETH/USD and accepts at most `maxSlippageBps` (3 %) less. A stale feed reverts `StaleOracle`. - WETH needs no route: `unwrapWeth()` unwraps it 1:1. `forwardEth()` passes ETH fees on. A production deploy has no routes until the owner adds them, so a token without one waits in the FeeCollector. A local deploy seeds USDG (10 000 per call) and its seeded stock (10 per call). ## Buying and burning `buyback()` takes BuybackV2's ETH balance, including any ETH sent with the call, up to `maxEthPerCall` (0.05 ETH). It keeps `callerRewardBps` (0.5 %) of that budget for the caller and spends the rest; a partial fill pays a proportional reward. Unspent ETH waits for the next call. Warning: Sending your own ETH ETH you send with `buyback()` joins the ETH already waiting. Anything above the 0.05 ETH cap stays in BuybackV2 for later buybacks. You get it back only if the call reverts. ### The price guard $LEVEE has no Chainlink feed, so BuybackV2 checks the pool against a reference price that follows it slowly. - The reference moves at most `driftBpsPerHour` (30 %) per hour since its last update, and at most `maxDeviationBps` (10 %) per update. With no time elapsed it cannot move, so a pump inside one block does not shift it. - `buyback()` catches the reference up, then reverts `PriceAboveBand` if $LEVEE is above the band: 10 % on the pool price ($LEVEE per ETH), about 11.1 % on $LEVEE's ETH price. - A cheaper $LEVEE never blocks a buyback, however old the reference. - The swap stops at the tighter of the reference band edge and 10 % above its own starting price. A reverted buyback also undoes its catch-up, so `poke()` exists to move the reference on its own. It pays nothing. The API's optional `buybackPoker` bot pokes when the reference is at least 1 % off the pool price and 600 s old. After `PriceAboveBand`: poke, wait, retry. ## Burned and circulating supply A burn is a transfer to `0x…dEaD`, so circulating supply is `totalSupply()` − `balanceOf(0x…dEaD)`, checkable by anyone. BuybackV2 also counts `totalEthSpent` and `totalBurned`. The indexer keeps every `Converted` and `BoughtBack` event for `GET /protocol`, and the Protocol page reads the live state on chain every 20 s. Note: Before $LEVEE launches The token launch is a separate step the owner approves. Until then BuybackV2 has no pool, `buyback()` and `poke()` revert `NotConfigured`, and converted ETH waits in BuybackV2, which has no other way to send it. A local deploy configures a test $LEVEE priced at 1 ETH = 1 000 000 LEVEE. ## What the owner controls One cold key owns `ProtocolConfig`, `FeeCollector` and `BuybackV2`; ownership changes in two steps, propose then accept. | Contract | The owner can | Limit | | --- | --- | --- | | ProtocolConfig | set the protocol share; name another FeeCollector | share at most 50 % | | ProtocolConfig | pause | stops swaps and new liquidity, never withdrawals | | FeeCollector | set or clear routes; set `maxSlippageBps`; name another buyback | slippage at most 20 % | | FeeCollector | `sweep` a token | only a token with no route, never ETH | | BuybackV2 | `configure` the pool | once | | BuybackV2 | set `maxEthPerCall`, `callerRewardBps`, the guard; `resetReference()` | reward at most 5 %, band 1 to 2000 bps, drift at most 10 000 bps per hour | So the owner can redirect revenue that has not reached BuybackV2: by naming another collector or buyback, or by clearing a token's route and sweeping it. It cannot take ETH from BuybackV2, and none of these contracts reaches a user's position or deposit. The other keys are in [Security model](https://levee.trade/docs/security). ## Reference ### Calls and reverts | Call | Can revert with | | --- | --- | | `FeeRouter.flush(token)` | `NothingToClaim` | | `sweep(currency)` on a hook | `NothingToSweep` | | `FeeCollector.convert(token, amount)` | `NoRoute`, `OverCap`, `StaleOracle`, `ZeroAmount`, or the Router's `TooLittleReceived` on a dust amount | | `FeeCollector.unwrapWeth()`, `forwardEth()` | `ZeroAmount` | | `BuybackV2.buyback()` | `NotConfigured`, `OncePerBlock`, `NothingToSpend` (no ETH, or nothing the band allows), `PriceAboveBand` | | `BuybackV2.poke()` | `NotConfigured` | ### Parameters Parameters: Deploy values - `protocolFeeShareBps` (allowed: 0 to 5000 bps of the fee; default: 2000): ProtocolConfig: Levee pool fee share sent to the FeeCollector - `maxSlippageBps` (allowed: 0 to 2000 bps; default: 300): FeeCollector: the oracle floor on convert - `maxEthPerCall` (allowed: any ETH; default: 0.05): BuybackV2: spend per buyback (BUYBACK_MAX_ETH_PER_CALL) - `callerRewardBps` (allowed: 0 to 500 bps; default: 50): BuybackV2: the caller reward - `maxDeviationBps` (allowed: 1 to 2000 bps; default: 1000): BuybackV2: the band, and the largest step the reference takes - `driftBpsPerHour` (allowed: 0 to 10 000 bps per hour; default: 3000): BuybackV2: how fast the reference follows the pool Where it lives: - `contracts/src/venue/periphery/FeeCollector.sol`: routes, the oracle floor, convert, sweep - `contracts/src/venue/periphery/BuybackV2.sol`: buyback, poke, the reference and the band - `contracts/src/venue/core/ProtocolConfig.sol`: the protocol share and pause - `contracts/src/venue/hooks/LeveeHookBase.sol`: how the hooks pay their share - `contracts/script/venue/VenueWiring.sol`: deploy values and ownership - `contracts/script/venue/VenueSeed.sol`: local routes and the test $LEVEE pool - `apps/web/lib/amm/buyback.ts`: the guard reading and circulating supply on the page --- # Fees and referrals Source: https://levee.trade/docs/fees · Protocol What each action costs, where the money goes, the referral share, bounties and points. This chapter lists what Levee charges, who receives it and how a referrer earns a share. Levee charges on what you put in or what a position earns, not on what you take out, with one exception for structured notes. Bounties, which pay for public upkeep calls, come last. In brief: - A zap costs 25 bps of what you put in. A spot trade on the Trade page's Uniswap path costs 25 bps of the output. A Levee Router swap pays only the pools' own fees. - Managed positions pay 10 % of the swap fees they earn and nothing on principal. The only fee on principal is 50 bps for leaving a structured note before maturity. - Levee pools send 20 % of each swap fee to the FeeCollector and leave the rest to their LPs. - If you were referred, your referrer earns 20 % of every FeeRouter fee you generate. The link is permanent. Vault and note fees never pay a referrer. - Points are an off-chain ledger. Nothing is promised about them. ## What you pay | Action | Fee | Taken from | Referrer credited | | --- | --- | --- | --- | | Swap in a Levee pool | the pool's fee, 20 % of which is the protocol share | your input | none: the share goes straight to the FeeCollector | | Swap through the Levee Router | no Levee fee beyond the pools' | - | - | | Spot trade on the Uniswap path (Trade) | 25 bps | the output | nobody, see below | | Zap into a Uniswap range | 25 bps | the input | yours | | Keeper harvest or rebalance | 10 % | the fees collected | the position owner's | | Range order fill or cancel | 10 % | the fees the order earned | the order owner's | | LP or hedged vault harvest or rebalance | 10 % | the fees collected | none: the vault names itself | | Structured note exit before maturity | 50 bps | the gross amount withdrawn | none: the note names itself | | Pre-market perp open, close or liquidation | 30 bps | the notional | the trader's | Levee pool fees depend on the pool. A DAMM pool charges a base fee of 5 to 100 bps plus a volatility surcharge, at most 500 bps in total, and a new pool may add an anti-snipe fee that starts as high as 5000 bps and falls to the base fee within an hour. Stock pools charge 30 bps while the market is open, 150 bps while it is closed and 500 bps when the price feed is stale (deploy values; the owner can set each asset up to 1000 bps). A DLMM pair charges a base fee plus a variable fee, capped at 10 %. [Levee pools](https://levee.trade/docs/pools) has the detail. Some actions pay no Levee fee at all: a deposit into any vault, a withdrawal from an LP, hedged, DLMM or stock vault, a DLMM limit order, and a launch's pre-launch vault. Some fees are not Levee's: a Pons curve charges its own fee and creator tax, and Lighter charges its own trading fees. The signer can tag Lighter orders with an integrator account (`INTEGRATOR_ACCOUNT_INDEX`, unset by default) for Lighter's fee-share attribution. ## Fees come out of earnings When a keeper, a vault or a range order closes or re-centres a Uniswap position, the contract compares two numbers: `decreaseLiquidity` reports the principal alone, and `collect` pays out principal plus fees. The 10 % and any bounty apply only to the difference, so a position that has earned nothing pays nothing and no harvest reaches the liquidity itself. Inside one split the order is fixed: the protocol share first, then the caller's bounty, then everything left to the owner. ## How a fee is booked Every fee except the Levee pools' protocol share passes through `FeeRouter`, a contract with no owner and two fixed addresses: the `ReferralRegistry` it reads and the treasury it pays, which is the FeeCollector. 1. The paying contract transfers the fee to the router, then reports it with `take(token, amount, user)`. A fee paid in native ETH arrives with `takeNative(user)` instead; a v4 zap paid in ETH uses it. 2. `take` is public, so the router checks the report against its balance. It accepts the fee only if the balance covers everything already owed plus the new amount, and reverts `FeeNotReceived` otherwise. 3. If `user` is bound to a referrer, 20 % (`REFERRAL_BPS`) is credited to that referrer and the rest to the treasury. The router emits `Taken`. 4. A referrer calls `claim(token)` to pull their own balance. Anyone can call `flush(token)` to push the treasury's balance to the FeeCollector, where [buyback and burn](https://levee.trade/docs/protocol) takes over. Diagram: Where each fee goes. A Levee contract transfers each fee to the FeeRouter, then reports it with take(). A report counts only if the router’s balance covers it. The user named in the report decides whose referrer earns the 20 %. Bounties go straight to the caller and never pass through the router. ### The Uniswap-path skim On the Trade page, a spot trade that goes through the Uniswap Universal Router includes a `PAY_PORTION` step that sends 25 bps of the output to the FeeRouter as a plain transfer; the minimum you receive is net of it. Nothing reports that transfer with `take`, so it stays in the router as an unbooked balance that neither `claim` nor `flush` can move. Anyone may book it with `take`, and whoever does picks the `user`, so the referrer credited is theirs to choose. No Levee service books it today. When the Levee Router pays more on a live quote, the trade goes through it instead and no skim applies. A build with no FeeRouter in its manifest turns the skim off. ## Referrals `ReferralRegistry` holds two write-once links and has no owner. - **Your code.** On the Ideas page, register 1 to 8 letters or digits; the app upper-cases the code and stores it as `bytes8` with `register`. An address owns one code for life, and a code belongs to one address. - **Your referrer.** Opening `levee.trade/ideas?ref=CODE` offers a one-click `bind` if you have no referrer yet. A binding never changes, and you cannot refer yourself. - **Who counts as the user.** Each contract names the user behind a fee: the zapper, the position owner rather than the bot that harvested, the order owner rather than the keeper that filled, the trader. A vault or a note names itself and can never bind, so its fees go to the treasury in full. A vault depositor is not referred through the vault. - **Earnings.** The indexer adds each `Taken` referrer share to the referrer's per-token total, and `GET /referral/:address` prices those totals into one dollar figure for the Ideas page. You claim on chain with `FeeRouter.claim(token)`, one token at a time, from the referrer wallet. The app has no claim button yet. ## Bounties A bounty pays the gas of a public call nobody else is paid to make. The contract pays it to `msg.sender` before the owner's share, and it never passes through the FeeRouter. | Call | Bounty | Paid from | | --- | --- | --- | | `PositionKeeper.harvest`, `rebalance` | the owner's `bountyBps`, at most 3 % | the fees collected, after the 10 % | | `LPVault.harvest`, `rebalance` | 0.5 % | the fees collected, after the 10 % | | `RangeOrders.fill` | 1 % | the order's output | | `PreMarketPerp.liquidate` | 1 % | the margin left after the loss and the fee; the rest goes to the insurance fund | Seed and graduated vaults write `bountyBps` 50 into their policy, matching what the vault pays. Levee's keeper bot sends a bounty-paid call only when the bounty is at least 3 times the gas, and skips it when it cannot price the bounty. An untaken bounty costs nothing; the fees stay in the position. ## The pre-market perp fee `PreMarketPerp` charges 30 bps of the notional on open, and reverts `TooSmall` if that fee would reach the collateral. It charges again on close and on liquidation, capped at collateral plus PnL and zero when that is zero or less, so a fee never deepens bad debt. `INSURANCE_SHARE_BPS` (3333) sends one third of each fee to the insurance fund and two thirds to the FeeRouter in the trader's name. [Graduation and pre-market perps](https://levee.trade/docs/graduation) covers the market. ## Points Points live in the API's Postgres, not on chain: 1 point per dollar of volume, 5 per dollar of fees earned, and volume counts double for 7 days after you post an idea. Totals are recomputed from the `points_events` ledger rather than incremented. `GET /points/:address` returns the total and its parts (volume, fees, `thesisBonus`); `POST /points/recompute` rebuilds every total behind `ADMIN_SECRET`. A background job reads indexed Levee activity every few minutes and writes it to the ledger: swaps through the Levee Router, pre-market perp opens and voluntary closes (liquidations earn nothing) count as volume; keeper harvests, range-order fills, vault harvests (split by share) and the 20 % referral share count as fees earned. Each row's id comes from its on-chain event, so a re-read never counts twice, and amounts are priced in USD when the job reads them. Plain Uniswap fee collections are not Levee activity and earn nothing. `rank` is your place among the stored totals. Nothing is promised about points. ## Reference ### Constants | Constant | Value | Where | | --- | --- | --- | | `ZAP_FEE_BPS` | 25 | `LeveeZapV3`, `LeveeZapV4` | | `SPOT_FEE_BPS` | 25 | `apps/web/lib/trade/spot.ts` | | `PROTOCOL_FEE_BPS` | 1000 | `PositionKeeper`, `LPVault`, `RangeOrders` | | `EARLY_EXIT_BPS` | 50 | `StructuredVault` | | `TAKER_FEE_BPS`, `INSURANCE_SHARE_BPS`, `LIQ_BOUNTY_BPS` | 30, 3333, 100 | `PreMarketPerp` | | `REFERRAL_BPS` | 2000 | `FeeRouter` | | `MAX_BOUNTY_BPS`, `HARVEST_BOUNTY_BPS`, `KEEPER_BOUNTY_BPS` | 300, 50, 100 | `PositionKeeper`, `LPVault`, `RangeOrders` | | `protocolFeeShareBps` | 2000, at most 5000 | `ProtocolConfig` | ### FeeRouter and ReferralRegistry errors | Error | When | | --- | --- | | `FeeNotReceived` | `take` with the zero address as token, or a balance that does not cover the report | | `NothingToClaim` | `claim` or `flush` with nothing owed | | `NativeTransferFailed` | the ETH recipient refused the payment | | `ZeroCode`, `CodeTaken`, `AlreadyRegistered` | `register` with an empty code, someone else's code, or a second code | | `UnknownCode`, `SelfReferral`, `AlreadyBound` | `bind` to a code nobody owns, to your own code, or a second time | Where it lives: - `contracts/src/FeeRouter.sol`: take, takeNative, claim, flush, the balance check - `contracts/src/ReferralRegistry.sol`: register and bind - `contracts/src/PositionKeeper.sol`: fees-only split: protocol, bounty, owner - `contracts/src/LPVault.sol`: the vault as its own user - `contracts/src/RangeOrders.sol`: the 10 % on earned fees and the fill bounty - `contracts/src/PreMarketPerp.sol`: the taker fee, the insurance third, the liquidation bounty - `apps/web/lib/trade/universalRouter.ts`: the PAY_PORTION skim - `apps/web/app/(app)/ideas/ReferralPanel.tsx`: register, the ?ref= bind - `apps/api/src/points.ts`: the points rules - `apps/api/src/points/job.ts`: the job that writes the ledger from indexed activity - `apps/api/src/bots/chain.ts`: the bounty-to-gas rule --- # Security model Source: https://levee.trade/docs/security · Protocol Every key and what it can do, the price checks in front of your funds, how it is tested and what is still open. This chapter is for anyone deciding how far to trust Levee with money. It lists every key that can do something the public cannot, the checks that stand between a pool price and your funds, how the contracts are tested, and what is still open. In brief: - No contract can be upgraded, and the LP-management contracts (vaults, zaps, range orders, the keeper, the launch pipeline, the pre-market perp, the fee router) have no owner and no pause switch. - Levee's own pools have one owner, a cold key behind `ProtocolConfig`. It can pause swaps and deposits, set the protocol share up to 50 % of the swap fee and tune stock oracles and fees. It cannot move liquidity or block a withdrawal. - The hedger key can move a hedged vault's share price through its reports. A report that moves equity by more than 15 % pauses the vault, and so does a report older than 1 h. - Before a vault, a note, the position keeper or hedge funding moves value at a Uniswap pool's price, it checks that spot is within 300 bps of a 30-minute average measured over at least 10 minutes of history. - One custody exception: a hedged vault's margin sits on Lighter in an account owned by that vault's funder key, which the operator holds, because a contract cannot own a Lighter account that trades. That key can withdraw the margin there; the vault lets at most 25 % of its on-chain assets go to Lighter per day. - The open vaults Levee deploys have no deposit cap, so no limit on deposits bounds what a bug or a bad report can reach. The Income note is not open yet. ## Who holds a key Diagram: Who can call what. Every gated call, grouped by the key that holds it; "Anyone" means permissionless. The frame on the right lists actions with no code path at all, so no key can perform them. The deploy uses five roles, with one funder per hedged vault, and `DeployRobinhood` refuses to run if any of them is the deploying key, if a funder is the hedger or the keeper, if the owner is a funder or either hot key, or if the two hedged vaults share a funder. The hedger and the keeper may share a key; the API signs both bots with one. After the deploy the deploying key is an ordinary address. | Key | Kept | Holds | | --- | --- | --- | | Owner | cold | the venue's admin calls: `ProtocolConfig`, `AssetRegistry`, `FeeCollector`, `BuybackV2`, and through `ProtocolConfig` the DLMM factory and the two venue vault factories | | Hedger | hot, on the API server | hedge and sleeve reports | | Keeper | hot, on the API server (may be the hedger's key) | rebalancing the DLMM and stock vaults | | Funder, one per hedged vault (`FUNDER_NVDA_HEDGED`, `FUNDER_SPY_HEDGED`, default `FUNDER`) | cold, never a hot key | moving margin from its vault to Lighter, within caps; and, off chain, the vault's Lighter account itself, including withdrawing from it | | Deployer | used once | nothing after the hand-over | ### The venue owner The four owned contracts use OpenZeppelin `Ownable2Step`: the deploy proposes the owner and the owner must call `acceptOwnership`. The owner can: - **Pause the venue** (`ProtocolConfig.setPaused`). This stops swaps and liquidity adds on DAMM and stock pools, DLMM swaps, deposits and new pairs, deposits and rebalances in the DLMM and stock vaults, and new DLMM limit orders. It never gates an exit: the hooks have no remove-liquidity callback, and `DlmmPair.burn`, the position NFT's `decrease` and `burn`, the venue vaults' `withdraw` and the limit orders' `cancel` and `claim` do not read the flag. - **Direct protocol revenue.** Set the protocol share of every swap fee, at most 5 000 bps; replace the fee collector; set the `FeeCollector`'s conversion routes, its slippage bound (at most 20 %) and where it sends ETH; sweep tokens that have no conversion route; tune `BuybackV2`'s price guard and caller reward. - **Curate stock pools.** List or delist an asset, choose its Chainlink feed and heartbeat, its fees (at most 1 000 bps) and its band, and set the session hours and holidays. - **Curate the rest.** Enable DLMM bin steps and their fee presets (new pairs only), create DLMM and stock vaults, name their keeper and rebalance them itself. None of these calls can take a liquidity provider's tokens. Two of them still matter to you: a wrong feed for a stock moves that pool's band, and providers would trade at it; and delisting an asset stops swaps in its pools until it is listed again. The LP-management contracts read nothing the owner controls. ### The hedger and the funder A hedged vault cannot see its Lighter position, so it believes the hedger's `reportHedge(notionalUsd, unrealizedPnlUsd, equityUsd, asOf)` and adds `equityUsd` to `totalAssets()` through the Chainlink ETH/USD feed. An `Income` note does the same with `reportSleeve`; a `Protected` note refuses it. Danger: The hedger key can move the share price Three bounds apply. A report that moves equity by more than `maxEquityJumpBps` (1 500 bps at deploy) is stored but pauses deposits and withdrawals until a report inside the band arrives. A report older than `maxHedgeAge` (1 h at deploy) pauses the vault with no transaction needed. `asOf` must move forward, cannot be in the future and must come after the last `fundHedge`/`returnHedge` (`StaleReport`). What the bounds miss: a hedger reporting a slow drift, inside the band each time, can walk the price anywhere over enough reports, and the hedged vaults have no deposit cap to limit how much money that reaches. The same key can also freeze a hedged vault's exits by reporting a jump or by going quiet. `fundHedge(assetAmount)` belongs to the funder alone. It can only post USDG to the one Lighter deposit contract fixed at initialisation, credited to the funder's own Lighter account; it moves at most `maxFundBpsPerCall` (1 000 bps) of the vault's on-chain assets per call and `maxFundBpsPerPeriod` (2 500 bps) per rolling day, measured against the position and idle balance rather than the reported equity; it sells at a floor taken from the USDG pool's TWAP; and it is refused while the vault is paused. No bot calls it. The USDG it delivers is added to the stored equity in the same transaction, and `returnHedge` (the funder again) takes the USDG it brings home off it, so margin on its way to or from Lighter never drops out of the share price; the hedger reports what is still in flight (see [Margin in transit](https://levee.trade/docs/hedged-vaults#margin-in-transit)). Danger: The funder key holds the hedge margin Lighter binds a trading key to an account only with an ordinary wallet signature from the account's owner, and it has no accounts owned by a contract. So each hedged vault's margin is credited to its funder's Lighter account, and the signer's order key for that account trades and never withdraws. The funder key itself can withdraw whatever margin is on Lighter. The bounds are the funding caps (10 % of on-chain assets per call, 25 % per day), one dedicated funder per vault, and that the key is cold and held by the operator. Margin comes back as USDG the funder hands to `returnHedge`, which sells it for WETH behind a TWAP floor; the function can only add to the vault. As keeper, the hedger may rebalance DLMM and stock vaults only when the contracts allow it: a DLMM vault once the active bin has drifted more than half its half-width, a stock vault on a session change or a large oracle move and only while the pool sits near the oracle; at most every 5 minutes, and never with a swap. ## Contracts with no admin The LP-management contracts (the three vault kinds and `VaultFactory`, both zaps, `RangeOrders`, `PositionKeeper`, `LaunchPipeline`, `PreMarketPerp`, `FeeRouter` and `ReferralRegistry`) have no owner, no pause switch and no upgrade path. Every address they call (Uniswap, WETH, Permit2, feeds, the Lighter deposit) is a constructor immutable or, for the vault clones, written once in `initialize`. On the venue side the Router, the DLMM pairs, position NFT and limit orders, `LaunchPools` and `LaunchVault` have no owner of their own either; the pools among them follow the venue pause and protocol share described above. The vaults are EIP-1167 clones of three implementations. Each implementation locks itself in its constructor, and `VaultFactory` clones and initialises in one transaction, so no stranger can configure a fresh clone. What that leaves: - A vault pays out only to whoever redeems its shares. A vault's cap and policy are set once, at creation, and never change. - `PositionKeeper` mints a rebalanced position to its owner and pays only the owner, the caller's bounty (capped by the owner, at most `MAX_BOUNTY_BPS`, 300) and the `FeeRouter`. A position that changed hands since enrolment reverts `OwnerChanged`. - `FeeRouter.take` believes a reported fee only if the router's balance covers it (`FeeNotReceived`), and referral links are write-once. - No contract can withdraw from Lighter, and neither can the signer's order keys. The one key that can is a hedged vault's funder, for that vault's margin (see above). - `LaunchPools` locks each launch's seed liquidity for good; only the right to its fees can change hands. Outside the Router and the hooks, functions that move tokens carry OpenZeppelin `ReentrancyGuard`. The two report functions carry it too, though they make no external call, so a report can never land in the middle of a deposit, exit or funding and move the share price under it. The Router keeps nothing between calls, and the hooks run inside the `PoolManager`'s lock. The v3 zap approves exact amounts and clears them, while the v4 zap keeps standing Permit2 approvals, which is safe only because it never holds a balance (see the invariants below). ## Price checks Diagram: Checks in front of every value-moving path. Each row is one check: what it tests, the revert it raises, and the paths that run behind it. A path that moves value at a pool or oracle price passes at least one row; the note at the foot lists the deliberate exceptions. The threat is the same everywhere: move a pool, get Levee to act at that price, move it back. `TwapGuard.check` is the shared answer. It reads spot and the mean tick over `TWAP_WINDOW` (30 minutes), compares them as prices, and reverts `PriceDeviation` when they are more than 300 bps apart. A new pool's observation ring holds one slot, so its "average" is the last swap; the guard therefore refuses to answer (`TwapWindowTooShort`) until the ring reaches back 10 minutes. `VaultFactory` and `LaunchPipeline.track` grow each ring to 60 slots, and anyone can deepen one further. The cost is availability: on a fresh pool, deposits, withdrawals and rebalances wait. The guard is waived three times, on purpose. A vault with no shares skips it, so the first depositor into a freshly graduated pool can get in. `harvest` skips it, since it only compounds fees under a slippage bound and blocking it would let a manipulator stop a vault being paid. A `Protected` note stops checking the SGOV pool once it holds no SGOV, which is what `settle` is for. Inside the 300 bps band, more checks apply: - `PositionKeeper.rebalance` values what it unwound and what it minted at the TWAP, and reverts `Slippage` if the round trip lost more than the owner's `maxSlippageBps`. This catches what the swap floor cannot see. - Swap floors: the vaults and the keeper quote at spot after the guard; a zap quotes at spot and also enforces the `minLiquidity` the app derives from the price you saw; `fundHedge` and the SGOV leg quote at the TWAP. - Mint minimums: the vaults allow 500 bps between the minted and the balanced amounts, the zaps your slippage. A zap refuses a slippage above 5 000 bps (`MAX_SLIPPAGE_BPS`, `SlippageTooHigh`), so no call can switch its floors off. - Chainlink reads (`ChainlinkGuard`) need a positive, complete answer under 25 h old. A bad read values a hedge or sleeve at zero rather than freezing exits, and makes the perp's index unreadable. - Stock pools end every swap inside their oracle band or closer to the oracle, and DLMM deposits carry an active-bin and minimum-amount guard; see [Levee pools](https://levee.trade/docs/pools). - Graduation measures pool depth as a 30-minute average, so liquidity added in the graduating block counts for nothing. ## The pre-market perp stays solvent `PreMarketPerp` has no counterparty; its USDG balance is all a winner can be paid from. Leverage is capped at 3x and a market's open interest at 5 times its virtual depth, which is at least 10 000 USDG. A third of each 30 bps taker fee feeds an insurance fund that absorbs bad debt first; what it cannot absorb becomes a deficit that the next profitable closers pay out of their profit, never their collateral. A payout never touches another position's collateral or the fund: whatever the contract cannot pay now becomes a `deferred` claim, collected with `claimDeferred`. Claims already waiting are set aside before any later close is paid, so USDG a realised loss frees goes to them first; among themselves they are paid first come, first served. The index, a 30-minute TWAP times Chainlink ETH/USD, drives funding, capped at 1 % per hour and at most 24 hours charged at once. A liquidation must hold at both the mark and the index, so with the index unreadable liquidations wait while `close` stays open. Details are in [Graduation and pre-market perps](https://levee.trade/docs/graduation). ## Off-chain keys Perp orders go through a signer service that holds a per-user Lighter order key, encrypted at rest. That key can trade and cannot withdraw. The signer fails closed without a current mark, refuses a signed price more than `LIMIT_PRICE_BAND_BPS` (1 000 bps by default) from it or an order above `MAX_ORDER_NOTIONAL_USD` ($250 000 by default), and gives a market order at least 100 bps of slippage while rejecting requests above 1 000 bps. Every perp write to the API carries an EIP-712 signature from your wallet, at most 300 s old and with a fresh nonce. See [Perpetuals](https://levee.trade/docs/perps). ## How it is tested - **Unit and fuzz tests.** 45 Foundry test files and more than 450 test functions, against locally built Uniswap v3 (compiled with solc 0.7.6 so pools keep the canonical init code hash) and v4. Fuzz tests take 256 runs; each top-level call is its own transaction so transient storage clears. - **Invariants.** Four suites run with `fail_on_revert = true`: the v3 zap holds no ETH, WETH or USDC and keeps no allowances; the v4 zap holds no ETH or USDC; a harvest never lowers `totalAssets()` beyond 8 wei of rounding, and a vault with no shares holds no position and almost no assets; the perp's balance covers collateral plus insurance fund less deficit. Each suite also asserts that enough calls actually landed. - **Fork tests.** Suites under `test/fork` and `test/venue` run against Robinhood Chain when `ROBINHOOD_RPC_URL` is set. Without it the venue suites are marked skipped, while the seven under `test/fork` pass without asserting anything and print `SKIP:`, so a green run means little without the RPC. One of them funds a hedged vault's margin into Lighter's real bridge, checks that the deposit opens the funder's Lighter account and not one for the vault, and brings margin back through `returnHedge`. - **Shared fixtures.** `contracts/fixtures/zap.json` and `payoff.json` hold the zap and note payoff maths bit for bit identical in Solidity and in `@levee/core`. - **Drift guards.** Tests fail when an API query names a field the indexer schema lacks, when a wire type differs between API and web, or when a page calls a function the generated ABI does not have. ## Open issues and limits - **Hedge margin in an operator account.** `fundHedge` deposits through the bridge's real `deposit(address, uint16, uint8, uint256)` (USDG asset 3, perp route 0) to the vault's funder, because a contract-owned Lighter account could never bind an API key. Until Lighter supports accounts owned by a contract or a delegated owner, the margin on Lighter depends on the funder key. The Income note is not open yet: its long has no funding path on chain at all. - **Withdrawal size.** A hedged vault sizes exits against its on-chain holdings, so a withdrawal larger than them reverts `Shortfall` until margin comes back from Lighter. - **The first depositor** into a vault with no shares prices themselves, with no TWAP check. Shares carry three more decimals than the asset (1 000 virtual shares per virtual asset unit), so donating to a near-empty vault to round the next depositor down costs about a thousand times what it can take. - **Deferred perp profits** are paid after the deficit haircut and only from realised losses; claims waiting are paid before later closes, and among themselves first come, first served. - **A frozen feed** that keeps a positive answer is believed for up to 25 h. - **Polish items** from the security review: the decimals offset on the ERC-4626 vaults, the bound on the zaps' `maxSlippageBps` and the reentrancy guards on the report functions are in; a nonce on signer requests is still open. ## Reference Parameters: Security constants and deploy values - `TWAP_WINDOW` (allowed: constant; default: 30 min): Window TwapGuard averages over, when the ring allows. - `MIN_TWAP_WINDOW` (allowed: constant; default: 10 min): Shortest history TwapGuard accepts. - `MAX_TWAP_DEVIATION_BPS` (allowed: constant bps; default: 300): Spot against the mean, compared as prices. - `MIN_OBS_CARDINALITY` (allowed: constant; default: 60): Ring size the factory and pipeline grow pools to. - `MAX_FEED_AGE` (allowed: constant; default: 25 h): Oldest Chainlink answer ChainlinkGuard accepts. - `maxHedgeAge` (allowed: per vault, fixed; default: 1 h): Report age that pauses a hedged vault or Income note. - `maxEquityJumpBps` (allowed: per vault, fixed bps; default: 1 500): Report move that pauses it. - `maxFundBpsPerCall / PerPeriod` (allowed: per vault, fixed bps; default: 1 000 / 2 500): fundHedge caps per call and per rolling day. - `cap` (allowed: per vault, fixed; default: none): Deposit limit, set once at creation. The seed, hedged and graduated vaults and the Protected note have none; the Income note deploys at 0, not open yet. - `MAX_SLIPPAGE_BPS` (allowed: constant bps; default: 5 000): Highest maxSlippageBps a zap accepts. - `SHARE_DECIMALS_OFFSET` (allowed: constant; default: 3): Extra share decimals on the ERC-4626 vaults: 1 000 virtual shares per virtual asset unit. - `MAX_PROTOCOL_SHARE_BPS` (allowed: constant bps; default: 5 000): Highest protocol share of a swap fee the owner can set. - `MAX_BOUNTY_BPS` (allowed: constant bps; default: 300): Highest bounty a kept position can offer. Where it lives: - `contracts/src/libraries/TwapGuard.sol`: the price guard, its window and its verdicts - `contracts/src/libraries/ChainlinkGuard.sol`: how the vaults and the perp read a feed - `contracts/src/HedgedLPVault.sol`: the reports, the funder caps and the pause - `contracts/src/venue/core/ProtocolConfig.sol`: the venue owner, the pause and the protocol share - `contracts/src/venue/core/AssetRegistry.sol`: stock feeds, fees, bands and sessions - `contracts/src/venue/periphery/FeeCollector.sol`: the owner-set fee routes - `contracts/src/PreMarketPerp.sol`: the perp bounds, payouts and deferred claims - `contracts/script/LeveeWiring.sol`: roles, hand-over and every deploy value above - `contracts/test/invariants`: the four invariant suites - `contracts/foundry.toml`: runs, depth, fail_on_revert and isolation - `services/signer/README.md`: the signer trust model and its bands - `docs/LAUNCH-TODO.md`: the open review items --- # Architecture Source: https://levee.trade/docs/architecture · Reference The contracts and services, which one calls which, and how a write travels from the app to the chain. This chapter is the map for engineers: every contract Levee deploys, the programs that run around them, and the two paths data takes, a write from your wallet to a contract and a read from the chain back to a page. Every other chapter zooms into one part of it. Every name in `code` can be found with a search of the repository. In brief: - `contracts/src` holds 31 deployable contracts: 12 in the liquidity-management layer (position tools, vaults, launch tracking, pre-market perps, fees) and 19 under `src/venue` for Levee's own pools, routing and the $LEVEE buyback. One call, `LeveeWiring.deployAll`, deploys 27 of them. The rest are made later by factories: DLMM pairs, pool vaults, launch tokens and vault clones. - Your browser sends your transactions itself, through one hook (`useWrite`) that simulates each call before your wallet opens. No Levee server signs anything for you. - Pages read from the API. The API reads the indexer over GraphQL, the chain over RPC, three market-data feeds and its own database. The browser never queries the indexer. - Three kinds of operator key are named at deploy: an owner (cold, owns four venue admin contracts), a hedger (the bot key on the API host) and one funder per hedged vault (cold; moves that vault's margin to Lighter and owns the Lighter account it lands in). The signer also holds one Lighter order key per account, which can trade and cannot withdraw. - The maths every page shows is written once, in `@levee/core`. ABIs, addresses and the deployment manifest are written once, in `@levee/abi`. ## The map Diagram: How the pieces connect. Solid arrows are calls or transfers; dashed arrows are reads and event streams. Nothing off chain touches a contract except through the RPC: the browser sends your own transactions, the bots send theirs, and the indexer reads the logs. Hover a box to see only its links. Read it from the top. The browser, the API and its bots, the indexer and the signer sit off chain, and all of them reach the contracts through the Robinhood Chain RPC. On chain, the contracts fall into two families with separate histories. The liquidity-management contracts in `contracts/src` work on top of Uniswap v3 and v4. The venue in `contracts/src/venue` is Levee's own set of pools, router and fee path. They meet at one point: `FeeRouter` collects the fees of the first family and names the venue's `FeeCollector` as its treasury, so whatever referrers do not earn joins the pools' protocol fees on the way to the $LEVEE buyback. Nothing in `contracts/src` is behind a proxy, and nothing can be upgraded. The liquidity-management contracts have no owner and no admin function. In the venue, `ProtocolConfig`, `AssetRegistry`, `FeeCollector` and `BuybackV2` are `Ownable2Step`. Their owner can tune parameters, steer fees and pause swaps and new liquidity. Withdrawals never check the pause. [Security model](https://levee.trade/docs/security) lists every power that key has. ## Contracts by job Each table names who creates the contract, what it is for and what it calls. "deployAll" means the contract is deployed once by `LeveeWiring.deployAll`. "deployVenue" means the venue half of that same call. ### Position tools | Contract | Created by | What it does | Calls | | --- | --- | --- | --- | | `LeveeZapV3` | deployAll | Takes one token, skims 25 bps, swaps the excess side, then mints or tops up a v3 position. Holds nothing between calls. | NPM, SwapRouter02, `FeeRouter` | | `LeveeZapV4` | deployAll | The same over the v4 `PoolManager`, hooks included. | PoolManager, v4 PositionManager, Permit2, `FeeRouter` | | `RangeOrders` | deployAll | Holds a one-sided v3 position as a limit order that earns fees until the price crosses it. Anyone may `fill`; only the owner may `cancel`. | NPM, `FeeRouter` | | `PositionKeeper` | deployAll | Lets anyone `harvest` or `rebalance` an enrolled v3 position under the owner's policy, for a bounty of at most 300 bps. | NPM, SwapRouter02, `FeeRouter` | Details: [One-token deposits](https://levee.trade/docs/zap), [Limit orders](https://levee.trade/docs/range-orders), [Position automation](https://levee.trade/docs/keeper). ### Vaults | Contract | Created by | What it does | Calls | | --- | --- | --- | --- | | `VaultFactory` | deployAll | Clones the three vault implementations (EIP-1167) and initialises each clone in the same transaction. Anyone may create a vault, so the factory's list is not a recommendation. | the implementations | | `LPVault` | `VaultFactory` | ERC-4626 shares over one v3 range, priced behind a TWAP guard, harvested and rebalanced by anyone. | NPM, SwapRouter02, `FeeRouter` | | `HedgedLPVault` | `VaultFactory` | An `LPVault` whose price exposure is shorted on Lighter, in its funder's Lighter account. The hedger reports the short; the funder moves margin. | Lighter deposit (through `HedgeFunding`), Chainlink ETH/USD | | `StructuredVault` | `VaultFactory` | A dated note: an `LPVault` sleeve plus an off-chain long (`Income`) or SGOV (`Protected`), unwound at maturity. Leaving early costs 50 bps. | its `LPVault`, SGOV pool, Chainlink, `FeeRouter` | Details: [Vaults](https://levee.trade/docs/vaults), [Hedged vaults](https://levee.trade/docs/hedged-vaults), [Structured notes](https://levee.trade/docs/structured). ### Launch tracking and pre-market perps | Contract | Created by | What it does | Calls | | --- | --- | --- | --- | | `LaunchPipeline` | deployAll | `track` records when a token's WETH pool was first seen. `graduate` clones an `LPVault` over it once the token is 3 days old and its pools hold `MIN_LIQUIDITY`. | v3 factory, `VaultFactory` | | `PreMarketPerp` | deployAll | A virtual-AMM perp for tokens no venue lists: USDG margin, 3x, an index price from a 30 min v3 TWAP times Chainlink ETH/USD. Anyone may open a market. | v3 pools, Chainlink, `FeeRouter` | Details: [Graduation and pre-market perps](https://levee.trade/docs/graduation). ### Levee pools and routing | Contract | Created by | What it does | Calls | | --- | --- | --- | --- | | `ProtocolConfig` | deployVenue | The venue's switches: pause, the protocol share of every pool fee (2 000 bps at deploy, at most 5 000) and the `FeeCollector` address. | none | | `AssetRegistry` | deployVenue | The curated list of oracle-priced assets and the quotes they may pair with. USDG is $1; ETH and WETH use Chainlink ETH/USD. | Chainlink | | `DammHook` | deployVenue, at a mined CREATE2 address | Permissionless dynamic-fee v4 pools: a 5 to 100 bps base fee, an optional anti-snipe start fee and a volatility surcharge, capped at 500 bps outside the snipe window. | PoolManager, `ProtocolConfig` | | `StockHook` | deployVenue, at a mined CREATE2 address | v4 pools for registry assets. The fee follows the market session, and a swap must end inside the oracle band or move the price toward it. | PoolManager, `AssetRegistry`, `ProtocolConfig` | | `DlmmFactory`, `DlmmPair` | deployVenue; pairs by anyone | Discrete-bin pools with a fixed price per bin, one pair per token pair and bin step. | `ProtocolConfig` | | `DlmmPositionNFT` | deployVenue | ERC-721 positions over a contiguous range of bins. | `DlmmPair` | | `DlmmLimitOrders` | deployVenue | Limit orders as single-bin liquidity, batched per bin. Anyone may `execute` a filled batch. | `DlmmPair` | | `Router` | deployVenue | Exact-in, multi-hop swaps across v4 pools (Levee hooks, Pons graduated pools and others), DLMM pairs and Pons bonding curves (through the `PonsAdapter` library). Holds nothing and charges nothing. | PoolManager, `DlmmPair`, Pons | Both hooks and every DLMM pair send the protocol share of each fee straight to `FeeCollector`. Details: [Levee pools](https://levee.trade/docs/pools), [Swaps and routing](https://levee.trade/docs/swaps). ### Pool vaults and launches | Contract | Created by | What it does | Calls | | --- | --- | --- | --- | | `DlmmVaultFactory`, `DlmmVault` | deployVenue; vaults by the owner | ERC-20 shares over the bins around the active one. Only the factory's keeper (or the owner) rebalances, at most every 5 min. | `DlmmPair` | | `DlmmVaultZap` | deployVenue | One-token deposits into, and withdrawals out of, a DLMM vault. | `Router` | | `StockVaultFactory`, `StockVault` | deployVenue; vaults by the owner | Passive liquidity in one stock pool, centred on the oracle price and wider while the market is closed. | PoolManager, `StockHook` | | `LaunchPools`, `LaunchToken` | deployVenue; tokens by `launch` | A fixed-supply token and its locked native-ETH DAMM pool in one transaction, with an optional first buy. | PoolManager, `DammHook` | | `LaunchVault` | deployVenue | Pools ETH before a launch, buys the token as the pool's first swap and shares it pro rata. | `LaunchPools` | Details: [Levee pools](https://levee.trade/docs/pools), [Launch a token](https://levee.trade/docs/launch). ### Fees and $LEVEE | Contract | Created by | What it does | Calls | | --- | --- | --- | --- | | `FeeRouter` | deployAll | The sink for every protocol fee of the position tools, vaults and pre-market perps. 20 % is credited to the user's referrer; the rest belongs to the treasury, and anyone can `flush` it there. | `ReferralRegistry`, `FeeCollector` | | `ReferralRegistry` | deployAll | Write-once referral codes and write-once user-to-referrer links. | none | | `FeeCollector` | deployVenue | Receives the pools' protocol share and `FeeRouter`'s flushes. Anyone can `convert` a token to ETH along the owner's route; the ETH goes to `BuybackV2`. | `Router`, `AssetRegistry` | | `BuybackV2` | deployVenue | Spends up to 0.05 ETH per call on $LEVEE in its native-ETH v4 pool, burns every token bought and pays the caller 50 bps. `poke` moves its reference price. | PoolManager | Details: [Buyback and burn](https://levee.trade/docs/protocol), [Fees and referrals](https://levee.trade/docs/fees). ## Who holds a key | Role | Set from | Can | Cannot | | --- | --- | --- | --- | | Owner | `OWNER`, proposed by `handOver`, accepted with `acceptOwnership` | pause the venue, tune fees and price guards, list stocks, set fee routes, sweep `FeeCollector`, create pool vaults, name their keeper | touch a liquidity-management contract or a user position; block a withdrawal | | Hedger | `HEDGER`, the address of the API's `BOT_PRIVATE_KEY` | `reportHedge` on hedged vaults, `reportSleeve` on notes, rebalance DLMM and stock vaults as their keeper | move vault funds | | Funder, one per hedged vault | `FUNDER_NVDA_HEDGED`, `FUNDER_SPY_HEDGED` (default `FUNDER`), never a hot key or each other | `fundHedge`: send margin to its own Lighter account, at most 10 % of on-chain assets per call and 25 % per day; off chain, withdraw that margin | report equity | | Order keys | generated by the signer, one per account | place and cancel Lighter orders | withdraw from Lighter | `DeployRobinhood` refuses to run if the owner, hedger or a funder is the deployer, if a funder is the hedger, the keeper or the owner, or if the two hedged vaults share a funder. ## The programs off chain | Program | Code | Runs on | Job | Holds | | --- | --- | --- | --- | --- | | Web app | `apps/web` | Next.js 15, wagmi, viem; Privy for email login, or RainbowKit when no Privy app id is set | Every page and every user transaction | nothing; your wallet signs | | API | `apps/api` | Fastify on `:8792`; Postgres, or memory when `DATABASE_URL` is unset | The routes pages read, the perp proxy, Ideas and points | `SIGNER_SECRET`, `ADMIN_SECRET` | | Bots | `apps/api/src/bots` | inside the API process, only when enabled | Seven loops: keeper, hedger, graduator and four pool duties | `BOT_PRIVATE_KEY`, the hedger | | Indexer | `apps/indexer` | Ponder 0.16 on `:42072`; Postgres, or PGlite | Logs into tables, served as GraphQL to the API alone | nothing | | Signer | `services/signer` | FastAPI on `:8793` | Lighter onboarding and order signing, with no code path that withdraws | Lighter order keys, Fernet-encrypted | [Indexer, API and bots](https://levee.trade/docs/offchain) covers the first four in depth; [Perpetuals](https://levee.trade/docs/perps) covers the signer. ## A write, from click to block Every button that changes chain state calls `useWrite().run(...)` in `apps/web/lib/tx.ts`. There is no second path. 1. Gate: `canWrite` checks for a connected wallet, chain 4663 (development builds also allow 31337) and a deployment manifest. If one is missing, the button says which. 2. Simulate: The hook runs `simulateContract`, an `eth_call` from your address. A revert stops here: it costs nothing and your wallet never opens. 3. Sign and send: Your wallet signs the simulated request and sends it through the RPC. A toast links the transaction on the explorer. 4. Refresh: The query keys the write affects are refetched at 1.5 s, 4 s and 10 s, the time the indexer needs to see the new logs. For anything a transaction must get exactly right, the browser reads the chain directly instead of asking the API: balances, allowances, vault previews, router quotes, the active DLMM bin. The API never sends a transaction for a user. Its POST routes store a signed Idea, forward a signed perp request to the signer, return a mirror plan for your wallet to send, record the contact form or, with the admin secret, recompute points. ## A read, from chain to page 1. A page hook in `apps/web/lib/data.ts` calls a typed fetcher in `apps/web/lib/api.ts` against `NEXT_PUBLIC_API_URL` (`http://127.0.0.1:8792` by default), with a 15 s stale time and no retry. 2. The API route answers from the indexer (GraphQL documents in `src/indexer/queries.ts`), from chain reads batched through Multicall3, from market data (Lighter first, then Hyperliquid or Yahoo) or from its database. A cached route keeps its answer for 15 to 60 s. 3. The route maps its own model to the exact shape `api.ts` declares (`routes/wire.ts`), and a test fails if either side renames a field. 4. If the API does not answer, a development or staging build shows a fixture with a "Sample data" tag. A production build never does: it shows the empty state and a status notice instead. The indexer is fed by the chain alone. It subscribes to the Uniswap factory, position manager and `PoolManager`, to every Levee contract in the manifest, and to the Pons factory, and folds their logs into tables. ## Shared packages ### `@levee/core` Pure TypeScript with no I/O, imported by the web, the API and the indexer. It holds tick maths and range shapes, zap maths, impermanent loss, fee APR, the Seasoned screen (`isEstablished`), Carry (`basis.ts`), LP delta and hedge sizing (`hedge.ts`), keeper policy predictions (`policy.ts`), note payoffs, DLMM bin maths, the perp request message (`perpAuth.ts`), slippage, market hours, position sizing and backtests. A number on a card and the number a bot acts on come from the same function. Two Foundry tests write fixtures when run with `WRITE_FIXTURES=true`: `contracts/fixtures/zap.json`, which `packages/core/src/zapMath.test.ts` asserts the TypeScript port against, and `contracts/fixtures/payoff.json`, eight points of each `StructuredVault.payoffCurve` mode that `payoff.ts` mirrors. ### `@levee/abi` and the manifest - `src/generated/` holds the compiler ABIs of every contract with an off-chain caller. `contracts/export-abis.sh` writes them, and they are committed, so no package needs Foundry to build. - `src/addresses.ts` holds the fixed facts: chain 4663, the public RPC, the Uniswap contracts, tokens, Chainlink feeds, Lighter, Multicall3, Pons and the stock list. - `src/manifest.ts` types the deployment manifest and validates it with `loadManifest`, which names the bad key when it throws. The manifest is the one file that says where a deployment lives: `chainId`, `deployBlock`, the owner, treasury, hedger and funder, each hedged vault's funder (`funders`), the three vault implementations, `addresses` (with seed vaults by name) and `venue`. The API reads it from `MANIFEST_PATH`, the indexer from `PONDER_MANIFEST`, the web from `apps/web/deployments/manifest.json` or `NEXT_PUBLIC_MANIFEST_URL`. Without one, writes are disabled, the bots stay off, the manifest routes answer empty and the indexer sees only Uniswap. ### Deployed addresses Read from the manifest this site loaded, so the table is always the deployment you are using: Core: - FeeRouter: 0x808C6a4451645B34895f1690D9F617E29CcefD71 · https://levee.trade/abi/FeeRouter.json - ReferralRegistry: 0x7395da0F38B3729380505d374975d39327c2831B · https://levee.trade/abi/ReferralRegistry.json - LeveeZapV3: 0x80999E61D250B413D9A1544cfb8C0CA99C8D9159 · https://levee.trade/abi/LeveeZapV3.json - LeveeZapV4: 0xdeF6A64413F25c01bD50803dee93ee1579769402 · https://levee.trade/abi/LeveeZapV4.json - RangeOrders: 0x1D68c0455C1668242F0E3121ccDfC19161Fa44D1 · https://levee.trade/abi/RangeOrders.json - PositionKeeper: 0x28B43Db87B6F6Fb10EcF1Fe38F042300A7219547 · https://levee.trade/abi/PositionKeeper.json - VaultFactory: 0x8823EBA2DEc5707FFdAC48F44E2090E36a05389a · https://levee.trade/abi/VaultFactory.json - LaunchPipeline: 0x6fB95366daBB8Bc3b9F6d5574b994DF7d83A2727 · https://levee.trade/abi/LaunchPipeline.json - PreMarketPerp: 0x458a99F4A55336aD87ae0C08C4d800b23aAFa767 · https://levee.trade/abi/PreMarketPerp.json Vaults: - ETH Vault: 0x98A496a35A235A01B02a8Eae15E8a90A929f4C21 · https://levee.trade/abi/LPVault.json - Income NVDA: 0x23f30D31CC3022B164AbaD2b9f0DA58250D3335a · https://levee.trade/abi/StructuredVault.json - NVDA Hedged: 0xC27081350872e9a330b6f0a68cAA9EDF644fd696 · https://levee.trade/abi/HedgedLPVault.json - Protected SPY: 0x3dd2a1a9e6514D7E0143bcb3D607680ee05c9143 · https://levee.trade/abi/StructuredVault.json - SPY Hedged: 0xCc57824cFBF35B23F299AffBCD3fDf4505a9A2C7 · https://levee.trade/abi/HedgedLPVault.json - USDG Vault: 0x472652CF38626321De89D0ef9e4a6736910919Ba · https://levee.trade/abi/LPVault.json Levee pools and router: - ProtocolConfig: 0xf3784EfF7AE924A15B6b9219D806b52e377D9A8C · https://levee.trade/abi/ProtocolConfig.json - AssetRegistry: 0x409A0a75478C4abc4a01f10dde613811bBFD6281 · https://levee.trade/abi/AssetRegistry.json - DammHook: 0x6FAB5Ceb23bC1d7bf2ffA547807C8CE48d5228cc · https://levee.trade/abi/DammHook.json - StockHook: 0xCd93565739D217CCBaddf786D7e4CDCb700628cc · https://levee.trade/abi/StockHook.json - DlmmFactory: 0x088BbcE10be0792EF5d8334dB1edE2934d2Cf04a · https://levee.trade/abi/DlmmFactory.json - DlmmPositionNFT: 0x7A0BF9ffaDB58C4cAb5662749c0cb57aa0AfFdE5 · https://levee.trade/abi/DlmmPositionNFT.json - Router: 0x936316bbA997110e3A8e9B170c60A6CAF5b0cF09 · https://levee.trade/abi/Router.json - FeeCollector: 0x2886389c20F0b94Fc4D363cEDE6367A0BB3ff55A · https://levee.trade/abi/FeeCollector.json - BuybackV2: 0x77B70D723D1c13e71fAcd4C811D88218Bb66cbA6 · https://levee.trade/abi/BuybackV2.json - DlmmLimitOrders: 0x55e7A04CbA97c5a180fA79d66A3F914910200A93 · https://levee.trade/abi/DlmmLimitOrders.json - DlmmVaultFactory: 0x488eA417DfaA92db4f3f8660822b1d7F767bE061 · https://levee.trade/abi/DlmmVaultFactory.json - DlmmVaultZap: 0x7CA6107c9de34F06ea74644aE0fC55b7F0D9b598 · https://levee.trade/abi/DlmmVaultZap.json - LaunchPools: 0x168a1213f30c6e79232406e795a09a3952Dcd76a · https://levee.trade/abi/LaunchPools.json - LaunchVault: 0xDC3A2e812a5D268ECe618461679a210947b90a51 · https://levee.trade/abi/LaunchVault.json - StockVaultFactory: 0x70063A8f8F2dDec2c0a66a43e2AEB627cb3727e1 · https://levee.trade/abi/StockVaultFactory.json Vault implementations (every vault clone delegates to one): - HedgedLPVault implementation: 0x7658D0190d9f0B2A0cdCEF7296D3efbd21BaA400 · https://levee.trade/abi/HedgedLPVault.json - LPVault implementation: 0x0f8DC4E67D06e1181dEaC49f5482453Bf60cF1DD · https://levee.trade/abi/LPVault.json - StructuredVault implementation: 0x203d6aDF5f255f584F540e764aE68A5A1d63EfC4 · https://levee.trade/abi/StructuredVault.json ## Deploying the stack `DeployRobinhood.s.sol` and `DeployLocal.s.sol` both call `deployAll` in `contracts/script/LeveeWiring.sol`. It never starts a broadcast and never reads the environment, so the same sequence runs under a broadcast, a dry run and a fork test. 1. The venue: `VenueWiring.deployVenue` deploys `AssetRegistry`, `BuybackV2`, `Router`, `FeeCollector` and `ProtocolConfig`, then both hooks at CREATE2 addresses whose low bits are their permission flags, then the DLMM, launch and stock-vault contracts. It sets the quotes, lists every stock with default risk parameters and names the hedger as keeper of both vault factories. 2. Fees: `ReferralRegistry`, then `FeeRouter` with the venue's `FeeCollector` as its treasury. 3. Position tools: Both zaps, `RangeOrders` and `PositionKeeper`. 4. Vaults and launches: The three vault implementations, then `VaultFactory`, which takes them as constructor arguments because their combined creation code is over the initcode limit. Then `LaunchPipeline` and `PreMarketPerp`. 5. Seed vaults: Best effort: the ETH and USDG vaults on WETH/USDG 0.05 %, NVDA and SPY Hedged on each token's deepest WETH pool, Income NVDA and Protected SPY. A missing pool logs a line and skips that vault. 6. Hand over: `handOver` proposes the owner for the four venue admin contracts. Until the owner calls `acceptOwnership`, the deployer still holds them. `writeManifest` then records the result. `DeployLocal` also runs `VenueSeed`, which seeds pools, a deposit, a resting DLMM limit order and a launch on a local fork. `Canary.s.sol` is the post-deploy smoke test: a tiny zap, a range order placed and cancelled, and a harvest on the ETH vault's pool. ## Reference ### Deploy values Parameters: contracts/script/LeveeWiring.sol and script/venue/VenueWiring.sol - `CURVE_HALF_TICKS` (allowed: ticks; default: 2231): Half-width of every seed vault range (about ±25 %), snapped to the pool spacing. - `HYSTERESIS_SPACINGS` (allowed: spacings; default: 10): Seed vault hysteresis, as a multiple of tick spacing. - `MIN_INTERVAL` (allowed: seconds; default: 6 h): Least time between two keeper actions on a seed vault. - `MAX_SLIPPAGE_BPS` (allowed: bps; default: 100): Swap bound on a seed vault rebalance. - `BOUNTY_BPS` (allowed: bps; default: 50): Bounty the seed vault policy advertises to bots. - `NO_CAP` (allowed: asset units; default: type(uint256).max): The cap of the ETH and USDG vaults and both hedged vaults: none. - `PROTECTED_NOTE_CAP / INCOME_NOTE_CAP` (allowed: WETH; default: NO_CAP / 0): The Protected note opens with no cap; the Income note is not open yet. - `MAX_HEDGE_AGE` (allowed: seconds; default: 1 h): A hedge or sleeve report older than this pauses the vault. - `MAX_EQUITY_JUMP_BPS` (allowed: bps; default: 1500): One report moving equity by more than 15 % pauses the vault. - `MAX_FUND_BPS_PER_CALL / PER_PERIOD` (allowed: bps; default: 1000 / 2500): fundHedge caps: 10 % of on-chain assets per call, 25 % per day. - `NOTE_TERM` (allowed: seconds; default: 90 days): Maturity of both seed notes, counted from the deploy block. - `INCOME_SLEEVE_BPS / PROTECTED_SLEEVE_BPS` (allowed: bps; default: 8000 / 8500): Share of a note in its LP sleeve (Income) or in SGOV (Protected). - `MIN_LIQUIDITY` (allowed: liquidity; default: 10 ether): In-range liquidity LaunchPipeline needs before a graduation, summed over four fee tiers. - `BUYBACK_MAX_ETH_PER_CALL` (allowed: wei; default: 0.05 ETH): Most one buyback call may spend. - `STOCK_OPEN / CLOSED / STALE_FEE_BPS` (allowed: bps; default: 30 / 150 / 500): Default stock pool fee by session; the owner can tune each asset. - `STOCK_OPEN / CLOSED_MAX_DEV_BPS` (allowed: bps; default: 200 / 50): Default oracle band of a stock pool, open and closed. ### Ports and environment | Program | Port | Points at | | --- | --- | --- | | API | `PORT` 8792 | `INDEXER_URL` (`http://127.0.0.1:42072`), `RPC_URL`, `MANIFEST_PATH`, `SIGNER_URL` (`http://127.0.0.1:8793`) | | Indexer | 42072 | `PONDER_RPC_URL`, `PONDER_MANIFEST`, `DATABASE_URL` | | Signer | 8793 | `SIGNER_SECRET`, shared with the API | | Web | 3000 (Next.js default) | `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_RPC_URL`, `NEXT_PUBLIC_CHAIN_ID`, `NEXT_PUBLIC_PRIVY_APP_ID` | Where it lives: - `contracts/src/`: the liquidity-management contracts and their libraries - `contracts/src/venue/`: pools, router, pool vaults, launches and the fee path - `contracts/script/LeveeWiring.sol`: deployAll, the seed vaults and writeManifest - `contracts/script/venue/VenueWiring.sol`: deployVenue and handOver - `apps/web/lib/tx.ts`: useWrite, the one write path - `apps/web/lib/data.ts`: page hooks and the fixture rule - `packages/core/src/`: the shared maths - `packages/abi/src/manifest.ts`: the manifest type and loadManifest - `contracts/export-abis.sh`: how the generated ABIs are written --- # Indexer, API and bots Source: https://levee.trade/docs/offchain · Reference Events into tables, the routes the app reads, and the seven bots that keep positions, hedges and pools current. Three programs sit between the chain and the page. The indexer turns contract logs into tables. The API turns those tables, live chain reads, market data and its own database into the JSON every page reads. Seven bots inside the API process send the routine transactions that keep positions, hedges and pools current. None of them holds your funds or can sign for you, and of everything the bots send, only two kinds of call need their key. In brief: - The indexer (Ponder 0.16, `:42072`) reads Uniswap, Levee and Pons logs. The API is its only reader; the browser never queries it. - The API (Fastify, `:8792`) maps every answer to the exact types `apps/web/lib/api.ts` declares. No route sends a transaction for you: the writes store a signed Idea, forward a signed perp request, return calldata for your wallet or record a message. - Bots start only when `BOTS`, `BOT_PRIVATE_KEY` and `MANIFEST_PATH` are all set. Every send is simulated first, and a send paid by a bounty goes out only when the bounty is at least 3 times its gas cost. - The hedger reports each hedged vault every 15 min. A seed vault whose last report is over 1 h old (`maxHedgeAge`) pauses itself, with no transaction needed. - Only `reportHedge` and the rebalance of DLMM and stock vaults need the bot's key. Every other bot action is a public function that pays whoever calls it, or nobody. ## The indexer `apps/indexer` is a Ponder 0.16 app. One handler per event writes Postgres (or the bundled PGlite under `.ponder/` when `DATABASE_URL` is empty), and the tables are served as GraphQL on port 42072. Every GraphQL document the API sends lives in `apps/api/src/indexer/queries.ts`, so the indexer's schema can change without any browser noticing. Diagram: From logs to tables. Left: the logs the indexer subscribes to, by source. Middle: the tables they fold into (ponder.schema.ts). The API is the only reader of the GraphQL endpoint; the browser never queries it. Every Levee source starts at the manifest deployBlock, and with no manifest it is capped at block 0 and fetches nothing. ### Sources | Family | Contracts | Starts at | Writes | | --- | --- | --- | --- | | Uniswap | v3 factory and every pool it creates (a Ponder factory source over `PoolCreated`), the v3 position manager, the v4 `PoolManager`, the v4 `PositionManager`'s `Transfer` | `PONDER_POOLS_START_BLOCK`, by default the manifest's `deployBlock` | `pool`, `poolHourStat`, `priceCache`, `position`, `rangeUptime`, `positionEvent`, `feesCollected`, `v4Position` | | Levee contracts | `FeeRouter`, `ReferralRegistry`, `RangeOrders`, `PositionKeeper`, `VaultFactory`, every vault clone, `LaunchPipeline`, `PreMarketPerp` | the manifest's `deployBlock` | `rangeOrder`, `keeperEnrollment`, `keeperAction`, `vault`, `vaultShare`, `vaultEvent`, `graduation`, `perp*`, `referral` | | Levee pools | `DammHook`, `StockHook`, `DlmmFactory` and every pair it creates, `Router`, `FeeCollector`, `BuybackV2` | `deployBlock`, when the manifest has a `venue` | `ammPool`, `ammSwap`, `routerSwap`, `feeConversion`, `buyback`, `ammStat` | | Pons | the Pons factory: launches and graduations | `PONS_LAUNCH_START_BLOCK` and `PONS_GRADUATION_START_BLOCK` | `ponsToken` | The Levee sources take their addresses from `PONDER_MANIFEST` and their ABIs from `@levee/abi`. With no manifest they are still declared, at the zero address and capped at block 0, so they fetch nothing while the generated event types stay the same. Vault clones are one source over `VaultFactory.VaultCreated`, using the union of the three vault ABIs; three sources would index every vault three times. DAMM and stock pools are v4 pools, so the `PoolManager` handlers pass any pool whose hook is Levee's to the pool handlers, and the hook's own fee event completes the swap row in the same transaction. Pons token metadata is filled in by two batched block jobs, about every 2 min live, instead of one RPC call per event. ### How derived fields are computed Handlers stay thin; anything with an edge case is a pure function in `src/logic/`, tested from `test/`. Tests cannot sit next to the code, because Ponder runs every file under `src/` except `src/api/` as an indexing file. - **Prices.** USDG is $1 by definition. WETH is the latest WETH/USDG price, stored under the id `'WETH'`. Any other token in a pool quoted by USDG or WETH is pool price times the quote's price. Volume is counted on the quote side only, so a pushed pool price cannot inflate it; a pool with no USDG or WETH side adds 0 USD. - **Pool TVL.** While a pool has at most 200 open positions (`TVL_EXACT_POSITION_LIMIT`), TVL is the exact value of those positions. Above that, the active liquidity is valued as one position spanning 953 ticks (about ±10 %) around the current tick. v4 pools always use the second method: liquidity added to a v4 pool outside the position manager (by a hook or a custom router) has no position in the logs to sum. TVL refreshes on `Mint`, `Burn` and the first swap of each hour. - **v4 positions.** A v4 position NFT logs no liquidity of its own. The position manager calls the `PoolManager` with the token id as the salt, so its `ModifyLiquidity` (sender = the position manager) carries the pool, the ticks and the liquidity delta, and the `Transfer` carries the owner. The principal is recomputed from the delta at the pool price. Fees are not columns: v4 pays them out inside each liquidity change, and the logs do not itemise them. `levee` marks rows in a DAMM or stock pool. - **APR and Seasoned.** `poolHourStat` is an hourly rollup; the 24 h window is the last 24 buckets. `apr24hPct` is `feeAprFromPool` from `@levee/core`, and `established` is `isEstablished`: at least 3 days old, market cap above $1M and at least 100 trades in 24 h. A token with no known supply has no market cap and is never Seasoned. - **Range uptime.** A position earns only while the tick is in `[tickLower, tickUpper)`. Each swap credits the time since the last one if the tick before that swap was in range, for v3 and v4 positions alike. The clock only moves forward. - **Loss against holding.** The entry amounts at the first deposit's price, compared with the position now plus everything withdrawn. Collected fees are left out on purpose. Every address and hash is stored in lowercase. Token amounts, liquidity and timestamps are `bigint` and travel through GraphQL as strings. USD figures and percentages are `double precision`, whole percents, for display and ranking only. ### Rows that look wrong but are not | What you see | Why | | --- | --- | | A keeper enrollment changes its key | `PositionKeeper.Rebalanced` mints a new NFT. The old `keeperEnrollment` row gets `succeededBy`, the new one `succeeds`, and the lifetime totals carry over. | | A perp is `liquidated`, never `closed` | `Liquidated` fires before `Closed` in the same transaction, and the close handler keeps the liquidated status. | | `vault.paused` is false, yet the vault is paused | The column mirrors the `HedgePaused` and `HedgeResumed` events. A stale report pauses the vault with no transaction and no log, so compare `hedgeAsOf` with the vault's `maxHedgeAge`, or call `paused()`. | | A holder's shares look too low | Share transfers are not indexed. `vaultShare.shares` is clamped at zero, so a sent share is attributed to the wrong holder but never counted twice. | | `referral.earned` holds strings | Raw token amounts routinely exceed 2^53. A `Taken` with no referrer writes nothing, and `Claimed` is not indexed. | ### Running it Ponder reserves `/health`, `/ready`, `/status` and `/metrics`. `src/api/index.ts` adds `/healthz` (a parseable `{ ok: true }`), the GraphQL endpoint at `/` and `/graphql`, and Ponder's read-only SQL client at `/sql/*`. Gate traffic on `/ready`: it turns 200 only once the historical backfill is done, while `/health` and `/healthz` are 200 as soon as the process is up. In production the image (`apps/indexer/Dockerfile`, built from the repo root) runs as a non-root user with no `.env` file inside, and `settings.ts` stops it at start without Postgres, an explicit `PONDER_RPC_URL`, chain 4663 or a start block, or when `PONDER_MANIFEST` names a missing file. `scripts/start.sh` gives every deploy its own schema, `levee_`: Ponder will not start a changed build on a schema another build used, so the new deploy backfills beside the old one, which keeps serving until the new one's `/ready` passes. Every deploy shares the RPC cache, so that backfill is mostly local. The live deploy's tables are also published as views in `levee_indexer`, and a restart of the same deploy resumes from its last checkpoint. `apps/api/src/indexer/queries.schema.test.ts` fails when a GraphQL document in `queries.ts` asks for a column `ponder.schema.ts` does not declare, so that mistake never reaches a page as an empty card. Warning: State reads need history The NPM mint handler and `RangeOrders.Placed` call `positions()` and `orders()` at the block of the event. The public Robinhood Chain RPC keeps only recent state and throttles above about 15 requests a second, so production requires an RPC with archive state. The Uniswap sources start at the deploy block unless `PONDER_POOLS_START_BLOCK` says otherwise, and the factory sources never see a pool created before their start block, which is why the Markets page reads its pool set from the chain instead. ## The API `apps/api` is one Fastify process. `loadConfig` parses the environment once into a `Config`, and `buildApp(config, deps)` takes every dependency injected, so no module reads `process.env` on its own and the tests run the real routes against fakes. Optional pieces degrade instead of failing: no `DATABASE_URL` means in-memory storage, an unreachable indexer means empty answers and `indexer: 'down'` on `/health`, no manifest means empty vault, launch and pool answers, and `VENUE` defaults to the simulated book. Diagram: Routes and where their answers come from. Dashed arrows are reads; the long-dash stream is the response. Every route maps its own model to the shape apps/web/lib/api.ts declares before it answers, and web.types.test.ts fails when a field is renamed on either side. ### Routes The API keeps its own route names. The Markets page reads `GET /radar` and the Ideas page reads `/theses`. | Route | Serves | Answers from | Cache | | --- | --- | --- | --- | | `GET /radar` | Markets | the chain pool snapshot merged with indexer pools, Levee pool rows, market data | 15 s | | `GET /markets`, `GET /candles?market=&tf=&limit=&before=` | Trade, charts | market data; `tf` is one of `1m 5m 15m 1h 4h 1d`, `limit` 1 to 2500 (300 by default; the chart asks for 1000), paged from Lighter 500 bars at a time; `before` (unix seconds) answers the bars older than it, from Lighter, then Hyperliquid, then Yahoo, `[]` at the start of the history | 15 s, 60 s; 10 min for a `before` page | | `GET /portfolio/:address` and `/performance/…` | Portfolio | indexer positions, vault shares, range orders and enrollments; perp venue; chain reads for live pool prices, pending fees and pools the indexer lacks; entry-time candles for cost basis | none | | `GET /vaults` | Earn | manifest and graduated vaults, chain reads, the indexer's harvest log, market data | 30 s | | `GET /launch?address=` | Launch | indexer pools and graduations, chain reads of `LaunchPipeline` and `PreMarketPerp`; your positions read fresh | 30 s for the shared part | | `GET /pools?kind=`, `/pools/:id`, `/stocks`, `/protocol` | Pools, Stocks, Protocol | indexer pool tables and live pool state, priced with market data; stock oracles and buyback state read per request | shared pool load, 15 s | | `GET /pons?tab=&q=&limit=&cursor=`, `/pons/:token`, `/amm/vaults` | Launch, Earn | `ponsToken` rows; DLMM and stock vaults read from their factories | shared pool load, 15 s | | `GET /theses`, `/theses/:id`, `/theses/leaderboard`, `POST /theses`, `POST /theses/:id/mirror` | Ideas | the database, plus indexer fees and PnL for the leaderboard | none | | `GET /referral/:address` | Ideas | the indexer's `referral` row, each token priced at its mark | none | | `GET /points/:address`, `POST /points/recompute` | Ideas | points events and Ideas in the database | none | | `POST /perp/onboard`, `/bind`, `/order`, `/cancel`, `/withdraw-intent`; `GET /perp/account/:address`, `/positions/:address`, `/orders/:address` | Trade | the perp venue | none | | `POST /contact` | contact form | the database; `name` and `concern` required, `handle` optional | none | | `GET /health` | status banner | a 1 s probe of the indexer's `/ready`, reused for 15 s | 15 s | Cursors ride in the `x-next-cursor` header, so list routes answer bare arrays. `/pons` returns at most 100 rows per page (30 by default). `/health` answers exactly `{ok, venue, indexer, db, signer, markets}` (each `ok`, `down` or `off`, and 503 when the database or the signer is down) and nothing about the configuration. Every error is `{error, code}`: `bad_request` 400, `unauthorized` 401, `forbidden` 403, `not_found` 404, `conflict` 409, `rate_limited` 429, `unavailable` 503, or a more specific code such as `invalid_address`, `stale_ts`, `nonce_used`, `duplicate_thesis` or `manifest_missing`. ### The wire contract The web's types are the contract. `apps/api/src/routes/types/web.ts` copies the interfaces of `apps/web/lib/api.ts`, and `web.types.test.ts` fails when a field is added, removed or renamed on either side. The API's own models keep whole percents and `{items, nextCursor}` pages and convert on the way out (`toRadarWireRow`, `toPortfolioWire`, `routes/wire.ts`): percent fields become fractions, and a candle's `t` in milliseconds becomes `time` in seconds (a bar a fallback priced also carries `source`, `hyperliquid` or `yahoo`). ### How Markets rows are built `buildRadar` is a pure function. The pool set comes from `chain/poolSnapshot.ts`, which asks the v3 factory for every tokenized asset against USDG and WETH on the four fee tiers and reads each pool's tick, liquidity and balances in Multicall3 batches. `chain/poolStats.ts` adds 24 h volume, fees, trades, age and market cap from each pool's own `Swap` logs, read incrementally into hourly buckets. Indexer rows replace snapshot rows for the same pool, and the snapshot keeps every pool the indexer never saw. The server warms the snapshot at start and every 60 s, so no request waits for it. A pool names its row through the side that is not USDG or WETH, joined to a Lighter market through `@levee/abi`'s `TOKENS`, or through the token's own symbol when there is no market. The table is the union of pools and perps. A symbol's best pool has the highest fee APR among pools holding at least 1 % of its pool TVL, so a dust pool cannot name the row. `established` (Seasoned) is `isEstablished` on that pool. A pool whose 24 h stats are not yet known answers `lpApr: null` rather than 0 %. Levee's own pools add one row each with `source: 'levee'`. Carry is `basisSignal` from `@levee/core`: pool APR plus hourly funding times 8 760, at least 8 % to watch and 20 % for strong. The wire's `basisSignal` reads it as follows: pool and perp give `lp-and-short` (Hedge) when carry clears the watch bar and `neutral` otherwise; a pool alone gives `neutral` if Seasoned and `avoid` if not; a perp alone gives `long-perp`; neither gives `avoid`. ### Market data `MarketData` is one cached view of every symbol with a fallback chain. Lighter REST is the source of truth: stats from `/api/v1/orderBookDetails`, funding from `/api/v1/funding-rates`, with the WebSocket stream filling gaps between polls. A crypto symbol without a Lighter mark falls back to Hyperliquid; a stock, index or commodity falls back to Yahoo. A symbol nothing answers for still comes back, with `mark: null` and `source: 'none'`. Funding and open interest exist only on Lighter. Concurrent requests for the same data share one upstream call. ### Ideas and mirroring `POST /theses` verifies an EIP-712 signature under `{ name: 'Levee', version: '1', chainId }` over `Thesis { positionRef, side, text, ts }`. The market is display metadata and is not signed. `positionRef` is `v3:`, `perp:::` or `vault:
`; `side` is `long`, `short` or `lp`; `text` is at most 500 characters; `ts` must be within 300 s of the server clock. Because `ts` is in the message, one signature makes one post: a repeat is 409 `duplicate_thesis`, enforced under a race by the `theses_replay_unique` index. The leaderboard ranks authors by realised fees plus realised PnL across their indexed positions. `POST /theses/:id/mirror` executes nothing. It returns `zapMint` calldata on `zapV3` for a v3 position (the author's pair, fee and ticks, `sizeUsd` worth of the paying token or a zero amount without one, 100 bps slippage, a 20 min deadline), `deposit(0, you)` for a vault, or, given `sizeUsd` and a live Lighter mark, a sized market order for a perp. ### Perp requests Every `/perp/*` route forwards to the configured venue. `VENUE=sim` is `SimVenue`: an in-memory book on real marks that credits each new account 10 000 USDG, fills market orders 5 bps worse than the mark and accrues funding hourly. `VENUE=lighter` is `LighterVenue`, an HTTP client for `services/signer` that signs every request with the shared `SIGNER_SECRET`: an HMAC over a timestamp, a one-time nonce, the path and the body, so the secret never crosses the network. The API never holds an order key, and there is no withdraw route: `/perp/withdraw-intent` returns a payload for your wallet to sign, and on the sim answers 503 `sim_no_withdraw`. Each write body carries `auth: { nonce, ts, signature }`, an EIP-712 signature over `PerpAction { action, account, payloadHash, nonce, ts }`. `payloadHash` is the keccak256 of the body without `auth`, as canonical JSON, so a signature fits exactly one body; `action` is fixed by the route. `requirePerpAuth` checks, in order: a valid `address` (400), a well-formed envelope (401), `ts` within 300 s (401 `stale_ts`), the signature recovering to `address` (401), and an unused nonce for that address (409 `nonce_used`). The nonce is spent last, only for a request that verified, so a forged request cannot burn a nonce you are about to use. The message builder is `perpAuth.ts` in `@levee/core`, which the web's `lib/perpAuth.ts` uses too. ### Limits and hardening - Every `POST`, `PUT`, `PATCH` and `DELETE` shares a budget of `RATE_LIMIT_WRITES_PER_MIN` (30) per route group and client IP, where the group is the first segment of the matched route pattern. A refusal is 429 with `Retry-After`. Reads are never limited. `/contact` adds its own 5 per minute per IP. - The limiter lives in the process, so each replica grants the full budget. - `TRUST_PROXY` decides what the client IP is and is off by default; turn it on only behind a proxy that sets `X-Forwarded-For`. - `CORS_ORIGIN` is a comma-separated list of scheme-and-host origins. `*` and bare host names are refused, and with `NODE_ENV=production` the API will not start without it. - `POST /points/recompute` needs `x-admin-secret`, compared in constant time; without `ADMIN_SECRET` it answers 503. ### When the API is not there Each page hook in `apps/web/lib/data.ts` wraps its fetcher in React Query with no retry and a 15 s stale time. In a development or staging build, a failed request falls back to a fixture from `lib/fixtures/` and the page shows a "Sample data" tag. A production build never shows a fixture: the page renders its empty state and a status notice. The sample portfolio, referral and points belong to one sample address, so any other address gets an empty answer. Writes need a manifest, not the API. ## The bots The bots live in `apps/api/src/bots` and run inside the API process. `startBots` returns without starting anything, and logs which variable is missing, unless `BOTS`, `BOT_PRIVATE_KEY` and `MANIFEST_PATH` are all set. A bot is one `tick()`: it reads, decides and returns a list of actions, each a transaction hash, a venue order id or a `skipped` reason. Every skip carries its reason. The runner's rule is that a bot must never take the API down: every tick is wrapped and its errors logged, a tick still running when its interval comes round is skipped rather than stacked, timers never hold up a shutdown, and every bot runs once at start. The hedger ticks every `HEDGER_INTERVAL_MS` (30 s), the four pool bots every `AMM_BOT_INTERVAL_MS` (15 s), and the keeper and graduator every `BOT_INTERVAL_MS` (60 s). Diagram: One keeper tick. Three sweeps in a fixed order, then the same attempt for every candidate. A skip is a normal result: the tick reports it, the guard remembers it, and a later tick tries again with fresh data. Sweep (c) is the one deliberate send without a priced bounty and says so with bounty {kind: none}, which skips the two bounty checks. The lower half is the send path of all seven bots. ### The send path Every bot write goes through `ChainClient.simulateAndSend`: 1. `simulateContract`, an `eth_call` against pending state. A revert is the usual result, not an error: the position no longer qualifies, the order is not fillable yet, the token is too young. It costs nothing, which is why the bots can run on a loop. 2. For a send paid by a bounty, `estimateContractGas` times `getGasPrice` gives its cost, and the send is skipped when the bounty is under 3 times that (`GAS_BOUNTY_MULTIPLE`). The margin absorbs a base-fee jump between the estimate and the block. A bounty that could not be priced skips before the estimate: a missed harvest costs the owner nothing, while sending blind would turn one market-data outage into unguarded gas for every position. 3. `writeContract`, then one confirmation. A reverted receipt counts as a failure. `ActionGuard`, keyed by action and target, mutes a target for `COOLDOWN_MS` (5 min) after a success, while the indexer catches up, and for `FAIL_BACKOFF_MS` (15 min) after a failure, so a position that always reverts costs one simulation per quarter hour. The state is in memory on purpose: a restarted bot rebuilds everything from the chain and the indexer, and the contracts' own intervals are the real guard. It also means two replicas would send twice, so run the bots on one. Reads are batched through Multicall3, which the bots' viem chain must declare, or every batch falls back to one call per read. ### Keeper One keeper tick runs three sweeps in order. 1. **Enrolled positions.** The indexer's active enrollments, then `shouldHarvest` and `shouldRebalance` for each in one Multicall3 batch, then `harvest` or `rebalance` where the contract says yes. The views are the contract's own answer, so the bot never re-implements the owner's policy. The bounty is `bountyBps` of the position's pending fees (`uncollectedFeesUsd`, which the indexer client reads from the chain), converted to wei at the ETH mark, and the same figure for a rebalance. 2. **Range orders.** `fillable(orderId)` for every open order, then `fill` only where it says yes. The bounty is 1 % of `amountInUsd` (`RANGE_ORDER_BOUNTY_BPS` 100). 3. **Manifest vaults.** `LPVault.harvest()` has no "worth it" view, so the bot simulates it once per `VAULT_HARVEST_INTERVAL_MS` (6 h) per vault and sends whenever the simulation passes, marked `bounty: { kind: 'none' }`. The vault's own `minInterval` rejects the rest. Diagram: Hedger and graduator ticks. Neither bot earns a bounty, so each sends whenever the simulation passes. The hedger reports every 15 minutes and re-hedges sooner when the delta drifts; the graduator tracks a token the first tick it qualifies and graduates it on the first tick after three days. ### Hedger A hedged vault's LP position changes its exposure as the price moves, so the short must be resized. For each vault whose manifest name contains "Hedged", plus any address in `HEDGED_VAULTS`, the hedger reads `positionTokenId()` and `asset()` on the vault, the position manager's `positions(tokenId)` and the pool's `slot0()`. The vault's asset (WETH) is the numeraire and is not hedged; the other pool token is the risk asset, whichever side of the pair it sorts to. `lpDelta` from `@levee/core` gives the target short as the position's balance of that token. The market is the Lighter symbol for the risk asset unless `VAULT_MARKETS` names another. The hedger acts when a report is due (`HEDGE_REPORT_INTERVAL_MS`, 15 min) or when the gap between target and current short exceeds `HEDGE_DRIFT_BPS` (200) of the target. `hedgeOrders` applies the dead band, rounds down to the lot size (`HEDGE_LOT_SIZE` 0.001) and drops anything under `HEDGE_MIN_NOTIONAL_USD` ($50), returning zero or one market order; a buy is always reduce-only. The order goes to the venue under the vault's hedge account: its funder's address, read once from `fundingConfig().funderKey`, because the margin sits in the funder's Lighter account. An account with no API key bound is skipped with an alert and no report, never read as holding no short, and two vaults on one account are both skipped. Then `reportHedge(notionalUsd, unrealizedPnlUsd, equityUsd, asOf)` writes that account's state on chain in USD times 1e6, with `asOf` from the bot's clock less 10 s, so the latest block, which trails the clock, never sees it as from the future. That report is what the vault's `totalAssets()` and its staleness pause rest on. It pays no bounty and goes out whenever it simulates. The seed vaults are deployed with `maxHedgeAge` of 1 h and `maxEquityJumpBps` of 1500, so a 15 min cadence fits four reports into the staleness window. The hedger never calls `fundHedge` (that is the funder's key) or `StructuredVault.reportSleeve`. ### Graduator `LaunchPipeline` checks only age and pool depth on chain; the rest of the Seasoned screen is the indexer's flag, and this bot joins the two. For every pool the indexer marks `established` whose launch token (the side that is not USDG or WETH) has not graduated, it reads `firstSeen(token)` in one batch. Zero means `track(token)`, which starts the three-day clock. Three days or more means `graduate(token)`, which clones the token's `LPVault`. Anything in between is skipped without a log line. Neither call pays a bounty. `ThinPool`, `TooYoung`, `NotTracked` and `AlreadyGraduated` are the normal result for most tokens on most ticks, so they are logged as skips and the 15 min backoff stops a re-simulation every minute. ### Pool duties Four bots look after Levee's own pools. They need the manifest's `venue` object, pay their own gas with no bounty, and share the send path above. | Bot | Acts when | Sends | Notes | | --- | --- | --- | --- | | `dlmmVaultRebalancer` | a DLMM vault's `needsRebalance()` (the active bin more than half the half-width from the range centre) has held for `DLMM_SUSTAIN_S` (120 s) and the vault's 5 min interval has passed | `rebalance(activeId, DLMM_ID_SLIPPAGE)`, 2 bins of slippage | only the factory's `keeper()` (or the protocol owner) may call it; with another key the bot reports the vault as due and sends nothing. The 120 s wait is the guard: holding a pushed price that long means trading against every arbitrageur | | `stockVaultRebalancer` | `rebalanceStatus()` returns ready: the session changed or the oracle drifted, the oracle is fresh, the pool is near the oracle and the 5 min interval has passed | `rebalance()` | keeper only; a failed attempt is retried after `STOCK_VAULT_RETRY_S` (60 s) | | `limitOrderSettler` | `DlmmLimitOrders.readyBooks()` lists filled batches | `executeMany` for up to `LIMIT_ORDER_BATCH` (25) books | anyone may call it; no cooldown after a success and a 60 s retry, because an unexecuted fill trades back if the price returns | | `buybackPoker` | `BuybackV2`'s reference price is at least `BUYBACK_POKE_MIN_GAP` (1 %) from the pool price and at least `BUYBACK_POKE_EVERY_S` (600 s) old | `poke()` | anyone may call it; keeps a buyback from waiting on an old reference after a fast move | Note: The bots hold no special power Everything a bot sends, anyone can send: `harvest`, `rebalance`, `fill`, `track`, `graduate`, `executeMany` and `poke` are public. The exceptions are `reportHedge`, which only a vault's hedger may call, and the DLMM and stock vault rebalances, which only their factory's keeper may call. The deploy makes the bot's address both. Neither role can move funds out of a vault. See [Hedged vaults](https://levee.trade/docs/hedged-vaults) and [Security model](https://levee.trade/docs/security). ## Reference ### Indexer settings Parameters: apps/indexer environment - `PONDER_CHAIN_ID` (allowed: 4663 or 31337; default: 4663): 31337 only for a plain anvil; a fork of Robinhood Chain keeps 4663. Production accepts 4663 only. - `PONDER_RPC_URL` (allowed: URL; default: the public Robinhood RPC): Needs state at past blocks; the public endpoint keeps only recent state. Required in production. - `PONDER_RPC_MAX_RPS` (allowed: 0 or more; default: 15): Request rate cap; 0 turns the throttle off for a local node. - `PONDER_MANIFEST` (allowed: path; default: image: /app/deployments/manifest.json): contracts/deployments/[network].json. Without it only Uniswap is indexed; in production a path to a missing file stops the start. - `PONDER_POOLS_START_BLOCK` (allowed: block; default: deployBlock): First block of the Uniswap sources. With no manifest: required in production, 0 otherwise. - `PONS_GRADUATION_START_BLOCK` (allowed: block; default: 26 841 846): First block for Pons graduations; ignored without a venue. - `PONS_LAUNCH_START_BLOCK` (allowed: block; default: deployBlock): First block for Pons launches, about 13 000 a day. - `DATABASE_URL` (allowed: URL; default: empty = PGlite): Postgres; production refuses PGlite. - `PONDER_DEPLOY_ID` (allowed: string; default: unset): Explicit schema suffix, levee_[id]. Unset in production, where each build gets levee_[build hash] and a redeploy of the same build resumes. - `PONDER_SCHEMA_PIN` (allowed: schema:hash; default: unset): Use that schema while the build hash matches; adopts a schema an earlier naming created. - `DATABASE_SCHEMA` (allowed: name; default: levee_indexer): ponder dev only. Never public, where the API keeps its tables. - `DATABASE_VIEWS_SCHEMA` (allowed: name; default: levee_indexer): Where the live build is published as views. - `PONDER_PORT / PORT` (allowed: port; default: 42072): Listen port of scripts/start.sh. ### API settings Parameters: apps/api environment - `PORT` (allowed: port; default: 8792): HTTP port. - `DATABASE_URL` (allowed: URL): Unset: in-memory storage, lost on restart. - `INDEXER_URL` (allowed: URL; default: http://127.0.0.1:42072): GraphQL endpoint and the /ready probe. - `RPC_URL / CHAIN_ID` (allowed: URL / id; default: public RPC / 4663): Chain reads, the bot wallet, and the EIP-712 domain of Ideas and perp requests. - `MANIFEST_PATH` (allowed: path): Unset: vault, launch and pool routes answer empty, v3 mirroring answers 503, bots stay off. - `VENUE` (allowed: sim or lighter; default: sim): lighter forwards to the signer. - `SIGNER_URL / SIGNER_SECRET` (allowed: URL / string; default: http://127.0.0.1:8793): Rotate the secret on both services together. - `LIGHTER_BASE_URL / LIGHTER_WS_URL` (allowed: URL; default: api.rh.lighter.xyz): Market data source of truth. - `CACHE_MARKETS_MS / CACHE_CANDLES_MS` (allowed: ms; default: 15 000 / 60 000): Market data caches. - `ADMIN_SECRET` (allowed: string): Unlocks POST /points/recompute. - `NODE_ENV` (allowed: development, test, production; default: development): production requires CORS_ORIGIN. - `RATE_LIMIT_WRITES_PER_MIN` (allowed: more than 0; default: 30): Per route group and client IP. - `TRUST_PROXY` (allowed: bool or proxy list; default: off): Only behind a proxy that sets X-Forwarded-For. - `CORS_ORIGIN` (allowed: origins): Unset: any origin is reflected, development only. ### Bot settings Parameters: apps/api environment, bots - `BOTS` (allowed: names, comma-separated; default: empty = off): keeper, hedger, graduator, dlmmVaultRebalancer, buybackPoker, limitOrderSettler, stockVaultRebalancer; an unknown name stops the API at start. - `BOT_PRIVATE_KEY` (allowed: hex key): A separate hot key; its address is the HEDGER the deploy names. - `BOT_INTERVAL_MS` (allowed: ms; default: 60 000): Keeper and graduator. - `HEDGER_INTERVAL_MS` (allowed: ms; default: 30 000): Hedger. - `AMM_BOT_INTERVAL_MS` (allowed: ms; default: 15 000): The four pool bots. - `COOLDOWN_MS / FAIL_BACKOFF_MS` (allowed: ms; default: 300 000 / 900 000): ActionGuard mutes after a success and after a failure. - `VAULT_HARVEST_INTERVAL_MS` (allowed: ms; default: 21 600 000): How often each manifest vault harvest is simulated (6 h). - `HEDGED_VAULTS / VAULT_MARKETS` (allowed: addresses / vault:MARKET[:BASE]; default: empty): Extra hedged vaults; market overrides. - `HEDGE_REPORT_INTERVAL_MS` (allowed: ms; default: 900 000): Report cadence; keep it well under maxHedgeAge (1 h on the seed vaults). - `HEDGE_DRIFT_BPS` (allowed: bps; default: 200): Drift that forces a re-hedge between reports. - `HEDGE_MIN_NOTIONAL_USD / HEDGE_LOT_SIZE` (allowed: USD / base units; default: 50 / 0.001): Smallest adjustment sent; sizes round down to the lot. - `HEDGER_MODE` (allowed: live, report-only, off; default: live): Kill switch: report-only places no order but keeps reporting; off does nothing. - `HEDGE_MAX_ORDER_USD / HEDGE_DAILY_LOSS_USD` (allowed: USD; default: 25 000 / 2 500): Largest single hedge order (at most the signer's MAX_ORDER_NOTIONAL_USD); hedge loss per UTC day that stops orders. - `HEDGE_MAX_NOTIONAL_USD` (allowed: USD; default: off): Optional ceiling on one vault's short; off, a vault is hedged in full. - `DLMM_SUSTAIN_S / DLMM_ID_SLIPPAGE` (allowed: s / bins; default: 120 / 2): DLMM vault rebalancer. - `BUYBACK_POKE_EVERY_S / BUYBACK_POKE_MIN_GAP` (allowed: s / fraction; default: 600 / 0.01): Buyback poker. - `LIMIT_ORDER_BATCH` (allowed: books; default: 25): Limit-order books per executeMany. - `STOCK_VAULT_RETRY_S` (allowed: s; default: 60): Retry delay after a failed stock vault rebalance. ### Known gaps - `GET /referral/:address` always reports `referredCount: 0`; the indexer counts referrals, but the API does not read the column. - Nothing in the API writes a points event yet, so every points total is zero. - No bot calls `StructuredVault.reportSleeve`, so the Income note's sleeve is reported only by hand. - The chain pool snapshot finds v3 pools only. Where it lives: - `apps/indexer/ponder.config.ts`: sources, start blocks and the no-manifest fallback - `apps/indexer/ponder.schema.ts`: every table and column - `apps/indexer/src/logic/`: pricing, TVL, stats, uptime and the Levee state machines - `apps/api/src/app.ts`: buildApp, the write budget and the error shape - `apps/api/src/config.ts`: every environment variable - `apps/api/src/radar.ts`: Markets rows and the Carry chip - `apps/api/src/chain/poolSnapshot.ts`: the pool set read from the chain - `apps/api/src/perp/auth.ts`: requirePerpAuth and its order of checks - `apps/api/src/routes/types/web.types.test.ts`: the wire guard - `apps/api/src/bots/chain.ts`: simulateAndSend and the gas rule - `apps/api/src/bots/runner.ts`: which bots start, and their intervals - `apps/api/src/bots/`: keeper, hedger, graduator and the four pool bots - `apps/web/lib/data.ts`: the fixture rule and isSampleData --- # Agents and integrations Source: https://levee.trade/docs/agents · Reference How an AI agent or a script uses Levee with its own wallet: the calls, the approvals, the minimums, and where the addresses and ABIs live. An agent, whether an AI agent, a bot or a script, can use every pool, vault and swap the app offers by calling Levee's contracts with its own wallet. Levee never holds its key: nothing on levee.trade can sign or send a transaction for anyone. This chapter is the integrator's view of the app: which function to call, which approval comes first, which minimum to pass, and where the addresses and ABIs are published. To set an agent up step by step, use [Connect your agent](https://levee.trade/agents): an instruction to paste into a chat agent, a script to start from, and a way to watch the agent's wallet. In brief: - Every Levee contract is non-upgradeable, with no admin function over user positions. An address and its ABI never change under you. - The addresses are in `/deployments/robinhood.json`, the ABIs in `/abi/.json`, and an index written for agents in `/llms.txt`. `/llms-full.txt` is every chapter as plain text. - Approve the exact amount, to the contract that pulls it, and pass a minimum that is not zero wherever the call takes one. A vault deposit takes none, so check its preview just before you sign. - Simulate before you sign. The public Robinhood Chain RPC answers `eth_simulateV1`, so an approval and the call after it can be checked together. - Perps are not for agents through Levee: Lighter orders are signed by Levee's signer service for people using the app. An agent trades on Lighter directly, with its own key. ## Where everything lives | Resource | Path on levee.trade | What it holds | | --- | --- | --- | | Setup page | `/agents` | An instruction for a chat agent, a script to start from, and the agent wallet's positions | | Agent index | `/llms.txt` | What Levee is, the rules, every contract with its ABI link, every chapter with its summary | | Full docs | `/llms-full.txt` | Every chapter of these docs as plain text, in reading order | | Deployment manifest | `/deployments/robinhood.json` | Chain id, deploy block, every Levee contract, the seed vaults by name | | Chain constants | `/deployments/robinhood-external.json` | Uniswap v3 and v4, Permit2, Universal Router, WETH, USDG, the stock tokens and their Chainlink feeds, Multicall3 | | ABIs | `/abi/index.json`, `/abi/.json` | One JSON ABI per Levee contract, and which address uses which | All of them are generated from the same manifest and ABIs the app itself uses, at build time. The sources are verified on the explorer, `robinhoodchain.blockscout.com`. The addresses this site was built with: Core: - FeeRouter: 0x808C6a4451645B34895f1690D9F617E29CcefD71 · https://levee.trade/abi/FeeRouter.json - ReferralRegistry: 0x7395da0F38B3729380505d374975d39327c2831B · https://levee.trade/abi/ReferralRegistry.json - LeveeZapV3: 0x80999E61D250B413D9A1544cfb8C0CA99C8D9159 · https://levee.trade/abi/LeveeZapV3.json - LeveeZapV4: 0xdeF6A64413F25c01bD50803dee93ee1579769402 · https://levee.trade/abi/LeveeZapV4.json - RangeOrders: 0x1D68c0455C1668242F0E3121ccDfC19161Fa44D1 · https://levee.trade/abi/RangeOrders.json - PositionKeeper: 0x28B43Db87B6F6Fb10EcF1Fe38F042300A7219547 · https://levee.trade/abi/PositionKeeper.json - VaultFactory: 0x8823EBA2DEc5707FFdAC48F44E2090E36a05389a · https://levee.trade/abi/VaultFactory.json - LaunchPipeline: 0x6fB95366daBB8Bc3b9F6d5574b994DF7d83A2727 · https://levee.trade/abi/LaunchPipeline.json - PreMarketPerp: 0x458a99F4A55336aD87ae0C08C4d800b23aAFa767 · https://levee.trade/abi/PreMarketPerp.json Vaults: - ETH Vault: 0x98A496a35A235A01B02a8Eae15E8a90A929f4C21 · https://levee.trade/abi/LPVault.json - Income NVDA: 0x23f30D31CC3022B164AbaD2b9f0DA58250D3335a · https://levee.trade/abi/StructuredVault.json - NVDA Hedged: 0xC27081350872e9a330b6f0a68cAA9EDF644fd696 · https://levee.trade/abi/HedgedLPVault.json - Protected SPY: 0x3dd2a1a9e6514D7E0143bcb3D607680ee05c9143 · https://levee.trade/abi/StructuredVault.json - SPY Hedged: 0xCc57824cFBF35B23F299AffBCD3fDf4505a9A2C7 · https://levee.trade/abi/HedgedLPVault.json - USDG Vault: 0x472652CF38626321De89D0ef9e4a6736910919Ba · https://levee.trade/abi/LPVault.json Levee pools and router: - ProtocolConfig: 0xf3784EfF7AE924A15B6b9219D806b52e377D9A8C · https://levee.trade/abi/ProtocolConfig.json - AssetRegistry: 0x409A0a75478C4abc4a01f10dde613811bBFD6281 · https://levee.trade/abi/AssetRegistry.json - DammHook: 0x6FAB5Ceb23bC1d7bf2ffA547807C8CE48d5228cc · https://levee.trade/abi/DammHook.json - StockHook: 0xCd93565739D217CCBaddf786D7e4CDCb700628cc · https://levee.trade/abi/StockHook.json - DlmmFactory: 0x088BbcE10be0792EF5d8334dB1edE2934d2Cf04a · https://levee.trade/abi/DlmmFactory.json - DlmmPositionNFT: 0x7A0BF9ffaDB58C4cAb5662749c0cb57aa0AfFdE5 · https://levee.trade/abi/DlmmPositionNFT.json - Router: 0x936316bbA997110e3A8e9B170c60A6CAF5b0cF09 · https://levee.trade/abi/Router.json - FeeCollector: 0x2886389c20F0b94Fc4D363cEDE6367A0BB3ff55A · https://levee.trade/abi/FeeCollector.json - BuybackV2: 0x77B70D723D1c13e71fAcd4C811D88218Bb66cbA6 · https://levee.trade/abi/BuybackV2.json - DlmmLimitOrders: 0x55e7A04CbA97c5a180fA79d66A3F914910200A93 · https://levee.trade/abi/DlmmLimitOrders.json - DlmmVaultFactory: 0x488eA417DfaA92db4f3f8660822b1d7F767bE061 · https://levee.trade/abi/DlmmVaultFactory.json - DlmmVaultZap: 0x7CA6107c9de34F06ea74644aE0fC55b7F0D9b598 · https://levee.trade/abi/DlmmVaultZap.json - LaunchPools: 0x168a1213f30c6e79232406e795a09a3952Dcd76a · https://levee.trade/abi/LaunchPools.json - LaunchVault: 0xDC3A2e812a5D268ECe618461679a210947b90a51 · https://levee.trade/abi/LaunchVault.json - StockVaultFactory: 0x70063A8f8F2dDec2c0a66a43e2AEB627cb3727e1 · https://levee.trade/abi/StockVaultFactory.json Vault implementations (every vault clone delegates to one): - HedgedLPVault implementation: 0x7658D0190d9f0B2A0cdCEF7296D3efbd21BaA400 · https://levee.trade/abi/HedgedLPVault.json - LPVault implementation: 0x0f8DC4E67D06e1181dEaC49f5482453Bf60cF1DD · https://levee.trade/abi/LPVault.json - StructuredVault implementation: 0x203d6aDF5f255f584F540e764aE68A5A1d63EfC4 · https://levee.trade/abi/StructuredVault.json ## The loop Diagram: One transaction, signed by the agent. Everything the agent reads comes from levee.trade or the chain; everything it sends is signed with its own key and goes through any RPC. After the receipt, the next step starts again at Build. An approval and the call after it are two turns of the loop, or one eth_simulateV1 request that checks both before the first is signed. 1. Read: Addresses from the manifest, ABIs from `/abi`, and the state you need from view calls: `previewDeposit`, `maxDeposit`, `paused`, a pool's `slot0`, `Router.quoteExactIn`. 2. Build: Encode the call: `to`, `data`, `value` and chain id 4663. Set every minimum and deadline the function takes; the next sections say which. 3. Check: The `to` must be in the manifest or the chain constants, and an approval's spender must be the contract the next call goes to. Anything else is a mistake or an attack. 4. Simulate: An `eth_call` from your own address, or one `eth_simulateV1` request for an approval and the call that follows it. A revert here costs nothing. 5. Sign and send: With the agent's own key, through any RPC. Wait for the receipt before the next step. 6. Confirm: Read the receipt's events (`Swapped`, `ZapMint`, `Deposit`) rather than trusting the simulation's numbers. ## What an agent can call | Goal | Call | Approval first | Bound you set | | --- | --- | --- | --- | | Swap | `Router.swapExactIn(hops, amountIn, minAmountOut, to, deadline)` | The exact `amountIn` of the token to the Router. ETH goes as `msg.value` equal to `amountIn`, with no approval | `minAmountOut`, `deadline` | | Unwrap WETH | `WETH.withdraw(amount)` | None | - | | One-token liquidity, Uniswap v3 | `LeveeZapV3.zapMint(params)`, or `zapIncrease` for a position you own | The exact `amountIn` to the zap | `minLiquidity`, `maxSlippageBps` (at most 5 000), `deadline` | | One-token liquidity, Uniswap v4 and Levee pools | `LeveeZapV4.zapMint(params)` | The exact `amountIn` to the zap, which holds its own Permit2 allowances | Same as v3 | | Remove v3 liquidity | Uniswap's `NonfungiblePositionManager.decreaseLiquidity`, then `collect` (or both in one `multicall`) | None: you own the position NFT | `amount0Min`, `amount1Min`, `deadline` | | Deposit into an LP, hedged or structured vault | `deposit(assets, receiver)` (ERC-4626) | The exact `assets` to the vault | None on chain: read `previewDeposit` and `maxDeposit` first | | Withdraw from those vaults | `redeem(shares, receiver, owner)` | None | None on chain: read `previewRedeem` and `maxRedeem` first | | DLMM vault | `DlmmVaultZap.zapIn(params)`, `zapOut(params)` | The input token to the zap; for `zapOut`, the vault shares to the zap | `minSwapOut` and `minShares`, or `minOut`; `deadline` | | Stock vault | `StockVault.deposit(amount0Max, amount1Max, minShares, to, deadline)`, `withdraw(shares, to, amount0Min, amount1Min, deadline)` | Both tokens to the vault | `minShares`, the two minimums, `deadline` | ### Routes through the Router A route is up to three swap hops, each a `Hop` of a `kind` and its ABI-encoded `data`. The Router checks that the hops chain token to token (`routeTokens`) and treats ETH and WETH as the same token. | `kind` | Hop | `data` | | --- | --- | --- | | 0 | A Uniswap v4 pool, Levee's DAMM and stock pools included | `abi.encode(PoolKey, bool zeroForOne, bytes hookData)` | | 1 | A Levee DLMM pair | `abi.encode(address pair, bool swapForY)` | | 2 | A Pons bonding curve | `abi.encode(address curve, bool isBuy)` | | 3 | Wrap ETH to WETH or unwrap WETH to ETH; does not count toward the three | empty | Quote a route with `quoteExactIn(hops, amountIn)` through `eth_call`. It is not a view function: it runs each v4 hop and reverts with the result, so it costs nothing as a call and must never be sent as a transaction. Levee's pools are listed under [Levee pools](https://levee.trade/docs/pools) and how the app picks a route under [Swaps and routing](https://levee.trade/docs/swaps). ## The rules 1. **Exact approvals.** Approve the amount of this one call, to the contract that pulls it, never `type(uint256).max`. When a token already has a different non-zero allowance, set it to 0 first: some tokens refuse a change from one non-zero value to another. This is what the app does (`apps/web/lib/trade/approvals.ts`). 2. **A real minimum.** The zaps' `maxSlippageBps` is measured against the pool price inside the same transaction, so a price moved before yours moves that bound with it. `minLiquidity` is the only bound fixed when you sign: derive it from `previewZap` at a price you trust, less your slippage. The same goes for `minAmountOut` from a fresh quote. See [One-token deposits](https://levee.trade/docs/zap). 3. **Short deadlines.** Minutes, where a function takes one. ERC-4626 `deposit` and `redeem` take none, so a signed vault transaction stays valid until it is mined: send it at once, never hold it. 4. **Vault decimals.** LP, hedged and structured vault shares carry 3 more decimals than their asset (OpenZeppelin virtual shares). Read the vault's own `decimals()`, never the asset's. 5. **Vaults can say no.** Deposits and withdrawals revert while the pool's spot price is more than 3 % from its 30-minute average (`TwapGuard`). A hedged vault pauses both when its last hedge report is over an hour old or its equity jumped more than 15 %; read `paused()`. A vault with a cap reports what it still takes in `maxDeposit`, and `Income NVDA` takes nothing. See [Vaults](https://levee.trade/docs/vaults) and [Hedged vaults](https://levee.trade/docs/hedged-vaults). 6. **Check every address.** Before signing, the `to` of the transaction and the spender of every approval must appear in `/deployments/robinhood.json` or `/deployments/robinhood-external.json`. Warning: Vault deposits take no minimum An ERC-4626 `deposit` has no minimum-shares argument. The protection is the vault's TWAP guard, which refuses a deposit while the pool price is far from its 30-minute average, and the hedged vaults' pause. Compare `previewDeposit` with what you expect just before you sign, and send straight away. ## Simulating a sequence Most actions are two transactions: an approval, then the call that uses it. A plain `eth_call` of the second fails until the first is mined. `eth_simulateV1` runs calls in order inside one simulated block, so the second sees the first. As of 14 October 2026 both the public RPC and Alchemy's Robinhood Chain endpoint answer it. ```json { "jsonrpc": "2.0", "id": 1, "method": "eth_simulateV1", "params": [ { "blockStateCalls": [ { "calls": [ { "from": "", "to": "", "data": "" }, { "from": "", "to": "", "data": "" } ] } ], "validation": false }, "latest" ] } ``` Each call comes back with a `status`, its `returnData` and `gasUsed`. A status of `0x1` on both means the pair would succeed against the current block. Simulate again right before you send: the chain moves. ## Referrals for agent builders An agent built for other people can earn from their activity without touching their funds. Register a code once with `ReferralRegistry.register(bytes8 code)`. A user who calls `bind(code)` is tied to you for life, and 20 % of every protocol fee their activity pays goes to you, through `FeeRouter`. The share comes out of the protocol fee, not out of the user's trade. A binding can never be changed, so never bind a user who has not asked for it. See [Fees and referrals](https://levee.trade/docs/fees). ## Reading data The chain is the source of truth, and everything an agent needs before it signs is a view call: vault previews and limits, pool prices, `Router.quoteExactIn`, `LeveeZapV3.previewZap`, token balances and allowances. Multicall3 (in the chain constants) batches them. The app's own API serves ranked markets, vault and pool lists and a portfolio by address; its routes are listed in [Indexer, API and bots](https://levee.trade/docs/offchain). Treat them as beta: they are unversioned and follow what the app needs, and the API gets a stable public address before it is documented for agents. ## Not for agents, for now - **Perps.** A Lighter order is signed by Levee's signer service with a key bound to the user's Lighter account. That is one of Levee's two custodial exceptions, and it exists for a person clicking in the app. An agent opens its own Lighter account and trades through Lighter's API with its own key. See [Perpetuals](https://levee.trade/docs/perps). Perps for agents are coming soon; see [Coming next](#coming-next). - **Keeper enrolment.** `PositionKeeper` needs `setApprovalForAll` on the position manager, which covers every position the wallet holds. See [Position automation](https://levee.trade/docs/keeper). - **Launches and range orders.** App flows with their own rules; read [Launch a token](https://levee.trade/docs/launch) and [Limit orders](https://levee.trade/docs/range-orders) before calling those contracts. ## Coming next Two things are coming soon for every agent and the people who run them. Neither is live yet. - **An MCP server.** Agent connect is a hosted tool server, over MCP and plain JSON, whose read tools answer market, vault, pool and portfolio questions and whose build tools return the transactions above as unsigned, simulated plans for your agent to sign. It will keep every rule on this page, and its address will appear in `/llms.txt` when it ships. - **Perps for agents.** Perps for every agent and the people who run them. Until they ship, perps stay an app flow: see [Not for agents, for now](#not-for-agents-for-now). Where it lives: - `apps/web/lib/agents/llms.ts`: buildLlmsTxt and buildLlmsFullTxt: the agent index and the full text - `apps/web/lib/agents/contracts.ts`: contractTable and ABIS: every deployed contract with the ABI to call it - `contracts/src/venue/periphery/Router.sol`: swapExactIn, quoteExactIn, routeTokens and the hop kinds - `contracts/src/LeveeZapV3.sol`: zapMint, zapIncrease, previewZap and MAX_SLIPPAGE_BPS - `contracts/src/LPVault.sol`: the ERC-4626 vault, its TWAP guard and SHARE_DECIMALS_OFFSET - `contracts/src/venue/vaults/DlmmVaultZap.sol`: zapIn and zapOut with their minimums - `contracts/src/venue/stockvaults/StockVault.sol`: deposit and withdraw with their minimums - `apps/web/lib/trade/approvals.ts`: exact approvals and the reset to 0, as the app does them --- # Glossary Source: https://levee.trade/docs/glossary · Reference The terms these docs use, each in a sentence or two, with a link to where it is explained. The words below come up across these docs. Each entry says what the term means in Levee, in a sentence or two, and points to the chapter that explains it properly. Numbers are the values the contracts enforce today; where a value is set at deploy time, the linked chapter names the constant. ## Trading **Carry.** What a hedged position earns per year before price moves: the pool's fee APR plus the perp funding a short receives (hourly funding × 8 760), minus any borrowing cost. Levee flags carry of 8 % or more as worth watching and 20 % or more as strong. See [Using the app](https://levee.trade/docs/app-guide#markets). **Funding.** The periodic payment between longs and shorts that keeps a perp's price near its index. Positive funding means longs pay shorts. On the pre-market perp it settles hourly and is capped at 1 % per hour. See [Graduation and pre-market perps](https://levee.trade/docs/graduation). **Hedge.** A liquidity position paired with a perp short sized to the position's delta, so price moves on the two legs roughly cancel and the fees remain. The Planner models it next to holding, providing liquidity and a plain perp. See [Hedged vaults](https://levee.trade/docs/hedged-vaults). **Hop.** One leg of a swap route: a v4 pool, a DLMM pair, a Pons bonding curve, or a wrap or unwrap of ETH. The Router accepts at most three swap hops per trade. See [Swaps and routing](https://levee.trade/docs/swaps). **Idea.** A position published with the argument for it and its running PnL. Mirroring an idea opens the same kind of position at your own size. See [Using the app](https://levee.trade/docs/app-guide). **Limit order.** On Levee, a one-sided liquidity position placed entirely above or below the price. It converts into the other token as the price crosses it and earns swap fees while it waits. See [Limit orders](https://levee.trade/docs/range-orders). **Order key.** A Lighter API key the signer service holds for you. It can place and cancel orders on your Lighter account and has no way to withdraw from it. See [Perpetuals](https://levee.trade/docs/perps). **Price impact.** How much worse a route's rate is than the rate for a tiny trade on the same hops. The Swap page warns above 1 %, asks you to confirm above 5 % and refuses at 15 %. See [Swaps and routing](https://levee.trade/docs/swaps). **Slippage.** The most the output may fall short of the quote before the transaction reverts. You set it per trade; the app turns it into a minimum amount out. ## Liquidity **Active bin.** The one bin of a DLMM pair that holds both tokens. Swaps fill there at a fixed price until it runs out, then move to the next bin. See [Levee pools](https://levee.trade/docs/pools). **Anti-snipe fee.** A fee that starts high when a pool opens and falls in a straight line to the base fee over a set time, at most one hour. It makes buying the first blocks of a launch expensive. See [Launch a token](https://levee.trade/docs/launch). **Bin and bin step.** A DLMM pair splits its price range into discrete bins. The bin step is the price distance between neighbouring bins, in basis points: 1 to 100 bps. See [Levee pools](https://levee.trade/docs/pools). **DAMM.** A Uniswap v4 pool on Levee's `DammHook`. Its fee is a base rate plus a surcharge that rises with recent price movement, with an optional anti-snipe schedule at the start. See [Levee pools](https://levee.trade/docs/pools). **DLMM.** Levee's bin-based pair (`DlmmPair`): liquidity sits in discrete price bins and each bin trades at one exact price. See [Levee pools](https://levee.trade/docs/pools). **Harvest.** Collecting a position's earned swap fees, and on a vault or an automated position, adding them back as liquidity. A harvest pays 10 % of the fees it collects to the protocol, never any principal. See [Position automation](https://levee.trade/docs/keeper). **Range.** The price interval a concentrated liquidity position is active in, written as two ticks. Outside it the position holds one token only and earns nothing. See [One-token deposits](https://levee.trade/docs/zap). **Rebalance.** Withdrawing a position that has drifted out of range and opening a new one centred on the current price, inside limits on slippage and timing. See [Position automation](https://levee.trade/docs/keeper). **Shape.** How a DLMM deposit is spread over its bins: evenly (Spot), concentrated near the price (Curve), or weighted towards the edges (Bid-Ask). See [Levee pools](https://levee.trade/docs/pools). **Stock pool.** A v4 pool on `StockHook` for a tokenized stock. Every swap must leave the price inside a band around the Chainlink price, and the band and fee change with the market session. See [Levee pools](https://levee.trade/docs/pools). **Zap.** One transaction that takes a single token, swaps the part the range does not need, mints the position and refunds what is left. It charges 25 bps of the input. See [One-token deposits](https://levee.trade/docs/zap). ## Vaults and notes **Deposit cap.** The deposit limit a vault is created with. It can never be changed. The vaults Levee deploys and the graduated vaults have none, so they take deposits of any size. See [Vaults](https://levee.trade/docs/vaults). **Equity jump.** On a hedged vault, a hedge report that moves the vault's equity by more than 15 % at once. The report is recorded, but deposits and withdrawals pause until a report inside the band arrives. See [Hedged vaults](https://levee.trade/docs/hedged-vaults). **ERC-4626.** The standard interface for tokenized vaults: you deposit an asset and receive shares; your shares' value follows the vault's total assets. **Sleeve.** One part of a structured note's capital. An Income note puts 80 % into an LP sleeve; a Protected note puts 85 % into T-bills (SGOV). See [Structured notes](https://levee.trade/docs/structured). **Stale report.** A hedge report older than one hour. A hedged vault with a stale report pauses deposits and withdrawals until a fresh one arrives. See [Hedged vaults](https://levee.trade/docs/hedged-vaults). **TWAP guard.** The check every vault and the keeper run before moving value at a pool's price: the spot price must be within 3 % of its 30-minute average, and that average must cover at least 10 minutes of real history. A single manipulated block cannot mint or redeem at a false price. See [Vaults](https://levee.trade/docs/vaults). ## Launch **Graduation.** Turning a tracked token into a vault once it is at least three days old and its pools hold enough liquidity. Anyone can call it once the conditions are met. See [Graduation and pre-market perps](https://levee.trade/docs/graduation). **Pons.** A token launchpad on Robinhood Chain. Levee can route swaps through a Pons bonding curve and give a Pons token a Levee pool. See [Launch a token](https://levee.trade/docs/launch). **Pre-launch vault.** A vault that collects ETH before a token exists and spends all of it as the new pool's first swap, so every depositor pays the same average price and nobody can trade ahead of them. See [Launch a token](https://levee.trade/docs/launch). **Pre-market perp.** A perp on a virtual AMM for a token no venue lists yet: USDG margin, up to 3x, hourly funding. See [Graduation and pre-market perps](https://levee.trade/docs/graduation). **Seasoned.** A screener label for a token whose pool is at least three days old, whose market cap is above $1 million, and which traded at least 100 times in the last 24 hours. It is set off chain, tells the graduator bot which tokens to work on, and is not a condition any contract checks. See [Graduation and pre-market perps](https://levee.trade/docs/graduation). ## Protocol **Bounty.** A payment to whoever calls a permissionless function, such as a harvest, a limit-order fill or a liquidation. It covers the caller's gas and is not protocol revenue. See [Fees and referrals](https://levee.trade/docs/fees). **Keeper.** Any account that calls Levee's permissionless maintenance functions for the bounty. Levee runs one as a bot; nothing stops anyone else from doing the same. See [Indexer, API and bots](https://levee.trade/docs/offchain). **$LEVEE.** Levee's token. Protocol fees are converted to ETH, which buys $LEVEE and sends it to the burn address. See [Buyback and burn](https://levee.trade/docs/protocol). **Protocol share.** The part of every Levee pool's swap fee that goes to the `FeeCollector`: 20 % at deploy, and never more than 50 %. Liquidity providers keep the rest. See [Levee pools](https://levee.trade/docs/pools). **Referral share.** 20 % of every protocol fee that passes through the `FeeRouter`, credited to the payer's referrer when one is registered. See [Fees and referrals](https://levee.trade/docs/fees). **USDG.** The dollar stablecoin Levee uses as its main quote asset on Robinhood Chain, and the margin of the pre-market perp. Where it lives: - `packages/core/src/basis.ts`: the carry formula and its two thresholds - `packages/core/src/established.ts`: the Seasoned label - `contracts/script/LeveeWiring.sol`: the values set at deploy time