Hartii developer docs

HartiiSwap

LP developer guide

Everything a developer needs to provide liquidity on HartiiSwap programmatically, with runnable quais code. The read-only parts of this page (pool, position and fee-index reads) were run against mainnet before publishing. For the product-level explanation see Liquidity; for the contract reference see Contracts.

How LPs earn

  • Every swap pays a 0.3% fee that stays inside the pool's reserves.
  • 5/6 of it (0.25% of volume) belongs to liquidity providers, pro-rata to LP tokens held.
  • 1/6 (0.05% of volume) is the protocol fee, the standard Uniswap V2 fee switch: it is minted as LP tokens to the treasury (feeTo) on the next add/remove, from the growth of sqrt(k) since kLast.
  • There is nothing to claim: fees raise what each LP token is worth, and you receive them when you remove liquidity.
  • Farms (optional) pay an extra reward token to LP holders who stake their LP — see LP rewards (farms).
text
your fee income ≈ your share of the pool × 0.25% × volume through the pool

Setup

js
import { quais } from 'quais';

const provider = new quais.JsonRpcProvider('https://rpc.quai.network/cyprus1', undefined, { usePathing: false });
const addr = (a) => quais.getAddress(a.toLowerCase()); // the Quai RPC rejects non-checksummed addresses

const FACTORY = addr('0x002E75653C6189087Eb9E512cE7319eC21140956');
const ROUTER  = addr('0x006B3450cca780066A3A8aA8b508103d25315Ea4');
const LENS    = addr('0x00295dFCA930646abAC327C9CcB2f4e1E206C5Ec');
const WQUAI   = addr('0x006C3e2AaAE5DB1bCd11A1a097cE572312EADdBB');

const FACTORY_ABI = ['function getPair(address, address) view returns (address)'];
const ROUTER_ABI = [
  'function addLiquidityETH(address token, uint256 amountTokenDesired, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) payable returns (uint256, uint256, uint256)',
  'function removeLiquidityETH(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) returns (uint256, uint256)',
];
const ERC20_ABI = ['function approve(address, uint256) returns (bool)', 'function allowance(address, address) view returns (uint256)', 'function balanceOf(address) view returns (uint256)'];
const PAIR_ABI = [...ERC20_ABI, 'function token0() view returns (address)', 'function getReserves() view returns (uint112, uint112, uint32)', 'function totalSupply() view returns (uint256)'];
const LENS_ABI = ['function positions(address factory, address user, uint256 start, uint256 count) view returns (tuple(address pair, uint256 lpBalance, uint256 totalSupply, uint256 amount0, uint256 amount1)[])'];

const deadline = () => BigInt(Math.floor(Date.now() / 1000) + 20 * 60);
const withSlippage = (x, bps = 50n) => (x * (10000n - bps)) / 10000n; // 0.5%

Money is always bigint wei (18 decimals for QUAI and every Hartii launch token). Never use floats for amounts you send.

Read a pool

Pairs are created with CREATE, not CREATE2, so their address cannot be computed off-chain. Always ask the factory.

js
export async function readPool(token) {
  const factory = new quais.Contract(FACTORY, FACTORY_ABI, provider);
  const pair = await factory.getPair(addr(token), WQUAI);
  if (pair === quais.ZeroAddress) return null; // no pool yet
  const p = new quais.Contract(pair, PAIR_ABI, provider);
  const [token0, [r0, r1], supply] = await Promise.all([p.token0(), p.getReserves(), p.totalSupply()]);
  const tokenIs0 = token0.toLowerCase() === token.toLowerCase();
  return {
    pair,
    tokenReserve: tokenIs0 ? r0 : r1,
    quaiReserve: tokenIs0 ? r1 : r0,
    supply, // total LP tokens
  };
}

Create a pool (first deposit)

With an empty pool, the ratio you deposit is the price. For a HartiiLabs token, seed at the bonding-curve spot price (formula in Pricing a token), rounding the QUAI side up so the pool never opens below the curve.

js
// quaiReserve / tokenReserve = the curve's spot price (see labs/pricing).
export function quaiToMatchPrice(tokenAmount, quaiReserve, tokenReserve) {
  return (tokenAmount * quaiReserve + tokenReserve - 1n) / tokenReserve; // round up
}

Then call addLiquidityETH exactly as in the next section. The router creates the pair on the first deposit. 1000 LP wei are locked to address(0) forever, and you receive sqrt(tokenAmount × quaiAmount) − 1000 LP.

Add liquidity

js
export async function addLiquidity(wallet, token, tokenAmount) {
  const pool = await readPool(token);
  if (!pool) throw new Error('No pool yet — see "Create a pool"');
  // Pair QUAI at the pool ratio (+1 wei so rounding never undershoots; the router refunds excess).
  const quaiAmount = (tokenAmount * pool.quaiReserve) / pool.tokenReserve + 1n;

  const t = new quais.Contract(addr(token), ERC20_ABI, wallet);
  if ((await t.allowance(wallet.address, ROUTER)) < tokenAmount) {
    await (await t.approve(ROUTER, tokenAmount, { from: wallet.address })).wait(); // exact, not unlimited
  }
  const router = new quais.Contract(ROUTER, ROUTER_ABI, wallet);
  const tx = await router.addLiquidityETH(
    addr(token), tokenAmount, withSlippage(tokenAmount), withSlippage(quaiAmount),
    wallet.address, deadline(), { value: quaiAmount, from: wallet.address },
  );
  const receipt = await tx.wait();
  return { receipt, lpMinted: lpMintedFromReceipt(receipt, pool.pair, wallet.address) };
}

Read the LP you actually received from the receipt. It is final the moment the transaction confirms, whereas a balance read can briefly lag on a public node:

js
const TRANSFER = quais.id('Transfer(address,address,uint256)');
const ZERO_TOPIC = '0x' + '0'.repeat(64);

export function lpMintedFromReceipt(receipt, pair, to) {
  let minted = 0n;
  for (const log of receipt.logs) {
    if (log.address.toLowerCase() !== pair.toLowerCase()) continue;
    if (log.topics[0] !== TRANSFER || log.topics[1] !== ZERO_TOPIC) continue;
    if ('0x' + log.topics[2].slice(-40) !== to.toLowerCase()) continue; // skips the fee mint to the treasury
    minted += BigInt(log.data);
  }
  return minted;
}

Read a position

js
export async function readPosition(user, token) {
  const pool = await readPool(token);
  if (!pool) return null;
  const lp = await new quais.Contract(pool.pair, PAIR_ABI, provider).balanceOf(addr(user));
  return {
    lp,
    shareBps: pool.supply > 0n ? (lp * 10_000n) / pool.supply : 0n, // 100 = 1%
    tokenAmount: (pool.tokenReserve * lp) / pool.supply,            // what the LP is worth right now
    quaiAmount: (pool.quaiReserve * lp) / pool.supply,
  };
}

All of a wallet's positions in one call:

js
const lens = new quais.Contract(LENS, LENS_ABI, provider);
const rows = await lens.positions(FACTORY, addr(user), 0n, 200n);
const mine = rows.filter((r) => r.lpBalance > 0n); // { pair, lpBalance, totalSupply, amount0, amount1 }

Measuring fee earnings exactly

The underlying amounts of a position move with price, so they do not show fees on their own. The clean measure is the fee index: sqrt(reserveToken × reserveQuai) per LP token. Price moves do not change sqrt(k), and adds and removes change sqrt(k) and supply in proportion. So the index only rises when swap fees (or donations) are added.

js
const sqrt = (n) => { if (n < 2n) return n; let x = n, y = (x + 1n) / 2n; while (y < x) { x = y; y = (x + n / x) / 2n; } return x; };

export async function feeIndex(token) {
  const p = await readPool(token);
  return (sqrt(p.tokenReserve * p.quaiReserve) * 10n ** 18n) / p.supply; // scaled 1e18
}

// Growth between two snapshots, e.g. at deposit time and now:
// feeGrowth = indexNow / indexThen − 1   (0.01 = your position earned 1% from fees)

The protocol's 1/6 is minted as new LP on the next add/remove, which is why LPs keep 5/6 of the index growth. On 2026-09-27 the QAXE pool's index was 1.000092e18, about 0.009% of growth from its first few swaps.

Remove liquidity

js
export async function removeLiquidity(wallet, token, bps = 10_000n) { // 10_000 = 100%, 5_000 = half
  const pool = await readPool(token);
  const lp = new quais.Contract(pool.pair, PAIR_ABI, wallet);
  const liquidity = ((await lp.balanceOf(wallet.address)) * bps) / 10_000n;
  const minToken = withSlippage((pool.tokenReserve * liquidity) / pool.supply);
  const minQuai = withSlippage((pool.quaiReserve * liquidity) / pool.supply);
  await (await lp.approve(ROUTER, liquidity, { from: wallet.address })).wait();
  const router = new quais.Contract(ROUTER, ROUTER_ABI, wallet);
  return (await router.removeLiquidityETH(addr(token), liquidity, minToken, minQuai, wallet.address, deadline(),
    { from: wallet.address })).wait();
}

LP rewards (farms)

A farm (HartiiFarm, a Synthetix StakingRewards port) pays a reward token to LP holders who stake their LP tokens in it. Full mechanics: Farms.

js
const FARM_ABI = [
  'function stakingToken() view returns (address)', 'function rewardsToken() view returns (address)',
  'function totalSupply() view returns (uint256)', 'function balanceOf(address) view returns (uint256)',
  'function earned(address) view returns (uint256)', 'function rewardRate() view returns (uint256)',
  'function periodFinish() view returns (uint256)',
  'function stake(uint256)', 'function withdraw(uint256)', 'function getReward()', 'function exit()',
];

export async function stakeAll(wallet, farmAddress, pair) {
  const lp = new quais.Contract(pair, PAIR_ABI, wallet);
  const amount = await lp.balanceOf(wallet.address);
  await (await lp.approve(farmAddress, amount, { from: wallet.address })).wait();
  return (await new quais.Contract(farmAddress, FARM_ABI, wallet).stake(amount, { from: wallet.address })).wait();
}

export const pendingReward = (user, farmAddress) =>
  new quais.Contract(farmAddress, FARM_ABI, provider).earned(addr(user));

export const claim = (wallet, farmAddress) =>
  new quais.Contract(farmAddress, FARM_ABI, wallet).getReward({ from: wallet.address });

export const unstakeAndClaim = (wallet, farmAddress) =>
  new quais.Contract(farmAddress, FARM_ABI, wallet).exit({ from: wallet.address });

Staked LP keeps earning swap fees: the farm holds the LP tokens, and their value still grows with the fee index. A farm adds the reward on top.

Reward APR (only meaningful while block.timestamp < periodFinish):

text
yearlyReward = rewardRate × 31,536,000
APR          = yearlyReward × rewardPriceInQuai / (farm.totalSupply × lpPriceInQuai)
lpPriceInQuai = 2 × quaiReserve / pair.totalSupply      // for a token/QUAI pair

Live farms: hartiilabs.com → Farms lists every funded farm and its pool. As of 2026-09-27 no farm is funded yet, so LPs currently earn swap fees only.

Getting LP rewards for your token

Farms are deployed and funded by the Hartii treasury (the farm owner). The owner can only fund and extend reward periods: it can never touch staked LP, pause stakers, or take back the reward token.

To set up rewards for your token's HartiiSwap pool:

  1. Make sure the pool exists (Pools → Create pool, or the code above).
  2. Contact the Hartii team with: the pool (pair address), the reward token (your token or WQUAI), the total reward amount, and the duration (e.g. 30 days).
  3. The reward tokens are sent to the treasury, which deploys the farm and calls notifyRewardAmount(reward, duration). That call pulls the tokens in and starts rewardRate = reward / duration per second.
  4. The farm appears on hartiilabs.com → Farms, and anyone holding that pool's LP can stake.

Events to index

ContractEventUse
PairMint(sender, amount0, amount1)liquidity added (the router is sender; the LP owner is the Transfer from 0x0 in the same tx)
PairBurn(sender, amount0, amount1, to)liquidity removed
PairSwap(sender, amount0In, amount1In, amount0Out, amount1Out, to)volume → fees (0.3% of the input side)
PairSync(reserve0, reserve1)reserves after every change
PairTransfer(from, to, value)LP token movements (mint = from 0x0)
FarmStaked, Withdrawn, RewardPaid, RewardAdded(reward, duration)farm activity

The public RPC rejects quai_getLogs ranges over 10,000 blocks, so scan in windows.

Gotchas

  • Checksum every address (addr() above) or quais throws "could not coalesce".
  • Gas is high in QUAI terms (~25–30k gwei): creating a pool is ~2.2M gas, and add/remove is ~150–185k. Budget for it.
  • Public nodes can lag a few blocks. Take amounts from receipts, and retry reads after a deposit.
  • Approve exact amounts for users unless they opt into unlimited.
  • Impermanent loss applies: when the token's price moves, your QAXE/QUAI mix shifts. Fees and farm rewards are what compensate for it.
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.