From c078f11f79dae091af5c2592895b59eef36e239d Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Thu, 28 May 2026 18:07:14 +0100 Subject: [PATCH] implement Multi-Asset Ledger Timeout (Time-Bounds) Enforcement --- src/services/stellarService.ts | 180 ++++++++++++++++++++++++++++--- test/stellarService.jest.test.ts | 133 +++++++++++++++++++++++ 2 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 test/stellarService.jest.test.ts diff --git a/src/services/stellarService.ts b/src/services/stellarService.ts index b48004e7..9835ce55 100644 --- a/src/services/stellarService.ts +++ b/src/services/stellarService.ts @@ -17,12 +17,41 @@ import { signer } from "../signer"; dotenv.config(); +interface PendingTimeBoundTransaction { + hash: string; + publicKey: string; + createdAtMs: number; + expiresAtMs: number; + timer?: ReturnType; + timedOut: boolean; +} + +class LocalTransactionTimeoutError extends Error { + readonly code = "LOCAL_TX_TIME_BOUND_EXPIRED"; + readonly transactionHash: string; + readonly publicKey: string; + + constructor(transactionHash: string, publicKey: string) { + super( + `Transaction ${transactionHash} exceeded local time-bound and was recycled`, + ); + this.name = "LocalTransactionTimeoutError"; + this.transactionHash = transactionHash; + this.publicKey = publicKey; + } +} + export class StellarService { private server: Horizon.Server; private network: string; private readonly MAX_RETRIES = 3; private readonly FEE_INCREMENT_PERCENTAGE = 0.5; // 50% increase each retry private readonly RETRY_DELAY_MS = 2000; // 2 seconds delay between retries + private readonly TRANSACTION_TIME_BOUND_SECONDS = 15; + private readonly pendingTimeBoundTransactions = new Map< + string, + PendingTimeBoundTransaction + >(); constructor() { this.network = process.env.STELLAR_NETWORK || "TESTNET"; @@ -74,7 +103,7 @@ export class StellarService { }), ) .addMemo(Memo.text(memoId)) - .setTimeout(60) + .setTimeout(this.TRANSACTION_TIME_BOUND_SECONDS) .build(); }, this.MAX_RETRIES, @@ -116,7 +145,10 @@ export class StellarService { ); } - return builder.addMemo(Memo.text(memoId)).setTimeout(60).build(); + return builder + .addMemo(Memo.text(memoId)) + .setTimeout(this.TRANSACTION_TIME_BOUND_SECONDS) + .build(); }, this.MAX_RETRIES, baseFee, @@ -153,7 +185,7 @@ export class StellarService { }), ) .addMemo(Memo.text(memoId)) - .setTimeout(60) + .setTimeout(this.TRANSACTION_TIME_BOUND_SECONDS) .build(); }, signatures, @@ -194,6 +226,7 @@ export class StellarService { ); const transaction = builderFn(sourceAccount, currentFee); + this.assertStrictTimeBounds(transaction); await assertSigningAllowed(); const txHash = transaction.hash(); @@ -207,21 +240,31 @@ export class StellarService { }) ); - return await this.server.submitTransaction(transaction); + return await this.submitWithTimeoutListener(transaction, publicKey); } catch (error: any) { const resultCode = error.response?.data?.extras?.result_codes?.transaction; - if (resultCode === "tx_bad_seq") { - console.warn("⚠️ SequenceManager: tx_bad_seq detected. Invalidating sequence and retrying..."); + if (resultCode === "tx_bad_seq" || this.isLocalTimeoutError(error)) { + console.warn( + "⚠️ SequenceManager: stale or invalid local transaction assignment detected. Invalidating sequence and retrying...", + ); sequenceManager.invalidate(await this.getPublicKey()); } attempt++; - stellarProvider.reportFailure(error); + if (!this.isLocalTimeoutError(error)) { + stellarProvider.reportFailure(error); + } if (this.isStuckError(error) && attempt <= maxRetries) { - console.warn(`⚠️ Transaction stuck or fee too low (Attempt ${attempt}). Bumping fee and retrying...`); - await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY_MS)); + console.warn( + `⚠️ Transaction stuck, expired, or fee too low (Attempt ${attempt}). Recycling locally and retrying...`, + ); + if (!this.shouldRecycleImmediately(error)) { + await new Promise((resolve) => + setTimeout(resolve, this.RETRY_DELAY_MS), + ); + } continue; } @@ -260,6 +303,7 @@ export class StellarService { ); const transaction = builderFn(sourceAccount, currentFee); + this.assertStrictTimeBounds(transaction); await assertSigningAllowed(); @@ -292,20 +336,28 @@ export class StellarService { } } - return await this.server.submitTransaction(transaction); + return await this.submitWithTimeoutListener(transaction, publicKey); } catch (error: any) { const resultCode = error.response?.data?.extras?.result_codes?.transaction; - if (resultCode === "tx_bad_seq") { - console.warn("⚠️ SequenceManager: tx_bad_seq detected in multi-sig. Invalidating sequence..."); + if (resultCode === "tx_bad_seq" || this.isLocalTimeoutError(error)) { + console.warn( + "⚠️ SequenceManager: stale or invalid multi-sig assignment detected. Invalidating sequence...", + ); sequenceManager.invalidate(await this.getPublicKey()); } attempt++; - stellarProvider.reportFailure(error); + if (!this.isLocalTimeoutError(error)) { + stellarProvider.reportFailure(error); + } if (this.isStuckError(error) && attempt <= maxRetries) { - await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY_MS)); + if (!this.shouldRecycleImmediately(error)) { + await new Promise((resolve) => + setTimeout(resolve, this.RETRY_DELAY_MS), + ); + } continue; } @@ -316,9 +368,96 @@ export class StellarService { throw new Error(`Failed to submit multi-signed transaction after ${maxRetries + 1} attempts`); } + private assertStrictTimeBounds(transaction: Transaction): void { + const timeBounds = (transaction as any).timeBounds; + const maxTime = Number(timeBounds?.maxTime); + const nowSeconds = Math.floor(Date.now() / 1000); + + if ( + !Number.isFinite(maxTime) || + maxTime <= nowSeconds || + maxTime - nowSeconds > this.TRANSACTION_TIME_BOUND_SECONDS + ) { + throw new Error( + `Transaction envelope must include strict time_bounds of ${this.TRANSACTION_TIME_BOUND_SECONDS}s or less`, + ); + } + } + + private async submitWithTimeoutListener( + transaction: Transaction, + publicKey: string, + ): Promise { + const pending = this.registerPendingTimeBoundTransaction( + transaction, + publicKey, + ); + + try { + return await Promise.race([ + this.server.submitTransaction(transaction), + new Promise((_, reject) => { + pending.timer = setTimeout(() => { + const activePending = this.pendingTimeBoundTransactions.get( + pending.hash, + ); + + if (!activePending) { + return; + } + + activePending.timedOut = true; + this.pendingTimeBoundTransactions.delete(pending.hash); + console.warn( + `[StellarService] Transaction ${pending.hash} exceeded ${this.TRANSACTION_TIME_BOUND_SECONDS}s time-bound. Recycling local assignment.`, + ); + reject( + new LocalTransactionTimeoutError(pending.hash, pending.publicKey), + ); + }, Math.max(pending.expiresAtMs - Date.now(), 0)); + }), + ]); + } finally { + this.clearPendingTimeBoundTransaction(pending.hash); + } + } + + private registerPendingTimeBoundTransaction( + transaction: Transaction, + publicKey: string, + ): PendingTimeBoundTransaction { + const createdAtMs = Date.now(); + const hash = transaction.hash().toString("hex"); + const pending: PendingTimeBoundTransaction = { + hash, + publicKey, + createdAtMs, + expiresAtMs: + createdAtMs + this.TRANSACTION_TIME_BOUND_SECONDS * 1000, + timedOut: false, + }; + + this.pendingTimeBoundTransactions.set(hash, pending); + return pending; + } + + private clearPendingTimeBoundTransaction(hash: string): void { + const pending = this.pendingTimeBoundTransactions.get(hash); + + if (!pending) { + return; + } + + if (pending.timer) { + clearTimeout(pending.timer); + } + this.pendingTimeBoundTransactions.delete(hash); + } + private isStuckError(error: any): boolean { const resultCode = error.response?.data?.extras?.result_codes?.transaction; return ( + this.isLocalTimeoutError(error) || resultCode === "tx_too_late" || resultCode === "tx_insufficient_fee" || resultCode === "tx_bad_seq" || @@ -327,10 +466,21 @@ export class StellarService { ); } + private shouldRecycleImmediately(error: any): boolean { + const resultCode = error.response?.data?.extras?.result_codes?.transaction; + return this.isLocalTimeoutError(error) || resultCode === "tx_too_late"; + } + + private isLocalTimeoutError( + error: unknown, + ): error is LocalTransactionTimeoutError { + return error instanceof LocalTransactionTimeoutError; + } + generateMemoId(currency: string): string { const timestamp = Math.floor(Date.now() / 1000); const random = Math.floor(Math.random() * 1000).toString().padStart(3, "0"); const id = `SF-${currency}-${timestamp}-${random}`; return id.substring(0, 28); } -} \ No newline at end of file +} diff --git a/test/stellarService.jest.test.ts b/test/stellarService.jest.test.ts new file mode 100644 index 00000000..7b615fef --- /dev/null +++ b/test/stellarService.jest.test.ts @@ -0,0 +1,133 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { Keypair } from "@stellar/stellar-sdk"; + +const sourceKeypair = Keypair.random(); +const fakeServer = { + feeStats: jest.fn(), + submitTransaction: jest.fn(), +}; +const reportFailure = jest.fn(); +const getNextSequence = jest.fn(); +const invalidate = jest.fn(); +const sign = jest.fn(); + +jest.unstable_mockModule("../src/lib/stellarProvider", () => ({ + default: { + getServer: () => fakeServer, + reportFailure, + }, +})); + +jest.unstable_mockModule("../src/services/sequence-manager", () => ({ + sequenceManager: { + getNextSequence, + invalidate, + }, +})); + +jest.unstable_mockModule("../src/state/appState", () => ({ + assertSigningAllowed: jest.fn(async () => undefined), +})); + +jest.unstable_mockModule("../src/signer", () => ({ + signer: { + getPublicKey: jest.fn(async () => sourceKeypair.publicKey()), + sign, + }, +})); + +const { StellarService } = await import("../src/services/stellarService"); + +describe("StellarService time-bound enforcement", () => { + beforeEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + fakeServer.feeStats.mockResolvedValue({ + fee_charged: { p50: "100" }, + } as never); + fakeServer.submitTransaction.mockResolvedValue({ + hash: "confirmed-hash", + } as never); + getNextSequence.mockResolvedValue("1" as never); + sign.mockResolvedValue(Buffer.alloc(64) as never); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("enforces explicit 15-second time bounds on single, batched, and multi-sig transactions", async () => { + const observedMaxTimes: number[] = []; + + fakeServer.submitTransaction.mockImplementation(async (transaction: any) => { + expect(transaction.timeBounds).toBeDefined(); + + const maxTime = Number(transaction.timeBounds.maxTime); + const nowSeconds = Math.floor(Date.now() / 1000); + expect(maxTime).toBeGreaterThan(nowSeconds); + expect(maxTime - nowSeconds).toBeLessThanOrEqual(15); + + observedMaxTimes.push(maxTime); + return { hash: "confirmed-hash" }; + }); + + const service = new StellarService(); + + await service.submitPriceUpdate("KES", 123.45, "SF-KES-TEST-001"); + await service.submitBatchedPriceUpdates( + [ + { currency: "KES", price: 123.45 }, + { currency: "NGN", price: 1500.25 }, + ], + "SF-BATCH-TEST-001", + ); + await service.submitMultiSignedPriceUpdate( + "GHS", + 15.2, + "SF-GHS-TEST-001", + [], + ); + + expect(observedMaxTimes).toHaveLength(3); + expect(fakeServer.submitTransaction).toHaveBeenCalledTimes(3); + }); + + it("recycles locally timed-out transactions and retries with a new assignment immediately", async () => { + jest.useFakeTimers(); + + let secondSubmitResolve!: (value: unknown) => void; + const secondSubmit = new Promise((resolve) => { + secondSubmitResolve = resolve; + }); + + fakeServer.submitTransaction + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockImplementationOnce(() => secondSubmit); + getNextSequence + .mockResolvedValueOnce("1" as never) + .mockResolvedValueOnce("2" as never); + + const service = new StellarService(); + const submitPromise = service.submitPriceUpdate( + "KES", + 456.78, + "SF-KES-TEST-002", + ); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(15_000); + + expect(invalidate).toHaveBeenCalledWith(sourceKeypair.publicKey()); + expect(fakeServer.submitTransaction).toHaveBeenCalledTimes(2); + expect(getNextSequence).toHaveBeenNthCalledWith( + 2, + sourceKeypair.publicKey(), + ); + + secondSubmitResolve({ hash: "reassigned-hash" }); + await expect(submitPromise).resolves.toBe("reassigned-hash"); + expect(reportFailure).not.toHaveBeenCalled(); + }); +});