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.
Sending a transaction: wallet = signing only
hartiilabs.com's own frontend sends every trade/approve/launch through one pattern (adopted
2026-09-30) instead of calling a contract method directly on a wallet signer
(curve.buy(...), curve.sell(...), token.approve(...)). That direct-call path runs quais's
JsonRpcSigner.sendTransaction, which itself does a getBlockNumber(), an estimateGas() (when
no gasLimit is given), and a post-send getTransaction() poll — all three against the
wallet's own provider (Pelagus, or Blip's in-app browser), not your RPC. A hiccup or
unsupported read on the wallet's side surfaces as quais's generic "could not coalesce error"
even when the trade would have gone through fine.
The fix is to keep the wallet doing exactly one thing — signing and broadcasting:
- Estimate gas yourself, against the exact
{ from, to, data, value }about to be sent, on your own read provider (provider.estimateGas(tx)) — never let the wallet do it. - Send with an explicit
gasLimit(estimate × 1.3) via the signer's low-level send —signer.sendUncheckedTransaction({ from, to, data, value, gasLimit })inquais— so the wallet's provider sees exactly one RPC call:quai_sendTransaction. - Poll for the receipt yourself, on the same read provider (
provider.getTransactionReceipt(hash), backoff 1s → 4s capped, timeout on the order of a few minutes) — nevertx.wait()on a wallet-provider transaction response. A timeout must never be reported as "the trade failed" (it may still confirm); a mined receipt withstatus === 0is a definite, on-chain revert and should be treated as one.
This also fixes a funding-UI bug, not just an error-message one: a wallet asked to sign a
transaction with no gasLimit estimates (and reserves) its own default. Before this fix,
Blip's "Fund & continue" sheet asked a payer for a 75 QUAI "preparation reserve" on a call that
actually needed about 100k gas (a relay-style payment measured at 98,098 gas on mainnet) — Blip had
reserved roughly 1.3M gas at the live gas price with nothing to tell it otherwise. Handing the
wallet an explicit, real gasLimit fixes the funding sheet along with the RPC errors.
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.