Payment safety upgrade and repair
To upgrade:
- Stop the old workers.
- Back up the host database.
- Upgrade the backend and checkout packages together.
- Rebuild your frontend assets.
- Restart all web and notification workers.
The payment table shape is unchanged for Node, Rails, Python and PHP. The durable scan gate now holds versioned leases and bounded progress. Running old and new workers side by side is not supported. If the gate holds a progress version from a future release, the library fails closed instead of guessing. Do not delete payment rows or reset their statuses to make an upgrade run.
BTCPay needs additive migrations and has its own upgrade and recovery procedure. This source change does not include a registry or BTCPay release.
Payment and instruction deadlines
A swap deposit deadline only controls when to stop showing the deposit address.
Reconciliation uses the saved Lightning invoice deadline in checkout_data.
For example, take a 600-second deposit window and an 1800-second wallet invoice. An unpaid attempt cannot be closed until a successful covering scan runs at or after 1800 + 900 seconds. A covering scan is a wallet-history scan that fully covers the attempt’s time window. The 900-second constant is a grace period for observing payments. It is not extra time to pay.
- Malformed saved wallet deadlines fail visibly and stay unresolved.
- Both
expiresAtand legacyexpires_atsnapshots keep the wallet deadline. Neither falls back to the swap deposit deadline.
The browser keeps monitoring the wallet and refunds after the payer’s instructions expire locally. Provider completion still needs wallet settlement. Retired swap instructions and refund recovery stay separate. Changing the checkout identity cancels any work for the old identity.
Reconciliation progress
Every mounted payment route, scheduled reconciliation pass and notification fallback shares one gate in the host database. Each pass handles at most 200 pending attempts, reads at most 50 wallet pages, and stops scanning after nine seconds.
The library takes turns through pending attempts in key order, and saves how far it got through wallet pages. So later attempts and deep history still get checked across process restarts. The scan window is narrowed by creation time only when the saved timestamp came from the wallet. Legacy or host-clock timestamps use the wider fallback window.
How batches move through the queue:
- A selected batch leaves the saved queue before the library calls the wallet.
- A successful scan that hits the cap saves where to continue.
- A failure or crash frees a slot so newer batches can run. The batch’s unresolved attempts stay in the ledger for the next turn.
- Selection starts over from the beginning as soon as a batch reaches the ledger’s current end. So new attempts arriving between passes cannot keep pushing older fulfillment retries back forever.
Rules for custom code:
- Custom repositories must fill the requested 200-row page, unless fewer pending rows remain after the cursor. A short page tells the library it has reached the end of the ledger.
- Python custom wallet clients must honor the internal monotonic
_deadlinerequest value. - Ruby’s bundled adapter enforces that deadline around the wallet RPC. Custom Ruby clients must enforce the same deadline.
- This value never belongs in a NIP-47 request.
- You can turn off reconciliation on HTTP requests. The separate worker keeps using the same durable gate.
A resumed offset walk can find a final payment state, but it cannot prove that no payment arrived. Wallet history can change between pages. Closing an attempt based on the clock needs a fresh, complete covering scan. So dense history that cannot be split safely may stay pending. It stays pending until the wallet shows positive evidence or an operator reviews it. Failed, truncated, stale-lease and unusable scans never prove absence. Only pending attempts change state automatically.
Before contacting the provider, a refund needs a supported asset and network from the server’s saved swap data. If recovery metadata is missing, the host must repair it. The payer cannot supply a replacement network. Provider diagnostic hooks in Node and Ruby now receive allowlisted metadata and presence flags instead of raw request and response bodies. Update any custom log consumers to match.
Custom repository and transaction changes
Node custom repositories must implement
recordSettlementWithFulfillment(settlement, fulfill) and findByPaymentHash.
The repository must:
- resolve the reference
- settle sibling attempts one at a time
- await
fulfill({ reference, paymentHash, paidAt, details, transaction })inside the same transaction as the payment update
If anything fails, both must roll back. A boolean claim followed by a callback
is no longer supported. createHost<Transaction> exposes the host’s
transaction type. Database mode still supplies its SQL query context.
Default reconciliation also needs lease-based claimReconcileGate and
checkpointReconcileGate, bounded keyset selection, and durable progress.
Custom repositories without a gate must explicitly turn off opportunistic
reconciliation and handle recovery themselves.
The bundled Node NWC adapter always runs the receive-only preflight. The old
bypass is gone. Custom Node wallet clients must honor the history request’s
AbortSignal. The bundled SDK transport cancels subscriptions and queued relay
work at the scan deadline.
Django after_paid now follows the outermost host transaction. A rollback
discards it. Rails fulfillment takes part in the host transaction and in its own
ActiveRecord after_commit workflow. Database fulfillment may run again after
a rollback, but only one fulfillment per reference commits. Post-commit
callbacks are still best effort. Put external effects in a host outbox inside
the settlement transaction. Then send them using the reference as the external
idempotency key. See fulfillment.
Review and repair existing attempts
Run these steps from a trusted host maintenance process, not a payer route. First produce a bounded dry-run report and keep it with the incident record. Reports contain hashes, references, deadlines, statuses and reasons. They never contain provider credentials or wallet connection strings.
| Backend | Dry run | Reviewed requeue |
|---|---|---|
| Node SQL | payments.listRepairCandidates({ limit: 100, after }) |
payments.requeueAttempt({ paymentHash, expectedStatus, expectedUpdatedAt, reason }) |
| Python SQL/Django | repository.maintenance_candidates(limit=100, after=cursor) |
repository.requeue_reviewed_attempt(candidate, decision_id="ticket-42") |
| Rails | OpenReceivePayment.maintenance_candidates(limit: 100, after: cursor) |
OpenReceivePayment.requeue_reviewed_attempt!(candidate, decision_id: "ticket-42") |
| PHP SQL | $repository->maintenanceCandidates($cursor, 100) |
$repository->requeueReviewedAttempt($candidate, 'ticket-42') |
Then:
- Follow each report’s next cursor until there are no more pages.
- Review early swap closures against the saved wallet deadline.
- Review
attentionrows against wallet history and the host’s order and fulfillment records. - Requeue only candidates you have explicitly reviewed.
Requeue checks the recorded status and version under the reference lock. It keeps an audit entry in the existing metadata table and never changes settled rows. Repeated or stale decisions return false. Requeue does not grant the order anything. The normal wallet settlement transaction still decides that, including the rule that fulfillment runs only for the reference’s first settled attempt.
Older Node custom repositories had a callback gap. Because of it, a settled payment may be missing its host fulfillment. Do not reset or replay all settled payments. For each affected reference, compare it against the host’s entitlement or outbox evidence and its external idempotency records. Repair only the specific missing host effect, through the host’s own transaction or outbox workflow. OpenReceive cannot tell from a payment row alone whether an external fulfillment happened.
Keep old swap rows and server-only provider tokens while any refunds or wallet payments are still unresolved. Some BTCPay legacy rows have no proven connection or LNURL mapping. For those, follow the plugin report and the explicit account-binding procedure. Never guess from the current wallet or checkout prompt. Rolling back binaries requires stopping all workers and a coordinated restore or forward repair. Do not remove recovery columns or discard history.