Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions backend/src/services/sorobanService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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<rpc.Api.GetSuccessfulTransactionResponse> {
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<ChainStream | null> {
if (!getContractId()) return null;

Expand Down
13 changes: 13 additions & 0 deletions backend/tests/cancel.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
14 changes: 14 additions & 0 deletions backend/tests/integration/top-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
149 changes: 149 additions & 0 deletions backend/tests/soroban.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => {
getAccount: vi.fn(),
simulateTransaction: vi.fn(),
sendTransaction: vi.fn(),
getTransaction: vi.fn(),
};

return {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading