Skip to content

fix/35-payment-execute-send-duplicate-guard - #64

Open
Wetshakat wants to merge 1 commit into
StellarSend:mainfrom
Wetshakat:fix/35-payment-execute-send-duplicate-guard
Open

fix/35-payment-execute-send-duplicate-guard#64
Wetshakat wants to merge 1 commit into
StellarSend:mainfrom
Wetshakat:fix/35-payment-execute-send-duplicate-guard

Conversation

@Wetshakat

@Wetshakat Wetshakat commented Aug 17, 2026

Copy link
Copy Markdown

closed #35

fix-payment-execute-send-duplicate-guard

Creates payment_submissions (stellar_tx_hash PK, transaction_id, created_at) — the single-payment parallel of batch_submissions from migration 010.
One row per distinct signed XDR hash, inserted atomically before Horizon is called. The PRIMARY KEY is what actually closes the race; no check-then-
insert.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

src/services/payment.rs

Three structural changes, all directly mirroring what batch.rs already does:

  1. PaymentService now holds a pool and network passphrase

PaymentService::new gains pool: PgPool and network_passphrase: impl Into. These are the same two things BatchPaymentService already carried.

  1. compute_transaction_hash is called first, before any DB work

The hash is computed immediately after the empty-XDR check. An unparseable XDR is rejected here — before any DB write — exactly like the batch path.
This is verified by rejects_invalid_signed_xdr_before_touching_the_database.

  1. execute_send now has all three guards from the issue
  • Terminal-status guard (gap 2): when transaction_id is supplied, if the existing record is already Completed or Failed, return AppError::Conflict
    . The first outcome is the authoritative one.
  • Atomic hash claim (gap 1): INSERT INTO payment_submissions before submit_transaction. A primary-key violation → Conflict. No TOCTOU gap.
  • classify_submission_error (gap 3): connection failures and timeouts → SubmittedUnconfirmed (not Failed), with the precomputed hash persisted on
    the row so ReconciliationService can find it by hash and converge it, same as it does for batches. Definitive Horizon rejections → Failed.

Unit tests added (all pass without a DB):

  • rejects_empty_signed_xdr
  • rejects_invalid_signed_xdr_before_touching_the_database
  • classifies_a_horizon_rejection_as_failed
  • classifies_connection_failure_as_submitted_unconfirmed

Integration tests added (#[ignore], require DATABASE_URL):

  • concurrent_duplicate_send_yields_exactly_one_success_and_one_conflict — the acceptance-criteria concurrency test: two tokio::join!'d calls with
    identical signed_xdr, asserts exactly one Ok and one Conflict, and exactly one payment_submissions row.
  • ambiguous_submission_is_unconfirmed_not_failed_and_blocks_a_retry — connection-refused submission → SubmittedUnconfirmed → retry with same XDR →
    Conflict.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

src/models/payment.rs

Added Clone to #[derive] on SendPaymentRequest — required by the concurrency test's req.clone(), and reasonable to have on a request type anyway.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

src/routes/payments.rs

Updated both get_quote and send_payment handlers to pass state.pool.clone() and &state.config.stellar_network_passphrase to PaymentService::new,
matching the updated constructor.

▸ Credits: 3.06 • Time: 6m 37s

6% >

give me a pr message for what you did

fix(payments): add duplicate-submission guard to execute_send (#35)

Brings PaymentService::execute_send up to the same hardening standard as BatchPaymentService::execute_batch after #30. Three concrete gaps closed:

  1. Duplicate-submission guard
    Compute the canonical transaction hash locally (via the existing tx_hash::compute_transaction_hash) before touching the DB or Horizon, then atomically
    claim it with INSERT INTO payment_submissions. A primary-key violation returns 409 Conflict immediately — the same primitive batch_submissions uses,
    for the same reason: no TOCTOU gap.

  2. Terminal-status guard
    When transaction_id is supplied, reject with 409 Conflict if the existing record is already Completed or Failed. Previously a second call could
    silently overwrite the recorded hash and status, discarding the authoritative outcome.

  3. Ambiguous-failure classification
    Connection failures and timeouts are now recorded as SubmittedUnconfirmed (not Failed), with the precomputed hash persisted on the row.
    ReconciliationService can now recover stuck single-payment sends by hash lookup, exactly as it already does for batches since BatchPaymentService leaves transactions permanently 'pending' if the process crashes between leg insert and status update #30. Definitive Horizon
    rejections remain Failed.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Changes

  • migrations/012_payment_submissions.sql — new table payment_submissions (stellar_tx_hash PK, transaction_id, created_at), the single-payment parallel
    of batch_submissions
  • src/services/payment.rs — PaymentService gains pool + network_passphrase; execute_send gets all three guards above; classify_submission_error added
    (mirrors batch.rs); unit tests + two #[ignore] integration tests
  • src/models/payment.rs — Clone derived on SendPaymentRequest (needed by concurrency test)
  • src/routes/payments.rs — get_quote and send_payment handlers updated for the new PaymentService::new signature

Testing

Unit tests (no DB required) cover: empty XDR rejected before DB, unparseable XDR rejected before DB, Horizon rejection → Failed, connection failure →
SubmittedUnconfirmed.

Integration tests (cargo test -- --ignored, requires DATABASE_URL) cover: two concurrent sends with identical signed_xdr — exactly one succeeds, the
other gets Conflict; ambiguous submission → SubmittedUnconfirmed → retry blocked with Conflict.

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.

PaymentService::execute_send has no duplicate-submission guard, unlike BatchPaymentService after #30

1 participant