Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/uniswap-v3-pool.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +71 to +72
// Check Factory.getPool and liquidity() before offering the pool.
```

### Pin an address by chain

```ts
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,8 @@
"engines": {
"node": ">=18"
},
"packageManager": "yarn@1.22.19"
"packageManager": "yarn@1.22.19",
"dependencies": {
"@noble/hashes": "1.8.0"
}
}
5 changes: 4 additions & 1 deletion src/protocols/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 27 additions & 5 deletions src/protocols/uniswap-v3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -260,4 +281,5 @@ export const uniswapV3 = Object.freeze({
quoterV2Abi,
factoryAbi,
tokens,
feeTiers: UNISWAP_V3_FEE_TIERS,
});
140 changes: 140 additions & 0 deletions src/protocols/uniswapV3Pool.ts
Original file line number Diff line number Diff line change
@@ -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;
}
96 changes: 96 additions & 0 deletions tests/uniswapV3Pool.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading