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.
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:
- 01No vault yetA token graduates once. There is no way to remove its vault.
- 02Old enoughAt least 3 days (
MIN_AGE) sincefirstSeen. A token nobody tracked is too young forever, so the first call for a new token is alwaystrack. - 03Deep enough, over timeFor 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. - 04Vault deployedOn the single deepest pool by the same measure, because a vault holds one position.
VaultFactory.createV3Vaultdeploys a clone with no admin andGraduatedis emitted.
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.
| Name | Allowed | Default | Effect |
|---|---|---|---|
asset | WETH | - | deposits and withdrawals are in WETH |
widthTicks | curveWidth(spacing) ticks | 4,440 at spacing 60 | ±25 % (2,231 ticks a side), floored to the spacing |
hysteresisTicks | 10 × spacing ticks | 600 at spacing 60 | how far price must leave the range before a rebalance counts |
minInterval | 6 h | - | minimum gap between two keeper actions |
maxSlippageBps | 100 bps | - | swap bound on every rebalance |
compound / bountyBps | true / 50 | - | fees go back into the range; a harvest pays its caller 50 bps of the fees |
cap | none | type(uint256).max | no deposit cap, fixed once the vault exists |
name / symbol | "Levee SYM Vault" / "lvSYM" | 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; the bots that service it are in Position automation.
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.
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.
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.
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).
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.
Reference
Perp constants
| Name | Allowed | Default | Effect |
|---|---|---|---|
MAX_LEVERAGE_X | 1 to 3 x | - | whole numbers only |
MAINT_MARGIN_BPS | 625 bps | - | of notional, at the mark and at the index |
TAKER_FEE_BPS | 30 bps | - | of notional, on entry and on exit |
INSURANCE_SHARE_BPS | 3,333 bps | - | of each fee |
LIQ_BOUNTY_BPS | 100 bps | - | of the remaining margin, to the liquidator |
TWAP_WINDOW / MIN_INDEX_WINDOW | 30 min / 10 min | - | index window, and the least history it accepts |
MIN_OBS_CARDINALITY | 60 | - | ring size a pool needs to back a market |
MIN_VIRTUAL_LIQ_USD | 10,000 USDG | - | smallest market depth |
OI_MULTIPLE | 5 x | - | open interest cap over depth |
FUNDING_CAP_X18 / MAX_FUNDING_HOURS | ±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.
contracts/src/LaunchPipeline.soltrack, graduate, canGraduate, _scanOverTime, _initForcontracts/script/LeveeWiring.solMIN_LIQUIDITY = 10 ether and the seed vault policycontracts/src/PreMarketPerp.solcreateMarket, open, close, liquidate, pokeFunding, _payOut, claimDeferredcontracts/src/libraries/ChainlinkGuard.solthe 25-hour feed agecontracts/test/invariants/PerpSolvency.t.solthe solvency invariant and its handlerpackages/core/src/established.tsisEstablished, the Seasoned testapps/api/src/bots/graduator.tsthe bot that tracks and graduatesapps/api/src/launch/build.tshow GET /launch joins indexer rows and chain readsapps/web/lib/launch/view.tslaunchAction, establishedChecks, tradeEstimate