Skip to content

[indexer]: update solver balances after every order, not only on phantom snapshots #1159

Description

@seunlanlege

Solver (liquidity provider) balances in the IntentGatewayV3 indexer (sdk/packages/indexer) are only refreshed by the phantom order sweep. Every fill moves a solver's balances, and the events that record the fill already identify the solver and the tokens, so the indexer can refresh the affected balances per order as well.

Today

handlePhantomOrderPrices (src/handlers/events/substrateChains/handlePhantomOrderPrices.handler.ts) runs on PhantomBidWindowExhausted. The SDK's aggregatePhantomBids (sdk/packages/sdk/src/protocols/intents/phantom-aggregation.ts) sweeps every bidding solver's balance of every configured token on every configured chain — balanceOf plus maxWithdraw on each ERC-4626 vault in yieldVaults, plus the Uniswap V4 positions declared in the bid on the bid's own chain — and the handler writes one LiquidityProviderBalanceV2 row per (solver, chain, token). updateLiquidityPools (src/services/liquidityPool.service.ts) derives PoolBidder.liquidity, PoolChainLiquidity.depth, PoolRoute.depth and LiquidityPool.sellDepth/buyDepth from the same reads.

On mainnet the pallet generates phantom orders every PhantomOrderInterval = 300 Hyperbridge blocks and closes the bid window 25 blocks later, so balances are sampled roughly every 34 minutes (generations at blocks 11569292 → 11569592 were 19:12:00 → 19:45:36 UTC on 2026-08-21). Everything in between is invisible until the next sweep: a solver that fills a large order has its destination-chain inventory overstated for up to half an hour, and queryAvailableLiquidity (LiquidityEngine, reading PoolChainLiquidity.depth / PoolRoute.depth) keeps advertising inventory that has already been spent. Overstating is the direction that hurts — a consumer sizes an order against depth that no longer exists. Mainnet currently sees ~36 fills/day with bursts of several per hour, so the gap is exercised routinely.

What the events already tell us

Every balance-moving step of an order emits an event the indexer already handles in src/handlers/events/intentGatewayV3/, and the yield-vault handlers add a third trigger:

  • OrderFilled(commitment, filler, outputs[], inputs[]) / PartialFill(...) on the destination chain. filler is msg.sender of fillOrder, which for our solvers is the EOA delegated to SolverAccount — exactly the LiquidityProvider.id the sweep writes (the live filler 0x13e41cde… is one). outputs[] names the tokens the solver just paid out.
  • EscrowReleased(commitment, tokens[]) on the source chain: the solver just received tokens[]. The event carries no filler, but the gateway's public _filled(commitment) mapping holds the beneficiary once _withdraw finalizes (evm/src/apps/intentsv2/IntentsBase.sol), so the source node resolves it with one eth_call and never depends on the destination node having indexed the fill first.
  • Deposit(sender, owner, assets, shares) / Withdraw(sender, receiver, owner, assets, shares) on every configured ERC-4626 vault (handleVaultDepositEvent / handleVaultWithdrawEvent in src/handlers/events/yieldVault/, one datasource per vault from yieldVaults in evm-chain.yaml.hbs). owner is the LP and YieldVaultService.underlyingTokenFor(chain, vault) maps the vault to its tracked token, so the event names the exact (chain, token, solver) triple whose inventory just moved; the handlers already gate on known solvers (LiquidityProvider row or isDelegatedSolver). An LP's own deposit or withdrawal only shifts inventory between the raw and vault halves of the same total, but the total does change when the counterparty is someone else — Deposit.sender != owner (a treasury funding the solver through the vault) or Withdraw.receiver != owner (inventory leaving the solver) — and no order event reports those. Re-reading on every vault event covers both with the same code path; a same-account move just yields a row that confirms the total. Mainnet sees ~47 vault ledger events a day (142 in the last 7 days), more than fills.

Proposal

1. Re-read, don't subtract

After each of those events, for each event token that is in YIELD_VAULT_ADDRESSES[chain] (the tracked token set — the sweep has no row for anything else; for a vault event it is the vault's underlying token), re-read the solver's total balance for (chain, token) with the same arithmetic the sweep uses (raw ERC-20 + vault maxWithdraw) and write a new LiquidityProviderBalanceV2 row.

Subtracting outputs[].amount from the last row is not an option: on overfills the event carries the promised amount, not what the solver delivered (#1104), and the last row is only what the previous sweep or event saw — any transfer, yield or rebalance since then is missing from it. A re-read is also stateless — nothing to reconcile at the next sweep.

  • Read at the event's block (eth_call with the block tag), not latest, so a reindex records the balance as of each fill rather than stamping today's balance onto historical rows.
  • getTotalSolverBalance in phantom-aggregation.ts is not exported. Export it (with a block-tag parameter) so both paths share one definition of "balance".
  • Only for known providers: LiquidityProvider.get(filler), falling back to YieldVaultService.isDelegatedSolver (src/services/yieldVault.service.ts) for a solver the Hyperbridge node has not written yet — the vault ledger handlers already do exactly this.
  • Best-effort, like recordOrderVolume in orderFilledV3.event.handler.ts: a failed RPC read must not fail the fill handler and stall the chain.

1a. Fills funded from a vault or a V4 position

Simplex does not fill from wallet balance alone: VaultFundingPlanner.planWithdrawalForToken prepends vault.withdraw(amount, solver, solver) to the fill's ERC-7821 batch, and UniswapV4FundingPlanner does the same with a modifyLiquidities unwind (sdk/packages/simplex/src/funding/, consumed in ContractInteractionService.ts as calls = [...prependCalls, approvals…, fillOrder]). Funding and fill are one transaction: inventory goes venue → wallet → user inside it, and the wallet ends the block roughly where it started.

  • A wallet-only balanceOf read therefore misses a vault-funded fill entirely — the decrease is in maxWithdraw. The per-event read must be the sweep's definition (raw + every configured vault), i.e. the exported getTotalSolverBalance, never a bare balanceOf.
  • A vault-funded fill emits the vault Withdraw and OrderFilled in the same transaction, so two triggers fire for the same (chain, token, solver). eth_call at the block tag returns post-block state, so every trigger in a block reads the identical value: key event-triggered rows by block (schema below), let the first trigger write and later ones find the row and skip — the sweep already does exactly this per Hyperbridge block — and run the depth recompute once per (pool, chain, direction) per block the same way.
  • A V4-funded fill is the case that decides the V4 question below: the position is drained inside the fill while wallet + vault barely move, so anything that carries a stale V4 share forward keeps advertising inventory that was just spent.

2. Refresh depth, not just the history row

queryAvailableLiquidity reads depth, and depth is computed at aggregation time, so a new balance row on its own changes nothing a consumer sees. The solver's PoolBidder rows on that chain whose outputToken is the event token are directly addressable (id {pool}-{chain}-{direction}-{outputToken}-{solver}): set their liquidity to the new 18-decimal-normalized balance, then re-sum the affected (pool, chain, direction) PoolChainLiquidity.depth / unrestrictedDepth / PoolRoute.depth from the bidder rows and re-merge the pool's sellDepth/buyDepth — the same merge updateLiquidityPools already performs, factored so an order event can run it for one (pool, chain, direction). Rates are untouched: a fill is not a quote.

3. Schema

LiquidityProviderBalanceV2.blockNumber is documented as the Hyperbridge block and the id embeds it; an order-triggered row is taken at an EVM block on the order's chain.

  • Add a trigger enum (PHANTOM_SNAPSHOT | ORDER_FILLED | PARTIAL_FILL | ESCROW_RELEASED | VAULT_DEPOSIT | VAULT_WITHDRAW) and a nullable transactionHash.
  • Id event-triggered rows as {chain}-{token}-{solver}-{blockNumber} — one row per block per triple, since every trigger in a block reads the same post-block state (§1a); idempotent on replay and rolled back with the block under historical indexing. transactionHash records the first trigger. The sweep's ids are {chain}-{token}-{block}-{solver}, so the two shapes cannot collide.
  • Document blockNumber as the block on chain for event-triggered rows, and change the "current liquidity" rule in the schema docs from greatest blockNumber to greatest snapshotTime, since the two block spaces are not comparable.

Uniswap V4 liquidity

The sweep values the positions a bid declared in paymasterAndData, and those tokenIds are not persisted, so a per-event re-read cannot re-value them on its own. Carrying the last sweep's V4 share forward is not an option, because simplex funds fills from V4 positions too (§1a): such a fill drains the position inside the fill transaction while wallet + vault barely move, and a carried share would keep advertising inventory that was just spent — the overstatement this issue exists to remove. Persist the verified tokenIds per (provider, chain) at aggregation time (the sweep already reads positionInfo, getPositionLiquidity and getSlot0 for them, see readV4Position in phantom-aggregation.ts) so the event path re-values them with the same reads. Until that lands, a fill whose output token a carried position backs should drop the carried share rather than keep it: understating between sweeps is the safe direction.

Cost

One balanceOf plus one maxWithdraw per configured vault (at most one per token on mainnet) per event token — one or two eth_calls per event against ~36 fills and ~47 vault events a day. The EVM nodes already have an ethers provider (api) and ENV_CONFIG + safeFetch for exactly this (yieldVault.service.ts, rpc.helpers.ts). The sweep stays as the periodic full reconciliation: it still catches vault yield, rebalancing transfers, and anything that is not an order or a vault movement — plain ERC-20 transfers into or out of a solver wallet are not indexed at all, so those stay sweep-only.

Same package conventions as the other indexer changes: docs/ai/ (Flow.md for the fill path, Decisions.md for re-read-vs-subtract and the blockNumber semantics), handler tests under __tests__/.

Activity

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions