From ecd64946f38066a46eaedd4d808e64708dfe4c94 Mon Sep 17 00:00:00 2001 From: "sola.awojobi00-bit" Date: Sat, 29 Aug 2026 21:54:59 +0100 Subject: [PATCH] fix(backend): await on-chain transaction finality before committing DB state Poll Soroban RPC getTransaction for terminal status (SUCCESS/FAILED) before resolving submitContractCall, preventing DB and on-chain state divergence. --- backend/src/services/sorobanService.ts | 53 ++++++++ backend/tests/cancel.controller.test.ts | 13 ++ backend/tests/integration/top-up.test.ts | 14 +++ backend/tests/soroban.service.test.ts | 149 +++++++++++++++++++++++ 4 files changed, 229 insertions(+) diff --git a/backend/src/services/sorobanService.ts b/backend/src/services/sorobanService.ts index 1181d14a..acbf453d 100644 --- a/backend/src/services/sorobanService.ts +++ b/backend/src/services/sorobanService.ts @@ -35,6 +35,16 @@ const RPC_MAX_RETRIES = Number(process.env.SOROBAN_RPC_MAX_RETRIES ?? 2); /** Base delay for exponential backoff between retries (doubles each attempt). */ const RPC_RETRY_BASE_MS = Number(process.env.SOROBAN_RPC_RETRY_BASE_MS ?? 250); +/** Bounded deadline for awaiting on-chain transaction finality (default 30s). */ +function getTxConfirmationTimeoutMs(): number { + return Number(process.env.SOROBAN_TX_CONFIRMATION_TIMEOUT_MS ?? 30_000); +} + +/** Polling interval when awaiting on-chain transaction finality (default 1s). */ +function getTxPollIntervalMs(): number { + return Number(process.env.SOROBAN_TX_POLL_INTERVAL_MS ?? 1_000); +} + export class RpcTimeoutError extends Error { constructor(label: string, timeoutMs: number) { super(`${label} timed out after ${timeoutMs}ms`); @@ -256,9 +266,52 @@ export async function submitContractCall(method: string, args: xdr.ScVal[], send throw new Error(`Transaction failed: ${JSON.stringify(response.errorResult)}`); } + await pollTransactionStatus(response.hash); + return response.hash; } +/** + * Poll Soroban RPC getTransaction until the transaction reaches a terminal status + * (SUCCESS or FAILED) or until the bounded timeout expires. + */ +export async function pollTransactionStatus( + txHash: string, + timeoutMs: number = getTxConfirmationTimeoutMs(), + pollIntervalMs: number = getTxPollIntervalMs(), +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const txResponse = await withRpcRetry('getTransaction', () => + withRpcTimeout('getTransaction', () => getServer().getTransaction(txHash)), + ); + + if ( + txResponse.status === rpc.Api.GetTransactionStatus.SUCCESS || + (txResponse.status as string) === 'SUCCESS' + ) { + return txResponse as rpc.Api.GetSuccessfulTransactionResponse; + } + + if ( + txResponse.status === rpc.Api.GetTransactionStatus.FAILED || + (txResponse.status as string) === 'FAILED' + ) { + const errorDetail = (txResponse as rpc.Api.GetFailedTransactionResponse).resultXdr + ? ` (resultXdr: ${(txResponse as rpc.Api.GetFailedTransactionResponse).resultXdr.toXDR('base64')})` + : ''; + throw new Error(`Transaction failed on-chain: ${txHash}${errorDetail}`); + } + + if (pollIntervalMs > 0) { + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } + + throw new Error(`Transaction confirmation timed out after ${timeoutMs}ms: ${txHash}`); +} + export async function getStreamFromChain(streamId: bigint): Promise { if (!getContractId()) return null; diff --git a/backend/tests/cancel.controller.test.ts b/backend/tests/cancel.controller.test.ts index 9ba145eb..5d88fbeb 100644 --- a/backend/tests/cancel.controller.test.ts +++ b/backend/tests/cancel.controller.test.ts @@ -91,4 +91,17 @@ describe('Cancel Stream Controller', () => { expect(res.status).toHaveBeenCalledWith(500); }); + + it('leaves DB unchanged and does not update status when cancelStream fails on-chain', async () => { + (prisma.stream.findUnique as any).mockResolvedValue({ sender: 'GSENDER1', isActive: true }); + (sorobanService.cancelStream as any).mockRejectedValue( + new Error('Transaction failed on-chain: tx_fail_post_submission') + ); + + await cancelStreamHandler(req as AuthenticatedRequest, res as Response); + + expect(sorobanService.cancelStream).toHaveBeenCalledWith(123n, 'SABC123'); + expect(streamRepository.updateStatus).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + }); }); diff --git a/backend/tests/integration/top-up.test.ts b/backend/tests/integration/top-up.test.ts index 9ff26e78..50e9a7f5 100644 --- a/backend/tests/integration/top-up.test.ts +++ b/backend/tests/integration/top-up.test.ts @@ -185,4 +185,18 @@ describe('POST /v1/streams/:streamId/top-up', () => { expect(res.status).toBe(409); expect(res.body.message).toMatch(/paused stream/); }); + + it('leaves DB unchanged when topUpStream fails on-chain', async () => { + vi.mocked(topUpStream).mockRejectedValueOnce( + new Error('Transaction failed on-chain: tx_fail_post_submission') + ); + + const res = await request(app) + .post('/v1/streams/42/top-up') + .set('Authorization', 'Bearer dummy') + .send({ amount: '1000' }); + + expect(res.status).toBe(400); + expect(mockPrisma.stream.update).not.toHaveBeenCalled(); + }); }); diff --git a/backend/tests/soroban.service.test.ts b/backend/tests/soroban.service.test.ts index a9a7486d..d8e93fcd 100644 --- a/backend/tests/soroban.service.test.ts +++ b/backend/tests/soroban.service.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => { getAccount: vi.fn(), simulateTransaction: vi.fn(), sendTransaction: vi.fn(), + getTransaction: vi.fn(), }; return { @@ -156,6 +157,154 @@ describe('Soroban Service', () => { ).rejects.toThrow('Transaction failed: "tx failed"'); expect(assembledTx.sign).toHaveBeenCalledWith(sender); }); + + it('polls getTransaction and returns tx hash when transaction succeeds on-chain', async () => { + const { submitContractCall } = await importService(); + const sender = Keypair.random(); + const assembledTx = { sign: vi.fn() }; + + mocks.server.getAccount.mockResolvedValue(new Account(sender.publicKey(), '1')); + mocks.server.simulateTransaction.mockResolvedValue(simulationSuccess(nativeToScVal(1))); + mocks.assembleTransaction.mockReturnValue({ build: () => assembledTx }); + mocks.server.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'tx-hash-success', + }); + mocks.server.getTransaction.mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.SUCCESS, + txHash: 'tx-hash-success', + }); + + const result = await submitContractCall( + 'cancel_stream', + [nativeToScVal(1, { type: 'u64' })], + sender.secret() + ); + + expect(result).toBe('tx-hash-success'); + expect(mocks.server.getTransaction).toHaveBeenCalledWith('tx-hash-success'); + }); + + it('polls across pending NOT_FOUND statuses until SUCCESS', async () => { + const { submitContractCall } = await importService(); + const sender = Keypair.random(); + const assembledTx = { sign: vi.fn() }; + + process.env.SOROBAN_TX_POLL_INTERVAL_MS = '10'; + mocks.server.getAccount.mockResolvedValue(new Account(sender.publicKey(), '1')); + mocks.server.simulateTransaction.mockResolvedValue(simulationSuccess(nativeToScVal(1))); + mocks.assembleTransaction.mockReturnValue({ build: () => assembledTx }); + mocks.server.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'tx-hash-eventual', + }); + mocks.server.getTransaction + .mockResolvedValueOnce({ + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + txHash: 'tx-hash-eventual', + }) + .mockResolvedValueOnce({ + status: rpc.Api.GetTransactionStatus.SUCCESS, + txHash: 'tx-hash-eventual', + }); + + const result = await submitContractCall( + 'cancel_stream', + [nativeToScVal(1, { type: 'u64' })], + sender.secret() + ); + + expect(result).toBe('tx-hash-eventual'); + expect(mocks.server.getTransaction).toHaveBeenCalledTimes(2); + delete process.env.SOROBAN_TX_POLL_INTERVAL_MS; + }); + + it('throws when getTransaction returns FAILED after mempool acceptance', async () => { + const { submitContractCall } = await importService(); + const sender = Keypair.random(); + const assembledTx = { sign: vi.fn() }; + + mocks.server.getAccount.mockResolvedValue(new Account(sender.publicKey(), '1')); + mocks.server.simulateTransaction.mockResolvedValue(simulationSuccess(nativeToScVal(1))); + mocks.assembleTransaction.mockReturnValue({ build: () => assembledTx }); + mocks.server.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'tx-hash-failed', + }); + mocks.server.getTransaction.mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.FAILED, + txHash: 'tx-hash-failed', + }); + + await expect( + submitContractCall('cancel_stream', [nativeToScVal(1, { type: 'u64' })], sender.secret()) + ).rejects.toThrow('Transaction failed on-chain: tx-hash-failed'); + }); + + it('throws when transaction confirmation times out', async () => { + const { submitContractCall } = await importService(); + const sender = Keypair.random(); + const assembledTx = { sign: vi.fn() }; + + process.env.SOROBAN_TX_CONFIRMATION_TIMEOUT_MS = '50'; + process.env.SOROBAN_TX_POLL_INTERVAL_MS = '10'; + mocks.server.getAccount.mockResolvedValue(new Account(sender.publicKey(), '1')); + mocks.server.simulateTransaction.mockResolvedValue(simulationSuccess(nativeToScVal(1))); + mocks.assembleTransaction.mockReturnValue({ build: () => assembledTx }); + mocks.server.sendTransaction.mockResolvedValue({ + status: 'PENDING', + hash: 'tx-hash-timeout', + }); + mocks.server.getTransaction.mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + txHash: 'tx-hash-timeout', + }); + + await expect( + submitContractCall('cancel_stream', [nativeToScVal(1, { type: 'u64' })], sender.secret()) + ).rejects.toThrow(/Transaction confirmation timed out/); + + delete process.env.SOROBAN_TX_CONFIRMATION_TIMEOUT_MS; + delete process.env.SOROBAN_TX_POLL_INTERVAL_MS; + }); + }); + + describe('pollTransactionStatus', () => { + it('returns transaction response when status is SUCCESS', async () => { + const { pollTransactionStatus } = await importService(); + mocks.server.getTransaction.mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.SUCCESS, + txHash: 'tx-poll-success', + }); + + const res = await pollTransactionStatus('tx-poll-success', 5000, 10); + expect(res.status).toBe(rpc.Api.GetTransactionStatus.SUCCESS); + expect(mocks.server.getTransaction).toHaveBeenCalledWith('tx-poll-success'); + }); + + it('throws with error details when status is FAILED', async () => { + const { pollTransactionStatus } = await importService(); + mocks.server.getTransaction.mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.FAILED, + txHash: 'tx-poll-failed', + }); + + await expect(pollTransactionStatus('tx-poll-failed', 5000, 10)).rejects.toThrow( + 'Transaction failed on-chain: tx-poll-failed' + ); + }); + + it('times out if transaction never reaches terminal status', async () => { + const { pollTransactionStatus } = await importService(); + mocks.server.getTransaction.mockResolvedValue({ + status: rpc.Api.GetTransactionStatus.NOT_FOUND, + txHash: 'tx-poll-pending', + }); + + await expect(pollTransactionStatus('tx-poll-pending', 50, 10)).rejects.toThrow( + 'Transaction confirmation timed out after 50ms: tx-poll-pending' + ); + }); }); describe('chain reads', () => {