Skip to content

Add support for multiple AAVE Money Market instances to Runtime API #1431

Description

@mckrava

Summary

The AaveTradeExecutor runtime API currently reports pools from a single Aave Money Market instance (pallet_liquidation::BorrowingContract). A second instance has been introduced (GigaHdxPoolContract), and more are expected. This spec extends pairs(), pool(), pools(), and liquidity_depth() to cover every active instance, plus extends internal supply/withdraw/routing to target the correct instance for each asset.

Key invariant (per product plan): each reserve asset lives in exactly one Aave MM instance. The main BorrowingContract hosts multiple reserves; each additional instance (GigaHdxPoolContract, future ones) hosts exactly one reserve. Consequence: (reserve, atoken)pool_address is a function — no disambiguator is needed in the public API.

Motivation

  • runtime/hydradx/src/lib.rs:1158-1191 hard-codes BorrowingContract::get() as the only pool in the Aave runtime API.
  • GigaHdxPoolContract is already used internally by AaveMoneyMarket (runtime/hydradx/src/gigahdx.rs:45,64) and by liquidate_gigahdx (pallets/liquidation/src/lib.rs:499), and it holds its own reserve/aToken — invisible to consumers of the runtime API today.
  • Without this change, every new Aave instance requires additional out-of-band wiring to be visible to clients and is unreachable through the trade-executor path.

Users / actors: off-chain indexers, swap/routing UIs, analytics dashboards, integrators consuming the AaveTradeExecutor runtime API.

Existing building blocks (already in-tree)

Most per-pool plumbing already exists — only the aggregation layer is missing:

Primitive Location Pool-parametric?
AaveTradeExecutor::get_reserves_list(pool) aave_trade_executor.rs:157
AaveTradeExecutor::get_reserve_data(pool, asset) aave_trade_executor.rs:196
AaveTradeExecutor::do_supply_on_behalf_of(pool, ...) aave_trade_executor.rs:306
AaveTradeExecutor::do_withdraw(pool, ...) aave_trade_executor.rs:346
AaveTradeExecutor::do_set_use_reserve_as_collateral(pool, ...) aave_trade_executor.rs:330

Still hardcoded to BorrowingContract::<T>::get() (the actual work):

  • AaveTradeExecutor::supply()aave_trade_executor.rs:303
  • AaveTradeExecutor::withdraw()aave_trade_executor.rs:367
  • AaveTradeExecutor::do_withdraw_all_to()aave_trade_executor.rs:374
  • TradeExecution::get_liquidity_depthaave_trade_executor.rs:566
  • Runtime API resolver pairs() / pool() / pools() / liquidity_depth()lib.rs:1158-1191
  • precompiles/flash-loan/src/lib.rs:129

Out of scope: node/src/liquidation_worker.rs runs against a single PAP_CONTRACT (line 38) and a single MoneyMarketData (liquidation-worker-support/src/lib.rs:946). Multi-instance worker support is a separate follow-up.

Design

Because of the one-reserve-per-non-main-instance invariant, the public runtime API shape does not change. pairs() / pool(reserve, atoken) / pools() / liquidity_depth(asset_in, asset_out) keep their current signatures and PoolData keeps its current fields. Only the resolver and the trade-executor internals change.

Two options differ only in where the list of pool contracts lives.


Option A — Light (no storage change)

Idea: A runtime-level helper returns the list of known pool contracts. Resolver and trade-executor internals iterate it.

Changes:

  1. Add a runtime helper in runtime/hydradx/src/lib.rs near the API impl:
    fn aave_pool_contracts() -> Vec<EvmAddress> {
        let mut pools = vec![pallet_liquidation::BorrowingContract::<Runtime>::get()];
        let giga = pallet_liquidation::GigaHdxPoolContract::<Runtime>::get();
        if giga != pools[0] {  // dedupe — see edge cases
            pools.push(giga);
        }
        pools
    }
  2. Rewrite pairs() (lib.rs:1159-1173) to flat-map over aave_pool_contracts().
  3. Rewrite pool(reserve, atoken) and liquidity_depth(asset_in, asset_out) to find the hosting pool via an internal helper (see point 4) before calling through to AaveTradeExecutor logic.
  4. Add an internal helper on AaveTradeExecutor:
    fn find_pool_for_asset(asset: EvmAddress) -> Option<EvmAddress>
    Iterates aave_pool_contracts(), calls get_reserves_list(pool), returns the first pool whose reserves include asset. This is the (reserve) → pool_address lookup that the invariant makes unambiguous.
  5. Update the remaining hardcoded callers to use find_pool_for_asset instead of BorrowingContract::<T>::get():
    • supply(), withdraw(), do_withdraw_all_to()
    • TradeExecution::get_liquidity_depth
    • (precompiles/flash-loan/src/lib.rs:129 — either route through the helper or keep pinned to BorrowingContract if flash-loans are main-pool-only; call out in review.)
  6. In do_sell / execute_sell / execute_buy, the asset address on the supply or withdraw side determines the pool via find_pool_for_asset.

Pros

  • No storage migration, no new extrinsic, no spec_version bump beyond a possible minor runtime-API version bump.
  • Touches only the runtime crate plus AaveTradeExecutor internals.
  • Public runtime API unchanged — no indexer/UI breakage.

Cons

  • Adding another instance requires a runtime release.
  • Knowledge of "which Aave instances exist" is split between pallet_liquidation storage and a runtime-level list.
  • find_pool_for_asset is O(instances × reserves) per call; acceptable given small bounds but worth caching if hot.

Migration: none. API shape unchanged.


Option B — Heavier (storage-backed registry)

Idea: Make pallet_liquidation the single source of truth for Aave pool contracts.

Changes:

  1. In pallets/liquidation/src/lib.rs, add:
    #[pallet::storage]
    pub type AavePoolContracts<T: Config> =
        StorageValue<_, BoundedVec<EvmAddress, T::MaxAavePoolContracts>, ValueQuery>;
    plus a MaxAavePoolContracts config constant (start at e.g. 8).
  2. Add AuthorityOrigin-gated extrinsics add_aave_pool_contract(EvmAddress) / remove_aave_pool_contract(EvmAddress) with events AavePoolContractAdded / AavePoolContractRemoved.
  3. Storage migration: on upgrade, seed AavePoolContracts with the distinct values of [BorrowingContract::get(), GigaHdxPoolContract::get()].
  4. Deprecate BorrowingContract / GigaHdxPoolContract as primary storage — either keep them as read-through aliases over the new vec, or migrate all call sites and remove them.
  5. Resolver + trade-executor changes are identical to Option A points 2–6, except aave_pool_contracts() reads from AavePoolContracts::get().

Pros

  • Adding a new instance is a single extrinsic — no runtime upgrade.
  • Single, on-chain, governance-controlled registry.
  • Eliminates the split of truth between pallet and runtime.

Cons

  • Storage migration + spec_version bump.
  • More review surface: extrinsic, origin check, bounded bounds, migration correctness.
  • All existing consumers of BorrowingContract / GigaHdxPoolContract must be migrated or shimmed.

Migration: one-shot OnRuntimeUpgrade seeds the new BoundedVec. Existing storage either becomes a view or is removed in a follow-up.

API compatibility: runtime API shape unchanged in both options.


Interactions

  • pallet_liquidation — storage source in both options (read-only in A, new writes in B).
  • runtime/hydradx/src/gigahdx.rs (AaveMoneyMarket) — continues reading GigaHdxPoolContract for its fixed role; unaffected in A, rerouted in B if aliases are removed.
  • precompiles/flash-loan/src/lib.rs:129 — still reads BorrowingContract; decision in review whether to route through find_pool_for_asset or keep pinned.
  • HydraErc20Mapping::address_to_asset — unchanged.
  • node/src/liquidation_worker.rs and MoneyMarketData — out of scope.

Constraints

  • Default-address collision. DefaultBorrowingContract and DefaultGigaHdxPoolContract both default to 0x1b02E051683b5cfaC5929C25E84adb26ECf87B38 (pallets/liquidation/src/lib.rs:162,172). On any chain where the GigaHdx contract hasn't been explicitly set, the registry / helper must dedupe by pool address or pairs() double-counts. Same rule applies to Option B's migration seeding.
  • Security (Option B). add_aave_pool_contract / remove_aave_pool_contract must be restricted to a governance or technical-committee origin.
  • Weight. pools() and find_pool_for_asset are O(instances × reserves). Today ≤ 2 instances × handful of reserves — trivial. Keep MaxAavePoolContracts ≤ 8.
  • Governance. Option B's setters are governable; Option A hard-codes the list.

Edge Cases

  • Invariant breach — asset appears on two instances. Product invariant says it won't happen, but if it does (governance misconfiguration), find_pool_for_asset must behave deterministically: return the first match (iteration order: BorrowingContract first, then additional instances) and log a warning. Do not panic, do not return multiple pools.
  • Two instances configured to the same contract address. Dedupe by pool address before iterating (see default-address collision). Otherwise pairs() returns every pair twice on a fresh chain.
  • Empty / zero pool contract. Skip zero-address entries. Current pairs() already swallows get_reserves_list errors to vec![] — preserve that per-instance.
  • Partial failures. A failing get_reserves_list or get_reserve_data on one instance must not poison results from the others.
  • Flash-loan caller. precompiles/flash-loan/src/lib.rs:129 is pinned to BorrowingContract. Confirm in review whether flash loans are main-pool-only (leave as-is) or should resolve per asset (route through helper).

Acceptance Criteria

  • pairs() returns reserves/aTokens from both BorrowingContract and GigaHdxPoolContract instances (and any future registered instance in Option B).
  • pools() returns one PoolData per (reserve, atoken) across all instances, with no duplicates.
  • pool(reserve, atoken) resolves correctly regardless of which instance hosts the pair.
  • liquidity_depth(asset_in, asset_out) returns correct depth regardless of hosting instance.
  • Trade-executor supply / withdraw / do_withdraw_all_to route to the correct Aave pool for the given asset.
  • execute_sell / execute_buy through PoolType::Aave succeed for pairs hosted on a non-main instance (e.g., HDX ↔ GIGAHDX).
  • Given both BorrowingContract and GigaHdxPoolContract point at the same address (fresh chain default), pairs() returns each pair exactly once.
  • A failing reserve call on one instance does not remove results from other instances.
  • If a reserve is (invariant-breaking) registered in two instances, find_pool_for_asset returns the first match and emits a warning log.
  • (Option B) add_aave_pool_contract / remove_aave_pool_contract are gated by AuthorityOrigin and emit events.
  • (Option B) Migration seeds the registry with the distinct existing contracts; runtime boots without manual intervention.

Open Questions

  • Which option? A (ship now, revisit) or B (one migration, done).
  • PoolData typo. Fix liqudity_in / liqudity_outliquidity_in / liquidity_out (aave_trade_executor.rs:609-610) in this PR or a separate cleanup? Fixing here is a breaking field rename; deferring is cheap.
  • Caching find_pool_for_asset. Worth caching the (asset → pool) map in a lazy-init runtime cache, or is the O(instances × reserves) scan fine given expected bounds?
  • Flash-loan precompile. Keep BorrowingContract-pinned or route per asset?
  • BorrowingContract deprecation (Option B). Remove old storage aliases in the same release or two-step?
  • Liquidation worker. Follow-up to iterate both pools in node/src/liquidation_worker.rs, or remain single-PAP?

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions