Skip to content

Commit 59c9a0f

Browse files
szhygulinclaude
andcommitted
feat(chains): add Optimism support
Add Optimism (chainId 10) as a fully-supported EVM chain with parity to Arbitrum/Polygon: balances, history, swaps via LiFi, native sends, plus Aave V3 lending, Compound V3 markets (USDC/WETH/USDT), and Uniswap V3. Lido and EigenLayer are L1-only — `lido`/`eigenlayer` keys absent from the Optimism block makes the readers short-circuit, matching how Polygon/Base are handled. Morpho Blue is deferred (same comment as Base — deployment block needs verification before the discovery scan can walk eth_getLogs from a known starting block). Verified contract addresses live against Etherscan V2 chainid=10 before commit: - Aave V3 Pool → InitializableImmutableAdminUpgradeabilityProxy - Uniswap V3 Factory → UniswapV3Factory - Compound cUSDCv3 / cWETHv3 / cUSDTv3 → TransparentUpgradeableProxy - USDC native → FiatTokenProxy (Circle) - OP token → GovernanceToken Sources for the address constants: - Aave V3 → bgd-labs/aave-address-book AaveV3Optimism.sol - Compound V3 → compound-finance/comet deployments/optimism/*/roots.json - Uniswap V3 → Uniswap/docs Optimism-Deployments.md (uses canonical cross-chain addresses, not Base's chain-specific ones) WalletConnect EVM namespace auto-picks up `eip155:10` via Object.values(CHAIN_IDS) in src/signing/walletconnect.ts. All Record<SupportedChain, T> maps now require an `optimism` key — the typechecker is the safety net for any I missed. 13 new tests (7 in optimism-chain-support.test.ts, 2 in chains.test.ts, 4 auto-picked-up by existing chain-loop tests). 522/522 pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent db5fba6 commit 59c9a0f

8 files changed

Lines changed: 175 additions & 3 deletions

File tree

src/config/chains.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mainnet, arbitrum, polygon, base } from "viem/chains";
1+
import { mainnet, arbitrum, polygon, base, optimism } from "viem/chains";
22
import type { Chain } from "viem";
33
import type { RpcProvider, SupportedChain, UserConfig } from "../types/index.js";
44

@@ -7,6 +7,7 @@ export const VIEM_CHAINS: Record<SupportedChain, Chain> = {
77
arbitrum,
88
polygon,
99
base,
10+
optimism,
1011
};
1112

1213
/** URL path segment per provider + chain. */
@@ -16,12 +17,14 @@ const PROVIDER_URL_TEMPLATES: Record<Exclude<RpcProvider, "custom">, Record<Supp
1617
arbitrum: (k) => `https://arbitrum-mainnet.infura.io/v3/${k}`,
1718
polygon: (k) => `https://polygon-mainnet.infura.io/v3/${k}`,
1819
base: (k) => `https://base-mainnet.infura.io/v3/${k}`,
20+
optimism: (k) => `https://optimism-mainnet.infura.io/v3/${k}`,
1921
},
2022
alchemy: {
2123
ethereum: (k) => `https://eth-mainnet.g.alchemy.com/v2/${k}`,
2224
arbitrum: (k) => `https://arb-mainnet.g.alchemy.com/v2/${k}`,
2325
polygon: (k) => `https://polygon-mainnet.g.alchemy.com/v2/${k}`,
2426
base: (k) => `https://base-mainnet.g.alchemy.com/v2/${k}`,
27+
optimism: (k) => `https://opt-mainnet.g.alchemy.com/v2/${k}`,
2528
},
2629
};
2730

@@ -30,6 +33,7 @@ const ENV_URL_VAR: Record<SupportedChain, string> = {
3033
arbitrum: "ARBITRUM_RPC_URL",
3134
polygon: "POLYGON_RPC_URL",
3235
base: "BASE_RPC_URL",
36+
optimism: "OPTIMISM_RPC_URL",
3337
};
3438

3539
export class RpcConfigError extends Error {

src/config/contracts.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,48 @@ export const CONTRACTS = {
162162
// verified for Base yet. Add the address + block together in a future
163163
// PR rather than guess and risk missing positions.
164164
},
165+
optimism: {
166+
aave: {
167+
// Aave V3 on Optimism. PoolAddressesProvider matches the deterministic
168+
// cross-L2 address (same on Arbitrum/Polygon); UiPoolDataProvider is
169+
// chain-specific. Sourced from bgd-labs/aave-address-book AaveV3Optimism.
170+
poolAddressProvider: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb",
171+
uiPoolDataProvider: "0xa6741111f4CcB5162Ec6A825465354Ed8c6F7095",
172+
pool: "0x794a61358D6845594F94dc1DB02A252b5b4814aD",
173+
},
174+
uniswap: {
175+
// Optimism uses the canonical cross-chain Uniswap V3 addresses (same as
176+
// Ethereum/Arbitrum/Polygon — only Base diverged). Verified against
177+
// Uniswap/docs Optimism-Deployments.md.
178+
positionManager: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
179+
factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
180+
swapRouter02: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45",
181+
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
182+
},
183+
// Lido / EigenLayer are L1-only; Morpho Blue is deployed on Optimism but
184+
// we omit it for the same reason as Base — the discovery scan needs a
185+
// verified deployment block. Add later as a follow-up.
186+
tokens: {
187+
// USDC is the Circle-native (post-2023); USDC.e is the bridged legacy
188+
// version that some positions are still denominated in.
189+
USDC: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85",
190+
"USDC.e": "0x7F5c764cBc14f9669B88837ca1490cCa17c31607",
191+
USDT: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58",
192+
DAI: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1",
193+
WETH: "0x4200000000000000000000000000000000000006",
194+
WBTC: "0x68f180fcCe6836688e9084f035309E29Bf0A2095",
195+
// OP-stack predeploy addresses (0x4200... range). The OP token is the
196+
// governance token for the Optimism Collective.
197+
OP: "0x4200000000000000000000000000000000000042",
198+
},
199+
compound: {
200+
// Compound V3 markets on Optimism, sourced from compound-finance/comet
201+
// deployments/optimism/{usdc,weth,usdt}/roots.json.
202+
cUSDCv3: "0x2e44e174f7D53F0212823acC11C01A11d58c5bCB",
203+
cWETHv3: "0xE36A30D249f7761327fd973001A32010b521b6Fd",
204+
cUSDTv3: "0x995E394b8B2437aC8Ce61Ee0bC610D617962B214",
205+
},
206+
},
165207
} as const;
166208

167209
export type ChainContracts<C extends SupportedChain> = (typeof CONTRACTS)[C];
@@ -173,6 +215,7 @@ export const NATIVE_SYMBOL: Record<SupportedChain, string> = {
173215
// Polygon's native token is MATIC (rebranding to POL is in progress — same contract).
174216
polygon: "MATIC",
175217
base: "ETH",
218+
optimism: "ETH",
176219
};
177220

178221
/**
@@ -202,6 +245,7 @@ const DECIMALS_BY_SYMBOL: Record<string, number> = {
202245
arb: 18,
203246
wmatic: 18,
204247
cbeth: 18,
248+
op: 18,
205249
};
206250

207251
export interface TokenInfo {

src/data/prices.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ const LLAMA_CHAIN: Record<SupportedChain, string> = {
1515
arbitrum: "arbitrum",
1616
polygon: "polygon",
1717
base: "base",
18+
optimism: "optimism",
1819
};
1920

2021
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
21-
/** Coingecko ID for each chain's native asset. Ethereum + Arbitrum + Base share ETH; Polygon uses MATIC. */
22+
/** Coingecko ID for each chain's native asset. Ethereum + Arbitrum + Base + Optimism share ETH; Polygon uses MATIC. */
2223
const NATIVE_COINGECKO_ID: Record<SupportedChain, string> = {
2324
ethereum: "coingecko:ethereum",
2425
arbitrum: "coingecko:ethereum",
2526
polygon: "coingecko:matic-network",
2627
base: "coingecko:ethereum",
28+
optimism: "coingecko:ethereum",
2729
};
2830

2931
export interface PriceQuery {

src/modules/history/prices.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const NATIVE_COIN_KEY: Record<AnyChain, string> = {
2323
arbitrum: "coingecko:ethereum",
2424
polygon: "coingecko:matic-network",
2525
base: "coingecko:ethereum",
26+
optimism: "coingecko:ethereum",
2627
tron: "coingecko:tron",
2728
};
2829

@@ -31,6 +32,7 @@ const LLAMA_CHAIN: Record<AnyChain, string> = {
3132
arbitrum: "arbitrum",
3233
polygon: "polygon",
3334
base: "base",
35+
optimism: "optimism",
3436
tron: "tron",
3537
};
3638

src/setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ const PROVIDER_URLS: Record<"infura" | "alchemy", Record<SupportedChain, (k: str
6666
arbitrum: (k) => `https://arbitrum-mainnet.infura.io/v3/${k}`,
6767
polygon: (k) => `https://polygon-mainnet.infura.io/v3/${k}`,
6868
base: (k) => `https://base-mainnet.infura.io/v3/${k}`,
69+
optimism: (k) => `https://optimism-mainnet.infura.io/v3/${k}`,
6970
},
7071
alchemy: {
7172
ethereum: (k) => `https://eth-mainnet.g.alchemy.com/v2/${k}`,
7273
arbitrum: (k) => `https://arb-mainnet.g.alchemy.com/v2/${k}`,
7374
polygon: (k) => `https://polygon-mainnet.g.alchemy.com/v2/${k}`,
7475
base: (k) => `https://base-mainnet.g.alchemy.com/v2/${k}`,
76+
optimism: (k) => `https://opt-mainnet.g.alchemy.com/v2/${k}`,
7577
},
7678
};
7779

src/types/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@
1111
* portfolio summary) accept. This split keeps TRON strictly additive: EVM
1212
* internals don't need to learn that TRON exists.
1313
*/
14-
export type SupportedChain = "ethereum" | "arbitrum" | "polygon" | "base";
14+
export type SupportedChain = "ethereum" | "arbitrum" | "polygon" | "base" | "optimism";
1515

1616
export const SUPPORTED_CHAINS: readonly SupportedChain[] = [
1717
"ethereum",
1818
"arbitrum",
1919
"polygon",
2020
"base",
21+
"optimism",
2122
] as const;
2223

2324
/** Non-EVM chains. Kept as its own union so EVM-only tables keep their type. */
@@ -45,13 +46,15 @@ export const CHAIN_IDS: Record<SupportedChain, number> = {
4546
arbitrum: 42161,
4647
polygon: 137,
4748
base: 8453,
49+
optimism: 10,
4850
};
4951

5052
export const CHAIN_ID_TO_NAME: Record<number, SupportedChain> = {
5153
1: "ethereum",
5254
42161: "arbitrum",
5355
137: "polygon",
5456
8453: "base",
57+
10: "optimism",
5558
};
5659

5760
/**

test/chains.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const RPC_ENV_KEYS = [
77
"ARBITRUM_RPC_URL",
88
"POLYGON_RPC_URL",
99
"BASE_RPC_URL",
10+
"OPTIMISM_RPC_URL",
1011
"RPC_PROVIDER",
1112
"RPC_API_KEY",
1213
];
@@ -61,6 +62,27 @@ describe("resolveRpcUrl", () => {
6162
expect(resolveRpcUrl("base", cfg)).toBe("https://env-base.example/");
6263
});
6364

65+
it("OPTIMISM_RPC_URL env var wins over everything else", () => {
66+
process.env.OPTIMISM_RPC_URL = "https://env-optimism.example/";
67+
process.env.RPC_PROVIDER = "alchemy";
68+
process.env.RPC_API_KEY = "envkey";
69+
const cfg: UserConfig = { rpc: { provider: "infura", apiKey: "cfgkey" } };
70+
expect(resolveRpcUrl("optimism", cfg)).toBe("https://env-optimism.example/");
71+
});
72+
73+
it("Infura and Alchemy build the right Optimism URL", () => {
74+
process.env.RPC_PROVIDER = "infura";
75+
process.env.RPC_API_KEY = "abc123";
76+
expect(resolveRpcUrl("optimism", null)).toBe(
77+
"https://optimism-mainnet.infura.io/v3/abc123"
78+
);
79+
80+
delete process.env.RPC_PROVIDER;
81+
delete process.env.RPC_API_KEY;
82+
const cfg: UserConfig = { rpc: { provider: "alchemy", apiKey: "k" } };
83+
expect(resolveRpcUrl("optimism", cfg)).toBe("https://opt-mainnet.g.alchemy.com/v2/k");
84+
});
85+
6486
it("custom provider uses per-chain URL from config", () => {
6587
const cfg: UserConfig = {
6688
rpc: {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
CHAIN_IDS,
4+
CHAIN_ID_TO_NAME,
5+
SUPPORTED_CHAINS,
6+
} from "../src/types/index.js";
7+
import { CONTRACTS, NATIVE_SYMBOL } from "../src/config/contracts.js";
8+
import { VIEM_CHAINS } from "../src/config/chains.js";
9+
10+
/**
11+
* Invariants for Optimism (chainId 10) — mirrors base-chain-support.test.ts.
12+
* Locks the chain registration so removing Optimism requires intentional
13+
* deletion of this suite rather than a silent refactor of one of the
14+
* Record<SupportedChain, …> tables.
15+
*/
16+
describe("Optimism chain registration", () => {
17+
it("is listed in SUPPORTED_CHAINS", () => {
18+
expect(SUPPORTED_CHAINS).toContain("optimism");
19+
});
20+
21+
it("maps to chainId 10 in both directions", () => {
22+
expect(CHAIN_IDS.optimism).toBe(10);
23+
expect(CHAIN_ID_TO_NAME[10]).toBe("optimism");
24+
});
25+
26+
it("uses ETH as its native symbol", () => {
27+
expect(NATIVE_SYMBOL.optimism).toBe("ETH");
28+
});
29+
30+
it("has a viem Chain registered with matching chainId", () => {
31+
expect(VIEM_CHAINS.optimism).toBeDefined();
32+
expect(VIEM_CHAINS.optimism.id).toBe(10);
33+
});
34+
});
35+
36+
describe("Optimism contract addresses", () => {
37+
const optimismContracts = CONTRACTS.optimism;
38+
39+
it("includes Aave V3 deployment triple (provider + uiPoolDataProvider + pool)", () => {
40+
expect(optimismContracts.aave.poolAddressProvider).toMatch(/^0x[a-fA-F0-9]{40}$/);
41+
expect(optimismContracts.aave.uiPoolDataProvider).toMatch(/^0x[a-fA-F0-9]{40}$/);
42+
expect(optimismContracts.aave.pool).toMatch(/^0x[a-fA-F0-9]{40}$/);
43+
});
44+
45+
it("pins the Aave V3 Pool to the canonical Optimism deployment", () => {
46+
// Used by pre-sign-check.ts as the allowlist entry for Aave txs on
47+
// Optimism. Hard-pin so a refactor can't silently swap it for an attacker
48+
// contract. Sourced from bgd-labs/aave-address-book AaveV3Optimism.sol.
49+
expect(optimismContracts.aave.pool).toBe("0x794a61358D6845594F94dc1DB02A252b5b4814aD");
50+
});
51+
52+
it("uses the canonical cross-chain Uniswap V3 deployment (not Base's chain-specific one)", () => {
53+
// Optimism follows the standard Uniswap V3 addresses shared across
54+
// Ethereum/Arbitrum/Polygon. Only Base diverged. Pin both factory and
55+
// SwapRouter02 so a copy-paste from the Base block fails this assertion.
56+
expect(optimismContracts.uniswap.factory).toBe(
57+
"0x1F98431c8aD98523631AE4a59f267346ea31F984"
58+
);
59+
expect(optimismContracts.uniswap.swapRouter02).toBe(
60+
"0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"
61+
);
62+
});
63+
64+
it("includes Compound V3 USDC + WETH + USDT markets", () => {
65+
expect(optimismContracts.compound.cUSDCv3).toBe(
66+
"0x2e44e174f7D53F0212823acC11C01A11d58c5bCB"
67+
);
68+
expect(optimismContracts.compound.cWETHv3).toBe(
69+
"0xE36A30D249f7761327fd973001A32010b521b6Fd"
70+
);
71+
expect(optimismContracts.compound.cUSDTv3).toBe(
72+
"0x995E394b8B2437aC8Ce61Ee0bC610D617962B214"
73+
);
74+
});
75+
76+
it("exposes the OP-stack predeploy WETH + native USDC + OP token", () => {
77+
expect(optimismContracts.tokens.WETH).toBe("0x4200000000000000000000000000000000000006");
78+
expect(optimismContracts.tokens.USDC).toBe("0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85");
79+
expect(optimismContracts.tokens.OP).toBe("0x4200000000000000000000000000000000000042");
80+
});
81+
82+
it("does NOT register Lido or EigenLayer (L1-only protocols)", () => {
83+
expect((optimismContracts as Record<string, unknown>).lido).toBeUndefined();
84+
expect((optimismContracts as Record<string, unknown>).eigenlayer).toBeUndefined();
85+
});
86+
87+
it("does NOT register Morpho Blue (deferred — discovery block unverified)", () => {
88+
// Morpho Blue is deployed on Optimism, but the discovery scan in
89+
// src/modules/morpho/discover.ts requires a known deployment block.
90+
// Same tripwire as Base.
91+
expect((optimismContracts as Record<string, unknown>).morpho).toBeUndefined();
92+
});
93+
});

0 commit comments

Comments
 (0)