Skip to content

fix(lore-0141): answer a nonce used by another transaction with its own code - #182

Open
karolko9 wants to merge 14 commits into
developfrom
lore-0141-settle-answer-when-entry-landed-elsewhere
Open

karolko9 wants to merge 14 commits into
developfrom
lore-0141-settle-answer-when-entry-landed-elsewhere

Conversation

@karolko9

@karolko9 karolko9 commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Anyone who sees the payer's signed auth entry can submit it first in their own transaction. Then our
transaction fails with Error(Auth, ExistingValue), and /settle told the client "sign a new
payment", although the payment may have settled. This PR answers that case with its own code and no
such advice (task 0141, decisions 2–4).

 /verify, and /settle's re-check
   upstream answers simulation_failed
+    the payer's nonce entry exists on chain   -> invalid_exact_stellar_payload_nonce_consumed_elsewhere
     a trustline is missing                    -> …_trustline_missing
 /settle, after the claim
   the pool's simulation fails (SimulationError)
+    the payer's nonce entry exists on chain   -> settle_exact_stellar_nonce_consumed_elsewhere
-                                              -> settle_exact_stellar_simulation_failed + "sign a new payment"
   our transaction fails on chain (in /settle, the reconcile sweep, or the pool's resolved event)
+    Error(Auth, ExistingValue) in the token's frame -> settle_exact_stellar_nonce_consumed_elsewhere
+    no error event, and the payer's nonce entry exists -> settle_exact_stellar_nonce_consumed_elsewhere
-                                              -> settle_exact_stellar_transaction_failed + "sign a new payment"
 every final record with the new code
+  log settlement_nonce_consumed_elsewhere (with feeCharged), count settlement_nonce_consumed_elsewhere_total{window=settle}
 every /verify or re-check refusal with the new code
+  count settlement_nonce_consumed_elsewhere_total{window=verify}

A used nonce does not prove the payment: the payer can spend it on a second entry. So /settle never
answers success for a transaction it did not submit.

New unit: apps/facilitator/src/settlement/nonce-used.ts (both codes, the failure test, the nonce
read). The final reason for our failed transaction comes from one function, resultReason, in every
writer that ends a record (settle.ts and its answer after a failed record write, the reconcile
sweep, the pool's resolved event); it reads the nonce entry when the error doesn't tell, as an
RPC without diagnostic events gives none. The read has no timeout of its own: FallbackRpc's
per-URL timeout and failover apply.

Evidence

Both testnet cases from 2026-11-12, as getTransaction answered them, are a fixture
(test/fixtures/nonce-consumed-elsewhere.json):

Case Our transaction Before After
the same entry landed one ledger earlier (bf6c8d21…) 233ea749… failed, 8,800 stroops settle_exact_stellar_transaction_failed, "the auth entry may still land … sign a new payment" settle_exact_stellar_nonce_consumed_elsewhere, no advice, logged with feeCharged
the payer signed a second entry under the same nonce (cc5538bd…) 09bc7098… failed, 8,800 stroops same as above same as above

Unchanged: the same error in a C-account payer's own frame stays …_transaction_failed (the
wallet's own nested auth); breaker attribution is unchanged. With an error event a used nonce feeds
no breaker; without one it still does, fixed in its own PR after this one (task 0141 decision 9).

  • Before: test(lore-0141) commit e53b511d: the new tests fail (no nonce-used.ts).
  • After: facilitator 1044 passed | 3 skipped, metrics-catalog 31 passed, infra 355 passed;
    format, lint, typecheck, build clean (see below).

Considered and rejected

  • Answer success with the other transaction's hash — a buyer can race one entry through two
    facilitators to the same seller and be served twice; 0 front-runs in ~4,500 pubnet settlements.
  • Reuse settle_exact_stellar_nonce_already_used — it means "another payload"; here the client may
    have paid, and clients act on the code, not the message.
  • Cover only our failure on chain — the other transaction can also land before ours is sent; 3 of 7
    testnet cases failed in the simulation.
  • Parse "Error(Auth, ExistingValue)" from the simulation's error text — the nonce entry on chain is
    the proof, and it also works after upstream, which drops the RPC's error.

Merge Danger

Door: two-way

Two new errorReason codes reach sellers. A seller that only checks success behaves as before.

Blast Radius: settlement

One more RPC read (getLedgerEntries) only after a failed simulation, or after our transaction
failed on chain without an error event that names a used nonce.

🤖 Generated with Claude Code

karolko9 and others added 4 commits November 13, 2026 10:13
Both testnet cases from the task, as getTransaction answered them, plus
the two simulation windows. Red until the code lands.

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
…wn code

When another transaction used the payer's nonce first, /settle answered
transaction_failed or simulation_failed and told the client to sign a new
payment, although the payment may have settled there. Now /verify and the
settle re-check answer invalid_exact_stellar_payload_nonce_consumed_elsewhere
(the payer's nonce entry exists on chain), and a claimed settlement ends with
settle_exact_stellar_nonce_consumed_elsewhere when our transaction failed with
Error(Auth, ExistingValue) or the pool's simulation failed on a used nonce.
Neither carries advice to sign again. Each such record is logged with the fee
lost and counted in settlement_nonce_consumed_elsewhere_total.

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
The threat model said a reused nonce means the payer signed one payment
and it settled; a second entry under the same nonce pays the seller
nothing. It now says what a used nonce proves, who can see the entry, why
/settle never answers success for another transaction, and the residual.
The README lists both new codes and the log; monitoring lists the counter.

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
karolko9 and others added 2 commits November 13, 2026 11:42
…when-entry-landed-elsewhere

# Conflicts:
#	apps/facilitator/src/settlement/reconcile.ts
#	apps/facilitator/src/settlement/settle.ts
Review fixes for the new answer:
- a reopen the checks refused with the nonce-consumed code got 'repeat
  the same payment' and extra.retryable; both nonce codes now carry no
  advice at all
- /verify's message holds when the nonce was used by our own landing too
- the nonce read is bounded to 3 s, as the trustline lookup is, and a
  failed read is false
- resultReason takes the status, so it has no unreachable branch
- both testnet cases are pinned through /settle's answer as well

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
@karolko9
karolko9 changed the base branch from develop to lore-0141-settle-reasons November 13, 2026 10:47
karolko9 and others added 3 commits November 13, 2026 11:52
An RPC without diagnostic events gives the failure no error, so a nonce
another transaction used would end as transaction_failed with the old
advice. When the cause doesn't tell, /settle now reads the payer's nonce
entry: ours failed and wrote none, so an entry means another transaction
used it.

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
…nswer-when-entry-landed-elsewhere

# Conflicts:
#	lore/1-tasks/active/0141_BUG_settle-answer-when-entry-landed-elsewhere.md
@karolko9
karolko9 changed the base branch from lore-0141-settle-reasons to develop November 13, 2026 12:01
karolko9 and others added 5 commits November 13, 2026 14:14
…its window

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
…ts window

resultReason decides the final reason in one place and reads the payer's
nonce entry when the failure names no Error(Auth, ExistingValue): /settle,
its answer after a failed record write, the reconcile sweep and the pool's
resolved event. settlement_nonce_consumed_elsewhere_total carries window
verify for /verify and /settle's re-check, settle for a record.

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
…entation

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
The read now has FallbackRpc's per-URL timeout and its failover to the next
RPC URL, which the 3 s cut kept from running. A read that still fails keeps
the old reason, as before.

Co-Authored-By: Claude <209825114+claude[bot]@users.noreply.github.com>
…nswer-when-entry-landed-elsewhere

# Conflicts:
#	lore/1-tasks/active/0141_BUG_settle-answer-when-entry-landed-elsewhere.md

This branch has not been deployed

No deployments
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.

1 participant