diff --git a/src/approvalWorkflowSequencer.ts b/src/approvalWorkflowSequencer.ts new file mode 100644 index 0000000..bc177d4 --- /dev/null +++ b/src/approvalWorkflowSequencer.ts @@ -0,0 +1,95 @@ +import { ApprovalTimeoutError } from "./errors.js"; +import { emitSdkEvent } from "./events.js"; +import type { ApprovalSessionResult, MultiSigPolicy } from "./types.js"; + +export type NotificationAdapter = (signerPublicKey: string, txXdr: string) => void | Promise; +export type SignatureApplier = ( + txXdr: string, + signatures: ReadonlyMap, +) => string; + +export interface ApprovalWorkflowOptions { + notifySigner?: NotificationAdapter; + applySignatures?: SignatureApplier; +} + +export class ApprovalSession { + private readonly signatures = new Map(); + private readonly signerWeights = new Map(); + private readonly expiresAt: number; + private completed = false; + private timer: ReturnType; + + constructor( + private readonly txXdr: string, + private readonly policy: MultiSigPolicy, + private readonly applySignatures: SignatureApplier, + ) { + for (const signer of policy.signers) { + this.signerWeights.set(signer.publicKey, signer.weight); + } + this.expiresAt = Date.now() + policy.timeoutMs; + this.timer = setTimeout(() => undefined, policy.timeoutMs); + } + + submitSignature(signerPublicKey: string, signatureBase64: string): ApprovalSessionResult { + this.assertActive(); + if (!this.signerWeights.has(signerPublicKey)) { + throw new Error(`Signer is not authorized: ${signerPublicKey}`); + } + + this.signatures.set(signerPublicKey, signatureBase64); + emitSdkEvent("approvalReceived", { signerPublicKey }); + + if (this.weight >= this.policy.threshold) { + this.completed = true; + clearTimeout(this.timer); + emitSdkEvent("approvalWorkflowComplete", { signerCount: this.signatures.size }); + } + + return { complete: this.completed, weight: this.weight }; + } + + getSignedXdr(): string { + this.assertActive(); + if (!this.completed) { + throw new Error("Approval threshold has not been reached"); + } + return this.applySignatures(this.txXdr, this.signatures); + } + + private get weight(): number { + let total = 0; + for (const publicKey of this.signatures.keys()) { + total += this.signerWeights.get(publicKey) ?? 0; + } + return total; + } + + private assertActive(): void { + if (this.completed) return; + if (Date.now() > this.expiresAt) { + clearTimeout(this.timer); + throw new ApprovalTimeoutError(this.policy.timeoutMs); + } + } +} + +export class ApprovalWorkflowSequencer { + constructor(private readonly options: ApprovalWorkflowOptions = {}) {} + + initiate(txXdr: string, policy: MultiSigPolicy): ApprovalSession { + const session = new ApprovalSession( + txXdr, + policy, + this.options.applySignatures ?? ((xdr) => xdr), + ); + + for (const signer of policy.signers) { + emitSdkEvent("approvalRequested", { signerPublicKey: signer.publicKey }); + void this.options.notifySigner?.(signer.publicKey, txXdr); + } + + return session; + } +} diff --git a/src/cursorTracker.ts b/src/cursorTracker.ts index 5ffc043..251e49c 100644 --- a/src/cursorTracker.ts +++ b/src/cursorTracker.ts @@ -8,6 +8,8 @@ import type { CursorStore } from "./types.js"; +export type CursorPersistence = CursorStore; + /** * In-memory cursor store suitable for session-scoped pagination. * Cursors are lost when the process exits. @@ -35,6 +37,7 @@ export class InMemoryCursorStore implements CursorStore { /** Singleton in-memory store shared across the module. */ let defaultStore: CursorStore = new InMemoryCursorStore(); +const syncCursorStore = new Map(); /** * Override the default cursor store. @@ -44,6 +47,11 @@ export function setDefaultCursorStore(store: CursorStore): void { defaultStore = store; } +/** Alias retained for public API compatibility. */ +export function configureCursorStore(store: CursorStore): void { + setDefaultCursorStore(store); +} + /** * Get the current default cursor store. */ @@ -51,6 +59,35 @@ export function getDefaultCursorStore(): CursorStore { return defaultStore; } +/** Persist a cursor in the default in-memory stream cursor cache. */ +export function setCursor(key: string, cursor: string): void { + syncCursorStore.set(key, cursor); + void defaultStore.save(key, cursor); +} + +/** Read a cursor from the default in-memory stream cursor cache. */ +export function getCursor(key: string): string | null { + return syncCursorStore.get(key) ?? null; +} + +/** Remove a persisted cursor from the default stream cursor cache. */ +export function removeCursor(key: string): void { + syncCursorStore.delete(key); + void defaultStore.delete(key); +} + +/** Persist a cursor from a snapshot-like object containing a cursor field. */ +export function setCursorFromSnapshot(key: string, snapshot: { cursor?: string | number | null }): void { + if (snapshot.cursor !== undefined && snapshot.cursor !== null) { + setCursor(key, String(snapshot.cursor)); + } +} + +/** Clear in-memory cursors for tests. */ +export function _resetCursorTrackerForTesting(): void { + syncCursorStore.clear(); +} + /** * Build a namespaced cursor key from a base name and namespace. */ diff --git a/src/events.ts b/src/events.ts index f1bd019..b3b0529 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,4 +1,6 @@ import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; +import type { FinalityStatus } from "./types.js"; /** Type of contract event. */ export type ContractEventType = "created" | "payment" | "released" | "refunded"; @@ -17,6 +19,21 @@ export interface ContractEvent { timestamp: number; } +export interface SDKEventMap extends Record { + streamStallDetected: { streamId: string }; + streamAutoReset: { streamId: string }; + invoiceFinalized: { txHash: string; finality: FinalityStatus }; + approvalRequested: { signerPublicKey: string }; + approvalReceived: { signerPublicKey: string }; + approvalWorkflowComplete: { signerCount: number }; +} + +export const sdkEvents = new TypedEventEmitter(); + +export function emitSdkEvent(event: K, payload: SDKEventMap[K]): void { + sdkEvents.emit(event, payload); +} + /** * Replay historical contract events in a ledger range. * diff --git a/src/finalityChecker.ts b/src/finalityChecker.ts new file mode 100644 index 0000000..c70a5d3 --- /dev/null +++ b/src/finalityChecker.ts @@ -0,0 +1,77 @@ +import { FinalityTimeoutError } from "./errors.js"; +import { emitSdkEvent } from "./events.js"; +import type { FinalityCheckConfig, FinalityStatus } from "./types.js"; + +interface CallBuilder { + call(): Promise; +} + +interface TransactionRecordLike { + ledger: number | string; + successful?: boolean; + result_successful?: boolean; +} + +interface LedgerRecordLike { + sequence?: number | string; +} + +export interface FinalityServerLike { + transactions(): { + transaction(hash: string): CallBuilder; + }; + ledgers(): { + order(direction: "desc"): { + limit(limit: number): CallBuilder<{ records: LedgerRecordLike[] }>; + }; + }; +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export class FinalityChecker { + private readonly server: FinalityServerLike; + private readonly config: Required; + + constructor(server: FinalityServerLike, config: FinalityCheckConfig = {}) { + this.server = server; + this.config = { + minConfirmations: config.minConfirmations ?? 2, + pollIntervalMs: config.pollIntervalMs ?? 1_000, + maxWaitMs: config.maxWaitMs ?? 30_000, + }; + } + + async check(txHash: string): Promise { + const startedAt = Date.now(); + + while (true) { + const status = await this.readStatus(txHash); + if (status.finalized) { + emitSdkEvent("invoiceFinalized", { txHash, finality: status }); + return status; + } + + if (Date.now() - startedAt >= this.config.maxWaitMs) { + throw new FinalityTimeoutError(txHash, this.config.maxWaitMs); + } + + await sleep(this.config.pollIntervalMs); + } + } + + private async readStatus(txHash: string): Promise { + const tx = await this.server.transactions().transaction(txHash).call(); + const ledgerSequence = Number(tx.ledger); + const latest = await this.server.ledgers().order("desc").limit(1).call(); + const currentLedger = Number(latest.records[0]?.sequence ?? ledgerSequence); + const confirmations = Math.max(0, currentLedger - ledgerSequence); + const successful = tx.successful === true || tx.result_successful === true; + + return { + finalized: successful && confirmations >= this.config.minConfirmations, + confirmations, + ledgerSequence, + }; + } +} diff --git a/src/index.ts b/src/index.ts index e10cf97..fd17073 100644 --- a/src/index.ts +++ b/src/index.ts @@ -199,6 +199,10 @@ export { ClassifiedHorizonError, isClassifiedHorizonError, HorizonErrorClassification, + FinalityTimeoutError, + isFinalityTimeoutError, + ApprovalTimeoutError, + isApprovalTimeoutError, } from "./errors.js"; // --------------------------------------------------------------------------- @@ -269,6 +273,14 @@ export { Deduplicator } from "./dedup.js"; export { TxQueue } from "./queue.js"; export { replayEvents } from "./events.js"; +export { sdkEvents } from "./events.js"; +export { FinalityChecker } from "./finalityChecker.js"; +export type { FinalityServerLike } from "./finalityChecker.js"; +export { ApprovalWorkflowSequencer, ApprovalSession } from "./approvalWorkflowSequencer.js"; +export type { ApprovalWorkflowOptions, NotificationAdapter, SignatureApplier } from "./approvalWorkflowSequencer.js"; +export { OperationChunker, MAX_OPERATIONS_PER_TRANSACTION } from "./operationChunker.js"; +export { StreamHealthProbe } from "./streamHealthProbe.js"; +export type { MonitoredStream, StreamHealthProbeOptions } from "./streamHealthProbe.js"; export { EventChecksumChain, verifyChain, @@ -394,6 +406,12 @@ export type { DecodedTransactionMeta, DecodedLedgerEntry, DecodedOperation, + FinalityStatus, + FinalityCheckConfig, + MultiSigPolicy, + ApprovalSessionResult, + BatchPaymentResult, + ChunkSubmitter, } from "./types.js"; export { InvalidTransitionError } from "./types.js"; @@ -580,6 +598,7 @@ export { generatePaymentReceipt, serializePaymentReceipt, deserializePaymentReceipt, + finalizePaymentReceipt, } from "./receipt.js"; export type { PaymentReceipt, diff --git a/src/operationChunker.ts b/src/operationChunker.ts new file mode 100644 index 0000000..e5bd274 --- /dev/null +++ b/src/operationChunker.ts @@ -0,0 +1,64 @@ +import { IdempotencyManager } from "./idempotency.js"; +import type { BatchPaymentResult, ChunkSubmitter } from "./types.js"; +import type { OperationOptions } from "@stellar/stellar-base"; + +export const MAX_OPERATIONS_PER_TRANSACTION = 100; + +export class OperationChunker { + private readonly idempotency: IdempotencyManager; + + constructor(idempotency = new IdempotencyManager()) { + this.idempotency = idempotency; + } + + static chunk(operations: T[], chunkSize = MAX_OPERATIONS_PER_TRANSACTION): T[][] { + if (chunkSize <= 0) { + throw new Error("chunkSize must be greater than zero"); + } + + const chunks: T[][] = []; + for (let index = 0; index < operations.length; index += chunkSize) { + chunks.push(operations.slice(index, index + chunkSize)); + } + return chunks; + } + + chunk(operations: T[], chunkSize = MAX_OPERATIONS_PER_TRANSACTION): T[][] { + return OperationChunker.chunk(operations, chunkSize); + } + + async submitAll( + chunks: T[][], + config: { sourceAddress: string; submitChunk: ChunkSubmitter }, + ): Promise { + const result: BatchPaymentResult = { succeeded: [], failed: null }; + + for (let index = 0; index < chunks.length; index++) { + const operations = chunks[index]!; + const key = this.idempotency.generateKey( + config.sourceAddress, + JSON.stringify({ index, operations }), + ); + const existing = this.idempotency.getResult(key); + + if (existing) { + result.succeeded.push({ chunkIndex: index, txHash: existing.txHash }); + continue; + } + + try { + const submitted = await config.submitChunk(operations, index); + this.idempotency.tryClaim(key, { txHash: submitted.txHash }); + result.succeeded.push({ chunkIndex: index, txHash: submitted.txHash }); + } catch (error) { + result.failed = { + chunkIndex: index, + error: error instanceof Error ? error : new Error(String(error)), + }; + break; + } + } + + return result; + } +} diff --git a/src/receipt.ts b/src/receipt.ts index 91c0a7d..21d40a0 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -189,6 +189,25 @@ export function deserializePaymentReceipt(json: string): PaymentReceipt { }); } +/** Run a finality check for a submitted payment transaction and attach it to the receipt. */ +export async function finalizePaymentReceipt( + receipt: PaymentReceipt, + txHash: string, + checker: FinalityChecker, +): Promise { + const finality = await checker.check(txHash); + return _buildReceiptObject({ + invoiceId: receipt.invoiceId, + payer: receipt.payer, + totalPaid: receipt.totalPaid, + payments: receipt.payments, + proofHash: receipt.proofHash, + generatedAt: receipt.generatedAt, + ledgerTimestamp: receipt.ledgerTimestamp, + finality, + }); +} + function _buildReceiptObject(data: { invoiceId: string; payer: string; @@ -224,4 +243,4 @@ function _buildReceiptObject(data: { }; }, }; -} \ No newline at end of file +} diff --git a/src/streamHealthProbe.ts b/src/streamHealthProbe.ts new file mode 100644 index 0000000..fb78893 --- /dev/null +++ b/src/streamHealthProbe.ts @@ -0,0 +1,92 @@ +import { getCursor } from "./cursorTracker.js"; +import { emitSdkEvent } from "./events.js"; + +export interface MonitoredStream { + close(): void; + on?(event: "message" | "event", handler: (event: unknown) => void): void; + off?(event: "message" | "event", handler: (event: unknown) => void): void; + addEventListener?(event: "message" | "event", handler: (event: unknown) => void): void; + removeEventListener?(event: "message" | "event", handler: (event: unknown) => void): void; +} + +export interface StreamHealthProbeOptions { + stalledThresholdMs?: number; + checkIntervalMs?: number; + autoReset?: boolean; + onStall?: (streamId: string) => void; + streamFactory?: (streamId: string, cursor: string | null) => MonitoredStream; +} + +interface Attachment { + stream: MonitoredStream; + lastEventAt: number; + timer: ReturnType; + handler: (event: unknown) => void; +} + +export class StreamHealthProbe { + private readonly attachments = new Map(); + private readonly stalledThresholdMs: number; + private readonly checkIntervalMs: number; + + constructor(private readonly options: StreamHealthProbeOptions = {}) { + this.stalledThresholdMs = options.stalledThresholdMs ?? 30_000; + this.checkIntervalMs = options.checkIntervalMs ?? Math.min(this.stalledThresholdMs, 5_000); + } + + attach(streamId: string, stream: MonitoredStream): void { + this.detach(streamId); + + const handler = (): void => { + const attachment = this.attachments.get(streamId); + if (attachment) attachment.lastEventAt = Date.now(); + }; + + this.bind(stream, handler); + const timer = setInterval(() => this.check(streamId), this.checkIntervalMs); + this.attachments.set(streamId, { stream, lastEventAt: Date.now(), timer, handler }); + } + + detach(streamId: string): void { + const attachment = this.attachments.get(streamId); + if (!attachment) return; + + clearInterval(attachment.timer); + this.unbind(attachment.stream, attachment.handler); + this.attachments.delete(streamId); + } + + private check(streamId: string): void { + const attachment = this.attachments.get(streamId); + if (!attachment) return; + + if (Date.now() - attachment.lastEventAt <= this.stalledThresholdMs) return; + + emitSdkEvent("streamStallDetected", { streamId }); + this.options.onStall?.(streamId); + + if (this.options.autoReset) { + attachment.stream.close(); + const replacement = this.options.streamFactory?.(streamId, getCursor(streamId)); + emitSdkEvent("streamAutoReset", { streamId }); + this.detach(streamId); + if (replacement) this.attach(streamId, replacement); + } else { + attachment.lastEventAt = Date.now(); + } + } + + private bind(stream: MonitoredStream, handler: (event: unknown) => void): void { + stream.on?.("message", handler); + stream.on?.("event", handler); + stream.addEventListener?.("message", handler); + stream.addEventListener?.("event", handler); + } + + private unbind(stream: MonitoredStream, handler: (event: unknown) => void): void { + stream.off?.("message", handler); + stream.off?.("event", handler); + stream.removeEventListener?.("message", handler); + stream.removeEventListener?.("event", handler); + } +} diff --git a/test/approvalWorkflowSequencer.test.ts b/test/approvalWorkflowSequencer.test.ts new file mode 100644 index 0000000..98ba99b --- /dev/null +++ b/test/approvalWorkflowSequencer.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApprovalWorkflowSequencer } from "../src/approvalWorkflowSequencer.js"; + +describe("ApprovalWorkflowSequencer", () => { + it("resolves a 2-of-3 flow on the second valid signature", () => { + const notifySigner = vi.fn(); + const sequencer = new ApprovalWorkflowSequencer({ + notifySigner, + applySignatures: (xdr, signatures) => `${xdr}:${signatures.size}`, + }); + + const session = sequencer.initiate("tx-xdr", { + threshold: 2, + timeoutMs: 1_000, + signers: [ + { publicKey: "G1", weight: 1 }, + { publicKey: "G2", weight: 1 }, + { publicKey: "G3", weight: 1 }, + ], + }); + + expect(notifySigner).toHaveBeenCalledTimes(3); + expect(session.submitSignature("G1", "sig-1")).toEqual({ complete: false, weight: 1 }); + expect(session.submitSignature("G2", "sig-2")).toEqual({ complete: true, weight: 2 }); + expect(session.getSignedXdr()).toBe("tx-xdr:2"); + }); +}); diff --git a/test/finalityChecker.test.ts b/test/finalityChecker.test.ts new file mode 100644 index 0000000..a08e904 --- /dev/null +++ b/test/finalityChecker.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { FinalityTimeoutError } from "../src/errors.js"; +import { FinalityChecker, type FinalityServerLike } from "../src/finalityChecker.js"; + +function server(txLedger: number, ledgers: number[], successful = true): FinalityServerLike { + let ledgerCall = 0; + return { + transactions: () => ({ + transaction: () => ({ + call: async () => ({ ledger: txLedger, successful }), + }), + }), + ledgers: () => ({ + order: () => ({ + limit: () => ({ + call: async () => ({ records: [{ sequence: ledgers[Math.min(ledgerCall++, ledgers.length - 1)]! }] }), + }), + }), + }), + }; +} + +describe("FinalityChecker", () => { + it("counts confirmations and finalizes only after threshold", async () => { + const checker = new FinalityChecker(server(10, [11, 12]), { + minConfirmations: 2, + pollIntervalMs: 1, + maxWaitMs: 50, + }); + + await expect(checker.check("hash")).resolves.toEqual({ + finalized: true, + confirmations: 2, + ledgerSequence: 10, + }); + }); + + it("throws on timeout", async () => { + vi.useFakeTimers(); + const checker = new FinalityChecker(server(10, [10], true), { + minConfirmations: 2, + pollIntervalMs: 10, + maxWaitMs: 20, + }); + const promise = checker.check("hash"); + const assertion = expect(promise).rejects.toBeInstanceOf(FinalityTimeoutError); + + await vi.advanceTimersByTimeAsync(30); + await assertion; + vi.useRealTimers(); + }); +}); diff --git a/test/operationChunker.test.ts b/test/operationChunker.test.ts new file mode 100644 index 0000000..bd3e55f --- /dev/null +++ b/test/operationChunker.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { OperationChunker, MAX_OPERATIONS_PER_TRANSACTION } from "../src/operationChunker.js"; +import type { OperationOptions } from "@stellar/stellar-base"; + +function operations(count: number): OperationOptions[] { + return Array.from({ length: count }, (_, index) => ({ + type: "payment", + destination: `G${index}`, + asset: "native", + amount: "1", + })) as OperationOptions[]; +} + +describe("OperationChunker", () => { + it("chunks 150 recipients into 2 transactions within the operation limit", () => { + const chunks = OperationChunker.chunk(operations(150)); + expect(chunks).toHaveLength(2); + expect(chunks.every((chunk) => chunk.length <= MAX_OPERATIONS_PER_TRANSACTION)).toBe(true); + }); + + it("chunks 250 recipients into 3 transactions within the operation limit", () => { + const chunks = OperationChunker.chunk(operations(250)); + expect(chunks).toHaveLength(3); + expect(chunks.every((chunk) => chunk.length <= MAX_OPERATIONS_PER_TRANSACTION)).toBe(true); + }); + + it("reports succeeded chunks and stops on partial failure", async () => { + const chunker = new OperationChunker(); + const result = await chunker.submitAll(OperationChunker.chunk(operations(150)), { + sourceAddress: "GABC", + submitChunk: async (_chunk, index) => { + if (index === 1) throw new Error("failed"); + return { txHash: "hash-0" }; + }, + }); + + expect(result.succeeded).toEqual([{ chunkIndex: 0, txHash: "hash-0" }]); + expect(result.failed?.chunkIndex).toBe(1); + }); +}); diff --git a/test/streamHealthProbe.test.ts b/test/streamHealthProbe.test.ts new file mode 100644 index 0000000..e35a471 --- /dev/null +++ b/test/streamHealthProbe.test.ts @@ -0,0 +1,32 @@ +import { EventEmitter } from "events"; +import { describe, expect, it, vi } from "vitest"; +import { sdkEvents } from "../src/events.js"; +import { StreamHealthProbe, type MonitoredStream } from "../src/streamHealthProbe.js"; + +class FakeStream extends EventEmitter implements MonitoredStream { + closed = false; + + close(): void { + this.closed = true; + } +} + +describe("StreamHealthProbe", () => { + it("emits streamStallDetected and calls onStall when a stream stalls", () => { + vi.useFakeTimers(); + const stream = new FakeStream(); + const onStall = vi.fn(); + const detected = vi.fn(); + const off = sdkEvents.on("streamStallDetected", detected); + const probe = new StreamHealthProbe({ stalledThresholdMs: 10, checkIntervalMs: 5, onStall }); + + probe.attach("stream-1", stream); + vi.advanceTimersByTime(20); + + expect(detected).toHaveBeenCalledWith({ streamId: "stream-1" }); + expect(onStall).toHaveBeenCalledWith("stream-1"); + probe.detach("stream-1"); + off(); + vi.useRealTimers(); + }); +});