Open app
[ BUILD ]

Developer guide

How Self-Quantum Liquidity reads Uniswap pools and creates positions with plain viem, and how to test money-moving code safely on a local fork. Every snippet is taken from the working code.

Architecture

Web appNext.js · React · viemPublic RPCone per network, read-onlyUniswap contractsv2 · v3 · v4, 15 networksreadsreadsYour browser walletEIP-6963 / EIP-1193Position managerv3 NonfungiblePositionManagerYour position NFTin your own addresssignmintsconnectNo server. No database. No contract of ours holds funds.Reads: browser → RPC → contracts. Writes: wallet → position manager.The source of truth is the blockchain itself.

Self-Quantum Liquidity is a static Next.js app. There is no backend. The browser reads each network through a public JSON-RPC endpoint and talks to the user's own wallet to send transactions.

FileWhat it does
lib/dapp.tsNetwork configuration, token metadata, pool discovery for v2/v3/v4, and positions across networks.
lib/clmm.tsConcentrated-liquidity maths: ticks, prices, range snapping, deposit amounts, capital efficiency.
lib/mint.tsPlanning, approving, simulating and sending a v3 mint, with human-readable errors.
lib/wallet.tsWallet discovery (EIP-6963) and network switching.
components/dapp/*Pools, Builder, Positions, wallet and create-position UI.
public/chains/*Network logos.

Reading data with viem

One client per network, with multicall batching

Batching merges concurrent eth_calls into a single Multicall3 call, which is why scanning 15 networks takes under a second.

lib/dapp.ts
import { createPublicClient, http } from "viem";

const clients = new Map<string, PublicClient>();

export function clientFor(c: DappChain): PublicClient {
  let cl = clients.get(c.key);
  if (!cl) {
    cl = createPublicClient({
      chain: c.viem,
      transport: http(c.rpc, { timeout: 12_000, retryCount: 2 }),
      batch: { multicall: true },
    }) as PublicClient;
    clients.set(c.key, cl);
  }
  return cl;
}

A Uniswap v3 pool

read price, liquidity and tick spacing
const factoryAbi = parseAbi(["function getPool(address,address,uint24) view returns (address)"]);
const poolAbi = parseAbi([
  "function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16, uint16, uint16, uint8, bool)",
  "function tickSpacing() view returns (int24)",
  "function liquidity() view returns (uint128)",
]);

const pool = await client.readContract({
  address: V3_FACTORY, abi: factoryAbi, functionName: "getPool",
  args: [WETH, USDC, 500], // 0.05% tier
});

const [slot, spacing, liquidity] = await Promise.all([
  client.readContract({ address: pool, abi: poolAbi, functionName: "slot0" }),
  client.readContract({ address: pool, abi: poolAbi, functionName: "tickSpacing" }),
  client.readContract({ address: pool, abi: poolAbi, functionName: "liquidity" }),
]);

// price of token0 in token1, in human units
const sqrtPrice = Number(slot[0]) / 2 ** 96;
const price01 = sqrtPrice ** 2 * 10 ** (decimals0 - decimals1);

A Uniswap v2 pair

ts
const pair = await client.readContract({
  address: V2_FACTORY,
  abi: parseAbi(["function getPair(address,address) view returns (address)"]),
  functionName: "getPair", args: [WETH, USDC],
});
const [r0, r1] = await client.readContract({
  address: pair,
  abi: parseAbi(["function getReserves() view returns (uint112,uint112,uint32)"]),
  functionName: "getReserves",
});
// price = reserveQuote / reserveBase (after decimals); TVL ≈ 2 × the quote reserve

A Uniswap v4 pool

v4 pools are identified by a hash of their pool key, and their state is read from the StateView contract. Self-Quantum Liquidity checks hookless pools on the standard fee and tick spacing pairs, and also tries the native-currency variant (address zero) for wrapped-native pairs.

derive a poolId and read state
import { encodeAbiParameters, keccak256, zeroAddress } from "viem";

const poolId = (c0: Address, c1: Address, fee: number, spacing: number) =>
  keccak256(encodeAbiParameters(
    [{ type: "address" }, { type: "address" }, { type: "uint24" }, { type: "int24" }, { type: "address" }],
    [c0, c1, fee, spacing, zeroAddress], // zeroAddress = no hook
  ));

// currencies must be sorted; the native currency is address(0) so it sorts first
const id = poolId(c0, c1, 500, 10);
const [sqrtPriceX96, tick] = await client.readContract({
  address: STATE_VIEW,
  abi: parseAbi(["function getSlot0(bytes32) view returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee)"]),
  functionName: "getSlot0", args: [id],
});
// sqrtPriceX96 == 0n means that pool was never initialised

The official guide to reading v4 state: Reading Pool State.

Ticks, ranges and amounts

lib/clmm.ts (abridged)
const LN = Math.log(1.0001);
const scale = (p: PoolView) => 10 ** (p.token0.decimals - p.token1.decimals);

export const priceToTick = (p: PoolView, price: number) =>
  Math.log((p.baseIsToken0 ? price : 1 / price) / scale(p)) / LN;

// round the lower edge down and the upper edge up to the pool's tick spacing,
// so the real range is never narrower than the one the user asked for
export function snapRange(p: PoolView, pMin: number, pMax: number) {
  const a = priceToTick(p, pMin);
  const b = priceToTick(p, pMax);
  const lower = Math.floor(Math.min(a, b) / p.tickSpacing) * p.tickSpacing;
  let upper = Math.ceil(Math.max(a, b) / p.tickSpacing) * p.tickSpacing;
  if (upper <= lower) upper = lower + p.tickSpacing;
  return { lower, upper };
}

// how much of the other token does a deposit of one token need?
export function counterpart(p: PoolView, lower: number, upper: number, side: 0 | 1, amount: number) {
  const sa = 1.0001 ** (lower / 2), sb = 1.0001 ** (upper / 2), sp = p.sqrtPrice;
  if (side === 0) {
    if (sp >= sb) return { error: "Price is above this range: only token1 is needed." };
    const su = Math.max(sp, sa);
    const x = amount * 10 ** p.token0.decimals;
    const L = (x * (su * sb)) / (sb - su);
    return { amount0: amount, amount1: (sp <= sa ? 0 : L * (sp - sa)) / 10 ** p.token1.decimals };
  }
  // …the mirror image for a token1 deposit
}

These are the standard v3 liquidity formulas. Within the range x = L·(1/√P − 1/√Pb) and y = L·(√P − √Pa). Floats are fine for previews, but never use floating-point numbers for the amounts you actually send: convert with parseUnits.

Enumerating positions

every v3 position an address owns
const npmAbi = parseAbi([
  "function balanceOf(address) view returns (uint256)",
  "function tokenOfOwnerByIndex(address,uint256) view returns (uint256)",
  "function positions(uint256) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 fg0, uint256 fg1, uint128 owed0, uint128 owed1)",
]);

const n = Number(await client.readContract({ address: NPM, abi: npmAbi, functionName: "balanceOf", args: [owner] }));
const ids = await Promise.all(Array.from({ length: n }, (_, i) =>
  client.readContract({ address: NPM, abi: npmAbi, functionName: "tokenOfOwnerByIndex", args: [owner, BigInt(i)] })));
const positions = await Promise.all(ids.map((id) =>
  client.readContract({ address: NPM, abi: npmAbi, functionName: "positions", args: [id] })));

// in range  ⇔  tickLower <= pool.tick < tickUpper     closed ⇔ liquidity == 0

Creating a position

Plan: amounts, minimums and the native-coin path

lib/mint.ts (abridged)
const BUFFER = 1.01; // offer the counterpart token 1% extra so the typed amount is the limiting side

const typed = parseUnits(amountText, decimalsOfTypedToken);       // exact, never a float
const otherDesired = parseUnits((estimate * BUFFER).toFixed(dec), dec);

const slip = BigInt(Math.round((100 - slippagePct) * 100));        // e.g. 0.5% -> 9950
const minOf = (n: bigint) => (n * slip) / 10000n;

// amountDesired is the most you are willing to put in; amountMin is the least you accept
const params = {
  token0, token1, fee, tickLower, tickUpper,
  amount0Desired, amount1Desired,
  amount0Min: minOf(amount0), amount1Min: minOf(amount1),
  recipient: owner,                       // the NFT goes to the user
  deadline: BigInt(now + 20 * 60),        // 20 minutes
};

Exact approvals

ts
// approve only what the mint may pull, to the position manager and nothing else
const { request } = await publicClient.simulateContract({
  account: owner, address: token, abi: erc20Abi,
  functionName: "approve", args: [NPM, amountDesired],
});
const hash = await walletClient.writeContract(request);
await publicClient.waitForTransactionReceipt({ hash });

// some tokens (USDT on Ethereum) need the allowance reset to 0 before a new non-zero approve

Simulate, then send

ts
// paying with the native coin: wrap ETH, mint, and refund the excess in one call
const mintData   = encodeFunctionData({ abi: npmAbi, functionName: "mint", args: [params] });
const refundData = encodeFunctionData({ abi: npmAbi, functionName: "refundETH" });

const sim = await publicClient.simulateContract({
  account: owner, address: NPM, abi: npmAbi,
  functionName: "multicall", args: [[mintData, refundData]],
  value: amountOfTheWrappedNativeSide,
});
const hash = await walletClient.writeContract(sim.request);
const receipt = await publicClient.waitForTransactionReceipt({ hash });

// the new position's id is in the IncreaseLiquidity event
const [log] = parseEventLogs({ abi: npmAbi, logs: receipt.logs, eventName: "IncreaseLiquidity" });
const tokenId = log.args.tokenId;

Always simulate first. A failed simulation tells you why before you spend gas. After the transaction, read the allowance again and offer to revoke anything left over.

Closing a position

Self-Quantum Liquidity does not do this yet, but the position manager makes it three calls (or one multicall):

close a v3 position
const closeAbi = parseAbi([
  "function decreaseLiquidity((uint256 tokenId, uint128 liquidity, uint256 amount0Min, uint256 amount1Min, uint256 deadline) params) payable returns (uint256 amount0, uint256 amount1)",
  "function collect((uint256 tokenId, address recipient, uint128 amount0Max, uint128 amount1Max) params) payable returns (uint256 amount0, uint256 amount1)",
  "function burn(uint256 tokenId) payable",
]);

const MAX128 = 2n ** 128n - 1n;

// 1. take all liquidity out of the position (tokens become claimable)
await write("decreaseLiquidity", [{ tokenId, liquidity, amount0Min: 0n, amount1Min: 0n, deadline }]);
// 2. send the tokens and any fees to your wallet
await write("collect", [{ tokenId, recipient: owner, amount0Max: MAX128, amount1Max: MAX128 }]);
// 3. optional: destroy the now-empty NFT
await write("burn", [tokenId]);

Connecting a wallet

Self-Quantum Liquidity does not bundle a wallet SDK. It uses the browser's standard interfaces: EIP-6963 to discover wallets and EIP-1193 to talk to them.

lib/wallet.ts (abridged)
export function discoverWallets(onFound: (w: InjectedWallet) => void): () => void {
  const handler = (e: Event) => {
    const d = (e as CustomEvent).detail;
    if (d?.provider && d?.info) onFound({ id: d.info.uuid, name: d.info.name, icon: d.info.icon, provider: d.provider });
  };
  window.addEventListener("eip6963:announceProvider", handler);
  window.dispatchEvent(new Event("eip6963:requestProvider"));
  // legacy fallback: window.ethereum
  return () => window.removeEventListener("eip6963:announceProvider", handler);
}

const accounts = await provider.request({ method: "eth_requestAccounts" });
const walletClient = createWalletClient({ account: accounts[0], chain, transport: custom(provider) });

Testing money flows on a fork

You do not need real funds to test a mint. Anvil can fork a live network locally, and you can fund a test account by impersonating a contract that holds tokens. Self-Quantum Liquidity's create flow was tested this way: ERC-20 path, native-ETH path, one-sided positions and the full UI.

fork Base and fund a test account
# 1. a local copy of Base mainnet
anvil --fork-url https://base-rpc.publicnode.com --port 8545

# 2. take WETH and USDC from a pool contract and give them to a test account
POOL=0xd0b53D9277642d899DF5C87A3966A349A798F224   # WETH/USDC 0.05% pool
ACC=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266     # anvil's first test account
cast rpc anvil_setBalance $POOL 0x56BC75E2D63100000
cast rpc anvil_impersonateAccount $POOL
cast send 0x4200000000000000000000000000000000000006 "transfer(address,uint256)" $ACC 5000000000000000000 \
  --from $POOL --unlocked
cast send 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 "transfer(address,uint256)" $ACC 20000000000 \
  --from $POOL --unlocked
  • Script tests: point a viem client at http://127.0.0.1:8545 with a local account and call the same planning, approval and mint functions the UI uses. Check the NFT's owner, its ticks, and the balance changes.
  • UI tests: in a browser test, inject a mock window.ethereum that forwards RPC calls to anvil, and route the page's RPC requests for that network to anvil too, so reads and writes see the same state.
  • Known limit: a free public endpoint will not serve old state, so an anvil fork can occasionally fail to fetch storage. Re-run, or fork from a node with archive access.
  • Check money, not just success: assert approvals equal the exact amount, balances moved by the expected amount, and leftovers can be revoked.

Adding a network

  1. Find official addresses in Uniswap's deployments data (developers.uniswap.org/deployments).
  2. Verify them on chain with the cast checks on the Networks page. Reject any that do not match.
  3. Pick an RPC that allows browser requests (CORS) and has Multicall3 deployed at 0xcA11bde05977b3631167028862bE2a173976CA11.
  4. Add an entry to DAPP_CHAINS in lib/dapp.ts; leave v2 or v4 out if they are not deployed.
  5. Add a logo as public/chains/<key>.png and the key to the ChainKey type.
  6. Check the pair tokens: confirm each token's symbol and decimals on chain. A wrong decimals value silently produces wrong prices.
  7. Scan it and compare the price with other networks. A real ETH/USDC price should agree within a small margin.
a network entry
{
  key: "unichain", name: "Unichain", viem: unichain,
  rpc: "https://mainnet.unichain.org", explorer: "https://uniscan.xyz",
  nativeSymbol: unichain.nativeCurrency.symbol, logo: "/chains/unichain.png",
  wrapped: "0x4200000000000000000000000000000000000006",
  v2: "0x1F98400000000000000000000000000000000002",
  v3: { factory: "0x1F98400000000000000000000000000000000003", npm: "0x943e6e07a7E8E791dAFC44083e54041D743C46E9" },
  v4: { poolManager: "0x1F98400000000000000000000000000000000004", stateView: "0x86e8631A016F9068C3f085fAF484Ee3F5fDee8f2", positionManager: "0x4529A01c7A0410167c5740C487A8DE60232617bf" },
  pairs: [["0x4200000000000000000000000000000000000006", "0x078D782b760474a361dDA0AF3839290b0EF57AD6"]],
}

Running it locally

bash
npm install
npm run dev        # http://localhost:3000  (landing)  ·  /app  ·  /docs
npm run build      # also rebuilds the docs search index (prebuild)

Known limits and ideas

  • Pools beyond the curated pairs need a manual lookup. An indexer or a subgraph would allow a full pool explorer.
  • v2 and v4 position lists need an indexer. v4 position creation uses a different flow (Permit2 and the v4 PositionManager's action encoding).
  • Not built yet: one-sided ranges in the UI, adjusting and withdrawing, collecting fees, alerts, WalletConnect.
  • Public RPCs are rate-limited. Use your own endpoints in production.
  • Reference code: v3-periphery (NonfungiblePositionManager) and v4-core.

These docs are educational. They are not financial, legal or tax advice. Providing liquidity carries risk, including loss of funds. Self-Quantum Liquidity is new software and has not been audited.