Repository navigation
fix: stop a restored wallet paying a spark-paid invoice again - #1858
Conversation
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
[High impact] The PR changes when the wallet can send Lightning payments across Spark and node channels. The PR needs changes before merging because restored wallets can still pay some invoices twice and Spark confirmation still waits for the node. FindingsSummaryThis PR adds restore-time payment holds for Spark and node history, moves the final Spark deadline check after both cache writes, and clears the shown Spending balance after a full withdrawal.
Explicitly deferred by Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Restore wallet] --> B{Metadata applied?}
B -->|Yes| C[Set history holds]
B -->|No| D[No new history holds]
C --> E[Each rail lists its sends]
E --> F[Seed hashes and clear hold]
F --> G[Trim cached hashes]
C --> H[Older invoices wait while history is unavailable]
D --> I[Node may pay a Spark-paid invoice]
G --> J[Hashes without expiry can be dropped above 100]
Reviews (1) · Last reviewed commit: "fix: hold pre-restore invoices until the..." · Reviewed by Greptile |
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
talosmachina
left a comment
There was a problem hiding this comment.
1 finding (1 P1). Holds a restored wallet's pre-restore invoices off the other rail until each rail lists its sends, re-checks the Spark deadline after the uncertainty write, lets Spark pay with the node down, and drops Spending after a full withdrawal. Reviewed 45413814, full tier.
What I checked, and 5 candidates I ruled out
Read in full: the changed functions in SparkRepo.kt (payInvoice, earlierLightningSend, syncActivities, seedRestoredSendHashes, refreshState, withdrawAll), LightningRepo.kt (payInvoice, earlierNodeSend, payInvoiceWithLdk, keepLdkSendHashes, executeWhenNodeRunning), CacheStore.kt seed/trim helpers, BackupRepo.restoreMetadataBackup, SendSheet.kt, SendConfirmScreen.kt
Call sites traced: earlierLightningSend (one caller, payInvoiceWithLdk), SparkError.SendRefused consumers (PaykitPaymentProofRepo.failLightningPayment, QuickPayRepo.classifyDispatchError), isNodeRunning into SendConfirmContent
CI: green at review time (build-and-test, lint, detekt, compose-ui); e2e-tests-local - lightning_security still pending
Build: not built here; on bitkit-android the review is reasoned from the code and CI
Ruled out
- New
SendRefusedfrom the node path misread as "nothing left": it is thrown beforeonBeforeSendand before LDK is called, so Paykit/QuickPay treating it as a pre-dispatch rejection is correct. - Seeding from
earlierLightningSendwithout the sync check: that list is read only whencanListPayments()holds, so it is a synced list. - Seed racing a restore: both seeders no-op once the
*UnseededSincefield is null, andupdateDatare-checks inside the transaction. - Deadline refusal leaking the uncertain flag: the new order removes both the uncertain send and the hash on refusal, covered by the new test.
withdrewAllhiding funds that arrive later: it clears on the first non-zero read, which the new test exercises.- Cold reader: the run on the finding did not finish inside this turn, so it is unconfirmed by a second model; the trace below is the evidence.
Merge confidence: 3/5, one P1: the node-down Spark send still stops on the Confirm screen.
Reviewer model: Claude Opus 5.5
Reasoning effort: Not exposed (the run metadata available to this reviewer does not carry it)
coreyphillips
left a comment
There was a problem hiding this comment.
Two independent reviews.
needs changing before merge
- Confirmation still blocks Spark sends when the node is stopped (app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt:182-187)
worth doing, does not block
- A concurrent refresh can clear
withdrewAlland bring the stale balance back (app/src/main/java/to/bitkit/repositories/SparkRepo.kt:578)
nits
- Every node send now decodes the invoice through
coreServicefor the hold check (app/src/main/java/to/bitkit/repositories/LightningRepo.kt:1664)
the reviewers disagree, your call
- Spark hold is never armed when a restore applies no metadata cache (app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1205-1213)
- Partial payment-history sync releases the restore hold (app/src/main/java/to/bitkit/repositories/SparkRepo.kt:1095-1098)
- Cache trimming removes protection for restored unexpired invoices (app/src/main/java/to/bitkit/data/CacheStore.kt:219-223)
pwltr
left a comment
There was a problem hiding this comment.
Reviewed 4541381.
A restore now holds each rail off invoices created before it until that rail's own synced list seeds the kept send hashes, and the seeding reads Spark's list only once Spark has synced. The deadline is checked after the uncertainty write, and a full withdrawal no longer keeps the old Spending balance.
High candidate — A restore without its metadata backup sets no hold, so the node can pay a Spark-paid invoice again. app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1210 sets the holds only while applying the restored metadata cache. When the metadata backup is missing or fails to download, performFullRestoreFromLatestBackup continues without it (:1070), leaving no hold and no kept send hashes. Before Spark's first synced listing, earlierLightningSend then returns no earlier send, and payInvoiceWithLdk can pay an unexpired invoice Spark paid before the restore. This is the missing-metadata half of the #1697 finding. Setting the holds when a full restore starts, whatever the metadata outcome, would close it. Whether the payee would accept the second payment has not been shown.
Review details
General review
Model: Claude Opus 5.5
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Code scan at 45413814d: no new findings beyond the open threads. I added a second consequence to greptile's expiry thread at SparkRepo.kt:1039: after an unsynced restart, Spark itself re-sends a seeded hash.
Checked and clean:
- Double pay across rails after a metadata restore:
sparkHistoryUnseededSinceandldkHistoryUnseededSincelive inAppCacheData(CacheStore.kt:349-359). Old backups decode with them null, and the restore always overwrites both with the restore time (BackupRepo.kt:1205-1213). Listed Spark sends are seeded intosparkSendHasheswhen the hold ends, sostartedSendholds them after a relaunch. The iOS twin misses that step; I raised it on synonymdev/bitkit-ios#1128. - No permanent hold: every hold is limited to invoices dated before the restore. It ends on
Synced, onsyncActivities, or on a successful list. - Spark paying with the node down (
2b240a573): the Spark path still runsearlierNodeSend, which refuses while the node list is unseeded. - Balance after a full withdrawal (
14813371d): display only. - Kill paths: the marker is persisted before seeding, and a kill between
addSparkSendHash,addSparkUncertainSendand the send leaves the hash held.
The restore-without-metadata path and Confirm waiting for the node are greptile's open P1s.
Spark is in no release yet (v3.1.0 predates the #1697 merge), so the changelog fragment describes a bug no released build had.
Device gate (partial): 45413814d — not run yet. Manual tests 1-3 from the PR body, plus a force-stop and relaunch with Spark unreachable before paying again, need a funded Spark regtest wallet and a restore. I'll run them on a later pass.
Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
|
Pushed f7ebbb8: the restore refusal now lasts until the invoice expires, and Spark's own send uses the same check.
Tests: compile, detekt and unit tests pass. Re-review is needed only because of this push: cb9a7af...f7ebbb8 |
coreyphillips
left a comment
There was a problem hiding this comment.
Rechecked after 3 new commits (9d5e267, 3214ec8, f7ebbb8). Nothing blocking remains.
addressed
- Partial payment-history sync releases the restore hold (app/src/main/java/to/bitkit/repositories/SparkRepo.kt:1095-1098). Fixed: A partial list can still seed and clear
sparkHistoryUnseededSince, but that no longer lets the invoice through. InearlierLightningSend(SparkRepo.kt:956-965, 939-948), NotFound, an un-retried FAILED and an unreadable list all reachrefuseUnverifiedRestoredInvoice(990-998). After 3214ec8 that refuses until the invoice expires, with no week cap. I traced each branch and confirmed LightningRepo.kt:1684 is the only nodesend, behind this check. I did not run the tests. - Spark's own pre-restore hold ends on a list that can lack its sends (app/src/main/java/to/bitkit/repositories/SparkRepo.kt:308-322). Fixed: In
payInvoice, NotFound, Found(FAILED) and an Unavailable read after seeding now fall to theelseat SparkRepo.kt:317-319, which applies the node'srefuseUnverifiedRestoredInvoicerule. The throw comes beforeonBeforeSendand the hash writes, so a refusal leaves nothing behind. I checked this by reading thewhenguards and the tests at SparkRepoTest.kt:344-399. I did not run them.
new since the last review
nits
- Comments still say a listing or a week ends the restore hold (app/src/main/java/to/bitkit/data/CacheStore.kt:366-369)
|
Pushed 2738531: comments and a log line only, from @coreyphillips's nit; no behaviour change. Compile and detekt pass. Re-review is needed only because of this push: f7ebbb8...2738531 |
jvsena42
left a comment
There was a problem hiding this comment.
Device gate (partial) at cb9a7af06, on the local Spark regtest stack (bitkit-docker spark profile, Gate_Throwaway emulator). All five cases pass. The head has since moved four commits (9d5e26773..273853135), which change payInvoice and refuseUnverifiedRestoredInvoice. I'll review that delta and re-run what it touches.
To stage a restore without the payment: the metadata backup already held the Spark-paid hash, so after the phrase restore I removed that hash and its expiry from app_cache.json with run-as. That stands in for a backup taken before the payment.
| Case | Result |
|---|---|
| 1. Pre-restore invoice X that Spark already paid, before Spark syncs | Pass. Over Spark (no channel, operators down) and over channels it is refused: "Bitkit can't confirm this invoice wasn't paid before you restored your wallet. Please request a new invoice." After Spark syncs and re-seeds, paying X shows "Bitcoin Sent" with no new send on either rail, and the payee has a single inbound record. |
| 2. New invoice while Spark is offline | Pass, paid over channels (PaymentSuccessful, 7,000 sats to the SSP node). A first attempt to another node failed with ROUTE_NOT_FOUND because the SSP won't forward from a private channel, a limit of this stack. |
| 3. Node down, Spark ready | Pass. Confirm opened with no sync overlay, and the payment went over Spark (Sent Spark payment '01a1cbc6…'). |
| 4. Pre-restore invoice Y that Spark never paid, over channels after Spark's sync | Pass, refused with the new wallet__payment_restored_invoice copy. |
| 5. Relaunch with Spark unreachable after its first sync, then pay X again | Pass. "Payment Pending", with no LDK or Spark send and no new activity row. |
17:41:34.364 WARN [SparkRepo.kt:318] Refused a Spark send of '1aaecbe8…' until Spark lists its sends
17:44:45.496 WARN [SparkRepo.kt:948] Refused a node send until Spark lists its earlier sends once
17:54:46.572 WARN [SparkRepo.kt:999] Refused a node send of a pre-restore invoice Spark hasn't listed
18:05:06.705 ERROR Error executing 'payInvoice' [PaymentPendingException='Payment pending']
Small UX notes, not findings:
- Paying an already-paid invoice again shows the "Bitcoin Sent" success sheet, with no sign that nothing new was sent.
- An invoice paid before the restore shows "Payment Pending" while Spark is unreachable.
- The restored-invoice refusal offers "Try Again", which can't succeed.
Device gate (partial): cb9a7af06 (head 273853135; the new commits are not yet reviewed or driven).
Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
jvsena42
left a comment
There was a problem hiding this comment.
Re-review of 9d5e26773, 3214ec889, f7ebbb888 and 273853135: no findings. Approving on the gate posted above (cb9a7af06, all five cases pass) plus this review.
- Spark rail (
9d5e26773,f7ebbb888):payInvoicenow runsrefuseUnverifiedRestoredInvoicewhen Spark's list is unreadable or doesn't hold the hash, the same rule as the node's send. It runs beforeonBeforeSend,sendsInFlightand the hash writes, so a refusal leaves nothing behind. An invoice Spark listed as paid or pending still returns its hash orPaymentPendingException. - No 7-day cap (
3214ec889): the refusal now lasts until the invoice expires. On the Spark path the expiry is always known, from the parsed invoice. On the node path an undecodable invoice is refused, which fails closed, and an invoice that won't decode wouldn't pay anyway.holdsRestoredInvoices()now stays true after a restore, which costs one invoice decode per node send. - Consequence: after a restore, an invoice made before it that Spark never paid can't be paid on either rail until it expires, and the copy asks for a new invoice. That is a deliberate trade against a double payment.
The gate drove the node-side refusal of a pre-restore invoice Spark hadn't listed (case 4). The Spark-side version added here is covered by the new SparkRepoTest cases, not driven.
Device gate: cb9a7af06 (head 273853135: the delta above, reviewed) — 5 manual cases passed on the local Spark regtest stack.
Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
coreyphillips
left a comment
There was a problem hiding this comment.
Rechecked after 1 new commit (2738531). Nothing blocking remains.
still open
- Comments still say a listing or a week ends the restore hold (app/src/main/java/to/bitkit/data/CacheStore.kt:366-369). Since 3214ec8 the hold ends only when the invoice expires, and it applies on both rails, but two comments still describe the old rule. The KDoc on
AppCacheData.sparkRestoredAt(CacheStore.kt:366-369) says "for a while after it the node refuses", which is now wrong on both counts. The comment on theisUnlistedSinceRestorebranch (SparkRepo.kt:312-316), and its log line "until Spark lists its sends", imply that a Spark listing ends the refusal. It doesn't: theelsebranch refuses the same invoice after the listing. The branch is also nearly redundant now, because the restore code setssparkHistoryUnseededSinceandsparkRestoredAtto the same time. Claude still sees it: Both places you named are fixed. The KDoc at CacheStore.kt:366-369 now says both rails refuse until expiry, and the comment and log line at SparkRepo.kt:312-316 no longer say a listing ends the refusal. I checked these againstrefuseUnverifiedRestoredInvoice, which keys only onsparkRestoredAtand expiry. Two comments from earlier commits on this branch still say a listing ends the hold. The KDoc onseedRestoredSendHashes(SparkRepo.kt:1081) says seeding means "the hold on older invoices ends", but seeding clears onlysparkHistoryUnseededSinceand the expiry hold stays. BackupRepo.kt:1051-1052 says the restore arms holds "until that rail lists its own sends", but the same update armssparkRestoredAt, which lasts until expiry.
new since the last review
worth doing, does not block
- New refusal string wallet__payment_restored_invoice has no translations (app/src/main/res/values/strings.xml:1502)
|
Pushed 7a80258: comments only, no behaviour change. Compile and detekt pass.
@jvsena42 Thanks for the device run. On your three UX notes:
Re-review is needed only because of this push: 2738531...7a80258 |
|
Cancelled and reran the e2e run: its |
|
Merging: @pwltr approved the head, 7a80258, and @jvsena42 and @coreyphillips approved 2738531. The commits after that change comments only. All checks are green, and no thread is open. The |
Closes #1855
Closes #1849
Closes #1868
This PR fixes four Spark payment gaps found in review after #1697 merged. The main one: a restored wallet could pay an invoice over its channels that Spark had already paid.
Description
Syncedevent doesn't end this, because it can fire before the payment history syncs. The user is asked to request a new invoice.Out of Scope
wallet__payment_restored_invoice: it is in English for now; translations follow in chore: translate spark strings #1840.Design
N/A — no UI changes.
Preview
N/A — the refusal copy is unchanged.
QA Notes
Setup
A local regtest stack (bitkit-docker) with Spark regtest. Build
devDebugfrom this PR's head.Journeys
N/A — not drivable; see Manual Tests.
Manual Tests
Automated Checks
SparkRepoTest.kt— a pre-restore invoice is refused until Spark lists its sends; a post-restore one pays; a never-restored install is never held; a pre-restore invoice Spark's synced list lacks is refused over the node until it expires; a ready Spark that hasn't synced since the restore refuses an invoice from before it; a send whose deadline passes during the uncertainty write is refused; a 0 read after a full withdrawal drops the Spending balanceLightningRepoTest.kt— a pre-restore invoice is refused over Spark until the node lists its payments; a post-restore one pays with the node down; the node's listing ends the hold and seeds its hashesBackupRepoTest.kt— a restore sets both holds and keeps the node list's seeded flagSendSheetSyncOverlayTest.kt— the sheet doesn't wait for the node when Spark can payEvidence
just compile,just lintandjust testpass on this PR's head.Gaps
Models used
Claude Opus 5.5(reasoning:high)Claude Opus 5.5(reasoning:high)