Uniswap V3 Pool Data in Raw viem: What Leaving evmquery Actually Costs

The hand-rolled viem code behind evmquery's Uniswap V3 pool-data read: slot0, liquidity, fee, and the sqrtPriceX96 precision trap, line by line.

evmquery team··7 min read
Share
Uniswap V3 pool data migrated from evmquery to raw viem: slot0, liquidity, and sqrtPriceX96 math

Our own post on how evmquery resolves a contract read named two guides on this blog that show only the evmquery call, with no hand-rolled equivalent published next to it: the Aave V3 health-factor guide and the Uniswap V3 pool-data guide. The Aave migration post closed the first gap. This post closes the second, for the same reason: the claim that leaving evmquery means writing the raw multicall yourself shouldn’t just be a promise.

TL;DR

The evmquery expression behind the Uniswap V3 pool-data guide is one CEL list literal. The raw viem equivalent, a full slot0/liquidity/fee ABI fragment plus a multicall block, is 40 lines. Both were run live against the same pool on 2026-09-14; the numbers below aren’t estimated.

What this migration actually costs

  • Single-call pool read (sqrtPriceX96, tick, liquidity, fee): one CEL list literal in evmquery vs. 40 lines of viem (three ABI fragments, a client.multicall block, a price-math helper).
  • Both code paths in this post were executed live on 2026-09-14: evmquery against the hosted API, viem against a public Ethereum RPC, same pool, six blocks apart.
  • The USDC/WETH 0.05% pool priced ETH at $2,511.80 (evmquery, block 25,973,188) and $2,513.14 (viem, block 25,973,194) — the six-block gap accounts for the difference, not a bug on either side.
  • sqrtPriceX96 values routinely land above 2^110, well past the 53-bit integer Number can represent exactly — the viem side has to do the price conversion in BigInt, not float, or the result silently drifts.

The expression this post is replacing

The Uniswap V3 pool-data guide batches four reads — sqrtPriceX96, tick, liquidity, and fee — into a single CEL list literal:

[pool.slot0().sqrtPriceX96, pool.slot0().tick, pool.liquidity(), pool.fee()]

Re-run live for this post, against the same USDC/WETH 0.05% pool on Ethereum (0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640):

{
"result": {
"value": [
"1580836164761528952003270890041330",
"198032",
"8301107898162515832",
"500"
]
},
"meta": { "blockNumber": 25973188, "totalCalls": 3, "totalRounds": 1 }
}

Block 25,973,188, 3 on-chain calls (slot0 is counted twice — once for sqrtPriceX96, once for tick), 1 round, 4 units. That’s a real, current read, not a canned example, and every downstream figure in this post is derived from it.

This list literal only works because every value is sol_int

A CEL list literal requires homogeneous element types. Adding pool.token0() or pool.token1() (both sol_address) to the same list throws List elements must have the same type, expected type 'sol_int' but found 'sol_address' — confirmed live while writing this post. The fix, documented elsewhere on this blog, is dyn() around each element, or a second call. The migration below sidesteps the issue entirely: JavaScript doesn’t have this constraint, so the viem side reads all five fields — including both token addresses — off one struct with no type-juggling at all.

The raw viem equivalent

Here’s the full replacement: three ABI fragments (slot0, liquidity, fee), a multicall call, and the price-conversion helper.

import { createPublicClient, http, type Abi } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({ chain: mainnet, transport: http() });
const POOL: `0x${string}` = "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640";
const poolAbi = [
{
name: "slot0",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [
{ name: "sqrtPriceX96", type: "uint160" },
{ name: "tick", type: "int24" },
{ name: "observationIndex", type: "uint16" },
{ name: "observationCardinality", type: "uint16" },
{ name: "observationCardinalityNext", type: "uint16" },
{ name: "feeProtocol", type: "uint8" },
{ name: "unlocked", type: "bool" },
],
},
{
name: "liquidity",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint128" }],
},
{
name: "fee",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint24" }],
},
] as const satisfies Abi;
async function getPoolState(pool: `0x${string}`) {
const [slot0, liquidity, fee] = await client.multicall({
contracts: [
{ address: pool, abi: poolAbi, functionName: "slot0" },
{ address: pool, abi: poolAbi, functionName: "liquidity" },
{ address: pool, abi: poolAbi, functionName: "fee" },
],
allowFailure: false,
});
const [sqrtPriceX96, tick] = slot0;
return { sqrtPriceX96, tick, liquidity, fee };
}
function sqrtPriceX96ToEthUsd(sqrtPriceX96: bigint): number {
const Q96 = 2n ** 96n;
// price_raw = (sqrtPriceX96 / Q96)^2 = WETH raw units per USDC raw unit
// adjust for decimals (USDC=6, WETH=18), then invert for USD per ETH
const numerator = Q96 * Q96 * 10n ** 12n;
const denominator = sqrtPriceX96 * sqrtPriceX96;
return Number(numerator) / Number(denominator);
}

40 lines, count them in the block above. Run against the same pool, against Ethereum mainnet, live for this post:

{
blockNumber: '25973194',
ethPriceUsd: '2513.14',
tick: 198027,
liquidity: '8345046813799225564',
fee: 500
}

Block 25,973,194, six blocks after the evmquery call above — the two ran a few seconds apart against different infrastructure, not simultaneously, and neither side is favored by the gap. client.multicall resolves the canonical Multicall3 deployment on Ethereum automatically, so this is one on-chain round trip, same as evmquery’s list literal. What the ABI fragments above buy you, on evmquery’s side, isn’t correctness — it’s not having to already know slot0’s seven-field return struct in the first place. That struct is copy-pasted from Uniswap’s own public IUniswapV3PoolState.sol interface, the same thing describe_schema would have handed back if you’d asked evmquery for it instead.

The trap: sqrtPriceX96 doesn’t fit in a JS Number

The line that makes or breaks this migration is sqrtPriceX96ToEthUsd’s use of BigInt all the way through. sqrtPriceX96 for this pool is 1580836164761528952003270890041330 — 34 digits, comfortably past 2^110. JavaScript’s Number type only represents integers exactly up to 2^53. Convert sqrtPriceX96 to a Number before squaring it, the way the naive version of this function looks like it should work —

// Don't do this — silently wrong for any real pool
function sqrtPriceX96ToEthUsdWrong(sqrtPriceX96: bigint): number {
const sqrtPrice = Number(sqrtPriceX96) / 2 ** 96;
const priceRaw = sqrtPrice * sqrtPrice;
return 1 / (priceRaw * 10 ** 6 / 10 ** 18);
}

— and the result isn’t NaN or an exception, it’s a plausible-looking dollar figure that’s wrong by a margin that depends on which bits of precision Number happened to drop during the cast. Nothing throws. Nothing looks obviously wrong in a type checker or in a quick manual spot-check against a price you already expect to be roughly right. The bug only shows up as a slow, silent drift between what your code reports and what the pool actually holds — the original guide already flags this with a warning callout, and it’s worth repeating here because raw viem doesn’t remove the trap, it just moves who has to know about it.

evmquery doesn’t hit this problem because CEL’s sol_int type and the API’s JSON response both represent large integers as strings, never as a JS number — the guide’s Python example uses decimal.Decimal for the same reason on that side. What migrating off evmquery changes is where that discipline has to live: inside evmquery, reading the guide once is enough to know large integers stay as strings; in raw viem, every fresh price-math helper has to remember to keep the whole computation in BigInt until the very last division, or find this post first.

What this actually proves

Both code blocks above are complete and were run against live infrastructure while writing this post, not sketched from memory. That’s the same standard the evmquery vs. raw viem benchmark holds itself to, and the comparison lands in a similar place: for a widely-integrated contract like a Uniswap V3 pool, the code you’d write by hand isn’t exotic. It’s roughly 40 lines instead of one CEL list literal, and every line of it is public information — Uniswap’s own interface file, the standard Q96 fixed-point convention, and viem’s own multicall action.

What’s genuinely different, and worth being honest about since this is a trust-pool post making a portability claim: this comparison was cheap to write because the pool-data guide had already worked out which fields matter, in what order, and how the price math has to be scaled for a USDC/WETH pool specifically, before this post ever touched viem. That knowledge came from reading Uniswap’s own contracts and testing against a live pool, not from evmquery’s resolver. A less-documented pool — a token pair with an unusual decimal split, say — would cost you that research too, on top of the 40 lines above. That’s the part evmquery is actually selling: not the one-line CEL expression, but not needing to have written this post yourself first.

If you’re weighing this trade-off for your own developer stack, the honest version is the same one the Aave post reached: for a read this well-trodden, the exit cost is real but small. For a pool with a decimal split nobody’s documented yet, the exit cost is whatever it takes to work that out by hand.

Next steps

Share

Skip the migration, for now

The expression benchmarked in this post is live. Get a free key and run it against your own pools before you decide whether to hand-roll it.