Contract Event Extraction & Decoding
What This Enables
Section titled “What This Enables”Every Soroban smart contract can emit arbitrary events during execution. These are the primary mechanism contracts use to signal state changes to the outside world: a DEX emits a swap event, an NFT contract emits a transfer event, a governance contract emits a vote event.
Without indexing these, it is impossible to build a contract activity feed, search events by topic, or reconstruct what a contract has been doing over time.
With this feature in place, the explorer can:
- Display the full event log for any contract, paginated and searchable
- Show human-readable decoded topics alongside the raw XDR
- Filter events by topic string (e.g. find all
swapevents across a DEX contract) - Link events back to the transaction and ledger that produced them
How It Works
Section titled “How It Works”Soroban contract events live inside the TransactionMeta XDR returned in the resultMetaXdr field of getTransactions. The location depends on the meta version:
| Meta version | Event location |
|---|---|
| V3 | meta.V3.SorobanMeta.Events[] |
| V4 | meta.V4.Operations[i].Events[] (per-operation) |
For each transaction, ContractEventsFromTransaction in transform/contract_events.go decodes the meta, walks the event list, and for each ContractEvent:
- Encodes the topic list as a JSON array of base64 XDR strings (
topics_xdr) - Encodes the event value as base64 XDR (
value_xdr) - Converts topics to human-readable strings via
scValToStringand stores them intopic_1–topic_4for indexed lookups - Stores the decoded value as a JSON string (
value_decoded)
scValToString handles all ScVal types: symbols, strings, booleans, integers (i64, u64, i128, u128), addresses (account G-addresses and contract C-addresses), bytes, maps, and vecs.
Events are batch-inserted into the contract_events TimescaleDB hypertable after every ledger.
Code path:
source/rpc.go (getTransactions resultMetaXdr) → transform/contract_events.go ContractEventsFromTransaction() → contractEventFromXDR() per event → store/postgres.go InsertContractEventBatch() → contract_events (TimescaleDB hypertable)Schema
Section titled “Schema”contract_events ( contract_id TEXT, -- C-address of the emitting contract transaction_hash TEXT, -- source transaction ledger_sequence INT, type SMALLINT, -- 0 contract, 1 system, 2 diagnostic topic_1..topic_4 TEXT, -- decoded, indexed for search topics_xdr TEXT, -- JSON array of base64 XDR strings value_xdr TEXT, -- base64 XDR of the event value topics_decoded TEXT, -- JSON array of human-readable strings value_decoded TEXT, -- human-readable value string created_at TIMESTAMPTZ)Verifying It Works
Section titled “Verifying It Works”Prerequisites
Section titled “Prerequisites”Docker services running and migrations applied. The testnet has active Soroban contracts — any ledger range with Soroban activity will produce events.
1. Run the indexer
Section titled “1. Run the indexer”RPC_ENDPOINT=https://soroban-testnet.stellar.org NETWORK=testnet ./bin/indexer live2. Check that contract events landed
Section titled “2. Check that contract events landed”docker compose -f infra/docker-compose.yml exec postgres psql -U explorer -d stellar_explorer -c "SELECT contract_id, topic_1, topic_2, topic_3, value_decoded, transaction_hashFROM contract_eventsORDER BY created_at DESCLIMIT 10;"Most events will have a topic_1 that is the function name or event name (e.g. transfer, swap, mint).
3. Filter by topic to find specific event types
Section titled “3. Filter by topic to find specific event types”# Find all transfer events across all contractsdocker compose -f infra/docker-compose.yml exec postgres psql -U explorer -d stellar_explorer -c "SELECT contract_id, topic_1, topic_2, topic_3, value_decodedFROM contract_eventsWHERE topic_1 = 'transfer'ORDER BY created_at DESCLIMIT 10;"4. Inspect raw XDR alongside decoded output
Section titled “4. Inspect raw XDR alongside decoded output”docker compose -f infra/docker-compose.yml exec postgres psql -U explorer -d stellar_explorer -c "SELECT contract_id, topics_decoded, value_decoded, topics_xdrFROM contract_eventsWHERE topic_1 IS NOT NULLORDER BY created_at DESCLIMIT 5;"5. Count events per contract
Section titled “5. Count events per contract”docker compose -f infra/docker-compose.yml exec postgres psql -U explorer -d stellar_explorer -c "SELECT contract_id, COUNT(*) as event_countFROM contract_eventsGROUP BY contract_idORDER BY event_count DESCLIMIT 10;"6. Run the unit tests
Section titled “6. Run the unit tests”go test ./internal/transform/ -run TestScValToString -vgo test ./internal/transform/ -run TestContractEvents -vThese tests cover ScVal decoding for all primitive types (symbol, bool, void, i64, account address) and the empty-meta fast path.
- Events with no
contract_idin the XDR are skipped (they cannot be attributed to a contract). topic_1–topic_4cover the first four topics. Contracts emitting more than four topics will have the extras only intopics_decodedandtopics_xdr.- Diagnostic events (
type = 2) are included. They are usually emitted by the Soroban runtime itself, not the contract code. - A transaction that fails execution still produces no contract events (failed Soroban calls do not emit events).
- Event extraction failures for a single transaction are non-fatal: the ledger is still fully ingested and a warning is logged.