Skip to content

feat: add Balancer V3 reclamm pool types#1049

Open
johngrantuk wants to merge 6 commits into
propeller-heads:mainfrom
johngrantuk:balancer-add-reclamm
Open

feat: add Balancer V3 reclamm pool types#1049
johngrantuk wants to merge 6 commits into
propeller-heads:mainfrom
johngrantuk:balancer-add-reclamm

Conversation

@johngrantuk

@johngrantuk johngrantuk commented May 26, 2026

Copy link
Copy Markdown

Adds support for Balancer V3 reClamm pools (rebranded as AutoRange).
Initial test pool can be found here: https://balancer.fi/pools/ethereum/v3/0xda66e8ddf9959e4db759bfd06256730d8a8b2d13

Balancer V3 ReCLAMM integration — issue and fixes

Summary of the test_reclamm_swap_usdc_weth VM simulation failure and the storage/indexing
fixes applied.


Symptom

Test: test_reclamm_swap_usdc_weth in protocols/substreams/ethereum-balancer-v3
Protocol: vm:balancer_v3
Pool: 0xda66e8ddf9959e4db759bfd06256730d8a8b2d13 (ReCLAMM, USDC/WETH)
Stop block: 25129786 (index range 25129750–25129786)

During integration testing, VM state decode failed with:

StateDecodingFailure ... Simulation reverted: ArithmeticOver/Underflow

Revert data uses Solidity panic selector 0x4e487b71…0011 (Panic(17) — arithmetic
overflow/underflow).

What failed in simulation: A small WETH → USDC probe swap used while building pool state
(spot-price probes via the Tycho VM adapter). The trace showed a plausible Vault swap quote, but
the ReCLAMM pool onSwap return was wrong at USDC scale, then Vault.send USDC reverted with
panic 0x11. That pattern indicates desynced Vault vs pool state in revm, not a bad swap on
canonical mainnet (on-chain querySwapExactIn at block 25129786 succeeded for the same amounts).

Misleading pass: The test could still report passed because decode uses
skip_state_decode_failures(true) — failures are logged as warnings, update.states stays empty,
and simulation/execution steps are skipped. A green test only proved RPC component metadata matched
YAML, not that swaps decoded correctly.


Root cause (end-to-end)

Point-in-time reads for the Balancer V3 Vault at block 25129786 did not include
reservesOf[USDC] in contract_storage, while WETH reserves, pool token balances, and pool
contract storage matched mainnet.

Without that slot, the VM snapshot loaded zero/missing USDC reserves. Vault math during decode then
desynced and surfaced as panic 0x11 on the USDC send path.

Several independent bugs contributed; all had to be addressed for fresh integration-test databases
and historical stop blocks.

Layer Problem
Substreams Vault reservesOf updates from settle/sendTo were tracked as token balances but not always written to ContractChange.slots, so they never reached contract_storage.
Substreams (merge) When trace storage and synthetic reserve updates were merged, a trace “no-op” (start_value == new_value) could make has_changed() false and drop the USDC reserve slot from emitted slots.
Indexer (deserialize) Multiple ContractChange messages per Vault address in one block were not merged; later messages could overwrite earlier ones and drop slots.
Indexer (read path) get_contract_slots did not union partitioned contract_storage with contract_storage_default, so point-in-time reads could miss the current row.
Indexer (retention) retention_horizon was always Utc::now(), so superseded slot versions were discarded instead of archived — nothing to read at an earlier stop block.
Database (partitions) pg_partman only pre-creates future daily partitions. On a fresh DB replaying historical blocks, archive rows landed in contract_storage_default, hit UNIQUE (account_id, slot), and were dropped or aborted the transaction.

On-chain, Vault.settle(USDC) ran in-range (e.g. add-liquidity tx at block 25129771). The gap was
indexer persistence and snapshot assembly, not missing protocol activity.


Fixes (implemented)

Substreams — Balancer V3 (protocols/substreams/ethereum-balancer-v3)

  • get_vault_reserves + map_protocol_changes: Keep full StorageChange per reserve token and
    write each change to both ContractChange.token_balances (Vault account balances) and
    ContractChange.slots (raw contract_storage). Why: Reserve slots must follow the same path
    as other contract storage deltas.
  • tycho-substreams slot merge unification (models.rs): Both upsert_slot and
    add_contract_changes now use the same slot merge semantics for start_value/new_value.
    Why: Reserve-slot persistence must be stable regardless of merge path or event ordering.

Indexer — extract and persist

  • protobuf_deserialisation.rs: Merge multiple ContractChange entries for the same address
    per block with explicit change precedence (merge_account_delta), not slot-only merging.
    Why: Avoid losing slots while preserving creation/deletion semantics in multi-message txs.
  • contract.rsget_contract_slots: UNION partitioned contract_storage (historical
    valid_from / valid_to) with contract_storage_default (current row). Why: Point-in-time
    queries must see both archived and live slot versions.
  • contract.rs / protocol.rs — archive inserts: Archive writes are strict inserts again
    (no on_conflict_do_nothing() fallback).
    Why: Silent conflict-ignore can hide historical data loss and break point-in-time correctness.
  • contract.rsupsert_slots: Additional handling for superseded rows and default-table
    conflicts (supporting correct versioning when slots move between current and archived tables).

Indexer — retention and DB setup

  • cli.rs / main.rs: Configurable retention_horizon CLI flag (default
    2024-01-01T00:00:00) instead of hard-coded Utc::now(). Why: Integration tests replay
    historical ranges; archiving must keep versions that are still “in window” relative to the data
    being indexed, not relative to today’s clock.
  • postgres/mod.rsbackfill_historical_partitions: On migration, create daily partitions on
    contract_storage, protocol_state, and component_balance using a data-aware range:
    retention_horizon..now (fallback now - 3 months..now), with optional
    PARTITION_BACKFILL_START/END overrides.
    Why: Historical replay windows can extend beyond a fixed 3-month lookback.

Follow-up hardening status

  • Slot-merge regression tests now cover:
    • trace no-op then synthetic reserve update
    • synthetic reserve update then trace no-op
    • merge through add_contract_changes
  • Account-delta regression tests now cover:
    • creation + update preserves creation semantics
    • deletion + update keeps deletion terminal semantics
    • deletion + creation re-creates state for the tx

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@johngrantuk johngrantuk changed the title Balancer add reclamm feat: add Balancer V3 reclamm pool types May 26, 2026
@zach030

zach030 commented May 29, 2026

Copy link
Copy Markdown
Contributor

Hi! Thanks for the work.

This part wasn’t changed by you, but I suspect the logic here might be wrong.
I don’t think we should use wrapped_token as the component_id to look up the related tokens.

I have a rough idea you could use as a reference. You can look at the previous liquidityBuffer integration PR I worked on. The flow would be:

  1. Detect the liquidityBuffer creation: 1_map_components.rs
  2. Write the mapping (wrapped_token->underlying_token) into a store: 4_store_token_mapping.rs
  3. When updating vault reserves, read the wrapped_token / underlying_token relationship from that store:
    7_map_protocol_changes.rs

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

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants