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
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.
| File | What it does |
|---|---|
lib/dapp.ts | Network configuration, token metadata, pool discovery for v2/v3/v4, and positions across networks. |
lib/clmm.ts | Concentrated-liquidity maths: ticks, prices, range snapping, deposit amounts, capital efficiency. |
lib/mint.ts | Planning, approving, simulating and sending a v3 mint, with human-readable errors. |
lib/wallet.ts | Wallet 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.
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
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
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.
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
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
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
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
// 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
// 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):
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.
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.
# 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:8545with 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.ethereumthat 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
- Find official addresses in Uniswap's deployments data (developers.uniswap.org/deployments).
- Verify them on chain with the
castchecks on the Networks page. Reject any that do not match. - Pick an RPC that allows browser requests (CORS) and has Multicall3 deployed at
0xcA11bde05977b3631167028862bE2a173976CA11. - Add an entry to
DAPP_CHAINSinlib/dapp.ts; leavev2orv4out if they are not deployed. - Add a logo as
public/chains/<key>.pngand the key to theChainKeytype. - Check the pair tokens: confirm each token's symbol and decimals on chain. A wrong decimals value silently produces wrong prices.
- Scan it and compare the price with other networks. A real ETH/USDC price should agree within a small margin.
{
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
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.