Mayavi
Protocol coverage·6 min read

Aave V3 Across Six Chains: One Adapter, Per-Chain Dispatch, and the BNB Decimal Bug We Caught

Aave V3 ships bytecode-identical contracts across mainnet, BNB, Polygon, Arbitrum, Base, and Optimism — but the addresses, decimals, and seconds-per-block aren't identical. Here is how we route one adapter to all six, and how a single hardcoded 12 silently corrupted RL training on five of them.

Part of the Protocol coverage series.

Aave V3's contracts are identical across the six EVM chains we support. The bytecode the Aave team deployed on Arbitrum is byte-for-byte the bytecode they deployed on Base. The 4-byte selectors match, the storage layout matches, the getUserAccountData() return tuple matches.

Which makes the wrong shape of "supports multiple chains" tempting: copy the adapter, change the address, ship six adapters. Don't. Aave V3 deploys identical bytecode, so the ABI is chain-agnostic, so there should be exactly one adapter. The thing that differs across chains is configuration: addresses, RPC endpoints, decimal counts on per-chain token variants, block production speed. That's a dispatch problem, not a code-duplication problem.

This post walks the dispatch architecture, the bugs we caught when we trusted the dispatch to be "obvious," and the regression tests that pin it.

One adapter, one dispatch table

mayavi/protocols/aave_v3.py holds a single AaveAccount class wrapping Fork. At construction it reads fork.config.chain_id and resolves the Pool address from a single dispatch table:

_AAVE_V3_POOL_BY_CHAIN: dict[int, bytes] = {
    1:     AAVE_V3_POOL,        # mainnet
    56:    _bnb.AAVE_V3_POOL,
    137:   _polygon.AAVE_V3_POOL,
    42161: _arbitrum.AAVE_V3_POOL,
    8453:  _base.AAVE_V3_POOL,
    10:    _optimism.AAVE_V3_POOL,
}

Constructing AaveAccount(fork, borrower_address) against a chain that's not in the table raises UnsupportedAaveV3ChainError immediately. No silent fallback to mainnet. No if chain_id == 1 branches scattered through the codebase.

The chain registry — mayavi/chains/registry.py — is the one source of truth for chain identity: name, RPC env-var, display name, and (since Sprint 5-N) seconds-per-block. We'll come back to that field — it's where the bug lived.

The result: one adapter, six chains, twelve committed bundles (one borrower + one depeg-cascade per chain).

Bundle: aave-v3-mainnet-borrower-2026-05-14Aave V3 mainnet @ block 19,000,000 — the canonical baseline
Bundle: aave-v3-bnb-borrower-2026-05-14Aave V3 BNB @ block 19,000,000 — same adapter, different chain_id

Both reports were produced by literally the same Python code path. The differences (TVL, collateral price, interest rate, block production cadence) are all on-chain truth at the pinned block.

The first bug: BNB's 18-decimal USDC

USDC has 6 decimals on Ethereum mainnet. On BNB Chain, the "USDC" token contract has 18 decimals. Same symbol. Same logo. Different decimals.

This is the kind of trap that's invisible until it explodes. Our AaveAccount.repay(USDC, 1700e6) worked on mainnet, Arbitrum, Optimism, Base, Polygon — every chain where USDC is 6-decimal. On BNB the same call passed 1700e61.7 USDC to a contract that expected 1.7e21 ≈ a tiny fraction of the original intent. The repay went through (BNB's "USDC" contract happily accepted the tiny amount), but the user's debt stayed essentially untouched.

The fix was a debt_decimals parameter threaded through the repay path:

class AaveAccount:
    def repay(
        self,
        asset: bytes,
        amount: int,
        *,
        debt_decimals: int,
        on_behalf_of: bytes | None = None,
    ) -> CallResult:
        ...

The caller now has to know the decimals. The unit tests parametrize over all six chains and all expected (asset, decimals) combinations. A future contract change — a bridged USDC variant with surprising decimals on a new chain — fails loud at the test layer.

The lesson isn't "decimals are tricky." It's that a multichain adapter cannot abstract over per-chain token-contract variants without an explicit parameter at every numerically-sensitive boundary. The temptation to look up decimals once and cache them globally is also the bug.

The second bug: 12 seconds is not the second-per-block constant

Aave V3 accrues interest based on block.timestamp. The gym envs advance time between PPO steps with Fork.warp(advance_seconds=blocks_per_step * seconds_per_block). Before Sprint 5-N, every gym env hardcoded:

advance_seconds = blocks_per_step * 12   # WRONG on every non-mainnet chain

The 12 is Ethereum mainnet's slot time. It is not BNB's (3 s), not Polygon's (2 s), not Base's or Optimism's (2 s), and certainly not Arbitrum's (~0.25 s). On Arbitrum the env was warping wall-clock up to 48× too fast (the chain registry rounds Arbitrum to int(1) because we kept the field int rather than letting float leak into every callsite). Aave V3 interest accrual is block.timestamp-based, so per-step debt growth — the PPO reward signal — was off by the same factor.

The fix added seconds_per_block: int to the Chain dataclass and threaded it through every env's warp body:

self._seconds_per_block = get_chain(chain_id).seconds_per_block
# inside step():
self.state.warp(advance_seconds=blocks_per_step * self._seconds_per_block)

The chain values: mainnet=12, BNB=3, Polygon=2, Arbitrum=1 (rounded up from 0.25), Base=2, Optimism=2. Mainnet behaviour is unchanged. Constructing an env with an unknown chain_id raises UnknownChainError loud at construction time, not silently at step 1's warp call.

24 new tests across tests/gym_env/test_seconds_per_block_plumbing.py (3 gym-env classes × 8 parametrised tests each) pin this. A future chain addition that forgets the field fails at test collection time — before CI gets to the wrong-reward regime.

The third bug we didn't ship: balance-slot mis-registration

ERC20 _balances is a mapping. The mapping's storage slot — the index into the contract's storage layout — varies by token implementation. Most native USDC variants use slot 9. Most bridged-USDC variants use slot 51. Polygon's bridged WETH (UChild pattern) uses slot 0.

Our borrower-funding cheat (the bypass-during-snapshot path from Post 2) writes directly to _balances[holder]. Wrong slot = the write goes to an unused storage cell, balanceOf reads as before, the borrower has no funded collateral, account.supply() reverts with "transfer amount exceeds balance."

The PR L matrix run hit this on 8 of 21 scenarios. The fix is scripts/probe_balance_slot.py: fork the chain, query balanceOf(holder) for a known-nonzero holder (an Aave pool, a hot wallet), iterate candidate slots 0..N computing keccak256(abi.encode(holder, slot)), read the resulting cell via Fork.get_storage, and print the first slot whose value matches.

Empirical results we discovered:

ChainTokenSlotNote
ArbitrumWETH (0x82aF49…)51Custom Arbitrum-bridge WETH9, NOT slot 3 like mainnet WETH9
ArbitrumUSDC.e (0xFF970A…)51Same ArbitrumBridgeToken lineage
OptimismUSDC (0x0b2C63…)9Native Circle FiatTokenV2_2, matches mainnet
BaseUSDC (0x833589…)9Same FiatTokenV2_2 pattern
PolygonWETH (PoS bridged)0UChildERC20 inheritance shifts to slot 0
PolygonUSDC.e0Same UChildERC20 lineage
BNBWBNB3Confirmed (BNB Alchemy free-tier 403 originally masked this)
BNBUSDC (18-dec)1Confirmed

Each entry in mayavi/evm/chains/<chain>.py::ERC20_BALANCE_SLOTS carries an inline citation comment naming the probe command, the holder address, and the matched balance. A reviewer can re-run the probe and verify the slot — that's the discipline. Once landed, the 21/21 matrix matrix-runs clean.

What we don't do

We don't:

  • Cache decimals globally. Per-call parameter, no exceptions.
  • Hardcode chain-specific constants in the adapter. Everything chain-specific routes through mayavi.chains.registry.
  • Use Web3.py for ABI encoding. Direct eth_abi.encode + function_signature_to_4byte_selector — predictable, no implicit conversions, zero surprises.
  • Trust "looks identical" — only "is identical, verified by a per-chain unit test." Identical bytecode on Aave's side does not imply identical surrounding context on every chain.

What this enables

One adapter, six chains, twelve bundles, regression tests that fire on every push. Adding a seventh chain (Linea, Scroll, …) is a four-step recipe documented in [[project_evm_first_phase5]]:

  1. Register the chain in mayavi/chains/registry.py (name, RPC env var, seconds-per-block).
  2. Add a per-chain module under mayavi/evm/chains/<chain>.py with USDC, WETH, Aave V3 Pool addresses.
  3. Run scripts/probe_balance_slot.py for every (chain, token) the scenarios need; add the slots to ERC20_BALANCE_SLOTS with citation comments.
  4. Add the chain to _AAVE_V3_POOL_BY_CHAIN and the parametrised round-trip tests in tests/sim/test_runner_migration.py::TestChainIdRoundTrip.

Next up in this series: Post 5 — Compound V3 Comet. Different protocol, different invariant (isolated-base-asset), different liquidation model (absorb vs liquidationCall), same dispatch pattern.