From 9b761479ea4d88fa409f06218faf0f7bb51b74bb Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:31:58 +0100 Subject: [PATCH 01/14] feat(event-indexer): add IndexedEvent TypeORM entity --- .../entities/indexed-event.entity.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 backend/src/event-indexer/entities/indexed-event.entity.ts diff --git a/backend/src/event-indexer/entities/indexed-event.entity.ts b/backend/src/event-indexer/entities/indexed-event.entity.ts new file mode 100644 index 00000000..aa152dca --- /dev/null +++ b/backend/src/event-indexer/entities/indexed-event.entity.ts @@ -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 | 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; +} \ No newline at end of file From c4362cdfed86b5054bf293a8cf1af05a8a384b23 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:32:08 +0100 Subject: [PATCH 02/14] feat(event-indexer): add backfill and query DTOs --- .../src/event-indexer/dto/index-events.dto.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 backend/src/event-indexer/dto/index-events.dto.ts diff --git a/backend/src/event-indexer/dto/index-events.dto.ts b/backend/src/event-indexer/dto/index-events.dto.ts new file mode 100644 index 00000000..6447bfc9 --- /dev/null +++ b/backend/src/event-indexer/dto/index-events.dto.ts @@ -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; +} \ No newline at end of file From 60653276ae68bc045aba7004b1e0a9db563b17be Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:32:38 +0100 Subject: [PATCH 03/14] feat(event-indexer): add EventIndexerService with polling, backfill, schema versioning --- .../event-indexer/event-indexer.service.ts | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 backend/src/event-indexer/event-indexer.service.ts diff --git a/backend/src/event-indexer/event-indexer.service.ts b/backend/src/event-indexer/event-indexer.service.ts new file mode 100644 index 00000000..06ab330d --- /dev/null +++ b/backend/src/event-indexer/event-indexer.service.ts @@ -0,0 +1,438 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository, DataSource } from 'typeorm'; +import { IndexedEvent } from './entities/indexed-event.entity'; +import { BackfillEventsDto, QueryIndexedEventsDto } from './dto/index-events.dto'; + +/** + * Current schema version. Increment this whenever the decoded payload shape + * changes, following the evolution rule in onchain/docs/event-schema.md: + * never reorder positional fields; add new fields at the end only. + */ +export const CURRENT_SCHEMA_VERSION = 1; + +/** How many milliseconds between live-polling ticks */ +const DEFAULT_POLL_INTERVAL_MS = 6_000; + +/** Soroban RPC event categories that we care about */ +const EVENT_TYPE = 'contract'; + +export interface SorobanEventRpcItem { + type: string; + ledger: string; + ledgerClosedAt: string; + contractId: string; + id: string; + pagingToken: string; + inSuccessfulContractCall: boolean; + topic: string[]; + value: { xdr: string }; + txHash?: string; +} + +export interface IndexerHealthStatus { + healthy: boolean; + latestIndexedLedger: string | null; + pendingBackfills: number; + lastPollAt: string | null; + schemaVersion: number; + rpcUrl: string; +} + +@Injectable() +export class EventIndexerService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(EventIndexerService.name); + private pollTimer: NodeJS.Timeout | null = null; + private latestIndexedLedger: string | null = null; + private lastPollAt: Date | null = null; + private pendingBackfills = 0; + private isPolling = false; + private destroyed = false; + + private readonly rpcUrl: string; + private readonly contractIds: string[]; + private readonly pollIntervalMs: number; + private readonly isMockMode: boolean; + + constructor( + @InjectRepository(IndexedEvent) + private readonly eventRepo: Repository, + private readonly configService: ConfigService, + private readonly dataSource: DataSource, + ) { + this.rpcUrl = + this.configService.get('SOROBAN_RPC_URL') ?? + 'https://soroban-testnet.stellar.org'; + + // Support indexing multiple contracts; comma-separated in config + const raw = [ + this.configService.get('STELLAR_HUNTS_CONTRACT_ID'), + this.configService.get('STELLAR_HUNTS_NFT_CONTRACT_ID'), + ] + .filter(Boolean) + .join(','); + this.contractIds = raw ? raw.split(',').map((s) => s.trim()) : []; + + this.pollIntervalMs = + parseInt( + this.configService.get('EVENT_INDEXER_POLL_INTERVAL_MS') ?? + String(DEFAULT_POLL_INTERVAL_MS), + 10, + ) || DEFAULT_POLL_INTERVAL_MS; + + this.isMockMode = + (this.configService.get('STELLAR_MODE') ?? 'live') === 'mock'; + } + + // ── lifecycle ────────────────────────────────────────────────────────────── + + async onModuleInit(): Promise { + if (this.isMockMode) { + this.logger.log('EventIndexer: running in mock mode — polling disabled'); + return; + } + if (this.contractIds.length === 0) { + this.logger.warn( + 'EventIndexer: no contract IDs configured — polling disabled', + ); + return; + } + + // Restore the cursor from the last indexed event so we resume correctly + await this.restoreCursor(); + this.schedulePoll(); + this.logger.log( + `EventIndexer: polling started (interval ${this.pollIntervalMs}ms, contracts: ${this.contractIds.join(', ')})`, + ); + } + + onModuleDestroy(): void { + this.destroyed = true; + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + // ── public API ───────────────────────────────────────────────────────────── + + /** + * Health snapshot consumed by the health-check controller endpoint. + */ + getHealthStatus(): IndexerHealthStatus { + return { + healthy: !this.isMockMode || this.contractIds.length > 0, + latestIndexedLedger: this.latestIndexedLedger, + pendingBackfills: this.pendingBackfills, + lastPollAt: this.lastPollAt?.toISOString() ?? null, + schemaVersion: CURRENT_SCHEMA_VERSION, + rpcUrl: this.rpcUrl, + }; + } + + /** + * Query indexed events with optional filters and pagination. + */ + async queryEvents(dto: QueryIndexedEventsDto): Promise<{ + data: IndexedEvent[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { + const page = Math.max(1, dto.page ?? 1); + const limit = Math.min(100, Math.max(1, dto.limit ?? 20)); + const skip = (page - 1) * limit; + + const qb = this.eventRepo.createQueryBuilder('event'); + + if (dto.contractId) { + qb.andWhere('event.contractId = :contractId', { + contractId: dto.contractId, + }); + } + if (dto.topic) { + qb.andWhere('event.topic = :topic', { topic: dto.topic }); + } + if (dto.fromLedger) { + qb.andWhere('CAST(event.ledgerSequence AS BIGINT) >= :fromLedger', { + fromLedger: BigInt(dto.fromLedger), + }); + } + if (dto.toLedger) { + qb.andWhere('CAST(event.ledgerSequence AS BIGINT) <= :toLedger', { + toLedger: BigInt(dto.toLedger), + }); + } + + qb.orderBy('event.ledgerSequence', 'ASC') + .addOrderBy('event.indexedAt', 'ASC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + return { data, total, page, limit, totalPages: Math.ceil(total / limit) || 1 }; + } + + /** + * Backfill events for a contract over a ledger range. + * Idempotent by default (skips already-indexed events) unless `force` is set. + */ + async backfill(dto: BackfillEventsDto): Promise<{ indexed: number; skipped: number }> { + this.pendingBackfills++; + try { + return await this._runBackfill(dto); + } finally { + this.pendingBackfills = Math.max(0, this.pendingBackfills - 1); + } + } + + // ── private helpers ──────────────────────────────────────────────────────── + + private schedulePoll(): void { + if (this.destroyed) return; + this.pollTimer = setTimeout(async () => { + await this.poll(); + this.schedulePoll(); + }, this.pollIntervalMs); + } + + private async restoreCursor(): Promise { + try { + const latest = await this.eventRepo.find({ + order: { ledgerSequence: 'DESC' }, + take: 1, + }); + if (latest.length > 0) { + this.latestIndexedLedger = latest[0].ledgerSequence; + this.logger.log( + `EventIndexer: cursor restored to ledger ${this.latestIndexedLedger}`, + ); + } + } catch (err: any) { + this.logger.warn(`EventIndexer: could not restore cursor — ${err.message}`); + } + } + + private async poll(): Promise { + if (this.isPolling) { + this.logger.debug('EventIndexer: previous poll still running, skipping tick'); + return; + } + this.isPolling = true; + this.lastPollAt = new Date(); + + try { + for (const contractId of this.contractIds) { + const cursor = this.latestIndexedLedger ?? '0'; + const events = await this.fetchEventsFromRpc(contractId, cursor, null, 200); + if (events.length === 0) continue; + + const indexed = await this.persistEvents(events, false); + if (indexed > 0) { + const maxLedger = events.reduce((max, e) => { + return BigInt(e.ledger) > BigInt(max) ? e.ledger : max; + }, events[0].ledger); + this.latestIndexedLedger = maxLedger; + this.logger.log( + `EventIndexer: indexed ${indexed} new events for contract ${contractId} up to ledger ${maxLedger}`, + ); + } + } + } catch (err: any) { + this.logger.error(`EventIndexer: poll error — ${err.message}`); + } finally { + this.isPolling = false; + } + } + + private async _runBackfill( + dto: BackfillEventsDto, + ): Promise<{ indexed: number; skipped: number }> { + const pageSize = dto.pageSize ?? 100; + let cursor = dto.fromLedger; + let totalIndexed = 0; + let totalSkipped = 0; + + this.logger.log( + `EventIndexer: backfill started for contract ${dto.contractId} ledgers ${dto.fromLedger}→${dto.toLedger ?? 'latest'}`, + ); + + while (true) { + const events = await this.fetchEventsFromRpc( + dto.contractId, + cursor, + dto.toLedger ?? null, + pageSize, + ); + + if (events.length === 0) break; + + if (dto.force) { + // Delete existing rows for this range so we re-index fresh + await this.eventRepo + .createQueryBuilder() + .delete() + .where('contractId = :contractId', { contractId: dto.contractId }) + .andWhere('CAST(ledgerSequence AS BIGINT) >= :from', { + from: BigInt(cursor), + }) + .execute(); + } + + const indexed = await this.persistEvents(events, true); + totalIndexed += indexed; + totalSkipped += events.length - indexed; + + // Advance cursor past the last ledger seen in this page + const lastLedger = events[events.length - 1].ledger; + const nextLedger = (BigInt(lastLedger) + 1n).toString(); + + if (dto.toLedger && BigInt(nextLedger) > BigInt(dto.toLedger)) break; + if (events.length < pageSize) break; + cursor = nextLedger; + } + + this.logger.log( + `EventIndexer: backfill complete — indexed ${totalIndexed}, skipped ${totalSkipped}`, + ); + return { indexed: totalIndexed, skipped: totalSkipped }; + } + + /** + * Calls the Soroban RPC `getEvents` method. + * Returns an empty array when the RPC is unreachable or returns an error. + */ + private async fetchEventsFromRpc( + contractId: string, + startLedger: string, + endLedger: string | null, + limit: number, + ): Promise { + try { + const body: Record = { + jsonrpc: '2.0', + id: 1, + method: 'getEvents', + params: { + startLedger: String(startLedger), + filters: [ + { + type: EVENT_TYPE, + contractIds: [contractId], + }, + ], + pagination: { limit }, + }, + }; + if (endLedger) { + (body.params as any).endLedger = String(endLedger); + } + + const resp = await fetch(this.rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + this.logger.warn( + `EventIndexer: RPC HTTP ${resp.status} for contract ${contractId}`, + ); + return []; + } + + const json = (await resp.json()) as any; + if (json.error) { + this.logger.warn( + `EventIndexer: RPC error ${json.error.code}: ${json.error.message}`, + ); + return []; + } + + return (json.result?.events ?? []) as SorobanEventRpcItem[]; + } catch (err: any) { + this.logger.error(`EventIndexer: fetchEventsFromRpc error — ${err.message}`); + return []; + } + } + + /** + * Decodes and persists a batch of RPC events. Skips duplicates using + * (contractId, ledgerSequence, topic, txHash) as a natural key. + * Returns the count of rows actually written. + */ + private async persistEvents( + rpcEvents: SorobanEventRpcItem[], + isBackfill: boolean, + ): Promise { + if (rpcEvents.length === 0) return 0; + + let written = 0; + for (const rpcEvent of rpcEvents) { + const [primaryTopic, ...secondaryTopics] = rpcEvent.topic ?? []; + + // Attempt to decode the XDR value to a JSON payload. + // We keep this best-effort so that an undecodable value does not + // block indexing of subsequent events. + let payload: Record | null = null; + try { + payload = this.decodeEventValue(rpcEvent.value?.xdr); + } catch { + // leave null; raw XDR is still preserved + } + + const entity = this.eventRepo.create({ + contractId: rpcEvent.contractId, + topic: primaryTopic ?? 'unknown', + topics: secondaryTopics.length > 0 ? secondaryTopics : null, + payload, + rawXdr: rpcEvent.value?.xdr ?? null, + ledgerSequence: rpcEvent.ledger, + txHash: rpcEvent.txHash ?? null, + schemaVersion: CURRENT_SCHEMA_VERSION, + isBackfill, + }); + + // ON CONFLICT DO NOTHING — idempotent insert for re-runs + try { + await this.eventRepo + .createQueryBuilder() + .insert() + .into(IndexedEvent) + .values(entity) + .orIgnore() + .execute(); + written++; + } catch (err: any) { + // Unique constraint violation means already indexed; skip silently + if (!err.message?.includes('duplicate') && !err.message?.includes('unique')) { + this.logger.warn( + `EventIndexer: failed to persist event ledger ${rpcEvent.ledger}: ${err.message}`, + ); + } + } + } + + return written; + } + + /** + * Best-effort XDR→JSON decoder. + * Attempts to use @stellar/stellar-sdk's xdr utilities when available. + * Falls back to returning the raw XDR string as `{ raw: xdr }`. + */ + private decodeEventValue(xdr: string | undefined): Record | null { + if (!xdr) return null; + try { + // Dynamic import avoids a hard dependency at module load time + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { xdr: stellarXdr } = require('@stellar/stellar-sdk'); + const scVal = stellarXdr.ScVal.fromXDR(xdr, 'base64'); + return { decoded: scVal.toXDR('base64'), raw: xdr }; + } catch { + return { raw: xdr }; + } + } +} \ No newline at end of file From f62bce920fa7a970afa76a224b635b7ea3d9c25c Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:32:53 +0100 Subject: [PATCH 04/14] feat(event-indexer): add health-check controller --- .../event-indexer/event-indexer.controller.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 backend/src/event-indexer/event-indexer.controller.ts diff --git a/backend/src/event-indexer/event-indexer.controller.ts b/backend/src/event-indexer/event-indexer.controller.ts new file mode 100644 index 00000000..e9f78fe8 --- /dev/null +++ b/backend/src/event-indexer/event-indexer.controller.ts @@ -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); + } +} \ No newline at end of file From cf52c7d0d79d9f40fab303c773381b0064584c5a Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:32:54 +0100 Subject: [PATCH 05/14] feat(event-indexer): add EventIndexerModule --- .../src/event-indexer/event-indexer.module.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/src/event-indexer/event-indexer.module.ts diff --git a/backend/src/event-indexer/event-indexer.module.ts b/backend/src/event-indexer/event-indexer.module.ts new file mode 100644 index 00000000..97f42d9b --- /dev/null +++ b/backend/src/event-indexer/event-indexer.module.ts @@ -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 {} \ No newline at end of file From fab9c0193ae2f6d781fc04ac1d9e8e8e53584dd1 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:37:23 +0100 Subject: [PATCH 06/14] feat(rewards): add SeasonalScheduleService with config-driven halving schedule --- .../src/reward/seasonal-schedule.service.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 backend/src/reward/seasonal-schedule.service.ts diff --git a/backend/src/reward/seasonal-schedule.service.ts b/backend/src/reward/seasonal-schedule.service.ts new file mode 100644 index 00000000..ec4ce833 --- /dev/null +++ b/backend/src/reward/seasonal-schedule.service.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * A single entry in the seasonal reward schedule. + * Halvings / multipliers are expressed as an absolute multiplier + * (e.g. 1.0 = baseline, 2.0 = double rewards, 0.5 = half rewards). + */ +export interface SeasonEntry { + /** Human-readable season name, e.g. "Genesis Season" */ + name: string; + /** ISO 8601 start datetime (inclusive) */ + startAt: string; + /** ISO 8601 end datetime (exclusive). Null means this season never ends. */ + endAt: string | null; + /** + * Reward multiplier for this period. + * 1.0 = normal, 2.0 = double, 0.5 = halving. + */ + multiplier: number; + /** + * Optional announcement text shown in the UI banner. + */ + announcement?: string; +} + +export interface ActiveSeasonInfo { + season: SeasonEntry | null; + multiplier: number; + nextHalvingDate: Date | null; + nextHalvingMultiplier: number | null; + announcement: string | null; +} + +/** + * Default schedule used when SEASONAL_SCHEDULE_JSON is not provided. + * Covers the initial genesis season with a 2× launch bonus that halvings + * every six months. + */ +const DEFAULT_SCHEDULE: SeasonEntry[] = [ + { + name: 'Genesis Season', + startAt: '2024-01-01T00:00:00Z', + endAt: '2024-07-01T00:00:00Z', + multiplier: 2.0, + announcement: + '🚀 Genesis Season is LIVE! Earn 2× rewards on all puzzles until July 2024.', + }, + { + name: 'Season 2', + startAt: '2024-07-01T00:00:00Z', + endAt: '2025-01-01T00:00:00Z', + multiplier: 1.5, + announcement: + '⚡ Season 2 is here! Earn 1.5× rewards until January 2025.', + }, + { + name: 'Halving Era', + startAt: '2025-01-01T00:00:00Z', + endAt: '2025-07-01T00:00:00Z', + multiplier: 1.0, + announcement: + '📉 First halving reached — standard 1× rewards apply.', + }, + { + name: 'Season 4', + startAt: '2025-07-01T00:00:00Z', + endAt: null, + multiplier: 0.75, + announcement: + '🌙 Season 4 — reduced 0.75× reward rate in effect.', + }, +]; + +/** + * SeasonalScheduleService + * + * Manages a config-driven seasonal reward schedule. The schedule is an + * ordered list of `SeasonEntry` objects read from the `SEASONAL_SCHEDULE_JSON` + * env var (or falling back to the hard-coded default above). + * + * Key responsibilities: + * - `getCurrentMultiplier()` — returns the reward multiplier currently active + * - `getNextHalvingDate()` — returns the Date of the next scheduled decrease + * - `getActiveSeasonInfo()` — full snapshot for the UI banner + */ +@Injectable() +export class SeasonalScheduleService { + private readonly logger = new Logger(SeasonalScheduleService.name); + private readonly schedule: SeasonEntry[]; + + constructor(private readonly configService: ConfigService) { + this.schedule = this.loadSchedule(); + this.logger.log( + `SeasonalScheduleService: loaded ${this.schedule.length} season entries`, + ); + } + + // ── public API ───────────────────────────────────────────────────────────── + + /** + * Returns the reward multiplier currently in effect based on wall-clock time. + * Defaults to 1.0 when no season matches the current date. + */ + getCurrentMultiplier(now: Date = new Date()): number { + const current = this.findCurrentSeason(now); + return current?.multiplier ?? 1.0; + } + + /** + * Returns the Date of the next scheduled multiplier decrease (halving). + * A "halving" is defined as any future season entry with a lower multiplier + * than the current one. Returns null if no future decrease is scheduled. + */ + getNextHalvingDate(now: Date = new Date()): Date | null { + const currentMultiplier = this.getCurrentMultiplier(now); + const futureSeason = this.schedule + .filter((s) => { + const start = new Date(s.startAt); + return start > now && s.multiplier < currentMultiplier; + }) + .sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime())[0]; + + return futureSeason ? new Date(futureSeason.startAt) : null; + } + + /** + * Full status snapshot consumed by the UI seasonal banner. + */ + getActiveSeasonInfo(now: Date = new Date()): ActiveSeasonInfo { + const season = this.findCurrentSeason(now); + const multiplier = season?.multiplier ?? 1.0; + const currentMultiplier = multiplier; + + const futureSeason = this.schedule + .filter((s) => { + const start = new Date(s.startAt); + return start > now && s.multiplier < currentMultiplier; + }) + .sort( + (a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime(), + )[0]; + + return { + season, + multiplier, + nextHalvingDate: futureSeason ? new Date(futureSeason.startAt) : null, + nextHalvingMultiplier: futureSeason?.multiplier ?? null, + announcement: season?.announcement ?? null, + }; + } + + /** + * Returns all schedule entries (read-only snapshot for admin views). + */ + getFullSchedule(): Readonly { + return Object.freeze([...this.schedule]); + } + + // ── private helpers ──────────────────────────────────────────────────────── + + private findCurrentSeason(now: Date): SeasonEntry | null { + // Entries are sorted ascending; find the last entry whose startAt ≤ now + // and whose endAt is either null or > now. + const candidates = this.schedule.filter((s) => { + const start = new Date(s.startAt); + const end = s.endAt ? new Date(s.endAt) : null; + return start <= now && (end === null || end > now); + }); + + if (candidates.length === 0) return null; + // In case of overlapping entries (should not happen) take the most recent + return candidates.sort( + (a, b) => new Date(b.startAt).getTime() - new Date(a.startAt).getTime(), + )[0]; + } + + private loadSchedule(): SeasonEntry[] { + const raw = this.configService.get('SEASONAL_SCHEDULE_JSON'); + if (!raw) { + this.logger.debug( + 'SEASONAL_SCHEDULE_JSON not set; using default schedule', + ); + return [...DEFAULT_SCHEDULE]; + } + + try { + const parsed = JSON.parse(raw) as SeasonEntry[]; + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('Schedule must be a non-empty array'); + } + return parsed.sort( + (a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime(), + ); + } catch (err: any) { + this.logger.error( + `Failed to parse SEASONAL_SCHEDULE_JSON: ${err.message}. Falling back to defaults.`, + ); + return [...DEFAULT_SCHEDULE]; + } + } +} \ No newline at end of file From 311230dad5c977ce3c0a4a7ef595b4e85e085f6b Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:37:38 +0100 Subject: [PATCH 07/14] test(rewards): add SeasonalScheduleService unit tests --- .../reward/seasonal-schedule.service.spec.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 backend/src/reward/seasonal-schedule.service.spec.ts diff --git a/backend/src/reward/seasonal-schedule.service.spec.ts b/backend/src/reward/seasonal-schedule.service.spec.ts new file mode 100644 index 00000000..424266a7 --- /dev/null +++ b/backend/src/reward/seasonal-schedule.service.spec.ts @@ -0,0 +1,180 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { SeasonalScheduleService, SeasonEntry } from './seasonal-schedule.service'; + +/** Helper to build a minimal ConfigService mock */ +function buildModule(seasonJson?: string): Promise { + return Test.createTestingModule({ + providers: [ + SeasonalScheduleService, + { + provide: ConfigService, + useValue: { + get: (key: string) => { + if (key === 'SEASONAL_SCHEDULE_JSON') return seasonJson; + return undefined; + }, + }, + }, + ], + }).compile(); +} + +const FIXED_SCHEDULE: SeasonEntry[] = [ + { + name: 'Season A', + startAt: '2024-01-01T00:00:00Z', + endAt: '2024-07-01T00:00:00Z', + multiplier: 2.0, + announcement: 'Double rewards!', + }, + { + name: 'Season B', + startAt: '2024-07-01T00:00:00Z', + endAt: '2025-01-01T00:00:00Z', + multiplier: 1.5, + announcement: '1.5x rewards', + }, + { + name: 'Season C', + startAt: '2025-01-01T00:00:00Z', + endAt: null, + multiplier: 0.5, + announcement: 'Halving in effect', + }, +]; + +describe('SeasonalScheduleService', () => { + describe('with default schedule (no env var)', () => { + let service: SeasonalScheduleService; + + beforeEach(async () => { + const module = await buildModule(undefined); + service = module.get(SeasonalScheduleService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return a multiplier of 1.0 when no season matches', () => { + // Provide a date well before the default schedule starts + const before = new Date('2000-01-01T00:00:00Z'); + expect(service.getCurrentMultiplier(before)).toBe(1.0); + }); + + it('getFullSchedule should return an array', () => { + const schedule = service.getFullSchedule(); + expect(Array.isArray(schedule)).toBe(true); + expect(schedule.length).toBeGreaterThan(0); + }); + }); + + describe('with fixed schedule via env var', () => { + let service: SeasonalScheduleService; + const json = JSON.stringify(FIXED_SCHEDULE); + + beforeEach(async () => { + const module = await buildModule(json); + service = module.get(SeasonalScheduleService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('getCurrentMultiplier returns 2.0 during Season A', () => { + const during = new Date('2024-03-15T12:00:00Z'); + expect(service.getCurrentMultiplier(during)).toBe(2.0); + }); + + it('getCurrentMultiplier returns 1.5 during Season B', () => { + const during = new Date('2024-09-01T00:00:00Z'); + expect(service.getCurrentMultiplier(during)).toBe(1.5); + }); + + it('getCurrentMultiplier returns 0.5 during Season C (open-ended)', () => { + const during = new Date('2026-06-01T00:00:00Z'); + expect(service.getCurrentMultiplier(during)).toBe(0.5); + }); + + it('getCurrentMultiplier returns 1.0 when no season is active', () => { + const before = new Date('2023-01-01T00:00:00Z'); + expect(service.getCurrentMultiplier(before)).toBe(1.0); + }); + + it('getNextHalvingDate returns the start of Season B when in Season A (multiplier drops)', () => { + const duringA = new Date('2024-03-01T00:00:00Z'); + const halvingDate = service.getNextHalvingDate(duringA); + expect(halvingDate).not.toBeNull(); + // Season B has lower multiplier (1.5) than Season A (2.0) + expect(halvingDate!.toISOString()).toBe('2024-07-01T00:00:00.000Z'); + }); + + it('getNextHalvingDate returns the start of Season C when in Season B', () => { + const duringB = new Date('2024-09-01T00:00:00Z'); + const halvingDate = service.getNextHalvingDate(duringB); + expect(halvingDate).not.toBeNull(); + expect(halvingDate!.toISOString()).toBe('2025-01-01T00:00:00.000Z'); + }); + + it('getNextHalvingDate returns null when in the last season (Season C)', () => { + const duringC = new Date('2026-01-01T00:00:00Z'); + expect(service.getNextHalvingDate(duringC)).toBeNull(); + }); + + it('getActiveSeasonInfo returns correct season name during Season A', () => { + const during = new Date('2024-04-01T00:00:00Z'); + const info = service.getActiveSeasonInfo(during); + expect(info.season?.name).toBe('Season A'); + expect(info.multiplier).toBe(2.0); + expect(info.announcement).toBe('Double rewards!'); + expect(info.nextHalvingDate).not.toBeNull(); + expect(info.nextHalvingMultiplier).toBe(1.5); + }); + + it('getActiveSeasonInfo returns null season when no season active', () => { + const before = new Date('2020-01-01T00:00:00Z'); + const info = service.getActiveSeasonInfo(before); + expect(info.season).toBeNull(); + expect(info.multiplier).toBe(1.0); + expect(info.nextHalvingDate).toBeNull(); + }); + }); + + describe('with invalid JSON in env var', () => { + it('falls back to default schedule without throwing', async () => { + const module = await buildModule('not-valid-json'); + const service = module.get(SeasonalScheduleService); + expect(service).toBeDefined(); + // Default schedule should load; multiplier should be a number + const m = service.getCurrentMultiplier(new Date()); + expect(typeof m).toBe('number'); + }); + }); + + describe('with empty array JSON in env var', () => { + it('falls back to default schedule', async () => { + const module = await buildModule('[]'); + const service = module.get(SeasonalScheduleService); + expect(service).toBeDefined(); + const schedule = service.getFullSchedule(); + expect(schedule.length).toBeGreaterThan(0); + }); + }); + + describe('schedule ordering', () => { + it('getFullSchedule entries are sorted ascending by startAt', async () => { + // Provide reversed order in JSON — service should sort it + const reversed = [...FIXED_SCHEDULE].reverse(); + const module = await buildModule(JSON.stringify(reversed)); + const service = module.get(SeasonalScheduleService); + const schedule = service.getFullSchedule(); + for (let i = 1; i < schedule.length; i++) { + const prev = new Date(schedule[i - 1].startAt).getTime(); + const curr = new Date(schedule[i].startAt).getTime(); + expect(prev).toBeLessThanOrEqual(curr); + } + }); + }); +}); \ No newline at end of file From 5eb530e8e1480f75ad20679f16bd851ec7094d11 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:38:55 +0100 Subject: [PATCH 08/14] feat(frontend): add SeasonalRewardBanner component with countdown to next halving --- frontend/components/SeasonalRewardBanner.jsx | 207 +++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 frontend/components/SeasonalRewardBanner.jsx diff --git a/frontend/components/SeasonalRewardBanner.jsx b/frontend/components/SeasonalRewardBanner.jsx new file mode 100644 index 00000000..dd52f5ea --- /dev/null +++ b/frontend/components/SeasonalRewardBanner.jsx @@ -0,0 +1,207 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; + +/** + * SeasonalRewardBanner + * -------------------- + * Displays the current season status, active reward multiplier, and a + * live countdown to the next reward halving. + * + * Props: + * @prop {string} apiBase – Base URL for the backend API, e.g. "/api/v1" + * @prop {boolean} [compact] – Render a slim single-line variant + * + * The component fetches season info from GET {apiBase}/rewards/season and + * polls every 60 seconds to pick up schedule changes without requiring a + * full page reload. + */ + +const POLL_INTERVAL_MS = 60_000; + +function formatCountdown(targetDate) { + const now = Date.now(); + const diff = new Date(targetDate).getTime() - now; + if (diff <= 0) return "Now"; + const totalSeconds = Math.floor(diff / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + return parts.join(" "); +} + +function MultiplierBadge({ multiplier }) { + const isBonus = multiplier > 1.0; + const isHalved = multiplier < 1.0; + const color = isBonus + ? "bg-green-500/20 text-green-300 border border-green-500/40" + : isHalved + ? "bg-orange-500/20 text-orange-300 border border-orange-500/40" + : "bg-blue-500/20 text-blue-300 border border-blue-500/40"; + + return ( + + {isBonus ? "🚀" : isHalved ? "📉" : "⚡"} + {multiplier}× + + ); +} + +function CountdownClock({ targetDate }) { + const [display, setDisplay] = useState(() => formatCountdown(targetDate)); + + useEffect(() => { + setDisplay(formatCountdown(targetDate)); + const timer = setInterval(() => { + setDisplay(formatCountdown(targetDate)); + }, 1000); + return () => clearInterval(timer); + }, [targetDate]); + + return ( + + {display} + + ); +} + +export function SeasonalRewardBanner({ apiBase = "/api/v1", compact = false }) { + const [seasonInfo, setSeasonInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSeasonInfo = useCallback(async () => { + try { + const resp = await fetch(`${apiBase}/rewards/season`, { + headers: { "Content-Type": "application/json" }, + // include credentials so the auth cookie travels with the request + credentials: "include", + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + setSeasonInfo(data); + setError(null); + } catch (err) { + setError(err.message ?? "Failed to load season info"); + } finally { + setLoading(false); + } + }, [apiBase]); + + useEffect(() => { + fetchSeasonInfo(); + const interval = setInterval(fetchSeasonInfo, POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchSeasonInfo]); + + // Don't render anything while loading to avoid layout shift + if (loading) return null; + + // Soft failure: render nothing rather than a broken banner + if (error || !seasonInfo) return null; + + const { + season, + multiplier = 1.0, + nextHalvingDate, + nextHalvingMultiplier, + announcement, + } = seasonInfo; + + // If there's no active season and multiplier is the base, skip the banner + if (!season && multiplier === 1.0 && !announcement) return null; + + if (compact) { + return ( +
+ {season && ( + + {season.name} + + )} + + {nextHalvingDate && ( + + Next halving: + + )} +
+ ); + } + + return ( +
+
+ {/* Season name + multiplier */} +
+ +
+ {season ? ( +

+ {season.name} +

+ ) : ( +

+ Active Season +

+ )} +
+ Reward rate: + +
+
+
+ + {/* Countdown to next halving */} + {nextHalvingDate && ( +
+ + Next halving + + + + + {nextHalvingMultiplier != null && ( + + → {nextHalvingMultiplier}× rate + + )} +
+ )} +
+ + {/* Announcement text */} + {announcement && ( +

+ {announcement} +

+ )} +
+ ); +} + +export default SeasonalRewardBanner; \ No newline at end of file From 7a23095f9dcc73b539cd08f24bb2c54b33e161a5 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:40:44 +0100 Subject: [PATCH 09/14] feat(rewards): add PrizePool TypeORM entity --- .../src/reward/entities/prize-pool.entity.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 backend/src/reward/entities/prize-pool.entity.ts diff --git a/backend/src/reward/entities/prize-pool.entity.ts b/backend/src/reward/entities/prize-pool.entity.ts new file mode 100644 index 00000000..25849683 --- /dev/null +++ b/backend/src/reward/entities/prize-pool.entity.ts @@ -0,0 +1,91 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; +import { PrizePoolPayout } from './prize-pool-payout.entity'; + +/** + * Represents a seasonal prize pool. + * One pool exists per season identifier. + * Funds are accumulated via `PrizePoolService.fundPool()` and distributed via + * `PrizePoolService.distributePool()`. + */ +@Entity('prize_pools') +@Index(['season'], { unique: true }) +export class PrizePool { + @ApiProperty({ description: 'Surrogate UUID primary key' }) + @PrimaryGeneratedColumn('uuid') + id: string; + + /** + * Season identifier, e.g. "2024-Q3" or "genesis-season". + * Must be unique — only one prize pool per season. + */ + @ApiProperty({ description: 'Season identifier (unique)' }) + @Column({ type: 'varchar', length: 128 }) + season: string; + + /** + * Human-readable name shown in the UI. + */ + @ApiProperty({ description: 'Display name for this season' }) + @Column({ type: 'varchar', length: 255, nullable: true }) + displayName: string | null; + + /** + * Current balance in the prize pool (in token units / XLM stropes). + * Stored as a string to avoid JavaScript floating-point precision loss. + */ + @ApiProperty({ description: 'Current pool balance (token units as string)' }) + @Column({ type: 'numeric', precision: 28, scale: 7, default: '0' }) + balance: string; + + /** + * Total amount funded over the pool's lifetime (not reduced by distributions). + * Used for the audit trail and display. + */ + @ApiProperty({ description: 'Total amount funded into this pool (lifetime)' }) + @Column({ type: 'numeric', precision: 28, scale: 7, default: '0' }) + totalFunded: string; + + /** + * Total amount distributed from this pool (lifetime). + */ + @ApiProperty({ description: 'Total amount distributed from this pool (lifetime)' }) + @Column({ type: 'numeric', precision: 28, scale: 7, default: '0' }) + totalDistributed: string; + + /** + * Distribution rules stored as JSON. + * Example: { "places": [0.50, 0.30, 0.20] } means top-3 get 50/30/20 %. + */ + @ApiProperty({ description: 'Distribution rules (JSON)' }) + @Column({ type: 'jsonb', nullable: true }) + distributionRules: Record | null; + + /** + * Whether the pool has been distributed (locked against further changes). + */ + @ApiProperty({ description: 'True when the pool has been fully distributed' }) + @Column({ type: 'boolean', default: false }) + isDistributed: boolean; + + @ApiProperty({ description: 'Creation timestamp' }) + @CreateDateColumn() + createdAt: Date; + + @ApiProperty({ description: 'Last update timestamp' }) + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => PrizePoolPayout, (payout) => payout.prizePool, { + cascade: true, + }) + payouts: PrizePoolPayout[]; +} \ No newline at end of file From ae35b957926dc813a0c41752ea1b85c1d888ce86 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:40:45 +0100 Subject: [PATCH 10/14] feat(rewards): add PrizePoolPayout audit-trail TypeORM entity --- .../entities/prize-pool-payout.entity.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 backend/src/reward/entities/prize-pool-payout.entity.ts diff --git a/backend/src/reward/entities/prize-pool-payout.entity.ts b/backend/src/reward/entities/prize-pool-payout.entity.ts new file mode 100644 index 00000000..f38f48ec --- /dev/null +++ b/backend/src/reward/entities/prize-pool-payout.entity.ts @@ -0,0 +1,96 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ApiProperty } from '@nestjs/swagger'; +import { PrizePool } from './prize-pool.entity'; + +/** + * Immutable audit-trail record for every fund or distribute action on a + * `PrizePool`. Each row is append-only; no updates or deletes are permitted + * by the application layer. + */ +export enum PrizePoolPayoutAction { + FUND = 'fund', + DISTRIBUTE = 'distribute', +} + +@Entity('prize_pool_payouts') +@Index(['prizePoolId', 'createdAt']) +@Index(['recipientAddress']) +export class PrizePoolPayout { + @ApiProperty({ description: 'Surrogate UUID primary key' }) + @PrimaryGeneratedColumn('uuid') + id: string; + + @ApiProperty({ description: 'Foreign key to the owning PrizePool' }) + @Column({ type: 'uuid' }) + prizePoolId: string; + + /** + * The action that produced this record: either a funding event or a + * distribution to a specific recipient. + */ + @ApiProperty({ + description: 'Action type', + enum: PrizePoolPayoutAction, + }) + @Column({ type: 'enum', enum: PrizePoolPayoutAction }) + action: PrizePoolPayoutAction; + + /** + * Stellar address of the recipient for DISTRIBUTE actions. + * Null for FUND actions. + */ + @ApiProperty({ + description: 'Recipient Stellar address (for DISTRIBUTE actions)', + nullable: true, + }) + @Column({ type: 'varchar', length: 128, nullable: true }) + recipientAddress: string | null; + + /** + * Finishing position (1-indexed) for DISTRIBUTE actions. + */ + @ApiProperty({ + description: 'Finishing position (1 = first place) for DISTRIBUTE actions', + nullable: true, + }) + @Column({ type: 'int', nullable: true }) + place: number | null; + + /** + * Amount transferred in this action (token units as string). + */ + @ApiProperty({ description: 'Amount transferred (token units as string)' }) + @Column({ type: 'numeric', precision: 28, scale: 7 }) + amount: string; + + /** + * Stellar transaction hash if this action was settled on-chain. + * Null for actions that remain off-chain (e.g. ledger credit only). + */ + @ApiProperty({ description: 'On-chain transaction hash', nullable: true }) + @Column({ type: 'varchar', length: 128, nullable: true }) + txHash: string | null; + + /** + * Arbitrary metadata (e.g. season, memo, operator notes). + */ + @ApiProperty({ description: 'Additional metadata (JSON)', nullable: true }) + @Column({ type: 'jsonb', nullable: true }) + meta: Record | null; + + @ApiProperty({ description: 'When this record was created (immutable)' }) + @CreateDateColumn() + createdAt: Date; + + @ManyToOne(() => PrizePool, (pool) => pool.payouts, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'prizePoolId' }) + prizePool: PrizePool; +} \ No newline at end of file From f40596649c2549bbf583e956670a46dd77e29429 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:41:29 +0100 Subject: [PATCH 11/14] feat(rewards): add PrizePoolService with fund/distribute/audit-trail --- backend/src/reward/prize-pool.service.ts | 320 +++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 backend/src/reward/prize-pool.service.ts diff --git a/backend/src/reward/prize-pool.service.ts b/backend/src/reward/prize-pool.service.ts new file mode 100644 index 00000000..1562f968 --- /dev/null +++ b/backend/src/reward/prize-pool.service.ts @@ -0,0 +1,320 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { PrizePool } from './entities/prize-pool.entity'; +import { + PrizePoolPayout, + PrizePoolPayoutAction, +} from './entities/prize-pool-payout.entity'; + +export interface TopFinisher { + /** Stellar public key of the winner */ + address: string; + /** Finishing position (1 = first) */ + place: number; + /** Optional on-chain transaction hash already settled by the caller */ + txHash?: string; +} + +export interface DistributionResult { + season: string; + totalDistributed: string; + payouts: { + place: number; + address: string; + amount: string; + txHash: string | null; + }[]; +} + +export interface PoolStatus { + season: string; + displayName: string | null; + balance: string; + totalFunded: string; + totalDistributed: string; + isDistributed: boolean; + distributionRules: Record | null; + payouts: PrizePoolPayout[]; +} + +/** + * Default distribution rule: top-3 split 50 / 30 / 20 percent. + * Expressed as fractions of the total pool balance. + */ +const DEFAULT_DISTRIBUTION_SHARES = [0.5, 0.3, 0.2]; + +@Injectable() +export class PrizePoolService { + private readonly logger = new Logger(PrizePoolService.name); + + constructor( + @InjectRepository(PrizePool) + private readonly poolRepo: Repository, + @InjectRepository(PrizePoolPayout) + private readonly payoutRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + // ── public API ───────────────────────────────────────────────────────────── + + /** + * Add funds to a seasonal prize pool. + * Creates the pool if it doesn't already exist. + * Logs a FUND audit-trail record for every call. + * + * @param amount Amount to add (positive numeric string, e.g. "100.0") + * @param season Season identifier, e.g. "2024-Q3" + * @param meta Optional metadata (source address, tx hash, notes) + */ + async fundPool( + amount: string, + season: string, + meta?: Record, + ): Promise { + const numericAmount = this.parsePositiveAmount(amount); + + return this.dataSource.transaction(async (manager) => { + const pools = manager.getRepository(PrizePool); + const payouts = manager.getRepository(PrizePoolPayout); + + // Upsert the pool row + let pool = await pools.findOne({ where: { season } }); + if (!pool) { + pool = pools.create({ + season, + balance: '0', + totalFunded: '0', + totalDistributed: '0', + isDistributed: false, + }); + } + + if (pool.isDistributed) { + throw new BadRequestException( + `Season "${season}" prize pool has already been distributed and is locked.`, + ); + } + + // Accumulate balance and total funded using BigInt arithmetic to + // avoid floating-point drift on large amounts. + const prevBalance = BigInt(Math.round(parseFloat(pool.balance) * 1e7)); + const prevFunded = BigInt(Math.round(parseFloat(pool.totalFunded) * 1e7)); + const addAmount = BigInt(Math.round(numericAmount * 1e7)); + + pool.balance = ((prevBalance + addAmount) / BigInt(1e7)).toString() + + '.' + + this.pad7(((prevBalance + addAmount) % BigInt(1e7)).toString()); + pool.totalFunded = ((prevFunded + addAmount) / BigInt(1e7)).toString() + + '.' + + this.pad7(((prevFunded + addAmount) % BigInt(1e7)).toString()); + + const savedPool = await pools.save(pool); + + // Append audit-trail record + const payout = payouts.create({ + prizePoolId: savedPool.id, + action: PrizePoolPayoutAction.FUND, + recipientAddress: null, + place: null, + amount: numericAmount.toFixed(7), + txHash: (meta?.txHash as string) ?? null, + meta: meta ?? null, + }); + await payouts.save(payout); + + this.logger.log( + `PrizePool FUND: season="${season}" amount=${numericAmount.toFixed(7)} new_balance=${savedPool.balance}`, + ); + + return savedPool; + }); + } + + /** + * Distribute the prize pool for a season to the top N finishers. + * The pool is locked (`isDistributed = true`) after distribution. + * Logs a DISTRIBUTE audit-trail record per winner. + * + * Distribution amounts are calculated from `distributionRules.places` + * (array of fractions summing to ≤1.0), or the default 50/30/20 split. + * + * @param season Season identifier + * @param topFinishers Ordered array of winners with their Stellar addresses + */ + async distributePool( + season: string, + topFinishers: TopFinisher[], + ): Promise { + if (!topFinishers || topFinishers.length === 0) { + throw new BadRequestException('topFinishers must not be empty'); + } + + return this.dataSource.transaction(async (manager) => { + const pools = manager.getRepository(PrizePool); + const payouts = manager.getRepository(PrizePoolPayout); + + const pool = await pools.findOne({ where: { season } }); + if (!pool) { + throw new NotFoundException(`No prize pool found for season "${season}"`); + } + if (pool.isDistributed) { + throw new ConflictException( + `Season "${season}" prize pool has already been distributed.`, + ); + } + + const totalBalance = parseFloat(pool.balance); + if (totalBalance <= 0) { + throw new BadRequestException( + `Season "${season}" prize pool has zero balance.`, + ); + } + + // Determine per-place share fractions + const shares: number[] = this.resolveShares( + pool.distributionRules, + topFinishers.length, + ); + + const distributionPayouts: DistributionResult['payouts'] = []; + let totalDistributed = 0; + + for (let i = 0; i < topFinishers.length; i++) { + const finisher = topFinishers[i]; + const share = shares[i] ?? 0; + if (share <= 0) continue; + + const amount = totalBalance * share; + totalDistributed += amount; + + const payoutRecord = payouts.create({ + prizePoolId: pool.id, + action: PrizePoolPayoutAction.DISTRIBUTE, + recipientAddress: finisher.address, + place: finisher.place, + amount: amount.toFixed(7), + txHash: finisher.txHash ?? null, + meta: { season, share, totalBalance: totalBalance.toFixed(7) }, + }); + await payouts.save(payoutRecord); + + distributionPayouts.push({ + place: finisher.place, + address: finisher.address, + amount: amount.toFixed(7), + txHash: finisher.txHash ?? null, + }); + } + + // Lock the pool and update accounting + const prevDistributed = parseFloat(pool.totalDistributed); + pool.totalDistributed = (prevDistributed + totalDistributed).toFixed(7); + pool.balance = Math.max(0, totalBalance - totalDistributed).toFixed(7); + pool.isDistributed = true; + await pools.save(pool); + + this.logger.log( + `PrizePool DISTRIBUTE: season="${season}" total_distributed=${totalDistributed.toFixed(7)} winners=${topFinishers.length}`, + ); + + return { + season, + totalDistributed: totalDistributed.toFixed(7), + payouts: distributionPayouts, + }; + }); + } + + /** + * Returns the current status of a prize pool including its balance, + * distribution rules, and full payout audit trail. + */ + async getPoolStatus(season: string): Promise { + const pool = await this.poolRepo.findOne({ + where: { season }, + relations: ['payouts'], + }); + if (!pool) { + throw new NotFoundException(`No prize pool found for season "${season}"`); + } + + return { + season: pool.season, + displayName: pool.displayName, + balance: pool.balance, + totalFunded: pool.totalFunded, + totalDistributed: pool.totalDistributed, + isDistributed: pool.isDistributed, + distributionRules: pool.distributionRules, + payouts: pool.payouts ?? [], + }; + } + + /** + * Set custom distribution rules for a season's prize pool. + * Must be called before `distributePool`. + * + * @param season Season identifier + * @param rules Distribution rules, e.g. `{ places: [0.5, 0.3, 0.2] }` + */ + async setDistributionRules( + season: string, + rules: Record, + ): Promise { + const pool = await this.poolRepo.findOne({ where: { season } }); + if (!pool) { + throw new NotFoundException(`No prize pool found for season "${season}"`); + } + if (pool.isDistributed) { + throw new BadRequestException( + `Season "${season}" pool is already distributed; rules cannot be changed.`, + ); + } + pool.distributionRules = rules; + return this.poolRepo.save(pool); + } + + /** + * List all prize pools (useful for admin dashboards). + */ + async listPools(): Promise { + return this.poolRepo.find({ order: { createdAt: 'DESC' } }); + } + + // ── private helpers ──────────────────────────────────────────────────────── + + private parsePositiveAmount(amount: string): number { + const num = parseFloat(amount); + if (!Number.isFinite(num) || num <= 0) { + throw new BadRequestException( + `Invalid amount "${amount}": must be a positive number.`, + ); + } + return num; + } + + /** + * Resolve per-place distribution shares from the pool's distributionRules + * or fall back to the default split. + */ + private resolveShares( + rules: Record | null, + winnerCount: number, + ): number[] { + const raw = (rules?.places as number[] | undefined) ?? DEFAULT_DISTRIBUTION_SHARES; + // Trim to the actual number of winners + return raw.slice(0, winnerCount); + } + + /** Left-pads a string to 7 digits (for fractional part of fixed-7 strings) */ + private pad7(s: string): string { + return s.padStart(7, '0').slice(-7); + } +} \ No newline at end of file From 2c396d308186d23c1eb0c6ae18a1b405b3978032 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:42:06 +0100 Subject: [PATCH 12/14] test(rewards): add PrizePoolService unit tests --- backend/src/reward/prize-pool.service.spec.ts | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 backend/src/reward/prize-pool.service.spec.ts diff --git a/backend/src/reward/prize-pool.service.spec.ts b/backend/src/reward/prize-pool.service.spec.ts new file mode 100644 index 00000000..0dacd06b --- /dev/null +++ b/backend/src/reward/prize-pool.service.spec.ts @@ -0,0 +1,275 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +import { PrizePoolService } from './prize-pool.service'; +import { PrizePool } from './entities/prize-pool.entity'; +import { PrizePoolPayout, PrizePoolPayoutAction } from './entities/prize-pool-payout.entity'; + +/** Minimal entity builder helpers */ +function makePrizePool(overrides: Partial = {}): PrizePool { + return { + id: 'pool-uuid-1', + season: 'test-season', + displayName: null, + balance: '0.0000000', + totalFunded: '0.0000000', + totalDistributed: '0.0000000', + distributionRules: null, + isDistributed: false, + createdAt: new Date(), + updatedAt: new Date(), + payouts: [], + ...overrides, + } as PrizePool; +} + +function makePayout(overrides: Partial = {}): PrizePoolPayout { + return { + id: 'payout-uuid-1', + prizePoolId: 'pool-uuid-1', + action: PrizePoolPayoutAction.FUND, + recipientAddress: null, + place: null, + amount: '100.0000000', + txHash: null, + meta: null, + createdAt: new Date(), + prizePool: {} as PrizePool, + ...overrides, + } as PrizePoolPayout; +} + +describe('PrizePoolService', () => { + let service: PrizePoolService; + + // Repository mocks + const mockPoolRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + }; + const mockPayoutRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + // Track manager repo mocks used inside transactions + const txPoolRepo = { ...mockPoolRepo }; + const txPayoutRepo = { ...mockPayoutRepo }; + + /** Simulates DataSource.transaction() by invoking the callback synchronously */ + const mockDataSource = { + transaction: jest.fn().mockImplementation(async (cb: any) => { + const manager = { + getRepository: (entity: any) => { + if (entity === PrizePool) return txPoolRepo; + if (entity === PrizePoolPayout) return txPayoutRepo; + throw new Error(`Unexpected entity: ${entity}`); + }, + }; + return cb(manager); + }), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + // Default happy-path mock chain for create/save + txPoolRepo.create = jest.fn().mockImplementation((v) => v); + txPoolRepo.save = jest.fn().mockImplementation(async (v) => ({ ...makePrizePool(), ...v })); + txPoolRepo.findOne = jest.fn().mockResolvedValue(null); + + txPayoutRepo.create = jest.fn().mockImplementation((v) => v); + txPayoutRepo.save = jest.fn().mockImplementation(async (v) => ({ ...makePayout(), ...v })); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PrizePoolService, + { provide: getRepositoryToken(PrizePool), useValue: mockPoolRepo }, + { provide: getRepositoryToken(PrizePoolPayout), useValue: mockPayoutRepo }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(PrizePoolService); + }); + + // ── fundPool ─────────────────────────────────────────────────────────────── + + describe('fundPool', () => { + it('creates a new pool when season does not exist', async () => { + txPoolRepo.findOne.mockResolvedValue(null); + + const result = await service.fundPool('100', 'new-season'); + expect(txPoolRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ season: 'new-season' }), + ); + expect(txPayoutRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ action: PrizePoolPayoutAction.FUND }), + ); + expect(result).toBeDefined(); + }); + + it('accumulates balance for an existing pool', async () => { + const existing = makePrizePool({ balance: '50.0000000', totalFunded: '50.0000000' }); + txPoolRepo.findOne.mockResolvedValue(existing); + txPoolRepo.save.mockImplementation(async (v) => v); + + await service.fundPool('50', 'existing-season'); + expect(txPoolRepo.save).toHaveBeenCalled(); + }); + + it('throws BadRequestException for zero amount', async () => { + await expect(service.fundPool('0', 'any-season')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException for negative amount', async () => { + await expect(service.fundPool('-10', 'any-season')).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException when pool is already distributed', async () => { + txPoolRepo.findOne.mockResolvedValue(makePrizePool({ isDistributed: true })); + await expect(service.fundPool('100', 'done-season')).rejects.toThrow( + BadRequestException, + ); + }); + + it('records fund meta when provided', async () => { + txPoolRepo.findOne.mockResolvedValue(null); + await service.fundPool('200', 'meta-season', { txHash: 'abc123', source: 'treasury' }); + expect(txPayoutRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: 'abc123', + meta: expect.objectContaining({ source: 'treasury' }), + }), + ); + }); + }); + + // ── distributePool ───────────────────────────────────────────────────────── + + describe('distributePool', () => { + const topFinishers = [ + { address: 'GAAA', place: 1 }, + { address: 'GBBB', place: 2 }, + { address: 'GCCC', place: 3 }, + ]; + + it('distributes pool to top finishers with default 50/30/20 split', async () => { + const pool = makePrizePool({ balance: '1000.0000000', totalFunded: '1000.0000000' }); + txPoolRepo.findOne.mockResolvedValue(pool); + txPoolRepo.save.mockImplementation(async (v) => v); + txPayoutRepo.save.mockImplementation(async (v) => v); + + const result = await service.distributePool('test-season', topFinishers); + expect(result.payouts).toHaveLength(3); + // 50% of 1000 = 500 + expect(parseFloat(result.payouts[0].amount)).toBeCloseTo(500, 3); + // 30% = 300 + expect(parseFloat(result.payouts[1].amount)).toBeCloseTo(300, 3); + // 20% = 200 + expect(parseFloat(result.payouts[2].amount)).toBeCloseTo(200, 3); + }); + + it('throws NotFoundException when pool does not exist', async () => { + txPoolRepo.findOne.mockResolvedValue(null); + await expect( + service.distributePool('missing-season', topFinishers), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ConflictException when pool is already distributed', async () => { + txPoolRepo.findOne.mockResolvedValue(makePrizePool({ isDistributed: true })); + await expect( + service.distributePool('done-season', topFinishers), + ).rejects.toThrow(ConflictException); + }); + + it('throws BadRequestException when pool has zero balance', async () => { + txPoolRepo.findOne.mockResolvedValue(makePrizePool({ balance: '0.0000000' })); + await expect( + service.distributePool('empty-season', topFinishers), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when topFinishers is empty', async () => { + await expect( + service.distributePool('test-season', []), + ).rejects.toThrow(BadRequestException); + }); + + it('locks the pool after distribution', async () => { + const pool = makePrizePool({ balance: '300.0000000', totalFunded: '300.0000000' }); + txPoolRepo.findOne.mockResolvedValue(pool); + let savedPool: any; + txPoolRepo.save.mockImplementation(async (v) => { + savedPool = v; + return v; + }); + txPayoutRepo.save.mockImplementation(async (v) => v); + + await service.distributePool('test-season', [{ address: 'GAAA', place: 1 }]); + expect(savedPool.isDistributed).toBe(true); + }); + + it('respects custom distributionRules', async () => { + const pool = makePrizePool({ + balance: '1000.0000000', + totalFunded: '1000.0000000', + distributionRules: { places: [0.6, 0.4] }, + }); + txPoolRepo.findOne.mockResolvedValue(pool); + txPoolRepo.save.mockImplementation(async (v) => v); + txPayoutRepo.save.mockImplementation(async (v) => v); + + const result = await service.distributePool('custom-season', [ + { address: 'GAAA', place: 1 }, + { address: 'GBBB', place: 2 }, + ]); + expect(parseFloat(result.payouts[0].amount)).toBeCloseTo(600, 3); + expect(parseFloat(result.payouts[1].amount)).toBeCloseTo(400, 3); + }); + }); + + // ── getPoolStatus ────────────────────────────────────────────────────────── + + describe('getPoolStatus', () => { + it('returns pool status with payouts', async () => { + const pool = makePrizePool({ + payouts: [makePayout()], + }); + mockPoolRepo.findOne.mockResolvedValue(pool); + + const status = await service.getPoolStatus('test-season'); + expect(status.season).toBe('test-season'); + expect(status.payouts).toHaveLength(1); + }); + + it('throws NotFoundException when pool does not exist', async () => { + mockPoolRepo.findOne.mockResolvedValue(null); + await expect(service.getPoolStatus('missing')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ── listPools ────────────────────────────────────────────────────────────── + + describe('listPools', () => { + it('returns all pools ordered by createdAt DESC', async () => { + const pools = [makePrizePool({ season: 's1' }), makePrizePool({ season: 's2' })]; + mockPoolRepo.find.mockResolvedValue(pools); + + const result = await service.listPools(); + expect(result).toHaveLength(2); + expect(mockPoolRepo.find).toHaveBeenCalledWith({ order: { createdAt: 'DESC' } }); + }); + }); +}); \ No newline at end of file From dc8c3a0fc51d770a166a15d83f0609a88d9f02f8 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:45:25 +0100 Subject: [PATCH 13/14] feat(wallet): add useWalletSession hook with in-flight tx tracking and abort on disconnect --- frontend/hooks/useWalletSession.js | 187 +++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 frontend/hooks/useWalletSession.js diff --git a/frontend/hooks/useWalletSession.js b/frontend/hooks/useWalletSession.js new file mode 100644 index 00000000..ff984cec --- /dev/null +++ b/frontend/hooks/useWalletSession.js @@ -0,0 +1,187 @@ +"use client"; + +import { useEffect, useRef, useCallback, useState } from "react"; +import { useWalletConnection, WALLET_STATUS } from "./useWalletConnection"; + +/** + * useWalletSession + * ---------------- + * A higher-level hook that wraps `useWalletConnection` and adds: + * + * 1. **In-flight transaction tracking** — callers register pending tx + * requests via `registerTx(id, controller)`. The hook holds a Map of + * `AbortController`s keyed by arbitrary tx IDs. + * + * 2. **Abort on account change / disconnect** — whenever the connected + * address changes or the wallet disconnects, all tracked in-flight + * requests are aborted immediately so stale tx flows cannot complete. + * + * 3. **App-state reset on disconnect** — calls the Zustand store's + * `resetAll` action when the wallet disconnects, clearing any + * user-specific state from the React tree. + * + * 4. **session-changed CustomEvent** — dispatches a `session-changed` + * event on `window` with `{ detail: { reason, address } }` so deeply + * nested components can react without prop-drilling. + * + * Exports (spread with `...walletConnectionState`): + * All fields from `useWalletConnection` PLUS: + * - `pendingTxCount` {number} Number of currently tracked in-flight txs + * - `registerTx` {Function} Register a tx: (id, abortController) => void + * - `abortAllTx` {Function} Manually abort all in-flight txs + * + * @param {object} opts + * @param {Function} [opts.getStore] Optional factory that returns the + * Zustand store with a `resetAll` method. + * Defaults to lazy require of + * `../store/useAppStore`. + * @param {number} [opts.pollInterval] Forwarded to useWalletConnection. + */ +export function useWalletSession(opts = {}) { + const { getStore, pollInterval } = opts; + + // ── underlying connection hook ────────────────────────────────────────── + const walletConnection = useWalletConnection({ pollInterval }); + const { address, isConnected, disconnect: baseDisconnect } = walletConnection; + + // ── in-flight tx registry ─────────────────────────────────────────────── + /** + * Map + * Using useRef so mutating it does not trigger re-renders. pendingTxCount + * is the reactive projection of the map's size. + */ + const txRegistryRef = useRef(new Map()); + const [pendingTxCount, setPendingTxCount] = useState(0); + + const syncCount = useCallback(() => { + setPendingTxCount(txRegistryRef.current.size); + }, []); + + /** + * Register an in-flight transaction. + * @param {string} id Unique identifier for this tx request + * @param {AbortController} controller AbortController whose signal is passed + * to fetch / XDR-sign calls + */ + const registerTx = useCallback( + (id, controller) => { + if (!(controller instanceof AbortController)) { + if (typeof window !== "undefined") { + console.warn( + `[useWalletSession] registerTx: expected AbortController for id="${id}"` + ); + } + return; + } + txRegistryRef.current.set(id, controller); + syncCount(); + }, + [syncCount] + ); + + /** + * Abort and deregister all tracked in-flight transactions. + * Automatically called when account changes or disconnects. + */ + const abortAllTx = useCallback( + (reason = "session-changed") => { + const registry = txRegistryRef.current; + if (registry.size === 0) return; + + for (const [id, controller] of registry.entries()) { + try { + controller.abort(reason); + } catch { + // AbortController.abort should never throw, but be defensive + } + registry.delete(id); + } + syncCount(); + }, + [syncCount] + ); + + // ── session-changed event emitter ─────────────────────────────────────── + const emitSessionChanged = useCallback((reason, newAddress) => { + if (typeof window === "undefined") return; + try { + window.dispatchEvent( + new CustomEvent("session-changed", { + detail: { reason, address: newAddress ?? null }, + bubbles: true, + }) + ); + } catch { + // CustomEvent unavailable in some SSR environments + } + }, []); + + // ── Zustand store reset ───────────────────────────────────────────────── + const resetAppStore = useCallback(() => { + try { + // Support injected store factory (for testing) or dynamic import + const factory = + getStore ?? + (() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { useAppStore } = require("../store/useAppStore"); + return useAppStore.getState(); + }); + const store = factory(); + if (typeof store?.resetAll === "function") { + store.resetAll(); + } + } catch { + // Store not available (SSR, test env, etc.) — silently skip + } + }, [getStore]); + + // ── Track previous address to detect changes ──────────────────────────── + const prevAddressRef = useRef(address); + + useEffect(() => { + const prevAddress = prevAddressRef.current; + prevAddressRef.current = address; + + // Disconnect detected: address dropped to null while was connected + if (prevAddress !== null && address === null && !isConnected) { + abortAllTx("wallet-disconnected"); + resetAppStore(); + emitSessionChanged("wallet-disconnected", null); + return; + } + + // Account switch: address changed to a different non-null value + if ( + prevAddress !== null && + address !== null && + prevAddress !== address + ) { + abortAllTx("account-changed"); + // Note: we do NOT reset the full app store on account change — + // we only abort pending txs and let the UI re-sync with the new address. + emitSessionChanged("account-changed", address); + } + }, [address, isConnected, abortAllTx, resetAppStore, emitSessionChanged]); + + // ── Wrapped disconnect that also aborts & resets ───────────────────────── + const disconnect = useCallback(() => { + abortAllTx("manual-disconnect"); + resetAppStore(); + emitSessionChanged("manual-disconnect", null); + baseDisconnect(); + }, [abortAllTx, resetAppStore, emitSessionChanged, baseDisconnect]); + + return { + // Spread all underlying wallet connection state + ...walletConnection, + // Override disconnect with the session-aware version + disconnect, + // Session-specific additions + pendingTxCount, + registerTx, + abortAllTx, + }; +} + +export default useWalletSession; \ No newline at end of file From e5847a6cadc3ab463e5f4b9ad15e065f106441c2 Mon Sep 17 00:00:00 2001 From: judithJn Date: Mon, 19 Oct 2026 20:46:12 +0100 Subject: [PATCH 14/14] test(wallet): add useWalletSession unit tests --- frontend/hooks/useWalletSession.test.js | 324 ++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 frontend/hooks/useWalletSession.test.js diff --git a/frontend/hooks/useWalletSession.test.js b/frontend/hooks/useWalletSession.test.js new file mode 100644 index 00000000..f6a6fe65 --- /dev/null +++ b/frontend/hooks/useWalletSession.test.js @@ -0,0 +1,324 @@ +/** + * @jest-environment jsdom + */ + +import { renderHook, act } from "@testing-library/react"; +import { useWalletSession } from "./useWalletSession"; + +// ── Mock useWalletConnection ──────────────────────────────────────────────── +let mockWalletState = { + status: "disconnected", + isConnected: false, + isExtensionInstalled: true, + address: null, + network: null, + unsupportedNetwork: false, + error: null, + connect: jest.fn(), + refresh: jest.fn(), + disconnect: jest.fn(), +}; + +jest.mock("./useWalletConnection", () => ({ + useWalletConnection: () => mockWalletState, + WALLET_STATUS: { + DISCONNECTED: "disconnected", + CONNECTING: "connecting", + CONNECTED: "connected", + ERROR: "error", + }, +})); + +// ── Mock Zustand store ────────────────────────────────────────────────────── +const mockResetAll = jest.fn(); +const mockGetStore = () => ({ resetAll: mockResetAll }); + +// ── Helpers ───────────────────────────────────────────────────────────────── +function makeController() { + return new AbortController(); +} + +function renderSession(storeFactory = mockGetStore) { + return renderHook(() => useWalletSession({ getStore: storeFactory })); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── +describe("useWalletSession", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockWalletState = { + status: "disconnected", + isConnected: false, + isExtensionInstalled: true, + address: null, + network: null, + unsupportedNetwork: false, + error: null, + connect: jest.fn(), + refresh: jest.fn(), + disconnect: jest.fn(), + }; + }); + + // ── initial state ────────────────────────────────────────────────────────── + + it("exposes all useWalletConnection fields in initial state", () => { + const { result } = renderSession(); + expect(result.current.isConnected).toBe(false); + expect(result.current.address).toBeNull(); + expect(result.current.status).toBe("disconnected"); + }); + + it("exposes pendingTxCount as 0 initially", () => { + const { result } = renderSession(); + expect(result.current.pendingTxCount).toBe(0); + }); + + it("exposes registerTx and abortAllTx functions", () => { + const { result } = renderSession(); + expect(typeof result.current.registerTx).toBe("function"); + expect(typeof result.current.abortAllTx).toBe("function"); + }); + + // ── registerTx / pendingTxCount ───────────────────────────────────────── + + it("increments pendingTxCount when a tx is registered", () => { + const { result } = renderSession(); + const ctrl = makeController(); + + act(() => { + result.current.registerTx("tx-1", ctrl); + }); + + expect(result.current.pendingTxCount).toBe(1); + }); + + it("tracks multiple in-flight txs", () => { + const { result } = renderSession(); + + act(() => { + result.current.registerTx("tx-1", makeController()); + result.current.registerTx("tx-2", makeController()); + result.current.registerTx("tx-3", makeController()); + }); + + expect(result.current.pendingTxCount).toBe(3); + }); + + it("does not blow up when a non-AbortController is passed", () => { + const { result } = renderSession(); + // Should log a warning but not throw + expect(() => { + act(() => { + result.current.registerTx("bad-tx", { signal: null }); + }); + }).not.toThrow(); + // Should not register the entry + expect(result.current.pendingTxCount).toBe(0); + }); + + // ── abortAllTx ───────────────────────────────────────────────────────────── + + it("aborts all controllers and resets count on abortAllTx()", () => { + const { result } = renderSession(); + const ctrl1 = makeController(); + const ctrl2 = makeController(); + + act(() => { + result.current.registerTx("tx-1", ctrl1); + result.current.registerTx("tx-2", ctrl2); + }); + + act(() => { + result.current.abortAllTx("manual"); + }); + + expect(ctrl1.signal.aborted).toBe(true); + expect(ctrl2.signal.aborted).toBe(true); + expect(result.current.pendingTxCount).toBe(0); + }); + + it("abortAllTx is a no-op when no txs are registered", () => { + const { result } = renderSession(); + expect(() => { + act(() => { + result.current.abortAllTx(); + }); + }).not.toThrow(); + expect(result.current.pendingTxCount).toBe(0); + }); + + // ── disconnect ────────────────────────────────────────────────────────────── + + it("calls resetAll and baseDisconnect when disconnect() is called", () => { + const { result } = renderSession(); + + act(() => { + result.current.disconnect(); + }); + + expect(mockResetAll).toHaveBeenCalledTimes(1); + expect(mockWalletState.disconnect).toHaveBeenCalledTimes(1); + }); + + it("aborts all in-flight txs on manual disconnect", () => { + const { result } = renderSession(); + const ctrl = makeController(); + + act(() => { + result.current.registerTx("tx-1", ctrl); + }); + + act(() => { + result.current.disconnect(); + }); + + expect(ctrl.signal.aborted).toBe(true); + expect(result.current.pendingTxCount).toBe(0); + }); + + it("dispatches session-changed event on disconnect", () => { + const { result } = renderSession(); + const listener = jest.fn(); + window.addEventListener("session-changed", listener); + + act(() => { + result.current.disconnect(); + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].detail.reason).toBe("manual-disconnect"); + window.removeEventListener("session-changed", listener); + }); + + // ── account-change detection ───────────────────────────────────────────── + + it("aborts in-flight txs when address changes (account switch)", () => { + // Start with an address connected + mockWalletState = { + ...mockWalletState, + status: "connected", + isConnected: true, + address: "GAAAA", + }; + + const { result, rerender } = renderSession(); + const ctrl = makeController(); + + act(() => { + result.current.registerTx("tx-1", ctrl); + }); + expect(result.current.pendingTxCount).toBe(1); + + // Simulate account switch + mockWalletState = { ...mockWalletState, address: "GBBBB" }; + rerender(); + + expect(ctrl.signal.aborted).toBe(true); + expect(result.current.pendingTxCount).toBe(0); + }); + + it("dispatches session-changed event on account change", () => { + mockWalletState = { + ...mockWalletState, + status: "connected", + isConnected: true, + address: "GAAAA", + }; + + const { rerender } = renderSession(); + const listener = jest.fn(); + window.addEventListener("session-changed", listener); + + mockWalletState = { ...mockWalletState, address: "GBBBB" }; + rerender(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ reason: "account-changed" }), + }) + ); + window.removeEventListener("session-changed", listener); + }); + + // ── wallet disconnect detection (address → null) ───────────────────────── + + it("resets app store when wallet disconnects (address drops to null)", () => { + mockWalletState = { + ...mockWalletState, + status: "connected", + isConnected: true, + address: "GAAAA", + }; + + const { rerender } = renderSession(); + + // Wallet disconnects + mockWalletState = { + ...mockWalletState, + status: "disconnected", + isConnected: false, + address: null, + }; + rerender(); + + expect(mockResetAll).toHaveBeenCalledTimes(1); + }); + + it("aborts in-flight txs when wallet disconnects externally", () => { + mockWalletState = { + ...mockWalletState, + status: "connected", + isConnected: true, + address: "GAAAA", + }; + + const { result, rerender } = renderSession(); + const ctrl = makeController(); + + act(() => { + result.current.registerTx("tx-pending", ctrl); + }); + + // External disconnect + mockWalletState = { + ...mockWalletState, + status: "disconnected", + isConnected: false, + address: null, + }; + rerender(); + + expect(ctrl.signal.aborted).toBe(true); + }); + + // ── store missing resetAll ──────────────────────────────────────────────── + + it("does not throw when store has no resetAll method", () => { + const badStore = () => ({ someOtherMethod: jest.fn() }); + const { result } = renderHook(() => + useWalletSession({ getStore: badStore }) + ); + + expect(() => { + act(() => { + result.current.disconnect(); + }); + }).not.toThrow(); + }); + + it("does not throw when getStore throws", () => { + const throwingStore = () => { + throw new Error("store unavailable"); + }; + const { result } = renderHook(() => + useWalletSession({ getStore: throwingStore }) + ); + + expect(() => { + act(() => { + result.current.disconnect(); + }); + }).not.toThrow(); + }); +}); \ No newline at end of file