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 ofsqrt(k)sincekLast. - 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).
your fee income ≈ your share of the pool × 0.25% × volume through the poolSetup
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.
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.
// 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
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:
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
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:
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.
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
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.
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):
yearlyReward = rewardRate × 31,536,000
APR = yearlyReward × rewardPriceInQuai / (farm.totalSupply × lpPriceInQuai)
lpPriceInQuai = 2 × quaiReserve / pair.totalSupply // for a token/QUAI pairLive 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:
- Make sure the pool exists (Pools → Create pool, or the code above).
- 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).
- The reward tokens are sent to the treasury, which deploys the farm and calls
notifyRewardAmount(reward, duration). That call pulls the tokens in and startsrewardRate = reward / durationper second. - The farm appears on hartiilabs.com → Farms, and anyone holding that pool's LP can stake.
Events to index
| Contract | Event | Use |
|---|---|---|
| Pair | Mint(sender, amount0, amount1) | liquidity added (the router is sender; the LP owner is the Transfer from 0x0 in the same tx) |
| Pair | Burn(sender, amount0, amount1, to) | liquidity removed |
| Pair | Swap(sender, amount0In, amount1In, amount0Out, amount1Out, to) | volume → fees (0.3% of the input side) |
| Pair | Sync(reserve0, reserve1) | reserves after every change |
| Pair | Transfer(from, to, value) | LP token movements (mint = from 0x0) |
| Farm | Staked, 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.