From 19d76987e1e3da521b4799b841724a4d5bd94e32 Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Thu, 27 Aug 2026 12:05:12 +0100 Subject: [PATCH 01/11] feat(stellar): implement Soroban event parser service --- .../services/soroban-event-parser.service.ts | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 src/src/modules/stellar/services/soroban-event-parser.service.ts diff --git a/src/src/modules/stellar/services/soroban-event-parser.service.ts b/src/src/modules/stellar/services/soroban-event-parser.service.ts new file mode 100644 index 0000000..8bcb6db --- /dev/null +++ b/src/src/modules/stellar/services/soroban-event-parser.service.ts @@ -0,0 +1,335 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { xdr, scValToNative, Address } from '@stellar/stellar-sdk'; +import { DomainEventName } from '../../../events/event-names'; + +/** Raw event shape from Stellar RPC `getEvents` / tx meta. */ +export interface RawSorobanEvent { + type?: string; + ledger?: number; + ledgerClosedAt?: string; + contractId?: string; + id?: string; + pagingToken?: string; + /** Base64-encoded SCVal XDR topics */ + topic?: string[]; + /** Base64-encoded SCVal XDR value */ + value?: string; + inSuccessfulContractCall?: boolean; + txHash?: string; +} + +export interface ParsedTopic { + index: number; + rawBase64: string; + native: unknown; + kind: string; +} + +export interface ParsedSorobanEvent { + contractId: string | null; + txHash: string | null; + ledger: number | null; + type: string | null; + topics: ParsedTopic[]; + value: unknown; + /** High-level classification when recognized */ + pattern: SorobanEventPattern; + /** Normalized payload for known patterns */ + normalized: SacTransferEvent | SacMintEvent | SacBurnEvent | Record | null; +} + +export type SorobanEventPattern = + | 'sac.transfer' + | 'sac.mint' + | 'sac.burn' + | 'sac.approve' + | 'unknown' + | 'unparseable'; + +export interface SacTransferEvent { + pattern: 'sac.transfer'; + contractId: string | null; + from: string | null; + to: string | null; + amount: string | null; + txHash: string | null; + ledger: number | null; +} + +export interface SacMintEvent { + pattern: 'sac.mint'; + contractId: string | null; + to: string | null; + amount: string | null; + txHash: string | null; + ledger: number | null; +} + +export interface SacBurnEvent { + pattern: 'sac.burn'; + contractId: string | null; + from: string | null; + amount: string | null; + txHash: string | null; + ledger: number | null; +} + +/** Nest domain event name for successfully parsed Soroban events. */ +export const SOROBAN_EVENT_PARSED = 'stellar.soroban_event_parsed'; + +@Injectable() +export class SorobanEventParserService { + private readonly logger = new Logger(SorobanEventParserService.name); + + constructor(private readonly emitter: EventEmitter2) {} + + /** + * Parse a batch of raw RPC events. Never throws for individual bad rows — + * unparseable entries are logged and returned with pattern `unparseable`. + */ + parseEvents(rawEvents: RawSorobanEvent[]): ParsedSorobanEvent[] { + if (!Array.isArray(rawEvents)) { + this.logger.warn('parseEvents called with non-array input'); + return []; + } + return rawEvents.map((raw) => this.parseOne(raw)); + } + + /** + * Parse, classify, and emit domain events for successfully translated rows. + * Safe for worker loops: decode failures do not throw. + */ + async ingestAndEmit(rawEvents: RawSorobanEvent[]): Promise { + const parsed = this.parseEvents(rawEvents); + + for (const event of parsed) { + if (event.pattern === 'unparseable') { + continue; + } + try { + this.emitter.emit(SOROBAN_EVENT_PARSED, event); + + if (event.pattern === 'sac.transfer' && event.normalized) { + this.emitter.emit(DomainEventName.TransactionConfirmed, { + source: 'soroban', + ...event.normalized, + }); + } + } catch (err) { + this.logger.warn( + `EventEmitter failed for contract=${event.contractId}: ${(err as Error).message}`, + ); + } + } + + return parsed; + } + + parseOne(raw: RawSorobanEvent): ParsedSorobanEvent { + const base: ParsedSorobanEvent = { + contractId: raw.contractId ?? null, + txHash: raw.txHash ?? null, + ledger: typeof raw.ledger === 'number' ? raw.ledger : null, + type: raw.type ?? null, + topics: [], + value: null, + pattern: 'unparseable', + normalized: null, + }; + + try { + const topics = this.decodeTopics(raw.topic ?? []); + const value = this.decodeScValBase64(raw.value); + + base.topics = topics; + base.value = value; + + const classified = this.classify(base.contractId, topics, value, base.txHash, base.ledger); + base.pattern = classified.pattern; + base.normalized = classified.normalized; + return base; + } catch (err) { + this.logger.warn( + `Failed to parse Soroban event contractId=\( {raw.contractId ?? '?'} tx= \){raw.txHash ?? '?'}: ${(err as Error).message}`, + ); + return base; + } + } + + private decodeTopics(topics: string[]): ParsedTopic[] { + const out: ParsedTopic[] = []; + for (let i = 0; i < topics.length; i++) { + const rawBase64 = topics[i]; + try { + const native = this.decodeScValBase64(rawBase64); + out.push({ + index: i, + rawBase64, + native, + kind: this.topicKind(native), + }); + } catch (err) { + this.logger.warn( + `Unparseable topic[${i}]: ${(err as Error).message}`, + ); + out.push({ + index: i, + rawBase64, + native: null, + kind: 'error', + }); + } + } + return out; + } + + /** + * Decode a single base64 SCVal XDR blob to a JS native value. + * Throws on malformed input — callers catch per-item. + */ + decodeScValBase64(base64: string | undefined | null): unknown { + if (base64 === undefined || base64 === null || base64 === '') { + return null; + } + const buf = Buffer.from(base64, 'base64'); + const scVal = xdr.ScVal.fromXDR(buf); + return scValToNative(scVal); + } + + private topicKind(native: unknown): string { + if (native === null || native === undefined) return 'null'; + if (typeof native === 'string') return 'string'; + if (typeof native === 'bigint' || typeof native === 'number') return 'number'; + if (typeof native === 'boolean') return 'boolean'; + if (Array.isArray(native)) return 'array'; + if (typeof native === 'object') return 'object'; + return typeof native; + } + + private classify( + contractId: string | null, + topics: ParsedTopic[], + value: unknown, + txHash: string | null, + ledger: number | null, + ): { pattern: SorobanEventPattern; normalized: ParsedSorobanEvent['normalized'] } { + const name = this.eventName(topics); + + if (name === 'transfer') { + const from = this.addressAt(topics, 1); + const to = this.addressAt(topics, 2); + const amount = this.amountFrom(value, topics, 3); + return { + pattern: 'sac.transfer', + normalized: { + pattern: 'sac.transfer', + contractId, + from, + to, + amount, + txHash, + ledger, + } satisfies SacTransferEvent, + }; + } + + if (name === 'mint') { + const to = this.addressAt(topics, 1); + const amount = this.amountFrom(value, topics, 2); + return { + pattern: 'sac.mint', + normalized: { + pattern: 'sac.mint', + contractId, + to, + amount, + txHash, + ledger, + } satisfies SacMintEvent, + }; + } + + if (name === 'burn') { + const from = this.addressAt(topics, 1); + const amount = this.amountFrom(value, topics, 2); + return { + pattern: 'sac.burn', + normalized: { + pattern: 'sac.burn', + contractId, + from, + amount, + txHash, + ledger, + } satisfies SacBurnEvent, + }; + } + + if (name === 'approve') { + return { + pattern: 'sac.approve', + normalized: { + pattern: 'sac.approve', + contractId, + topics: topics.map((t) => t.native), + value, + txHash, + ledger, + }, + }; + } + + return { + pattern: 'unknown', + normalized: { + contractId, + topics: topics.map((t) => t.native), + value, + txHash, + ledger, + }, + }; + } + + /** First topic is usually a Symbol event name for SAC / contract events. */ + private eventName(topics: ParsedTopic[]): string | null { + if (topics.length === 0) return null; + const n = topics[0].native; + if (typeof n === 'string') return n.toLowerCase(); + return null; + } + + private addressAt(topics: ParsedTopic[], index: number): string | null { + if (index >= topics.length) return null; + const n = topics[index].native; + if (typeof n === 'string') return n; + if (n && typeof n === 'object' && 'address' in (n as object)) { + return String((n as { address: string }).address); + } + try { + // Some SDK paths return Address-like objects + if (n instanceof Address) return n.toString(); + } catch { + /* ignore */ + } + return n != null ? String(n) : null; + } + + private amountFrom(value: unknown, topics: ParsedTopic[], topicIndex: number): string | null { + const fromValue = this.stringifyAmount(value); + if (fromValue !== null) return fromValue; + if (topicIndex < topics.length) { + return this.stringifyAmount(topics[topicIndex].native); + } + return null; + } + + private stringifyAmount(v: unknown): string | null { + if (v === null || v === undefined) return null; + if (typeof v === 'bigint') return v.toString(); + if (typeof v === 'number' && Number.isFinite(v)) return String(v); + if (typeof v === 'string' && v.length > 0) return v; + return null; + } +} From 548026ae7e3209e57961051dc274727fe8baaf2c Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Thu, 27 Aug 2026 12:06:36 +0100 Subject: [PATCH 02/11] Remove SorobanEventParserService and related types This commit removes the SorobanEventParserService and its related interfaces and constants, effectively deleting the event parsing functionality for Soroban events. --- .../services/soroban-event-parser.service.ts | 334 ------------------ 1 file changed, 334 deletions(-) diff --git a/src/src/modules/stellar/services/soroban-event-parser.service.ts b/src/src/modules/stellar/services/soroban-event-parser.service.ts index 8bcb6db..8b13789 100644 --- a/src/src/modules/stellar/services/soroban-event-parser.service.ts +++ b/src/src/modules/stellar/services/soroban-event-parser.service.ts @@ -1,335 +1 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { xdr, scValToNative, Address } from '@stellar/stellar-sdk'; -import { DomainEventName } from '../../../events/event-names'; -/** Raw event shape from Stellar RPC `getEvents` / tx meta. */ -export interface RawSorobanEvent { - type?: string; - ledger?: number; - ledgerClosedAt?: string; - contractId?: string; - id?: string; - pagingToken?: string; - /** Base64-encoded SCVal XDR topics */ - topic?: string[]; - /** Base64-encoded SCVal XDR value */ - value?: string; - inSuccessfulContractCall?: boolean; - txHash?: string; -} - -export interface ParsedTopic { - index: number; - rawBase64: string; - native: unknown; - kind: string; -} - -export interface ParsedSorobanEvent { - contractId: string | null; - txHash: string | null; - ledger: number | null; - type: string | null; - topics: ParsedTopic[]; - value: unknown; - /** High-level classification when recognized */ - pattern: SorobanEventPattern; - /** Normalized payload for known patterns */ - normalized: SacTransferEvent | SacMintEvent | SacBurnEvent | Record | null; -} - -export type SorobanEventPattern = - | 'sac.transfer' - | 'sac.mint' - | 'sac.burn' - | 'sac.approve' - | 'unknown' - | 'unparseable'; - -export interface SacTransferEvent { - pattern: 'sac.transfer'; - contractId: string | null; - from: string | null; - to: string | null; - amount: string | null; - txHash: string | null; - ledger: number | null; -} - -export interface SacMintEvent { - pattern: 'sac.mint'; - contractId: string | null; - to: string | null; - amount: string | null; - txHash: string | null; - ledger: number | null; -} - -export interface SacBurnEvent { - pattern: 'sac.burn'; - contractId: string | null; - from: string | null; - amount: string | null; - txHash: string | null; - ledger: number | null; -} - -/** Nest domain event name for successfully parsed Soroban events. */ -export const SOROBAN_EVENT_PARSED = 'stellar.soroban_event_parsed'; - -@Injectable() -export class SorobanEventParserService { - private readonly logger = new Logger(SorobanEventParserService.name); - - constructor(private readonly emitter: EventEmitter2) {} - - /** - * Parse a batch of raw RPC events. Never throws for individual bad rows — - * unparseable entries are logged and returned with pattern `unparseable`. - */ - parseEvents(rawEvents: RawSorobanEvent[]): ParsedSorobanEvent[] { - if (!Array.isArray(rawEvents)) { - this.logger.warn('parseEvents called with non-array input'); - return []; - } - return rawEvents.map((raw) => this.parseOne(raw)); - } - - /** - * Parse, classify, and emit domain events for successfully translated rows. - * Safe for worker loops: decode failures do not throw. - */ - async ingestAndEmit(rawEvents: RawSorobanEvent[]): Promise { - const parsed = this.parseEvents(rawEvents); - - for (const event of parsed) { - if (event.pattern === 'unparseable') { - continue; - } - try { - this.emitter.emit(SOROBAN_EVENT_PARSED, event); - - if (event.pattern === 'sac.transfer' && event.normalized) { - this.emitter.emit(DomainEventName.TransactionConfirmed, { - source: 'soroban', - ...event.normalized, - }); - } - } catch (err) { - this.logger.warn( - `EventEmitter failed for contract=${event.contractId}: ${(err as Error).message}`, - ); - } - } - - return parsed; - } - - parseOne(raw: RawSorobanEvent): ParsedSorobanEvent { - const base: ParsedSorobanEvent = { - contractId: raw.contractId ?? null, - txHash: raw.txHash ?? null, - ledger: typeof raw.ledger === 'number' ? raw.ledger : null, - type: raw.type ?? null, - topics: [], - value: null, - pattern: 'unparseable', - normalized: null, - }; - - try { - const topics = this.decodeTopics(raw.topic ?? []); - const value = this.decodeScValBase64(raw.value); - - base.topics = topics; - base.value = value; - - const classified = this.classify(base.contractId, topics, value, base.txHash, base.ledger); - base.pattern = classified.pattern; - base.normalized = classified.normalized; - return base; - } catch (err) { - this.logger.warn( - `Failed to parse Soroban event contractId=\( {raw.contractId ?? '?'} tx= \){raw.txHash ?? '?'}: ${(err as Error).message}`, - ); - return base; - } - } - - private decodeTopics(topics: string[]): ParsedTopic[] { - const out: ParsedTopic[] = []; - for (let i = 0; i < topics.length; i++) { - const rawBase64 = topics[i]; - try { - const native = this.decodeScValBase64(rawBase64); - out.push({ - index: i, - rawBase64, - native, - kind: this.topicKind(native), - }); - } catch (err) { - this.logger.warn( - `Unparseable topic[${i}]: ${(err as Error).message}`, - ); - out.push({ - index: i, - rawBase64, - native: null, - kind: 'error', - }); - } - } - return out; - } - - /** - * Decode a single base64 SCVal XDR blob to a JS native value. - * Throws on malformed input — callers catch per-item. - */ - decodeScValBase64(base64: string | undefined | null): unknown { - if (base64 === undefined || base64 === null || base64 === '') { - return null; - } - const buf = Buffer.from(base64, 'base64'); - const scVal = xdr.ScVal.fromXDR(buf); - return scValToNative(scVal); - } - - private topicKind(native: unknown): string { - if (native === null || native === undefined) return 'null'; - if (typeof native === 'string') return 'string'; - if (typeof native === 'bigint' || typeof native === 'number') return 'number'; - if (typeof native === 'boolean') return 'boolean'; - if (Array.isArray(native)) return 'array'; - if (typeof native === 'object') return 'object'; - return typeof native; - } - - private classify( - contractId: string | null, - topics: ParsedTopic[], - value: unknown, - txHash: string | null, - ledger: number | null, - ): { pattern: SorobanEventPattern; normalized: ParsedSorobanEvent['normalized'] } { - const name = this.eventName(topics); - - if (name === 'transfer') { - const from = this.addressAt(topics, 1); - const to = this.addressAt(topics, 2); - const amount = this.amountFrom(value, topics, 3); - return { - pattern: 'sac.transfer', - normalized: { - pattern: 'sac.transfer', - contractId, - from, - to, - amount, - txHash, - ledger, - } satisfies SacTransferEvent, - }; - } - - if (name === 'mint') { - const to = this.addressAt(topics, 1); - const amount = this.amountFrom(value, topics, 2); - return { - pattern: 'sac.mint', - normalized: { - pattern: 'sac.mint', - contractId, - to, - amount, - txHash, - ledger, - } satisfies SacMintEvent, - }; - } - - if (name === 'burn') { - const from = this.addressAt(topics, 1); - const amount = this.amountFrom(value, topics, 2); - return { - pattern: 'sac.burn', - normalized: { - pattern: 'sac.burn', - contractId, - from, - amount, - txHash, - ledger, - } satisfies SacBurnEvent, - }; - } - - if (name === 'approve') { - return { - pattern: 'sac.approve', - normalized: { - pattern: 'sac.approve', - contractId, - topics: topics.map((t) => t.native), - value, - txHash, - ledger, - }, - }; - } - - return { - pattern: 'unknown', - normalized: { - contractId, - topics: topics.map((t) => t.native), - value, - txHash, - ledger, - }, - }; - } - - /** First topic is usually a Symbol event name for SAC / contract events. */ - private eventName(topics: ParsedTopic[]): string | null { - if (topics.length === 0) return null; - const n = topics[0].native; - if (typeof n === 'string') return n.toLowerCase(); - return null; - } - - private addressAt(topics: ParsedTopic[], index: number): string | null { - if (index >= topics.length) return null; - const n = topics[index].native; - if (typeof n === 'string') return n; - if (n && typeof n === 'object' && 'address' in (n as object)) { - return String((n as { address: string }).address); - } - try { - // Some SDK paths return Address-like objects - if (n instanceof Address) return n.toString(); - } catch { - /* ignore */ - } - return n != null ? String(n) : null; - } - - private amountFrom(value: unknown, topics: ParsedTopic[], topicIndex: number): string | null { - const fromValue = this.stringifyAmount(value); - if (fromValue !== null) return fromValue; - if (topicIndex < topics.length) { - return this.stringifyAmount(topics[topicIndex].native); - } - return null; - } - - private stringifyAmount(v: unknown): string | null { - if (v === null || v === undefined) return null; - if (typeof v === 'bigint') return v.toString(); - if (typeof v === 'number' && Number.isFinite(v)) return String(v); - if (typeof v === 'string' && v.length > 0) return v; - return null; - } -} From 388486b659ab9171c50a1dfd850153580808358f Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Thu, 27 Aug 2026 12:08:51 +0100 Subject: [PATCH 03/11] feat(stellar): implement Soroban event parser service --- .../services/soroban-event-parser.service.ts | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 src/modules/stellar/services/soroban-event-parser.service.ts diff --git a/src/modules/stellar/services/soroban-event-parser.service.ts b/src/modules/stellar/services/soroban-event-parser.service.ts new file mode 100644 index 0000000..8bcb6db --- /dev/null +++ b/src/modules/stellar/services/soroban-event-parser.service.ts @@ -0,0 +1,335 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { xdr, scValToNative, Address } from '@stellar/stellar-sdk'; +import { DomainEventName } from '../../../events/event-names'; + +/** Raw event shape from Stellar RPC `getEvents` / tx meta. */ +export interface RawSorobanEvent { + type?: string; + ledger?: number; + ledgerClosedAt?: string; + contractId?: string; + id?: string; + pagingToken?: string; + /** Base64-encoded SCVal XDR topics */ + topic?: string[]; + /** Base64-encoded SCVal XDR value */ + value?: string; + inSuccessfulContractCall?: boolean; + txHash?: string; +} + +export interface ParsedTopic { + index: number; + rawBase64: string; + native: unknown; + kind: string; +} + +export interface ParsedSorobanEvent { + contractId: string | null; + txHash: string | null; + ledger: number | null; + type: string | null; + topics: ParsedTopic[]; + value: unknown; + /** High-level classification when recognized */ + pattern: SorobanEventPattern; + /** Normalized payload for known patterns */ + normalized: SacTransferEvent | SacMintEvent | SacBurnEvent | Record | null; +} + +export type SorobanEventPattern = + | 'sac.transfer' + | 'sac.mint' + | 'sac.burn' + | 'sac.approve' + | 'unknown' + | 'unparseable'; + +export interface SacTransferEvent { + pattern: 'sac.transfer'; + contractId: string | null; + from: string | null; + to: string | null; + amount: string | null; + txHash: string | null; + ledger: number | null; +} + +export interface SacMintEvent { + pattern: 'sac.mint'; + contractId: string | null; + to: string | null; + amount: string | null; + txHash: string | null; + ledger: number | null; +} + +export interface SacBurnEvent { + pattern: 'sac.burn'; + contractId: string | null; + from: string | null; + amount: string | null; + txHash: string | null; + ledger: number | null; +} + +/** Nest domain event name for successfully parsed Soroban events. */ +export const SOROBAN_EVENT_PARSED = 'stellar.soroban_event_parsed'; + +@Injectable() +export class SorobanEventParserService { + private readonly logger = new Logger(SorobanEventParserService.name); + + constructor(private readonly emitter: EventEmitter2) {} + + /** + * Parse a batch of raw RPC events. Never throws for individual bad rows — + * unparseable entries are logged and returned with pattern `unparseable`. + */ + parseEvents(rawEvents: RawSorobanEvent[]): ParsedSorobanEvent[] { + if (!Array.isArray(rawEvents)) { + this.logger.warn('parseEvents called with non-array input'); + return []; + } + return rawEvents.map((raw) => this.parseOne(raw)); + } + + /** + * Parse, classify, and emit domain events for successfully translated rows. + * Safe for worker loops: decode failures do not throw. + */ + async ingestAndEmit(rawEvents: RawSorobanEvent[]): Promise { + const parsed = this.parseEvents(rawEvents); + + for (const event of parsed) { + if (event.pattern === 'unparseable') { + continue; + } + try { + this.emitter.emit(SOROBAN_EVENT_PARSED, event); + + if (event.pattern === 'sac.transfer' && event.normalized) { + this.emitter.emit(DomainEventName.TransactionConfirmed, { + source: 'soroban', + ...event.normalized, + }); + } + } catch (err) { + this.logger.warn( + `EventEmitter failed for contract=${event.contractId}: ${(err as Error).message}`, + ); + } + } + + return parsed; + } + + parseOne(raw: RawSorobanEvent): ParsedSorobanEvent { + const base: ParsedSorobanEvent = { + contractId: raw.contractId ?? null, + txHash: raw.txHash ?? null, + ledger: typeof raw.ledger === 'number' ? raw.ledger : null, + type: raw.type ?? null, + topics: [], + value: null, + pattern: 'unparseable', + normalized: null, + }; + + try { + const topics = this.decodeTopics(raw.topic ?? []); + const value = this.decodeScValBase64(raw.value); + + base.topics = topics; + base.value = value; + + const classified = this.classify(base.contractId, topics, value, base.txHash, base.ledger); + base.pattern = classified.pattern; + base.normalized = classified.normalized; + return base; + } catch (err) { + this.logger.warn( + `Failed to parse Soroban event contractId=\( {raw.contractId ?? '?'} tx= \){raw.txHash ?? '?'}: ${(err as Error).message}`, + ); + return base; + } + } + + private decodeTopics(topics: string[]): ParsedTopic[] { + const out: ParsedTopic[] = []; + for (let i = 0; i < topics.length; i++) { + const rawBase64 = topics[i]; + try { + const native = this.decodeScValBase64(rawBase64); + out.push({ + index: i, + rawBase64, + native, + kind: this.topicKind(native), + }); + } catch (err) { + this.logger.warn( + `Unparseable topic[${i}]: ${(err as Error).message}`, + ); + out.push({ + index: i, + rawBase64, + native: null, + kind: 'error', + }); + } + } + return out; + } + + /** + * Decode a single base64 SCVal XDR blob to a JS native value. + * Throws on malformed input — callers catch per-item. + */ + decodeScValBase64(base64: string | undefined | null): unknown { + if (base64 === undefined || base64 === null || base64 === '') { + return null; + } + const buf = Buffer.from(base64, 'base64'); + const scVal = xdr.ScVal.fromXDR(buf); + return scValToNative(scVal); + } + + private topicKind(native: unknown): string { + if (native === null || native === undefined) return 'null'; + if (typeof native === 'string') return 'string'; + if (typeof native === 'bigint' || typeof native === 'number') return 'number'; + if (typeof native === 'boolean') return 'boolean'; + if (Array.isArray(native)) return 'array'; + if (typeof native === 'object') return 'object'; + return typeof native; + } + + private classify( + contractId: string | null, + topics: ParsedTopic[], + value: unknown, + txHash: string | null, + ledger: number | null, + ): { pattern: SorobanEventPattern; normalized: ParsedSorobanEvent['normalized'] } { + const name = this.eventName(topics); + + if (name === 'transfer') { + const from = this.addressAt(topics, 1); + const to = this.addressAt(topics, 2); + const amount = this.amountFrom(value, topics, 3); + return { + pattern: 'sac.transfer', + normalized: { + pattern: 'sac.transfer', + contractId, + from, + to, + amount, + txHash, + ledger, + } satisfies SacTransferEvent, + }; + } + + if (name === 'mint') { + const to = this.addressAt(topics, 1); + const amount = this.amountFrom(value, topics, 2); + return { + pattern: 'sac.mint', + normalized: { + pattern: 'sac.mint', + contractId, + to, + amount, + txHash, + ledger, + } satisfies SacMintEvent, + }; + } + + if (name === 'burn') { + const from = this.addressAt(topics, 1); + const amount = this.amountFrom(value, topics, 2); + return { + pattern: 'sac.burn', + normalized: { + pattern: 'sac.burn', + contractId, + from, + amount, + txHash, + ledger, + } satisfies SacBurnEvent, + }; + } + + if (name === 'approve') { + return { + pattern: 'sac.approve', + normalized: { + pattern: 'sac.approve', + contractId, + topics: topics.map((t) => t.native), + value, + txHash, + ledger, + }, + }; + } + + return { + pattern: 'unknown', + normalized: { + contractId, + topics: topics.map((t) => t.native), + value, + txHash, + ledger, + }, + }; + } + + /** First topic is usually a Symbol event name for SAC / contract events. */ + private eventName(topics: ParsedTopic[]): string | null { + if (topics.length === 0) return null; + const n = topics[0].native; + if (typeof n === 'string') return n.toLowerCase(); + return null; + } + + private addressAt(topics: ParsedTopic[], index: number): string | null { + if (index >= topics.length) return null; + const n = topics[index].native; + if (typeof n === 'string') return n; + if (n && typeof n === 'object' && 'address' in (n as object)) { + return String((n as { address: string }).address); + } + try { + // Some SDK paths return Address-like objects + if (n instanceof Address) return n.toString(); + } catch { + /* ignore */ + } + return n != null ? String(n) : null; + } + + private amountFrom(value: unknown, topics: ParsedTopic[], topicIndex: number): string | null { + const fromValue = this.stringifyAmount(value); + if (fromValue !== null) return fromValue; + if (topicIndex < topics.length) { + return this.stringifyAmount(topics[topicIndex].native); + } + return null; + } + + private stringifyAmount(v: unknown): string | null { + if (v === null || v === undefined) return null; + if (typeof v === 'bigint') return v.toString(); + if (typeof v === 'number' && Number.isFinite(v)) return String(v); + if (typeof v === 'string' && v.length > 0) return v; + return null; + } +} From f81e6488b1773c18571bf406949ce6841eb3258c Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Thu, 27 Aug 2026 12:11:02 +0100 Subject: [PATCH 04/11] test(stellar): cover Soroban event parser and EventEmitter emission --- .../soroban-event-parser.service.spec.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/modules/stellar/services/soroban-event-parser.service.spec.ts diff --git a/src/modules/stellar/services/soroban-event-parser.service.spec.ts b/src/modules/stellar/services/soroban-event-parser.service.spec.ts new file mode 100644 index 0000000..dc6c62f --- /dev/null +++ b/src/modules/stellar/services/soroban-event-parser.service.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { nativeToScVal, xdr } from '@stellar/stellar-sdk'; +import { + SorobanEventParserService, + SOROBAN_EVENT_PARSED, + RawSorobanEvent, +} from './soroban-event-parser.service'; +import { DomainEventName } from '../../../events/event-names'; + +function scValToBase64(value: unknown): string { + const scVal = nativeToScVal(value as never); + return scVal.toXDR('base64'); +} + +function makeTransferRaw(overrides: Partial = {}): RawSorobanEvent { + return { + type: 'contract', + ledger: 1_234_567, + contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + txHash: 'abc123def456', + topic: [ + scValToBase64('transfer'), + scValToBase64('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'), + scValToBase64('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'), + ], + value: scValToBase64(1_000_000n), + ...overrides, + }; +} + +describe('SorobanEventParserService', () => { + let emitter: EventEmitter2; + let service: SorobanEventParserService; + + beforeEach(() => { + emitter = new EventEmitter2(); + service = new SorobanEventParserService(emitter); + }); + + it('parses SAC transfer topics and value into typed normalized payload', () => { + const raw = makeTransferRaw(); + const [parsed] = service.parseEvents([raw]); + + expect(parsed.pattern).toBe('sac.transfer'); + expect(parsed.contractId).toBe(raw.contractId); + expect(parsed.txHash).toBe('abc123def456'); + expect(parsed.topics[0].native).toBe('transfer'); + expect(parsed.normalized).toMatchObject({ + pattern: 'sac.transfer', + amount: '1000000', + }); + expect((parsed.normalized as { from: string }).from).toBeTruthy(); + expect((parsed.normalized as { to: string }).to).toBeTruthy(); + }); + + it('maps mint and burn patterns', () => { + const mint: RawSorobanEvent = { + contractId: 'CMint', + topic: [scValToBase64('mint'), scValToBase64('GTO')], + value: scValToBase64(50n), + txHash: 'mint-tx', + ledger: 10, + }; + const burn: RawSorobanEvent = { + contractId: 'CBurn', + topic: [scValToBase64('burn'), scValToBase64('GFROM')], + value: scValToBase64(25n), + txHash: 'burn-tx', + ledger: 11, + }; + + const [m, b] = service.parseEvents([mint, burn]); + expect(m.pattern).toBe('sac.mint'); + expect(m.normalized).toMatchObject({ pattern: 'sac.mint', amount: '50' }); + expect(b.pattern).toBe('sac.burn'); + expect(b.normalized).toMatchObject({ pattern: 'sac.burn', amount: '25' }); + }); + + it('does not throw on malformed XDR; marks unparseable / logs path', () => { + const bad: RawSorobanEvent = { + contractId: 'CBad', + topic: ['not-valid-base64-xdr!!!'], + value: '%%%', + txHash: 'bad-tx', + }; + + expect(() => service.parseEvents([bad])).not.toThrow(); + const [parsed] = service.parseEvents([bad]); + // topics may be error-kind; overall still returns a row + expect(parsed.contractId).toBe('CBad'); + expect(parsed.txHash).toBe('bad-tx'); + }); + + it('emits SOROBAN_EVENT_PARSED and TransactionConfirmed for transfers', async () => { + const parsedSpy = vi.fn(); + const confirmedSpy = vi.fn(); + emitter.on(SOROBAN_EVENT_PARSED, parsedSpy); + emitter.on(DomainEventName.TransactionConfirmed, confirmedSpy); + + const raw = makeTransferRaw(); + const result = await service.ingestAndEmit([raw]); + + expect(result).toHaveLength(1); + expect(result[0].pattern).toBe('sac.transfer'); + expect(parsedSpy).toHaveBeenCalledTimes(1); + expect(confirmedSpy).toHaveBeenCalledTimes(1); + expect(confirmedSpy.mock.calls[0][0]).toMatchObject({ + source: 'soroban', + pattern: 'sac.transfer', + }); + }); + + it('skips emit for unparseable events but continues the batch', async () => { + const spy = vi.fn(); + emitter.on(SOROBAN_EVENT_PARSED, spy); + + const good = makeTransferRaw(); + const bad: RawSorobanEvent = { + contractId: 'CBad', + topic: ['!!!!'], + value: '!!!!', + }; + + const result = await service.ingestAndEmit([bad, good]); + expect(result).toHaveLength(2); + expect(spy).toHaveBeenCalled(); + }); + + it('returns empty array for non-array input', () => { + expect(service.parseEvents(null as unknown as RawSorobanEvent[])).toEqual([]); + }); + + it('classifies unknown symbol as unknown pattern', () => { + const raw: RawSorobanEvent = { + contractId: 'C1', + topic: [scValToBase64('custom_event')], + value: scValToBase64(1n), + }; + const [parsed] = service.parseEvents([raw]); + expect(parsed.pattern).toBe('unknown'); + expect(parsed.topics[0].native).toBe('custom_event'); + }); +}); From 5a31c837df83332470cee4023e21092bc02f3abc Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Thu, 27 Aug 2026 12:14:05 +0100 Subject: [PATCH 05/11] feat(stellar): register SorobanEventParserService in StellarModule --- src/modules/stellar/stellar.module.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/stellar/stellar.module.ts b/src/modules/stellar/stellar.module.ts index b490454..3b4ff08 100644 --- a/src/modules/stellar/stellar.module.ts +++ b/src/modules/stellar/stellar.module.ts @@ -9,6 +9,7 @@ import { } from '../../integrations/stellar'; import { StellarService } from './stellar.service'; import { StellarController } from './stellar.controller'; +import { SorobanEventParserService } from './services/soroban-event-parser.service'; /** * Global Stellar module. Selects the mock or Horizon-backed client based on @@ -28,7 +29,8 @@ import { StellarController } from './stellar.controller'; }, }, StellarService, + SorobanEventParserService, ], - exports: [StellarService], + exports: [StellarService, SorobanEventParserService], }) export class StellarModule {} From e0c9a18d9e2b1632b4f75445beae7798ec579069 Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Thu, 27 Aug 2026 20:44:46 +0100 Subject: [PATCH 06/11] fix(stellar): remove unused xdr import in event parser spec --- .../stellar/services/soroban-event-parser.service.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/stellar/services/soroban-event-parser.service.spec.ts b/src/modules/stellar/services/soroban-event-parser.service.spec.ts index dc6c62f..5982429 100644 --- a/src/modules/stellar/services/soroban-event-parser.service.spec.ts +++ b/src/modules/stellar/services/soroban-event-parser.service.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { nativeToScVal, xdr } from '@stellar/stellar-sdk'; +import { nativeToScVal } from '@stellar/stellar-sdk'; import { SorobanEventParserService, SOROBAN_EVENT_PARSED, @@ -22,7 +22,7 @@ function makeTransferRaw(overrides: Partial = {}): RawSorobanEv topic: [ scValToBase64('transfer'), scValToBase64('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'), - scValToBase64('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'), + scValToBase64('GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'), ], value: scValToBase64(1_000_000n), ...overrides, @@ -87,7 +87,6 @@ describe('SorobanEventParserService', () => { expect(() => service.parseEvents([bad])).not.toThrow(); const [parsed] = service.parseEvents([bad]); - // topics may be error-kind; overall still returns a row expect(parsed.contractId).toBe('CBad'); expect(parsed.txHash).toBe('bad-tx'); }); From b9dcd1946bcff8ac3e5a04329b4ceef6dabac79a Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Sat, 29 Aug 2026 07:39:18 +0100 Subject: [PATCH 07/11] fix(stellar): register SorobanEventParserService in module providers --- src/modules/stellar/stellar.module.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/stellar/stellar.module.ts b/src/modules/stellar/stellar.module.ts index b2a449a..3b4ff08 100644 --- a/src/modules/stellar/stellar.module.ts +++ b/src/modules/stellar/stellar.module.ts @@ -8,7 +8,6 @@ import { StellarClient, } from '../../integrations/stellar'; import { StellarService } from './stellar.service'; -import { StellarTransactionService } from './services/stellar-transaction.service'; import { StellarController } from './stellar.controller'; import { SorobanEventParserService } from './services/soroban-event-parser.service'; @@ -30,8 +29,8 @@ import { SorobanEventParserService } from './services/soroban-event-parser.servi }, }, StellarService, - StellarTransactionService, + SorobanEventParserService, ], - exports: [StellarService, StellarTransactionService], + exports: [StellarService, SorobanEventParserService], }) export class StellarModule {} From 018ae282c388bc1b176634b2ed3f34795bcf0e95 Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Sat, 29 Aug 2026 07:45:33 +0100 Subject: [PATCH 08/11] fix(stellar): remove duplicate nested src/src parser path --- src/src/modules/stellar/services/soroban-event-parser.service.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 src/src/modules/stellar/services/soroban-event-parser.service.ts diff --git a/src/src/modules/stellar/services/soroban-event-parser.service.ts b/src/src/modules/stellar/services/soroban-event-parser.service.ts deleted file mode 100644 index 8b13789..0000000 --- a/src/src/modules/stellar/services/soroban-event-parser.service.ts +++ /dev/null @@ -1 +0,0 @@ - From 18cc4a9a83dc4677e5971b0eaa03f0545c723300 Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Sat, 29 Aug 2026 11:00:57 +0100 Subject: [PATCH 09/11] fix(stellar): keep StellarTransactionService and fix parser log templates --- .../stellar/services/soroban-event-parser.service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/modules/stellar/services/soroban-event-parser.service.ts b/src/modules/stellar/services/soroban-event-parser.service.ts index 8bcb6db..e38ab16 100644 --- a/src/modules/stellar/services/soroban-event-parser.service.ts +++ b/src/modules/stellar/services/soroban-event-parser.service.ts @@ -170,9 +170,7 @@ export class SorobanEventParserService { kind: this.topicKind(native), }); } catch (err) { - this.logger.warn( - `Unparseable topic[${i}]: ${(err as Error).message}`, - ); + this.logger.warn(`Unparseable topic[${i}]: ${(err as Error).message}`); out.push({ index: i, rawBase64, @@ -308,7 +306,6 @@ export class SorobanEventParserService { return String((n as { address: string }).address); } try { - // Some SDK paths return Address-like objects if (n instanceof Address) return n.toString(); } catch { /* ignore */ @@ -332,4 +329,4 @@ export class SorobanEventParserService { if (typeof v === 'string' && v.length > 0) return v; return null; } -} + } From c4a530c0f95854986e7abf5ee8eba797abdfb2db Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Sat, 29 Aug 2026 11:37:04 +0100 Subject: [PATCH 10/11] fix(stellar): rewrite parser logs with string concat, fix syntax --- .../services/soroban-event-parser.service.ts | 99 +++++++++---------- 1 file changed, 45 insertions(+), 54 deletions(-) diff --git a/src/modules/stellar/services/soroban-event-parser.service.ts b/src/modules/stellar/services/soroban-event-parser.service.ts index e38ab16..442566e 100644 --- a/src/modules/stellar/services/soroban-event-parser.service.ts +++ b/src/modules/stellar/services/soroban-event-parser.service.ts @@ -3,7 +3,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { xdr, scValToNative, Address } from '@stellar/stellar-sdk'; import { DomainEventName } from '../../../events/event-names'; -/** Raw event shape from Stellar RPC `getEvents` / tx meta. */ export interface RawSorobanEvent { type?: string; ledger?: number; @@ -11,9 +10,7 @@ export interface RawSorobanEvent { contractId?: string; id?: string; pagingToken?: string; - /** Base64-encoded SCVal XDR topics */ topic?: string[]; - /** Base64-encoded SCVal XDR value */ value?: string; inSuccessfulContractCall?: boolean; txHash?: string; @@ -33,9 +30,7 @@ export interface ParsedSorobanEvent { type: string | null; topics: ParsedTopic[]; value: unknown; - /** High-level classification when recognized */ pattern: SorobanEventPattern; - /** Normalized payload for known patterns */ normalized: SacTransferEvent | SacMintEvent | SacBurnEvent | Record | null; } @@ -75,7 +70,6 @@ export interface SacBurnEvent { ledger: number | null; } -/** Nest domain event name for successfully parsed Soroban events. */ export const SOROBAN_EVENT_PARSED = 'stellar.soroban_event_parsed'; @Injectable() @@ -84,10 +78,6 @@ export class SorobanEventParserService { constructor(private readonly emitter: EventEmitter2) {} - /** - * Parse a batch of raw RPC events. Never throws for individual bad rows — - * unparseable entries are logged and returned with pattern `unparseable`. - */ parseEvents(rawEvents: RawSorobanEvent[]): ParsedSorobanEvent[] { if (!Array.isArray(rawEvents)) { this.logger.warn('parseEvents called with non-array input'); @@ -96,10 +86,6 @@ export class SorobanEventParserService { return rawEvents.map((raw) => this.parseOne(raw)); } - /** - * Parse, classify, and emit domain events for successfully translated rows. - * Safe for worker loops: decode failures do not throw. - */ async ingestAndEmit(rawEvents: RawSorobanEvent[]): Promise { const parsed = this.parseEvents(rawEvents); @@ -118,7 +104,10 @@ export class SorobanEventParserService { } } catch (err) { this.logger.warn( - `EventEmitter failed for contract=${event.contractId}: ${(err as Error).message}`, + 'EventEmitter failed for contract=' + + String(event.contractId) + + ': ' + + (err as Error).message, ); } } @@ -151,7 +140,12 @@ export class SorobanEventParserService { return base; } catch (err) { this.logger.warn( - `Failed to parse Soroban event contractId=\( {raw.contractId ?? '?'} tx= \){raw.txHash ?? '?'}: ${(err as Error).message}`, + 'Failed to parse Soroban event contractId=' + + String(raw.contractId ?? '?') + + ' tx=' + + String(raw.txHash ?? '?') + + ': ' + + (err as Error).message, ); return base; } @@ -165,15 +159,17 @@ export class SorobanEventParserService { const native = this.decodeScValBase64(rawBase64); out.push({ index: i, - rawBase64, - native, + rawBase64: rawBase64, + native: native, kind: this.topicKind(native), }); } catch (err) { - this.logger.warn(`Unparseable topic[${i}]: ${(err as Error).message}`); + this.logger.warn( + 'Unparseable topic[' + String(i) + ']: ' + (err as Error).message, + ); out.push({ index: i, - rawBase64, + rawBase64: rawBase64, native: null, kind: 'error', }); @@ -182,10 +178,6 @@ export class SorobanEventParserService { return out; } - /** - * Decode a single base64 SCVal XDR blob to a JS native value. - * Throws on malformed input — callers catch per-item. - */ decodeScValBase64(base64: string | undefined | null): unknown { if (base64 === undefined || base64 === null || base64 === '') { return null; @@ -222,13 +214,13 @@ export class SorobanEventParserService { pattern: 'sac.transfer', normalized: { pattern: 'sac.transfer', - contractId, - from, - to, - amount, - txHash, - ledger, - } satisfies SacTransferEvent, + contractId: contractId, + from: from, + to: to, + amount: amount, + txHash: txHash, + ledger: ledger, + }, }; } @@ -239,12 +231,12 @@ export class SorobanEventParserService { pattern: 'sac.mint', normalized: { pattern: 'sac.mint', - contractId, - to, - amount, - txHash, - ledger, - } satisfies SacMintEvent, + contractId: contractId, + to: to, + amount: amount, + txHash: txHash, + ledger: ledger, + }, }; } @@ -255,12 +247,12 @@ export class SorobanEventParserService { pattern: 'sac.burn', normalized: { pattern: 'sac.burn', - contractId, - from, - amount, - txHash, - ledger, - } satisfies SacBurnEvent, + contractId: contractId, + from: from, + amount: amount, + txHash: txHash, + ledger: ledger, + }, }; } @@ -269,11 +261,11 @@ export class SorobanEventParserService { pattern: 'sac.approve', normalized: { pattern: 'sac.approve', - contractId, + contractId: contractId, topics: topics.map((t) => t.native), - value, - txHash, - ledger, + value: value, + txHash: txHash, + ledger: ledger, }, }; } @@ -281,16 +273,15 @@ export class SorobanEventParserService { return { pattern: 'unknown', normalized: { - contractId, + contractId: contractId, topics: topics.map((t) => t.native), - value, - txHash, - ledger, + value: value, + txHash: txHash, + ledger: ledger, }, }; } - /** First topic is usually a Symbol event name for SAC / contract events. */ private eventName(topics: ParsedTopic[]): string | null { if (topics.length === 0) return null; const n = topics[0].native; @@ -308,7 +299,7 @@ export class SorobanEventParserService { try { if (n instanceof Address) return n.toString(); } catch { - /* ignore */ + // ignore } return n != null ? String(n) : null; } @@ -329,4 +320,4 @@ export class SorobanEventParserService { if (typeof v === 'string' && v.length > 0) return v; return null; } - } + } From ed57e944ce9162413c8d4ad59281b73d8a9c8b77 Mon Sep 17 00:00:00 2001 From: Oluwatoyin Date: Sun, 30 Aug 2026 06:40:28 +0100 Subject: [PATCH 11/11] fix(stellar): restore StellarTransactionService alongside SorobanEventParserService --- src/modules/stellar/stellar.module.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/stellar/stellar.module.ts b/src/modules/stellar/stellar.module.ts index 3b4ff08..ea57c98 100644 --- a/src/modules/stellar/stellar.module.ts +++ b/src/modules/stellar/stellar.module.ts @@ -8,6 +8,7 @@ import { StellarClient, } from '../../integrations/stellar'; import { StellarService } from './stellar.service'; +import { StellarTransactionService } from './services/stellar-transaction.service'; import { StellarController } from './stellar.controller'; import { SorobanEventParserService } from './services/soroban-event-parser.service'; @@ -29,8 +30,9 @@ import { SorobanEventParserService } from './services/soroban-event-parser.servi }, }, StellarService, + StellarTransactionService, SorobanEventParserService, ], - exports: [StellarService, SorobanEventParserService], + exports: [StellarService, StellarTransactionService, SorobanEventParserService], }) export class StellarModule {}