The indexer ships a single Soroban event parser. It decodes raw XDR once, then
delegates to an EventHandlerRegistry of per-event IEventHandlers. Schema
versioning of events (see handlers/schema-version.ts) is a separate concept
from this parser implementation — do not conflate the two.
The parser contract lives in event-parser.interface.ts:
export interface IEventParser {
parse(rawEvent: RawSorobanEvent): DomainEvent | null;
}- Returns a typed
DomainEventfor a supported, well-formed contract event. - Returns
null(never throws) for non-contract events, malformed XDR, unknown event symbols, or events from unregistered contracts.
RawSorobanEvent (the raw Horizon event shape) is defined in the same file.
Ingestion services depend on this contract through the EVENT_PARSER DI token,
bound to EventParserService:
{ provide: EVENT_PARSER, useExisting: EventParserService }LedgerPollerService injects @Inject(EVENT_PARSER) eventParser: IEventParser,
so it depends on the interface rather than a concrete class.
Key components:
- EventHandlerRegistry — central registry of event handlers
- IEventHandler — interface every handler implements
- BaseEventHandler — shared utilities for handlers
- EventParserService — canonical parser used by the ingestion pipeline
- Configuration — JSON config for contracts and handlers (
config/event-handlers.json)
indexer/src/ingestor/
├── event-handler.interface.ts
├── event-handler-registry.service.ts
├── event-parser.service.ts
├── event-parser.interface.ts
├── event-handlers.module.ts
├── handlers/
│ ├── base-event.handler.ts
│ ├── schema-version.ts
│ └── ...
└── event.types.ts
1. Raw Soroban Event
↓
2. EventParserService.parse()
↓
3. Extract contract address, event name, schemaVersion
↓
4. EventHandlerRegistry.parseEvent()
↓
5. Handler.parse() → DomainEvent | null
RaffleCreated, TicketPurchased, DrawTriggered, RandomnessRequested,
RandomnessReceived, RaffleFinalized, RaffleCancelled, TicketRefunded,
ContractPaused, ContractUnpaused, AdminTransferProposed,
AdminTransferAccepted.
Covered end-to-end (real XDR → parser → handler) in event-parser.service.spec.ts.
- Add the event shape to
event.types.tsand theDomainEventunion. - Add an
IEventHandlerinhandlers/(extendBaseEventHandler). - Register it in
event-handlers.module.ts. - Add a decode test to
event-parser.service.spec.ts.
No changes to EventParserService are required.
import { Injectable } from "@nestjs/common";
import { xdr } from "@stellar/stellar-sdk";
import { BaseEventHandler } from "./base-event.handler";
import { DomainEvent } from "../event.types";
import { RawSorobanEvent } from "../event-parser.interface";
@Injectable()
export class CustomEventHandler extends BaseEventHandler {
constructor() {
super("CustomEventName");
}
parse(
topics: xdr.ScVal[],
value: xdr.ScVal,
rawEvent: RawSorobanEvent,
): DomainEvent | null {
try {
const id = this.toNumber(topics[1]);
const address = this.toString(topics[2]);
const data = this.toNative(value);
if (id === null || address === null || !data) return null;
return {
type: "CustomEvent",
id,
address,
customField: data.customField,
} as DomainEvent;
} catch {
return null;
}
}
}Config file (config/event-handlers.json):
{
"contracts": [
{
"address": "YOUR_CONTRACT_ADDRESS",
"version": "v1",
"description": "Your custom contract",
"enabled": true,
"eventHandlers": {
"CustomEventName": "CustomEventHandler"
}
}
]
}Runtime:
eventHandlerRegistry.registerHandler("CONTRACT_ADDRESS", customHandler);
// or
eventHandlerRegistry.registerContractAtRuntime(contractConfig);Env: EVENT_HANDLER_CONFIG_PATH=config/event-handlers.json
- Every parsed
DomainEventincludesschemaVersion(defaults to1). EventParserServiceresolves the version viaresolveSchemaVersion(seehandlers/schema-version.ts).EventHandlerRegistryroutes by{ contractAddress, eventName, schemaVersion }.- Multiple handler versions for the same event can coexist for rolling upgrades.
- When no exact versioned handler exists, the registry falls back to schema
version
1. raffle_events.schema_versionpersists the parsed version for audit/replay.
eventHandlerRegistry.registerHandler("CONTRACT_A", raffleCreatedV1Handler, 1);
eventHandlerRegistry.registerHandler("CONTRACT_A", raffleCreatedV2Handler, 2);- Handled — successfully parsed
- Unhandled supported — known contract, no handler for that event
- Unknown — unregistered contract
[EventParserService] [unhandled_supported] Event "NewEventType" from known contract CDLZ...
[EventParserService] [unknown] Event "CustomEvent" from unknown contract ABCD...
[EventHandlerRegistry] Registered handler for CDLZ...: RaffleCreated
- One handler per event type; use
BaseEventHandlerutilities. - Validate extracted data; return
nullrather than throwing. - Prefer config or registry registration over editing the parser class.
- Unit-test each handler; add an end-to-end case in
event-parser.service.spec.ts.
this.eventParser.getRegistry().registerContractAtRuntime(config);
this.eventParser.getRegistry().getRegisteredContracts();
this.eventParser.getRegistry().unregisterContract(address);Prefer injecting IEventParser via EVENT_PARSER in production code paths.
An earlier monolithic switch-based parser was removed after the registry-based
implementation became the sole runtime parser. File and class names no longer
carry a -v2 / V2 suffix.