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:
- 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.
- 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.
- 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:
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:
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:
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.
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.
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
| Rejected | Error | Why |
|---|---|---|
| 0 legs, or more than 8 | BadLegCount | Redemption walks every leg; the bound keeps it gas-safe |
| Unsorted or duplicate legs | NotAscending | Ascending order is what makes the id canonical |
| The zero address | ZeroLeg | |
| A leg that is itself a vault | NestedVault | A basket of baskets is a good feature and a vault-to-vault reentrancy path. Not in v1 |
| Weights not summing to 10000 | WeightsMustSumToBps | Keeps the id space clean; see §3 on what weights are |
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.
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:
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 ≥ rGradin 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+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.
| Basket | What it did | Graduation | k fell | Unpayable sells |
|---|---|---|---|---|
| meme, mid, stock-like | x1.28 a share | day 22, $6,900 of buying | 0 | 0 |
| two stock-like legs | x1.18 | day 26, $8,100 | 0 | 0 |
| three memecoins | x91.65 | never, 2,621 of 8,000 shares | 0 | 0 |
| crash window, then everyone exits | x2.42, meme leg −82% inside it | never, reserve drained to 7 shares | 0 | 0 |
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:
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:
Selling returns coins. The fee is taken off the output, and the reserve is debited the full gross:
| Quantity | Rounds | In favour of |
|---|---|---|
| Buy fee | up | the fee recipients |
| Coins out on a buy | down | the curve |
| Gross on a sell | down | the curve |
| Sell fee | up | the 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:
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:
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:
The proof rests on exactly three facts.
c ≤ sTotal − S. You cannot sell coins that do not exist outside the curve, and outstanding supply is exactlysTotal − S. This is true only becausearm()verified that every coin in existence came out of this curve.(R + V) · S ≥ V · sTotal. The invariant, monotone under every rounding, as shown in §5.kstarted atV · sTotal, because the curve opened withR = 0andS = sTotal.
Proof. The claim (R+V)·c/(S+c) ≤ R is equivalent, multiplying out by the
positive quantity S + c, to:
So the whole thing reduces to showing V·c ≤ R·S. From fact 2:
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.
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.
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:
On equal thirds, computed two ways and checked against a simulation of the arbitrage:
| What the legs did | Basket | Coin | Shortfall |
|---|---|---|---|
| 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% |
(Σ 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:
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/catcharound 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.
| Side | Fee 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
| Parameter | Value | Note |
|---|---|---|
| Fee tier | 10_000 | 1%, the memecoin tier. Falls back to 3000, 500, 100 |
| Tick spacing | 200 | What the 1% tier conventionally pairs with, so discovery tooling finds it |
| Hooks | address(0) | No hook contract |
| Range | −887200 … 887200 | Full range, aligned to spacing 200 |
| Opening price | sqrt(a1 · 2^192 / a0) | Exactly the ratio it was handed |
| Liquidity | getLiquidityForAmounts | Computed against the price the pool is actually at |
| Price tolerance | 10 bps | How far a pre-existing pool may sit from that price and still be used. On sqrtPrice, so about 20 bps on price |
| Reprice budget | 10 bps | Most of one side that may be spent pushing a squatted pool back to that price |
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:
- A pool that already exists is never fatal.
initializeis tried, not required. - 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_BPSof 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. - A pool at the wrong price is never used. If the live price is still not within
PRICE_TOLERANCE_BPSof 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.
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.
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:
- The basket.
vaultFactory.createVault(legs, weights), which is idempotent, so the second coin on a basket lands in the first one's vault. - 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
mintMaxat 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. - The coin. A fresh
BasketCoinis minted to the launchpad and then transferred wholly to the curve, which is what letsarm()verify that no coin exists outside it. - 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. - 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.
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.
| Contract | Owner | Pause | Upgrade | What the key can do |
|---|---|---|---|---|
| BasketVault | none | none | none | Nothing. There is no privileged function. |
| BasketVaultFactory | none | none | none | Nothing. |
| BasketCoin | none | none | none | Nothing. No mint, no transfer hook, no fee. |
| BasketCurve | none | none | none | Nothing. Every parameter is immutable from construction. |
| BarbellZap | none | none | none | Nothing. Stateless, and holds nothing between calls. |
| CurveDeployer | none | none | none | Nothing. A curve deployed through it by anyone else can never be armed or registered. |
| LpLocker | deployer, one-shot | none | none | setLaunchpad once, then the deployer field is zeroed. Burned on the live deployment. |
| BarbellLaunchpad | owner | yes | none | See below. |
| FeeRecipient | recipient | none | none | Redirect its own future revenue. |
The launchpad owner
The owner can do exactly three things:
setConfig: changefeeBps(hard-capped at 500, meaning 5%),creatorFeeShareBpsand 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.transferOwnershiporrenounceOwnership.
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.
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.
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
| Field | Type | Meaning |
|---|---|---|
legs | address[] | 1 to 8, strictly ascending, no duplicates, no vaults |
weightsBps | uint16[] | Sums to 10000. Part of the basket's id; not an enforced allocation (§3) |
amounts | uint256[] | What the creator deposits, per leg. Whatever the mint cannot take is donated to the vault |
basketShares | uint256 | Shares created when seeding a brand-new basket. Must exceed 1000 |
name, symbol | string | The coin's, immutable |
metadata | string | Opaque; image, description, links. No setter, ever |
creatorFeeRecipient | address | Zero means the caller |
supply | uint256 | Total coin supply. 100% goes to the curve |
vReserve | uint256 | Virtual reserve, in basket shares. Opening price is vReserve / supply |
rGrad | uint256 | Basket reserve, in shares, at which the curve graduates. See the note on presets below |
minCoinOut | uint256 | Slippage floor on the creator's own first buy, made from the deposit |
recipient | address | Who 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:
| Preset | vReserve | rGrad |
|---|---|---|
| Starter | 1,500 shares | 3,000 shares |
| Standard | 4,000 shares | 8,000 shares |
| Serious | 12,000 shares | 30,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.
Constants and bounds
| Name | Value | Where |
|---|---|---|
MAX_LEGS | 8 | BasketVault, BasketVaultFactory |
DEAD_SHARES | 1e3 | BasketVault |
feeBps cap | 500 (5%) | BasketCurve constructor and setConfig |
creatorFeeShareBps cap | 10000 | Same |
POOL_FEE | 10000 | LpLocker, primary tier |
| Fallback tiers | 3000, 500, 100 | LpLocker |
TICK_SPACING | 200 | LpLocker |
| Tick range | −887200 … 887200 | LpLocker |
PRICE_TOLERANCE_BPS | 10 | LpLocker. On sqrtPrice, so about 20 bps on price |
REPRICE_BUDGET_BPS | 10 | LpLocker. Most of one side spendable on pushing a squatted pool back |
REPRICE_GAS | 400,000 | LpLocker. Stipend for that swap; a pool stuffed with ticks simply fails the tier |
gradSettleGas | 1,400,000 | BasketCurve. The floor a graduating buy must carry, so the estimator sees the requirement |
What graduation costs
| Legs in the basket | Gas for the buy that graduates | At 0.047 gwei |
|---|---|---|
| 2 | 1,024,324 | about 12 cents |
| 8 (the maximum) | 1,024,324 | about 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
| Setting | Value |
|---|---|
feeBps | 100 (1% per trade) |
creatorFeeShareBps | 5000 (half to the creator) |
paused | false |
| Default supply in the UI | 1e27 (one billion, 18 decimals) |
Appendix BErrors§
| Error | Raised by | Condition |
|---|---|---|
BadLegCount | Vault, Factory | 0 legs, more than 8, or a length mismatch on mintMax |
NotAscending | Factory | Legs out of order, or a duplicate |
ZeroLeg | Factory | A leg is the zero address |
NestedVault | Factory | A leg is itself a basket vault |
WeightsMustSumToBps | Factory | Weights do not total 10000 |
NotOurVault | Factory | seedVault called on an address this factory did not deploy |
AlreadySeeded | Vault, Factory | totalSupply is already non-zero |
NotFactory | Vault, Curve | seed or arm called by anyone else |
NotSeeded | Vault | mint or mintMax before the vault has a supply |
ZeroShares | Vault | Zero shares requested, or mintMax could not cover one share |
EmptyLeg | Vault | A leg's balance is zero at seed time |
FeeOnTransferLeg | Vault | Less arrived than was transferred |
Reentrancy | Vault, Curve, Zap | A guarded function re-entered. The curve keeps a second guard for graduation, because a buy already holds the first one |
NotArmed | Curve | Trading before arm() |
AlreadyArmed | Curve | arm() called twice |
SupplyNotWhollyOnCurve | Curve | Supply is zero, or any coin exists outside the curve at arming |
AlreadyGraduated | Curve, Zap | Trading on a graduated curve, or graduating one that already has |
NotGraduated | Curve, Zap | graduate() below the reserve threshold, or a pool trade on a coin still on its curve |
ZeroAmount | Curve, Zap, Locker | A zero input, or a buy that would take the whole curve |
Slippage | Curve, Zap | Output below minOut. Carries got and minOut |
BadParams | Curve, Launchpad | Out-of-range fee, zero supply, or a zero vReserve or rGrad |
NotOwner, Paused | Launchpad | Owner-only call; new launches paused |
NotAuthorised | Locker | Caller is not a registered curve, or not the launchpad |
UnknownPosition | Locker | collect() on a tokenId this locker does not hold |
NotDeployer, LaunchpadAlreadySet | Locker | setLaunchpad after it was burned |
NoUsablePool | Locker | Every 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) |
PoolStateUnreadable | Locker | A pool exists but its state reads empty, so its price cannot be trusted |
AmountOverflow | Locker | A computed liquidity or amount exceeds uint128 |
FirstLaunchNeedsABuy | Launchpad | The first coin on a set of legs launched with no deposit, so the basket would have no opening mix |
GasTooLow | Curve | A 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 |
PriceOutOfRange | Locker | The two amounts are too far apart to be a price v4 can represent, so the pair cannot be opened at all |
NothingMinted | Zap | mintMax produced zero shares |
RouterLeftFunds | Zap | The route left a leg sitting in the UniversalRouter, which is the hazard the zap exists to close |
NotPoolManager | Zap, Locker | unlockCallback called by anyone but the PoolManager |
ZeroAddress, NotRecipient | FeeRecipient | Redirecting revenue to nowhere, or by anyone but the current recipient |
LengthMismatch | Factory | legs 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
| Test | What it establishes |
|---|---|
test_fullLaunchAndGraduation | A 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_secondLaunchReusesTheSameBasket | The second coin on the same basket lands in the same vault. |
Trading, in one call each test/fork/ZapTrade.fork.t.sol
| Test | What it establishes |
|---|---|
test_aDeadLegDoesNotChangeWhatTheCoinIsWorthInShares | The 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_buyOnCurveLeavesTheWalletHoldingOnlyTheCoin | Dollars in, coin out. No leg and no basket token ever reaches the wallet, and the over-bought sliver is donated to the vault. |
test_sellOnCurveReturnsOnlyUsdg | Coin in, dollars out, with the redemption and the sale inside one call. |
test_buyGraduatedThroughItsPool | The same buy after graduation: the last hop is the pool instead of the curve, and nothing else changes. |
test_sellGraduatedReturnsOnlyUsdgAndTakesItsFee | And the mirror, with the gas charge taken out of the proceeds so a seller needs no USDG in hand. |
test_slippageBoundStopsABadSale | A sale that comes back short reverts the whole transaction rather than handing over the difference. |
test_theShareFloorIsCheckedBeforeTheRouteRuns | The 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
| Test | What it establishes |
|---|---|
test_anEmptySquatIsRepricedAndUsed | A 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_aFundedSquatIsRefusedAndTheNextTierIsUsed | A 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_aFullyGriefedCoinStaysOnItsCurve | Every 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_lockerHoldsNothingAfterGraduating | Whatever a mint did not take leaves in the same transaction. |
Gas, and the router
| Test | What it establishes |
|---|---|
test_gasForTwoLegs / test_gasForEightLegs | Graduation 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_v4DirectSellSpendsWhatTheRouterHolds | The 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_canOpenAPoolAndMintAFullRangePosition | The raw v4 path this all rests on, against the real contracts. |
Against the contracts alone
The curve's own arithmetic test/BasketCurve.t.sol
| Test | What it establishes |
|---|---|
testFuzz_kNeverFalls fuzz | Constant 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 fuzz | The solvency bound of §6, over random trade sequences. This is the property the no-pre-mint rule exists to protect. |
testFuzz_roundTripNeverProfits fuzz | Buying and immediately selling always costs the fee. No free money from rounding in either direction. |
test_openingPriceIsVOverS | The opening price is exactly vReserve / supply. |
test_priceRisesWithBuys | Price is monotone in the reserve. |
test_cannotBuyOutTheWholeCurve | No single buy can take the last coin, however large. |
test_graduationSeedsPoolAtTheClosingPrice | The pool opens at the curve's final marginal price, so there is no step for the first arbitrageur. |
test_burnedAmountIsTheVirtualShare | What is burned is exactly the share backed by the virtual reserve, which never existed. |
test_graduationIsOnce | A second call has nothing to move and says so, rather than opening a second position from an empty reserve. |
test_noTradingAfterGraduation | The curve closes for good. |
test_cannotTradeBeforeArmed | No trading before the supply check has run. |
test_armRejectsSupplyNotWhollyOnCurve | A pre-mint, a team allocation or a withheld tranche cannot be armed. This is what makes sell solvency unconditional. |
test_armIsOnceAndFactoryOnly | Arming is one-shot and callable only by the launchpad. |
test_feesAccrueAndSplit | Fees accrue and split at the configured share. |
test_claimingFeesDoesNotTouchTheReserve | Fees are held outside the reserve, so claiming them can never reduce what sellers are owed. |
test_aBuyTooPoorToGraduateReverts | A 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 fuzz | Starving 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_failingGraduationDoesNotBlockTheBuy | A graduation that fails on its own terms never takes the trade down with it. |
test_aRefusedPoolIsRetriedRatherThanLost | A pool that cannot be opened leaves the curve live and retryable rather than stranding anything. |
test_theLockerCannotReenterGraduation | The one contract graduation hands control to cannot come back in. |
The vault, which is the collateral test/BasketVault.t.sol
| Test | What it establishes |
|---|---|
testFuzz_backingPerShareNeverFalls fuzz | The 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_mintRedeemRoundTripInKind | Deposit and redemption are pro rata and in kind. No price, no route, no oracle. |
test_mintMaxLeavesDustAndNeverOverpulls | A deposit binds on the scarcest leg and never takes more of the others than it is entitled to. |
test_skippedClaimAccruesToRemainingHolders | A skipped leg's claim stays with the remaining holders. It is not destroyed and does not go to the protocol. |
test_blacklistingLegCannotLockTheVault | One leg that blacklists the vault cannot turn the basket into a roach motel. |
test_feeOnTransferLegIsRejectedAtMint | A leg that silently shrinks is refused rather than accounted around, because pro-rata maths over it would misreport every holder. |
test_rebasingLegAccruesProRata | A leg whose balance changes without a transfer lands pro rata on its own, with no sync to forget. |
test_deadSharesPinSupplyAboveZero | The first-depositor inflation attack is closed. |
test_mintBeforeSeedReverts | The opening ratio is set once, atomically, and cannot be front-run. |
test_onlyFactorySeedsAndOnlyOnce | Seeding is the one moment a price is chosen rather than derived, so it is locked down. |
test_sameBasketSameVault | The same legs and weights always resolve to the same vault, which is what stops liquidity fragmenting. |
testFuzz_basketIdMatchesEngineHasher fuzz | The id the contract computes matches the one the engine computes, over random inputs. |
test_rejectsBadBaskets | Unsorted, duplicated, empty and over-long baskets are refused. |
test_rejectsNestedVault | A vault cannot be a leg of another vault. |
Launching test/BarbellLaunchpad.t.sol
| Test | What it establishes |
|---|---|
test_creatorEndsUpHoldingTheirCoinNotTheBasket | The 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_secondLaunchAlsoLeavesNoBasketToken | The same holds for every later launch on an existing basket. |
test_secondLaunchDonatesWhatTheMintDidNotTake | Leftover legs go to the vault, which raises what every share is worth, rather than sitting in the launchpad forever. |
test_seedingLaunchStrandsNothing | A seeding launch leaves nothing behind in any contract. |
test_firstCoinOnNewLegsNeedsABuy | The first coin on a new set of legs cannot launch with no deposit, because the basket would have no opening mix. |
test_launchWithNoBuyOnAnExistingBasket | On an existing basket it can, because the mix already exists. |
test_aDepositOverTheThresholdGraduatesOnLaunch | A deposit big enough to cross rGrad graduates inside the launch transaction. |
test_minCoinOutIsEnforcedOnTheCreatorsOwnBuy | The creator gets the same slippage protection as anyone else. |
test_configIsForwardOnly | Config changes reach future launches only. Nothing already deployed is reachable. |
test_onlyOwnerMovesConfig | And only the owner can move it. |
test_pauseStopsLaunchesOnly | Pausing stops new launches and never stops trading or exit on an existing coin. |
Fees test/FeeRecipient.t.sol
| Test | What it establishes |
|---|---|
test_sweepIsPermissionlessButDestinationIsNot | Anyone may push the fees along; nobody may choose where they go. |
test_onlyRecipientCanRotate | Only the current recipient can hand over. |
test_cannotRotateToZero | And not to the zero address. |
test_rotationDoesNotReachAlreadySweptFunds | A rotation cannot claw back what has already been swept. |
test_sweepOfNothingIsHarmless | Sweeping an empty balance is a no-op, not a revert. |
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.
| Contract | Address |
|---|---|
| BasketVaultFactory | 0x33f7A7096a48b3A3CB2FF4EecB9400e4aa88A505 |
| LpLocker | 0x9af4A74091D74C504ab8826AF783B865965CdAC9 |
| BarbellLaunchpad | 0x07e7ebE7AfD5C35D65d40C7D2eD2d25Ba25bB8E7 |
| FeeRecipient | 0xC254df85bB67A28D5Ad33735639e87F7162Ae7Ec |
| CurveDeployer | 0x4aC704678a0dB3aC860E26847AD20efb003A719e |
| BarbellZap | 0xE7Cb8F2A23Deb58DB73D0F6Ccf33F1A038e923d7 |
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