Skip to content

fix(payin): designate EVM sends before broadcasting (double-send crash window)#4249

Merged
TaprootFreak merged 10 commits into
developfrom
fix/payin-send-designate-before-broadcast
Jul 17, 2026
Merged

fix(payin): designate EVM sends before broadcasting (double-send crash window)#4249
TaprootFreak merged 10 commits into
developfrom
fix/payin-send-designate-before-broadcast

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

What

Closes the broadcast-before-persist double-send on the EVM pay-in forwarding/return path (part 1 of #4192; mirrors the payout fix from #4181).

Why

EvmStrategy.dispatch() (pay-in send) broadcast first and persisted afterwards. The minute-cron re-selects on status IN (...) AND outTxId IS NULL, so a process death between the send and the save left the group re-selectable — the next run broadcast a second time. One group can carry several pay-ins (batched by source/destination/asset), so a single crash re-sent the whole batch. The EIP-7702 delegation path (evm.token.strategy.ts) had the same window and additionally never persisted any pre-send marker.

How

Mirrors #4181:

  • Two new PayInStatus values: Sending (persisted in-flight marker, written per group member BEFORE the broadcast) and SendUncertain (terminal for the crons; manual investigation). No DB migration needed — the status column is a plain varchar.
  • dispatch() and the delegation path persist Sending first, then broadcast inside a boundary-aware try/catch:
    • TxBroadcastError (the send call was reached; the tx may be in flight — the EVM client boundary from fix(payout): prevent double payouts via designate-before-broadcast + structural broadcast boundary (all chains) #4181, and a new equivalent boundary around the delegation relayer send) or any error after a tx id was already obtained (post-broadcast persistence failures): fail closed, keep Sending; when the tx id is known but could not be persisted, it is logged so the evidence survives;
    • a plain error before a tx id was obtained is provably pre-broadcast: every member's captured previous status is restored and the next cron run retries.
  • Escalation (analogue of the payout processFailedOrders): at the end of both send crons, Sending entries older than ten minutes are stranded (an in-flight dispatch holds Sending for seconds; the age gate never matches the other pay-in cron's live work). Each escalates via an atomic conditional transition (update({ id, status: Sending }, { status: SendUncertain }) — no stale full-entity saves that could overwrite a concurrent Forwarded + outTxId), and ONE monitoring mail (isLiqMail, plus an error log) lists exactly the entries that were escalated. Neither status is selectable by the forward/return/confirmation queries, which is the entire protection.

Resolution of a SendUncertain pay-in is a deliberate operator decision (verify on-chain state first). The refund guards enforce this: returnPayIn and the buy-fiat chargeback trigger reject pay-ins in Sending/SendUncertain, so a routine chargeback retry cannot re-arm an unresolved send. Sending/SendUncertain also stay part of the pending-balance set of the finance log.

Tests

  • dispatch persists Sending on every group member before the broadcast (call-order assertion)
  • plain pre-broadcast error → captured statuses restored per member, error rethrown
  • TxBroadcastError → members stay Sending (no restore), error rethrown
  • post-broadcast persistence failure (dispatch succeeded, the send-data update throws) → NO restore, members stay Sending, tx id logged
  • delegation path: same behaviors on its own boundary
  • escalation: age-gated selection asserted, conditional-transition criteria asserted, mail lists only the entries whose transition won; no mail when none are stranded
  • refund guards reject Sending/SendUncertain pay-ins (payin service + buy-fiat chargeback trigger)
  • entity: new status transitions

…h window)

The EVM forwarding/return path broadcast first and persisted afterwards; the
minute cron re-selects on status + outTxId IS NULL, so a crash between send
and save re-broadcast the whole group. Mirrors the payout designation fix:
a persisted Sending marker is written per group member before the broadcast,
provably pre-broadcast errors restore the captured statuses for auto-retry,
ambiguous broadcast failures keep Sending, and a cron escalation moves
stranded entries to SendUncertain with one monitoring mail. The EIP-7702
delegation path gets the same barrier plus a proper broadcast boundary around
the relayer send. No migration needed (status column is varchar).

Part 1 of #4192
…lation

Review follow-ups: the restore path now only fires while no tx id was
obtained - a plain persistence error after a successful broadcast keeps the
group in Sending (and logs the tx id) instead of re-arming it for a second
broadcast. The stranded-send escalation is age-gated (ten minutes; the two
send crons run concurrently and must not escalate each other's live work)
and escalates via atomic conditional transitions instead of stale full-entity
saves that could overwrite a concurrently persisted outTxId; the mail lists
only actually escalated entries, logs an error and reaches the liquidity
recipients. Refund paths reject Sending/SendUncertain pay-ins, the finance
log keeps both statuses in its pending set, and the impossible
mixed-group narrative in comments/fixtures is gone.
…cleanups

Review follow-ups: the in-flight/uncertain guard lives as an exported
constant next to CryptoInputSettledStatus and both refund guards use it with
a message in the house style; the orphaned sendUncertain() helper (escalation
writes conditionally) is removed; spec imports are sorted and the token
strategy spec header describes the file again.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Review protocol: 3 full review passes (conformity + logic lens each) until zero findings. Pass 1 surfaced a BLOCKER — a plain persistence error after a successful broadcast restored the group's status and re-opened the double-send window — plus two MAJORs (a concurrent second send-cron could escalate the other's live in-flight work via stale full-entity saves; the refund guards did not reject in-flight/uncertain sends). Fixed by guarding the restore on the tx id, moving escalation to age-gated conditional transitions, and adding the refund guards. Pass 2: conformity cleanups (shared guard constant, dead helper removal). Pass 3: clean on both lenses.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 16, 2026 22:07
@TaprootFreak
TaprootFreak merged commit b65efc2 into develop Jul 17, 2026
7 checks passed
@TaprootFreak
TaprootFreak deleted the fix/payin-send-designate-before-broadcast branch July 17, 2026 20:38
TaprootFreak added a commit that referenced this pull request Jul 18, 2026
* fix(payin): fail closed on ambiguous non-EVM sends (Monero, Zano, Cardano, Lightning)

Ports the designate-before-broadcast boundary from #4249 (EVM) to the non-EVM pay-in send strategies. A failure at or after the broadcast now keeps the pay-in in the non-reselectable SENDING status (escalated to SEND_UNCERTAIN by the cron) instead of re-broadcasting a fresh transfer on the next run, which could pay a customer twice.

Adds a shared sendWithBroadcastBoundary helper in SendStrategy used by the bitcoin-based, Monero, Zano, Cardano and Lightning send loops, and makes the Lightning completion lookup fail-safe so a post-broadcast lookup error cannot mask a completed send.

Completes the non-EVM part of #4192.

* fix(payin): extend the fail-closed boundary to ICP, Solana and Tron sends

The ICP, Solana and Tron pay-in send strategies share Cardano's staged PREPARED send loop and carried the same pre-broadcast double-send window; route them through sendWithBroadcastBoundary like the other non-EVM chains. Also sort the PayInRepository import.
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