diff --git a/.changeset/uniswap-v3-pool.md b/.changeset/uniswap-v3-pool.md new file mode 100644 index 0000000..72c819c --- /dev/null +++ b/.changeset/uniswap-v3-pool.md @@ -0,0 +1,5 @@ +--- +"@avaprotocol/protocols": minor +--- + +Add `uniswapV3Pool({ chainId, tokenA, tokenB, fee })`, which derives the canonical Uniswap v3 pool address and token order from the catalog factory. `UNISWAP_V3_FEE_TIERS` is the list of fee tiers to try (100, 500, 3000, 10000). The address is a CREATE2 prediction: callers still check `getPool` and `liquidity()` before offering a pool. diff --git a/README.md b/README.md index a303f10..3da744e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,21 @@ Works with Node 18+, modern bundlers, and any TypeScript 4.7+ consumer. Ships du ## Usage patterns +### Derive a Uniswap v3 pool + +```ts +import { uniswapV3Pool, UNISWAP_V3_FEE_TIERS, Chains, Protocols } from "@avaprotocol/protocols"; + +const pool = uniswapV3Pool({ + chainId: Chains.Sepolia, + tokenA: Protocols.uniswapV3.tokens.USDC[Chains.Sepolia]!, + tokenB: Protocols.uniswapV3.tokens.WETH[Chains.Sepolia]!, + fee: UNISWAP_V3_FEE_TIERS[2], // 3000, 0.3% +}); +// pool.address is the CREATE2 address; token0 is the lower address. +// Check Factory.getPool and liquidity() before offering the pool. +``` + ### Pin an address by chain ```ts diff --git a/package.json b/package.json index 6f02684..20d64ef 100644 --- a/package.json +++ b/package.json @@ -103,5 +103,8 @@ "engines": { "node": ">=18" }, - "packageManager": "yarn@1.22.19" + "packageManager": "yarn@1.22.19", + "dependencies": { + "@noble/hashes": "1.8.0" + } } diff --git a/src/protocols/index.ts b/src/protocols/index.ts index a9c9250..4412595 100644 --- a/src/protocols/index.ts +++ b/src/protocols/index.ts @@ -29,7 +29,10 @@ export { rocketPool } from "./rocket-pool"; export { sky } from "./sky"; export { spark } from "./spark"; export { superfluid } from "./superfluid"; -export { uniswapV3 } from "./uniswap-v3"; +export { uniswapV3, UNISWAP_V3_FEE_TIER, UNISWAP_V3_FEE_TIERS } from "./uniswap-v3"; +export type { UniswapV3FeeTier } from "./uniswap-v3"; +export { uniswapV3Pool, UNISWAP_V3_POOL_INIT_CODE_HASH } from "./uniswapV3Pool"; +export type { UniswapV3PoolAddress } from "./uniswapV3Pool"; export { wrapped } from "./wrapped"; export { aggregatorV3Abi, erc4626VaultAbi } from "./common"; export type { diff --git a/src/protocols/uniswap-v3.ts b/src/protocols/uniswap-v3.ts index 45c9a76..b6b2470 100644 --- a/src/protocols/uniswap-v3.ts +++ b/src/protocols/uniswap-v3.ts @@ -6,11 +6,12 @@ // Canonical addresses: // https://docs.uniswap.org/contracts/v3/reference/deployments // -// Per-chain pool registries (every USDC/WETH pool at each fee tier, -// etc.) are deliberately NOT shipped here — they're indexable from -// the Uniswap subgraph or `Factory.getPool(tokenA, tokenB, fee)` and -// belong to a discovery layer, not a catalog. Consumers that need a -// specific pool address pass it inline. +// Per-chain pool registries are deliberately NOT shipped here. A pool +// address is `uniswapV3Pool({ chainId, tokenA, tokenB, fee })`: one +// pool per pair and fee, with token0 the lower address. That result +// is the CREATE2 address. It does not mean the pool is deployed or +// has liquidity — callers still read `Factory.getPool` and +// `liquidity()` before offering it. import { Chains } from "../chains"; import { type AbiFragment, type AddressByChain } from "./types"; @@ -61,6 +62,26 @@ const permit2: AddressByChain = { [Chains.RobinhoodMainnet]: "0x000000000022d473030F116dDEE9F6B43aC78BA3", }; +/** + * Uniswap v3 fee tiers, in the order a caller should try them. + * Hundredths of a bip: 100 = 0.01%, 500 = 0.05%, 3000 = 0.3%, 10000 = 1%. + */ +export const UNISWAP_V3_FEE_TIER = { + lowest: 100, + low: 500, + medium: 3000, + high: 10000, +} as const; + +export type UniswapV3FeeTier = (typeof UNISWAP_V3_FEE_TIER)[keyof typeof UNISWAP_V3_FEE_TIER]; + +export const UNISWAP_V3_FEE_TIERS: readonly UniswapV3FeeTier[] = [ + UNISWAP_V3_FEE_TIER.lowest, + UNISWAP_V3_FEE_TIER.low, + UNISWAP_V3_FEE_TIER.medium, + UNISWAP_V3_FEE_TIER.high, +]; + /** Uniswap V3 Factory — derives the deterministic pool address per token-pair+fee. */ const factory: AddressByChain = { [Chains.EthereumMainnet]: "0x1F98431c8aD98523631AE4a59f267346ea31F984", @@ -260,4 +281,5 @@ export const uniswapV3 = Object.freeze({ quoterV2Abi, factoryAbi, tokens, + feeTiers: UNISWAP_V3_FEE_TIERS, }); diff --git a/src/protocols/uniswapV3Pool.ts b/src/protocols/uniswapV3Pool.ts new file mode 100644 index 0000000..06f8ad9 --- /dev/null +++ b/src/protocols/uniswapV3Pool.ts @@ -0,0 +1,140 @@ +// Canonical Uniswap v3 pool address for a token pair and fee. +// +// The factories in `uniswapV3.factory` deploy pools with CREATE2, so +// the address is fixed by (token0, token1, fee). token0 is the lower +// address. Argument order does not matter. This is a prediction: it +// does not check that the pool was deployed or that it has liquidity. + +import { keccak_256 } from "@noble/hashes/sha3"; + +import { uniswapV3 } from "./uniswap-v3"; + +/** Canonical UniswapV3Pool creation-code hash. */ +export const UNISWAP_V3_POOL_INIT_CODE_HASH = + "0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54" as const; + +const MAX_UINT24 = 0xffffff; +/** 40 hex characters, no `0x` prefix. `address(0)` is not a token. */ +const ZERO_ADDRESS_BODY = "0".repeat(40); +const INIT_CODE_HASH_BYTES = hexToBytes(UNISWAP_V3_POOL_INIT_CODE_HASH); + +export type UniswapV3PoolAddress = { + address: `0x${string}`; + token0: `0x${string}`; + token1: `0x${string}`; +}; + +/** + * The Uniswap v3 pool for a token pair and fee, from the catalog factory. + * Returns undefined when the chain has no factory, an address is invalid, + * either token is the zero address, the two tokens are the same, or the fee + * is not a uint24. + */ +export function uniswapV3Pool(options: { + chainId: number; + tokenA: string; + tokenB: string; + fee: number; +}): UniswapV3PoolAddress | undefined { + const factory = uniswapV3.factory[options.chainId]; + if (!factory) return undefined; + if (!Number.isInteger(options.fee) || options.fee < 0 || options.fee > MAX_UINT24) return undefined; + + const tokenA = parseAddress(options.tokenA); + const tokenB = parseAddress(options.tokenB); + if (!tokenA || !tokenB) return undefined; + if (tokenA.lower === ZERO_ADDRESS_BODY || tokenB.lower === ZERO_ADDRESS_BODY) return undefined; + if (tokenA.lower === tokenB.lower) return undefined; + + const [token0, token1] = tokenA.lower < tokenB.lower ? [tokenA, tokenB] : [tokenB, tokenA]; + const salt = keccak_256(concatBytes([pad32(token0.bytes), pad32(token1.bytes), uint256Bytes(options.fee)])); + const preimage = concatBytes([new Uint8Array([0xff]), hexToBytes(factory), salt, INIT_CODE_HASH_BYTES]); + const hash = keccak_256(preimage); + const addressBytes = hash.slice(12); + + return { + address: checksumAddress(bytesToHex(addressBytes)), + token0: token0.checksum, + token1: token1.checksum, + }; +} + +type ParsedAddress = { + lower: string; + checksum: `0x${string}`; + bytes: Uint8Array; +}; + +function parseAddress(value: string): ParsedAddress | undefined { + if (!/^0x[0-9a-fA-F]{40}$/.test(value)) return undefined; + const lower = value.slice(2).toLowerCase(); + return { + lower, + checksum: checksumAddress(lower), + bytes: hexToBytes(lower), + }; +} + +/** EIP-55 checksum. `lowerHex` is 40 hex characters, no 0x prefix. */ +function checksumAddress(lowerHex: string): `0x${string}` { + // Hex is ASCII. The declaration build compiles src alone with lib ES2022 + // and an empty `types` list, so TextEncoder is not in scope there. + const hash = bytesToHex(keccak_256(asciiBytes(lowerHex))); + let out = "0x"; + for (let i = 0; i < lowerHex.length; i++) { + const nibble = Number.parseInt(hash[i] ?? "0", 16); + const ch = lowerHex[i] ?? ""; + out += nibble >= 8 ? ch.toUpperCase() : ch; + } + return out as `0x${string}`; +} + +function asciiBytes(value: string): Uint8Array { + const out = new Uint8Array(value.length); + for (let i = 0; i < value.length; i++) { + out[i] = value.charCodeAt(i); + } + return out; +} + +function uint256Bytes(value: number): Uint8Array { + const out = new Uint8Array(32); + let remaining = value; + for (let i = 31; i >= 0 && remaining > 0; i--) { + out[i] = remaining & 0xff; + remaining = Math.floor(remaining / 256); + } + return out; +} + +function pad32(bytes: Uint8Array): Uint8Array { + const out = new Uint8Array(32); + out.set(bytes, 32 - bytes.length); + return out; +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const length = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function hexToBytes(hex: string): Uint8Array { + const body = hex.startsWith("0x") ? hex.slice(2) : hex; + const out = new Uint8Array(body.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (const byte of bytes) out += byte.toString(16).padStart(2, "0"); + return out; +} diff --git a/tests/uniswapV3Pool.test.ts b/tests/uniswapV3Pool.test.ts new file mode 100644 index 0000000..668a081 --- /dev/null +++ b/tests/uniswapV3Pool.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { Chains, Protocols, UNISWAP_V3_FEE_TIER, UNISWAP_V3_FEE_TIERS, uniswapV3Pool } from "../src"; + +const SEPOLIA_USDC_WETH_3000 = "0x6Ce0896eAE6D4BD668fDe41BB784548fb8F59b50"; +const ETHEREUM_USDC_WETH_500 = "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"; +const BASE_WETH_USDC_500 = "0xd0b53D9277642d899DF5C87A3966A349A798F224"; +const BASE_SEPOLIA_USDC_WETH_3000 = "0x46880b404CD35c165EDdefF7421019F8dD25F4Ad"; + +describe("UNISWAP_V3_FEE_TIERS", () => { + it("tries the four canonical tiers from 0.01% to 1%", () => { + expect(UNISWAP_V3_FEE_TIERS).toEqual([100, 500, 3000, 10000]); + expect(Protocols.uniswapV3.feeTiers).toEqual(UNISWAP_V3_FEE_TIERS); + expect(UNISWAP_V3_FEE_TIER.medium).toBe(3000); + }); +}); + +describe("uniswapV3Pool", () => { + it("derives the Sepolia USDC/WETH 0.3% pool with USDC as token0", () => { + const usdc = Protocols.uniswapV3.tokens.USDC[Chains.Sepolia]!; + const weth = Protocols.uniswapV3.tokens.WETH[Chains.Sepolia]!; + const pool = uniswapV3Pool({ + chainId: Chains.Sepolia, + tokenA: weth, + tokenB: usdc, + fee: UNISWAP_V3_FEE_TIER.medium, + }); + + expect(pool?.address).toBe(SEPOLIA_USDC_WETH_3000); + expect(pool?.token0).toBe(usdc); + expect(pool?.token1).toBe(weth); + }); + + it("derives the same address when the token arguments are swapped", () => { + const usdc = Protocols.uniswapV3.tokens.USDC[Chains.EthereumMainnet]!; + const weth = Protocols.uniswapV3.tokens.WETH[Chains.EthereumMainnet]!; + const forward = uniswapV3Pool({ + chainId: Chains.EthereumMainnet, + tokenA: usdc, + tokenB: weth, + fee: UNISWAP_V3_FEE_TIER.low, + }); + const reverse = uniswapV3Pool({ + chainId: Chains.EthereumMainnet, + tokenA: weth, + tokenB: usdc, + fee: UNISWAP_V3_FEE_TIER.low, + }); + + expect(forward?.address).toBe(ETHEREUM_USDC_WETH_500); + expect(reverse).toEqual(forward); + }); + + it("derives the Base and Base Sepolia USDC/WETH pools", () => { + const base = uniswapV3Pool({ + chainId: Chains.BaseMainnet, + tokenA: Protocols.uniswapV3.tokens.USDC[Chains.BaseMainnet]!, + tokenB: Protocols.uniswapV3.tokens.WETH[Chains.BaseMainnet]!, + fee: UNISWAP_V3_FEE_TIER.low, + }); + const baseSepolia = uniswapV3Pool({ + chainId: Chains.BaseSepolia, + tokenA: Protocols.uniswapV3.tokens.WETH[Chains.BaseSepolia]!, + tokenB: Protocols.uniswapV3.tokens.USDC[Chains.BaseSepolia]!, + fee: UNISWAP_V3_FEE_TIER.medium, + }); + + expect(base?.address).toBe(BASE_WETH_USDC_500); + expect(base?.token0).toBe(Protocols.uniswapV3.tokens.WETH[Chains.BaseMainnet]); + expect(baseSepolia?.address).toBe(BASE_SEPOLIA_USDC_WETH_3000); + }); + + it("returns undefined for the same token, the zero address, a bad address, a bad fee, or an unknown chain", () => { + const usdc = Protocols.uniswapV3.tokens.USDC[Chains.Sepolia]!; + const weth = Protocols.uniswapV3.tokens.WETH[Chains.Sepolia]!; + + expect( + uniswapV3Pool({ chainId: Chains.Sepolia, tokenA: usdc, tokenB: usdc, fee: UNISWAP_V3_FEE_TIER.medium }), + ).toBeUndefined(); + expect( + uniswapV3Pool({ + chainId: Chains.Sepolia, + tokenA: "0x0000000000000000000000000000000000000000", + tokenB: weth, + fee: UNISWAP_V3_FEE_TIER.medium, + }), + ).toBeUndefined(); + expect( + uniswapV3Pool({ chainId: Chains.Sepolia, tokenA: "not-an-address", tokenB: weth, fee: UNISWAP_V3_FEE_TIER.medium }), + ).toBeUndefined(); + expect(uniswapV3Pool({ chainId: Chains.Sepolia, tokenA: usdc, tokenB: weth, fee: 1.5 })).toBeUndefined(); + expect( + uniswapV3Pool({ chainId: 999_999, tokenA: usdc, tokenB: weth, fee: UNISWAP_V3_FEE_TIER.medium }), + ).toBeUndefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index db46eeb..ce07a4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -657,6 +657,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== +"@noble/hashes@1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"