Integrating with quais
All examples use quais, Quai Network's SDK — never
ethers. Contract addresses and semantics here match
Token factory, Bonding curve, and
Launch token; re-read those pages for the full function/event/revert reference.
Quai gotchas before you start
- Checksum every address, always. Quai's RPC node rejects a lowercase or mixed-case-invalid
address outright — surfaced through
quaisas"could not coalesce error", which does not obviously point at "your address needs checksumming." Wallets frequently hand back lowercase addresses. Fix:const from = quais.getAddress(String(account).toLowerCase());on every address before it touches the RPC — provider calls, contract addresses, and event filter addresses alike. JsonRpcProviderneeds{ usePathing: false }on this stack:new quais.JsonRpcProvider(RPC_URL, undefined, { usePathing: false }).getFeeData()needs the zone for an accurate quote:provider.getFeeData(quais.Zone.Cyprus1)(fall back to the zone-less call if that throws, but prefer the zone-aware one).- Always pass an explicit
fromonsendTransaction/ contract writes — don't rely on a default signer resolution. quai_getLogshas a bounded block-range cap (~10,000 blocks) per call. Querying a wide range in one shot will fail or be rejected; page through the chain in bounded windows and track a cursor (fromBlock = lastSeenBlock + 1,toBlock: 'latest'or a bounded end block) rather than one huge range from genesis.- Clone addresses are plain
CREATE, notCREATE2. Don't try to precompute a launch's token or curve address off-chain from a salt — there is no salt. Read it from theTokenLaunchedevent orTokenFactory.tokens(i)/curveOf(token)after the launch transaction confirms.
Setup
import { quais } from 'quais';
const RPC_URL = 'https://rpc.quai.network/cyprus1';
const FACTORY = quais.getAddress('0x001AF1BbB40807fcb99C9Eeaa49dF5E91e7Efd42');
const provider = new quais.JsonRpcProvider(RPC_URL, undefined, { usePathing: false });
// Minimal ABIs — only what each example below needs.
const FACTORY_ABI = [
'function launch(string name_, string symbol_, bytes32 metadataHash, uint256 creatorAllocBps) payable returns (address token, address curve)',
'function creationFee() view returns (uint256)',
'function curveOf(address token) view returns (address)',
'event TokenLaunched(address indexed token, address indexed curve, address indexed creator, string name, string symbol, bytes32 metadataHash)',
];
const CURVE_ABI = [
'function buy(uint256 minTokensOut) payable returns (uint256 tokensOut)',
'function sell(uint256 tokensIn, uint256 minQuaiOut) returns (uint256 quaiOut)',
'function quoteBuy(uint256 quaiIn) view returns (uint256 tokensOut)',
'function quoteSell(uint256 tokensIn) view returns (uint256 quaiOut)',
'function tokensRemaining() view returns (uint256)',
'function realQuaiReserve() view returns (uint256)',
'function virtualQuaiReserve() view returns (uint256)',
'function virtualTokenReserve() view returns (uint256)',
'function tokensSold() view returns (uint256)',
'function graduated() view returns (bool)',
'function poolQuaiReserve() view returns (uint256)',
'function poolTokenReserve() view returns (uint256)',
'function feeBps() view returns (uint256)',
'function creator() view returns (address)',
'function creatorFees() view returns (uint256)',
'function withdrawCreatorFees()',
'event Buy(address indexed buyer, uint256 quaiIn, uint256 tokensOut, uint256 fee)',
'event Sell(address indexed seller, uint256 tokensIn, uint256 quaiOut, uint256 fee)',
'event Graduated(address indexed token, uint256 quaiReserve, uint256 tokenReserve)',
];
const TOKEN_ABI = [
'function name() view returns (string)',
'function symbol() view returns (string)',
'function totalSupply() view returns (uint256)',
'function balanceOf(address account) view returns (uint256)',
'function allowance(address owner_, address spender) view returns (uint256)',
'function approve(address spender, uint256 amount) returns (bool)',
'function burn(uint256 amount)',
'function creator() view returns (address)',
'function creatorAllocation() view returns (uint256)',
'function unlockedCreatorAllocation() view returns (uint256)',
'function lockedUntil() view returns (uint256)',
];Read a token's state
async function readTokenState(tokenAddress) {
tokenAddress = quais.getAddress(tokenAddress.toLowerCase());
const factory = new quais.Contract(FACTORY, FACTORY_ABI, provider);
const curveAddress = await factory.curveOf(tokenAddress);
if (curveAddress === quais.ZeroAddress) throw new Error('Not a HartiiLabs token');
const curve = new quais.Contract(curveAddress, CURVE_ABI, provider);
const token = new quais.Contract(tokenAddress, TOKEN_ABI, provider);
const [name, symbol, totalSupply, graduated, feeBps] = await Promise.all([
token.name(),
token.symbol(),
token.totalSupply(),
curve.graduated(),
curve.feeBps(),
]);
return { curveAddress, name, symbol, totalSupply, graduated, feeBps };
}Quote and buy, with slippage protection
quoteBuy does not subtract the trading fee (see Bonding curve for the
exact math) — pass it the amount you intend to net into the curve, not your gross spend, if you
want an accurate quote. The cleanest way to get a trustworthy quote for a given gross spend is to
compute the fee yourself:
async function quoteBuyForGrossSpend(curveAddress, grossQuaiWei) {
const curve = new quais.Contract(curveAddress, CURVE_ABI, provider);
const feeBps = await curve.feeBps();
const fee = (grossQuaiWei * feeBps) / 10000n;
const netIn = grossQuaiWei - fee;
const tokensOut = await curve.quoteBuy(netIn);
return { fee, netIn, tokensOut };
}
async function buyWithSlippage(signer, curveAddress, grossQuaiWei, slippageBps = 100n) {
const curveAddr = quais.getAddress(curveAddress.toLowerCase());
const curve = new quais.Contract(curveAddr, CURVE_ABI, signer);
const { tokensOut } = await quoteBuyForGrossSpend(curveAddr, grossQuaiWei);
// Slippage floor: accept no fewer tokens than (quote * (1 - slippageBps/10000)).
const minTokensOut = (tokensOut * (10000n - slippageBps)) / 10000n;
const from = quais.getAddress(String(await signer.getAddress()).toLowerCase());
const tx = await curve.buy(minTokensOut, {
value: grossQuaiWei,
from, // explicit `from` — do not rely on default resolution
});
const receipt = await tx.wait();
return receipt;
}Sell — approval required
BondingCurve.sell() calls LaunchToken.transferFrom(msg.sender, curve, tokensIn) — the curve
must be approved for at least tokensIn first. There is no permit() (no EIP-2612) on
LaunchToken; approval is a separate transaction.
async function sellWithSlippage(signer, curveAddress, tokenAddress, tokensInWei, slippageBps = 100n) {
const curveAddr = quais.getAddress(curveAddress.toLowerCase());
const tokenAddr = quais.getAddress(tokenAddress.toLowerCase());
const from = quais.getAddress(String(await signer.getAddress()).toLowerCase());
const token = new quais.Contract(tokenAddr, TOKEN_ABI, signer);
const curve = new quais.Contract(curveAddr, CURVE_ABI, signer);
const allowance = await token.allowance(from, curveAddr);
if (allowance < tokensInWei) {
const approveTx = await token.approve(curveAddr, tokensInWei, { from });
await approveTx.wait();
}
// quoteSell is also fee-blind (returns the GROSS pre-fee amount) — apply feeBps yourself
// for an accurate floor, same reasoning as the buy side.
const feeBps = await curve.feeBps();
const grossQuote = await curve.quoteSell(tokensInWei);
const netQuote = (grossQuote * (10000n - feeBps)) / 10000n;
const minQuaiOut = (netQuote * (10000n - slippageBps)) / 10000n;
const tx = await curve.sell(tokensInWei, minQuaiOut, { from });
return tx.wait();
}Listen for trades and detect graduation
quai_getLogs is capped at roughly a 10,000-block range per call. Page through it with a cursor
rather than one wide query:
const BUY_TOPIC0 = quais.id('Buy(address,uint256,uint256,uint256)');
const SELL_TOPIC0 = quais.id('Sell(address,uint256,uint256,uint256)');
const GRADUATED_TOPIC0 = quais.id('Graduated(address,uint256,uint256)');
const MAX_WINDOW = 9500; // stay comfortably under the ~10k cap
async function fetchTradeLogs(curveAddress, fromBlock, toBlock) {
const curveAddr = quais.getAddress(curveAddress.toLowerCase());
const out = [];
let start = fromBlock;
while (start <= toBlock) {
const end = Math.min(start + MAX_WINDOW - 1, toBlock);
const logs = await provider.send('quai_getLogs', [
{
address: curveAddr,
topics: [[BUY_TOPIC0, SELL_TOPIC0, GRADUATED_TOPIC0]],
fromBlock: `0x${start.toString(16)}`,
toBlock: `0x${end.toString(16)}`,
},
]);
out.push(...logs);
start = end + 1;
}
return out;
}
function decodeTradeLog(curveInterface, log) {
const parsed = curveInterface.parseLog(log);
if (!parsed) return null;
if (parsed.name === 'Buy') {
return { type: 'buy', buyer: parsed.args.buyer, quaiIn: parsed.args.quaiIn, tokensOut: parsed.args.tokensOut, fee: parsed.args.fee };
}
if (parsed.name === 'Sell') {
return { type: 'sell', seller: parsed.args.seller, tokensIn: parsed.args.tokensIn, quaiOut: parsed.args.quaiOut, fee: parsed.args.fee };
}
if (parsed.name === 'Graduated') {
return { type: 'graduated', quaiReserve: parsed.args.quaiReserve, tokenReserve: parsed.args.tokenReserve };
}
return null;
}To detect graduation going forward without re-scanning history, poll curve.graduated() alongside
your log poll, or watch for a decoded graduated event — either is authoritative; Graduated
fires exactly once per curve (re-graduation is structurally impossible, see
Bonding curve).
Compute price and market cap
Marginal price is the derivative of the x*y=k curve at the current reserves — in practice, the
cheapest accurate way to get it is a tiny quoteBuy/quoteSell probe against the current
effective reserves, or just derive it directly from the reserve ratio (equivalent, no probe
transaction needed):
async function priceAndMarketCap(curveAddress, totalSupplyWei) {
const curveAddr = quais.getAddress(curveAddress.toLowerCase());
const curve = new quais.Contract(curveAddr, CURVE_ABI, provider);
const graduated = await curve.graduated();
let qr, tr;
if (graduated) {
[qr, tr] = await Promise.all([curve.poolQuaiReserve(), curve.poolTokenReserve()]);
} else {
const [virtualQuai, realQuai, virtualToken, tokensSold] = await Promise.all([
curve.virtualQuaiReserve(),
curve.realQuaiReserve(),
curve.virtualTokenReserve(),
curve.tokensSold(),
]);
qr = virtualQuai + realQuai;
tr = virtualToken - tokensSold;
}
// Marginal price of 1 whole token, in QUAI, scaled by 1e18 on both sides so units cancel.
const priceWeiPerToken = (qr * 10n ** 18n) / tr;
const marketCapWei = (priceWeiPerToken * totalSupplyWei) / 10n ** 18n;
return { priceWeiPerToken, marketCapWei };
}totalSupplyWei is fixed per token (read once via LaunchToken.totalSupply()) — it never changes
except by burn().
Creator fee withdrawal
Only the creator address itself can call this — there's no owner override and no to parameter:
async function withdrawCreatorFees(signer, curveAddress) {
const curveAddr = quais.getAddress(curveAddress.toLowerCase());
const from = quais.getAddress(String(await signer.getAddress()).toLowerCase());
const curve = new quais.Contract(curveAddr, CURVE_ABI, signer);
const claimable = await curve.creatorFees();
if (claimable === 0n) return null; // withdrawCreatorFees() would revert "Nothing to withdraw"
const tx = await curve.withdrawCreatorFees({ from });
return tx.wait();
}External venue: QAXE
The standard QAXE token trades on a Quainance V2 pool outside the launchpad curve model — see QAXE pool for that pair's address, router, and integration notes.