Repository navigation
feat: reconcile ambiguous submitted requests - #12
Merged
Merged
Conversation
A request can be marked 'submitted' before the network call is made and before submission_hash is recorded on success. If the process dies in between, the row is left ambiguous forever: status='submitted' with submission_hash NULL, and the normal pending-retry path never selects it. Add a background reconciliation pass that looks such rows up on the network by tx_hash (the deterministic, signature-independent hash of the unsigned envelope, safe to use as a lookup key since the transaction body is never mutated between creation and submission). Only a positive, successful network confirmation persists submission_hash, taken from the network's own response, never derived locally. Not-found and transient failures leave the row unchanged for a later pass; a found-but-failed transaction is left for manual review rather than inventing a new terminal status. The write is a single guarded UPDATE (status='submitted' AND submission_hash IS NULL), so concurrent reconciliation attempts — this instance or another — cannot race or overwrite a newer state. The batch query is bounded (RECONCILIATION_BATCH_SIZE, default 100) and backed by a new partial index so it never sequential-scans the full table.
Proves: an ambiguous submitted row is selected and confirmed on positive network confirmation; a confirmed row is not repeatedly reconciled; a temporary lookup failure and an uncertain (not-found or found-but-failed) lookup both leave the row unchanged; reconciliation does not interfere with normal pending retry; the batch size bound is respected; and concurrent reconciliation attempts on the same row cannot corrupt state (real Postgres-backed). The ambiguous row shape is constructed directly via store.tryClaimSubmission — the same call the real claim path uses — since no code path in the service itself produces status='submitted' with submission_hash NULL. This is the logical equivalent of the crash window, not a claim that an actual process kill was exercised.
ZeePearl56
approved these changes
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
submission hash.
Problem
A request can enter
submittedstate before the real submission hash ispersisted. If the process fails at the wrong point, the request can remain
submitted with no submission hash and is not currently selected by the normal
pending retry path.
Fix
A new background reconciliation pass (
src/reconcile.ts, wired into theexisting scheduler in
src/background.tsalongside expiry and retry) selectsa bounded batch (
RECONCILIATION_BATCH_SIZE, default 100) ofsubmittedrowsstill missing
submission_hash, oldest first.For each row, it looks the transaction up on the network by
tx_hash— thehash of the unsigned transaction envelope, computed at creation time. This is
a safe, deterministic lookup key: Stellar's transaction hash is computed over
the transaction body only (not the signatures), and the stored envelope is
never mutated between creation and submission, so
tx_hashis exactly thehash the network will report for this submission.
Three outcomes:
derived value) is persisted to
submission_hashvia a guardedUPDATE ... WHERE status = 'submitted' AND submission_hash IS NULL. Thisguard is what makes the write safe under concurrency (see Safety).
unchanged so a later pass can retry it.
transaction as failed) — also left unchanged; this is logged at
warnforoperator review. There is no existing terminal status for "submitted but
failed on-chain", and introducing one was out of scope for this change, so
no automatic state transition or resubmission happens here.
This PR does not implement or claim automatic resubmission of any ambiguous
transaction.
A new partial index (
migrations/003_add_reconciliation_index.sql) backs thebounded batch query so it never sequential-scans the full table.
The Horizon lookup is abstracted behind
TransactionLookupGateway(
src/types.ts, implemented byHorizonTransactionLookupGatewayinsrc/horizon.ts), the same pattern already used for account state andsubmission. Tests substitute
FakeTransactionLookupGateway(
test/helpers.ts), which can simulate a confirmed transaction, a not-foundresult, or a thrown network failure.
Safety
submission_hashis only ever written from thenetwork's own response to a successful, positive lookup — never derived
locally, never written on a "not found" or on a found-but-failed record.
not treated as proof of non-submission (propagation/indexing delay could
explain it), and any other lookup failure (timeout, 5xx, connection error)
is caught and logged, leaving the row untouched for the next pass.
UPDATE ... WHERE status = 'submitted' AND submission_hash IS NULL. If tworeconciliation passes (this instance or another) race on the same row, only
the first commit applies; every later one is a no-op — proven under a real
Postgres-backed concurrent-write test, not mocked.
touches rows already at
status = 'submitted'withsubmission_hash IS NULL; it never changesstatus, so it cannot race with expiry (which onlyacts on
pendingrows) or with the normal submission/retry path.Testing
npm test— 64/64 passed (56 pre-existing + 8 new reconciliation tests)npm run typecheck— passednpm run lint— passednpm audit --omit=dev— 0 vulnerabilitiesgit diff --check— cleanproject tooling for a live Testnet smoke test, and building a new E2E
harness for this task was explicitly out of scope. This change is
unit/integration tested only, against a real Postgres database and a fake
network gateway (
FakeTransactionLookupGateway) — real Horizon behavior(404 mapping, response shape) was verified by reading the installed
@stellar/stellar-sdksource, not exercised live.Scope
This PR does not change: