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

Current LP holders (API) and locked liquidity

GET /api/pool/{pair}/lp-holders (hartiilabs.com) returns a live snapshot of who holds a pair's LP token right now. It builds candidate addresses from the pair's own Transfer log history, then confirms every candidate with a live balanceOf read — the balance is always the source of truth, never the sum of historical transfer amounts.

json
{
  "pair": "0x00052a890c39ff5592ca88a89924d6083e4e3179",
  "token0": "0x0035187a7660f595d93cd53a4d16c635d6cffc8f",
  "token1": "0x006c3e2aaae5db1bcd11a1a097ce572312eaddbb",
  "totalSupplyWei": "77223024797857338182220",
  "asOfBlock": 10331302,
  "locked": {
    "deadWei": "0", "zeroWei": "1000", "lockerWei": "0",
    "totalWei": "1000", "bps": 0, "locks": []
  },
  "holders": [
    { "address": "0x0019f740d9e1602ce0e5a5da864e50b8d7d5ab61", "balanceWei": "43881710136805506336714", "shareBps": 5682, "kind": "wallet" }
  ],
  "farmStakers": [],
  "lockerStakers": []
}
  • holders excludes the dead address, the zero address, the pool's own farm/locker contracts themselves, and the pair contract — those balances are accounted for in locked, farmStakers and lockerStakers instead. kind is wallet, farm (a funded HartiiFarm whose individual stakers could not be resolved — rare; normally they show up in farmStakers instead), or contract (any other contract holding LP directly).
  • farmStakers resolves a funded farm's staked LP down to individual stakers (from the farm's Staked/Withdrawn logs, confirmed against the farm's own balanceOf).
  • Locked liquidity is never permanent. locked.lockerWei is LP held by LpLocker — a contract that only ever time-locks LP for a bounded period (max 3 months) and returns it to the lock's own owner once it matures; nobody, including the Hartii team, can extend a lock past 3 months or withdraw someone else's lock early. locked.locks lists each active lock ({id, owner, amountWei, unlockAt}) on this pair, and lockerStakers resolves that same LP down to individual lock owners, pro-rata, the same way farmStakers does for a farm.
  • locked.deadWei/locked.zeroWei (LP sent to 0x000…dEaD or 0x0, including the MINIMUM_LIQUIDITY every pool locks forever on its first deposit) are reported as informational "burned LP" only — there is no UI on hartiilabs.com to send LP to either address on purpose.
  • Cached 60s at the edge. Pair identity is re-derived from factory.getPair(token0, token1) on every call (never trusted from the URL); a mismatch or non-pair address 404s.

Reward and lock tools (product surfaces)

  • Reward LP holders — the pool owner (or anyone) can send a QUAI or pool-token reward pro-rata to a pool's current LP holders (wallets + resolved farm stakers + resolved locker lock-owners; contracts and unresolved farm balances are excluded and shown with a reason), as of asOfBlock. Payouts under a configurable dust floor (default 0.01 QUAI-equivalent) are skipped and redistributed. One transfer transaction per recipient, sent sequentially from the connected wallet, stopping on the first failure.
  • Lock liquidity — locks a chosen amount of your own LP into LpLocker for a fixed duration (presets from 1 week to 3 months, or a custom date up to 3 months out). You can extend a lock's unlock time later, or withdraw once it matures. PoolAdd also offers locking a % of newly-minted LP as a second, clearly-labelled transaction right after the deposit confirms.

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
LpLockerLockCreated(id, owner, pair, amount, unlockAt), LockWithdrawn(id, owner, pair, amount), LockExtended(id, previousUnlockAt, newUnlockAt)temporary LP time-locks (never permanent)

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.