Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions backend/src/event-indexer/dto/index-events.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsNumberString,
IsBoolean,
IsInt,
Min,
Max,
} from 'class-validator';
import { Transform } from 'class-transformer';

/**
* DTO for triggering a backfill of Soroban contract events.
* Consumers POST this to /api/v1/event-indexer/backfill.
*/
export class BackfillEventsDto {
@ApiProperty({
description: 'Stellar contract ID to backfill events for',
example: 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA',
})
@IsString()
contractId: string;

@ApiProperty({
description: 'Ledger sequence to start backfill from (inclusive)',
example: '1000000',
})
@IsNumberString()
fromLedger: string;

@ApiPropertyOptional({
description:
'Ledger sequence to stop backfill at (inclusive). Defaults to latest ledger.',
example: '1001000',
})
@IsOptional()
@IsNumberString()
toLedger?: string;

@ApiPropertyOptional({
description:
'Maximum number of events to index per RPC page (1-200). Defaults to 100.',
example: 100,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(200)
@Transform(({ value }) => parseInt(value, 10))
pageSize?: number;

@ApiPropertyOptional({
description:
'When true, re-index events that already exist (overwrite). Defaults to false.',
example: false,
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
force?: boolean;
}

/**
* DTO for querying indexed events with optional filters.
*/
export class QueryIndexedEventsDto {
@ApiPropertyOptional({ description: 'Filter by contract ID' })
@IsOptional()
@IsString()
contractId?: string;

@ApiPropertyOptional({ description: 'Filter by topic symbol (e.g. "level_badge_minted")' })
@IsOptional()
@IsString()
topic?: string;

@ApiPropertyOptional({ description: 'Filter by minimum ledger sequence' })
@IsOptional()
@IsNumberString()
fromLedger?: string;

@ApiPropertyOptional({ description: 'Filter by maximum ledger sequence' })
@IsOptional()
@IsNumberString()
toLedger?: string;

@ApiPropertyOptional({ description: 'Page number (1-based). Defaults to 1.' })
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => parseInt(value, 10))
page?: number;

@ApiPropertyOptional({ description: 'Page size (1-100). Defaults to 20.' })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Transform(({ value }) => parseInt(value, 10))
limit?: number;
}
80 changes: 80 additions & 0 deletions backend/src/event-indexer/entities/indexed-event.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';

/**
* Represents a single Soroban contract event that has been indexed into the
* database. Each row is immutable once written; re-indexing creates new rows
* rather than updating existing ones (append-only).
*/
@Entity('indexed_events')
@Index(['contractId', 'ledgerSequence'])
@Index(['topic', 'indexedAt'])
@Index(['schemaVersion', 'contractId'])
export class IndexedEvent {
@ApiProperty({ description: 'Internal surrogate key' })
@PrimaryGeneratedColumn('uuid')
id: string;

/** Soroban contract address that emitted the event */
@ApiProperty({ description: 'Stellar contract ID (C...) that emitted the event' })
@Column({ type: 'varchar', length: 128 })
contractId: string;

/** The first Symbol topic of the event (e.g. "level_badge_minted") */
@ApiProperty({ description: 'Primary topic symbol of the event' })
@Column({ type: 'varchar', length: 128 })
topic: string;

/**
* Additional topics beyond the first (positional order preserved).
* Stored as a JSON array of hex/string representations.
*/
@ApiProperty({ description: 'Secondary topics (JSON array)' })
@Column({ type: 'jsonb', nullable: true })
topics: string[] | null;

/** Full decoded event payload; positional fields keyed by index */
@ApiProperty({ description: 'Decoded event payload (JSON)' })
@Column({ type: 'jsonb', nullable: true })
payload: Record<string, unknown> | null;

/** Raw XDR value from the RPC response for archival/re-decode */
@ApiProperty({ description: 'Raw XDR value string for archival' })
@Column({ type: 'text', nullable: true })
rawXdr: string | null;

/** Ledger sequence at which this event was emitted */
@ApiProperty({ description: 'Stellar ledger sequence number' })
@Column({ type: 'bigint' })
ledgerSequence: string;

/** Transaction hash that produced this event */
@ApiProperty({ description: 'Transaction hash' })
@Column({ type: 'varchar', length: 128, nullable: true })
txHash: string | null;

/**
* Schema version tag. When the payload shape changes the indexer bumps
* this number and downstream consumers can filter/migrate by version.
* Follows the evolution rule from onchain/docs/event-schema.md:
* never reorder fields; increment version for any additive or breaking change.
*/
@ApiProperty({ description: 'Payload schema version' })
@Column({ type: 'smallint', default: 1 })
schemaVersion: number;

/** Whether this row was produced by a backfill run vs live polling */
@ApiProperty({ description: 'True if row was created by a backfill job' })
@Column({ type: 'boolean', default: false })
isBackfill: boolean;

@ApiProperty({ description: 'When this event was indexed into the database' })
@CreateDateColumn()
indexedAt: Date;
}
109 changes: 109 additions & 0 deletions backend/src/event-indexer/event-indexer.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
Controller,
Get,
Post,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { EventIndexerService } from './event-indexer.service';
import { BackfillEventsDto, QueryIndexedEventsDto } from './dto/index-events.dto';

/**
* Event Indexer controller.
*
* Base path is determined by the global API prefix configured in app.config.ts.
* All routes are under /api/v1/event-indexer when the default prefix is active.
*/
@ApiTags('event-indexer')
@Controller('event-indexer')
export class EventIndexerController {
private readonly logger = new Logger(EventIndexerController.name);

constructor(private readonly eventIndexerService: EventIndexerService) {}

/**
* GET /api/v1/event-indexer/health
*
* Returns the current health and operational status of the Soroban event
* indexer. Reports the latest indexed ledger, pending backfills, last poll
* timestamp, active schema version, and whether the service is healthy.
*/
@Get('health')
@ApiOperation({
summary: 'Event indexer health check',
description:
'Returns operational status of the Soroban contract event indexer, ' +
'including the latest indexed ledger, pending backfills, and schema version.',
})
@ApiResponse({
status: 200,
description: 'Indexer health status returned successfully',
schema: {
type: 'object',
properties: {
healthy: { type: 'boolean' },
latestIndexedLedger: { type: 'string', nullable: true },
pendingBackfills: { type: 'number' },
lastPollAt: { type: 'string', nullable: true },
schemaVersion: { type: 'number' },
rpcUrl: { type: 'string' },
},
},
})
getHealth() {
return this.eventIndexerService.getHealthStatus();
}

/**
* GET /api/v1/event-indexer/events
*
* Query indexed contract events with optional filters.
*/
@Get('events')
@ApiOperation({
summary: 'Query indexed contract events',
description:
'Returns a paginated list of indexed Soroban contract events. ' +
'Supports filtering by contractId, topic, and ledger range.',
})
@ApiResponse({ status: 200, description: 'Paginated event list' })
async queryEvents(@Query() dto: QueryIndexedEventsDto) {
return this.eventIndexerService.queryEvents(dto);
}

/**
* POST /api/v1/event-indexer/backfill
*
* Trigger a backfill of contract events over a specified ledger range.
* The operation is idempotent unless `force` is set.
*/
@Post('backfill')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({
summary: 'Trigger event backfill',
description:
'Initiates a backfill of Soroban contract events from `fromLedger` to ' +
'`toLedger` (defaults to latest). Idempotent unless `force: true` is passed.',
})
@ApiResponse({
status: 202,
description: 'Backfill accepted and started',
schema: {
type: 'object',
properties: {
indexed: { type: 'number' },
skipped: { type: 'number' },
},
},
})
async triggerBackfill(@Body() dto: BackfillEventsDto) {
this.logger.log(
`Backfill requested: contract=${dto.contractId} from=${dto.fromLedger} to=${dto.toLedger ?? 'latest'}`,
);
return this.eventIndexerService.backfill(dto);
}
}
31 changes: 31 additions & 0 deletions backend/src/event-indexer/event-indexer.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { EventIndexerService } from './event-indexer.service';
import { EventIndexerController } from './event-indexer.controller';
import { IndexedEvent } from './entities/indexed-event.entity';

/**
* EventIndexerModule
*
* Self-contained module responsible for:
* - Polling Soroban RPC for new contract events on a configurable interval
* - Persisting decoded events into the `indexed_events` table with schema versioning
* - Supporting backfill/resync over arbitrary ledger ranges
* - Exposing a health-check endpoint at GET /api/v1/event-indexer/health
*
* Import this module into AppModule to activate the indexer.
* Required env vars: SOROBAN_RPC_URL, STELLAR_HUNTS_CONTRACT_ID,
* STELLAR_HUNTS_NFT_CONTRACT_ID, STELLAR_MODE.
* Optional: EVENT_INDEXER_POLL_INTERVAL_MS (default 6000).
*/
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([IndexedEvent]),
],
controllers: [EventIndexerController],
providers: [EventIndexerService],
exports: [EventIndexerService],
})
export class EventIndexerModule {}
Loading
Loading