← barbell.meme
Barbell Launchpad Technical paper v1 Robinhood Chain · 4663 2026

A bonding curve whose reserve asset is a basket of other coins.

Every launchpad prices a new coin against a stablecoin, so the coin only moves when someone trades it. This one lets the creator choose the pair: an arbitrary basket of up to eight ERC-20s, tokenised into a redeemable share, used as the reserve asset of a constant-product curve. The launched coin is backed by that basket and priced in it, on the curve and in the locked pool it graduates into, so it tracks the basket whatever the individual coins do to each other. No oracle is read anywhere in the system. This document specifies the mechanism in full, including the design that was built, measured and rejected on the way.

OneThe quote asset§

Every launchpad you have used makes the same choice and never mentions it: the new coin is quoted against a stable asset. Pump pairs against SOL, then against a stablecoin at graduation. Every clone does some version of it. The choice is so universal it reads as physics.

It is not physics. It is a parameter, and it has a consequence. A coin quoted against a stablecoin has exactly one source of price movement: someone buying or selling that coin. It has no exposure to anything. It is a closed system with a fixed denominator, and the only way the chart moves is flow.

That is fine, and it is also the reason ninety-nine percent of launches are flat within an hour. There is nothing to be right about except the coin itself.

The alternative is to change the denominator. If a coin is quoted against a basket of other coins, its price is a product of two independent things: the flow into the coin, and the value of the basket it is priced in. A coin paired with an AI basket goes up when AI coins go up, before anybody has traded it at all. The creator is not just launching a ticker, they are expressing a position, and buyers are taking that position with leverage from the curve on top.

Doing this needs three things that do not exist together anywhere:

  1. A way to turn an arbitrary set of coins into one fungible ERC-20 that can serve as a reserve asset, without a manager, a rebalancer, or a price feed.
  2. A bonding curve that can be denominated in a volatile reserve without reading what that reserve is worth, and that stays solvent when it moves.
  3. A graduation into a real pool that opens at the price the curve closed at, so there is no step for the first arber to take, and that is paired with the BASKET so the coin keeps tracking the thing it was bought to track.

The rest of this paper is those three things, in order, plus what it takes to make the result reachable by an ordinary buyer holding dollars.

TwoThree objects§

A launch creates or reuses three contracts. They are strictly layered: the vault knows nothing about the curve, and the curve knows nothing about dollars.

          legs the creator chose            AI    PONS    BONER    STONKBROKER
                                             │      │       │           │
                                             └──────┴───┬───┴───────────┘
                                                        │  deposited in kind
                                                        ▼
  ┌──────────────────────────────────────────────────────────────────────────┐
  │  BasketVault            one per basket, CREATE2 at an address derived     │
  │                         from the basket itself. ERC-20 shares over the    │
  │                         legs. Mint and redeem are in kind, pro rata,      │
  │                         permissionless. Never swaps. Never sells.         │
  └──────────────────────────────────────────────────────────────────────────┘
                                                        │  shares are the reserve asset
                                                        ▼
  ┌──────────────────────────────────────────────────────────────────────────┐
  │  BasketCurve            constant product, virtual reserve on the basket   │
  │                         side. Holds 100% of the coin. Denominated         │
  │                         entirely in basket units: reads no price, ever.   │
  └──────────────────────────────────────────────────────────────────────────┘
                                                        │  at R ≥ rGrad: moved, not redeemed
                                                        ▼
  ┌──────────────────────────────────────────────────────────────────────────┐
  │  LpLocker               one full-range Uniswap v4 position: COIN against   │
  │                         the BASKET it was bought with. Opened at the       │
  │                         curve's final marginal price and held forever.     │
  │                         Fees are realised, principal is unreachable by     │
  │                         construction.                                      │
  └──────────────────────────────────────────────────────────────────────────┘
BasketCoin is the fourth: a fixed-supply ERC-20 with no owner and no mint, created by the launch and handed wholly to its curve. BasketVaultFactory, BarbellLaunchpad, FeeRecipient, CurveDeployer and BarbellZap are the plumbing around these.

The layering is the design. While a coin is trading, every call into an untrusted token lives in one contract, the vault: the curve touches only the coin and the vault share, so a hostile leg can make a vault unpleasant and reach nothing else.

Graduation is the exception, and it is worth stating rather than glossing. To pair the coin with the coins behind it, the curve must hold them: it redeems in kind, holds each leg for the length of one transaction, and approves the locker to take them. So a hostile leg does get one call each from the curve and the locker, on one path, once. That path is built for it. A leg that will not leave the vault is found by a probe that always rolls back and is skipped; a leg that cannot be paired is parked and written off after a week; and a leg that calls back into the curve while it is being moved is refused by a second guard, which is the subject of §7.

ThreeThe basket§

BasketVault is an ERC-20 whose shares are a pro-rata claim on a fixed set of up to eight underlying tokens. Mint and redeem are in kind: you hand over the legs in ratio and receive shares, you burn shares and receive the legs.

It is deliberately not a fund. There is no manager, no rebalancing, no fee, no owner, no pause and no upgrade. There is no function anywhere in it that sells a leg, and that absence is load-bearing rather than aesthetic. Two properties follow from never selling.

A leg that cannot be sold is harmless

On Robinhood Chain a token with fourteen million dollars of liquidity and a clean guard verdict reverts TRANSFER_FROM_FAILED on every attempt to sell it, at any size. In a vault that had to liquidate, that one token would brick every redemption for everyone. Here, holders receive the token itself, not the proceeds of a forced sale, so a token that cannot be sold is simply a token you now hold.

There is no exchange rate to manipulate

Mint and redeem are computed pro rata against the vault's own balances, read inside the same transaction, with balanceOf(address(this)) as the only input. No oracle, no TWAP, no slippage parameter, nothing an attacker can move in a flash loan and nothing that can go stale. Reserves are read rather than tracked, so a rebasing leg accrues or depletes pro rata by itself and needs no sync().

Mint: shares-denominated, on purpose

mint(shares, to) names the number of shares you want and charges whatever each leg's pro-rata amount comes to:

paidi = ⌈ shares · reservei / supply ⌉ Rounded up, always, so rounding can only ever favour the vault and therefore every existing holder.

Naming shares rather than assets is what removes the classic first-depositor problem at the root. There is no assets-in to shares-out rate in this contract, so there is no rate for a donation to inflate. The interface is simply a different one from the ERC-4626 shape that makes that attack possible.

A router cannot use that function, because a composed transaction cannot promise exact swap outputs. So there is mintMax(maxIn[], to): hand over at most maxIn[i] of each leg and receive the largest number of shares every leg can cover. The binding leg is whichever is scarcest relative to its reserve:

shares = mini ⌊ maxIni · supply / reservei ⌋ Then the pull for each leg is computed exactly as in mint, and capped at maxIn[i] because rounding up can exceed the cap by one wei. Everything the binding leg leaves unspent stays with the caller.

Note the direction of the only grief available here. Donating tokens to leg i raises that reserve, which lowers the shares granted for a given deposit. The attacker pays real tokens to slightly worsen someone else's mint and receives nothing. It is not an attack anyone runs twice.

Redeem, and the escape hatch

redeem(shares, to) burns shares and pays out every leg pro rata:

goti = ⌊ shares · reservei / supply ⌋ Rounded down. Both directions round toward the vault, so the invariant that shares are worth at least their pro-rata claim is never violated by arithmetic.

It needs no pool, no router, no counterparty and no oracle. That is what makes the backing claim a fact about the present rather than a promise about future liquidity: a share is redeemable for its contents right now, by anyone holding it, at any size, in one call.

The redemption walks every leg, which means a leg that reverts on transfer out, for instance because it blacklisted the vault as a sender, would otherwise brick redemption for everybody. So the real function is:

function redeemSkipping(uint256 shares, address to, uint256 legMask)

Burn your full shares, take only the legs whose bit is set, and forfeit your claim on the rest. A skipped leg is never touched, so a reverting token cannot poison the rest of the redemption. The forfeited claim is not destroyed and does not go to the protocol; it stays in the vault and belongs to the remaining holders, which needs no extra accounting at all because reserves are read from balances. The result is monotone: a bad leg can never lock the vault, it can only cost you your share of that leg.

This is deliberately structural rather than an admin allowlist of permitted legs. An allowlist is a key over which baskets are allowed to exist, and the point of this system is that no such key exists.

Fee-on-transfer legs are refused, not accounted around

Every pull measures the vault's balance across the transfer and reverts with FeeOnTransferLeg if less arrived than was asked for. Pro-rata accounting against a token that silently delivers less would be a lie for every holder. If a leg becomes fee-on-transfer after the fact, mints start failing while redeems keep working: the vault degrades, it does not brick.

DEAD_SHARES

At seed, 1e3 shares are minted to the zero address and the seeder receives shares - 1e3. Its job here is narrower and more specific than the inflation-attack folklore, because the shares-denominated mint above already handles that.

What it prevents is re-seeding. seed is guarded by if (totalSupply() != 0) revert AlreadySeeded(). If every holder redeemed and supply returned to exactly zero, that guard would open again and the opening ratio could be chosen a second time, on a vault that already has an address, a history and a curve pointing at it. Pinning supply permanently above zero closes that door, and incidentally removes every division-by-zero branch from mint and redeem.

Weights are identity, not allocation

This is the part most likely to be misread, so it is stated plainly. The weightsBps array a creator supplies is validated to sum to 10_000, and it is an input to the basket's canonical id. It is not an allocation target and nothing rebalances to it. The vault never stores the weights, no mint or redeem path reads them, and there is no rebalancing mechanism of any kind.

Nothing reads them after the basket is created. That includes graduation: an earlier design split a coin's liquidity across one pool per leg by the declared weights, which made a drifted basket cost its holders something at graduation. Pairing with the basket removed that along with the split (§7), so a basket's drift now costs a launch nothing at all.

What the weights do is name the basket. Equal-weight AI/PONS and sixty-forty AI/PONS are two different baskets with two different ids living at two different addresses, and they are different because the weights say so. The actual composition of a vault is whatever its seeder deposited, and from then on it drifts with the market exactly as an unrebalanced portfolio does: the leg that outperforms becomes a larger fraction of every share, and nothing pulls it back.

Consequence Because a vault's opening ratio is set by whoever seeds it first, and because seedVault is permissionless on a vault the factory made, the composition a basket actually opens with is a property of its first seeding transaction rather than of the weights in its name. A vault's real contents are always readable from reserves(), which is the number that matters for every mint, redeem and NAV. The one place the declared weights are used instead is the graduation split (§7), and the gap between the two is exactly what that section quantifies.

FourCanonical baskets§

If two people launch coins against the same basket and get two different vaults, the liquidity that should have concentrated fragments instead. So a basket's identity is its address.

basketId = keccak256(abi.encode(address[] legs, uint16[] weightsBps)) Legs strictly ascending by address, which rejects duplicates for free and is what makes the id canonical rather than order-dependent.

That id is used directly as the CREATE2 salt, so the vault address is a pure function of the basket. BasketVaultFactory.createVault is idempotent: ask for a basket that exists and you get the existing vault back. The second coin launched against a basket lands in the first coin's vault, mints against its live ratio, and shares its liquidity.

predict(legs, weights) returns the address whether or not the vault exists yet, so the whole graph can be resolved off chain before anything is deployed.

Why the symbol is derived rather than passed in

A vault's symbol is BSKT- plus three hex characters taken from the id. It is computed in the constructor from basketId and there is no way to supply one.

The reason is CREATE2, not taste. Constructor arguments are part of the init code, and the init code hash is part of the address. A name argument would make the vault address depend on the name, which would let the same basket exist at two addresses under two names, which defeats the single thing CREATE2 is here to do. Deriving the symbol from the id means the label can never disagree with the address.

What the factory refuses

RejectedErrorWhy
0 legs, or more than 8BadLegCountRedemption walks every leg; the bound keeps it gas-safe
Unsorted or duplicate legsNotAscendingAscending order is what makes the id canonical
The zero addressZeroLeg
A leg that is itself a vaultNestedVaultA basket of baskets is a good feature and a vault-to-vault reentrancy path. Not in v1
Weights not summing to 10000WeightsMustSumToBpsKeeps the id space clean; see §3 on what weights are
Off-chain parity The hasher in the routing engine (guard/src/resolver.ts) is byte-identical to computeBasketId, so an id computed off chain addresses the same vault on chain. Drift there is a silent-failure mode rather than a loud one, so it is asserted by a differential test over random baskets rather than trusted to review. The engine also emits a b2:-prefixed sha256 id for baskets containing Solana, perpetual or ERC-4626 legs; those cannot be reproduced on chain and this factory accepts only plain same-chain ERC-20s, so such a basket simply has no vault.

Why the factory is a separate contract

The launchpad is disposable and the factory is not. A future launchpad with different economics must resolve to the same vault addresses, or the liquidity CREATE2 exists to concentrate fragments across launchpad versions, which is the one outcome the whole scheme is built to avoid. The factory is the shared public good; the launchpad is a client of it.

FiveThe curve§

Constant product with a virtual reserve on the basket side. Every quantity in the contract is denominated in basket units. It never learns a dollar price and never reads an oracle.

Two state variables carry it. R (rReserve) is the real basket shares held on behalf of the curve. S (sCurve) is the coin still held by the curve, so circulating supply is exactly sTotal - S. One immutable, V (vReserve), is a virtual basket reserve that is never actually held.

k = (R + V) · S The invariant. At arming, R = 0 and S = sTotal, so k = V · sTotal.

Solving for the supply on the curve and the marginal price at any reserve level gives the closed form the entire product is built on:

S(R) = V · sTotal / (R + V) price(R) = (R + V) / S(R) = (R + V)2 / (V · sTotal) In basket units per coin. The opening price is V / sTotal, set entirely by the creator's choice of vReserve. Price is quadratic in the reserve, so the coin gets four times more expensive for every doubling of the basket flowing in.

Why this shape, given a volatile reserve

The reserve asset here moves. That normally forces a design decision about how the curve should respond, and almost every answer to that question requires knowing what the reserve is worth. This one does not, and three consequences follow:

  • Dollar price tracks the basket for free. If the basket doubles in dollars, the coin's dollar price doubles with zero contract involvement. Nothing to update, nothing to go stale, nothing to manipulate. The coin's dollar price is the product of two independent things: price(R) in basket units, and the basket's own value.
  • Graduation is a flow threshold, not a price threshold. It triggers on R ≥ rGrad in basket units. A dollar threshold would make graduating an oracle-dependent event that could be pushed over the line by moving the basket rather than by buying the coin. The consequence, stated plainly because it surprises people: what graduation costs in DOLLARS moves with the basket. Eight thousand shares is $8,000 of buying when a share is $1 and $80,000 when a share is $10. The coin page shows the figure live and says so; see the backtest below for how far this can go.
  • Sequencer downtime is a non-event. Nothing reads an uptime feed. Gating trades on one would mean holders cannot exit during an outage, which is strictly worse than the problem.
R = 0 rGrad price graduates 9.00× of the opening price
Price in basket units as the reserve fills. It is (R+V)² / (V·S), so the whole curve is a fixed (1 + rGrad/vReserve)² — exactly 9× on the Starter and Standard presets, before the basket does anything at all.

What this looks like on real prices

The curve was run over real daily closes from this chain, with a vault seeded at $1,000 a leg and a share opening at exactly $1, and a steady $300 a day of buying. The contract's arithmetic is transcribed exactly, roundings included.

BasketWhat it didGraduationk fellUnpayable sells
meme, mid, stock-likex1.28 a shareday 22, $6,900 of buying00
two stock-like legsx1.18day 26, $8,10000
three memecoinsx91.65never, 2,621 of 8,000 shares00
crash window, then everyone exitsx2.42, meme leg −82% inside itnever, reserve drained to 7 shares00

The identity holds exactly. For every holder in every run, the dollar value of a position held unchanged equals its entry mark times the curve's move in basket units times the basket's move in dollars, to 0.000000%. That is the two-independent-things claim above, not argued but measured:

value(t₂) / value(t₁) = [price(R₂) / price(R₁)] · [nav(t₂) / nav(t₁)] No fee term, because fees are paid on the way in and change only how many coins the holder ended up with.

Solvency survived the worst case in the data. In the run where the basket crashed and every holder sold a quarter of their position every day, the reserve was drained from healthy to seven shares and every single sell was payable. Holders were marked $3,152 in aggregate and were paid $3,143, the difference being the fee. That is the unconditional solvency bound of §6 doing its job while the reserve asset itself fell.

And the sharp edge. The three-memecoin basket went up 91x, and because rGrad is denominated in shares, the $12,300 that went in bought only 2,621 of the 8,000 shares needed. That coin ends with $240,000 of backing and no graduation: it trades on its curve perfectly well, buys and sells both work, it simply never gets a pool. Finishing it at those prices would take another $493,000. The reverse is also true: a basket that halves makes graduation cost half as much. This is the price of a threshold that cannot be manipulated, and it is the right trade, but a creator picking rGrad should know that they are choosing an amount of basket, not an amount of money.

Quotes, and which way each rounding goes

Buying spends basket shares. The fee is taken off the input first, so it never enters the reserve:

fee = ⌈ basketIn · feeBps / 10000 ⌉ ΔR = basketIn − fee coinOut = ⌊ S · ΔR / (R + V + ΔR) ⌋

Selling returns coins. The fee is taken off the output, and the reserve is debited the full gross:

gross = ⌊ (R + V) · coinIn / (S + coinIn) ⌋ fee = ⌈ gross · feeBps / 10000 ⌉ basketOut = gross − fee
QuantityRoundsIn favour of
Buy feeupthe fee recipients
Coins out on a buydownthe curve
Gross on a selldownthe curve
Sell feeupthe fee recipients

Every rounding is against the trader and toward the curve. That is not incidental: it is what makes the invariant monotone, which the next section needs.

k never falls

Take a buy with the fee already removed. After it, the reserve is R + ΔR and the curve's coin balance is S − coinOut. Before rounding:

(R + V + ΔR) · (S − S·ΔR/(R+V+ΔR))   = (R + V + ΔR) · S · (R+V)/(R+V+ΔR)   = (R + V) · S  = k

Exactly preserved. The same algebra runs for a sell, where R falls by the full gross and S rises by coinIn. Since coinOut rounds down and gross rounds down, the realised S is always at least and the realised R is always at least the exact figure, so in practice:

(R + V) · S ≥ V · sTotal   always

Arming, and why the supply check is load-bearing

The coin mints to the curve and the curve verifies the coin, so neither can be constructed first. Rather than drop the check, it is deferred by exactly one call and then locked forever. arm() is factory-only, one-shot, and runs in the same transaction as the launch. The "factory" here is the launchpad, passed to the constructor rather than taken from msg.sender, because curves are deployed one hop away through CurveDeployer (§10):

uint256 supply = BasketCoin(coin).totalSupply();
if (supply == 0 || BasketCoin(coin).balanceOf(address(this)) != supply)
    revert SupplyNotWhollyOnCurve();
sTotal = supply;
sCurve = supply;
armed  = true;

It asserts that 100% of the coin supply is sitting on the curve at the moment trading opens. Not 95%, not "the rest is vested". The next section is the reason that number has to be exact.

SixSolvency§

The curve can always pay every seller. This is not a check that happens to pass, and not an economic argument about incentives. It is an arithmetic property that holds for every possible input, and it is worth writing out in full because it is the part of this design that does the most work.

Claim. For every coinIn any account could possibly hand to sell, the gross payout is at most the real reserve:

gross = (R + V) · c / (S + c) ≤ R

The proof rests on exactly three facts.

  1. c ≤ sTotal − S. You cannot sell coins that do not exist outside the curve, and outstanding supply is exactly sTotal − S. This is true only because arm() verified that every coin in existence came out of this curve.
  2. (R + V) · S ≥ V · sTotal. The invariant, monotone under every rounding, as shown in §5.
  3. k started at V · sTotal, because the curve opened with R = 0 and S = sTotal.

Proof. The claim (R+V)·c/(S+c) ≤ R is equivalent, multiplying out by the positive quantity S + c, to:

(R + V)·c ≤ R·(S + c) R·c + V·c ≤ R·S + R·c V·c ≤ R·S

So the whole thing reduces to showing V·c ≤ R·S. From fact 2:

(R + V)·S ≥ V·sTotal R·S + V·S ≥ V·sTotal R·S ≥ V·(sTotal − S)

And from fact 1, c ≤ sTotal − S, so V·c ≤ V·(sTotal − S) ≤ R·S. Which is what was required. ∎

The bound is exactly tight

Equality holds when both inequalities are equalities: when c = sTotal − S, the entire circulating supply, and when k sits exactly at its starting value. In that case gross = R precisely. Selling every coin in existence back to the curve drains the reserve to exactly zero and not one wei further. There is no slack in the design and none is needed.

Every fee ever paid and every wei of rounding makes the inequality strict, so in practice a full unwind leaves a small surplus rather than landing on the knife edge.

What this rules out

Fact 1 is the fragile one, and it is fragile in a specific direction. A team allocation, a pre-mint, a vested tranche, an LP reserve held back at launch, a mint function, anything at all that puts a coin into circulation without it passing through the curve, breaks c ≤ sTotal − S and with it the entire proof. Those coins would be sellable against a reserve that was never paid in for them.

That is why BasketCoin has no owner, no mint function, no pause, no transfer hook and no fee, and why arm() checks for the whole supply rather than most of it. The absences are not modesty. They are the hypotheses of a theorem.

In the code sell carries assert(gross <= rReserve). That is a belt on top of the proof, not the proof itself: an assert rather than a require precisely because reaching it would mean one of the three facts above had been violated, which is a broken invariant rather than a bad input.

Fee accounting

Fees are held as basket shares in the curve and tracked separately from the reserve, so until it graduates the contract's basket balance is always exactly rReserve + feesAccrued. After graduation the reserve has been redeemed away and what remains of the basket token is the unclaimed fees alone. Reserve accounting reads the tracked variable rather than balanceOf, which is what keeps accrued fees from being lent out as backing. claimFees() is permissionless to call and its destinations are immutable: the caller chooses nothing.

toCreator = ⌊ feesAccrued · creatorFeeShareBps / 10000 ⌋ toProtocol = feesAccrued − toCreator

SevenGraduation§

At R ≥ rGrad the curve moves its whole reserve and its remaining coins into one locked full-range pool: COIN against the BASKET it was bought with. The reserve does not change denomination, nothing is sold, no route and no price is involved, and the split is closed form.

Why against the basket rather than per coin

One pool per underlying is the tempting answer, and it was built and measured before it was rejected. Every coin in a basket already trades and already has a public price, so a COIN/leg pair is priced by outsiders the moment it trades, with nobody funding anything. That part works: a throwaway token was paired against a memecoin and against a tokenized stock, one swap was sent through each, and DexScreener and GeckoTerminal listed both within minutes with a USD price and USD liquidity, agreeing to within two percent.

It also stops the coin tracking its basket, which is the one thing it must do.

N independent pools have no common price. When the underlyings move apart, each pool reprices on its own and arbitrage settles them at a single coin price that conserves the coins in the pools. Writing r_i for what leg i did and w_i for its weight, the closed form falls out of constant product directly:

coin return = (Σ w_i·√r_i)²   vs.   basket return = Σ w_i·r_i The first is below the second for every mix except all legs moving together, by Cauchy-Schwarz. The gap is not a fee or a slippage; it is the design.

On equal thirds, computed two ways and checked against a simulation of the arbitrage:

What the legs didBasketCoinShortfall
One +400%, two flat+133%+99%15%
One +400%, one flat, one to zero+100%+16%42%
One to zero, two flat−33%−56%33%
flat basket +133% coin +99% 15% of the move
Equal thirds. The basket is what the legs did; the coin is where arbitrage settles N independent pools, (Σ wi√ri)². The gap is the shortfall, and it is widest when the thesis is working.

A leg at zero is worse than its weight, because its tokens become free and anyone can drain that pool's coins for nothing, releasing them into the float. And the damage is largest exactly when the basket is doing what it was bought to do: one leg running while the others do not is the thesis working, and it is the case that costs the most.

Hooks cannot fix it. The blend exists because N pools have no common price, and pricing one underlying against another inside a hook needs an oracle, which is manipulable in one block. The one price source that needs no oracle is the basket itself, because the vault mints and redeems in kind. Tracking the basket and trading against the basket are the same choice.

What that costs is visibility, and it is bounded: nobody outside this product prices a basket, so a graduated coin's pair shows on an indexer with no dollar figure next to it. §12 is how that is answered, with one pool per basket paid for out of the fees that basket has already earned.

The split

The pool must open at the price the curve closed at, or the first arbitrageur is handed the difference and it reads as a rug to everyone else. A full-range constant-product position holding r of reserve against X coins prices the coin at r / X, and the curve's final marginal price is (r + V) / S:

X = S · r / (r + V) Which is coinToPool in the contract, computed with fullMulDiv. Everything else is burned: those coins were backed by the virtual reserve, which never existed, so handing them to anyone - including the team - would be minting supply out of the pricing mechanism.

There is nothing else to decide. No weights, no value split, no leg prices: both sides of the pool are things the contract already holds, in the units it already held them in. The entire body of the function is the burn, two approvals and one call.

Gas, and the failure that hides

Graduation runs inside somebody's buy, and an estimator searches for the smallest gas at which that buy succeeds - and a buy succeeds perfectly well when the graduation inside it quietly runs out of gas. Left alone, that means buys that trade and never graduate, with nobody able to tell why. Two things prevent it:

  • A buy that would graduate reverts below a fixed floor (gradSettleGas, 1.4M), so the estimator sees the requirement instead of settling below it. The floor covers one pool open with room for the repair path in §8 and for walking more than one fee tier.
  • The try/catch around graduation no longer swallows running out of gas: EIP-150 leaves the parent a sixty-fourth of what it had, so a child that burned everything is recognisable, and that case reverts the buy rather than pretending the coin graduated.

Graduation itself is one shot and atomic. Either the reserve is in the pool or it is still on the curve; it is never split between the two, and there is no partial state to resume.

When the pool cannot be opened

Every fee tier squatted at an unusable price (§8) means the graduation reverts, and that is the correct answer rather than a failure. The curve stays live, holders can still sell back at the price it quotes, and nothing has been half-moved. graduate() is permissionless, so the moment any tier becomes usable anyone can carry it through - and the next trade does. Minting a reserve into a pool at a price an attacker chose would be strictly worse for holders than not graduating.

Nothing inside the vault can stop a graduation any more, either. There is no redemption in this path, so a leg that blacklists the vault, pauses transfers or caps them is simply not involved. That whole class of failure - and the probe machinery that used to handle it - is gone rather than handled.

Ordering, and why this is not nonReentrant

graduate() is permissionless. Every parameter is derived from immutables and current state, so there is nothing to front-run and no advantage to calling it. It is also called automatically at the end of any buy that crosses the threshold:

if (rReserve >= rGrad) {
    if (gasleft() < gradSettleGas) revert GasTooLow();
    uint256 g = gasleft();
    try this.graduate() {} catch {
        if (gasleft() < g / 64) revert GasTooLow(); // out of gas, not a logic failure
    }
}

The buy path calls it through this.graduate() while still holding the trade guard, so a second guard on the same variable would make automatic graduation impossible. It uses a separate one, and every piece of state is written before the first external call, so anything that re-enters reverts.

The try/catch still means a graduation that fails on its own terms cannot block a trade. What it no longer swallows is running out of gas: EIP-150 leaves the parent a sixty-fourth of what it had, so a child that burned everything is recognisable, and that case reverts the buy rather than pretending the coin graduated.

EightThe lock§

LpLocker holds every position this launchpad creates, forever. The lock is by absence of code, which is a stronger claim than a timelock and a verifiable one.

Read the contract and you will find: no path that transfers a position NFT out, no BURN_POSITION action, no INCREASE_LIQUIDITY, no owner, no upgrade, no delegatecall, and exactly one DECREASE_LIQUIDITY whose liquidity delta is a literal uint256(0) written in the source. There is no parameter for that zero and no other call site. No amount of calldata can turn it into a withdrawal.

It accepts the NFT through onERC721Received. There is no matching path that sends one out. That is the whole of the lock.

One honest caveat, because anyone reading the source will grep for transfers and find them: the locker does now have a swap path. unlockCallback settles and takes tokens with the PoolManager, which is how a squatted pool is pushed back to the right price before a position is opened into it. It is not a way out for a position: it is gated to the PoolManager as caller, it is only ever reached from a swap this contract itself initiated, it moves a capped sliver of what the contract is holding at that moment, and it cannot touch a position that already exists.

It still earns, which a burned NFT cannot

Sending a position to a burn address locks it and also destroys the fee stream. Here, collect(tokenId) realises accrued fees with a zero-liquidity decrease, which is the canonical v4 way to sweep fees without touching principal, followed by TAKE_PAIR. It is permissionless: anyone may poke it, and the destinations were fixed when the position was locked, so the caller decides nothing.

One kind of position, and how a coin's are found

Everything here is a graduated launch: COIN against the BASKET it was bought with, one position per coin. There is one entry point, addInitialLiquidity, callable only by a curve the launchpad registered and only for that curve's own coin, so a curve can never pair somebody else's token.

SideFee treatment
The coin Burned. Paying coin-side fees out would be a slow permanent sell into the pool this contract exists to protect.
The basket Split between the creator and the protocol at the curve's creatorFeeShareBps. The protocol's half is more of the token its basket's price beacon is made of (§12).

The locker indexes a coin's position: positionsOf(coin) returns it, and the position records the fee tier it landed on. That is the only honest way to find it, because the tier is not fixed - the locker steps down when the primary is taken at an unusable price, so probing tiers finds whatever a stranger initialised, which is a different pool with a different price.

The one position it will never hold is a basket's own BASKET/USDG pool (§12). That pool has to grow as the fees that fund it accrue, and this contract deliberately cannot increase a position, so it is held by the treasury instead. Naming the exception is the point: the permanence claim above applies to the pool a coin trades in, not to the beacon that puts a dollar sign in front of it.

Pool parameters

ParameterValueNote
Fee tier10_0001%, the memecoin tier. Falls back to 3000, 500, 100
Tick spacing200What the 1% tier conventionally pairs with, so discovery tooling finds it
Hooksaddress(0)No hook contract
Range−887200 … 887200Full range, aligned to spacing 200
Opening pricesqrt(a1 · 2^192 / a0)Exactly the ratio it was handed
LiquiditygetLiquidityForAmountsComputed against the price the pool is actually at
Price tolerance10 bpsHow far a pre-existing pool may sit from that price and still be used. On sqrtPrice, so about 20 bps on price
Reprice budget10 bpsMost of one side that may be spent pushing a squatted pool back to that price
What the lock does not say The lock is a statement about what happens to a position once it exists. It says nothing about how much goes into one. That is the creator's choice of rGrad, visible on chain before anyone buys, and readable from the curve at any time. A coin that graduates with a small reserve gets a small locked pool, locked just as permanently.

Opening a pool a stranger may already have opened

A v4 pool key is public data. The fee, the spacing and the hooks are constants, and both token addresses are known long before graduation: the coin from the Launched event, and the legs from the basket itself. Since PoolManager.initialize reverts on a pool that already exists, anyone can call it first on exactly the keys the launchpad is going to want.

Three rules make that survivable, and they are deliberately the set rather than any one alone:

  1. A pool that already exists is never fatal. initialize is tried, not required.
  2. An empty pool at a junk price is repaired rather than avoided. A v4 swap through zero liquidity moves the price to the limit and costs nothing, so the squat is simply undone: the locker swaps the pool back to the price it wants, bounded by REPRICE_BUDGET_BPS of one side and a fixed gas stipend. The bound is the point. Without it, a squatter who left real liquidity behind would be paid out of the reserve to be pushed through.
  3. A pool at the wrong price is never used. If the live price is still not within PRICE_TOLERANCE_BPS of the price the curve closed at, the reserve does not go into it. The locker moves to the next fee tier instead, of which there are four.

The second rule is the one that matters, and it is worth saying why it is not the obvious choice. Minting into a pool at somebody else's price would hand them the difference as a free arbitrage the moment the position opened. Not graduating is the better of those two outcomes, because a coin still on its curve can still be sold back to that curve at a fair price. So when a tier is unusable the locker declines it rather than paying for it.

Taken together, the cheap version of this attack no longer works: an empty squat is repriced away for nothing. What is left costs real capital, on every tier of every leg, held off price for as long as the griefing is meant to last, with no payout at the end of it. A leg whose tiers are all unusable is parked on the curve and retried rather than lost (§7). graduate() is permissionless and retryable, so a pool that later moves back into tolerance becomes usable again. Whichever tier a position lands on is recorded on the position and carried in the Locked event, so the pool key is always reconstructable from chain state.

The liquidity figure follows from the same rule. It is Uniswap's getLiquidityForAmounts evaluated at the price the pool is actually at, and the amount0Max and amount1Max caps handed to the mint are exactly what the locker is holding at that moment, read from its own balances rather than from the amounts it was handed a moment earlier, because repricing a squatted pool can spend a sliver of one side. So a mint can never pull in more than the curve handed over. Anything the mint does not take leaves in the same transaction under the same rule as fees: the burn side is burned, the other side is split. The locker holds nothing between calls, which is what lets collect safely pay out the whole balance it finds.

setLaunchpad, and why it is not immutable

The locker needs the launchpad's address to authorise a graduating curve, and the launchpad needs the locker's address in its own constructor. One of the two has to be filled in a moment later, so launchpad is written once by the deployer and then frozen: setLaunchpad reverts if it has already been set, and it clears _deployer to the zero address on success, so there is nothing left for the deployer to do afterwards, ever. On the live deployment this has been called and burned; see Appendix C for the command that verifies it.

NineBuying and selling§

A buyer holds dollars and wants the coin. A seller holds the coin and wants dollars. Neither should ever meet the basket, and neither does: it goes in and comes out of the vault inside a single transaction and never reaches a wallet.

buy   USDG ─swap─▶ legs ─mintMax─▶ shares ─curve.buy─▶  COIN
sell  COIN ─curve.sell─▶ shares ─redeem─▶ legs ─swap─▶  USDG

Why every trade is a single call

The app sends gasless batches, and a batch is not atomic: the bundler may split it across transactions. Any flow that leaves tokens somewhere shared between two calls is a flow where somebody else can step into the gap and take them. The sell used to do exactly that - redeem the legs into the UniversalRouter in one call, sell them in the next - and the window was real even though nothing was ever lost through it.

So BarbellZap takes the router's half of the trade as calldata, composed by the engine, and runs it itself, inside the same call. Four entry points, one call each: buy and sell on the curve, buy and sell after graduation. The contract holds nothing when the call returns, and it checks that the router holds nothing extra either - a route that left a leg behind reverts the whole trade rather than donating it to the next person through.

Why buying needs a contract and not just a batch

This could almost be done with a batched transaction and no new contract, and that is exactly how the launch flow works. It does not work for a buy, because the middle step's output is not known in advance. mintMax returns whatever the binding leg allows, so a batch would have to hard-code the amount it spends on the curve before the mint has run. The only safe guess is a low one, and the difference is left behind in the buyer's wallet as a token they never asked for. A contract simply reads the return value.

The same applies to what the mint does not take. Buying several coins can never land exactly on the vault's ratio, so a sliver of one of them is always over-bought. That sliver is donated to the basket rather than pushed into the buyer's wallet: it raises what every share is worth, and the shares in question are the ones backing the coin that buyer just bought, so it comes back to them through their own coin instead of being given away.

Why selling pays the router, not the seller

Selling has the same problem in the other direction, and it is subtler. The redemption at the end of a sell produces an amount of each leg that is only known once it executes. The obvious design redeems the legs into the seller's wallet and then sells them through Permit2, but Permit2 pulls a named amount, and the only amount that can be named in advance is the guaranteed floor. So it sells the floor, and everything the redemption delivered above it is stranded in the wallet.

The redemption therefore sends the legs straight to the UniversalRouter, and every swap that follows spends CONTRACT_BALANCE: whatever actually arrived, to the wei. Both happen inside one zap call, so the legs exist as a router balance only between two lines of the same transaction. Nothing is pulled from the seller, no allowance is needed, and the only thing that ever reaches their wallet is USDG. The floor is still computed, but it is used for one thing only, the slippage bound, and it stays sound because more input into an AMM can only ever produce more output.

This has been checked against the live router for every route shape a leg can take: a direct v3 pool, a v3 hop through WETH, a v4 hop through WETH, and a direct v4 pool. In the direct v4 case the router spent its entire balance and the seller received USDG and none of the token.

The one shape that cannot work A leg that trades on another launchpad's bonding curve sells by having that curve pull from whoever holds it, and the router is not a holder it can pull from. Such a coin could be bought into a basket and never sold back out to dollars, so the launch form refuses it before launch, which is the only point at which that is cheap to fix.

After graduation

Graduation closes the curve and leaves the coin trading in one pool, against the basket. So a trade does not change shape at all: it replaces one hop with another.

buy   before   USDG --router--> legs --mintMax--> shares --curve.buy--> COIN
buy   after    USDG --router--> legs --mintMax--> shares --v4 swap-->  COIN

sell  before   COIN --curve.sell--> shares --redeem--> legs --router--> USDG
sell  after    COIN --v4 swap-->    shares --redeem--> legs --router--> USDG

One line differs on each side. Everything else - the route into the legs, the in-kind mint, the in-kind redemption, the router-funded sale, the leftovers donated to the basket - is the same code running in the same order. That is worth more than it looks: the path a buyer takes does not acquire a new failure mode on the day a coin graduates, and the page prices both sides of that line with the same multiplication, shares per coin times what a share is worth.

Both run inside the same zap, in one call, with the pool swap done directly against the PoolManager rather than through the router: fewer moving parts, no Permit2 on that hop, and the sell's shares are redeemed straight into the router that is about to sell the legs.

The sell carries two floors rather than one, and they mean different things. minShares bounds the pool hop and minUsdgOut bounds the route, so a bad pool price and a bad route are distinguishable rather than arriving as the same failure.

The pool is found through the locker, never by probing fee tiers. A griefer can initialise any tier at a nonsense price, and the locker may then step down to another one (§8), so "the first tier that has a pool" can be the griefer's pool. Only a position the locker opened is real, and it records the tier it landed on. Price and depth are then read straight from the PoolManager's storage, and a quote built from them reproduced a real swap on this chain to the wei.

There is no second price to keep in line With one pool there is no gap between pools to arbitrage, no best-execution choice to make and no split to size: the coin has one price, and it is the basket's price times what the pool says. The keeper that exists (§12) is not doing that job - it keeps the basket's own pool honest against NAV, which is a different pool with a different purpose.

It holds nothing

BarbellZap has no owner, no state and no upgrade path, and it cannot move a token except as part of the call the caller asked for. Every balance it touches is swept back in the same transaction, so a balance sitting in it is never anyone's and never worth draining. Verified by test: after any operation, its balance of every token involved is exactly zero.

That is also what makes the standing Permit2 approval it holds for the router safe. It needs one, because the router pulls what it spends through Permit2, and an approval on a contract that never holds anything is an approval over nothing.

Who pays for the gas on a sell

A sponsored sell is charged a gas surcharge in USDG, and the zap takes it out of the proceeds rather than pulling it from the seller. So selling needs no USDG in hand, which matters because the usual reason to sell is not having any. The charge is a parameter of the call, bounded by what the sale actually produced, and the seller receives the remainder. "Only USDG reaches the wallet" stays true; a slice of it is the gas.

Slippage

The guard on a buy is minCoinOut, and a breach reverts the whole transaction rather than half-filling. Computing it correctly is subtler than it looks: an optimistic estimate of the shares a deposit will mint is wrong, because mintMax binds on the worst leg, not the average one. The correct figure comes from the router's guaranteed per-leg minimums run through the vault's live reserves, and then quoted against the curve or the pool. Using the expected output instead produces a tolerance breach on most transactions rather than a rare one.

TenLaunching§

One transaction defines a basket, tokenises it, mints a coin against it, opens the curve, and hands the creator their own coin. Nothing else is left in their wallet.

BarbellLaunchpad.launch(LaunchParams) runs in five steps:

  1. The basket. vaultFactory.createVault(legs, weights), which is idempotent, so the second coin on a basket lands in the first one's vault.
  2. The deposit. Every leg is pulled from the creator. If the vault is brand new it is seeded, which is the only moment the share price is chosen rather than derived. If it already exists, the deposit runs through mintMax at the live ratio, and whatever the mint does not take is donated to the vault. Either way the shares are minted to the launchpad, not to the creator.
  3. The coin. A fresh BasketCoin is minted to the launchpad and then transferred wholly to the curve, which is what lets arm() verify that no coin exists outside it.
  4. The curve. Deployed through CurveDeployer, with today's config and the basket's declared weights frozen into immutables, then armed. The deployer exists for one boring reason: a contract that contains another's creation code carries it in its own bytecode, and the launchpad plus the curve plus the coin went past the 24KB limit. It is ownerless and holds nothing, and a curve deployed through it by anyone else is worthless, because only the launchpad can arm or register one.
  5. The creator's buy at launch. Every share the deposit produced is spent on the curve, and the coin goes to recipient, which is the caller unless they name somebody else. With no deposit, there is nothing to spend and this step does nothing.

Why the leftovers go to the basket

A deposit can almost never land exactly on an existing vault's ratio, so some leg is always over-supplied, and mintMax takes only what the scarcest one allows. Sending the remainder back would hand the creator a few dollars of a coin they did not ask for, in a product whose whole argument is that they never have to hold one.

Donating it is not a gift to strangers. It raises what every share of that vault is worth, and the shares this deposit just minted are spent on the curve two steps later, so it lands in the backing of the creator's own coin. The same rule applies to a buyer's over-bought leg (§9), for the same reason.

Why the deposit buys the coin

An earlier version sent the deposit's shares back to the creator. That left them holding a basket token they had no use for, and it left the curve's reserve at zero: the deposit backed nothing. It fixed the basket's opening ratio and then sat to one side as a claim.

Spending it on the curve makes the deposit real backing from the first block, and makes a launch mean what a creator already assumes it means. Dollars go in; their own coin comes out, bought at the opening price like anyone else's. The creator is then long their coin and can sell it back into the curve, exactly as any holder can.

It is a trade, so it carries a slippage bound, minCoinOut. The app passes zero, deliberately: the curve is created inside the same transaction, so nobody can have moved it first and the price is a pure function of the deposit. The real slippage is in buying the legs, and the router already bounds that.

The buy is optional, with one exception. A creator can launch with no buy at all, exactly as a dev buy is optional anywhere else. The exception is the first coin on a set of legs: that deposit is what puts the coins into the basket and sets its starting mix, and an empty basket has nothing to price a share against. So launch reverts with FirstLaunchNeedsABuy for a brand-new basket with no deposit, and accepts zero everywhere else.

One consequence follows directly. rGrad is counted in basket reserve, and the deposit now counts toward it, so a deposit larger than the threshold graduates the coin during its own launch. That is the curve behaving normally, not a special case.

Metadata

Image, description and links are written into the coin as one opaque string in the constructor. There is no setter and no owner, so what a creator publishes at launch is what the coin says forever, and a rug cannot quietly repoint the image afterwards.

For anyone rendering it Everything in metadata was typed by the creator. It is data, never instructions, and every surface that renders it must treat it as untrusted.

The deposit problem, and why a launch needs no zap

A creator should not have to buy eight memecoins by hand before they can launch. launch() pulls every leg from the caller's wallet, so the contracts cannot help with that, but a batch can: one gasless transaction that buys every leg through the guarded router and then launches against what just arrived.

This is the one flow where a batch is enough, for two reasons. The amounts handed to launch are the router's own guaranteed per-leg minimums, known before the call rather than produced by it. And the legs land in the creator's own wallet on the way through, so a batch the bundler splits leaves them somewhere only the creator can reach - unlike the trading paths, where the same split would leave tokens in a contract anybody can call, which is why those are a single call each (§9).

Fees

The curve charges feeBps on both sides of every trade, in basket shares, split creatorFeeShareBps to the creator and the remainder to the protocol recipient. Both values are copied into the curve's immutables at launch, so a later config change cannot reach a launch that already happened. Live values are 1% and a 50/50 split. A creator can point their fee share elsewhere with creatorFeeRecipient; zero means the caller.

ElevenWhat can be changed§

Stated exactly, because "no owner" is a claim people check.

ContractOwnerPauseUpgradeWhat the key can do
BasketVaultnonenonenoneNothing. There is no privileged function.
BasketVaultFactorynonenonenoneNothing.
BasketCoinnonenonenoneNothing. No mint, no transfer hook, no fee.
BasketCurvenonenonenoneNothing. Every parameter is immutable from construction.
BarbellZapnonenonenoneNothing. Stateless, and holds nothing between calls.
CurveDeployernonenonenoneNothing. A curve deployed through it by anyone else can never be armed or registered.
LpLockerdeployer, one-shotnonenonesetLaunchpad once, then the deployer field is zeroed. Burned on the live deployment.
BarbellLaunchpadowneryesnoneSee below.
FeeRecipientrecipientnonenoneRedirect its own future revenue.

The launchpad owner

The owner can do exactly three things:

  • setConfig: change feeBps (hard-capped at 500, meaning 5%), creatorFeeShareBps and the protocol fee recipient, for future launches only. Every value is copied into the new curve's immutables at launch time, so config is forward-only and cannot reach a curve that already exists.
  • setPaused: stop new launches. It cannot pause a curve, move a reserve, touch a vault or unlock a position. Coins already launched trade, sell and graduate normally while paused.
  • transferOwnership or renounceOwnership.

Nothing already deployed is reachable from the launchpad. It holds no funds between transactions, has no function that can call into a curve or a vault other than during a launch it is performing, and has no upgrade path.

FeeRecipient

Every curve freezes its fee destination at construction. Pointing them all at one small contract rather than at a wallet means the treasury can be rotated later, after a key change or into a multisig, without a single deployed contract needing an admin function. The alternative is a settable address on every curve, which is a far larger surface for the same outcome.

The only power it holds is redirecting its own future revenue. It cannot touch a curve, a vault, a locked position or anyone else's money, and sweep can only ever push to the current recipient, so a compromised key redirects income and steals nothing that has already arrived.

TwelveVisibility§

A graduated coin trades against its basket, which is the pairing that makes it track that basket. Nobody outside this product prices a basket, so the dollar figure has to come from somewhere: one pool per basket against USDG, paid for out of the fees that basket has already earned.

Inside the product nothing depends on a pool at all. Buying and selling run through the vault's in-kind mint and redeem, a share is priced at NAV from the legs' own prices, and a graduated coin is priced at what its pool says times that. The vault is the liquidity, and a holder can leave through it whether or not any indexer has ever heard of the coin.

What outside indexers need

A coin on its curve has no pool, so it does not appear on public indexers. That is normal for a curve-phase launch anywhere.

After graduation it has one pool, COIN/BASKET. An indexer lists it on its first swap and reports its depth, but in basket units: it has no price for a basket, so the pair carries no dollar figure. One pool per underlying would have carried one for free - and would have cost 15% to 42% of the basket's move when the underlyings diverged (§7). The visibility problem is the cheaper of the two to fix, because it can be fixed with one pool per basket rather than one per launch.

The basket's own pool

One full-range BASKET/USDG pool per basket, shared by every coin ever launched on it. The second coin on a basket rides the first one's pool for nothing.

Nobody writes a cheque for it. Both sides are already accruing to the treasury, from that exact basket, and always have been:

  • the curve charges 1% a trade in basket shares, and the protocol's half goes to FeeRecipient, which sweeps to the treasury;
  • the app charges 0.75% in USDG, to the same treasury;
  • the locked COIN/BASKET position earns basket-side fees after graduation, split creator and protocol, which is more of the same token.

So the pool is funded by the people trading that basket, in proportion to how much they trade it. A busy basket earns its price beacon quickly; a dead basket never gets one and never should, which is a better rule than deciding up front which baskets deserve the money. It also means no creator is asked for anything at launch and there is no contract surface for it: it is an ordinary Uniswap position, opened by a script, and anyone else who wants to seed one may.

coins graduated on this basket $486 of beacon, paid by fees $9 — measured enough to be indexed and priced
Each graduation realises about $81 for its basket: the protocol’s half of the 1% curve fee, taken in shares, paired with USDG from the 0.75% app fee. Nobody is asked for anything.

Treasury-owned, deliberately not locked. LpLocker has no way to increase a position, by design (§8), and topping this one up as fees accrue is the entire point. This is infrastructure rather than a coin's liquidity: the COIN/BASKET pool stays locked forever, and pulling this one would remove a number from a third-party site without touching any holder's ability to trade or redeem. That is a real difference from everything else here and is stated rather than buried.

A thin pool is only dangerous if it is wrong, and a basket is the one thing that can be priced without an oracle, because the vault mints and redeems in kind. So the gap between the pool and NAV is knowable arithmetic, and closing it is ordinary permissionless steps: buy shares from a cheap pool and redeem them, or buy the legs, mint, and sell the shares into a rich one. Barbell runs a keeper that does this; it is not privileged, and the same trades are what keep the pool indexed.

What a brand-new basket looks like A basket that has earned no fees yet has no pool yet, so its first coin can graduate before its beacon exists. In that window the coin trades normally in the product, priced at NAV, and appears on indexers as a pair with no dollar figure. It heals as soon as that basket's first fees are seeded. The alternative was paying up front for beacons on baskets that may never be used.
Measured on this chain Before any of this was built, a throwaway token was paired against AI (a memecoin) and against AAPL (a tokenized stock), with about nine dollars in each pool and one swap through each. DexScreener and GeckoTerminal both listed both pairs within minutes, with a USD price and USD liquidity. That is what says a small beacon is worth opening rather than waiting for a large one - and the same test confirmed a stock token moves through the PoolManager and PositionManager with no transfer restriction.
Listing needs a trade, not just liquidity Indexers list a pool on its first swap. A pool opened with real depth and no trades stayed unlisted; a single one-dollar swap listed it within minutes. A graduated coin's pool therefore appears as soon as anyone trades it, which the first buy after graduation does, and a freshly seeded basket pool needs one deliberate swap to exist as far as an indexer is concerned.
On fee tiers Pools open at fee tier 10_000 with tick spacing 200, the pairing that tier conventionally uses. A pool is not guaranteed to be on that tier: if it is already taken at an unusable price the locker steps down through 3000, 500 and 100 (§8). The tier a position landed on is recorded on the position and in the Locked event, so anything reading these pools should take the key from there rather than assume the primary tier.

Appendix AParameters§

Launch parameters

FieldTypeMeaning
legsaddress[]1 to 8, strictly ascending, no duplicates, no vaults
weightsBpsuint16[]Sums to 10000. Part of the basket's id; not an enforced allocation (§3)
amountsuint256[]What the creator deposits, per leg. Whatever the mint cannot take is donated to the vault
basketSharesuint256Shares created when seeding a brand-new basket. Must exceed 1000
name, symbolstringThe coin's, immutable
metadatastringOpaque; image, description, links. No setter, ever
creatorFeeRecipientaddressZero means the caller
supplyuint256Total coin supply. 100% goes to the curve
vReserveuint256Virtual reserve, in basket shares. Opening price is vReserve / supply
rGraduint256Basket reserve, in shares, at which the curve graduates. See the note on presets below
minCoinOutuint256Slippage floor on the creator's own first buy, made from the deposit
recipientaddressWho receives the coin bought at launch. Zero means the caller

Presets carry a dollar intent, and it is converted off chain

A creator picks a preset rather than typing curve parameters, because the numbers that make a curve behave are not a matter of taste and a free text box invites someone to open at a billion-dollar valuation by accident. The three are written in BASKET SHARES:

PresetvReserverGrad
Starter1,500 shares3,000 shares
Standard4,000 shares8,000 shares
Serious12,000 shares30,000 shares

A brand-new basket is seeded at a dollar a share, so on one of those "8,000 shares" and "$8,000" are the same sentence. On a basket that already exists they are not. A share that has grown to $50 would turn Standard into $400,000 of buying and a $200,000 opening market cap, which is not what the preset says on its own label.

So what a preset preserves is the dollar figure, and the share amounts are derived from it at launch against the basket's live NAV. Standard means about $8,000 of buying on every basket, and the form shows both the dollar figure and the share count it turned into.

This is not a price entering the protocol The conversion happens in the form, once, before the transaction is built. The contract receives a plain number it never questions and reads no price for the rest of its life. That distinction is the whole of §5: choosing a constant off chain is not the same as depending on an oracle on chain, and a creator who calls the launchpad directly picks their own numbers with no conversion at all.

Constants and bounds

NameValueWhere
MAX_LEGS8BasketVault, BasketVaultFactory
DEAD_SHARES1e3BasketVault
feeBps cap500 (5%)BasketCurve constructor and setConfig
creatorFeeShareBps cap10000Same
POOL_FEE10000LpLocker, primary tier
Fallback tiers3000, 500, 100LpLocker
TICK_SPACING200LpLocker
Tick range−887200 … 887200LpLocker
PRICE_TOLERANCE_BPS10LpLocker. On sqrtPrice, so about 20 bps on price
REPRICE_BUDGET_BPS10LpLocker. Most of one side spendable on pushing a squatted pool back
REPRICE_GAS400,000LpLocker. Stipend for that swap; a pool stuffed with ticks simply fails the tier
gradSettleGas1,400,000BasketCurve. The floor a graduating buy must carry, so the estimator sees the requirement

What graduation costs

Legs in the basketGas for the buy that graduatesAt 0.047 gwei
21,024,324about 12 cents
8 (the maximum)1,024,324about 12 cents

The same number to the gas unit, because it is the same one pool either way. That equality is the cheapest possible confirmation that no per-leg work survives anywhere in the path, so GradGas.fork.t.sol measures both against the live PoolManager and PositionManager rather than asserting it. The floor a graduating buy must carry is 1,400,000, leaving about 37% of headroom for the pool-repair path in §8.

Paid by whoever happens to send the buy that crosses the threshold. The earlier per-leg design cost 1.81M for two legs and 6.28M for eight, so this is also the cheapest graduation this launchpad has had.

Live configuration

SettingValue
feeBps100 (1% per trade)
creatorFeeShareBps5000 (half to the creator)
pausedfalse
Default supply in the UI1e27 (one billion, 18 decimals)

Appendix BErrors§

ErrorRaised byCondition
BadLegCountVault, Factory0 legs, more than 8, or a length mismatch on mintMax
NotAscendingFactoryLegs out of order, or a duplicate
ZeroLegFactoryA leg is the zero address
NestedVaultFactoryA leg is itself a basket vault
WeightsMustSumToBpsFactoryWeights do not total 10000
NotOurVaultFactoryseedVault called on an address this factory did not deploy
AlreadySeededVault, FactorytotalSupply is already non-zero
NotFactoryVault, Curveseed or arm called by anyone else
NotSeededVaultmint or mintMax before the vault has a supply
ZeroSharesVaultZero shares requested, or mintMax could not cover one share
EmptyLegVaultA leg's balance is zero at seed time
FeeOnTransferLegVaultLess arrived than was transferred
ReentrancyVault, Curve, ZapA guarded function re-entered. The curve keeps a second guard for graduation, because a buy already holds the first one
NotArmedCurveTrading before arm()
AlreadyArmedCurvearm() called twice
SupplyNotWhollyOnCurveCurveSupply is zero, or any coin exists outside the curve at arming
AlreadyGraduatedCurve, ZapTrading on a graduated curve, or graduating one that already has
NotGraduatedCurve, Zapgraduate() below the reserve threshold, or a pool trade on a coin still on its curve
ZeroAmountCurve, Zap, LockerA zero input, or a buy that would take the whole curve
SlippageCurve, ZapOutput below minOut. Carries got and minOut
BadParamsCurve, LaunchpadOut-of-range fee, zero supply, or a zero vReserve or rGrad
NotOwner, PausedLaunchpadOwner-only call; new launches paused
NotAuthorisedLockerCaller is not a registered curve, or not the launchpad
UnknownPositionLockercollect() on a tokenId this locker does not hold
NotDeployer, LaunchpadAlreadySetLockersetLaunchpad after it was burned
NoUsablePoolLockerEvery fee tier for this pair already has a pool and none is at a usable price. The graduation reverts and stays retryable; the curve keeps trading meanwhile (§7)
PoolStateUnreadableLockerA pool exists but its state reads empty, so its price cannot be trusted
AmountOverflowLockerA computed liquidity or amount exceeds uint128
FirstLaunchNeedsABuyLaunchpadThe first coin on a set of legs launched with no deposit, so the basket would have no opening mix
GasTooLowCurveA buy that would graduate carries less than gradSettleGas, or graduation ran out of gas inside the try/catch rather than failing on its own terms
PriceOutOfRangeLockerThe two amounts are too far apart to be a price v4 can represent, so the pair cannot be opened at all
NothingMintedZapmintMax produced zero shares
RouterLeftFundsZapThe route left a leg sitting in the UniversalRouter, which is the hazard the zap exists to close
NotPoolManagerZap, LockerunlockCallback called by anyone but the PoolManager
ZeroAddress, NotRecipientFeeRecipientRedirecting revenue to nowhere, or by anyone but the current recipient
LengthMismatchFactorylegs and amounts differ in length

Appendix DWhat is tested§

67 tests, none of them skipped. Fifty run against the contracts in isolation; seventeen run against a fork of this chain, using the real PoolManager, the real PositionManager and the real UniversalRouter rather than mocks of them.

forge test                                        # the 50 unit tests
forge test --fork-url $RH_RPC                     # all 67
forge test --fork-url $RH_RPC --match-path "test/fork/GradGas*" -vv   # the gas numbers
node contracts/test/curve-backtest.mjs            # the curve over real price history

Every name below is the real function name, so any claim in this paper can be traced to the thing that checks it and run in about thirty seconds.

Against a fork of the live chain

The whole thing, end to end test/fork/Launch.fork.t.sol

TestWhat it establishes
test_fullLaunchAndGraduationA real launch on a fork of this chain: basket created, coin minted, curve traded both ways, graduation into ONE locked position against the vault, fees collected, and the backing redeemed in kind.
test_secondLaunchReusesTheSameBasketThe second coin on the same basket lands in the same vault.

Trading, in one call each test/fork/ZapTrade.fork.t.sol

TestWhat it establishes
test_aDeadLegDoesNotChangeWhatTheCoinIsWorthInSharesThe test this design exists for. A leg is emptied out of the vault entirely, which is what a leg going to zero looks like in kind, and the coin's price in basket shares does not move by one wei. It is still bought and sold afterwards through the surviving leg. This is the property one pool per underlying failed by 42%.
test_buyOnCurveLeavesTheWalletHoldingOnlyTheCoinDollars in, coin out. No leg and no basket token ever reaches the wallet, and the over-bought sliver is donated to the vault.
test_sellOnCurveReturnsOnlyUsdgCoin in, dollars out, with the redemption and the sale inside one call.
test_buyGraduatedThroughItsPoolThe same buy after graduation: the last hop is the pool instead of the curve, and nothing else changes.
test_sellGraduatedReturnsOnlyUsdgAndTakesItsFeeAnd the mirror, with the gas charge taken out of the proceeds so a seller needs no USDG in hand.
test_slippageBoundStopsABadSaleA sale that comes back short reverts the whole transaction rather than handing over the difference.
test_theShareFloorIsCheckedBeforeTheRouteRunsThe pool-side floor is separate from the dollar floor, so a bad pool price and a bad route are distinguishable.

What a griefer can and cannot do test/fork/PoolPreInit.fork.t.sol

TestWhat it establishes
test_anEmptySquatIsRepricedAndUsedA pool squatted at a junk price with no liquidity behind it is pushed back and used, because a v4 swap through zero liquidity costs nothing.
test_aFundedSquatIsRefusedAndTheNextTierIsUsedA squat with real money behind it cannot be pushed on a 10 bps budget, so that tier is refused and the next is used. The griefer pays and gains nothing.
test_aFullyGriefedCoinStaysOnItsCurveEvery tier funded and held off price: the coin does not graduate, nothing is half-moved, the reserve is intact, and holders can still sell at the price the curve quotes. Strictly better for them than being minted into a pool at an attacker's price.
test_lockerHoldsNothingAfterGraduatingWhatever a mint did not take leaves in the same transaction.

Gas, and the router

TestWhat it establishes
test_gasForTwoLegs / test_gasForEightLegsGraduation costs 1,024,324 gas, the SAME to the unit for a two-leg basket and an eight-leg one, because it is the same one pool either way. Measured against the live PoolManager and PositionManager.
test_v4DirectSellSpendsWhatTheRouterHoldsThe router-funded sell spends its whole balance to the wei, which is what lets a redemption of unknown size be sold in the same call.
test_canOpenAPoolAndMintAFullRangePositionThe raw v4 path this all rests on, against the real contracts.

Against the contracts alone

The curve's own arithmetic test/BasketCurve.t.sol

TestWhat it establishes
testFuzz_kNeverFalls fuzzConstant product holds across any sequence of buys and sells. Every rounding is against the user, so k can only rise. If it ever fell, rounding would be draining the reserve.
testFuzz_sellersCannotExtractMoreThanWasPaidIn fuzzThe solvency bound of §6, over random trade sequences. This is the property the no-pre-mint rule exists to protect.
testFuzz_roundTripNeverProfits fuzzBuying and immediately selling always costs the fee. No free money from rounding in either direction.
test_openingPriceIsVOverSThe opening price is exactly vReserve / supply.
test_priceRisesWithBuysPrice is monotone in the reserve.
test_cannotBuyOutTheWholeCurveNo single buy can take the last coin, however large.
test_graduationSeedsPoolAtTheClosingPriceThe pool opens at the curve's final marginal price, so there is no step for the first arbitrageur.
test_burnedAmountIsTheVirtualShareWhat is burned is exactly the share backed by the virtual reserve, which never existed.
test_graduationIsOnceA second call has nothing to move and says so, rather than opening a second position from an empty reserve.
test_noTradingAfterGraduationThe curve closes for good.
test_cannotTradeBeforeArmedNo trading before the supply check has run.
test_armRejectsSupplyNotWhollyOnCurveA pre-mint, a team allocation or a withheld tranche cannot be armed. This is what makes sell solvency unconditional.
test_armIsOnceAndFactoryOnlyArming is one-shot and callable only by the launchpad.
test_feesAccrueAndSplitFees accrue and split at the configured share.
test_claimingFeesDoesNotTouchTheReserveFees are held outside the reserve, so claiming them can never reduce what sellers are owed.
test_aBuyTooPoorToGraduateRevertsA buy that would graduate but cannot afford to REVERTS rather than silently skipping it, which is what makes graduation visible to a gas estimator.
testFuzz_gasCannotHalfGraduate fuzzStarving the call may stop a graduation but can never leave a half-done one. Either the reserve is in the pool or it is still on the curve.
test_failingGraduationDoesNotBlockTheBuyA graduation that fails on its own terms never takes the trade down with it.
test_aRefusedPoolIsRetriedRatherThanLostA pool that cannot be opened leaves the curve live and retryable rather than stranding anything.
test_theLockerCannotReenterGraduationThe one contract graduation hands control to cannot come back in.

The vault, which is the collateral test/BasketVault.t.sol

TestWhat it establishes
testFuzz_backingPerShareNeverFalls fuzzThe core claim of the whole design: what a share is a claim on can only rise. A holder cannot be diluted by anyone else's mint or redemption.
test_mintRedeemRoundTripInKindDeposit and redemption are pro rata and in kind. No price, no route, no oracle.
test_mintMaxLeavesDustAndNeverOverpullsA deposit binds on the scarcest leg and never takes more of the others than it is entitled to.
test_skippedClaimAccruesToRemainingHoldersA skipped leg's claim stays with the remaining holders. It is not destroyed and does not go to the protocol.
test_blacklistingLegCannotLockTheVaultOne leg that blacklists the vault cannot turn the basket into a roach motel.
test_feeOnTransferLegIsRejectedAtMintA leg that silently shrinks is refused rather than accounted around, because pro-rata maths over it would misreport every holder.
test_rebasingLegAccruesProRataA leg whose balance changes without a transfer lands pro rata on its own, with no sync to forget.
test_deadSharesPinSupplyAboveZeroThe first-depositor inflation attack is closed.
test_mintBeforeSeedRevertsThe opening ratio is set once, atomically, and cannot be front-run.
test_onlyFactorySeedsAndOnlyOnceSeeding is the one moment a price is chosen rather than derived, so it is locked down.
test_sameBasketSameVaultThe same legs and weights always resolve to the same vault, which is what stops liquidity fragmenting.
testFuzz_basketIdMatchesEngineHasher fuzzThe id the contract computes matches the one the engine computes, over random inputs.
test_rejectsBadBasketsUnsorted, duplicated, empty and over-long baskets are refused.
test_rejectsNestedVaultA vault cannot be a leg of another vault.

Launching test/BarbellLaunchpad.t.sol

TestWhat it establishes
test_creatorEndsUpHoldingTheirCoinNotTheBasketThe creator's deposit is spent on their own curve; they end up holding the coin, not a basket token they did not ask for.
test_secondLaunchAlsoLeavesNoBasketTokenThe same holds for every later launch on an existing basket.
test_secondLaunchDonatesWhatTheMintDidNotTakeLeftover legs go to the vault, which raises what every share is worth, rather than sitting in the launchpad forever.
test_seedingLaunchStrandsNothingA seeding launch leaves nothing behind in any contract.
test_firstCoinOnNewLegsNeedsABuyThe first coin on a new set of legs cannot launch with no deposit, because the basket would have no opening mix.
test_launchWithNoBuyOnAnExistingBasketOn an existing basket it can, because the mix already exists.
test_aDepositOverTheThresholdGraduatesOnLaunchA deposit big enough to cross rGrad graduates inside the launch transaction.
test_minCoinOutIsEnforcedOnTheCreatorsOwnBuyThe creator gets the same slippage protection as anyone else.
test_configIsForwardOnlyConfig changes reach future launches only. Nothing already deployed is reachable.
test_onlyOwnerMovesConfigAnd only the owner can move it.
test_pauseStopsLaunchesOnlyPausing stops new launches and never stops trading or exit on an existing coin.

Fees test/FeeRecipient.t.sol

TestWhat it establishes
test_sweepIsPermissionlessButDestinationIsNotAnyone may push the fees along; nobody may choose where they go.
test_onlyRecipientCanRotateOnly the current recipient can hand over.
test_cannotRotateToZeroAnd not to the zero address.
test_rotationDoesNotReachAlreadySweptFundsA rotation cannot claw back what has already been swept.
test_sweepOfNothingIsHarmlessSweeping an empty balance is a no-op, not a revert.
What the tests do not cover They do not cover the router leg of a graduated trade against the live UniversalRouter: the fork tests use a stand-in there, and the route's own encoding is proven separately by RouterFundedSell and by the curve path, which is the same code. They do not cover the keeper or the pool-opening script, neither of which has run against real money. And no test can tell you whether anyone wants the coin.

Appendix CAddresses§

Robinhood Chain, chain id 4663. Quote asset is USDG, 6 decimals.

ContractAddress
BasketVaultFactory0x33f7A7096a48b3A3CB2FF4EecB9400e4aa88A505
LpLocker0x9af4A74091D74C504ab8826AF783B865965CdAC9
BarbellLaunchpad0x07e7ebE7AfD5C35D65d40C7D2eD2d25Ba25bB8E7
FeeRecipient0xC254df85bB67A28D5Ad33735639e87F7162Ae7Ec
CurveDeployer0x4aC704678a0dB3aC860E26847AD20efb003A719e
BarbellZap0xE7Cb8F2A23Deb58DB73D0F6Ccf33F1A038e923d7

Verifying the wiring yourself

Every claim in §11 about what cannot be changed is checkable in one call each. None of these need a key.

# The locker points at the launchpad, and the deployer's one-shot is burned.
cast call $LOCKER "launchpad()(address)"      --rpc-url $RPC
cast call $LOCKER "setLaunchpad(address)" $X  --rpc-url $RPC   # must revert

# The launchpad points at the factory, the locker and the quote asset.
cast call $PAD "vaultFactory()(address)"      --rpc-url $RPC
cast call $PAD "locker()(address)"            --rpc-url $RPC
cast call $PAD "quote()(address)"             --rpc-url $RPC
cast call $PAD "curveDeployer()(address)"     --rpc-url $RPC

# Live config.
cast call $PAD "feeBps()(uint256)"            --rpc-url $RPC
cast call $PAD "creatorFeeShareBps()(uint256)" --rpc-url $RPC
cast call $PAD "paused()(bool)"               --rpc-url $RPC

# For any coin: 100% of supply was on the curve at arming, and the
# invariant (R+V)*S >= V*sTotal holds right now.
cast call $CURVE "sTotal()(uint256)"          --rpc-url $RPC
cast call $CURVE "sCurve()(uint256)"          --rpc-url $RPC
cast call $CURVE "rReserve()(uint256)"        --rpc-url $RPC
cast call $CURVE "vReserve()(uint256)"        --rpc-url $RPC

# After graduation: how many coins reached the pool, and which pool it is.
# positionsOf is the ONLY honest way to find it - the tier is not fixed,
# so probing fee tiers finds squatters rather than the coin.
cast call $CURVE "coinToPool()(uint256)"      --rpc-url $RPC
cast call $CURVE "positionId()(uint256)"      --rpc-url $RPC
cast call $CURVE "graduatedAt()(uint64)"      --rpc-url $RPC
cast call $CURVE "gradSettleGas()(uint256)"   --rpc-url $RPC
cast call $LOCKER "positionsOf(address)(uint256[])" $COIN --rpc-url $RPC
cast call $LOCKER "positions(uint256)(address,address,address,address,address,uint96,uint24)"   $TOKEN_ID --rpc-url $RPC

# A basket's real contents, which is what a share is a claim on.
cast call $VAULT "legs()(address[])"          --rpc-url $RPC
cast call $VAULT "reserves()(uint256[])"      --rpc-url $RPC
cast call $VAULT "totalSupply()(uint256)"     --rpc-url $RPC

The vault address for any basket can be derived without touching the chain, and checked against the factory:

cast call $FACTORY "predict(address[],uint16[])(address,bytes32)" \
  "[$LEG0,$LEG1]" "[5000,5000]" --rpc-url $RPC