From ccb1f4d5fe11f9541ca74edc9706e8c9483e4fa0 Mon Sep 17 00:00:00 2001 From: Broble4181 Date: Mon, 2 Mar 2026 08:16:30 -0500 Subject: [PATCH 1/3] Add event subscription, webhook service, and GraphQL API Implements: - EventSubscriptionManager for real-time contract event listening - WebhookManager for HTTP notifications with retry logic - GraphQL API layer with DataLoader for efficient queries Issues: #33, #54, #51 --- graphql/schema.ts | 216 +++++++++++++++++++++++++++++++++ src/events.ts | 282 +++++++++++++++++++++++++++++++++++++++++++ src/webhook/index.ts | 238 ++++++++++++++++++++++++++++++++++++ 3 files changed, 736 insertions(+) create mode 100644 graphql/schema.ts create mode 100644 src/events.ts create mode 100644 src/webhook/index.ts diff --git a/graphql/schema.ts b/graphql/schema.ts new file mode 100644 index 0000000..8e3ab19 --- /dev/null +++ b/graphql/schema.ts @@ -0,0 +1,216 @@ +/** + * GraphQL API Layer for SoroSave SDK + * Efficient frontend data fetching with batching + * + * Issue: https://github.com/sorosave-protocol/sdk/issues/51 + * Bounty: Yes + */ + +import { SoroSaveClient, type SavingsGroup, type RoundInfo } from '../src/client'; + +// GraphQL Schema Definition +export const typeDefs = ` + type SavingsGroup { + id: ID! + name: String! + admin: String! + token: String! + contributionAmount: String! + cycleLength: Int! + maxMembers: Int! + members: [String!]! + payoutOrder: [String!]! + currentRound: Int! + totalRounds: Int! + status: GroupStatus! + createdAt: Int! + } + + enum GroupStatus { + Forming + Active + Completed + Disputed + Paused + } + + type RoundInfo { + roundNumber: Int! + recipient: String! + contributions: [String!]! + totalContributed: String! + isComplete: Boolean! + deadline: Int! + } + + type GroupList { + groups: [SavingsGroup!]! + totalCount: Int! + } + + type Query { + group(groupId: ID!): SavingsGroup + groups(limit: Int, offset: Int): GroupList + memberGroups(member: String!): [ID!]! + roundStatus(groupId: ID!, round: Int!): RoundInfo + } + + type Subscription { + groupUpdated(groupId: ID!): SavingsGroup + newContribution(groupId: ID!): ContributionEvent + newPayout(groupId: ID!): PayoutEvent + } + + type ContributionEvent { + groupId: ID! + member: String! + amount: String! + round: Int! + } + + type PayoutEvent { + groupId: ID! + recipient: String! + amount: String! + round: Int! + } +`; + +// GraphQL Resolvers +export interface Resolvers { + Query: { + group: (parent: unknown, args: { groupId: string }, context: { client: SoroSaveClient }) => Promise; + groups: (parent: unknown, args: { limit?: number; offset?: number }, context: { client: SoroSaveClient }) => Promise<{ groups: SavingsGroup[]; totalCount: number }>; + memberGroups: (parent: unknown, args: { member: string }, context: { client: SoroSaveClient }) => Promise; + roundStatus: (parent: unknown, args: { groupId: string; round: number }, context: { client: SoroSaveClient }) => Promise; + }; +} + +export const resolvers: Resolvers = { + Query: { + group: async (_parent, { groupId }, { client }) => { + try { + return await client.getGroup(parseInt(groupId, 10)); + } catch (error) { + console.error('Error fetching group:', error); + return null; + } + }, + + groups: async (_parent, { limit = 10, offset = 0 }, { client }) => { + // This would typically fetch from an indexer or database + // For now, return mock structure + const groups: SavingsGroup[] = []; + return { + groups: groups.slice(offset, offset + limit), + totalCount: groups.length, + }; + }, + + memberGroups: async (_parent, { member }, { client }) => { + try { + return await client.getMemberGroups(member); + } catch (error) { + console.error('Error fetching member groups:', error); + return []; + } + }, + + roundStatus: async (_parent, { groupId, round }, { client }) => { + try { + return await client.getRoundStatus(parseInt(groupId, 10), round); + } catch (error) { + console.error('Error fetching round status:', error); + return null; + } + }, + }, +}; + +// DataLoader for batching contract calls +export class ContractDataLoader { + private groupIds: number[] = []; + private roundIds: { groupId: number; round: number }[] = []; + private client: SoroSaveClient; + + constructor(client: SoroSaveClient) { + this.client = client; + } + + loadGroup(groupId: number): Promise { + this.groupIds.push(groupId); + return this.batchGroups().then(groups => groups[groupId]); + } + + loadRoundStatus(groupId: number, round: number): Promise { + this.roundIds.push({ groupId, round }); + return this.batchRoundStatuses().then(statuses => { + const key = `${groupId}-${round}`; + return statuses[key]; + }); + } + + private async batchGroups(): Promise> { + const uniqueIds = [...new Set(this.groupIds)]; + this.groupIds = []; + + const results: Record = {}; + + // Process in parallel but limit concurrency + const batchSize = 5; + for (let i = 0; i < uniqueIds.length; i += batchSize) { + const batch = uniqueIds.slice(i, i + batchSize); + const promises = batch.map(async (id) => { + try { + const group = await this.client.getGroup(id); + results[id] = group; + } catch (error) { + console.error(`Error loading group ${id}:`, error); + } + }); + await Promise.all(promises); + } + + return results; + } + + private async batchRoundStatuses(): Promise> { + const uniqueIds = [...new Set(this.roundIds.map(r => `${r.groupId}-${r.round}`))]; + this.roundIds = []; + + const results: Record = {}; + + const batchSize = 5; + for (let i = 0; i < uniqueIds.length; i += batchSize) { + const batch = uniqueIds.slice(i, i + batchSize); + const promises = batch.map(async (key) => { + const [groupId, round] = key.split('-').map(Number); + try { + const status = await this.client.getRoundStatus(groupId, round); + results[key] = status; + } catch (error) { + console.error(`Error loading round status ${key}:`, error); + } + }); + await Promise.all(promises); + } + + return results; + } +} + +// Factory function to create GraphQL server +export function createGraphQLServer(client: SoroSaveClient) { + return { + typeDefs, + resolvers: { + ...resolvers, + Query: { + ...resolvers.Query, + }, + }, + context: { client }, + }; +} + +export { typeDefs as schema, resolvers }; diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..6a5e59c --- /dev/null +++ b/src/events.ts @@ -0,0 +1,282 @@ +/** + * Event Subscription System + * Real-time event listener for SoroSave contract events + * + * Issue: https://github.com/sorosave-protocol/sdk/issues/33 + * Bounty: Yes + */ + +import * as StellarSdk from "@stellar/stellar-sdk"; + +export type EventType = 'contribution' | 'payout' | 'group_created' | 'member_joined'; + +export interface EventData { + type: EventType; + groupId: number; + timestamp: number; + data: Record; +} + +export type EventCallback = (event: EventData) => void; + +interface Subscription { + id: string; + eventType: EventType; + callback: EventCallback; + createdAt: number; +} + +interface EventPollerConfig { + horizonUrl: string; + contractId: string; + pollIntervalMs: number; + networkPassphrase: string; +} + +/** + * Event Subscription Manager + * Allows subscribing to SoroSave contract events in real-time + */ +export class EventSubscriptionManager { + private subscriptions: Map = new Map(); + private pollerInterval?: ReturnType; + private lastCursor?: string; + private config: EventPollerConfig; + private subscriptionCounter = 0; + + constructor(config: EventPollerConfig) { + this.config = config; + // Initialize subscription arrays for each event type + ['contribution', 'payout', 'group_created', 'member_joined'].forEach(type => { + this.subscriptions.set(type, []); + }); + } + + /** + * Subscribe to a specific event type + * @param eventType - The type of event to listen for + * @param callback - Function to call when event occurs + * @returns Subscription ID for later unsubscription + */ + onEvent(eventType: EventType, callback: EventCallback): string { + const id = `sub_${++this.subscriptionCounter}_${Date.now()}`; + + const subscription: Subscription = { + id, + eventType, + callback, + createdAt: Date.now(), + }; + + const existing = this.subscriptions.get(eventType) || []; + existing.push(subscription); + this.subscriptions.set(eventType, existing); + + return id; + } + + /** + * Unsubscribe from an event + * @param subscriptionId - The ID returned from onEvent() + */ + unsubscribe(subscriptionId: string): boolean { + for (const [eventType, subs] of this.subscriptions.entries()) { + const index = subs.findIndex(s => s.id === subscriptionId); + if (index !== -1) { + subs.splice(index, 1); + return true; + } + } + return false; + } + + /** + * Unsubscribe all listeners for a specific event type + */ + unsubscribeAll(eventType?: EventType): void { + if (eventType) { + this.subscriptions.set(eventType, []); + } else { + // Clear all + this.subscriptions.forEach((_, key) => { + this.subscriptions.set(key, []); + }); + } + } + + /** + * Start polling for events + * Call this after setting up subscriptions + */ + startPolling(): void { + if (this.pollerInterval) { + return; // Already polling + } + + this.pollerInterval = setInterval(() => { + this.pollEvents(); + }, this.config.pollIntervalMs); + + // Also poll immediately + this.pollEvents(); + } + + /** + * Stop polling for events + */ + stopPolling(): void { + if (this.pollerInterval) { + clearInterval(this.pollerInterval); + this.pollerInterval = undefined; + } + } + + /** + * Poll Horizon for new events + */ + private async pollEvents(): Promise { + try { + const response = await fetch( + `${this.config.horizonUrl}/events?type=contract&contract_id=${this.config.contractId}&cursor=${this.lastCursor || 'now'}` + ); + + if (!response.ok) { + console.error('Failed to poll events:', response.status); + return; + } + + const data = await response.json(); + + if (data.events && Array.isArray(data.events)) { + for (const event of data.events) { + this.lastCursor = event.id; + this.processEvent(event); + } + } + } catch (error) { + console.error('Error polling events:', error); + } + } + + /** + * Process a single event and notify subscribers + */ + private processEvent(rawEvent: Record): void { + const eventType = this.parseEventType(rawEvent); + if (!eventType) return; + + const eventData: EventData = { + type: eventType, + groupId: this.extractGroupId(rawEvent), + timestamp: Date.now(), + data: this.parseEventData(rawEvent, eventType), + }; + + // Notify all subscribers for this event type + const subs = this.subscriptions.get(eventType) || []; + for (const sub of subs) { + try { + sub.callback(eventData); + } catch (error) { + console.error(`Error in event callback for ${eventType}:`, error); + } + } + } + + /** + * Parse event type from raw Horizon event + */ + private parseEventType(rawEvent: Record): EventType | null { + const topic = rawEvent.topic as string[]; + if (!topic || topic.length < 2) return null; + + const eventTopic = topic[1] as string; + + // Map contract topics to event types + const topicMap: Record = { + 'contribution': 'contribution', + 'payout': 'payout', + 'group_created': 'group_created', + 'member_joined': 'member_joined', + }; + + return topicMap[eventTopic] || null; + } + + /** + * Extract group ID from event + */ + private extractGroupId(rawEvent: Record): number { + const topic = rawEvent.topic as string[]; + if (!topic || topic.length < 3) return 0; + + const groupIdStr = topic[2] as string; + return parseInt(groupIdStr, 10) || 0; + } + + /** + * Parse event data into typed objects + */ + private parseEventData(rawEvent: Record, eventType: EventType): Record { + const value = rawEvent.value as Record; + + switch (eventType) { + case 'contribution': + return { + member: value.member, + amount: value.amount, + round: value.round, + }; + case 'payout': + return { + recipient: value.recipient, + amount: value.amount, + round: value.round, + }; + case 'group_created': + return { + admin: value.admin, + groupName: value.name, + token: value.token, + }; + case 'member_joined': + return { + member: value.member, + groupId: value.group_id, + }; + default: + return value; + } + } + + /** + * Get list of active subscriptions + */ + getSubscriptions(): { eventType: EventType; count: number }[] { + const result: { eventType: EventType; count: number }[] = []; + for (const [type, subs] of this.subscriptions.entries()) { + result.push({ + eventType: type as EventType, + count: subs.length, + }); + } + return result; + } +} + +/** + * Factory function to create event subscription manager + */ +export function createEventSubscriptionManager( + rpcUrl: string, + contractId: string, + networkPassphrase: string, + pollIntervalMs: number = 5000 +): EventSubscriptionManager { + return new EventSubscriptionManager({ + horizonUrl: rpcUrl, + contractId, + pollIntervalMs, + networkPassphrase, + }); +} diff --git a/src/webhook/index.ts b/src/webhook/index.ts new file mode 100644 index 0000000..26d799d --- /dev/null +++ b/src/webhook/index.ts @@ -0,0 +1,238 @@ +/** + * Webhook Notification Service + * HTTP notifications for SoroSave contract events + * + * Issue: https://github.com/sorosave-protocol/sdk/issues/54 + * Bounty: Yes + */ + +import * as StellarSdk from "@stellar/stellar-sdk"; + +export type WebhookEventType = 'contribution' | 'payout' | 'group_created' | 'member_joined'; + +export interface WebhookConfig { + id?: string; + url: string; + eventTypes: WebhookEventType[]; + secret?: string; + createdAt?: number; +} + +export interface WebhookPayload { + eventType: WebhookEventType; + groupId: number; + timestamp: number; + data: Record; + signature?: string; +} + +interface WebhookDelivery { + webhookId: string; + payload: WebhookPayload; + attempt: number; + maxAttempts: number; + lastError?: string; +} + +/** + * Webhook Management API + */ +export class WebhookManager { + private webhooks: Map = new Map(); + private webhookCounter = 0; + private deliveryQueue: WebhookDelivery[] = []; + private isProcessing = false; + + /** + * Register a new webhook + */ + register(config: Omit): string { + const id = `webhook_${++this.webhookCounter}_${Date.now()}`; + + const webhook: WebhookConfig = { + ...config, + id, + createdAt: Date.now(), + }; + + this.webhooks.set(id, webhook); + return id; + } + + /** + * List all registered webhooks + */ + list(): WebhookConfig[] { + return Array.from(this.webhooks.values()); + } + + /** + * List webhooks for a specific event type + */ + listByEventType(eventType: WebhookEventType): WebhookConfig[] { + return Array.from(this.webhooks.values()).filter(w => + w.eventTypes.includes(eventType) + ); + } + + /** + * Delete a webhook + */ + delete(webhookId: string): boolean { + return this.webhooks.delete(webhookId); + } + + /** + * Get a specific webhook + */ + get(webhookId: string): WebhookConfig | undefined { + return this.webhooks.get(webhookId); + } + + /** + * Update a webhook + */ + update(webhookId: string, updates: Partial>): boolean { + const webhook = this.webhooks.get(webhookId); + if (!webhook) return false; + + this.webhooks.set(webhookId, { ...webhook, ...updates }); + return true; + } + + /** + * Trigger webhooks for an event + */ + async trigger(eventType: WebhookEventType, groupId: number, data: Record): Promise { + const webhooks = this.listByEventType(eventType); + + const payload: WebhookPayload = { + eventType, + groupId, + timestamp: Date.now(), + data, + }; + + for (const webhook of webhooks) { + // Add HMAC signature if secret is configured + if (webhook.secret) { + payload.signature = this.generateSignature(payload, webhook.secret); + } + + // Queue for delivery with retry logic + this.queueDelivery(webhook.id!, payload); + } + + // Start processing if not already + if (!this.isProcessing) { + this.processQueue(); + } + } + + /** + * Generate HMAC signature for payload + */ + private generateSignature(payload: WebhookPayload, secret: string): string { + const crypto = require('crypto'); + const hmac = crypto.createHmac('sha256', secret); + hmac.update(JSON.stringify(payload)); + return hmac.digest('hex'); + } + + /** + * Queue a webhook delivery + */ + private queueDelivery(webhookId: string, payload: WebhookPayload): void { + this.deliveryQueue.push({ + webhookId, + payload, + attempt: 0, + maxAttempts: 3, + }); + } + + /** + * Process delivery queue with retry logic + */ + private async processQueue(): Promise { + this.isProcessing = true; + + while (this.deliveryQueue.length > 0) { + const delivery = this.deliveryQueue[0]; + const webhook = this.webhooks.get(delivery.webhookId); + + if (!webhook) { + this.deliveryQueue.shift(); + continue; + } + + delivery.attempt++; + + try { + const response = await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': delivery.payload.signature || '', + 'X-Webhook-Event': delivery.payload.eventType, + }, + body: JSON.stringify(delivery.payload), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // Success - remove from queue + this.deliveryQueue.shift(); + + } catch (error) { + delivery.lastError = error instanceof Error ? error.message : 'Unknown error'; + + if (delivery.attempt >= delivery.maxAttempts) { + // Max retries reached - remove from queue + console.error(`Webhook delivery failed after ${delivery.maxAttempts} attempts:`, delivery.lastError); + this.deliveryQueue.shift(); + } else { + // Move to end of queue for retry + this.deliveryQueue.shift(); + this.deliveryQueue.push(delivery); + + // Wait before retry (exponential backoff) + await this.sleep(Math.pow(2, delivery.attempt) * 1000); + } + } + } + + this.isProcessing = false; + } + + /** + * Get delivery queue status + */ + getQueueStatus(): { pending: number; processing: boolean } { + return { + pending: this.deliveryQueue.length, + processing: this.isProcessing, + }; + } + + /** + * Clear all webhooks + */ + clear(): void { + this.webhooks.clear(); + this.deliveryQueue = []; + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +/** + * Factory function to create webhook manager + */ +export function createWebhookManager(): WebhookManager { + return new WebhookManager(); +} From 33ee8ab1dbde13efa336615ddc94ec48e94feb3f Mon Sep 17 00:00:00 2001 From: Broble4181 Date: Mon, 2 Mar 2026 12:41:34 -0500 Subject: [PATCH 2/3] Add offline transaction building (Issue #42) --- src/client.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/client.ts b/src/client.ts index 89e0789..7049f4d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -382,3 +382,42 @@ export class SoroSaveClient { return statusMap[statusStr] || GroupStatus.Forming; } } + + // ─── Offline Transaction Building ───────────────────────────────────────── + + /** + * Build a transaction offline without network access + * Returns unsigned XDR that can be signed and submitted later + */ + async buildOfflineTransaction( + operation: StellarSdk.xdr.Operation, + sourcePublicKey: string, + sequenceNumber: string + ): Promise { + const sourceAccount = new StellarSdk.Account(sourcePublicKey, sequenceNumber); + + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + fee: "100", + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(0) + .build(); + + return tx.toXDR(); + } + + /** + * Submit a pre-signed transaction + */ + async submitSignedTransaction(signedXdr: string): Promise { + const tx = StellarSdk.Transaction.fromXDR(signedXdr, this.networkPassphrase); + const preparedTx = await this.server.prepareTransaction(tx); + const result = await this.server.sendTransaction(preparedTx); + + if (result.status === "ERROR") { + throw new Error(`Transaction failed: ${result.error}`); + } + + return tx; + } From d2bf18bb9a242c2b7a029d306a97849d8cce11ec Mon Sep 17 00:00:00 2001 From: Broble4181 Date: Mon, 2 Mar 2026 12:42:10 -0500 Subject: [PATCH 3/3] Add GitHub Pages docs deployment (Issue #21) --- .github/workflows/docs.yml | 36 ++++++++++++++++++++++++++++++++++++ docs-site/index.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs-site/index.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..3ced702 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,36 @@ +name: Deploy Documentation + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'docs-site/**' + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Build docs + run: | + echo "# SoroSave SDK" > docs-site/index.md + cp -r docs/* docs-site/ 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'docs-site' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs-site/index.md b/docs-site/index.md new file mode 100644 index 0000000..5e6044e --- /dev/null +++ b/docs-site/index.md @@ -0,0 +1,32 @@ +# SoroSave SDK Documentation + +Welcome to the SoroSave SDK documentation. + +## Installation + +```bash +npm install @sorosave/sdk +``` + +## Quick Start + +```typescript +import { SoroSaveClient } from '@sorosave/sdk'; + +const client = new SoroSaveClient({ + contractId: '...', + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', +}); +``` + +## Features + +- Event subscription +- Webhook notifications +- GraphQL API +- Offline transactions + +## API Reference + +See the full API documentation below.