diff --git a/.changeset/aave-v3-data-provider-addresses.md b/.changeset/aave-v3-data-provider-addresses.md new file mode 100644 index 0000000..cc13bcf --- /dev/null +++ b/.changeset/aave-v3-data-provider-addresses.md @@ -0,0 +1,11 @@ +--- +"@avaprotocol/protocols": minor +--- + +Ship AAVE V3 reserve-config read surface (issue #19, the scale optimization). + +- Add `aaveV3.poolAddressesProvider` and `aaveV3.uiPoolDataProvider` per-chain address maps (Mainnet, Sepolia, Base, Base Sepolia, BNB). Each `PoolAddressesProvider` was verified on-chain — `getPool()` returns the catalog's existing `pool` address — and each `UiPoolDataProvider` verified as deployed bytecode. +- Extend `aaveV3.poolMethodsAbi` with the version-stable Pool config reads: `getConfiguration(asset)`, `getUserConfiguration(user)`, and `getReserveData(asset)` (`ReserveDataLegacy`). These cover a health-factor / liquidation solver's per-reserve risk config, a user's collateral/borrow flags, and the indices needed to scale balances — on every chain, with no periphery-version pinning. +- Export `aaveV3.reserveConfigurationBits` and `aaveV3.userConfigurationBits` — the `ReserveConfigurationMap` / `UserConfigurationMap` bit layouts — so consumers decode the on-chain bitmaps without hardcoding offsets. Only the version-stable low bits (0–167) are exported; higher bits shift across deployed V3 versions and are intentionally omitted. + +Non-goal (unchanged): governance-mutable, HF-critical values (`ltv` / `liquidationThreshold` / `usageAsCollateralEnabled`) are **not** baked into the static `AaveV3Reserve` catalog — the catalog ships the address to read from, not the values. The `UiPoolDataProvider` address is shipped without its return-struct ABI on purpose: that tuple is periphery-version-specific and already differs across covered chains (Ethereum/Base/BNB/Base Sepolia return the v3.3 layout, Sepolia an earlier one), so pair the address with a version-aware ABI at the call site. diff --git a/README.md b/README.md index e236d2d..29630d6 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 + Pool methods/events ABI + topics | Mainnet, Sepolia, Base, Base Sepolia, **BNB** (no WETH Gateway on BNB) | +| **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) | | **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 | @@ -91,6 +91,28 @@ await wallet.contractWrite({ }); ``` +### Read a reserve's risk config (health-factor math) + +Risk parameters (`ltv`, `liquidationThreshold`, `active`/`frozen`/`paused`, …) are **governance-mutable** — the catalog deliberately does **not** bake their values into the static `reserves` list. Ship the address to read from, not the value; the live read stays in the consumer. Decode the on-chain bitmap with the exported bit layout, which is version-stable across every deployed AAVE V3 market: + +```ts +import { Protocols, Chains } from "@avaprotocol/protocols"; + +// Pool.getConfiguration(asset) → ReserveConfigurationMap { data: uint256 } +const raw = BigInt(configData); // the `data` field of the returned struct +const { ltv, liquidationThreshold, decimals } = Protocols.aaveV3.reserveConfigurationBits; +const field = (f: { offset: number; bits: number }) => + (raw >> BigInt(f.offset)) & ((1n << BigInt(f.bits)) - 1n); + +const ltvBps = Number(field(ltv)); // e.g. 8050 = 80.50% +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. + +> **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. + ### Filter on an event topic ```ts diff --git a/src/protocols/aave-v3.ts b/src/protocols/aave-v3.ts index 88b99c0..c6d18a8 100644 --- a/src/protocols/aave-v3.ts +++ b/src/protocols/aave-v3.ts @@ -1,6 +1,9 @@ -// AAVE V3 — Pool / Oracle / WETH Gateway addresses per supported -// chain, the Pool ABI fragments (events + the read/write methods -// callers actually touch), and event-topic hashes. +// AAVE V3 — Pool / Oracle / WETH Gateway / PoolAddressesProvider / +// UiPoolDataProvider addresses per supported chain, 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 +// event-topic hashes. // // Pool is the single entry point templates target (supply, borrow, // repay, withdraw, getUserAccountData, setUserUseReserveAsCollateral) @@ -57,6 +60,51 @@ const wethGateway: AddressByChain = { [Chains.BaseSepolia]: "0x0568130e794429D2eEBC4dafE18f25Ff1a1ed8b6", }; +/** + * PoolAddressesProvider — the per-market registry that resolves the + * Pool, Oracle, ACL, etc. It's the handle you pass to the + * `UiPoolDataProvider` reads below (`getReservesData(provider)` / + * `getUserReservesData(provider, user)`) and the canonical "which + * market" identifier. Verified on-chain: `getPool()` on each of these + * returns exactly the `pool` address above. + */ +const poolAddressesProvider: AddressByChain = { + [Chains.EthereumMainnet]: "0x2f39d218133AFaB8F2B819B1066c7E434Ad94E9e", + [Chains.Sepolia]: "0x012bAC54348C0E635dCAc9D5FB99f06F24136C9A", + [Chains.BaseMainnet]: "0xe20fCBdBfFC4Dd138cE8b2E6FBb6CB49777ad64D", + [Chains.BaseSepolia]: "0xE4C23309117Aa30342BFaae6c95c6478e0A4Ad00", + [Chains.BnbMainnet]: "0xff75B6da14FfbbfD355Daf7a2731456b3562Ba6D", +}; + +/** + * UiPoolDataProvider — the periphery aggregator the Aave UI uses to read + * a whole market's per-reserve risk config + prices in one call + * (`getReservesData(provider)`) and a user's per-reserve supply/debt in + * another (`getUserReservesData(provider, user)`). This is the scale + * optimization: one round-trip for the full market instead of N Pool + * reads. + * + * We ship the ADDRESS but intentionally NOT a return-struct ABI. The + * `AggregatedReserveData` / `UserReserveData` tuples this contract + * returns are periphery-version-specific and already differ across the + * chains we cover — e.g. Ethereum / Base / BNB / Base Sepolia currently + * return the v3.3 layout while Sepolia returns an earlier one. AAVE + * upgrades this periphery contract per chain independently of the Pool, + * so a single baked tuple would silently fail to decode on some markets + * and drift over time. Pair this address with a version-aware ABI at the + * call site (e.g. `@bgd-labs/aave-address-book`). For a + * version-independent read, use the `Pool` config reads below instead + * (`getConfiguration` / `getUserConfiguration` / `getReserveData`), whose + * ABIs are stable across deployments. + */ +const uiPoolDataProvider: AddressByChain = { + [Chains.EthereumMainnet]: "0x2dAd8162A989cd99D673dE4425Bb2298Db1E1aA2", + [Chains.Sepolia]: "0x69529987FA4A075D0C00B0128fa848dc9ebbE9CE", + [Chains.BaseMainnet]: "0x0C6BC4a12039788be08F87e87Cff87FEDbd1D386", + [Chains.BaseSepolia]: "0x3cB7B00B6C09B71998124196691e8bF2694De863", + [Chains.BnbMainnet]: "0x68100bD5345eA474D93577127C11F39FF8463e93", +}; + /** * Pre-computed keccak256 of the canonical event signatures. Cheaper * than recomputing per-call and stable across deployments. Match @@ -144,8 +192,21 @@ const poolEventsAbi: readonly AbiFragment[] = Object.freeze([ /** * Pool method ABI — the read + write surface templates routinely call: * `getUserAccountData` (account health), `supply`, `borrow`, `repay`, - * `withdraw`, and `setUserUseReserveAsCollateral`. Raw ABI so any - * `contractRead`/`contractWrite` consumer can plug it in directly. + * `withdraw`, `setUserUseReserveAsCollateral`, and the per-reserve / + * per-user config reads a liquidation/health-factor solver needs + * (`getConfiguration`, `getUserConfiguration`, `getReserveData`). Raw ABI + * so any `contractRead`/`contractWrite` consumer can plug it in directly. + * + * The three config reads are the version-independent path to per-reserve + * risk config (LTV, liq. threshold, active/frozen/paused, …) and a user's + * collateral/borrow flags: `getConfiguration` / `getUserConfiguration` + * each return a single `uint256` bitmap, and `getReserveData` returns the + * (ABI-stable) `ReserveDataLegacy` struct carrying the config bitmap plus + * the liquidity / variable-borrow indices needed to scale a user's + * balances. Decode the bitmaps with `reserveConfigurationBits` / + * `userConfigurationBits` below. Prefer these over `uiPoolDataProvider` + * when you need one asset's config on any chain without pinning a + * periphery version. */ const poolMethodsAbi: readonly AbiFragment[] = Object.freeze([ { @@ -220,8 +281,148 @@ const poolMethodsAbi: readonly AbiFragment[] = Object.freeze([ stateMutability: "nonpayable", type: "function", }, + { + // Returns the reserve's ReserveConfigurationMap — a single uint256 + // bitmap. Decode with `reserveConfigurationBits`. + inputs: [{ internalType: "address", name: "asset", type: "address" }], + name: "getConfiguration", + outputs: [ + { + components: [{ internalType: "uint256", name: "data", type: "uint256" }], + internalType: "struct DataTypes.ReserveConfigurationMap", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + // Returns the user's UserConfigurationMap — a single uint256 bitmap + // packing, per reserve index, an is-borrowing and a + // use-as-collateral flag. Decode with `userConfigurationBits`. + inputs: [{ internalType: "address", name: "user", type: "address" }], + name: "getUserConfiguration", + outputs: [ + { + components: [{ internalType: "uint256", name: "data", type: "uint256" }], + internalType: "struct DataTypes.UserConfigurationMap", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + // ReserveDataLegacy — kept ABI-stable across V3 versions for + // back-compat. Carries the config bitmap (`configuration.data`), the + // liquidity / variable-borrow indices (to scale a user's aToken / + // debt balances to underlying), and the reserve's aToken / + // variable-debt token addresses + `id` (its index in the user + // configuration bitmap). Note: `stableDebtTokenAddress` is deprecated + // (stable-rate borrowing is removed on V3.1+ markets — it reads as the + // zero address there); a repay path should use `variableDebtTokenAddress`. + inputs: [{ internalType: "address", name: "asset", type: "address" }], + name: "getReserveData", + outputs: [ + { + components: [ + { + components: [{ internalType: "uint256", name: "data", type: "uint256" }], + internalType: "struct DataTypes.ReserveConfigurationMap", + name: "configuration", + type: "tuple", + }, + { internalType: "uint128", name: "liquidityIndex", type: "uint128" }, + { internalType: "uint128", name: "currentLiquidityRate", type: "uint128" }, + { internalType: "uint128", name: "variableBorrowIndex", type: "uint128" }, + { internalType: "uint128", name: "currentVariableBorrowRate", type: "uint128" }, + { internalType: "uint128", name: "currentStableBorrowRate", type: "uint128" }, + { internalType: "uint40", name: "lastUpdateTimestamp", type: "uint40" }, + { internalType: "uint16", name: "id", type: "uint16" }, + { internalType: "address", name: "aTokenAddress", type: "address" }, + { internalType: "address", name: "stableDebtTokenAddress", type: "address" }, + { internalType: "address", name: "variableDebtTokenAddress", type: "address" }, + { internalType: "address", name: "interestRateStrategyAddress", type: "address" }, + { internalType: "uint128", name: "accruedToTreasury", type: "uint128" }, + { internalType: "uint128", name: "unbacked", type: "uint128" }, + { internalType: "uint128", name: "isolationModeTotalDebt", type: "uint128" }, + ], + internalType: "struct DataTypes.ReserveDataLegacy", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, ]); +/** + * Bit layout of the AAVE V3 `ReserveConfigurationMap.data` bitmap + * (the `uint256` returned by `Pool.getConfiguration(asset)` and carried + * as `getReserveData(asset).configuration.data`). Each entry is + * `{ offset, bits }` — the low bit index and field width — so a consumer + * decodes a field with: + * + * const raw = BigInt(configData); + * const { offset, bits } = aaveV3.reserveConfigurationBits.ltv; + * const ltv = Number((raw >> BigInt(offset)) & ((1n << BigInt(bits)) - 1n)); + * + * Single-bit flags (`bits: 1`) decode to 0/1. `ltv` / + * `liquidationThreshold` / `liquidationBonus` / `reserveFactor` / + * `liquidationProtocolFee` are in basis points (1e4 = 100%); `decimals` + * is the underlying's ERC-20 decimals. + * + * Only the version-stable low bits (0–167) are exported. Higher bits + * (stable-rate flag, isolation/siloed flags, eMode category, debt + * ceiling, unbacked mint cap, virtual-accounting flag, …) sit at + * positions that have shifted or changed meaning between deployed AAVE + * V3 versions — and our covered chains run different versions + * concurrently — so baking them here would risk a wrong decode. Read + * those from the market's own contracts if needed. These low bits are + * the health-factor-relevant fields and are identical across every V3 + * deployment (cross-checked on-chain against `UiPoolDataProvider`). + */ +const reserveConfigurationBits = Object.freeze({ + ltv: { offset: 0, bits: 16 }, + liquidationThreshold: { offset: 16, bits: 16 }, + liquidationBonus: { offset: 32, bits: 16 }, + decimals: { offset: 48, bits: 8 }, + active: { offset: 56, bits: 1 }, + frozen: { offset: 57, bits: 1 }, + borrowingEnabled: { offset: 58, bits: 1 }, + paused: { offset: 60, bits: 1 }, + flashLoanEnabled: { offset: 63, bits: 1 }, + reserveFactor: { offset: 64, bits: 16 }, + borrowCap: { offset: 80, bits: 36 }, + supplyCap: { offset: 116, bits: 36 }, + liquidationProtocolFee: { offset: 152, bits: 16 }, +} as const); + +/** + * Bit layout of the AAVE V3 `UserConfigurationMap.data` bitmap (the + * `uint256` returned by `Pool.getUserConfiguration(user)`). Two bits per + * reserve, indexed by the reserve's `id` (its position in + * `Pool.getReservesList()` / `getReserveData(asset).id`): + * + * const raw = BigInt(userConfigData); + * const base = BigInt(id) * BigInt(aaveV3.userConfigurationBits.bitsPerReserve); + * const isBorrowing = + * ((raw >> (base + BigInt(aaveV3.userConfigurationBits.borrowingOffset))) & 1n) === 1n; + * const usedAsCollateral = + * ((raw >> (base + BigInt(aaveV3.userConfigurationBits.collateralOffset))) & 1n) === 1n; + * + * This is the source of `usageAsCollateralEnabled` per reserve for a + * given user. Stable across all V3 versions. + */ +const userConfigurationBits = Object.freeze({ + bitsPerReserve: 2, + borrowingOffset: 0, + collateralOffset: 1, +} as const); + /** * Per-chain reserve token addresses for the assets AAVE V3 markets * keep available across chains. Mainly used by template tests that @@ -253,9 +454,13 @@ export const aaveV3 = Object.freeze({ pool, oracle, wethGateway, + poolAddressesProvider, + uiPoolDataProvider, eventTopics, poolEventsAbi, poolMethodsAbi, + reserveConfigurationBits, + userConfigurationBits, tokens, reserves, }); diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index 8421894..03a79c8 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -105,6 +105,84 @@ describe("AAVE V3 catalog", () => { expect(methods).toContain("setUserUseReserveAsCollateral"); }); + it("ships the version-stable config reads in the Pool method ABI", () => { + const methods = Protocols.aaveV3.poolMethodsAbi.map((f) => f.name); + expect(methods).toContain("getConfiguration"); + expect(methods).toContain("getUserConfiguration"); + expect(methods).toContain("getReserveData"); + }); + + it("has PoolAddressesProvider + UiPoolDataProvider on every covered chain", () => { + for (const chain of [ + Chains.EthereumMainnet, + Chains.Sepolia, + Chains.BaseMainnet, + Chains.BaseSepolia, + Chains.BnbMainnet, + ]) { + expect(Protocols.aaveV3.poolAddressesProvider[chain]).toMatch(ADDRESS_RE); + expect(Protocols.aaveV3.uiPoolDataProvider[chain]).toMatch(ADDRESS_RE); + } + }); + + it("decodes a ReserveConfigurationMap bitmap via reserveConfigurationBits", () => { + const bits = Protocols.aaveV3.reserveConfigurationBits; + // Issue's named low bits are pinned to their canonical positions. + expect(bits.ltv).toEqual({ offset: 0, bits: 16 }); + expect(bits.liquidationThreshold).toEqual({ offset: 16, bits: 16 }); + expect(bits.decimals).toEqual({ offset: 48, bits: 8 }); + expect(bits.active).toEqual({ offset: 56, bits: 1 }); + expect(bits.frozen).toEqual({ offset: 57, bits: 1 }); + expect(bits.borrowingEnabled).toEqual({ offset: 58, bits: 1 }); + expect(bits.paused).toEqual({ offset: 60, bits: 1 }); + + const field = (data: bigint, f: { offset: number; bits: number }) => + (data >> BigInt(f.offset)) & ((1n << BigInt(f.bits)) - 1n); + + // Pack a bitmap the way the Pool does, then round-trip it back out. + const packed = + 8050n | // ltv + (8300n << 16n) | // liquidationThreshold + (18n << 48n) | // decimals + (1n << 56n) | // active + (1n << 58n); // borrowingEnabled (frozen/paused left 0) + expect(field(packed, bits.ltv)).toBe(8050n); + expect(field(packed, bits.liquidationThreshold)).toBe(8300n); + expect(field(packed, bits.decimals)).toBe(18n); + expect(field(packed, bits.active)).toBe(1n); + expect(field(packed, bits.frozen)).toBe(0n); + expect(field(packed, bits.borrowingEnabled)).toBe(1n); + expect(field(packed, bits.paused)).toBe(0n); + }); + + it("decodes per-reserve collateral/borrow flags via userConfigurationBits", () => { + const { bitsPerReserve, borrowingOffset, collateralOffset } = + Protocols.aaveV3.userConfigurationBits; + expect(bitsPerReserve).toBe(2); + // reserve id 3 used as collateral, id 5 borrowed. + const data = (1n << (3n * 2n + BigInt(collateralOffset))) | (1n << (5n * 2n + BigInt(borrowingOffset))); + const flag = (id: bigint, off: number) => + ((data >> (id * BigInt(bitsPerReserve) + BigInt(off))) & 1n) === 1n; + expect(flag(3n, collateralOffset)).toBe(true); + expect(flag(3n, borrowingOffset)).toBe(false); + expect(flag(5n, borrowingOffset)).toBe(true); + expect(flag(5n, collateralOffset)).toBe(false); + }); + + it("does NOT bake mutable risk values into the static reserve catalog", () => { + // Explicit non-goal (issue #19): governance-mutable, HF-critical + // values must be read live, never baked. Guard the reserve shape so + // a future generator change can't silently ship a stale LTV. + const forbidden = ["ltv", "liquidationThreshold", "liqThreshold", "usageAsCollateralEnabled"]; + for (const chainReserves of Object.values(Protocols.aaveV3.reserves)) { + for (const reserve of chainReserves ?? []) { + for (const key of forbidden) { + expect(Object.prototype.hasOwnProperty.call(reserve, key)).toBe(false); + } + } + } + }); + it("ships the Borrow event topic + ABI in lockstep", () => { const borrow = Protocols.aaveV3.poolEventsAbi.find((e) => e.name === "Borrow"); expect(borrow).toBeDefined(); @@ -227,10 +305,14 @@ describe("Shared ABIs", () => { }); describe("Chain coverage", () => { - it("AAVE V3 Pool + Oracle cover the same chains; WETH Gateway is a subset", () => { + it("AAVE V3 Pool + Oracle + data providers cover the same chains; WETH Gateway is a subset", () => { const poolChains = Object.keys(Protocols.aaveV3.pool).sort(); const oracleChains = Object.keys(Protocols.aaveV3.oracle).sort(); expect(oracleChains).toEqual(poolChains); + // The data providers read the same markets as Pool, so they cover + // the same chain set. + expect(Object.keys(Protocols.aaveV3.poolAddressesProvider).sort()).toEqual(poolChains); + expect(Object.keys(Protocols.aaveV3.uiPoolDataProvider).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.