Skip to content

Broadcast Unified Balance Event ( BalanceUpdated ) #1421

@mckrava

Description

@mckrava

Feature: Broadcast Unified Balance Event (BalanceUpdated)

Summary

Add a new event to the broadcast pallet that captures every balance-affecting operation (transfers, reserves, unreserves, deposits, withdrawals, slashing, minting, EVM transfers) as a single unified event. This gives indexers a single event to track for full account balance reconstruction — including both balance movement and balance state changes (free ↔ reserved). Like Swapped3, it emits alongside the original events without replacing them.

Motivation

  • Problem: Reconstructing account balance history from on-chain data is unreliable via events (7+ event types across multiple pallets, each with different shapes) and expensive via RPC. Neither approach scales well — events are fragile to pallet changes, and RPC polling is resource-intensive and loses intra-block granularity.
  • Users/actors: Indexer developers, analytics pipelines, portfolio trackers, wallet backends.
  • Current workaround: Indexers fetch real balance state from RPC calls per account per block. This works but is expensive and loses granularity — you see the final state but not individual operations that led to it.
  • Events currently involved: Tokens.Transfer, Tokens.Deposited, Tokens.Withdrawn, Balances.Transfer, Balances.Deposited, Balances.Withdrawn, Evm.Log → Transfer, plus reserve-related events.

Design

Overview

A new event BalanceUpdated is emitted from the broadcast pallet for every operation that changes an account's total balance or balance state (free ↔ reserved). A single operation produces one BalanceUpdated event containing a vector of BalanceImpact entries — one per affected account-balance-type pair.

Note: Lock operations (set_lock/remove_lock used by staking) are not covered — locks restrict spendability but don't change total balance or move balance between free and reserved. The indexer tracks total balance and doesn't need lock granularity.

Examples:

Operation Impacts
Transfer (Alice → Bob, 100 HDX) 2 impacts: Alice free -100, Bob free +100
Reserve (Alice reserves 50 HDX for DCA) 2 impacts: Alice free -50, Alice reserved +50
Unreserve (Alice unreserves 50 HDX) 2 impacts: Alice reserved -50, Alice free +50
Repatriate reserved (Alice reserved → Bob free) 2 impacts: Alice reserved -50, Bob free +50
Deposit/Mint (50 HDX to Alice) 1 impact: Alice free +50
Burn/Withdraw (50 HDX from Alice) 1 impact: Alice free -50
EVM ERC-20 transfer 2 impacts: same as transfer (routed through orml-tokens)
XCM incoming transfer 1 impact: recipient free +X (routed through Tokens.Deposited)

Key insight: XCM transfers and EVM ERC-20 transfers are already routed through orml-tokens (visible as Tokens.Deposited/Tokens.Withdrawn and Tokens.Transfer respectively), so hooking at the orml-tokens layer captures them automatically.

Interface

Event

BalanceUpdated {
    /// Individual balance changes caused by this operation.
    impacts: BoundedVec<BalanceImpact<T::AccountId>, ConstU32<8>>,
    /// What triggered this balance change.
    source: BalanceSource,
    /// Execution context stack, same as Swapped3.
    operation_stack: Vec<ExecutionType>,
}

No new extrinsics or RPCs — this is a passive event emitted via hooks.

Types

pub struct BalanceImpact<AccountId> {
    /// The account whose balance changed.
    pub who: AccountId,
    /// Which asset.
    pub asset_id: AssetId,
    /// Amount of the change.
    pub amount: Balance,
    /// Whether balance increased or decreased for this account.
    pub direction: BalanceDirection,
    /// Which balance category was affected.
    pub balance_type: BalanceType,
}

pub enum BalanceDirection {
    /// Balance increased (deposit, incoming transfer, unreserve into free).
    Credit,
    /// Balance decreased (withdrawal, outgoing transfer, reserve from free).
    Debit,
}

pub enum BalanceType {
    /// Freely spendable balance.
    Free,
    /// Named-reserved balance (DCA schedules, OTC orders, intents, etc.).
    Reserved,
}

pub enum BalanceSource {
    /// orml-tokens transfer or mutation.
    Tokens,
    /// pallet-balances (native HDX) transfer or mutation.
    Balances,
    /// EVM ERC-20 transfer via Frontier precompile.
    Evm,
    /// XCM-initiated transfer.
    Xcm,
    /// DCA schedule reserve/unreserve.
    DCA,
    /// OTC order reserve/unreserve/fill.
    OTC,
    /// Intent swap/DCA reserve/unreserve/resolve (pallet-intent + pallet-ice).
    Intent,
    /// Omnipool liquidity operations (LP position changes).
    Omnipool,
    /// Stableswap liquidity operations.
    Stableswap,
    /// AAVE supply/withdraw/borrow/repay.
    AAVE,
    /// Dust removal / existential deposit reaping.
    DustLost,
    /// Any other source not yet categorized.
    Other,
}

State Changes

New storage items: None. The event is emitted inline — no new persistent state.

Reused storage: ExecutionContext (existing) — read via get_context() to populate operation_stack.

Interactions

Pallet / Component Interaction Hook point
orml-tokens Intercept all mutations (transfer, deposit, withdraw, reserve, unreserve, repatriate_reserved) MutationHooks trait implementation
pallet-balances Intercept native token (HDX) mutations Hooks at the pallet-balances config level
pallet-broadcast (self) Emit event, read ExecutionContext Internal

Coverage through existing routing:

  • EVM ERC-20 transfers — routed through orml-tokens, captured by MutationHooks automatically. source can be set to Evm if the execution context indicates EVM origin.
  • XCM transfers — routed through orml-tokens as Tokens.Deposited/Tokens.Withdrawn, captured by MutationHooks automatically. source can be set to Xcm if the execution context indicates XCM origin.

Design decision: Hook at the lowest layer (orml-tokens MutationHooks, pallet-balances config hooks) to capture all mutations regardless of which higher-level pallet initiated them. The BalanceSource is determined from the ExecutionContext stack or calling context.

Migration

None — this is a new additive event. No storage changes, no breaking changes to existing events.

Constraints

  • Backwards compatibility: Fully backwards-compatible. Original events (Tokens.Transfer, etc.) continue to be emitted unchanged. BalanceUpdated is emitted alongside them.
  • Performance / weight: Each balance mutation gains the overhead of one additional event emission. The impacts vector is bounded to max 8 entries via BoundedVec.
  • No duplicate events: A single balance operation must emit exactly one BalanceUpdated event. Since XCM and EVM transfers route through orml-tokens, the MutationHooks layer must not double-emit.
  • Security: Read-only / event-only feature. No new attack surface.
  • BalanceSource extensibility: New variants can be added as new pallets are integrated. This is a non-breaking change (events are not matched exhaustively on-chain).

Edge Cases

Case Handling
Zero-amount transfer Do not emit BalanceUpdated — no balance change occurred.
Transfer to self Emit event with 2 impacts (debit + credit to same account). Indexer sees net zero but knows it happened.
Existential deposit reaping Emit with source: DustLost. Impact shows the dust amount debited from free balance.
Reserve with insufficient free balance The underlying pallet returns an error — no event emitted.
Nested operations (e.g., Router → Omnipool → Token transfer) operation_stack captures the full context. The innermost transfer emits the event with the full stack.
EVM revert If the EVM transaction reverts, no state was committed — no event emitted.
Batch/utility calls Each individual balance change within a batch emits its own BalanceUpdated. operation_stack contains the batch context.
Unknown source If a balance mutation cannot be attributed to a known source, emit with source: Other. Ensures completeness — no silent gaps.

Acceptance Criteria

Transfers

  • BalanceUpdated is emitted for Tokens.Transfer with 2 impacts (sender free debit, receiver free credit), source: Tokens.
  • BalanceUpdated is emitted for Balances.Transfer with 2 impacts, source: Balances.
  • BalanceUpdated is emitted for EVM ERC-20 transfers with source: Evm.

Deposits / Withdrawals

  • BalanceUpdated is emitted for Tokens.Deposited with 1 impact (free credit).
  • BalanceUpdated is emitted for Tokens.Withdrawn with 1 impact (free debit).
  • BalanceUpdated is emitted for Balances.Deposit / Balances.Withdraw.

Reserves (DCA, OTC, Intents)

  • BalanceUpdated is emitted for reserve_named with 2 impacts: free debit + reserved credit (same account).
  • BalanceUpdated is emitted for unreserve_named with 2 impacts: reserved debit + free credit.
  • BalanceUpdated is emitted for repatriate_reserved with 2 impacts: source reserved debit + dest free credit.

General

  • Zero-amount operations do not emit BalanceUpdated.
  • No duplicate BalanceUpdated events for a single balance mutation.
  • operation_stack correctly reflects execution context (Router, DCA, Batch, etc.).
  • impacts vector is bounded (max 8 entries).
  • Existing events (Tokens.Transfer, Balances.Transfer, etc.) are unaffected.

Integration

  • End-to-end test: DCA schedule creation (reserve) → swap execution → schedule completion (unreserve) — all BalanceUpdated events have correct source: DCA, balance_type, and operation_stack.
  • End-to-end test: OTC order placement (reserve) → fill (unreserve + transfer) — correct events emitted.
  • End-to-end test: Intent submission (reserve) → ICE solution execution (unreserve + transfer to holding pot + transfer payout to owner) — correct events with source: Intent.
  • End-to-end test: DCA intent with rolling budget — reserve, trade, re-reserve, completion — all BalanceUpdated events correct.

Open Questions

  1. BalanceSource determination: How to reliably determine the BalanceSource at the MutationHooks layer? The hook sees the low-level mutation but may not know which higher-level pallet triggered it. Options: (a) derive from ExecutionContext stack, (b) add a thread-local / storage item that higher-level pallets set before calling token operations, (c) use the existing Swapper storage pattern from broadcast pallet.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions