Hartii developer docs

HartiiLabs

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 quais as "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.
  • JsonRpcProvider needs { 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 from on sendTransaction / contract writes — don't rely on a default signer resolution.
  • quai_getLogs has 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, not CREATE2. Don't try to precompute a launch's token or curve address off-chain from a salt — there is no salt. Read it from the TokenLaunched event or TokenFactory.tokens(i) / curveOf(token) after the launch transaction confirms.

Setup

js
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

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

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

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

js
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):

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

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

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.