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
Deposits / Withdrawals
Reserves (DCA, OTC, Intents)
General
Integration
Open Questions
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.
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
Tokens.Transfer,Tokens.Deposited,Tokens.Withdrawn,Balances.Transfer,Balances.Deposited,Balances.Withdrawn,Evm.Log → Transfer, plus reserve-related events.Design
Overview
A new event
BalanceUpdatedis emitted from the broadcast pallet for every operation that changes an account's total balance or balance state (free ↔ reserved). A single operation produces oneBalanceUpdatedevent containing a vector ofBalanceImpactentries — one per affected account-balance-type pair.Note: Lock operations (
set_lock/remove_lockused 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:
orml-tokens)Tokens.Deposited)Key insight: XCM transfers and EVM ERC-20 transfers are already routed through
orml-tokens(visible asTokens.Deposited/Tokens.WithdrawnandTokens.Transferrespectively), so hooking at theorml-tokenslayer captures them automatically.Interface
Event
No new extrinsics or RPCs — this is a passive event emitted via hooks.
Types
State Changes
New storage items: None. The event is emitted inline — no new persistent state.
Reused storage:
ExecutionContext(existing) — read viaget_context()to populateoperation_stack.Interactions
orml-tokensMutationHookstrait implementationpallet-balancespallet-balancesconfig levelpallet-broadcast(self)ExecutionContextCoverage through existing routing:
orml-tokens, captured byMutationHooksautomatically.sourcecan be set toEvmif the execution context indicates EVM origin.orml-tokensasTokens.Deposited/Tokens.Withdrawn, captured byMutationHooksautomatically.sourcecan be set toXcmif the execution context indicates XCM origin.Design decision: Hook at the lowest layer (
orml-tokensMutationHooks,pallet-balancesconfig hooks) to capture all mutations regardless of which higher-level pallet initiated them. TheBalanceSourceis determined from theExecutionContextstack or calling context.Migration
None — this is a new additive event. No storage changes, no breaking changes to existing events.
Constraints
Tokens.Transfer, etc.) continue to be emitted unchanged.BalanceUpdatedis emitted alongside them.impactsvector is bounded to max 8 entries viaBoundedVec.BalanceUpdatedevent. Since XCM and EVM transfers route throughorml-tokens, theMutationHookslayer must not double-emit.BalanceSourceextensibility: 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
BalanceUpdated— no balance change occurred.source: DustLost. Impact shows the dust amount debited from free balance.operation_stackcaptures the full context. The innermost transfer emits the event with the full stack.BalanceUpdated.operation_stackcontains the batch context.source: Other. Ensures completeness — no silent gaps.Acceptance Criteria
Transfers
BalanceUpdatedis emitted forTokens.Transferwith 2 impacts (sender free debit, receiver free credit),source: Tokens.BalanceUpdatedis emitted forBalances.Transferwith 2 impacts,source: Balances.BalanceUpdatedis emitted for EVM ERC-20 transfers withsource: Evm.Deposits / Withdrawals
BalanceUpdatedis emitted forTokens.Depositedwith 1 impact (free credit).BalanceUpdatedis emitted forTokens.Withdrawnwith 1 impact (free debit).BalanceUpdatedis emitted forBalances.Deposit/Balances.Withdraw.Reserves (DCA, OTC, Intents)
BalanceUpdatedis emitted forreserve_namedwith 2 impacts: free debit + reserved credit (same account).BalanceUpdatedis emitted forunreserve_namedwith 2 impacts: reserved debit + free credit.BalanceUpdatedis emitted forrepatriate_reservedwith 2 impacts: source reserved debit + dest free credit.General
BalanceUpdated.BalanceUpdatedevents for a single balance mutation.operation_stackcorrectly reflects execution context (Router, DCA, Batch, etc.).impactsvector is bounded (max 8 entries).Tokens.Transfer,Balances.Transfer, etc.) are unaffected.Integration
BalanceUpdatedevents have correctsource: DCA,balance_type, andoperation_stack.source: Intent.BalanceUpdatedevents correct.Open Questions
BalanceSourcedetermination: How to reliably determine theBalanceSourceat theMutationHookslayer? The hook sees the low-level mutation but may not know which higher-level pallet triggered it. Options: (a) derive fromExecutionContextstack, (b) add a thread-local / storage item that higher-level pallets set before calling token operations, (c) use the existingSwapperstorage pattern from broadcast pallet.