Loading
Reading the pool…
Loading
Reading the pool…
FLOOR / Technical
Strictly technical. Every figure is printed by the suite at ETH = $2,400.
01 — Overview
A single hook serves every launch on a chain and keeps each launch’s accounting under its own pool id; each launch’s ETH and tokens sit in a vault of its own. Native ETH is currency0; the launch token is currency1.
BaseHook (OpenZeppelin uniswap-hooks). Permissions: beforeInitialize, beforeSwap, afterSwap, beforeSwapReturnDelta, afterSwapReturnDelta; nothing on liquidity or donate. An approved factory calls registerPool(key, config, launch, platformPool) once per launch: it deploys that pool’s FloorPoolVault and writes its Config — platformVault, creator and its rates, the holder tax, which nothing can rewrite; the four skim rates (far and at-the-floor, each side), which the pool’s feeAdmin alone can retune within the caps through setSkimBps; and liftMaxBps, still validated (BadRange above 10_000) and read by nothing since the lift was deleted. Every per-pool read and write is execute(poolId, calldata), delegated to SingletonFloorEngine or SingletonFloorViews; stored state reads in one call as poolState(poolId), a 58-field struct that only ever grows at its end (feeAdmin, skimBuyAtFloorBps, skimSellAtFloorBps, bidWidth are its last four). The hook itself has no proxy, no beacon, no admin, no upgrade path and no withdraw function; the vaults it deploys are beacon proxies — see 02.
Deployed by registerPool as a BeaconProxy at a CREATE3 address salted by the pool id, and initialised in the same call with its hook, pool id and assets. Skim, fees and closed positions are taken straight into it, and settlement pays out of it. Only its own hook can ask it to pay, and a payment can never draw on another launch’s vault. No owner and no sweep — but its code is not fixed: every vault on the chain reads one UpgradeableBeacon (the hook’s immutable vaultBeacon), and the deployment’s upgrade owner can point that beacon at a new implementation.
Pure library. sqrtPriceX96FromRatio, floorTick, askTick, _ceilToSpacing, _floorToSpacing, and the constant TICKS_PER_DOUBLING = 6932.
TOTAL_SUPPLY = 1_000_000_000e18, 18 decimals, minted once, straight to the sale. No mint, no transfer tax. It is deployed behind an upgradeable proxy whose admin is the deployment’s upgrade owner. Three optional extras: bindRewards() (once, by the deployer) makes transfer / transferFrom notify a HolderRewards distributor; burn(amount) destroys the caller’s own tokens; bindLaunch() (once, by the deployer) names the one sale that may move the token while it is locked, which makes transfer / transferFrom / burn revert Locked for anyone else until that sale calls unlock() at graduation. sellBack(amount) is the exit while locked: it moves the caller’s own balance to the sale and tells it to pay. There is no pull form — nothing can move a balance its holder did not authorise.
The factory mints 100% of supply to the sale before it exists; buy() takes the quote asset and hands the buyer their tokens in the same call, so there is no claim step; FloorToken.sellBack() returns the quote at cost until graduation; graduate() initializes the pool, binds it on the hook, seeds floor / POL / ladder and calls ratchet() — releasing the token’s sale lock first, before any seeding moves a token. No deadline: the sale graduates when it sells out.
Pure library. preset(Mode), hookConfig(Mode, liftMaxBps, platformVault, creator) and tranchePriceFor(mcapUsd, ethUsd, totalSupply). No state, no oracle; the launchpad calls it at deploy time.
Per-share accumulator fed by the pool’s holder tax, paid in from its vault; the token calls onTransfer before every balance change. Protocol addresses excluded at deployment; no owner. claim() pays the caller what their balance earned.
lock(token, beneficiary, amount, cliff, duration) records an immutable schedule; claim(id) pays only the beneficiary. No revoke, no early unlock.
Orientation (read before touching any sign)
currency0 = native ETH (address(0)), currency1 = FLOOR pool price = token1 / token0 = TOKEN per ETH P_eth = 1 / poolPrice higher tick == more TOKEN per ETH == CHEAPER token "floor up" == floorTick DOWN bid (all ETH) sits ABOVE the current tick ask (all TOK) sits BELOW the current tick hard ordering: askTick < currentTick < floorTick
test/FloorMath.t.sol pins this orientation (test_higherBackingMeansLowerTick, test_askIsAlwaysBelowFloorTick) before anything else is trusted.
Pool state that matters · poolState(poolId)
02 — Floor math
Q is ETH backing (vaultEth + bidEthDeployed). S is the supply that can physically reach the bid.
// backing per circulating token F = Q / S_circ // the same ratio, two readings floor / price = backing / mcap // selling q ETH into the bid removes q/F tokens F' = (Q − q) / (S − q/F) = (Q − q) / (S·(Q − q)/Q) = Q / S = F // invariant under own fills // bid capacity in tokens: the whole circulating supply Q / F = S_circ
F is where the bid’s DEAR edge solves; the published floor, floorTick, is one band cheaper — BID_BAND_TICKS 120, rounded up to the spacing, ≈ 1.2% in price (see 04 and 05). The floor rises only from skim, ask proceeds, ladder proceeds and seed-fee sweeps. It is unchanged by fills of the bid itself. test_floorBuyIsInvariant and testFuzz_floorBuyNeverLowersTheFloor (512 fuzz runs) pin the algebra in tick space.
Why the fee is taken in ETH only: a fee worth V taken as ETH gives F = (Q+V)/S; taken as tokens and retired it gives F = Q/(S − V/P). The second wins only when Q + V > S·P, i.e. backing above market cap, which does not hold while the pool trades.
// what the bid must cover sellable(F) = totalSupply − inventory − ladder − T_pol(F) // T_pol(F): tokens the protocol-owned position holds at the // floor price. A concentrated position's composition is a // pure function of price, so this is exact, not a bound. polTokensAtTick(t) = getAmount1Delta(sa, min(sp, sb), seedLiq, false) // the dear edge is the highest F with F · sellable(F) <= Q // F on both sides: a fixed point, bisected in tick space _solveFloorTick(q): hi = ceil(MAX_TICK), lo = ceil(MIN_TICK) if sellable(lo) < MIN_SELLABLE return floorTick − W // the dear edge, unchanged if cost(hi) > q return hi while hi − lo > tickSpacing: mid = ceilToSpacing(lo + (hi − lo)/2) cost(mid) <= q ? hi = mid : lo = mid return hi cost(t) = _costAtTick(sellable(t), t) = tokens · 2^192 / sqrtP(t)^2 // what is published is the band's cheap edge, W = ceilToSpacing(BID_BAND_TICKS) _publish(solved) = ceilToSpacing(solved + W)
| FloorMath helper | Does | Rounding / guard |
|---|---|---|
| TICKS_PER_DOUBLING | 6932 = ln(2)/ln(1.0001); ticks spanned by a 2× move in ETH per token | subtract from floorTick to get a 2× ask |
| sqrtPriceX96FromRatio(ethWei, tokenWei) | sqrt(tokenWei · 2^96 / ethWei) << 48, staying inside uint256 | tokenWei == 0 reverts ZeroDenominator; ethWei == 0 returns MAX_SQRT_PRICE − 1; clamped to [MIN_SQRT_PRICE, MAX_SQRT_PRICE − 1] |
| floorTick(quoteWei, circ, spacing) | tick of Q/S, rounded conservatively | _ceilToSpacing: rounds UP = cheaper token = understated floor. Under-stating is safe (wall absorbs more than full supply); over-stating would break redemption |
| askTick(cost, tokens, multipleBps, floorTick, minSpread, spacing) | min(byCostBasis, floorTick − minSpread) in tick terms, i.e. the higher ask | empty inventory or zero cost returns the floor-derived ceiling; constraint 2 dominates as the floor ratchets past old lots |
| _ceilToSpacing / _floorToSpacing | round to the next / previous multiple of tickSpacing | clamped one spacing inside MAX_TICK / MIN_TICK |
The hook itself does not call FloorMath.floorTick; it bisects with _solveFloorTick because sellable depends on the answer. FloorMath.floorTick is exercised directly by test/FloorMath.t.sol.
03 — Fee and skim schedule
The pool is a DYNAMIC_FEE_FLAG pool; the hook overrides the fee per swap with LP_FEE | OVERRIDE_FEE_FLAG. All four skim rates — far from the floor and at it, each side — are written per pool when it is registered (the reference figures on this page use 1%/1% far and the defaults 0 / 1000 at the floor) and may be retuned within the caps by that pool’s feeAdmin (setSkimBps, SkimParamsUpdated); the ramp’s width is the same for every launch, and it is measured to the bid band’s DEAR edge.
LP_FEE
2500
hundredths of a bip = 0.25%, flat
skimBuyFarBps → skimBuyAtFloorBps
≤ 1000 → ≤ 1000
bps of the ETH leg, per pool (modes: 200 / 500 far; at the floor default 0, so buys ramp to free)
skimSellFarBps → skimSellAtFloorBps
≤ 1000 → ≤ 1000
bps, per pool (modes: 200 / 500 far; at the floor default 1000, so sells ramp to 10%)
SKIM_RAMP_TICKS
16094
≈ 5× in price; ramp width above the band’s dear edge
_skimBps(zeroForOne): distance = (floorTick − bidWidth) − currentTick // to the DEAR edge; ≤ 0 inside the band closeness = distance <= 0 ? 10_000 : distance >= RAMP_TICKS ? 0 : 10_000 − distance · 10_000 / RAMP_TICKS buy (zeroForOne): buyFar − (buyFar − buyAtFloor) · closeness / 10_000 // or + if buyAtFloor > buyFar sell (!zeroForOne): sellFar + (sellAtFloor − sellFar) · closeness / 10_000 // flat if sellFar ≥ sellAtFloor fee = ethLeg · bps / 10_000 // floor skim → vault (less the platform cut) cfee = ethLeg · creatorBps(dir) / 10_000 // creator fee → creator (see 07) poolManager.take(currency0, custodyVault, fee + cfee) vaultEth += _intake(fee); unpaidSkim += kept; creatorFeesOwed += cfee
Volume: cumulativeVolumeEth adds the ETH leg of every swap whether or not it was skimmed, so a 0%-skim buy at the floor still counts. Swaps whose sender == address(this) (the hook’s own nudge — the re-anchor inside a sell and the ratchet’s dust crossing, see 05) pay the LP fee but no skim and add no volume. custodyVault is the launch’s own FloorPoolVault: the hook takes every fee straight into it and holds none itself.
// ETH is the SPECIFIED currency exactly when
_ethIsSpecified(p) = p.zeroForOne == (p.amountSpecified < 0)
One boolean decides which callback skims. Cases where ETH is specified return the fee through BeforeSwapDelta; the other two take it from the unspecified leg in afterSwap and return it as the hook delta.
| Case | zeroForOne | amountSpecified | ETH leg | Skimmed in |
|---|---|---|---|---|
| Buy, exact in | true | < 0 | specified (ETH in) | beforeSwap · BeforeSwapDelta(+fee, 0) |
| Sell, exact out | false | > 0 | specified (ETH out) | beforeSwap · BeforeSwapDelta(+fee, 0) |
| Buy, exact out | true | > 0 | unspecified (ETH in) = delta.amount0() | afterSwap · hookDelta = fee |
| Sell, exact in | false | < 0 | unspecified (ETH out) = delta.amount0() | afterSwap · hookDelta = fee |
test/Skim.t.sol has one test per cell plus test_noFreeLane_allFourRoutesPay and test_hookEthBalanceMatchesVaultAccounting.
04 — Positions
All modifyLiquidity calls run inside unlockCallback with ACTION_RATCHET / ACTION_SEED / ACTION_LADDER; the pool id rides at the end of the callback data, so one hook can own positions in every launch’s pool without two of them sharing a position.
Range [floorTick − W, floorTick] with W = _ceilToSpacing(BID_BAND_TICKS = 120) — 120 at spacing 60, ≈ 1.2% in price. The CHEAP edge is floorTick, the promise: the lowest price any fill in the band can pay. The DEAR edge, bidLower = floorTick − W, is where the backing solves and where a sell fills first; bidPriceE18 reads it. A partial fill leaves the market inside the band, so the re-place keeps only the part still above the market — it narrows from the dear side (lower = max(floorTick − W, _ceilToSpacing(current + 1))), and the cheap edge never moves, so the wall no longer walks down one spacing per sell. Its token capacity is Q / geomean(edges) > sellable, so no sequence of sells carries the price through it. Only when the whole band is behind the market — the sellable float absorbed, or a pool that arrived in a gap — does it fall back to one spacing above market. Sized by _liqForAmount0(sa, sb, vaultEth); skipped under MIN_BID_ETH = 0.001 ether; bidWidth records the placed width (0 on a bid that predates the band, read as one spacing).
seedLiquidity(tickLower, tickUpper, tokenAmount) is payable and permissionless; the first call fixes the range (SeedRangeFixed otherwise); ticks must be spacing-aligned. FloorLaunch uses lower = _ceilToSpacing(TickMath.MIN_TICK) (v4’s minUsableTick, −887220 at spacing 60), upper = _floorToSpacing(open), so upper ≤ current and the position is 100% tokens: buy-side depth at every price above the open, and a constant-liquidity tail that thins as price rises but never runs out — a buy past the ladder top always fills against it (ruled 2026-09-14; it ran open → 2× before, and a buy above 8× found nothing). The ETH a buy leaves in it above 8× is reversible — it pays sellers on the way down — and is not backing; the floor grows there by skim and swept fees. No code path removes it; each ratchet raises its lowest-price edge to the bid band’s dear edge (raisePol), so nothing of the protocol’s ever sells inside the band. _sweepSeedFees does a zero-delta modifyLiquidity each ratchet: ETH fees to vaultEth, token fees to inventory at zero basis.
seedInventory(tokens, openTick), once (LadderAlreadySeeded). ladderUpper = _floorToSpacing(openTick − LADDER_START_TICKS), ladderLower = _floorToSpacing(openTick − LADDER_END_TICKS). On redeploy, if current <= ladderUpper the range shrinks from below: upper = _floorToSpacing(current − tickSpacing); once upper <= lower (past 8×) nothing is placed. The 8× side is fixed; the 2× side only moves to a higher price. Teardown routes ETH to vaultEth, unpaidSkim and ladderProceedsEth.
Range [at − RECYCLING_WIDTH_TICKS, at] with at = FloorMath.askTick(inventoryCostEth, inventoryTokens, ASK_MULTIPLE_BPS, floorTick, MIN_ASK_SPREAD_TICKS, tickSpacing), then at = min(at, _floorToSpacing(current)) so an ask the market has already passed is lifted to market rather than left idle. Deployed only if current >= upper. MIN_ASK_SPREAD_TICKS = TICKS_PER_DOUBLING: never within 2× of the floor, and the vault buys at or below the floor, so in practice the band runs 2× → 8× of the floor, the ladder's own span. The multiple is fixed: it does not move with cumulative volume (ruled 2026-09-16; it was 2× / 4× / 8× by volume, a rule no commit, test or note ever justified). If at − RECYCLING_WIDTH_TICKS would pass MIN_TICK the inventory is left idle rather than the band narrowed. Teardown: tokBack >= deployed is fee income joining inventory at zero basis; otherwise the sold share retires basis pro rata, so inventory always ends at before − deployed + returned.
Liquidity helpers (overflow-safe)
_liqForAmount0(sa, sb, amount0): l = mulDiv(amount0, mulDiv(sa, sb, Q96), sb − sa) return l > type(uint128).max ? 0 : uint128(l) _liqForAmount1(sa, sb, amount1): l = mulDiv(amount1, Q96, sb − sa) return l > type(uint128).max ? 0 : uint128(l) // 0 means "not deployed"; the ETH / tokens stay idle in the pool's // vault instead of reverting the whole ratchet on an extreme range.
Views · execute(poolId, selector), decoded twice
05 — ratchet()
Permissionless, one pool at a time: execute(poolId, ratchet()). It reverts NotBound until that pool’s bindPool; otherwise it unlocks the PoolManager with ACTION_RATCHET and runs _ratchet(msg.sender). There is no lift: the bid is a band whose cheap edge is the published floor, so the gap a lift existed to close never opens, and the one thing that buys between ratchets is the re-anchor inside a sell (below).
_ratchet(beneficiary): // no-op guard against rounding drains (no lift, so no "gap" clause) if !needsRatchet && unpaidSkim == 0 && vaultEth < MIN_BID_ETH: bidCanRise = bidLiquidity > 0 && floorTick − W < bidLower && ceilToSpacing(cur + 1) < bidLower if !bidCanRise: return needsRatchet = false; floor0 = floorTick _redeemLiftClaims() // the in-swap re-place's ERC-6909 claims come home _sweepSeedFees(key) // zero-delta modifyLiquidity on SEED_SALT _teardownLadder(key) // ETH → vault + unpaidSkim + ladderProceedsEth _teardownBid(key) // tokens → inventory at cost _teardownAsk(key) // ETH → vault; fee income vs sold accounting // everything that leaves before the bid is placed leaves BEFORE the solve bounty = min(unpaidSkim · BOUNTY_BPS / 10_000, MAX_BOUNTY, vaultEth) unpaidSkim = 0; pay beneficiary (failure re-credits vaultEth) reserve = min(vaultEth, MIN_BID_ETH) // the nudge's dust, held out of Q for i in 0..2: // solve, then the POL follows the floor if vaultEth <= reserve || circulatingSupply() == 0: break candidate = _publish(_solveFloorTick(vaultEth − reserve)) // dear edge + W if candidate < floorTick: floorTick = candidate // ratchet guard else if i != 0: break if !_raisePol(): break _nudge(key, reserve) // up to the dear edge; free across an empty gap stand = ceilToSpacing(cur + 1 + W) // the closing clamp if floorTick < stand: floorTick = min(stand, floor0) // withholds a rise, never lowers _deployLadder(key); at = _deployAsk(key); _deployBid(key) emit Ratcheted(floorTick, at, vaultEth, inventoryTokens, bounty)
The guard matters because each teardown/redeploy cycle loses a wei or two to v4's LP-unfavourable rounding; without it a spammer could turn idle ratchets into a slow drain (test_spammingRatchetCannotDrainBacking). The bounty is paid from unpaidSkim only, for the same reason (test_ratchetPaysBountyOutOfNewSkimOnly) — and it is paid BEFORE the solve, as is the nudge’s reserve, so the bid always holds at least the Q its floor was solved on; paid after, a bid landed up to MAX_BOUNTY short of the floor it was solved for. The closing clamp holds the published floor at a band the bid can actually stand in — one spacing above where the market ended, plus W — so a third party parked in the gap DELAYS the rise instead of leaving a bid under the floor; the standing floor is never lowered (SingletonRootFix.t.sol test_f).
In-swap re-anchor · replaceBidInSwap, inside a sell that filled the bid
afterSwap, on a sell: (bidEth, bidTok) = _bidLive(sqrtP) // exact mid-fill composition if bidTok > 0: self.call(replaceBidInSwap) // an empty revert fails the swap closed replaceBidInSwap(): // hook only, inside the NET window fold earlier claims into the net // every wei of vaultEth: in custody or a credit here _teardownBid(key) // tokens absorbed → inventory at cost; ETH left → vault _nudge(key, vaultEth) // RE-ANCHOR: back up to the dear edge, across the emptied band _deployBid(key) // the whole band again; cheap edge = floorTick, unchanged needsRatchet = true net quote debit → settled from custody; net credit → ERC-6909 claims (liftClaims) // no floor re-solve, no bounty; costs 0 wei in every honest state
A sell that crosses into the band leaves TOKENS in the bid, and a two-sided v4 position would resell them at the floor, skim-free, to the next buyer. So the filled bid is taken out inside the same sell and re-placed with the ETH it has left. Without the re-anchor every re-place forfeited the ticks between the market and the next spacing boundary, and a few small sells walked the band shut from the dear side (measured: 2 sells at spacing 60, band 120). The nudge crosses the bid’s own just-emptied band, so it spends nothing; anything a third party parked there was filled by the seller at those same ticks a moment ago. liftClaims is named for the netting it was born in, not for the lift (SingletonBidReplace.t.sol).
The band · _nudge, _deployBid
W = ceilToSpacing(BID_BAND_TICKS) // 120 → 120 at spacing 60 dearEdge = floorTick − W // where the backing solves; a sell fills here first _nudge(key, budget): // buy up to the dear edge, spending at most budget if budget == 0 || price already at or above dearEdge: return _buyUpTo(key, budget, dearEdge) // bought tokens → inventory at cost _deployBid(key): if vaultEth < MIN_BID_ETH: return upper = floorTick // the cheap edge: the published floor lower = max(upper − W, ceilToSpacing(current + 1)) // a partial fill narrows from the dear side if lower >= upper: upper = lower + spacing // last resort: one spacing above market bidLiquidity = liqForAmount0(lower, upper, vaultEth) bidEthDeployed = owed; bidLower = lower; bidWidth = upper − lower
The dear edge is what the POL’s edge, the ladder’s cap, the nudge’s limit and the skim ramp all measure to, so nothing of the protocol’s ever sits inside the band and the market rests just above it. liftMaxBps stays in Config and the three lift meters stay in PoolState as dead zeros; the engine reads none of them (SingletonRootFix.t.sol test_a: the POL’s edge and the bid’s dear edge are floorTick − W, bidWidth == W).
06 — The platform cut
FLOOR is the launchpad and a token. Every pool launched on it is a pool on the chain’s one hook, registered with platformVault set to a SingletonPlatformVault — an adapter that hands the cut to the FLOOR token’s own pool. That pool is registered with platformVault == address(0) and forwards nothing: it is where the cuts land. It is otherwise an ordinary Custom launch — 2.5% floor skim, 0.25% creator fee and 0.25% holder rewards each side, graduating once its sale raises 8 ETH (test_genesisRaisesEightEthAtEthUsd2400, DeploySingletonChain.t.sol) — and because it pays no cut to itself, all of that raise becomes the bid.
uint256 public constant PLATFORM_BPS = 250; address platformVault; // per pool, written once at registerPool (see 07, 08, 09) uint256 platformPaidEth; // lifetime forwarded (launched pools) uint256 platformReceivedEth; // lifetime received (FLOOR's own pool) function _intake(uint256 amount) internal returns (uint256 kept) { if (platformVault == address(0) || amount == 0) return amount; uint256 cut = (amount * PLATFORM_BPS) / 10_000; if (cut == 0) return amount; platformPaidEth += cut; FloorPoolVault(custodyVault).forwardQuote(platformVault, cut, false); emit PlatformCut(poolId, cut); return amount - cut; } function intake() external payable { // FLOOR's pool, via execute(poolId, intake()) vaultEth += msg.value; platformReceivedEth += msg.value; needsRatchet = true; emit PlatformIntake(poolId, msg.sender, msg.value); }
Every inflow passes through _intake before the vault books it: the raise in seedFloor(), the skim in both _beforeSwap and _afterSwap (unpaidSkim and the bounty base are the kept 97.5%), ladder proceeds in _teardownLadder, ask proceeds in _teardownAsk, and ETH fees in _sweepSeedFees. The bid’s own ETH returning in _teardownBid is the vault’s money coming home, not an inflow, and never passes through it. The forward pays the cut out of the launch’s own vault into the SingletonPlatformVault, which calls execute(poolId, intake()) for the FLOOR pool; the receiving side only increments that pool’s storage, and every vault payment holds a transient reentrancy lock.
| Same $297k launch | FLOOR’s own pool | A launched pool |
|---|---|---|
| Day-one bid as % of open | 99% | 96% |
| Vault after the $576k pump | $759k | $740k |
| Vault after everyone dumps | $44k | $43k |
| Bid after the dump | $0.001828 | $0.001753 |
| Forwarded to FLOOR on the raise alone | — | $7,425 |
| Mode | Floor skim, far | All in, at the bid · buy / sell | All in, far from it |
|---|---|---|---|
| Degen | 1% / 1% | 1.247% / 11.222% | 2.245% |
| Meme | 2% / 2% | 1.247% / 11.222% | 3.242% |
| Bluechip | 5% / 5% | 0.749% / 10.724% | 5.736% |
| $FLOOR | 2.5% / 2.5% | 0.749% / 10.724% | 3.242% |
⚠ The skim column is the floor skim alone; the two all in columns are what the trader actually pays, which is why Bluechip’s 5% skim reads 5.736% — skim, creator fee and holder tax are three percentages of the same ETH leg and are summed, then the pool’s 0.25% LP fee is charged on what is left, composing multiplicatively. Buys slide to 0% skim at the bid and sells climb to 10%, so the at-the-bid pair is asymmetric in every mode. $FLOOR pairs Meme’s far rate with Bluechip’s at-the-bid rate: it is not the cheapest of the four, and Degen is cheaper far from the floor. What differs is where the money goes — 2.5 of $FLOOR’s 3.0 points are floor skim against Meme’s 2.0, so at the same total cost more of it is booked under the token. The 2.5% platform cut is a split of skim already paid and never appears in a quote.
FLOOR’s floor is a function of the whole platform’s volume: F_FLOOR = (Q_FLOOR + Σ 0.025 · inflows_i) / S_FLOOR over every launched pool i, and nothing can withdraw it from there either.
07 — The creator fee
Whoever launches sets a recipient (the launchpad defaults it to their own wallet) and two rates. All three are written into the launch’s pool once, when the factory registers it, with no setter: after launch nobody — not the creator, not FLOOR — can move the address or change the rates. The one exception is a recipient named by handle, which starts empty and is filled exactly once when its verified owner claims it.
uint256 public constant MAX_CREATOR_BPS = 100; // 1% each way, hard cap address creator; // address(0) with no pending handle disables the fee uint256 creatorBuyBps; uint256 creatorSellBps; uint256 creatorFeesOwed; // accrued, unpaid uint256 creatorFeesPaid; // lifetime paid out registerPool(key, Config memory c, launch, platformPool) initialize(c, launch) // BadRange if a rate > cap, or a rate with no recipient // _beforeSwap / _afterSwap, per swap (currency0 = ETH, zeroForOne = buy): fee = ethLeg · skimBps(dir) / 10_000 // to the vault, after the platform cut cfee = ethLeg · creatorBps(dir) / 10_000 // to the creator, no platform cut poolManager.take(ETH, custodyVault, fee + cfee) vaultEth += _intake(fee); creatorFeesOwed += cfee return delta(fee + cfee) function claimCreatorFees() external returns (uint256 paid) { paid = creatorFeesOwed; creatorFeesOwed = 0; creatorFeesPaid += paid; bool ok = _payQuote(creator, paid); // out of the pool's vault, only ever to `creator` if (!ok) revert CreatorTransferFailed(); }
The creator fee is taken in the same take as the skim and returned in the same hook delta, so a swap pays LP fee (0.25%) + skim (0–10%, ramped, to the vault) + creator fee (flat, ≤ 1%) + holder tax (flat, ≤ 2%). It is not a vault inflow: it never passes through _intake, so the platform takes no cut of it, and it is never counted in vaultEth, unpaidSkim or the bounty base, so it never moves the floor. The launch’s FloorPoolVault therefore holds idle vault ETH + creatorFeesOwed, and its ETH balance must cover both after every completed operation (test/VaultIsolation.t.sol). A wallet claims with execute(poolId, claimCreatorFees()) on the hook; anyone may send it, and the ETH lands only with creator.
| Per swap, ETH leg | Buy | Sell | Goes to |
|---|---|---|---|
| LP fee | 0.25% | 0.25% | pool liquidity (the hook’s own positions) |
| Floor skim | 1% → 0% at floor | 1% → 10% at floor | vault, less 2.5% to FLOOR |
| Creator fee | 0–1% flat | 0–1% flat | creator, no cut |
| Holder tax | 0–2% flat | 0–2% flat | HolderRewards, no cut |
08 — Launch modes
src/FloorModes.sol. A mode fixes the market cap the token graduates at, the far-from-floor skim per side, the creator fee and the holder tax (none). Degen, Meme and Bluechip cannot be edited on the launchpad — the point of a preset is that everyone recognises it; Custom is the editable one. The launchpad converts the cap to a tranche price with the ETH price at deploy time; nothing on-chain reads an oracle.
| Mode | Graduation mcap | Sale raises (55%) | Skim far, buy / sell | Creator fee |
|---|---|---|---|---|
| Degen | $5k | $2.75k | 1% / 1% | 1% / 1% |
| Meme | $20k | $11k | 2% / 2% | 1% / 1% |
| Bluechip | $100k | $55k | 5% / 5% | 0.5% / 0.5% |
| Custom | launcher’s | 55% of it | ≤ 10% / 10% | ≤ 1% / 1% |
library FloorModes {
enum Mode { Degen, Meme, Bluechip, Custom }
struct Preset { uint256 graduationMcapUsd; uint256 creatorBps; uint256 skimBuyFarBps; uint256 skimSellFarBps; }
function preset(Mode) internal pure returns (Preset memory);
function hookConfig(Mode, uint256 liftMaxBps, address platformVault, address creator)
internal pure returns (FloorHookStorage.Config memory); // liftMaxBps: still passed and range-checked, read by no engine path
function tranchePriceFor(uint256 mcapUsd, uint256 ethUsd, uint256 totalSupply)
internal pure returns (uint256); // mcapUsd·1e36 / (ethUsd·totalSupply)
}
The near-floor ramp does not change with the mode: by default buys slide to 0% at the floor and sells climb to 10% from whatever far rate the mode sets (the at-the-floor endpoints are per-pool state the pool’s feeAdmin may retune — 03). Custom is the empty preset — the hook’s own caps (MAX_SKIM_FAR_BPS 10%, MAX_CREATOR_BPS 1%, MAX_HOLDER_BPS 2%) are the only limits.
Measured · Modes.t.sol, platform cut on, ETH at $2,400
| Launch, then a buy of 2× the raise, then every holder dumps | Meme | Bluechip |
|---|---|---|
| Vault at graduation | $10.7k | $53.6k |
| Market cap / vault after the pump | $118k / $27.7k | $598k / $139k |
| Market cap / vault after the dump | $67k / $1.7k | $336k / $8.3k |
| Bid after the dump vs open | 3.5× | 3.4× |
| Creator fees earned on the path | $529 | $1,326 |
| Forwarded to FLOOR on the path | $788 | $3,957 |
09 — Holder rewards
Optional, fixed at launch like the creator fee. Rebasing balances would change the pool’s token balance underneath the PoolManager and corrupt the bid, the ladder and the floor math, so the distributor keeps a per-share accumulator instead and the token tells it about every transfer.
// hook (Config): holderRewards, holderBuyBps, holderSellBps uint256 public constant MAX_HOLDER_BPS = 200; // 2% each way, hard cap function _takeFees(key, ethLeg, zeroForOne) internal returns (uint256 total) { fee = ethLeg · skimBps / 10_000 // vault, after the platform cut cfee = ethLeg · creatorBps / 10_000 // creator, accrued hfee = ethLeg · holderBps / 10_000 // holders, deposited now poolManager.take(ETH, custodyVault, fee + cfee + hfee) …; FloorPoolVault(custodyVault).forwardQuote(holderRewards, hfee, true); } // HolderRewards rewardPerShareE18 += deposit · 1e18 / eligibleSupply // on every deposit onTransfer(from, to, amount): // called by the token, pre-transfer settle(from): owed += shares·rps/1e18 − debt; shares −= amount; debt = shares·rps/1e18 settle(to): same, shares += amount claimable(who) = owed[who] + shares[who]·rps/1e18 − debt[who] claim(): settle(msg.sender); pay owed; owed = 0
Excluded, fixed in the constructor with no owner to change it: the PoolManager (holds the pool’s tokens), the launch’s FloorPoolVault (inventory), the hook, the launch contract (the sale tokens it still holds), the vesting contract (locked tokens), the OFT adapter (bridged tokens), the token, the distributor and the zero address. A deposit made while nobody holds yet is parked in unallocated and folded into the next one. The tax on a buy is deposited in beforeSwap, before the buyer’s tokens arrive, so a buyer never pays themselves. A wallet whose balance predates binding (the deployer’s supply) is mirrored on its first outgoing transfer.
Measured on the reference launch with a 2% / 2% holder tax and a 1% / 1% creator fee over five buy-sell rounds: $1,415 deposited for holders, $766 claimed by the first five wallets, the rest claimable.
10 — Lock & vest, burn
FloorVesting.sol is one contract for every token on the platform; FloorToken.burn is on the token itself.
struct Lock { token; beneficiary; total; claimed; start; cliff; end; }
lock(token, beneficiary, amount, cliffSeconds, durationSeconds) → id
// cliff ≤ duration; tokens pulled now; schedule immutable
vested(id) = t < cliff ? 0 : t ≥ end ? total : total · (t − start) / (end − start)
claimable(id) = vested(id) − claimed
claim(id) // anyone may call; pays only the beneficiary
No owner, no revoke, no early unlock, no way to move the beneficiary. Locked tokens sit under the floor like any other — the bid is sized for the whole supply — but they do not earn holder rewards: the launchpad excludes the vesting contract from every token’s distributor, so a lock is a commitment, not a farm.
function burn(uint256 amount) external {
_notify(msg.sender, address(0), amount); // stops earning
_burn(msg.sender, amount); // totalSupply falls
totalBurned += amount;
}
The hook reads totalSupply() live in circulatingSupply(), so a burn shrinks sellable supply against the same ETH and the floor rises on the next ratchet for everyone else (test_burnRaisesTheFloorAndStopsEarning). Only a holder can burn what they hold. The vault still keeps, never burns, its own inventory — the keep-not-burn rule is about the protocol, not about you.
11 — FloorLaunch
Immutable config, the whole supply minted to the sale at creation, the quote asset held by the sale until graduation — and returnable at cost until then. The tokens are handed over inside the buy: there is no claim step.
struct Config {
uint256 saleTokens; // sold in tranches
uint256 polTokens; // protocol-owned, token-only, open → ∞ (the tail)
uint256 vaultTokens; // genesis ladder
uint256 p0; // ETH wei per whole token, first tranche
uint256 p1; // last tranche; pool opens at p1
uint8 tranches;
uint256 floorShareBps; // share of the raise → seedFloor()
}
// constructor: saleTokens + polTokens + vaultTokens == totalSupply
// p1 >= p0, p0 != 0, tranches != 0, floorShareBps <= 10_000
tranchePrice(i) = p0 + (p1 − p0) · i / (tranches − 1)
openTick() = getTickAtSqrtPrice(sqrtPriceX96FromRatio(p1, 1e18))
canGraduate() = funded && !graduated && sold >= saleTokens
_sell(tokens) = contributed · tokens / allocation // your own average price
custodyAtBuy = token.launch() == address(this) // probed at initialize: true on every factory sale
// the exit: token.sellBack(tokens) -> onSellBack -> _sell; FloorLaunch.sell() reverts UseSellBack
// no claim step: buy() already transferred the tokens; claim() reverts Nothing
Launch.t.sol config
The factory mints the whole supply straight to the sale’s predicted address before the sale exists, and binds the token to it, so the sale starts funded and delivers at the buy. Whatever the deployer keeps is nothing, by construction (test_founderAllocationIsZeroByConstruction).
Reverts SaleOver after graduation or when sold out; Nothing if zero tokens result. Records allocation and contributed and transfers the tokens to the buyer before the change refund — all sale state is settled first, and solmate’s transfer has no receiver callback, so it hands control to nobody. There is nothing to claim afterwards: claim() reverts Nothing rather than paying a second time out of the ask ladder.
floorEth = raised · floorShareBps / 10_000; the rest pairs into POL. Any balance the POL position could not use goes to seedFloor; unsold sale tokens join vaultTokens on the ladder. Emits Graduated(raised, floorEth, polEth, ladderTokens, openTick).
The call is FloorToken.sellBack(tokens): it moves the seller’s own balance to the sale and then tells the sale to pay, through the same _sell: contributed × tokens / allocation — your own average price, never more than you paid — and the tranche capacity is released. FloorLaunch.sell() reverts UseSellBack, because the tokens are in the seller’s wallet and paying out without taking them back would be an unconditional drain.
Sale
55%
550_000_000e18, 10 flat tranches at 225 gwei (test config)
POL
15%
150_000_000e18, tokens only, open → ∞ (the tail; overlaps the ladder)
Ladder
30%
300_000_000e18, 2× → 8×
Founder
0
floorShareBps 10_000; no minimum raise, no deadline
Day-one ratio: with floor share f, floor / price = f · (sold / circulating) · (avg / p1). A flat sale (avg == p1) with everything to the floor opens at 100% of the bid-covered supply; the measured 78% backing/mcap in the next section counts POL tokens in circulating supply for the headline number.
12 — Invariants and suite
test/Invariant.t.sol: a Handler with four actors, four swap shapes, whale dumps, random seeds and random ratchets; ghost counters bidHits / askFills prove the interesting paths ran. Config: 128 runs × depth 64, fail_on_revert = false; the README reports all hold over 30k adversarial calls. ⚠ These run against a per-launch harness hook (test/Base.t.sol, RatchetFloorHook), where one hook holds one launch’s ETH and tokens — hence rows 05 and 06 — and which still bids ONE SPACING wide and still carries the lift. On a chain the singleton engine (what every deployment runs) bids the band of 04 and 05 and has no lift, so rows 02 and 04 read differently there: bidLower = floorTick − bidWidth, the vault pays at most the DEAR edge’s price per token, and liveFloorTick() publishes the cheap edge. The band is pinned on the singleton by test/SingletonBidReplace.t.sol, test/SingletonRootFix.t.sol and test/PolStages.t.sol (re-pinned with the port, 1014 of 1015 passing; the one red, GenesisTarget.test_theTableNamesEveryCommittedDeployConfig, fails identically without it). The balances sit in each launch’s FloorPoolVault; test/SingletonHook.t.sol and test/VaultIsolation.t.sol cover that (shared hook, isolated accounting, each vault covering its own idle liabilities), and the contracts repo’s own checklist (docs/per-launch-vaults.md) records the isolation invariant as written and compiled, not yet run.
Suite size · 1015 tests (forge test at ratchet-floor 1cf1766: 1014 pass) · the mechanism files below are 306 of them
| File | Covers | Tests |
|---|---|---|
| FloorMath.t.sol | orientation, invariant algebra, fuzz | 7 |
| Skim.t.sol | four-case table, no free lane, balance | 6 |
| Ratchet.t.sol | bid/ask lifecycle, dumps, bounty, spam, full cycle | 11 |
| Seed.t.sol | refunds, fixed range, permissionless adds, fee sweep, immutability | 5 |
| Invariant.t.sol | 8 invariants + call summary | 9 |
| Sim.t.sol | balanced / net-buy / crash at ~1000 ETH | 3 |
| Launch.t.sol | LaunchTest: sale, wiring, day one, claim, ladder, fee schedule (12, inherited by 14 contracts); LaunchEventsTest (3) and LaunchExitTest (7) sit on LaunchBase, so they run once | 22 |
| DumpDayOne / Lift_Off / Lift_On / Sim30 / Where | 1 + 2 + 2 + 3 + 1 own cases; each also inherits the 12 LaunchTest cases. Lift_Off / Lift_On measure the harness hook’s lift, which the singleton does not have | 69 |
| Platform.t.sol | PlatformTest: 7 own cases + 12 inherited (one overridden); FloorItselfTest: 1 own + 12 inherited | 32 |
| Creator.t.sol | CreatorTest: 7 own + 12 inherited (one overridden); CreatorConfigTest: 6 own + 12 inherited; CreatorRejectsTest: 1 own + 12 inherited | 50 |
| Modes.t.sol | DegenModeTest 5, MemeModeTest 5, BluechipModeTest 5, CustomModeTest 2 own + 12 inherited | 29 |
| Holder.t.sol | HolderTest: 11 own + 12 inherited (one overridden); HolderConfigTest: 3 own + 12 inherited | 38 |
| Registry.t.sol | the launch directory: an entry is read back off the launch, so it cannot misdescribe its own wiring; only an approved factory may register | 17 |
| Vesting.t.sol | VestingTest 6, BurnTest 2 | 8 |
Three bugs the suite caught during development
forge test forge test --match-path test/Invariant.t.sol -vv FOUNDRY_INVARIANT_RUNS=300 FOUNDRY_INVARIANT_DEPTH=100 \ forge test --match-path test/Invariant.t.sol
13 — Measured results
All figures from the README, reproduced from Launch.t.sol, DumpDayOne.t.sol, Sim30.t.sol and Lift.t.sol with the 55/15/30 config at 225 gwei (raise ≈ $297k). ⚠ All four run on the per-launch harness hook (test/Base.t.sol), whose bid is one spacing wide and whose “Live bid” is that one price. On the singleton the bid is the band of 04: its DEAR edge is the figure these tables call the live bid (what bidPriceE18 reads, where a sell fills first), and the PUBLISHED floor is one band — BID_BAND_TICKS 120, ≈ 1.2% — under it. No singleton test prints the reference launch’s day-one pair yet; the band’s geometry is pinned in SingletonRootFix.t.sol test_a.
Pump, then everyone dumps · test_ladderFillsConvertMarketCapIntoBacking
| Moment | Price | Market cap (price × 1B) | Vault ETH | Supply held by vault | Live bid |
|---|---|---|---|---|---|
| Graduation (raise $297k) | $0.000540 | $540k | $297k | 30% (POL + ladder) | $0.000537 · 99% of open |
| After a $576k pump (ladder sells 248M for $458k) | $0.003139 | $3.14M | $759k | 5% | $0.000911 |
| After everyone dumps | $0.001836 | $1.84M | $44k | 91% | $0.001828 |
Market cap is price × total supply, as every tracker quotes it; nothing is burned, so tokens the vault buys back still count. The cap therefore cannot fall below bid × supply: after the dump the price sits on the bid, the cap is $1.84M, and the vault holds 91% of the supply as inventory it will re-list at ≥ 2× cost. $714k was paid to sellers in row three — the vault is the money sellers can take out, so it fills on the pump and empties on the dump. The bid price never falls and ends at 3.4× the launch price because the near-floor sell skim retires tokens cheaper than the bid pays for them (on this harness the atomic lift did too; the singleton has no lift — 05). On the singleton the quantity that never falls is the published floor, floorTick: bidPriceE18 is the band’s dear edge and steps down as a part-filled band narrows, until the next ratchet re-solves it.
Day-one dump · DumpDayOne.t.sol, Launch.t.sol
| 70/30 split, wide POL | 100% to bid, token-only POL | |
|---|---|---|
| Day-one bid as % of opening price | 76% | 99% |
| Bid ETH used if everyone dumps | $214k of $216k | $249k of $297k |
| Buyers' recovery, total day-one dump | 68¢ / $ | 89¢ / $ (96¢ at 3% skim) |
| Where the missing cents went | $68k stranded as ETH in POL + skim | skim only, all back in the vault |
30 days of balanced flow · Sim30.t.sol, ratchet() once a day
| Daily volume | Volume, day 30 | Vault, day 30 | Live bid, day 30 | Effective take |
|---|---|---|---|---|
| $100k | $2.99M | $436k | $0.000739 | 4.7% |
| $1M | $29.9M | $1.75M | $0.002991 | 4.8% |
| $10M | $299M | $15.3M | $0.026723 | 5.0% |
Each day is round trips (buy, then sell everything back), so volume counts both sides. Effective take = vault growth ÷ volume. Far from the floor it is 1.25% (1% skim + 0.25% LP sweep); on the floor sells pay up to 10% and buys 0%.
Historical · the lift the singleton no longer has, measured · Lift.t.sol on the per-launch harness hook, same post-crash state
| Scenario | Vault ETH after | Bot P&L |
|---|---|---|
| No lift, nobody arbs | $89k | — |
| No lift, a bot arbs the gap | $11k | +$21k |
| Lift, nobody else trading | $32k | — |
| Lift, a bot front-runs the first ratchet | $19k | +$17k |
In the lift run $84k of buying happened atomically inside the sellers' transactions and $57k in the ratchet. The lift did not earn the vault money: without it a bot took $21k, and a bot that front-ran the first ratchet still took $17k; the residual was bounded by the 10% near-floor sell skim on the bot's exit. The singleton deleted the lift and closed the gap it existed to arb a different way: the bid’s cheap edge IS the published floor, so nothing is ever priced under the floor for a bot to buy — SingletonRootFix.t.sol test_f’s gap bot finds no entry and skips, and the nudge spent exactly MIN_BID_ETH on the gap’s third-party tokens. ⚠ Open, recorded not closed: SingletonBidReplace.t.sol’s full-dump drain bot still nets $42,405 at $2,400 (19.59 ETH → 17.669 ETH measured); its target property, pnl ≤ 0, is not yet met on the band or on the code before it.
Verify