diff --git a/.changeset/aave-v3-pool-markets.md b/.changeset/aave-v3-pool-markets.md new file mode 100644 index 0000000..d79d4d4 --- /dev/null +++ b/.changeset/aave-v3-pool-markets.md @@ -0,0 +1,13 @@ +--- +"@avaprotocol/protocols": minor +--- + +Ship AAVE V3 Pool coverage for writes (issue #23). + +- Add Arbitrum One (42161) and Optimism (10) to `Chains` and to the AAVE V3 `pool` / `oracle` / `poolAddressesProvider` / `uiPoolDataProvider` / `wethGateway` maps. Addresses from `@aave-dao/aave-address-book` (`AaveV3Arbitrum`, `AaveV3Optimism`). +- Add `aaveV3.markets` — every Pool on a chain, not just the canonical one. Ethereum lists Core / EtherFi / Lido / Horizon; every other covered chain is a single `core` row. Each row carries `pool` plus `poolAddressesProvider` (the on-chain `getPool()` escape hatch). `aaveV3.pool[chainId]` stays Core so existing consumers do not break. +- Document that the static Pool map is a deliberate cache, not an assumption of immutability. +- Regenerate `aaveV3.reserves` so Arbitrum and Optimism ship a Core reserve list (underlyings / aTokens / decimals). Without this a consumer can resolve a Pool and still get an empty supply-token picker. +- Guard the next regenerate: a test now asserts `reserves` covers the same chain set as `pool`. Bridged USDC.e on Arb/OP is labeled `USDC.e` (on-chain `symbol()` is still `"USDC"`) so a symbol lookup cannot silently resolve to the bridged token. + +Non-goal (unchanged): `aaveV3.reserves` stays Core-only. Non-Core Ethereum markets (EtherFi / Lido / Horizon) list different reserves; scoping those is a separate issue. diff --git a/README.md b/README.md index 29630d6..e7e189b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ const sig = Protocols.aaveV3.eventTopics.Borrow; | Protocol | Contracts | Chains | |---|---|---| -| **AAVE V3** | Pool, Oracle, WETH Gateway, PoolAddressesProvider, UiPoolDataProvider + Pool methods/events ABI (incl. config reads) + reserve/user config bit layouts + topics | Mainnet, Sepolia, Base, Base Sepolia, **BNB** (no WETH Gateway on BNB) | +| **AAVE V3** | Pool, Oracle, WETH Gateway, PoolAddressesProvider, UiPoolDataProvider + `markets` (Ethereum Core/EtherFi/Lido/Horizon) + Pool methods/events ABI (incl. config reads) + reserve/user config bit layouts + topics | Mainnet, Sepolia, **Optimism**, Base, Base Sepolia, **BNB**, **Arbitrum** (no WETH Gateway on BNB) | | **Aerodrome** | Router | Base | | **Chainlink** | ETH/USD + BTC/USD + BNB/USD feeds + AggregatorV3 ABI | Mainnet, Sepolia, **BNB** (BNB/USD is BNB-only) | | **Compound V3** | USDC Comet market | Mainnet, Base | @@ -109,7 +109,29 @@ const liqThresholdBps = Number(field(liquidationThreshold)); const tokenDecimals = Number(field(decimals)); ``` -For a whole-market sweep in one round-trip, use `Protocols.aaveV3.uiPoolDataProvider[chainId]` with `PoolAddressesProvider` — note the SDK ships the **addresses** but not the periphery return-struct ABI, which is version-specific per chain; pair it with a version-aware ABI (e.g. `@bgd-labs/aave-address-book`). `Protocols.aaveV3.poolMethodsAbi` carries the version-stable `getConfiguration` / `getUserConfiguration` / `getReserveData` reads for the per-asset path on any chain. +For a whole-market sweep in one round-trip, use `Protocols.aaveV3.uiPoolDataProvider[chainId]` with `PoolAddressesProvider` — note the SDK ships the **addresses** but not the periphery return-struct ABI, which is version-specific per chain; pair it with a version-aware ABI (e.g. `@aave-dao/aave-address-book`). `Protocols.aaveV3.poolMethodsAbi` carries the version-stable `getConfiguration` / `getUserConfiguration` / `getReserveData` reads for the per-asset path on any chain. + +### Pick an AAVE V3 Pool (including non-Core markets) + +`aaveV3.pool[chainId]` is the canonical / Core market so existing callers stay unchanged. Ethereum also hosts EtherFi, Lido, and Horizon — each with its own Pool. Enumerate every Pool on a chain via `aaveV3.markets`: + +```ts +import { Protocols, Chains } from "@avaprotocol/protocols"; + +const ethMarkets = Protocols.aaveV3.markets[Chains.EthereumMainnet] ?? []; +// core, etherFi, lido, horizon — each { key, pool, poolAddressesProvider } + +const lido = ethMarkets.find((m) => m.key === "lido"); +await wallet.contractWrite({ + contractAddress: lido!.pool, + contractAbi: Protocols.aaveV3.poolMethodsAbi, + methodCalls: [{ methodName: "supply", methodParams: [/* … */] }], +}); +``` + +The static Pool address is a cache of `PoolAddressesProvider.getPool()`. Implementation upgrades do not move the proxy, but `setPool()` theoretically can — `poolAddressesProvider` on the same market row is the on-chain escape hatch. + +`aaveV3.reserves[chainId]` is the Core market's supply-token list. Symbols are unique per chain. On Arbitrum and Optimism the bridged token is labeled `USDC.e` (native Circle USDC stays `USDC`) even though both contracts report `"USDC"` on-chain — otherwise `find(r => r.symbol === "USDC")` would silently return the bridged token. Prefer `underlying` when you need the contract identity. > **Caveat — `liquidationThreshold` alone doesn't fully determine collateral.** The exported bits cover only the version-stable low bits (0–167); isolation-mode / eMode live in the omitted higher bits. A freshly-supplied **isolation-mode** asset may not count as collateral at all, and eMode raises the effective threshold for correlated assets — so a top-up solver sizing a health-factor lift off `liquidationThreshold` alone can over- or under-estimate it in those cases. Also read the user's collateral flags (`getUserConfiguration` + `userConfigurationBits`) and, where isolation/eMode is in play, the market's own contracts for those bits. @@ -136,8 +158,8 @@ For consumers that need to *recognize* an address rather than look it up by name import { Protocols, Chains } from "@avaprotocol/protocols"; function isAaveV3Pool(chainId: number, address: string): boolean { - const expected = Protocols.aaveV3.pool[chainId]; - return !!expected && expected.toLowerCase() === address.toLowerCase(); + const listed = Protocols.aaveV3.markets[chainId] ?? []; + return listed.some((m) => m.pool.toLowerCase() === address.toLowerCase()); } ``` diff --git a/scripts/generate-aave-reserves.ts b/scripts/generate-aave-reserves.ts index 5a34edf..ddf83a7 100644 --- a/scripts/generate-aave-reserves.ts +++ b/scripts/generate-aave-reserves.ts @@ -28,8 +28,10 @@ import { aaveV3 } from "../src/protocols/aave-v3"; // private endpoint via env if a public one rate-limits during a refresh. const RPC_URLS: Readonly> = Object.freeze({ [Chains.EthereumMainnet]: process.env.RPC_MAINNET ?? "https://ethereum-rpc.publicnode.com", + [Chains.OptimismMainnet]: process.env.RPC_OPTIMISM ?? "https://optimism-rpc.publicnode.com", [Chains.BaseMainnet]: process.env.RPC_BASE ?? "https://base-rpc.publicnode.com", [Chains.BnbMainnet]: process.env.RPC_BNB ?? "https://bsc-rpc.publicnode.com", + [Chains.ArbitrumOne]: process.env.RPC_ARBITRUM ?? "https://arbitrum-one-rpc.publicnode.com", [Chains.Sepolia]: process.env.RPC_SEPOLIA ?? "https://ethereum-sepolia-rpc.publicnode.com", [Chains.BaseSepolia]: process.env.RPC_BASE_SEPOLIA ?? "https://base-sepolia-rpc.publicnode.com", }); @@ -37,8 +39,10 @@ const RPC_URLS: Readonly> = Object.freeze({ // Emit `Chains.X` keys (not magic numbers) to match the catalog's convention. const CHAIN_CONST_NAME: Readonly> = Object.freeze({ [Chains.EthereumMainnet]: "EthereumMainnet", + [Chains.OptimismMainnet]: "OptimismMainnet", [Chains.BaseMainnet]: "BaseMainnet", [Chains.BnbMainnet]: "BnbMainnet", + [Chains.ArbitrumOne]: "ArbitrumOne", [Chains.Sepolia]: "Sepolia", [Chains.BaseSepolia]: "BaseSepolia", }); @@ -59,6 +63,26 @@ const POOL_ABI = [ const ERC20_STRING_ABI = ["function symbol() view returns (string)", "function decimals() view returns (uint8)"]; const ERC20_BYTES32_ABI = ["function symbol() view returns (bytes32)"]; +/** + * Bridged USDC.e — on-chain `symbol()` is still `"USDC"`, same as native + * Circle USDC. Rename so a symbol lookup (`find(r => r.symbol === "USDC")`) + * cannot silently resolve to the bridged token on chains that list both. + * Addresses are the well-known Circle USDC.e deployments and do not move. + */ +const BRIDGED_USDC_E = new Set( + [ + "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", // Arbitrum One + "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", // Optimism + ].map((address) => address.toLowerCase()), +); + +function disambiguateSymbol(row: ReserveRow): ReserveRow { + if (row.symbol === "USDC" && BRIDGED_USDC_E.has(row.underlying.toLowerCase())) { + return { ...row, symbol: "USDC.e" }; + } + return row; +} + interface ReserveRow { symbol: string; underlying: string; @@ -127,6 +151,7 @@ async function fetchChainReserves(chainId: number, poolAddress: string): Promise return rows .filter((row): row is ReserveRow => row !== null) + .map(disambiguateSymbol) .sort((a, b) => a.symbol.localeCompare(b.symbol)); } diff --git a/src/chains.ts b/src/chains.ts index fea0188..3cdbb05 100644 --- a/src/chains.ts +++ b/src/chains.ts @@ -11,9 +11,11 @@ export const Chains = Object.freeze({ EthereumMainnet: 1 as const, Sepolia: 11_155_111 as const, Holesky: 17_000 as const, + OptimismMainnet: 10 as const, BaseMainnet: 8453 as const, BaseSepolia: 84_532 as const, BnbMainnet: 56 as const, + ArbitrumOne: 42_161 as const, }); export type ChainId = (typeof Chains)[keyof typeof Chains] | number; diff --git a/src/protocols/aave-v3-reserves.ts b/src/protocols/aave-v3-reserves.ts index 8fe51bb..fff0d58 100644 --- a/src/protocols/aave-v3-reserves.ts +++ b/src/protocols/aave-v3-reserves.ts @@ -38,6 +38,7 @@ export const aaveV3Reserves: AaveV3ReservesByChain = { { symbol: "osETH", underlying: "0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38", aToken: "0x927709711794F3De5DdBF1D176bEE2D55Ba13c21", variableDebtToken: "0x8838eefF2af391863E1Bb8b1dF563F86743a8470", decimals: 18 }, { symbol: "PT-eUSDE-14AUG2025", underlying: "0x14Bdc3A3AE09f5518b923b69489CBcAfB238e617", aToken: "0x2eDff5AF94334fBd7C38ae318edf1c40e072b73B", variableDebtToken: "0x22517fE16DEd08e52E7EA3423A2EA4995b1f1731", decimals: 18 }, { symbol: "PT-eUSDE-29MAY2025", underlying: "0x50D2C7992b802Eef16c04FeADAB310f31866a545", aToken: "0x4B0821e768Ed9039a70eD1E80E15E76a5bE5Df5F", variableDebtToken: "0x3c20fbFD32243Dd9899301C84bCe17413EeE0A0C", decimals: 18 }, + { symbol: "PT-srUSDe-22OCT2026", underlying: "0x59bC9FaE5D62B19d4f8d07D758047aCb9EE19d34", aToken: "0x01E69a58ca3ddc695820ce6f66FBdf3fA55D0545", variableDebtToken: "0x4Af166c6F2CF882f7B196E3625c3E8f2Fe2a8083", decimals: 18 }, { symbol: "PT-srUSDe-25JUN2026", underlying: "0x619D75E3b790eBC21c289f2805Bb7177A7D732E2", aToken: "0xA78Eba24D89e70A03ddB7AC195A15E53315aA5b4", variableDebtToken: "0x9Ba896531F878Ed5f0A4F73E82d63bA7b424F910", decimals: 18 }, { symbol: "PT-srUSDe-2APR2026", underlying: "0x9Bf45ab47747F4B4dD09B3C2c73953484b4eB375", aToken: "0x1241ec22C9BdF16BA1Eb636F2a8de7e28A4343Cf", variableDebtToken: "0xacD3d3FaCeA0424984F662827B988f4581a3cE31", decimals: 18 }, { symbol: "PT-sUSDE-25SEP2025", underlying: "0x9F56094C450763769BA0EA9Fe2876070c0fD5F77", aToken: "0x5f4a0873a3A02f7C0CB0e13a1d4362a1AD90e751", variableDebtToken: "0xc9AD8Dd111e6384128146467aAf92B81EC422848", decimals: 18 }, @@ -76,6 +77,22 @@ export const aaveV3Reserves: AaveV3ReservesByChain = { { symbol: "wstETH", underlying: "0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0", aToken: "0x0B925eD163218f6662a35e0f0371Ac234f9E9371", variableDebtToken: "0xC96113eED8cAB59cD8A66813bCB0cEb29F06D2e4", decimals: 18 }, { symbol: "XAUt", underlying: "0x68749665FF8D2d112Fa859AA293F07A622782F38", aToken: "0x8A2b6f94Ff3A89a03E8c02Ee92b55aF90c9454A2", variableDebtToken: "0xa665bB258D2a732C170dFD505924214c0b1AC74F", decimals: 6 }, ], + [Chains.OptimismMainnet]: [ + { symbol: "AAVE", underlying: "0x76FB31fb4af56892A25e32cFC43De717950c9278", aToken: "0xf329e36C7bF6E5E86ce2150875a84Ce77f477375", variableDebtToken: "0xE80761Ea617F66F96274eA5e8c37f03960ecC679", decimals: 18 }, + { symbol: "DAI", underlying: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", aToken: "0x82E64f49Ed5EC1bC6e43DAD4FC8Af9bb3A2312EE", variableDebtToken: "0x8619d80FB0141ba7F184CbF22fd724116D9f7ffC", decimals: 18 }, + { symbol: "LINK", underlying: "0x350a791Bfc2C21F9Ed5d10980Dad2e2638ffa7f6", aToken: "0x191c10Aa4AF7C30e871E70C95dB0E4eb77237530", variableDebtToken: "0x953A573793604aF8d41F306FEb8274190dB4aE0e", decimals: 18 }, + { symbol: "LUSD", underlying: "0xc40F949F8a4e094D1b49a23ea9241D289B7b2819", aToken: "0x8Eb270e296023E9D92081fdF967dDd7878724424", variableDebtToken: "0xCE186F6Cccb0c955445bb9d10C59caE488Fea559", decimals: 18 }, + { symbol: "MAI", underlying: "0xdFA46478F9e5EA86d57387849598dbFB2e964b02", aToken: "0x8ffDf2DE812095b1D19CB146E4c004587C0A0692", variableDebtToken: "0xA8669021776Bc142DfcA87c21b4A52595bCbB40a", decimals: 18 }, + { symbol: "OP", underlying: "0x4200000000000000000000000000000000000042", aToken: "0x513c7E3a9c69cA3e22550eF58AC1C0088e918FFf", variableDebtToken: "0x77CA01483f379E58174739308945f044e1a764dc", decimals: 18 }, + { symbol: "rETH", underlying: "0x9Bcef72be871e61ED4fBbc7630889beE758eb81D", aToken: "0x724dc807b04555b71ed48a6896b6F41593b8C637", variableDebtToken: "0xf611aEb5013fD2c0511c9CD55c7dc5C1140741A6", decimals: 18 }, + { symbol: "sUSD", underlying: "0x8c6f28f2F1A3C87F0f938b96d27520d9751ec8d9", aToken: "0x6d80113e533a2C0fe82EaBD35f1875DcEA89Ea97", variableDebtToken: "0x4a1c3aD6Ed28a636ee1751C69071f6be75DEb8B8", decimals: 18 }, + { symbol: "USDC", underlying: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", aToken: "0x38d693cE1dF5AaDF7bC62595A37D667aD57922e5", variableDebtToken: "0x5D557B07776D12967914379C71a1310e917C7555", decimals: 6 }, + { symbol: "USDC.e", underlying: "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", aToken: "0x625E7708f30cA75bfd92586e17077590C60eb4cD", variableDebtToken: "0xFCCf3cAbbe80101232d343252614b6A3eE81C989", decimals: 6 }, + { symbol: "USDT", underlying: "0x94b008aA00579c1307B0EF2c499aD98a8ce58e58", aToken: "0x6ab707Aca953eDAeFBc4fD23bA73294241490620", variableDebtToken: "0xfb00AC187a8Eb5AFAE4eACE434F493Eb62672df7", decimals: 6 }, + { symbol: "WBTC", underlying: "0x68f180fcCe6836688e9084f035309E29Bf0A2095", aToken: "0x078f358208685046a11C85e8ad32895DED33A249", variableDebtToken: "0x92b42c66840C7AD907b4BF74879FF3eF7c529473", decimals: 8 }, + { symbol: "WETH", underlying: "0x4200000000000000000000000000000000000006", aToken: "0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8", variableDebtToken: "0x0c84331e39d6658Cd6e6b9ba04736cC4c4734351", decimals: 18 }, + { symbol: "wstETH", underlying: "0x1F32b1c2345538c0c6f582fCB022739c4A194Ebb", aToken: "0xc45A479877e1e9Dfe9FcD4056c699575a1045dAA", variableDebtToken: "0x34e2eD44EF7466D5f9E0b782B5c08b57475e7907", decimals: 18 }, + ], [Chains.BnbMainnet]: [ { symbol: "BTCB", underlying: "0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c", aToken: "0x56a7ddc4e848EbF43845854205ad71D5D5F72d3D", variableDebtToken: "0x7b1E82F4f542fbB25D64c5523Fe3e44aBe4F2702", decimals: 18 }, { symbol: "Cake", underlying: "0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82", aToken: "0x4199CC1F5ed0d796563d7CcB2e036253E2C18281", variableDebtToken: "0xE20dBC7119c635B1B51462f844861258770e0699", decimals: 18 }, @@ -103,6 +120,28 @@ export const aaveV3Reserves: AaveV3ReservesByChain = { { symbol: "wrsETH", underlying: "0xEDfa23602D0EC14714057867A78d01e94176BEA0", aToken: "0x80a94C36747CF51b2FbabDfF045f6D22c1930eD1", variableDebtToken: "0xe9541C77a111bCAa5dF56839bbC50894eba7aFcb", decimals: 18 }, { symbol: "wstETH", underlying: "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452", aToken: "0x99CBC45ea5bb7eF3a5BC08FB1B7E56bB2442Ef0D", variableDebtToken: "0x41A7C3f5904ad176dACbb1D99101F59ef0811DC1", decimals: 18 }, ], + [Chains.ArbitrumOne]: [ + { symbol: "AAVE", underlying: "0xba5DdD1f9d7F570dc94a51479a000E3BCE967196", aToken: "0xf329e36C7bF6E5E86ce2150875a84Ce77f477375", variableDebtToken: "0xE80761Ea617F66F96274eA5e8c37f03960ecC679", decimals: 18 }, + { symbol: "ARB", underlying: "0x912CE59144191C1204E64559FE8253a0e49E6548", aToken: "0x6533afac2E7BCCB20dca161449A13A32D391fb00", variableDebtToken: "0x44705f578135cC5d703b4c9c122528C73Eb87145", decimals: 18 }, + { symbol: "DAI", underlying: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", aToken: "0x82E64f49Ed5EC1bC6e43DAD4FC8Af9bb3A2312EE", variableDebtToken: "0x8619d80FB0141ba7F184CbF22fd724116D9f7ffC", decimals: 18 }, + { symbol: "EURS", underlying: "0xD22a58f79e9481D1a88e00c343885A588b34b68B", aToken: "0x6d80113e533a2C0fe82EaBD35f1875DcEA89Ea97", variableDebtToken: "0x4a1c3aD6Ed28a636ee1751C69071f6be75DEb8B8", decimals: 2 }, + { symbol: "ezETH", underlying: "0x2416092f143378750bb29b79eD961ab195CcEea5", aToken: "0xEA1132120ddcDDA2F119e99Fa7A27a0d036F7Ac9", variableDebtToken: "0x1fFD28689DA7d0148ff0fCB669e9f9f0Fc13a219", decimals: 18 }, + { symbol: "FRAX", underlying: "0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F", aToken: "0x38d693cE1dF5AaDF7bC62595A37D667aD57922e5", variableDebtToken: "0x5D557B07776D12967914379C71a1310e917C7555", decimals: 18 }, + { symbol: "GHO", underlying: "0x7dfF72693f6A4149b17e7C6314655f6A9F7c8B33", aToken: "0xeBe517846d0F36eCEd99C735cbF6131e1fEB775D", variableDebtToken: "0x18248226C16BF76c032817854E7C83a2113B4f06", decimals: 18 }, + { symbol: "LINK", underlying: "0xf97f4df75117a78c1A5a0DBb814Af92458539FB4", aToken: "0x191c10Aa4AF7C30e871E70C95dB0E4eb77237530", variableDebtToken: "0x953A573793604aF8d41F306FEb8274190dB4aE0e", decimals: 18 }, + { symbol: "LUSD", underlying: "0x93b346b6BC2548dA6A1E7d98E9a421B42541425b", aToken: "0x8ffDf2DE812095b1D19CB146E4c004587C0A0692", variableDebtToken: "0xA8669021776Bc142DfcA87c21b4A52595bCbB40a", decimals: 18 }, + { symbol: "MAI", underlying: "0x3F56e0c36d275367b8C502090EDF38289b3dEa0d", aToken: "0xc45A479877e1e9Dfe9FcD4056c699575a1045dAA", variableDebtToken: "0x34e2eD44EF7466D5f9E0b782B5c08b57475e7907", decimals: 18 }, + { symbol: "rETH", underlying: "0xEC70Dcb4A1EFa46b8F2D97C310C9c4790ba5ffA8", aToken: "0x8Eb270e296023E9D92081fdF967dDd7878724424", variableDebtToken: "0xCE186F6Cccb0c955445bb9d10C59caE488Fea559", decimals: 18 }, + { symbol: "rsETH", underlying: "0x4186BFC76E2E237523CBC30FD220FE055156b41F", aToken: "0x6b030Ff3FB9956B1B69f475B77aE0d3Cf2CC5aFa", variableDebtToken: "0x80cA0d8C38d2e2BcbaB66aA1648Bd1C7160500FE", decimals: 18 }, + { symbol: "tBTC", underlying: "0x6c84a8f1c29108F47a79964b5Fe888D4f4D0dE40", aToken: "0x62fC96b27a510cF4977B59FF952Dc32378Cc221d", variableDebtToken: "0xB5b46F918C2923fC7f26DB76e8a6A6e9C4347Cf9", decimals: 18 }, + { symbol: "USD₮0", underlying: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", aToken: "0x6ab707Aca953eDAeFBc4fD23bA73294241490620", variableDebtToken: "0xfb00AC187a8Eb5AFAE4eACE434F493Eb62672df7", decimals: 6 }, + { symbol: "USDC", underlying: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", aToken: "0x724dc807b04555b71ed48a6896b6F41593b8C637", variableDebtToken: "0xf611aEb5013fD2c0511c9CD55c7dc5C1140741A6", decimals: 6 }, + { symbol: "USDC.e", underlying: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", aToken: "0x625E7708f30cA75bfd92586e17077590C60eb4cD", variableDebtToken: "0xFCCf3cAbbe80101232d343252614b6A3eE81C989", decimals: 6 }, + { symbol: "WBTC", underlying: "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", aToken: "0x078f358208685046a11C85e8ad32895DED33A249", variableDebtToken: "0x92b42c66840C7AD907b4BF74879FF3eF7c529473", decimals: 8 }, + { symbol: "weETH", underlying: "0x35751007a407ca6FEFfE80b3cB397736D2cf4dbe", aToken: "0x8437d7C167dFB82ED4Cb79CD44B7a32A1dd95c77", variableDebtToken: "0x3ca5FA07689F266e907439aFd1fBB59c44fe12f6", decimals: 18 }, + { symbol: "WETH", underlying: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", aToken: "0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8", variableDebtToken: "0x0c84331e39d6658Cd6e6b9ba04736cC4c4734351", decimals: 18 }, + { symbol: "wstETH", underlying: "0x5979D7b546E38E414F7E9822514be443A4800529", aToken: "0x513c7E3a9c69cA3e22550eF58AC1C0088e918FFf", variableDebtToken: "0x77CA01483f379E58174739308945f044e1a764dc", decimals: 18 }, + ], [Chains.BaseSepolia]: [ { symbol: "cbETH", underlying: "0xD171b9694f7A2597Ed006D41f7509aaD4B485c4B", aToken: "0x9Fd6d1DBAd7c052e0c43f46df36eEc6a68814B63", variableDebtToken: "0xa1a483652b157FF006292CDb0e9EB7FFad2a5142", decimals: 18 }, { symbol: "LINK", underlying: "0x810D46F9a9027E28F9B01F75E2bdde839dA61115", aToken: "0x0aD46dE765522399d7b25B438b230A894d72272B", variableDebtToken: "0xBA42C6752F347e3c22DD0A4e5578dCB0137C1325", decimals: 18 }, diff --git a/src/protocols/aave-v3.ts b/src/protocols/aave-v3.ts index c6d18a8..006c2e7 100644 --- a/src/protocols/aave-v3.ts +++ b/src/protocols/aave-v3.ts @@ -1,5 +1,6 @@ // AAVE V3 — Pool / Oracle / WETH Gateway / PoolAddressesProvider / -// UiPoolDataProvider addresses per supported chain, the Pool ABI +// UiPoolDataProvider addresses per supported chain, the per-chain +// `markets` list (Ethereum hosts four distinct Pools), the Pool ABI // fragments (events + the read/write methods callers actually touch, // including the per-reserve / per-user config reads), the // ReserveConfigurationMap / UserConfigurationMap bit layouts, and @@ -8,27 +9,44 @@ // Pool is the single entry point templates target (supply, borrow, // repay, withdraw, getUserAccountData, setUserUseReserveAsCollateral) // and the contract whose events templates filter on (`Supply`, -// `Borrow`, etc.). +// `Borrow`, etc.). `aaveV3.pool[chainId]` is the canonical / Core +// market; `aaveV3.markets[chainId]` enumerates every Pool on that +// chain (EtherFi / Lido / Horizon on Ethereum). // // Address source: AAVE V3 deployment registries -// (https://github.com/bgd-labs/aave-address-book). When AAVE -// redeploys on a new chain, mirror the canonical address book. +// (https://github.com/aave-dao/aave-address-book). When AAVE +// redeploys on a new chain or adds a market, mirror the canonical +// address book. import { Chains } from "../chains"; -import { type AbiFragment, type AddressByChain } from "./types"; +import { type AbiFragment, type AddressByChain, type AaveV3Market } from "./types"; import { aaveV3Reserves } from "./aave-v3-reserves"; /** - * AAVE V3 Pool addresses per chain. The Pool is the single entry point - * for supply / borrow / repay / withdraw / liquidationCall and is the - * contract whose events templates filter on (`Supply`, `Borrow`, etc.). + * AAVE V3 Pool addresses per chain — the **canonical / Core** market + * only. The Pool is the single entry point for supply / borrow / repay + * / withdraw / liquidationCall and is the contract whose events + * templates filter on (`Supply`, `Borrow`, etc.). + * + * Ethereum also hosts EtherFi / Lido / Horizon markets with their own + * Pools; those live on `markets[1]`, not here. `pool[chainId]` is + * kept as the Core address so existing consumers do not break. + * + * This map is a deliberate cache of `PoolAddressesProvider.getPool()`, + * not an assumption that the address is immutable. The Pool is a + * proxy, so implementation upgrades do not move it — but + * `PoolAddressesProvider.setPool()` can. If a Pool address ever + * drifts, `poolAddressesProvider` (and the matching field on each + * `markets[]` row) is the on-chain escape hatch. */ const pool: AddressByChain = { [Chains.EthereumMainnet]: "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", [Chains.Sepolia]: "0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951", + [Chains.OptimismMainnet]: "0x794a61358D6845594F94dc1DB02A252b5b4814aD", [Chains.BaseMainnet]: "0xA238Dd80C259a72e81d7e4664a9801593F98d1c5", [Chains.BaseSepolia]: "0x8bAB6d1b75f19e9eD9fCe8b9BD338844fF79aE27", [Chains.BnbMainnet]: "0x6807dc923806fE8Fd134338EABCA509979a7e0cB", + [Chains.ArbitrumOne]: "0x794a61358D6845594F94dc1DB02A252b5b4814aD", }; /** @@ -39,9 +57,11 @@ const pool: AddressByChain = { const oracle: AddressByChain = { [Chains.EthereumMainnet]: "0x54586bE62E3c3580375aE3723C145253060Ca0C2", [Chains.Sepolia]: "0x2da88497588bf89281816106C7259e31AF45a663", + [Chains.OptimismMainnet]: "0xD81eb3728a631871a7eBBaD631b5f424909f0c77", [Chains.BaseMainnet]: "0x2Cc0Fc26eD4563A5ce5e8bdcfe1A2878676Ae156", [Chains.BaseSepolia]: "0x943b0dE18d4abf4eF02A85912F8fc07684C141dF", [Chains.BnbMainnet]: "0x39bc1bfDa2130d6Bb6DBEfd366939b4c7aa7C697", + [Chains.ArbitrumOne]: "0xb56c2F0B653B2e0b10C9b928C8580Ac5Df02C7C7", }; /** @@ -56,8 +76,10 @@ const oracle: AddressByChain = { const wethGateway: AddressByChain = { [Chains.EthereumMainnet]: "0xd01607c3C5eCABa394D8be377a08590149325722", [Chains.Sepolia]: "0x387d311e47e80b498169e6fb51d3193167d89F7D", + [Chains.OptimismMainnet]: "0x5f2508cAE9923b02316254026CD43d7902866725", [Chains.BaseMainnet]: "0xa0d9C1E9E48Ca30c8d8C3B5D69FF5dc1f6DFfC24", [Chains.BaseSepolia]: "0x0568130e794429D2eEBC4dafE18f25Ff1a1ed8b6", + [Chains.ArbitrumOne]: "0x5283BEcEd7ADF6D003225C13896E536f2D4264FF", }; /** @@ -71,9 +93,11 @@ const wethGateway: AddressByChain = { const poolAddressesProvider: AddressByChain = { [Chains.EthereumMainnet]: "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e", [Chains.Sepolia]: "0x012bAC54348C0E635dCAc9D5FB99f06F24136C9A", + [Chains.OptimismMainnet]: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", [Chains.BaseMainnet]: "0xe20fCBdBfFC4Dd138cE8b2E6FBb6CB49777ad64D", [Chains.BaseSepolia]: "0xE4C23309117Aa30342BFaae6c95c6478e0A4Ad00", [Chains.BnbMainnet]: "0xff75B6da14FfbbfD355Daf7a2731456b3562Ba6D", + [Chains.ArbitrumOne]: "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", }; /** @@ -100,9 +124,11 @@ const poolAddressesProvider: AddressByChain = { const uiPoolDataProvider: AddressByChain = { [Chains.EthereumMainnet]: "0x2dAd8162A989cd99D673dE4425Bb2298Db1E1aA2", [Chains.Sepolia]: "0x69529987FA4A075D0C00B0128fa848dc9ebbE9CE", + [Chains.OptimismMainnet]: "0x68100bD5345eA474D93577127C11F39FF8463e93", [Chains.BaseMainnet]: "0x0C6BC4a12039788be08F87e87Cff87FEDbd1D386", [Chains.BaseSepolia]: "0x3cB7B00B6C09B71998124196691e8bF2694De863", [Chains.BnbMainnet]: "0x68100bD5345eA474D93577127C11F39FF8463e93", + [Chains.ArbitrumOne]: "0x91E04cf78e53aEBe609e8a7f2003e7EECD743F2B", }; /** @@ -447,15 +473,73 @@ const tokens = Object.freeze({ * `underlying` is what users supply; `aToken` is the receipt they get). * Generated from chain — see `scripts/generate-aave-reserves.ts` / * `aave-v3-reserves.ts`. + * + * Core market only, including Arbitrum and Optimism. Non-Core Ethereum + * markets (EtherFi / Lido / Horizon) list different reserves; scoping + * those is a separate issue. */ const reserves = aaveV3Reserves; +/** + * Core row for `markets[chainId]`. Reads the existing per-chain maps + * so the three stay in lockstep. Missing entries stay `undefined` + * rather than throwing at import — a catalog inconsistency is a test + * failure (`core.pool === pool[chain]`), not a package-load crash + * for every consumer. + */ +function coreMarket(chainId: number): AaveV3Market { + return Object.freeze({ + key: "core", + pool: pool[chainId] as `0x${string}`, + poolAddressesProvider: poolAddressesProvider[chainId] as `0x${string}`, + }); +} + +/** + * Every AAVE V3 market on each covered chain. `pool[chainId]` / + * `poolAddressesProvider[chainId]` always match the `core` row. + * Ethereum lists four markets (Core, EtherFi, Lido, Horizon); every + * other chain is a single `core` market. + * + * Address source: `@aave-dao/aave-address-book` modules + * `AaveV3Ethereum`, `AaveV3EthereumEtherFi`, `AaveV3EthereumLido`, + * `AaveV3EthereumHorizon`, `AaveV3Arbitrum`, `AaveV3Optimism`, + * `AaveV3Base`, `AaveV3BNB`, `AaveV3Sepolia`, `AaveV3BaseSepolia`. + */ +const markets = Object.freeze({ + [Chains.EthereumMainnet]: Object.freeze([ + coreMarket(Chains.EthereumMainnet), + Object.freeze({ + key: "etherFi", + pool: "0x0AA97c284e98396202b6A04024F5E2c65026F3c0", + poolAddressesProvider: "0xeBa440B438Ad808101d1c451C1C5322c90BEFCdA", + } satisfies AaveV3Market), + Object.freeze({ + key: "lido", + pool: "0x4e033931ad43597d96D6bcc25c280717730B58B1", + poolAddressesProvider: "0xcfBf336fe147D643B9Cb705648500e101504B16d", + } satisfies AaveV3Market), + Object.freeze({ + key: "horizon", + pool: "0xAe05Cd22df81871bc7cC2a04BeCfb516bFe332C8", + poolAddressesProvider: "0x5D39E06b825C1F2B80bf2756a73e28eFAA128ba0", + } satisfies AaveV3Market), + ]), + [Chains.Sepolia]: Object.freeze([coreMarket(Chains.Sepolia)]), + [Chains.OptimismMainnet]: Object.freeze([coreMarket(Chains.OptimismMainnet)]), + [Chains.BaseMainnet]: Object.freeze([coreMarket(Chains.BaseMainnet)]), + [Chains.BaseSepolia]: Object.freeze([coreMarket(Chains.BaseSepolia)]), + [Chains.BnbMainnet]: Object.freeze([coreMarket(Chains.BnbMainnet)]), + [Chains.ArbitrumOne]: Object.freeze([coreMarket(Chains.ArbitrumOne)]), +}); + export const aaveV3 = Object.freeze({ pool, oracle, wethGateway, poolAddressesProvider, uiPoolDataProvider, + markets, eventTopics, poolEventsAbi, poolMethodsAbi, diff --git a/src/protocols/index.ts b/src/protocols/index.ts index d4d4f52..a9c9250 100644 --- a/src/protocols/index.ts +++ b/src/protocols/index.ts @@ -32,7 +32,15 @@ export { superfluid } from "./superfluid"; export { uniswapV3 } from "./uniswap-v3"; export { wrapped } from "./wrapped"; export { aggregatorV3Abi, erc4626VaultAbi } from "./common"; -export type { AbiFragment, AddressByChain, AaveV3Reserve, AaveV3ReservesByChain } from "./types"; +export type { + AbiFragment, + AddressByChain, + AaveV3Reserve, + AaveV3ReservesByChain, + AaveV3MarketKey, + AaveV3Market, + AaveV3MarketsByChain, +} from "./types"; import { aaveV3 } from "./aave-v3"; import { aerodrome } from "./aerodrome"; diff --git a/src/protocols/types.ts b/src/protocols/types.ts index 7484ed8..4f29abe 100644 --- a/src/protocols/types.ts +++ b/src/protocols/types.ts @@ -38,6 +38,13 @@ export type AddressByChain = Partial>; * (Pool.getReservesList + Pool.getReserveData + ERC-20 metadata). */ export interface AaveV3Reserve { + /** + * Display / lookup symbol. Usually the on-chain ERC-20 `symbol()`, + * except when two reserves on the same chain collide (bridged USDC.e + * vs native Circle USDC both report `"USDC"`) — the generator then + * disambiguates so a symbol lookup cannot silently pick the bridged + * token. Prefer `underlying` when you need the contract identity. + */ readonly symbol: string; /** The token the user supplies/borrows (ERC-20 underlying). */ readonly underlying: `0x${string}`; @@ -54,3 +61,34 @@ export interface AaveV3Reserve { * and the list is ordered by symbol for readable diffs. */ export type AaveV3ReservesByChain = Partial>; + +/** + * Named AAVE V3 market on a chain. Ethereum hosts four (`core` / + * `etherFi` / `lido` / `horizon`); every other covered chain is a + * single `core` market. `aaveV3.pool[chainId]` always points at `core`. + * + * Closed on purpose: Aave adds markets (Horizon is recent). A new key + * must be a type change so exhaustive consumers fail to compile + * instead of silently ignoring the row. Do not widen this to `string`. + */ +export type AaveV3MarketKey = "core" | "etherFi" | "lido" | "horizon"; + +/** + * One AAVE V3 market: the Pool to write against, plus the + * PoolAddressesProvider that is the on-chain source of truth for that + * Pool (`getPool()`). The static `pool` address is a cache of that + * call — implementation upgrades do not move the proxy, but + * `PoolAddressesProvider.setPool()` theoretically can. + */ +export interface AaveV3Market { + readonly key: AaveV3MarketKey; + readonly pool: `0x${string}`; + readonly poolAddressesProvider: `0x${string}`; +} + +/** + * Per-chain list of every AAVE V3 market. `Partial` — same coverage as + * `aaveV3.pool`. Ethereum lists four entries; other chains list one + * (`core`). + */ +export type AaveV3MarketsByChain = Partial>; diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index 03a79c8..868f4d2 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -86,13 +86,21 @@ describe("Event topic shape", () => { }); }); +const AAVE_V3_CHAINS = [ + Chains.EthereumMainnet, + Chains.Sepolia, + Chains.OptimismMainnet, + Chains.BaseMainnet, + Chains.BaseSepolia, + Chains.BnbMainnet, + Chains.ArbitrumOne, +] as const; + describe("AAVE V3 catalog", () => { it("has Pool addresses on every covered chain", () => { - expect(Protocols.aaveV3.pool[Chains.EthereumMainnet]).toMatch(ADDRESS_RE); - expect(Protocols.aaveV3.pool[Chains.Sepolia]).toMatch(ADDRESS_RE); - expect(Protocols.aaveV3.pool[Chains.BaseMainnet]).toMatch(ADDRESS_RE); - expect(Protocols.aaveV3.pool[Chains.BaseSepolia]).toMatch(ADDRESS_RE); - expect(Protocols.aaveV3.pool[Chains.BnbMainnet]).toMatch(ADDRESS_RE); + for (const chain of AAVE_V3_CHAINS) { + expect(Protocols.aaveV3.pool[chain]).toMatch(ADDRESS_RE); + } }); it("ships the Pool method ABI with getUserAccountData + supply", () => { @@ -113,18 +121,73 @@ describe("AAVE V3 catalog", () => { }); it("has PoolAddressesProvider + UiPoolDataProvider on every covered chain", () => { - for (const chain of [ - Chains.EthereumMainnet, - Chains.Sepolia, - Chains.BaseMainnet, - Chains.BaseSepolia, - Chains.BnbMainnet, - ]) { + for (const chain of AAVE_V3_CHAINS) { expect(Protocols.aaveV3.poolAddressesProvider[chain]).toMatch(ADDRESS_RE); expect(Protocols.aaveV3.uiPoolDataProvider[chain]).toMatch(ADDRESS_RE); } }); + it("enumerates every Pool on a chain via markets, with core matching pool[]", () => { + for (const chain of AAVE_V3_CHAINS) { + const listed = Protocols.aaveV3.markets[chain] ?? []; + expect(listed.length).toBeGreaterThan(0); + const keys = listed.map((m) => m.key); + expect(new Set(keys).size).toBe(keys.length); + expect(keys[0]).toBe("core"); + + const core = listed[0]; + expect(core.pool).toBe(Protocols.aaveV3.pool[chain]); + expect(core.poolAddressesProvider).toBe(Protocols.aaveV3.poolAddressesProvider[chain]); + + for (const market of listed) { + expect(market.pool).toMatch(ADDRESS_RE); + expect(market.poolAddressesProvider).toMatch(ADDRESS_RE); + } + } + + expect(Object.keys(Protocols.aaveV3.markets).sort()).toEqual( + Object.keys(Protocols.aaveV3.pool).sort(), + ); + }); + + it("lists Ethereum Core + EtherFi + Lido + Horizon markets", () => { + const eth = Protocols.aaveV3.markets[Chains.EthereumMainnet] ?? []; + expect(eth.map((m) => m.key)).toEqual(["core", "etherFi", "lido", "horizon"]); + // Official address-book Pools — independently confirmed, not inherited + // from Studio's seed. Core stays equal to the existing pool[1]. + expect(eth[0]?.pool).toBe("0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"); + expect(eth[1]?.pool).toBe("0x0AA97c284e98396202b6A04024F5E2c65026F3c0"); + expect(eth[2]?.pool).toBe("0x4e033931ad43597d96D6bcc25c280717730B58B1"); + expect(eth[3]?.pool).toBe("0xAe05Cd22df81871bc7cC2a04BeCfb516bFe332C8"); + expect(eth[1]?.poolAddressesProvider).toBe("0xeBa440B438Ad808101d1c451C1C5322c90BEFCdA"); + expect(eth[2]?.poolAddressesProvider).toBe("0xcfBf336fe147D643B9Cb705648500e101504B16d"); + expect(eth[3]?.poolAddressesProvider).toBe("0x5D39E06b825C1F2B80bf2756a73e28eFAA128ba0"); + }); + + it("ships Core reserves on Arbitrum and Optimism (not just a Pool)", () => { + const arb = Protocols.aaveV3.reserves[Chains.ArbitrumOne] ?? []; + const op = Protocols.aaveV3.reserves[Chains.OptimismMainnet] ?? []; + // Chain-native symbols guard against mixing the two catalogs — + // they share a CREATE2 Pool address. + expect(arb.find((r) => r.symbol === "ARB")).toBeDefined(); + expect(op.find((r) => r.symbol === "OP")).toBeDefined(); + expect(arb.find((r) => r.symbol === "WETH")).toBeDefined(); + expect(op.find((r) => r.symbol === "WETH")).toBeDefined(); + }); + + it("ships Arbitrum + Optimism Core Pools (CREATE2-shared address)", () => { + const shared = "0x794a61358D6845594F94dc1DB02A252b5b4814aD"; + expect(Protocols.aaveV3.pool[Chains.ArbitrumOne]).toBe(shared); + expect(Protocols.aaveV3.pool[Chains.OptimismMainnet]).toBe(shared); + // Same CREATE2 PoolAddressesProvider on both L2s; oracles differ. + expect(Protocols.aaveV3.poolAddressesProvider[Chains.ArbitrumOne]).toBe( + Protocols.aaveV3.poolAddressesProvider[Chains.OptimismMainnet], + ); + expect(Protocols.aaveV3.oracle[Chains.ArbitrumOne]).not.toBe( + Protocols.aaveV3.oracle[Chains.OptimismMainnet], + ); + }); + it("decodes a ReserveConfigurationMap bitmap via reserveConfigurationBits", () => { const bits = Protocols.aaveV3.reserveConfigurationBits; // Issue's named low bits are pinned to their canonical positions. @@ -191,17 +254,36 @@ describe("AAVE V3 catalog", () => { it("ships a non-empty reserve list on every covered chain", () => { const { reserves } = Protocols.aaveV3; - for (const chain of [ - Chains.EthereumMainnet, - Chains.Sepolia, - Chains.BaseMainnet, - Chains.BaseSepolia, - Chains.BnbMainnet, - ]) { + for (const chain of AAVE_V3_CHAINS) { expect((reserves[chain] ?? []).length).toBeGreaterThan(0); } }); + it("reserve symbols are unique per chain (no silent first-match collisions)", () => { + for (const [chain, chainReserves] of Object.entries(Protocols.aaveV3.reserves)) { + const symbols = (chainReserves ?? []).map((reserve) => reserve.symbol); + expect(new Set(symbols).size, `duplicate symbol on chain ${chain}`).toBe(symbols.length); + } + }); + + it("disambiguates bridged USDC.e so symbol lookup returns native USDC", () => { + const native = { + [Chains.ArbitrumOne]: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + [Chains.OptimismMainnet]: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + } as const; + const bridged = { + [Chains.ArbitrumOne]: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", + [Chains.OptimismMainnet]: "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", + } as const; + for (const chain of [Chains.ArbitrumOne, Chains.OptimismMainnet] as const) { + const listed = Protocols.aaveV3.reserves[chain] ?? []; + const usdc = listed.find((r) => r.symbol === "USDC"); + const usdce = listed.find((r) => r.symbol === "USDC.e"); + expect(usdc?.underlying.toLowerCase()).toBe(native[chain].toLowerCase()); + expect(usdce?.underlying.toLowerCase()).toBe(bridged[chain].toLowerCase()); + } + }); + it("every reserve carries valid underlying/aToken/variableDebtToken + decimals", () => { for (const chainReserves of Object.values(Protocols.aaveV3.reserves)) { for (const reserve of chainReserves ?? []) { @@ -313,6 +395,9 @@ describe("Chain coverage", () => { // the same chain set. expect(Object.keys(Protocols.aaveV3.poolAddressesProvider).sort()).toEqual(poolChains); expect(Object.keys(Protocols.aaveV3.uiPoolDataProvider).sort()).toEqual(poolChains); + // Reserves must cover the same chains as Pool. A hand-maintained + // chain list would let a new Pool ship without a regenerate. + expect(Object.keys(Protocols.aaveV3.reserves).sort()).toEqual(poolChains); // WETH Gateway is only deployed on chains whose native gas token // is ETH. Chains without it (e.g. BNB Chain) still have Pool + // Oracle. So the invariant is "gateway ⊆ pool", not equality.