galacticcouncil / galacticcouncil/hydration-node
Broadcast Unified Balance Event ( `BalanceUpdated` )
- Dominant language
- Rust
- Stars
- 208
- Forks
- 109
- Avg merge
- 6d 3h
- Merged PRs (30d)
- 3
Description
# 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
```rust
BalanceUpdated {
/// Individual balance changes caused by this operation.
impacts: BoundedVec, ConstU32<8>>,
/// What triggered this balance change.
source: BalanceSource,
/// Execution context stack, same as Swapped3.
operation_stack: Vec,
}
```
No new extrinsics or RPCs — this is a passive event emitted via hooks.
#### Types
```rust
pub struct BalanceImpact {
/// 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.
Contributor guide
Assessment
This issue has not been assessed yet.