Hartii developer docs

HartiiLabs

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.

text
effective QUAI reserve  (qr) = virtualQuaiReserve + realQuaiReserve
effective token reserve (tr) = virtualTokenReserve - tokensSold

Buy math (bonding phase)

text
fee    = floor(msg.value * feeBps / 10000)
netIn  = msg.value - fee
k      = qr * tr
newTr  = floor(k / (qr + netIn))
tokensOut = tr - newTr

The 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)

text
(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 - fee

The 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

solidity
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:

StepValue
fee = 1000 * 100 / 1000010 QUAI
netIn = 1000 - 10990 QUAI
qr = 17000 + 017,000
tr = 1,073,000,000 - 01,073,000,000
k = qr * tr18,241,000,000,000
newTr = floor(k / (qr + netIn)) = floor(18,241,000,000,000 / 17,990)1,013,952,195
tokensOut = tr - newTr59,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):

StepValue
qr = 17000 + 300020,000
tr = 1,073,000,000 - 73,000,0001,000,000,000
k = qr * tr20,000,000,000,000
newTr = tr + tokensIn1,025,000,000
newQr = floor(k / newTr)19,512.195... QUAI
grossOut = qr - newQr487.805 QUAI
fee = grossOut * 100 / 100004.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:

solidity
if (tokensRemaining() == 0 || realQuaiReserve >= graduationRaiseWei) {
    _graduate();
}
  • Primary trigger: tokensRemaining() == 0 — the curve's entire real curveSupply has 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 QUAI graduationRaiseWei guard.
  • 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

solidity
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:

text
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 - fee

Both 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:

solidity
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.

FunctionWho may callPaysReverts
withdrawTreasuryFees()owner onlytreasury, 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:

CapValueWindow
Creator launch-block capCREATOR_CAP_BPS = 200 (2% of curveSupply)Only on block.number == launchBlock, only for msg.sender == creator
Per-wallet snipe-window capPER_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

FunctionReturnsNotes
token()addressThe LaunchToken clone this curve trades.
creator()addressSet once at initialize().
factory()addressThe TokenFactory that deployed this clone.
owner()addressFee/admin controls only — never touches reserves.
treasury()addressThis curve's own fee-split destination (independent per curve).
virtualQuaiReserve()uint256Frozen at initialize().
virtualTokenReserve()uint256Frozen at initialize().
curveSupply()uint256Real tokens sellable pre-graduation. Frozen at initialize().
migrationTokenReserve()uint256Tokens held aside, never sellable pre-graduation, that seed the pool's token side at graduation. Frozen at initialize().
graduationRaiseWei()uint256Belt-and-braces threshold. Frozen at initialize().
realQuaiReserve()uint256Real QUAI held against sellable tokens, bonding phase only (zeroed at graduation).
tokensSold()uint256Tokens sold out of curveSupply, bonding phase.
graduated()boolPhase flag.
poolQuaiReserve()uint256Real QUAI held by the pool, post-graduation.
poolTokenReserve()uint256Real tokens held by the pool, post-graduation.
feeBps()uint256This curve's own fee, default 100 (1%) at init, owner-adjustable up to 300.
MAX_FEE_BPS()uint256Constant, 300 (3%).
treasuryFees() / creatorFees()uint256Claimable accumulators.
launchBlock()uint256Block this curve was initialized in.
SNIPE_WINDOW_BLOCKS()uint256Constant, 20.
PER_WALLET_CAP_BPS() / CREATOR_CAP_BPS()uint256Constants, 100 / 200.
boughtDuringSnipeWindow(address)uint256Cumulative snipe-window buys for a wallet.
pending(address)uint256Safe-pay fallback balance.
tokensRemaining()uint256curveSupply - tokensSold pre-graduation, 0 post-graduation.
quoteBuy(uint256 quaiIn)uint256 tokensOutFee-blind — see the Warning above.
quoteSell(uint256 tokensIn)uint256 quaiOutFee-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

FunctionSignatureWhoReverts
buybuy(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"
sellsell(uint256 tokensIn, uint256 minQuaiOut) external nonReentrant returns (uint256 quaiOut)Anyone holding/approving tokens"Zero tokens", "Token pull failed", "Slippage" (quaiOut < minQuaiOut)
withdrawTreasuryFeesexternal onlyOwner nonReentrantowner"Not owner", "Nothing to withdraw"
withdrawCreatorFeesexternal nonReentrantcreator"Not creator", "Nothing to withdraw"
setFeeBpssetFeeBps(uint256 newBps) external onlyOwnerowner"Not owner", "Exceeds fee cap" (> 300)
setTreasurysetTreasury(address newTreasury) external onlyOwnerowner"Not owner", "Zero treasury"
transferOwnershiptransferOwnership(address newOwner) external onlyOwnerowner"Not owner", "Zero owner"
sweepDonatedTokenssweepDonatedTokens(address to) external onlyOwner nonReentrantowner"Not owner", "Zero to", "Nothing to sweep"
withdrawwithdraw() external nonReentrantAnyone with a nonzero pending balance"Nothing to withdraw", "Withdraw failed"
receivereceive() external payable {}AnyoneAccepts 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

EventIndexedNon-indexedEmitted by
BuybuyerquaiIn, tokensOut, feebuy(), both phases. quaiIn is the actual QUAI applied to the trade (net of any snipe-clamp refund, but gross of fee).
SellsellertokensIn, quaiOut, feesell(), both phases. quaiOut is net of fee — what the seller actually received.
GraduatedtokenquaiReserve, tokenReserve_graduate(), exactly once per curve
FeeBpsUpdatedoldBps, newBpssetFeeBps
TreasuryUpdatednewTreasurysetTreasury
TreasuryFeesWithdrawntoamountwithdrawTreasuryFees
CreatorFeesWithdrawntoamountwithdrawCreatorFees
PaymentDeferredtoamountAny _safePay whose direct send fails
Withdrawntoamountwithdraw()
DonationSwepttoamountsweepDonatedTokens
Quai Network mainnet · chain 9 · Cyprus-1. Figures marked "read on" a date were read from the chain that day; re-read before relying on them.