Skip to content

feat: freeze-the-attempt send idempotency (ENG-533) - #717

Merged
islandbitcoin merged 4 commits into
mainfrom
feat/eng-533-freeze-attempt
Sep 3, 2026
Merged

feat: freeze-the-attempt send idempotency (ENG-533)#717
islandbitcoin merged 4 commits into
mainfrom
feat/eng-533-freeze-attempt

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Closes ENG-533. Supersedes #713 (six review rounds established that content-fingerprinting a send attempt doesn't converge — every fingerprint either collided two deliberate payments or mismatched the server's own fingerprint, turning the one retry this feature exists for into `IdempotencyKeyReuseError` → double pay).

Design

An attempt is an object, not a fingerprint. At the first tap we freeze the current send closure together with one random uuid key. Nothing ever recognises a repeat — a repeat is simply the retained object being re-run.

Event Attempt Why
First tap created (closure + fresh key) wire input frozen: later re-renders may re-mint the invoice or re-derive the amount; we never call the new closure
Send throws retained outcome unknown — the retry re-runs the identical input under the same key and the backend replays the original result if it committed
Any server status cleared outcome known — next tap is a new payment with a fresh key by construction (kills both residual fingerprint bugs: deliberate-repeat collision and the 24h cached-FAILURE lockout)

Deliberately not persisted: force-quit mid-send leaves exactly today's pre-existing risk. Cross-session recovery wants a server-side lookup, not client heuristics.

Capability gate

GraphQL input coercion rejects unknown fields before execution, so a backend without the `idempotencyKey` inputs would fail every send. `idempotency-support.ts` gates the field per (endpoint, input type): on coercion refusal the send retries keyless (safe — refusal proves nothing settled) and the gate remembers.

UI contract

  • Synchronous `inFlightRef` guard: a second tap in the same frame returns `{ ignored: true }` — no spinner toggle, no analytics, no phantom error toast.
  • Retry is reachable: Failure/throw re-arm the button (`paymentError` no longer pins it disabled; it's cleared at tap start). `ALREADY_PAID`/success keep it disabled via `hasAttemptedSend`.
  • Breez (BTC-wallet) sends now share the Failure re-arm semantics; they take no key (local SDK, no idempotency surface).

Tests

  • `use-send-payment.spec.tsx` — lifecycle behavioral suite: double-tap → one call + `ignored`; throw → retry runs the original closure with the same key even after a re-render swapped the mutation; Failure → fresh key + current closure; throw/Failure re-arm, success doesn't.
  • `idempotency-support.spec.ts` — gate behavior + input-type names pinned against `public-schema.graphql`.
  • Existing payment-details suites extended to assert the key reaches each mutation input.

Full suite: 949 passed. tsc clean. No new lint findings (three `require-atomic-updates` suppressions are refs-as-synchronous-flags — the write-after-await is the design).

An attempt is an object — the send closure captured at the first tap plus
one random uuid key — never a content fingerprint. Retained on throw
(unknown outcome: retry re-runs the identical input under the same key so
the backend replays), cleared on ANY server status (fresh key by
construction: no deliberate-repeat collision, no 24h cached-FAILURE
lockout). Deliberately not persisted; cross-session recovery belongs to a
server-side lookup.

Supersedes the fingerprint approach parked in #713.

- payment-details: idempotencyKey threaded into all five send inputs
  behind a runtime capability gate (GraphQL input coercion rejects unknown
  fields pre-execution, so backends without the field get a keyless send
  and the gate remembers per endpoint+input type)
- use-send-payment: inFlightRef double-tap guard (synchronous, closes the
  same-frame window the render-time gate can't); Breez Failure now re-arms
  the button like the GraphQL path
- confirmation screen: early-return on ignored taps; retry enabled after
  Failure/throw (paymentError no longer pins the button; cleared on tap)
@linear

linear Bot commented Sep 1, 2026

Copy link
Copy Markdown

ENG-533

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

this PR needs to be updated to follow conventional commits message

- withIdempotencyKey: keyless coercion-refusal fallback now fires only on an
  attempt's FIRST dispatch. Retries of a dispatched attempt always go out
  keyed and surface UnresolvedAttemptKeyRefusedError instead of silently
  re-executing a payment whose earlier outcome is unknown (mixed-fleet
  rolling-deploy double-pay). attemptIsRetry threads from the hook's
  attemptRef through SendPaymentMutationParams into all five gated inputs.
- use-send-payment: wire isIdempotencyKeyReuseError in as defense in depth —
  an IdempotencyKeyReuseError failure no longer re-arms the button with a
  fresh key; the user is told to check their transaction history.
- confirmation screen: an ignored duplicate tap returns before touching the
  activity indicator, so it can no longer clobber the owning tap's spinner.
- finish/remove editing-debris comment fragments in lightning.ts,
  intraledger.ts and stray blank lines in index.types.ts/lightning.ts.

Tests: retry-path gate cases, attemptIsRetry lifecycle + key-reuse case in
the hook suite, and a confirmation-screen duplicate-tap spinner contract
test (verified to fail against the unfixed screen).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

this PR needs to be updated to follow conventional commits message

…cs, and duplicated user copy

Review fixes for ENG-533 (PR #717), round 2:

- A retry of an attempt whose earlier dispatch went out KEYLESS is no
  longer auto-dispatched. The gate now reports every un-keyed send via
  onKeylessDispatch (both the fallback after a first-dispatch refusal
  and the latched-gate path); use-send-payment records it on the attempt
  and a retry tap of such an attempt surfaces the check-your-history
  failure without calling the mutation — the server never saw the key,
  so there is nothing to replay and a keyed re-send could pay twice.

- An ignored duplicate tap no longer logs payment_attempt: the screen
  reads the in-flight guard synchronously (isInFlight) and returns
  before any side effect, so attempt counts stay honest against results.

- The 'check your transaction history' sentence is now a single shared
  constant (CHECK_TRANSACTION_HISTORY_MESSAGE) used by both
  UnresolvedAttemptKeyRefusedError and the reuse-error branch, so the
  copy cannot drift between modules.

Tests: hook spec pins the keyless-retry refusal (exactly one dispatch,
history error, button stays disarmed); gate spec pins when
onKeylessDispatch fires and when it must not; the duplicate-tap spec now
asserts payment_attempt is logged exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

this PR needs to be updated to follow conventional commits message

@islandbitcoin islandbitcoin changed the title Freeze-the-attempt send idempotency (ENG-533) feat: freeze-the-attempt send idempotency (ENG-533) Sep 1, 2026
@islandbitcoin
islandbitcoin merged commit c0f7c9e into main Sep 3, 2026
9 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.

1 participant