Rebalance Activity — Methodology

Technical explainer for porting the per-vault Rebalance Activity chart (rendered on the /address page) into the production Fusion app. Covers exactly what we read on-chain, what we deliberately don't read, the heuristics behind the volume numbers, and what would change in a first-party implementation.

Contents
  1. Overview & data flow
  2. RPC methods we call (and don't call)
  3. Identifying rebalance txs
  4. Token → protocol classification
  5. USD pricing & receipt-token inference
  6. Volume math (24h / 7d / 30d / chart)
  7. Protocol chips & "Other"
  8. Output JSON schema
  9. Porting to production Fusion

1. Overview & data flow

The chart on each single-vault page (e.g. /address?addr=0xb8a4...715e) shows daily USD volume the vault has rotated between underlying lending/yield protocols. Every value on that panel — 24h / 7d / 30d totals, the daily bar chart, the protocol chips — is derived from a single static JSON file: rebalance-events-<vault>.json.

That file is produced offline by collect-rebalances.js and committed to the repo. The frontend never hits an RPC; it only fetches the JSON and re-renders.

collect-rebalances.js  ─▶  rebalance-events-<vault>.json  ─▶  address/index.html (renderRebalances)

The detector's premise: every ERC-20 Transfer event where the vault is from or to is one of:

  1. (a) a user deposit/withdraw of the vault asset, or
  2. (b) the vault supplying/withdrawing capital to/from an integrated protocol via a fuse.

We keep (b) and discard (a) by cross-referencing tx hashes already known to be user activity (from activity-events.json, which is built from ERC-4626 Deposit/Withdraw events).

2. RPC methods we call — and what we deliberately don't

Functions we read

JSON-RPC methodPurpose
eth_blockNumber Current head, used as upper bound for the scan window.
eth_getBlockByNumber Block timestamps only. Called with false for the second arg so tx bodies are not returned.
eth_getLogs The only "real" data call. Two passes per chunk:
topics: [TRANSFER_TOPIC, <padded vault>] — outflows (vault as from)
topics: [TRANSFER_TOPIC, null, <padded vault>] — inflows (vault as to)
Chunked at 10,000 blocks; on RPC error we halve recursively down to a 500-block floor.

TRANSFER_TOPIC is the canonical 0xddf2…b3ef. We never decode an ABI — we parse the raw log directly:

function parseTransfer(log) {
  if (log.topics.length !== 3) return null; // skip non-standard ERC-20s
  return {
    token:  log.address.toLowerCase(),
    from:   '0x' + log.topics[1].slice(26).toLowerCase(),
    to:     '0x' + log.topics[2].slice(26).toLowerCase(),
    amount: BigInt(log.data || '0x0'),
    block:  parseInt(log.blockNumber, 16),
    tx:     log.transactionHash,
    logIdx: parseInt(log.logIndex, 16),
  };
}

Functions we don't read

This is the important part — the production app should be aware that this implementation is intentionally a black-box "follow the tokens" approach. We do not call any of:

  • Noeth_call against the vault — no balanceOf, totalAssets, convertToAssets, convertToShares, asset(), decimals(), etc.
  • Noeth_call against the FuseManager / fuse registry — we don't enumerate the vault's connected fuses or read their state.
  • NoFuse-level events like FuseSupply / FuseWithdraw — we synthesise direction from raw Transfer topics and the vault's role in them.
  • NoPriceOracleMiddleware reads. All USD pricing comes from DeFi Llama's public price API (see §5).
  • NoUnderlying market reads on Aave / Spark / Morpho / Euler / Compound / Pendle pools — we don't ask "is this vault still supplied to market X?", we just observe the receipt-token transfer.
  • NoTrace APIs (debug_traceTransaction, trace_call) — everything is log-based for portability across public RPCs.
  • Novault-specific event ABI beyond the ERC-20 Transfer topic.
Why this matters in production:

If a fuse moves capital without producing an ERC-20 Transfer with the vault as a leg (e.g. a fuse that holds intermediary positions through a sub-account, or one that uses ERC-4626 deposit with the recipient set to a helper contract), this detector will miss it. A first-party implementation should index fuse-emitted events directly.

Reliability / RPC hygiene

  • Per-chain RPC pool with sticky "active" index (last working endpoint is tried first).
  • Per-endpoint throttle: RPC_MIN_INTERVAL_MS (default 150ms) between calls.
  • 15s timeout per request via AbortSignal.timeout.
  • Fail-over to next RPC silently on any error; halve chunk size on eth_getLogs error.
  • Per-chain initial backfill window (INITIAL_BACKFILL, ~7d) and per-run safety cap (MAX_BLOCKS_PER_RUN) to prevent runaway scans.

3. Identifying rebalance txs

After collecting all Transfer logs touching the vault, we partition by tx hash:

const userTxs = new Set(/* tx hashes from activity-events.json for this vault */);
const rebalanceTransfers = allTransfers.filter(t => !userTxs.has(t.tx.toLowerCase()));

// Group surviving transfers by tx hash
const txGroups = {};
for (const t of rebalanceTransfers) {
  (txGroups[t.tx] ||= []).push(t);
}

activity-events.json is produced by collect-activity.js from the vault's ERC-4626 Deposit and Withdraw events. Any tx hash present there is, by definition, user-initiated; we drop it from the rebalance set.

Every remaining tx is a candidate rebalance. Each transfer in the tx is tagged with a direction:

direction: t.from === vault ? 'out' : 'in'

So a typical Aave→Spark rotation tx will produce four flows: aUSDC out, USDC in, USDC out, spUSDC in (or two, if the fuse routes asset-to-asset internally).

4. Token → protocol classification

For every unique token address in the rebalance set, we resolve metadata via DeFi Llama:

GET https://coins.llama.fi/prices/current/<chain>:<tokenAddress>?searchWidth=4h
// returns { symbol, decimals, price }

Then we run an ordered regex chain (PROTOCOL_RULES) over the resolved symbol + name. Order matters — specific protocols are matched before generic stable / LST buckets so receipt tokens like sUSDe classify as Ethena, not Spark.

The current rule set (kept in lock-step between collect-rebalances.js and the PROTOCOL_RULES_FRONTEND array in address/index.html so existing JSONs re-classify on the client without a re-scan):

ProtocolkindMatch heuristic (symbol / name)
Sparklending/^sp[A-Za-z]/, sDAI, sUSDS, name contains spark, variableDebtSp*, stableDebtSp*
AavelendingaEth*, aBase*, aArb*, aPrime*, … / name contains aave / variableDebt* / stableDebt*
Compoundlending/^c[A-Z].*[Vv]3$/, name contains compound
PendleyieldPT- / YT- / SY- / LP- prefixes, name contains pendle
Eulerlendingevk-*, e<Asset>V2/Vault, name contains euler
Morpholendingname matches morpho|metamorpho or any known curator (Steakhouse, Gauntlet, Re7, Smokehouse, Hyperithm, LlamaRisk, MEV Capital, Block Analitica, 9Summits, Apostro, Tulipa, Hakutora, B.Protocol, Index Coop, Usual)
EthenacollateralUSDe, sUSDe, ENA, name contains ethenaplaced before LST/Stable to catch sUSDe first
LSTcollateralstETH, wstETH, weETH, eETH, ETHx, rETH, cbETH, swETH, frxETH, sfrxETH, mETH, osETH, rswETH, ezETH, pufETH
BTCcollateralWBTC, cbBTC, tBTC, LBTC, FBTC, solvBTC
StablecollateralUSDC, USDT, DAI, crvUSD, GHO, USDS, FRAX, LUSD, PYUSD, FDUSD, TUSD, USDP, sFRAX

kind distinguishes destination protocols (lending / yield) from transit assets (collateral). The transit kinds (LST, BTC, Stable) are excluded from the protocol chips so rotating USDC between two destinations doesn't show "Stable" as a third destination.

Anything that doesn't match returns { protocol: 'Unknown', kind: 'unknown' }. These flows feed the "Other" chip (see §7).

Frontend re-classification. address/index.html ships a duplicate of these rules and re-runs them on every load (see PROTOCOL_RULES_FRONTEND and reclassifyFlow(), around line 458). This means tightening the regex set ships immediately to all historical data — no re-scan required. Keep the two arrays in sync.

5. USD pricing & receipt-token inference

Per token, we take price from the same DeFi Llama call. Then per flow:

const amount   = Number(t.amount) / 10 ** decimals;
const usdValue = meta?.price ? amount * meta.price : null;

DeFi Llama doesn't price every fuse receipt token (e.g. spwstETH, certain MetaMorpho vault shares). For those, the data file would otherwise have a one-sided USD value — only the underlying leg is priced.

To recover the missing side, we run a per-tx proxy step:

const sumKnownOut = flows.filter(f => f.direction === 'out' && f.usdValue != null).reduce(...);
const sumKnownIn  = flows.filter(f => f.direction === 'in'  && f.usdValue != null).reduce(...);

flows.forEach(f => {
  if (f.usdValue != null) return;
  if (f.kind === 'unknown') return;       // never fabricate value for tokens we can't classify
  const proxy = f.direction === 'out' ? sumKnownIn : sumKnownOut;
  if (proxy > 0) {
    f.usdValue   = round2(proxy);
    f.usdInferred = true;                 // flagged in JSON for transparency
  }
});

Rationale: in a rebalance, the value entering the vault should equal the value leaving (modulo fees / slippage). So if we know the underlying side's USD, the receipt-token side mirrors it. This same step is duplicated on the frontend, again so old JSONs benefit from new classification rules.

6. Volume math (24h / 7d / 30d / daily chart)

The single most important detail: per-tx volume is max(|outflow USD|, |inflow USD|) — not the sum.

// Compute per-tx volume = max(|outflows|, |inflows|) to avoid double-counting rebalance pairs
const txVolumes = all.map(r => {
  let outUsd = 0, inUsd = 0, hasFlow = false;
  for (const f of r.flows) {
    if (f.kind === 'unknown' || !f.usdValue) continue;
    hasFlow = true;
    if (f.direction === 'out') outUsd += Math.abs(f.usdValue);
    else                       inUsd  += Math.abs(f.usdValue);
  }
  return { ts: r.timestamp || 0, volume: Math.max(outUsd, inUsd), tx: r.tx, hasFlow, raw: r };
}).filter(t => t.hasFlow && t.volume > 0);

Without the max, an Aave→Spark rotation of $10M would register as $20M ($10M out of Aave + $10M into Spark). With max, it reads correctly as one $10M move.

Headline stats are simple time-window sums over txVolumes:

  • 24Hnow - ts ≤ 86 400
  • 7D≤ 7 × 86 400
  • 30D≤ 30 × 86 400
  • All Time Tracked — sum over the whole dataset, with {trackedDays}d window shown as a sub-label so the value is read in context.

The daily chart bins by floor(ts / 86 400) from the earliest event up to today — gaps included as zero-volume bars. Bar height is volume / maxDailyVolume; the tooltip shows full date, USD, and tx count.

7. Protocol chips & the "Other" bucket

The chip row uses a parallel-but-not-identical aggregation. For each tx, for each protocol, we again take max(|out|, |in|) — but per-protocol within the tx:

for (const t of txVolumes) {
  const perProto = {};
  for (const f of t.raw.flows) {
    if (!f.usdValue) continue;
    if (f.kind === 'unknown' || f.protocol === 'Unknown') { /* → Other */ continue; }
    (perProto[f.protocol] ||= { out: 0, in: 0 })[f.direction] += Math.abs(f.usdValue);
  }
  for (const [proto, sides] of Object.entries(perProto)) {
    const v = Math.max(sides.out, sides.in);
    if (v <= 0) continue;
    (protoMap[proto] ||= { count: 0, volume: 0 });
    protoMap[proto].count++;
    protoMap[proto].volume += v;
  }
}

Then we strip transit kinds (Stable, LST, BTC) from the chip list so the row only shows actual destination venues. count is unique txs touching that protocol, not flow count.

"Other" aggregates anything that fell through the regex chain. To make it auditable, we keep a per-symbol breakdown and surface the top 8 in the chip's hover tooltip:

otherSymTooltip = `Tokens we couldn't classify (top by volume):
${topSyms.join('\n')}

Tell us if you recognize a protocol — we'll add it to the rules.`;

This is how the "Other" chip earns its keep: it's a data-driven backlog of missing rules. If a vault shows $50M of Other volume, that's 50M USD of fuse activity we owe a regex for.

8. Output JSON schema

One file per vault: rebalance-events-<vault>.json.

{
  "vault":     "0xb8a4...715e",
  "chain":     "ethereum",
  "updatedAt": "2026-05-04T08:23:11.413Z",
  "blockRange": { "from": 21345678, "to": 21456789 },
  "txCount":   42,
  "rebalances": [
    {
      "tx":        "0x...",
      "block":     21456000,
      "timestamp": 1746371234,
      "flows": [
        {
          "token":      "0x...",
          "symbol":     "aEthUSDC",
          "protocol":   "Aave",
          "kind":       "lending",
          "direction":  "out",
          "amount":     1234567.123456,
          "usdValue":   1234567.0,
          "usdInferred": false           // true if proxy-priced (see §5)
        },
        ...
      ]
    },
    ...
  ]
}

blockRange is the union of all scan ranges that have ever touched this file — runs are incremental: the next invocation starts from blockRange.to + 1. Records are deduped by tx hash and sorted newest-first.

9. Porting to production Fusion

The current implementation is optimised for "works on free public RPCs, runs in GitHub Actions, ships static JSON". A first-party implementation inside the Fusion app should rethink several of the trade-offs above.

Replace

  • DropPublic-RPC eth_getLogs sweeps. Use the existing Fusion indexer / subgraph. The detector chunks at 10K blocks and halves on error precisely because public RPCs are flaky and rate-limited — you don't have that constraint.
  • DropDeFi Llama for token symbol/decimals/price. Read decimals once via ERC20.decimals() (or your token metadata service) and price via Fusion's PriceOracleMiddleware. Llama latency and 4h searchWidth introduce stale prices that we currently paper over with the inference proxy in §5; with the oracle middleware that step becomes unnecessary.
  • DropRegex-based protocol classification. Fusion knows the canonical protocol identity of every token a vault touches because it knows the fuse. Replace PROTOCOL_RULES with a lookup keyed by fuseAddress → protocolName. The regex chain exists only because we have no read access to the fuse registry.
  • DropThe activity-events.json tx-hash subtraction for separating user vs rebalance txs. Use the actual fuse events (FuseSupply / FuseWithdraw or whatever the production names are) directly — they're unambiguous.

Keep

  • KeepPer-tx max(out, in) volume. The double-counting problem is structural to "rotation" semantics, not to our data source. Same logic applies even if you read fuse events directly.
  • KeepThe kind distinction between destination protocols and transit assets. The chip row needs to know which legs of a flow are "where the money went" vs. "what it was carried in".
  • KeepThe "Other" bucket as a data-quality signal, even if it should be near-zero in a first-party implementation — it's a useful safety net for new fuses that haven't been mapped yet.
  • KeepThe chart aggregation (daily bins, full history, peak-normalised heights) and stat tiles (24h / 7d / 30d / All Time + window). The UX has been validated; the data inputs are what change.

Suggested production data shape

If the production app exposes a per-vault endpoint, the minimal shape the existing renderer needs is essentially:

GET /api/vaults/:address/rebalances
{
  vault, chain, updatedAt,
  rebalances: [
    {
      tx, block, timestamp,
      flows: [
        { protocol, kind, direction, usdValue, /* symbol/amount optional but useful for tooltips */ }
      ]
    }
  ]
}

Everything from §6 / §7 onwards in renderRebalances (address/index.html, line 481) can be lifted as-is — just swap the fetch('../rebalance-events-...json') call for the new endpoint.

Files to read in this repo

FileWhat's in it
collect-rebalances.jsDetector: RPC scan, classification rules, USD inference, JSON writer. Single source of truth for the methodology.
address/index.htmlrenderRebalances()The renderer: stats tiles, chart, chips, paginated tx list. Lines ~455–710. Frontend re-classifier PROTOCOL_RULES_FRONTEND mirrors the backend rules.
collect-activity.jsProducer of activity-events.json, used for the user-tx subtraction step.
rebalance-events-<addr>.jsonPer-vault output. Schema in §8.