Skip to content
balancerPublic

Repository files navigation

Balancer Legacy Exit UI

Standalone static website for withdrawing legacy Balancer positions. Currently enabled: Balancer v1 on Ethereum, plus Balancer v2/v3 on every production network in the registry (Ethereum, Polygon, Arbitrum, Gnosis, Optimism, Avalanche, Base, Polygon zkEVM, Mode, Fraxtal, HyperEVM, Plasma, X Layer, Monad).

  • Connect an injected wallet (MetaMask, Rabby, ...) or watch any address read-only
  • Optionally configure a custom RPC URL per chain (persisted in localStorage)
  • Scan for pool (BPT) and gauge (staked) positions via Multicall3 against a bundled pool list
  • Unstake from gauges (withdraw(amount, claim_rewards=true)) and claim rewards
  • Proportionally exit v1 core and smart pools (BPool.exitPool / CRP.exitPool)
  • Proportionally exit v2 pools (Vault.exitPool) and v3 pools (Router.removeLiquidityProportional)
  • Exit Xave FX and Element pools, which use their own userData and have no queryExit support: FX takes (bptIn, assets[]) and is previewed with the pool's viewWithdraw; Element takes the amounts out and burns the matching BPT from the recipient (the holder). Both exits are simulated from the user's address. Matured Element principal tokens (ePyv…) are found by the scan and can be redeemed for the underlying (Tranche.withdrawPrincipal).
  • Exits are simulated first; minimum amounts = expected − slippage (default 1%). An opt-in emergency mode submits with zero minimums.
  • Recovery-mode pools (incl. paused and linear pools) exit via the recovery path automatically; expected amounts are computed client-side (cashBalance × bptIn / virtualSupply) because BalancerQueries.queryExit reverts on paused pools while the real recovery exit succeeds.

The site is fully static: no backend, no subgraph — it only talks to the RPC you configure.

No UI? MANUAL_EXIT.md documents every exit as raw contract calls (explorer, cast, Solidity) and @balancer/sdk snippets, plus how to identify what you hold.

Run locally

npm install
npm run dev      # http://localhost:3001
npm run build    # static output in dist/

Dependencies: react, react-dom, viem. That's all.

How discovery works

Positions are scanned against per-chain pool/gauge lists committed in src/config/data/<chain>.json. These lists are generated by:

npm run discover -- --chain mode [--rpc <url>] [--to-block <n>]

eth_getLogs requests cover at most 10k blocks: some RPCs accept larger ranges but silently drop logs from the answer (seen on dRPC for 100k–1M-block ranges). Each range is also requested twice and the results merged, since a load-balanced RPC can answer from a backend that misses recent logs.

A daily GitHub Action (.github/workflows/discover.yml) keeps them current. It runs --incremental, which resumes from meta.scannedAtBlock, sweeps only newer blocks (up to 1000 blocks behind head), merges new pools/gauges in and pushes the result to main. It covers every chain marked "deprecated": true in the registry. Halted chains (head older than a day, e.g. zkEVM) are swept to their final block once and skipped afterwards; an unreachable RPC is a warning, not a failure. RPCs can be overridden with a DISCOVERY_RPC_URLS repository secret ({"mode": "https://..."}).

The script enumerates:

  • v1 pools: LOG_NEW_POOL events from the Ethereum BFactory. Like the official V1 subgraph, it classifies event.caller with CRPFactory.isCrp: a recognized caller is the user-facing smart-pool share token and the emitted pool is its backing BPool.
  • v2 pools: PoolCreated events from every pool factory (from src/config/registry.json)
  • v3 pools: PoolRegistered events from the v3 Vault (covers all v3 factories)
  • gauges: gauge-creation events from the gauge factories (GaugeCreated, plus the legacy GaugeCreated(gauge, pool) and RewardsOnlyGaugeCreated forms of the older factories)

and enriches everything (poolId, tokens, symbols, decimals, phantom-BPT detection) via Multicall3. eth_getLogs is chunked adaptively and respects strict public RPCs (zkEVM caps at 1k blocks).

Ethereum V1 discovery can be run independently with an archive-capable RPC:

npm run discover -- --chain mainnet --protocol v1 --rpc <archive-rpc-url>

Pools missing from a list can always be added in the UI by pasting the pool address.

Chain registry

src/config/registry.json holds config for all Balancer chains (Ethereum v1 factories, v2 vault + pool factories + gauge factories + start blocks, v3 vault + router, BalancerQueries, Multicall3, default RPC). It is machine-generated from sibling repos — never hand-edit it; regenerate instead:

node tools/extract-registry.mjs --repos <dir containing the 4 repos below>

Sources: the archived Balancer V1 address docs and V1 subgraph, balancer-subgraph-v2/networks.json, balancer-subgraph-v3/networks.json, gauges-subgraph/subgraph.<chain>.yaml, backend/config/<chain>.ts.

Deprecating another chain

  1. Regenerate the registry if factory lists changed: node tools/extract-registry.mjs
  2. Set "deprecated": true for the chain in src/config/registry.json (or mark it in tools/extract-registry.mjs and regenerate)
  3. Run discovery: npm run discover -- --chain <key> (the daily workflow picks it up from then on)
  4. Smoke test the exits: node tools/smoke-test.mjs --chain <key>
  5. Build and deploy

Chains that are not deprecated (currently only Sepolia) are hidden in the UI but visible in dev mode (npm run dev or append ?dev=1 to the URL).

Verification tools

node tools/smoke-test.mjs --chain mode

Headless test against the live chain (no funds needed): uses each gauge contract as a pseudo-user (gauges hold the staked BPT), simulates the exact exit calls the UI would send, and for recovery-mode pools verifies the client-side math by simulating the real exitPool with 0.5%-tight minimums.

For end-to-end testing with real transactions, fork a chain with anvil --fork-url <rpc>, set the UI's RPC to http://localhost:8545, and impersonate a holder.

Notes / limitations

  • Exits always pay out wrapped native tokens (no auto-unwrap), matching vault registration.
  • V1 was deployed only on Ethereum mainnet. V1 has no gauge discovery or unstaking path here.
  • V1 discovery intentionally watches only BFactory, matching the V1 subgraph; CRPFactory is used to classify factory-event callers and recover the smart-pool share-token address.
  • No USD pricing — amounts are token quantities.
  • FX pools convert amounts through price-oracle assimilators: a dead oracle feed would block exits.
  • Pools pasted into the manual entry are checked for FX (derivatives) and Element (bond) getters, so they never get the standard (exitKind, bptIn) userData. On FX that encoding would burn 1 wei.
  • Linear pools can only exit while in recovery mode; the UI blocks them otherwise instead of implementing batch swaps.
  • Nested/boosted pools (e.g. bb-o-USD on zkEVM) pay out the inner BPTs (linear pool tokens). Rescan after exiting — the inner BPTs show up as new positions and exit via the recovery path.
  • v3 exits require a one-time BPT approval to the v3 Router (plain ERC20 approve, no permit2).
  • Child-chain BAL rewards are distributed as regular gauge reward tokens and are included in claim_rewards.