Skip to content

fix(backend): await on-chain transaction finality before committing DB state - #1308

Merged
K1NGD4VID merged 1 commit into
LabsCrypt:mainfrom
solaawojobi00-bit:fix/issue-1218-optimistic-db-finality
Aug 29, 2026
Merged

fix(backend): await on-chain transaction finality before committing DB state#1308
K1NGD4VID merged 1 commit into
LabsCrypt:mainfrom
solaawojobi00-bit:fix/issue-1218-optimistic-db-finality

Conversation

@solaawojobi00-bit

Copy link
Copy Markdown
Contributor

Summary

Resolves optimistic database state mutations in backend/src/services/sorobanService.ts by polling getTransaction for on-chain finality (SUCCESS / FAILED) before resolving submitContractCall. Downstream controllers (cancelStreamHandler, topUpStreamHandler) now only commit DB updates after the on-chain transaction has confirmed.

Problem

In submitContractCall, the response from getServer().sendTransaction() was only checked for response.status !== 'ERROR' (meaning accepted into the transaction queue / mempool, not confirmed on-chain) before immediately returning response.hash. Handlers like cancelStreamHandler and topUpStreamHandler then immediately mutated the PostgreSQL database (setting stream status to CANCELLED or updating depositedAmount) before knowing whether the transaction succeeded on-chain. If the transaction was dropped or failed on-chain, the database permanently diverged from ledger state.

Scenarios

Scenario Previous Behavior Desired Behavior
Transaction accepted into mempool but fails on-chain DB commits status CANCELLED or incremented depositedAmount; DB and ledger permanently diverge submitContractCall detects FAILED status, throws on-chain failure error, DB mutation is skipped
Transaction confirmation delayed or dropped Handlers mutate DB immediately without waiting for inclusion pollTransactionStatus waits up to timeout; aborts DB mutation on timeout
Transaction succeeds on-chain DB updated after mempool acceptance DB updated only after confirmed SUCCESS on-chain

Solution

  1. Transaction Finality Polling (pollTransactionStatus):
    • Polled getServer().getTransaction(txHash) wrapped in withRpcRetry and withRpcTimeout.
    • Returns on rpc.Api.GetTransactionStatus.SUCCESS.
    • Throws descriptive error on rpc.Api.GetTransactionStatus.FAILED (including transaction result XDR when present).
    • Loops on rpc.Api.GetTransactionStatus.NOT_FOUND with configurable interval (SOROBAN_TX_POLL_INTERVAL_MS, default 1s).
    • Enforces a bounded timeout (SOROBAN_TX_CONFIRMATION_TIMEOUT_MS, default 30s).
  2. Submit Integration:
    • submitContractCall awaits pollTransactionStatus(response.hash) before returning.
  3. Database Consistency:
    • If an on-chain failure occurs, submitContractCall throws, preventing streamRepository.updateStatus() and prisma.stream.update() from executing.

Changes by File

  • backend/src/services/sorobanService.ts:
    • Added getTxConfirmationTimeoutMs() and getTxPollIntervalMs() for configurable confirmation deadlines.
    • Implemented and exported pollTransactionStatus().
    • Updated submitContractCall() to await pollTransactionStatus(response.hash) before returning response.hash.
  • backend/tests/soroban.service.test.ts:
    • Added getTransaction mock to RPC server mock setup.
    • Added test cases covering: successful confirmation, multi-attempt polling across pending NOT_FOUND statuses, failure on FAILED status, and timeout handling.
  • backend/tests/cancel.controller.test.ts:
    • Added test verifying that a post-submission on-chain failure in cancelStream leaves the DB status untouched (streamRepository.updateStatus is not called).
  • backend/tests/integration/top-up.test.ts:
    • Added test verifying that a post-submission on-chain failure in topUpStream leaves the DB untouched (prisma.stream.update is not called).

Regression Tests

Acceptance Criteria Test Case Status
Mempool acceptance followed by on-chain failure leaves DB unchanged backend/tests/cancel.controller.test.ts: leaves DB unchanged and does not update status when cancelStream fails on-chain Passed
Mempool acceptance followed by on-chain failure leaves DB unchanged backend/tests/integration/top-up.test.ts: leaves DB unchanged when topUpStream fails on-chain Passed
submitContractCall awaits terminal status backend/tests/soroban.service.test.ts: polls getTransaction and returns tx hash when transaction succeeds on-chain Passed
submitContractCall polls through pending status backend/tests/soroban.service.test.ts: polls across pending NOT_FOUND statuses until SUCCESS Passed
submitContractCall throws on terminal failure backend/tests/soroban.service.test.ts: throws when getTransaction returns FAILED after mempool acceptance Passed
Bounded timeout for transaction finality backend/tests/soroban.service.test.ts: throws when transaction confirmation times out Passed

Testing

Literal vitest output:

 ✓ backend/tests/soroban.service.test.ts (24 tests | 3 skipped) 1953ms
 ✓ backend/tests/cancel.controller.test.ts (6 tests) 26ms
 ✓ backend/tests/integration/top-up.test.ts (10 tests) 242ms

 Test Files  50 passed (50)
      Tests  378 passed | 3 skipped (381)

Typecheck:

npx tsc --noEmit (exit code 0)

Notes for Reviewers

  • Polling interval and confirmation timeouts are configurable via SOROBAN_TX_POLL_INTERVAL_MS (default 1,000 ms) and SOROBAN_TX_CONFIRMATION_TIMEOUT_MS (default 30,000 ms).
  • Existing error handling in handlers remains intact while preventing optimistic DB writes.

Closes #1218

…B state

Poll Soroban RPC getTransaction for terminal status (SUCCESS/FAILED) before resolving submitContractCall, preventing DB and on-chain state divergence.
@K1NGD4VID
K1NGD4VID merged commit 96a98b4 into LabsCrypt:main Aug 29, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Audit] Optimistic DB state committed before on-chain transaction finality is known

2 participants