Migrating from Uniswap v3
This guide is for developers who already read from or route through Uniswap v3 and want to add Ekubo, or move to it, on an EVM chain. It maps each Uniswap v3 concept to its Ekubo counterpart, then reads the same ETH/USDC market from both protocols with one script so you can check the mapping against live state.
The two protocols are not interchangeable at the code level. Pools are identified differently, prices and fees are encoded differently, and the SDKs have different surfaces. Plan for a new adapter rather than a configuration change.
| Item | Covered here |
|---|---|
| Ekubo | Ekubo V3 on EVM chains. Types and lens contracts are cited from evm-contracts v3.4.0; Core, the pool types, and CoreDataFetcher are unchanged from the v3.2.0 deployment listed in EVM Contracts (V3). Starknet differs in price and fee encoding (see Price representation) and is not covered. |
| Uniswap | Uniswap v3, cited from v3-core at 5ea68a7 and v3-periphery. Uniswap v4 is a different design and is not covered. |
| Example | Ethereum mainnet, read-only. No transaction is sent. |
Concept mapping
Section titled “Concept mapping”| Concept | Uniswap v3 | Ekubo V3 on EVM |
|---|---|---|
| Pool identity | One contract per (token0, token1, fee), found with UniswapV3Factory.getPool |
No pool contracts. Every pool lives in the Core singleton and is identified by a PoolKey of token0, token1, and a packed bytes32 config. The pool ID is keccak256(abi.encode(poolKey)). |
| Native ETH | Not supported in pools; WETH is used | Native ETH is address(0), so it is always token0. WETH pools are separate pools. |
| Price | slot0().sqrtPriceX96, Q64.96 |
CoreDataFetcher.poolPrice(poolKey) returns a 64.128 fixed-point sqrt ratio. Core stores a packed 96-bit SqrtRatio; see Price representation. |
| Tick | Price 1.0001^tick, range ±887,272 |
Price 1.000001^tick, range ±88,722,835 |
| Tick spacing | Fixed per fee tier by the factory: 100 → 1, 500 → 10, 3000 → 60, 10000 → 200 on Ethereum | A free parameter of each concentrated pool, up to 698,605. Stableswap and full-range pools have no tick spacing. |
| Swap fee | uint24 in millionths: 500 is 0.05% |
uint64 fraction of 2^64: 0.05% is 9223372036854775 or 9223372036854776, depending on rounding. Each value is a different pool. |
| Protocol fee | Per pool, slot0().feeProtocol: a share 1/N of each swap fee, taken at swap time. The factory owner can change it with setFeeProtocol |
Core takes none. A Positions contract takes SWAP_PROTOCOL_FEE_X64 / 2^64 of the fees it collects. The value is an immutable set at deployment, so it differs only between manager deployments; read it from the manager you use. |
| Swap entry | pool.swap(...) with uniswapV3SwapCallback, or a router |
Core.lock(), then swaps inside the lock callback, settling net balances once at the end. Production swaps use the Yul Router; see Swapping. |
| Quoting | QuoterV2, called with eth_call |
The Quoter API returns block-pinned split routes over HTTP. On-chain, the Yul Router’s quote(bytes) evaluates a route with eth_call. The Rust SDK quotes locally. |
| Hooks | None | A pool’s extension is part of its key and can run at eight call points. Some, such as MEVCapture, must be swapped through Core.forward rather than Core.swap. |
| LP positions | NonfungiblePositionManager ERC-721 |
Positions ERC-721, with a manager generation that varies by chain; see Position and order managers. |
| Price oracle | observe() on every pool |
Only pools using the Oracle extension record history, and only against native ETH. |
| SDK | @uniswap/v3-sdk: Pool, Route, Trade, position helpers |
@ekubo/sdk for math and pool-key encoding, @ekubo/yul-router-sdk for route encoding, and the Rust ekubo_sdk for quoting. There is no Trade equivalent. |
| Deployments | Per-chain factory and periphery addresses; see Uniswap’s deployment list | Core and lens contracts share one address on every supported chain; see EVM Contracts (V3) for chains and the per-chain manager addresses. |
| License | v3-core: BUSL-1.1 until a change date of 2023-04-01 at the latest, GPL-2.0-or-later after it. v3-periphery: GPL-2.0-or-later |
Ekubo DAO Shared Revenue License 1.0. Read section 7 before shipping a contract that is derived from, or incorporates, the contracts or their ABI. |
Converting a tick
Section titled “Converting a tick”The two tick bases differ, so a Uniswap tick does not map to a whole Ekubo tick. For the same token order, ekuboTick ≈ uniswapTick × ln(1.0001) / ln(1.000001), a factor of about 99.99505. When native ETH replaces WETH, the token order can flip, as it does for USDC: USDC is token0 of the Uniswap pool and token1 of the Ekubo pool, so the sign flips too. In the example below, Uniswap tick 198935 corresponds to about Ekubo tick -19892515.
Converting through the price is safer than converting ticks, because a position bound must also be a multiple of the target pool’s tick spacing. Round each bound to that spacing and show the user the resulting price range: it will not equal the original range exactly.
Run the example
Section titled “Run the example”The script reads the Uniswap v3 USDC/WETH 0.05% pool and the Ekubo ETH/USDC pool with a 0.05% fee, a tick spacing of 4988, and no extension, at the same block. It derives the Ekubo pool ID locally with @ekubo/sdk.
Prerequisites
Section titled “Prerequisites”- Node.js 20 or later. The example was run with Node.js 22.23.4 in a fresh
node:22-slimcontainer. - An Ethereum mainnet RPC URL in
RPC_URL. Without one, the script uses the publichttps://ethereum-rpc.publicnode.comendpoint, which can rate-limit.
npm init -ynpm install viem@2.57.11 @ekubo/sdk@0.0.10@ekubo/sdk is an alpha release, so pin the exact version.
Save this as compare-pools.mjs:
import { createPublicClient, http, keccak256, parseAbi, zeroAddress,} from "viem";import { mainnet } from "viem/chains";import { decodeEvmPoolConfig, deriveEvmPoolId, encodeEvmConcentratedPoolConfig,} from "@ekubo/sdk";
const client = createPublicClient({ chain: mainnet, transport: http(process.env.RPC_URL ?? "https://ethereum-rpc.publicnode.com"),});
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const UNISWAP_V3_FACTORY = "0x1F98431c8aD98523631AE4a59f267346ea31F984";const EKUBO_CORE_DATA_FETCHER = "0xF68F25CA6C817733b7B15a42191AE72A34d56a2B";const EKUBO_POSITIONS = "0x02D9876A21AF7545f8632C3af76eC90b5ad4b66D";
const blockNumber = await client.getBlockNumber();const read = (args) => client.readContract({ ...args, blockNumber });
// USDC per ETH, to 2 decimals, from a sqrt ratio with `shift` fractional bits.function usdcPerEth(sqrtRatio, shift, ethIsToken0) { const ratio = sqrtRatio * sqrtRatio; const one = 1n << (2n * shift); const scaled = ethIsToken0 ? (ratio * 10n ** 14n) / one : (one * 10n ** 14n) / ratio; return `${scaled / 100n}.${(scaled % 100n).toString().padStart(2, "0")}`;}
const uniPool = await read({ address: UNISWAP_V3_FACTORY, abi: parseAbi([ "function getPool(address,address,uint24) view returns (address)", ]), functionName: "getPool", args: [USDC, WETH, 500],});const uniAbi = parseAbi([ "function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16, uint16, uint16, uint8 feeProtocol, bool)", "function liquidity() view returns (uint128)", "function fee() view returns (uint24)", "function tickSpacing() view returns (int24)", "function token0() view returns (address)",]);const [slot0, uniLiquidity, uniFee, uniSpacing, uniToken0] = await Promise.all( ["slot0", "liquidity", "fee", "tickSpacing", "token0"].map((functionName) => read({ address: uniPool, abi: uniAbi, functionName }), ),);const [sqrtPriceX96, uniTick, , , , feeProtocol] = slot0;
const poolKey = { token0: zeroAddress, token1: USDC, config: encodeEvmConcentratedPoolConfig({ fee: 9223372036854776n, tickSpacing: 4988, extension: zeroAddress, }),};const poolKeyAbi = "(address token0, address token1, bytes32 config)";const [sqrtRatioFixed, ekuboTick] = await read({ address: EKUBO_CORE_DATA_FETCHER, abi: parseAbi([ `function poolPrice(${poolKeyAbi}) view returns (uint256, int32)`, ]), functionName: "poolPrice", args: [poolKey],});const [, , ekuboLiquidity] = await read({ address: EKUBO_CORE_DATA_FETCHER, abi: parseAbi([ `function poolState(${poolKeyAbi}) view returns (uint96, int32, uint128)`, ]), functionName: "poolState", args: [poolKey],});const swapProtocolFeeX64 = await read({ address: EKUBO_POSITIONS, abi: parseAbi(["function SWAP_PROTOCOL_FEE_X64() view returns (uint64)"]), functionName: "SWAP_PROTOCOL_FEE_X64",});const config = decodeEvmPoolConfig(poolKey.config);
console.log(`Ethereum block ${blockNumber}\n`);console.log("Uniswap v3 USDC/WETH");console.log(` pool address ${uniPool}`);console.log(` token0 ${uniToken0}`);console.log(` fee ${uniFee} / 1e6`);console.log(` tick spacing ${uniSpacing}`);console.log(` sqrtPriceX96 ${sqrtPriceX96}`);console.log(` tick ${uniTick}`);console.log(` liquidity ${uniLiquidity}`);console.log( ` feeProtocol ${feeProtocol % 16} (token0), ${feeProtocol >> 4} (token1)`,);console.log(` USDC per ETH ${usdcPerEth(sqrtPriceX96, 96n, false)}\n`);
console.log("Ekubo ETH/USDC");console.log(` pool id ${deriveEvmPoolId(poolKey, keccak256)}`);console.log(` token0 ${poolKey.token0}`);console.log(` config ${poolKey.config}`);console.log(` fee ${config.fee} / 2^64`);console.log(` tick spacing ${config.tickSpacing}`);console.log(` extension ${config.extension}`);console.log(` sqrtRatio 64.128 ${sqrtRatioFixed}`);console.log(` tick ${ekuboTick}`);console.log(` liquidity ${ekuboLiquidity}`);console.log(` Positions swap protocol fee ${swapProtocolFeeX64} / 2^64`);console.log(` USDC per ETH ${usdcPerEth(sqrtRatioFixed, 128n, true)}`);Run it:
node compare-pools.mjsExpected output
Section titled “Expected output”Observed at block 26263507 (15:48:59 UTC on 24 October 2026). The addresses, pool ID, config, fee, tick spacing, and Positions swap protocol fee should match exactly. Uniswap’s feeProtocol is the value at that block and can change. Prices, ticks, and liquidity change with every block.
Ethereum block 26263507
Uniswap v3 USDC/WETH pool address 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640 token0 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 fee 500 / 1e6 tick spacing 10 sqrtPriceX96 1653817353788712817544262592047087 tick 198935 liquidity 1847104564058872996 feeProtocol 4 (token0), 4 (token1) USDC per ETH 2295.00
Ekubo ETH/USDC pool id 0xde39735c66c6f51669c80e1fde362e42a1b1b927d49dee8d6577765a4e4d38c6 token0 0x0000000000000000000000000000000000000000 config 0x00000000000000000000000000000000000000000020c49ba5e353f88000137c fee 9223372036854776 / 2^64 tick spacing 4988 extension 0x0000000000000000000000000000000000000000 sqrtRatio 64.128 16300619316558869431231032374329344 tick -19892666 liquidity 8069515514150211 Positions swap protocol fee 1844674407370955161 / 2^64 USDC per ETH 2294.71Reading the output
Section titled “Reading the output”The two prices agree to within a few hundredths of a percent, which confirms the conversions: Uniswap’s price is WETH per USDC in Q64.96 and has to be inverted, while Ekubo’s is USDC per ETH in 64.128.
feeProtocol of 4 means a quarter of this Uniswap pool’s swap fees went to the protocol at that block. It is mutable: the Uniswap v3 factory owner can change it per pool with setFeeProtocol, so read it at the block you care about.
1844674407370955161 / 2^64 is 10%: the share the canonical Ethereum Positions contract takes from fees it collects for Ekubo liquidity providers. In Positions.sol it is an immutable assigned in the constructor, so it cannot change for this deployment. A different Positions address can have been deployed with a different value, which is why the script reads it from the manager it targets rather than hard-coding it.
Active liquidity is in the same units in both protocols, but one pool’s liquidity says little about execution. Ekubo has several ETH/USDC pools with different fees, tick spacings, and extensions, and the Quoter API splits across them. Compare routed quotes, not single pools, when deciding where to send a trade.
config packs three fields into one word: the extension address in the top 160 bits, the fee in the next 64, and a 32-bit pool-type field. Here that field is 0x8000137c: the high bit marks a concentrated pool and 0x137c is the tick spacing 4988. poolConfig.sol defines the layout; decodeEvmPoolConfig decodes it.
Get a quote
Section titled “Get a quote”Uniswap integrations usually quote through QuoterV2. The closest Ekubo equivalent for off-chain routing is the Quoter API, which needs no key:
curl -s https://prod-api-quoter.ekubo.org/1/1000000000000000000/0x0000000000000000000000000000000000000000/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ | jq '{block_number, total_calculated, estimated_gas_cost, splits: (.splits | length), first_split: (.splits[0] | {amount_specified, amount_calculated, pool_config: .route[0].swap.pool_key.config})}'Observed at 15:49 UTC on 24 October 2026:
{ "block_number": 26263508, "total_calculated": "2282386247", "estimated_gas_cost": 206609, "splits": 6, "first_split": { "amount_specified": "750000000000000000", "amount_calculated": "1712557071", "pool_config": "0x00000000000000000000000000000000000000000020c49ba5e353f88000137c" }}The path is /{chainId}/{amount}/{specifiedToken}/{otherToken} with amounts in base units. This request sells 1 ETH. In the observed response, 75% of it routed through the pool the script read; the split count and shares change from block to block. For exact output, make the amount negative and specify the output token: /1/-2000000000/{USDC}/{ETH} asks what it costs to receive 2,000 USDC, and total_calculated comes back negative because it is an input.
A quote is pinned to block_number. To execute it, encode its splits with @ekubo/yul-router-sdk as described in Preparing a swap from the Quoter API. The same guide shows how to re-check a route on-chain with quote(bytes).
Liquidity positions do not migrate atomically
Section titled “Liquidity positions do not migrate atomically”There is no contract that converts a Uniswap v3 position into an Ekubo position. Moving liquidity is two independent operations: remove liquidity and collect fees from the Uniswap NonfungiblePositionManager, then deposit into an Ekubo pool through Positions. The price can move between them, the range has to be re-expressed in Ekubo ticks rounded to the new pool’s spacing, and the amounts deposited will depend on the price at deposit time.
Choose the target pool deliberately. A fee tier in Uniswap maps to more than one possible Ekubo pool, and a pool with an extension behaves differently from one without. Narrower ranges earn more fees per dollar while the price is inside them and suffer more divergence loss and time out of range when it is not; moving protocols does not change that trade-off.
When to keep Uniswap v3
Section titled “When to keep Uniswap v3”Adding Ekubo does not require removing Uniswap. Keep the existing integration, alone or alongside Ekubo, when any of these hold:
- The chain you need is not in Ekubo’s supported chains, or is listed as awaiting EIP-7939.
- Routed Ekubo quotes for your pairs and sizes are consistently worse than what you get today. Compare at the sizes you actually trade.
- Your product depends on Uniswap-specific surfaces:
observe()on arbitrary pairs, Uniswap position NFTs accepted by other protocols, or tooling built on@uniswap/v3-sdktypes. - Your contracts would be derived from or incorporate Ekubo’s contracts or ABI, and the license terms do not suit you. This page does not interpret the license; read it with your own counsel.
For aggregators and solvers, adding Ekubo as an additional source is the usual pattern. The Aggregators guide and its reference implementations show how others did it.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause and fix |
|---|---|
| Ekubo price and liquidity are all zero | The pool key does not match an initialized pool. Every field counts: WETH instead of address(0), a fee off by one unit, a different tick spacing, or a different extension each name another pool. Find initialized keys with the Quoter API response or the MCP server’s list_pool_keys. |
Revert with TokensMustBeSorted() |
token0 must be the numerically smaller address. Core rejects an unsorted key when a pool is initialized; a read with an unsorted key just returns zero state. With native ETH, address(0) is always token0. |
The Ekubo price is off by a factor of 10^12 |
The decimal adjustment was skipped or inverted. The sqrt ratio is for token1 per token0 in base units, so scale by 10^(decimals0 - decimals1) for a human price. |
| The prices differ by much more than a fraction of a percent | The two reads used different blocks, or one pool has little active liquidity. Pin both reads to one block, as the script does. |
HTTP 429 from the RPC |
The public endpoint rate-limits. Set RPC_URL to your own provider. |
| Swaps revert on a chain that has Core deployed | Some chains still lack the CLZ opcode; see Supported chains. |
Differences this guide does not cover
Section titled “Differences this guide does not cover”This guide is the minimum needed to read state and get a quote. A full migration also has to deal with these, each of which is its own piece of work:
- Executing swaps from your own contract inside a Core lock, including settlement and native-ETH payment; see Swapping.
- Stableswap and full-range pools, which have no tick spacing and use different liquidity math.
- Pools with extensions, including forwarded hops for MEVCapture and Ve33, and quoting their behavior off-chain; see Aggregators.
- Indexing: Ekubo emits events from Core rather than from per-pool contracts, so pool discovery and state tracking differ. The open-source indexer is a working reference.
- TWAMM orders, the Oracle extension’s interface, boosted fees, and signed exclusive swaps, none of which have a Uniswap v3 equivalent.
- Position management through Positions: minting, ID generation, fee collection, and the per-chain manager generations.
- Starknet, where prices and fees are encoded differently.