Indexing Events
Everything the shielded pool records in a block is a Substrate event of the ShieldedPool pallet.
An indexer, an explorer or a wallet backend reads them from each block's System.Events, with
@orbinum/protocol or polkadot-api. This page lists the events, shows how to read and follow
them, and how to rebuild the commitment tree's leaves from them.
The precompiles emit no EVM logs. A shield sent through the ShieldedPool precompile has a receipt
with an empty logs array, and eth_getLogs returns nothing for it. Its Shielded event is a
Substrate event; see Matching an EVM shield to its event.
Events
| Event | Fields | Emitted by |
|---|---|---|
Shielded | depositor, amount, commitment, encrypted_memo, leaf_index | shield, and once per entry of shield_batch |
NullifiersSpent | nullifiers (2) | private_transfer |
CommitmentsInserted | commitments (≤ 2), encrypted_memos (≤ 2), leaf_indices (≤ 2) | private_transfer |
Unshielded | nullifier, amount, recipient, change_commitment, change_encrypted_memo, change_leaf_index | unshield. The three change_* fields are empty for a total unshield |
MerkleRootUpdated | old_root, new_root, tree_size | Every leaf inserted, by any call |
TreeSealed | tree_id, final_root, first_leaf_index, leaf_count | The insert that fills a tree |
FeeCredited | validator, asset_id, amount | Every spend: its fee is credited to the block author's validator account |
FeesClaimed | validator, asset_id, amount | claim_fees |
AssetRegistered, AssetVerified, AssetUnverified | asset_id | The asset registry's governance calls |
| Field | Type | Decoded by polkadot-api as |
|---|---|---|
commitment, commitments, change_commitment, nullifier, nullifiers, old_root, new_root, final_root | 32 bytes, a field element in little-endian | 0x hex string |
encrypted_memo, encrypted_memos, change_encrypted_memo | 180 bytes | Uint8Array |
depositor, recipient, validator | AccountId32 | SS58 string |
amount | Balance in planck | bigint |
leaf_index, leaf_indices, change_leaf_index, tree_size, tree_id, first_leaf_index, leaf_count, asset_id | u32 | number |
An empty Option | — | undefined |
tree_sizecounts every leaf ever inserted, across the whole forest: after an insert it equals that leaf's index plus one.NullifiersSpentalways carries two entries. A zero nullifier (0x00…00) marks a dummy input: no note was spent and the runtime does not record it.ShieldedandUnshieldedcarry no asset id. The asset is an argument of the call; the pool accepts only ORB, asset0.
Order within an extrinsic
| Call | Events, in order |
|---|---|
shield | Balances.Transfer (depositor to pool account), MerkleRootUpdated, TreeSealed if the leaf filled a tree, Shielded |
shield_batch | The shield sequence once per entry |
private_transfer | MerkleRootUpdated twice, FeeCredited, NullifiersSpent, CommitmentsInserted |
unshield | Balances.Transfer (pool account to recipient), MerkleRootUpdated for a change note, FeeCredited, Unshielded |
claim_fees | Balances.Transfer (pool account to validator), FeesClaimed |
Every event of one extrinsic has the same phase, ApplyExtrinsic(index), which is how to group
them. A failed extrinsic emits none of them. A private transfer, read from a block (hashes abbreviated):
2 MerkleRootUpdated { old_root: '0x4361…b32f', new_root: '0x3b27…2418', tree_size: 10434 }
2 MerkleRootUpdated { old_root: '0x3b27…2418', new_root: '0xce3c…fd04', tree_size: 10435 }
2 FeeCredited { validator: '5Grwva…utQY', asset_id: 0, amount: '1000000000000000' }
2 NullifiersSpent { nullifiers: [ '0x0bae…ab23', '0x0000…0000' ] }
2 CommitmentsInserted { commitments: [ '0x615a…fe2a', '0xd24f…750d' ], encrypted_memos: [ … ], leaf_indices: [ 10433, 10434 ] }
Reading one block
client.substrate.queryBlockEvents(blockHash) decodes a block's events with the runtime of that
block. Each record has phase, and event.section, event.method and event.data, whose
toJSON() gives the fields with hashes and memos as hex and balances as decimal strings.
import { OrbinumClient } from '@orbinum/protocol';
const client = await OrbinumClient.connect({ substrateWs: 'wss://rpc-1.testnet.orbinum.io' });
try {
const blockNumber = Number(process.argv[2]);
const hash = await client.substrate.getBlockHash(blockNumber);
if (hash === null) throw new Error(`no block ${blockNumber}`);
const records = (await client.substrate.queryBlockEvents(hash)) ?? [];
for (const { phase, event } of records) {
if (event.section !== 'shieldedPool') continue;
const extrinsic = phase.isApplyExtrinsic ? phase.asApplyExtrinsic.toNumber() : null;
console.log(extrinsic, event.method, event.data.toJSON());
}
} finally {
client.destroy();
}
queryBlockEvents returns null when the events cannot be read, for example from a node that has
pruned that block's state. Index from an archive node to read any height.
Following finalized blocks
An indexer follows finalized blocks only, so it never records events from a block that is later
replaced. This one uses polkadot-api directly, processes every block number in order even when
finalization jumps several blocks at once, and stops on a gap in the leaf indices.
import { createClient } from 'polkadot-api';
import { getWsProvider } from 'polkadot-api/ws';
import { toHex } from '@orbinum/protocol';
// The shapes polkadot-api decodes these events into: hashes as 0x hex,
// memos as bytes, accounts as SS58, balances as bigint, None as undefined.
type PoolEvent =
| { type: 'Shielded'; value: { commitment: string; encrypted_memo: Uint8Array; leaf_index: number } }
| { type: 'CommitmentsInserted'; value: { commitments: string[]; encrypted_memos: Uint8Array[]; leaf_indices: number[] } }
| { type: 'NullifiersSpent'; value: { nullifiers: string[] } }
| {
type: 'Unshielded';
value: {
nullifier: string;
change_commitment?: string;
change_encrypted_memo?: Uint8Array;
change_leaf_index?: number;
};
}
| { type: 'MerkleRootUpdated' | 'TreeSealed' | 'FeeCredited' | 'FeesClaimed'; value: unknown };
type EventRecord = { event: { type: string; value: PoolEvent } };
type Leaf = { leafIndex: number; commitment: string; memo: string; block: number };
const ZERO = `0x${'00'.repeat(32)}`;
const client = createClient(getWsProvider('wss://rpc-1.testnet.orbinum.io'));
const api = client.getUnsafeApi();
/** The leaves a block appended, in leaf order, and the nullifiers it spent. */
async function readBlock(hash: string, block: number) {
const records = (await api.query.System.Events.getValue({ at: hash })) as EventRecord[];
const leaves: Leaf[] = [];
const spent: string[] = [];
for (const { event } of records) {
if (event.type !== 'ShieldedPool') continue;
const e = event.value;
switch (e.type) {
case 'Shielded':
leaves.push({
leafIndex: e.value.leaf_index,
commitment: e.value.commitment,
memo: toHex(e.value.encrypted_memo),
block,
});
break;
case 'CommitmentsInserted':
e.value.leaf_indices.forEach((leafIndex, i) =>
leaves.push({
leafIndex,
commitment: e.value.commitments[i]!,
memo: toHex(e.value.encrypted_memos[i]!),
block,
}),
);
break;
case 'Unshielded':
spent.push(e.value.nullifier);
if (e.value.change_leaf_index !== undefined) {
leaves.push({
leafIndex: e.value.change_leaf_index,
commitment: e.value.change_commitment!,
memo: toHex(e.value.change_encrypted_memo!),
block,
});
}
break;
case 'NullifiersSpent':
// A zero nullifier is a dummy input: nothing was spent.
spent.push(...e.value.nullifiers.filter((n) => n !== ZERO));
break;
}
}
return { leaves: leaves.sort((a, b) => a.leafIndex - b.leafIndex), spent };
}
let next = Number(process.env.FROM ?? 1); // first block not yet indexed
const stopAt = Number(process.env.TO ?? Infinity);
let expectedLeaf: number | null = null;
let running = false;
const subscription = client.finalizedBlock$.subscribe(async (finalized) => {
if (running) return; // the next emission resumes from `next`
running = true;
try {
for (; next <= Math.min(finalized.number, stopAt); next++) {
const hash: string = await client._request('chain_getBlockHash', [next]);
const { leaves, spent } = await readBlock(hash, next);
for (const leaf of leaves) {
if (expectedLeaf !== null && leaf.leafIndex !== expectedLeaf) {
throw new Error(`leaf ${expectedLeaf} missing before block ${next}`);
}
expectedLeaf = leaf.leafIndex + 1;
}
if (leaves.length || spent.length) {
console.log(`#${next}: ${leaves.length} leaves, ${spent.length} nullifiers`);
}
}
if (next > stopAt) {
subscription.unsubscribe();
client.destroy();
}
} finally {
running = false;
}
});
FROM and TO bound the run; without TO it keeps following the chain. Store each block's leaves
and nullifiers in one database transaction together with next, so a restart resumes exactly where
it stopped.
Rebuilding the leaves
No RPC method returns a leaf by its index, or the 64 leaves of a leaf block (the unit whose root is a level-6 subtree root). The events are the only source, and they are complete: every insert carries its leaf index.
| Leaf source | Index field |
|---|---|
A shield or each shield_batch entry | Shielded.leaf_index |
| A private transfer's two outputs | CommitmentsInserted.leaf_indices, in the order of commitments |
| An unshield's change note | Unshielded.change_leaf_index |
Leaf indices are global across the forest, dense, and strictly increasing in chain order, so a gap
means a missed block. The last MerkleRootUpdated.tree_size of a block equals the next leaf index,
and privacy_getPoolStats reports the same number as commitment_count. A leaf's tree is
leafIndex >> 20 (treeIdOf({ leafIndex }) in @orbinum/protocol); a tree holds 1,048,576
leaves, in 16,384 leaf blocks of 64.
To check the leaves you store, fold a full leaf block into its subtree root and compare it with
privacy_getSubtreeRoots; see Merkle Forest. A wallet
uses these leaf blocks to build its Merkle paths without naming its note to a node.
Nullifiers and commitments in separate events
A private transfer reports its nullifiers in NullifiersSpent and its new commitments in
CommitmentsInserted. They are two feeds with two consumers: a wallet's scanner downloads every
commitment and memo to find notes addressed to it, and checks spent state against the whole set of
nullifiers. Serve them as separate feeds, each read whole by every client; see
Note Discovery.
Both events come from the same extrinsic and share its phase, so they show that one transfer spent
these nullifiers and created these commitments. What they do not reveal is which earlier commitment
a nullifier spends: a nullifier is Poseidon2(commitment, spending_key), so linking it to its note
takes the owner's spending key. See Notes.
Matching an EVM shield to its event
A shield sent from an EVM wallet runs inside an Ethereum.transact extrinsic. The pallet sees the
precompile, not the user, as the depositor:
| Where | What it shows |
|---|---|
Shielded.depositor | The precompile's mapped account: 0x…0801 followed by 12 zero bytes, 5C4hrfjw9DjXZTzV3MwzrrAr9P2LdXerjSYnseqkK8c9u9Am in SS58 |
Ethereum.Executed | from (the user's EVM address), to (0x…0801), transaction_hash, exit_reason |
Balances.Transfer | The user's mapped account to the precompile account, then the precompile account to the pool account |
The EVM block number is the Substrate block number, but the EVM transaction index is not the
extrinsic index. Match on the transaction hash: find the Ethereum.Executed event whose
transaction_hash is the EVM hash, then the Shielded event with the same phase.
import { OrbinumClient } from '@orbinum/protocol';
type ShieldedData = { depositor: string; amount: string; commitment: string; leaf_index: number };
/** The `Shielded` event an EVM `shield` transaction produced, or null. */
async function shieldedEventOf(client: OrbinumClient, txHash: string): Promise<ShieldedData | null> {
const receipt = await client.evm!.request<{ blockNumber: string; status: string } | null>(
'eth_getTransactionReceipt',
[txHash],
);
if (receipt === null || receipt.status !== '0x1') return null;
const blockHash = await client.substrate.getBlockHash(Number(receipt.blockNumber));
const records = blockHash ? ((await client.substrate.queryBlockEvents(blockHash)) ?? []) : [];
// The extrinsic that carried the EVM transaction emits Ethereum.Executed with its hash.
const executed = records.find(
({ event }) =>
event.section === 'ethereum' &&
event.method === 'Executed' &&
(event.data.toJSON() as { transaction_hash: string }).transaction_hash === txHash.toLowerCase(),
);
if (!executed?.phase.isApplyExtrinsic) return null;
const extrinsic = executed.phase.asApplyExtrinsic.toNumber();
const shielded = records.find(
({ phase, event }) =>
phase.isApplyExtrinsic &&
phase.asApplyExtrinsic.eq(extrinsic) &&
event.section === 'shieldedPool' &&
event.method === 'Shielded',
);
return shielded ? (shielded.event.data.toJSON() as ShieldedData) : null;
}
const client = await OrbinumClient.connect({
substrateWs: 'wss://rpc-1.testnet.orbinum.io',
evmRpc: 'https://rpc-1.testnet.orbinum.io',
});
try {
console.log(await shieldedEventOf(client, process.argv[2]!));
} finally {
client.destroy();
}
A reverted shield has receipt status 0x0 and no Shielded event. Index the user by
Ethereum.Executed.from, never by depositor, which is the same for every EVM shield. The
precompile's call rules are in ShieldedPool precompile.
Fee events
Every spend credits its fee to the validator account of the block's author: one FeeCredited per
spend, with no token movement. claim_fees pays part or all of it out with a Balances.Transfer
from the pool account and a FeesClaimed. A validator's pending balance for an asset is the sum of
its FeeCredited amounts minus the sum of its FeesClaimed amounts, which
client.shieldedPool.pendingFees(account, assetId) reads directly. See
Spend Fees.
Related
- Reading Pool State — the privacy RPC, fees and circuit versions
- Merkle Forest — trees, sealing, subtree roots and local paths
- Note Discovery — how wallets read the commitment and nullifier feeds
- Transaction Lifecycle — what each step puts on chain
- ShieldedPool precompile — shielding from an EVM wallet
- @orbinum/protocol — the package reference