From ae89e48c04452d3bafaefb8c13ca3878daf87d18 Mon Sep 17 00:00:00 2001 From: mirastan Date: Tue, 2 Jun 2026 10:04:45 +0100 Subject: [PATCH] stellar event streaming --- .github/prompts/autonomy-version.json | 3 + .github/prompts/autonomy.manifest.json | 8 ++ packages/stellar-sdk/package.json | 15 ++ packages/stellar-sdk/src/index.ts | 1 + .../src/registry/interfaces/index.ts | 9 ++ .../src/registry/interfaces/registry.ts | 78 ++++++++++ .../src/registry/interfaces/types.ts | 52 +++++++ packages/stellar-sdk/tsconfig.json | 8 ++ src/streaming/stellar/event-stream.spec.ts | 111 ++++++++++++++ src/streaming/stellar/event-stream.ts | 135 ++++++++++++++++++ src/streaming/stellar/index.ts | 2 + 11 files changed, 422 insertions(+) create mode 100644 .github/prompts/autonomy-version.json create mode 100644 .github/prompts/autonomy.manifest.json create mode 100644 packages/stellar-sdk/package.json create mode 100644 packages/stellar-sdk/src/index.ts create mode 100644 packages/stellar-sdk/src/registry/interfaces/index.ts create mode 100644 packages/stellar-sdk/src/registry/interfaces/registry.ts create mode 100644 packages/stellar-sdk/src/registry/interfaces/types.ts create mode 100644 packages/stellar-sdk/tsconfig.json create mode 100644 src/streaming/stellar/event-stream.spec.ts create mode 100644 src/streaming/stellar/event-stream.ts create mode 100644 src/streaming/stellar/index.ts diff --git a/.github/prompts/autonomy-version.json b/.github/prompts/autonomy-version.json new file mode 100644 index 0000000..39afc29 --- /dev/null +++ b/.github/prompts/autonomy-version.json @@ -0,0 +1,3 @@ +{ + "version": "2025.11" +} diff --git a/.github/prompts/autonomy.manifest.json b/.github/prompts/autonomy.manifest.json new file mode 100644 index 0000000..25f7f88 --- /dev/null +++ b/.github/prompts/autonomy.manifest.json @@ -0,0 +1,8 @@ +{ + "version": "2025.11", + "consent": { + "phrase": "", + "expiresMinutes": 0 + }, + "actions": [] +} \ No newline at end of file diff --git a/packages/stellar-sdk/package.json b/packages/stellar-sdk/package.json new file mode 100644 index 0000000..3453344 --- /dev/null +++ b/packages/stellar-sdk/package.json @@ -0,0 +1,15 @@ +{ + "name": "@gasguard/stellar-sdk", + "version": "1.0.0", + "description": "Stellar SDK with Soroban contract interface support", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": ["dist"], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rd /s /q dist" + } +} \ No newline at end of file diff --git a/packages/stellar-sdk/src/index.ts b/packages/stellar-sdk/src/index.ts new file mode 100644 index 0000000..23ca5ab --- /dev/null +++ b/packages/stellar-sdk/src/index.ts @@ -0,0 +1 @@ +export * from './registry/interfaces'; \ No newline at end of file diff --git a/packages/stellar-sdk/src/registry/interfaces/index.ts b/packages/stellar-sdk/src/registry/interfaces/index.ts new file mode 100644 index 0000000..ddecd1f --- /dev/null +++ b/packages/stellar-sdk/src/registry/interfaces/index.ts @@ -0,0 +1,9 @@ +export { SorobanInterfaceRegistry } from './registry'; +export type { + SorobanContractInterface, + SorobanFunctionInterface, + SorobanParameter, + SorobanType, + SorobanStructInterface, + SorobanEventInterface, +} from './types'; \ No newline at end of file diff --git a/packages/stellar-sdk/src/registry/interfaces/registry.ts b/packages/stellar-sdk/src/registry/interfaces/registry.ts new file mode 100644 index 0000000..b3079c5 --- /dev/null +++ b/packages/stellar-sdk/src/registry/interfaces/registry.ts @@ -0,0 +1,78 @@ +import { + SorobanContractInterface, + SorobanFunctionInterface, + SorobanStructInterface, + SorobanEventInterface, +} from './types'; + +export class SorobanInterfaceRegistry { + private contracts: Map = new Map(); + private contractIdMap: Map = new Map(); + + register(contractInterface: SorobanContractInterface): void { + this.contracts.set(contractInterface.id, contractInterface); + + if (contractInterface.contractId) { + this.contractIdMap.set(contractInterface.contractId, contractInterface.id); + } + } + + unregister(id: string): boolean { + const contract = this.contracts.get(id); + if (contract?.contractId) { + this.contractIdMap.delete(contract.contractId); + } + return this.contracts.delete(id); + } + + getById(id: string): SorobanContractInterface | undefined { + return this.contracts.get(id); + } + + getByContractId(contractId: string): SorobanContractInterface | undefined { + const id = this.contractIdMap.get(contractId); + return id ? this.contracts.get(id) : undefined; + } + + getAll(): SorobanContractInterface[] { + return Array.from(this.contracts.values()); + } + + findByName(name: string): SorobanContractInterface[] { + return this.getAll().filter( + (c) => c.name.toLowerCase() === name.toLowerCase() + ); + } + + findByVersion(version: string): SorobanContractInterface[] { + return this.getAll().filter((c) => c.version === version); + } + + searchByName(query: string): SorobanContractInterface[] { + const lowerQuery = query.toLowerCase(); + return this.getAll().filter( + (c) => + c.name.toLowerCase().includes(lowerQuery) || + c.metadata.description?.toLowerCase().includes(lowerQuery) + ); + } + + getFunction( + contractId: string, + functionName: string + ): SorobanFunctionInterface | undefined { + const contract = this.getByContractId(contractId); + if (!contract) return undefined; + + return contract.functions.find((f) => f.name === functionName); + } + + getInterfaceCount(): number { + return this.contracts.size; + } + + clear(): void { + this.contracts.clear(); + this.contractIdMap.clear(); + } +} \ No newline at end of file diff --git a/packages/stellar-sdk/src/registry/interfaces/types.ts b/packages/stellar-sdk/src/registry/interfaces/types.ts new file mode 100644 index 0000000..28e756c --- /dev/null +++ b/packages/stellar-sdk/src/registry/interfaces/types.ts @@ -0,0 +1,52 @@ +export interface SorobanFunctionInterface { + name: string; + signature: string; + inputs: SorobanParameter[]; + outputs: SorobanParameter[]; + documentation?: string; + entryPoint?: boolean; +} + +export interface SorobanParameter { + name: string; + type: SorobanType; + documentation?: string; +} + +export interface SorobanType { + name: string; + isPrimitive: boolean; + isVec?: boolean; + isOption?: boolean; + isMap?: boolean; + innerType?: SorobanType; +} + +export interface SorobanContractInterface { + id: string; + name: string; + version: string; + contractId?: string; + functions: SorobanFunctionInterface[]; + structs: SorobanStructInterface[]; + events: SorobanEventInterface[]; + metadata: { + author?: string; + description?: string; + license?: string; + sourceUrl?: string; + }; +} + +export interface SorobanStructInterface { + name: string; + fields: SorobanParameter[]; + documentation?: string; +} + +export interface SorobanEventInterface { + name: string; + topics: string[]; + data: SorobanParameter[]; + documentation?: string; +} \ No newline at end of file diff --git a/packages/stellar-sdk/tsconfig.json b/packages/stellar-sdk/tsconfig.json new file mode 100644 index 0000000..4515a1b --- /dev/null +++ b/packages/stellar-sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../packages/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/src/streaming/stellar/event-stream.spec.ts b/src/streaming/stellar/event-stream.spec.ts new file mode 100644 index 0000000..c59496d --- /dev/null +++ b/src/streaming/stellar/event-stream.spec.ts @@ -0,0 +1,111 @@ +import { StellarEventStream, StellarEvent, EventStreamOptions } from './event-stream'; +import { RpcClient } from '@rpc/index'; + +describe('StellarEventStream', () => { + let eventStream: StellarEventStream; + let mockRpcClient: jest.Mocked; + + beforeEach(() => { + mockRpcClient = { + call: jest.fn(), + } as any; + + eventStream = new StellarEventStream(mockRpcClient as RpcClient); + }); + + afterEach(() => { + eventStream.dispose(); + }); + + describe('subscribe', () => { + it('should subscribe to events and return a subscription', async () => { + mockRpcClient.call.mockResolvedValue({ events: [] }); + + const subscription = eventStream.subscribe( + { contractId: 'test_contract' }, + jest.fn() + ); + + expect(subscription.id).toBeDefined(); + expect(subscription.unsubscribe).toBeInstanceOf(Function); + }); + + it('should call callback with normalized events', async () => { + const mockEvent = { + type: 'ContractEvent', + contractId: 'test_contract', + topic: ['topic1'], + value: { data: 'test' }, + ledgerSequence: 100, + }; + + mockRpcClient.call.mockResolvedValue({ events: [mockEvent] }); + + const callback = jest.fn(); + const subscription = eventStream.subscribe({}, callback); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockRpcClient.call).toHaveBeenCalledWith('getEvents', expect.any(Object)); + subscription.unsubscribe(); + }); + + it('should support filtering by contractId', async () => { + mockRpcClient.call.mockResolvedValue({ events: [] }); + + eventStream.subscribe({ contractId: 'specific_contract' }, jest.fn()); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockRpcClient.call).toHaveBeenCalledWith( + 'getEvents', + expect.objectContaining({ + filters: [{ contractId: 'specific_contract' }], + }) + ); + + eventStream.dispose(); + }); + + it('should support filtering by topic', async () => { + mockRpcClient.call.mockResolvedValue({ events: [] }); + + eventStream.subscribe({ topic: 'transfer' }, jest.fn()); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockRpcClient.call).toHaveBeenCalledWith( + 'getEvents', + expect.objectContaining({ + topics: [['transfer']], + }) + ); + + eventStream.dispose(); + }); + }); + + describe('unsubscribe', () => { + it('should remove subscription when unsubscribed', () => { + mockRpcClient.call.mockResolvedValue({ events: [] }); + + const subscription = eventStream.subscribe({}, jest.fn()); + subscription.unsubscribe(); + + eventStream.dispose(); + + expect(subscription.id).toBeDefined(); + }); + }); + + describe('dispose', () => { + it('should clear all subscriptions', () => { + mockRpcClient.call.mockResolvedValue({ events: [] }); + + eventStream.subscribe({ contractId: 'contract1' }, jest.fn()); + eventStream.subscribe({ contractId: 'contract2' }, jest.fn()); + + eventStream.dispose(); + }); + }); +}); \ No newline at end of file diff --git a/src/streaming/stellar/event-stream.ts b/src/streaming/stellar/event-stream.ts new file mode 100644 index 0000000..4cd199b --- /dev/null +++ b/src/streaming/stellar/event-stream.ts @@ -0,0 +1,135 @@ +import { RpcClient } from '@rpc/index'; + +export interface StellarEvent { + id: string; + type: 'contract_call' | 'event_emit' | 'ledger_close' | 'transaction'; + contractId?: string; + transactionId?: string; + ledgerSequence?: number; + timestamp: number; + data: Record; + topics?: string[]; +} + +export interface EventStreamOptions { + contractId?: string; + topic?: string; + startLedger?: number; + endLedger?: number; + pollIntervalMs?: number; +} + +export interface Subscription { + id: string; + unsubscribe: () => void; +} + +export class StellarEventStream { + private subscriptions: Map = new Map(); + private eventCallbacks: Map void> = new Map(); + + constructor(private rpcClient: RpcClient) {} + + subscribe( + options: EventStreamOptions, + callback: (event: StellarEvent) => void + ): Subscription { + const subscriptionId = `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + this.eventCallbacks.set(subscriptionId, callback); + + this.startPolling(subscriptionId, options); + + return { + id: subscriptionId, + unsubscribe: () => this.unsubscribe(subscriptionId), + }; + } + + private startPolling(subscriptionId: string, options: EventStreamOptions): void { + const pollInterval = options.pollIntervalMs || 5000; + let currentLedger = options.startLedger || 0; + + const poll = async () => { + const callback = this.eventCallbacks.get(subscriptionId); + if (!callback) { + this.subscriptions.delete(subscriptionId); + return; + } + + try { + const events = await this.fetchEvents({ + ...options, + startLedger: currentLedger, + }); + + for (const event of events) { + callback(event); + if (event.ledgerSequence) { + currentLedger = Math.max(currentLedger, event.ledgerSequence + 1); + } + } + } catch (error) { + console.error(`[EventStream] Error fetching events:`, error); + } + }; + + poll(); + const timer = setInterval(poll, pollInterval); + this.subscriptions.set(subscriptionId, timer); + } + + private async fetchEvents(options: EventStreamOptions): Promise { + try { + const response = await this.rpcClient.call('getEvents', [ + { + startLedger: options.startLedger, + filters: options.contractId + ? [{ contractId: options.contractId }] + : undefined, + topics: options.topic ? [[options.topic]] : undefined, + }, + ]); + + return response.events?.map(this.normalizeEvent.bind(this)) || []; + } catch (error) { + throw error; + } + } + + private normalizeEvent(rawEvent: any): StellarEvent { + return { + id: rawEvent.id || `event_${Date.now()}`, + type: this.determineEventType(rawEvent), + contractId: rawEvent.contractId, + transactionId: rawEvent.transactionId, + ledgerSequence: rawEvent.ledgerSequence, + timestamp: rawEvent.timestamp || Date.now(), + data: rawEvent.value || rawEvent.data || {}, + topics: rawEvent.topic || rawEvent.topics || [], + }; + } + + private determineEventType(rawEvent: any): StellarEvent['type'] { + if (rawEvent.type === 'InvokeContract') return 'contract_call'; + if (rawEvent.type === 'ContractEvent') return 'event_emit'; + if (rawEvent.topic) return 'event_emit'; + if (rawEvent.ledgerSequence) return 'ledger_close'; + return 'transaction'; + } + + private unsubscribe(subscriptionId: string): void { + const timer = this.subscriptions.get(subscriptionId); + if (timer) { + clearInterval(timer); + this.subscriptions.delete(subscriptionId); + } + this.eventCallbacks.delete(subscriptionId); + } + + dispose(): void { + for (const subscriptionId of this.subscriptions.keys()) { + this.unsubscribe(subscriptionId); + } + } +} \ No newline at end of file diff --git a/src/streaming/stellar/index.ts b/src/streaming/stellar/index.ts new file mode 100644 index 0000000..c2304d4 --- /dev/null +++ b/src/streaming/stellar/index.ts @@ -0,0 +1,2 @@ +export { StellarEventStream } from './event-stream'; +export type { StellarEvent, EventStreamOptions, Subscription } from './event-stream'; \ No newline at end of file