Bonding curve
BondingCurve is cloned once per launched token (deliberately not a shared pool — a bug in a
shared contract would drain every token's reserves at once). Source:
contracts/contracts/BondingCurve.sol in the hartii-labs repo. Implementation contract live at
0x0062D75A096E67FEF48A8C5a9fD9094d2BEa9D14.
Every launch's actual trading contract is a clone at its own address — look it up via
TokenFactory.curveOf(token).
Two phases live in this same contract, forever: there is no external DEX, no WQUAI, no router, no LP token sent anywhere. This contract is the market for its token before and after graduation.
Phase 1 — bonding curve
A constant-product (x*y=k) curve priced off virtual reserves (the pump.fun model): the curve
quotes as if it always held virtualQuaiReserve + realQuaiReserve QUAI and
virtualTokenReserve - tokensSold tokens, without ever actually holding the virtual portion. This
gives a smooth, always-positive price curve without needing a large real QUAI reserve up front.
effective QUAI reserve (qr) = virtualQuaiReserve + realQuaiReserve
effective token reserve (tr) = virtualTokenReserve - tokensSoldBuy math (bonding phase)
fee = floor(msg.value * feeBps / 10000)
netIn = msg.value - fee
k = qr * tr
newTr = floor(k / (qr + netIn))
tokensOut = tr - newTrThe fee is taken on the input side (QUAI in), before the swap math runs — netIn, not
msg.value, is what actually moves the curve.
If tokensOut would exceed tokensRemaining() (the curve doesn't have that many tokens left to
sell), the contract clamps the buy to exactly remaining tokens, recomputes the exact
requiredNetIn for that amount by inverting the same formula, derives the gross-of-fee amount by
rounding up (so the curve never undercollects vs. what it pays out), and refunds the leftover
msg.value to the buyer via the safe-pay fallback. No value is created or destroyed by this
clamp.
Sell math (bonding phase)
(tokens pulled from seller via transferFrom, BEFORE the math below)
k = qr * tr
newTr = tr + tokensIn
newQr = floor(k / newTr)
grossOut = qr - newQr (clamped to realQuaiReserve if rounding pushes it over)
fee = floor(grossOut * feeBps / 10000)
quaiOut = grossOut - feeThe fee is taken on the output side (QUAI out) here — the opposite side from a buy. Both directions take the fee in QUAI, never in the token.
quoteBuy / quoteSell — fee-blind, read carefully
function quoteBuy(uint256 quaiIn) public view returns (uint256 tokensOut) {
(uint256 qr, uint256 tr) = graduated ? (poolQuaiReserve, poolTokenReserve) : _effectiveReserves();
uint256 k = qr * tr;
uint256 newTr = k / (qr + quaiIn);
tokensOut = tr - newTr;
}
function quoteSell(uint256 tokensIn) public view returns (uint256 quaiOut) {
(uint256 qr, uint256 tr) = graduated ? (poolQuaiReserve, poolTokenReserve) : _effectiveReserves();
uint256 k = qr * tr;
uint256 newQr = k / (tr + tokensIn);
quaiOut = qr - newQr;
}Worked example (bonding phase)
Using the live factory defaults as of 2026-09-23 — virtualQuaiReserve = 17,000 QUAI,
virtualTokenReserve = 1,073,000,000 tokens, feeBps = 100 (1%, the value observed on every live
curve sampled) — on a freshly launched curve with no trades yet
(realQuaiReserve = 0, tokensSold = 0):
Buy 1,000 QUAI:
| Step | Value |
|---|---|
fee = 1000 * 100 / 10000 | 10 QUAI |
netIn = 1000 - 10 | 990 QUAI |
qr = 17000 + 0 | 17,000 |
tr = 1,073,000,000 - 0 | 1,073,000,000 |
k = qr * tr | 18,241,000,000,000 |
newTr = floor(k / (qr + netIn)) = floor(18,241,000,000,000 / 17,990) | 1,013,952,195 |
tokensOut = tr - newTr | 59,047,805 tokens |
Compare this to quoteBuy(1000 QUAI) — passing the gross amount, as the Warning above cautions
against — which computes newTr = floor(18,241,000,000,000 / 18,000) = 1,013,388,888 and reports
59,611,112 tokens: about 563,000 tokens (~0.95%) more than the real buy() output, because it
never subtracted the 1% fee from the input.
Then sell 25,000,000 tokens, from a (different, illustrative) curve state where
tokensSold = 73,000,000 and realQuaiReserve = 3,000 QUAI (chosen for round numbers):
| Step | Value |
|---|---|
qr = 17000 + 3000 | 20,000 |
tr = 1,073,000,000 - 73,000,000 | 1,000,000,000 |
k = qr * tr | 20,000,000,000,000 |
newTr = tr + tokensIn | 1,025,000,000 |
newQr = floor(k / newTr) | 19,512.195... QUAI |
grossOut = qr - newQr | 487.805 QUAI |
fee = grossOut * 100 / 10000 | 4.878 QUAI |
quaiOut = grossOut - fee | ≈ 482.927 QUAI |
Figures rounded to a few decimals for readability; the contract works in integer wei throughout.
Phase 2 — pool (post-graduation)
Graduation trigger
Checked at the end of every buy(), after state is fully updated:
if (tokensRemaining() == 0 || realQuaiReserve >= graduationRaiseWei) {
_graduate();
}- Primary trigger:
tokensRemaining() == 0— the curve's entire realcurveSupplyhas sold out. Under the live default parameters (17,000 virtual QUAI / 1.073B virtual tokens / 800M curve-sellable supply), a full sellout raises approximately 50,000 QUAI gross, well under the 100,000 QUAIgraduationRaiseWeiguard. - Belt-and-braces trigger:
realQuaiReserve >= graduationRaiseWei— reachable first if the owner sets different curve params (e.g. a lower threshold or more generous virtual reserves) such that real QUAI crosses the threshold before the token supply runs out.
Graduation mechanics
function _graduate() internal {
require(!graduated, "Already graduated");
graduated = true; // set BEFORE any further mutation
uint256 quaiSeed = realQuaiReserve;
uint256 tokenSeed = (curveSupply - tokensSold) + migrationTokenReserve;
realQuaiReserve = 0;
poolQuaiReserve = quaiSeed;
poolTokenReserve = tokenSeed;
emit Graduated(token, quaiSeed, tokenSeed);
}The pool is seeded from the curve's real reserves at the instant of graduation, plus whatever
migrationTokenReserve was set aside at launch specifically for this — deliberately not from
the virtual-inclusive quote the curve was showing a moment before.
graduated is set to true before any further state mutation (checks-effects-interactions),
so re-graduation is structurally impossible — a second call can never re-run the seeding logic.
Pool buy/sell math
Identical x*y=k mechanics to the bonding phase, but always against poolQuaiReserve /
poolTokenReserve — never virtual reserves, since the pool has none:
buy: fee = floor(msg.value * feeBps / 10000); netIn = msg.value - fee
k = poolQuaiReserve * poolTokenReserve
tokensOut = poolTokenReserve - floor(k / (poolQuaiReserve + netIn))
sell: (tokens pulled from seller first)
k = poolQuaiReserve * poolTokenReserve
grossOut = poolQuaiReserve - floor(k / (poolTokenReserve + tokensIn))
fee = floor(grossOut * feeBps / 10000); quaiOut = grossOut - feeBoth directions additionally enforce a reserve floor: a single trade may never fully drain
either side of the pool to exactly zero ("Pool reserve floor" / "Insufficient pool liquidity"
reverts) — that would leave the pool unable to price the very next trade.
Pool-phase trades emit the same Buy/Sell events as bonding-phase trades, so an indexer can
follow a token's whole trading history without special-casing the graduation boundary.
Fee accrual and withdrawal
Every fee collected (either phase, either direction) is split via _accrueFee:
function _accrueFee(uint256 fee) internal {
uint256 toCreator = fee / 2;
uint256 toTreasury = fee - toCreator;
creatorFees += toCreator;
treasuryFees += toTreasury;
}50% to the token's creator, 50% to treasury — any 1-wei odd remainder from an odd fee goes
to treasury. The two pots (creatorFees, treasuryFees) are fully independent accumulators; there
is no combined pot and no HARTII buyback leg — creators earn their own trading fees directly.
| Function | Who may call | Pays | Reverts |
|---|---|---|---|
withdrawTreasuryFees() | owner only | treasury, all of treasuryFees, then zeroes it | "Not owner", "Nothing to withdraw" |
withdrawCreatorFees() | creator only (checked against msg.sender, not a passed-in address) | creator, all of creatorFees, then zeroes it | "Not creator", "Nothing to withdraw" |
Both are nonReentrant and use the same safe-pay fallback as everything else on this contract.
Anti-snipe caps
Enforced inside buy(), checked before state mutation, using the trade's post-clamp
tokensOut:
| Cap | Value | Window |
|---|---|---|
| Creator launch-block cap | CREATOR_CAP_BPS = 200 (2% of curveSupply) | Only on block.number == launchBlock, only for msg.sender == creator |
| Per-wallet snipe-window cap | PER_WALLET_CAP_BPS = 100 (1% of curveSupply) | block.number < launchBlock + SNIPE_WINDOW_BLOCKS (20 blocks), tracked cumulatively per wallet in boughtDuringSnipeWindow |
Reverts: "Exceeds creator launch-block cap", "Exceeds per-wallet snipe-window cap".
Reference tables
State / view functions
| Function | Returns | Notes |
|---|---|---|
token() | address | The LaunchToken clone this curve trades. |
creator() | address | Set once at initialize(). |
factory() | address | The TokenFactory that deployed this clone. |
owner() | address | Fee/admin controls only — never touches reserves. |
treasury() | address | This curve's own fee-split destination (independent per curve). |
virtualQuaiReserve() | uint256 | Frozen at initialize(). |
virtualTokenReserve() | uint256 | Frozen at initialize(). |
curveSupply() | uint256 | Real tokens sellable pre-graduation. Frozen at initialize(). |
migrationTokenReserve() | uint256 | Tokens held aside, never sellable pre-graduation, that seed the pool's token side at graduation. Frozen at initialize(). |
graduationRaiseWei() | uint256 | Belt-and-braces threshold. Frozen at initialize(). |
realQuaiReserve() | uint256 | Real QUAI held against sellable tokens, bonding phase only (zeroed at graduation). |
tokensSold() | uint256 | Tokens sold out of curveSupply, bonding phase. |
graduated() | bool | Phase flag. |
poolQuaiReserve() | uint256 | Real QUAI held by the pool, post-graduation. |
poolTokenReserve() | uint256 | Real tokens held by the pool, post-graduation. |
feeBps() | uint256 | This curve's own fee, default 100 (1%) at init, owner-adjustable up to 300. |
MAX_FEE_BPS() | uint256 | Constant, 300 (3%). |
treasuryFees() / creatorFees() | uint256 | Claimable accumulators. |
launchBlock() | uint256 | Block this curve was initialized in. |
SNIPE_WINDOW_BLOCKS() | uint256 | Constant, 20. |
PER_WALLET_CAP_BPS() / CREATOR_CAP_BPS() | uint256 | Constants, 100 / 200. |
boughtDuringSnipeWindow(address) | uint256 | Cumulative snipe-window buys for a wallet. |
pending(address) | uint256 | Safe-pay fallback balance. |
tokensRemaining() | uint256 | curveSupply - tokensSold pre-graduation, 0 post-graduation. |
quoteBuy(uint256 quaiIn) | uint256 tokensOut | Fee-blind — see the Warning above. |
quoteSell(uint256 tokensIn) | uint256 quaiOut | Fee-blind, gross — see the Warning above. |
Live sample (two curves read 2026-09-23): feeBps = 100, MAX_FEE_BPS = 300,
virtualQuaiReserve = 17,000 QUAI, virtualTokenReserve = 1,073,000,000,
curveSupply = 800,000,000, migrationTokenReserve = 200,000,000,
graduationRaiseWei = 100,000 QUAI on both sampled curves — matching the factory's current
defaults exactly. Neither sampled curve had graduated at read time.
State-changing functions
| Function | Signature | Who | Reverts |
|---|---|---|---|
buy | buy(uint256 minTokensOut) external payable nonReentrant returns (uint256 tokensOut) | Anyone | "Zero QUAI" (msg.value == 0), "Zero out", "Slippage" (tokensOut < minTokensOut), "Exceeds creator launch-block cap", "Exceeds per-wallet snipe-window cap", "Token transfer failed" |
sell | sell(uint256 tokensIn, uint256 minQuaiOut) external nonReentrant returns (uint256 quaiOut) | Anyone holding/approving tokens | "Zero tokens", "Token pull failed", "Slippage" (quaiOut < minQuaiOut) |
withdrawTreasuryFees | external onlyOwner nonReentrant | owner | "Not owner", "Nothing to withdraw" |
withdrawCreatorFees | external nonReentrant | creator | "Not creator", "Nothing to withdraw" |
setFeeBps | setFeeBps(uint256 newBps) external onlyOwner | owner | "Not owner", "Exceeds fee cap" (> 300) |
setTreasury | setTreasury(address newTreasury) external onlyOwner | owner | "Not owner", "Zero treasury" |
transferOwnership | transferOwnership(address newOwner) external onlyOwner | owner | "Not owner", "Zero owner" |
sweepDonatedTokens | sweepDonatedTokens(address to) external onlyOwner nonReentrant | owner | "Not owner", "Zero to", "Nothing to sweep" |
withdraw | withdraw() external nonReentrant | Anyone with a nonzero pending balance | "Nothing to withdraw", "Withdraw failed" |
receive | receive() external payable {} | Anyone | Accepts stray QUAI with no effect on pricing; no sweep path exists for donated QUAI (deliberate, v1) |
sell() requires the caller to have approved this curve contract for at least tokensIn on
LaunchToken first (it calls transferFrom, not transfer) — see
Integrating with quais.
Events
| Event | Indexed | Non-indexed | Emitted by |
|---|---|---|---|
Buy | buyer | quaiIn, tokensOut, fee | buy(), both phases. quaiIn is the actual QUAI applied to the trade (net of any snipe-clamp refund, but gross of fee). |
Sell | seller | tokensIn, quaiOut, fee | sell(), both phases. quaiOut is net of fee — what the seller actually received. |
Graduated | token | quaiReserve, tokenReserve | _graduate(), exactly once per curve |
FeeBpsUpdated | — | oldBps, newBps | setFeeBps |
TreasuryUpdated | — | newTreasury | setTreasury |
TreasuryFeesWithdrawn | to | amount | withdrawTreasuryFees |
CreatorFeesWithdrawn | to | amount | withdrawCreatorFees |
PaymentDeferred | to | amount | Any _safePay whose direct send fails |
Withdrawn | to | amount | withdraw() |
DonationSwept | to | amount | sweepDonatedTokens |