Repository navigation
Conversation
A spending-only wallet pays a Paykit contact whose only endpoint is on-chain through a Pubky Swaps provider found among its contacts. Behind PUBKY_SWAPS_ROUTES on regtest debug builds. Uses bitkit-core 0.8.0-swaps.2.
|
Pushed
|
|
Pushed
|
|
Pushed |
The send succeeds once bitkit-core broadcasts the claim, and the claim is advanced in the background until it confirms. A failed hold payment stops the wait at once, a failed advance is retried, tags and the contact land on the hold payment, a Direct answer moves to Savings, quotes for a replaced send or another amount are dropped, configured providers come first, and restore shows the Savings notice only for swaps rebuilt now.
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. |
A claim wait that runs out leaves the send pending on the hold payment and keeps advancing the swap in the background, instead of a failure whose Retry could pay the contact twice. A swap result reaches only the sheet and quote that started it, so a payment edited from Amount keeps its own result. The restore journey checks the toast before it hides, and the changelog entry is dropped: the routes are behind a regtest debug flag.
coreyphillips
left a comment
There was a problem hiding this comment.
Rechecked after 1 new commit (d2621c6). Nothing blocking remains.
addressed
- Restore journey checks the toast after it has already hidden (journeys/paykit-swap-restore.xml:21-22). Fixed:
journeys/paykit-swap-restore.xml:20now checks the toast right after the first block, before the provider log and the second block.journeys/README.md:255-257records the iOS order. This matchesAppScene.swift:1294-1295, which shows the toast whenawaitClaimreturns at claim broadcast. I read it against the code and did not run the journey. - Claim timeout reports failure while the hold payment is still pending (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1416-1460). Fixed: On
.timedOut,PubkySwapsRouteService.swift:143-146now startsadvanceInBackground, so a lockup that confirms late is still claimed.SendConfirmationView.swift:1440-1450pushes.pendingon the hold hash instead of the failure screen. I checked this by reading the code; there was no simulator to run the tests. - Changelog announces a feature release builds cannot reach (changelog.d/next/1170.added.md:1). Fixed:
changelog.d/next/1170.added.mdis deleted, andgit diff origin/master...HEAD -- changelog.dis now empty. - Claim timeouts allow a duplicate payment on Retry (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1453-1460). Fixed: A timeout now throws
PubkySwapClaimError.timedOut(PubkySwapsRouteService.swift:195).isDefiniteSwapFailurereturns false for it, soSendConfirmationView.swift:1440-1450shows Pending instead of failure with Retry. For a Lightning hash,SendPendingScreenoffers only Details and Close (its retry buttons need an on-chain attempt, which loads only whenpaymentHash == nil). It goes to failure only on the hold payment's ownpaymentFailed. In corev0.8.0-swaps.9claim.rs,Failedwith the claim never published (no lockup, or too late to claim) means the provider cannot settle the hold invoice. A refund that beats a published claim is the point 3 risk, not a double payment to the contact.
still open
- Background claim advancing stops after an hour, not at confirmation (Bitkit/Services/PubkySwapsRouteService.swift:21). The PR says the claim keeps advancing in the background until it confirms, but the loop stops after 60 minutes.
confirmationTimeoutis 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop inawaitClaimthen logs and exits. The claim fee is a fixed 2 sat/vB with no bump, so a claim that drops out of mempools after that hour is never rebroadcast. The provider settles the hold invoice as soon as it sees the claim. If the claim then does not confirm before the lock'stimeout_block_height, the provider can also refund its lockup. The user pays and the contact receives nothing. This cannot happen today: the flag is regtest-only and bitkit-core refuses mainnet commits. It does need to change before the flag goes, and the description currently says otherwise. Codex still sees it: PubkySwapsRouteService.swift:28 and :149-157 still stop advancement after an hour. The corrected description and tracking in #1262 document the remaining gap. - Swap restore runs once and is skipped if the wallet start fails (Bitkit/AppScene.swift:1268).
restorePubkySwaps()runs right afterstartWallet(completingBackupRestore: true)(AppScene.swift:1268), whether or not the node actually started.startWalletreturns early when offline and swallows start errors. In that caserestoreSwapsthrows (no node fornewAddress/listPayments), and the error is only logged.retryWalletStart(AppScene.swift:1300) and later wallet starts never call it again. In those cases the swap J2 covers is never claimed and waits for the provider's timeout instead. Funds return through that timeout, so this is not urgent. A retry once the node is running would close the gap. Codex still sees it: AppScene.swift:1267-1285 still attempts recovery once and only logs failure; retryWalletStart at :1301 does not retry recovery. Tracking this in #1263 leaves the behavior unchanged. - Old swap results overwrite an edited payment in the same sheet (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1381-1382). While a 20,000-sat commit waits, the user can return to Amount and continue with 30,000 sats in the same sheet. The unchanged presentation ID accepts the old result: success opens Bitcoin Sent displaying the new draft amount, while failure clears the replacement quote. Both branches reproduced with the extracted payment method and a suspended commit. The quote-id check covers an edit that already has its new quote. But Continue writes
wallet.sendAmountSats(SendAmountView.swift:377) beforequoteSwapRouteruns. While that quote is in flight, or after it fails (.noneor a network error),app.paykitSwapRoute?.quotestill holds the committed quote. The old result then passesisSendCurrent(), and Bitcoin Sent shows the edited amount (SendSuccess.swift:47). Comparingquote.deliveredSatwithwallet.sendAmountSatsinisSendCurrent, or clearing the quote when Continue starts, would close it. - Swap records survive a successful wallet wipe (Bitkit.xcodeproj/project.pbxproj:1244). After a routed payment, the in-app wallet wipe leaves its swap records behind. The pinned release writes contracts, destination addresses, amounts and signed claims to
core/pubky_swaps/*.json. Itswipe_all_databases()clears only activity and Blocktank tables, whileAppResetdeletes only LDK storage. Swap history and any saved claim preimage therefore survive a successful wipe. Codex still sees it: The pinned 0.8.0-swaps.9 still omits swap JSON files from wipe_all_databases(), and AppReset.swift:96 adds no cleanup. The response defers this to synonymdev/bitkit-core#197; the wipe gap remains.
new since the last review
worth doing, does not block
- Timed-out swap send stays on Pending after its hold payment settled (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1440-1450)
- Early hold settlement leaves the send pending indefinitely (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1444-1449)
Once the hold invoice is paid, the send moves to Pending, which has no way back to Amount, so a fresh quote cannot pay the contact a second time. The hold payment settling or failing finishes the send, early settlement too; the claim wait only drives the swap, and a swap bitkit-core failed fails the pending send.
|
@coreyphillips thanks for the approval. I pushed one more fix after it, 4bee733, because it closes a double payment on iOS, the twin of synonymdev/bitkit-android#1732's discussion_r4520342025. During the claim wait the user could go back from Confirm to Amount, continue with a fresh quote and swipe again, funding a second swap. Now, once the hold invoice is sent, the send moves to Pending, which has no way back, and it finishes on the hold payment's own success or failure, like a pending Lightning payment. That also fixes both of your new threads. A re-approval is needed only for this commit. |
coreyphillips
left a comment
There was a problem hiding this comment.
Rechecked after 1 new commit (4bee733). Nothing blocking remains.
addressed
- Timed-out swap send stays on Pending after its hold payment settled (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1440-1450). Fixed: SendConfirmationView.swift:1420-1423 now registers the hold hash and pushes Pending as soon as
sendreturns. The claim only starts after that, indriveSwapClaim(:1424). A settlement needs the preimage that the claim reveals, sopaymentSuccessfulalways finds the hash at AppViewModel.swift:1884-1891. SendPendingScreen.swift:315 and :338 then turn that resolution into Bitcoin Sent. I traced this path but did not run it. - Early hold settlement leaves the send pending indefinitely (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1444-1449). Fixed: Same change as point 5. The hold hash is in
pendingPaymentHashesbefore any claim exists (SendConfirmationView.swift:1420), so an early settlement can no longer arrive unregistered. The pending screen, which is already shown, resolves throughsendSheetPendingResolution(SendPendingScreen.swift:338). Traced, not run.
still open
- Background claim advancing stops after an hour, not at confirmation (Bitkit/Services/PubkySwapsRouteService.swift:21). The PR says the claim keeps advancing in the background until it confirms, but the loop stops after 60 minutes.
confirmationTimeoutis 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop inawaitClaimthen logs and exits. The claim fee is a fixed 2 sat/vB with no bump, so a claim that drops out of mempools after that hour is never rebroadcast. The provider settles the hold invoice as soon as it sees the claim. If the claim then does not confirm before the lock'stimeout_block_height, the provider can also refund its lockup. The user pays and the contact receives nothing. This cannot happen today: the flag is regtest-only and bitkit-core refuses mainnet commits. It does need to change before the flag goes, and the description currently says otherwise. The code has not changed:confirmationTimeoutis still 60 minutes at PubkySwapsRouteService.swift:28. The description now says "for up to an hour" and #1262 tracks the rest, so this need not hold the merge. Nothing in the code answers it yet. - Swap restore runs once and is skipped if the wallet start fails (Bitkit/AppScene.swift:1268).
restorePubkySwaps()runs right afterstartWallet(completingBackupRestore: true)(AppScene.swift:1268), whether or not the node actually started.startWalletreturns early when offline and swallows start errors. In that caserestoreSwapsthrows (no node fornewAddress/listPayments), and the error is only logged.retryWalletStart(AppScene.swift:1300) and later wallet starts never call it again. In those cases the swap J2 covers is never claimed and waits for the provider's timeout instead. Funds return through that timeout, so this is not urgent. A retry once the node is running would close the gap. AppScene.swift is unchanged since d2621c6.restorePubkySwaps()still runs once at :1268, andretryWalletStart(:1301) never calls it again. It is deferred to #1263, which is reasonable for a non-blocking point, but it is not fixed. - Old swap results overwrite an edited payment in the same sheet (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1381-1382). While a 20,000-sat commit waits, the user can return to Amount and continue with 30,000 sats in the same sheet. The unchanged presentation ID accepts the old result: success opens Bitcoin Sent displaying the new draft amount, while failure clears the replacement quote. Both branches reproduced with the extracted payment method and a suspended commit. The failure half is answered: both quote resets (SendConfirmationView.swift:1443 and :1488) are behind the quote-ID check. The success half is narrower but still reachable. Back and the amount tap stay live during the commit, and Continue sets
wallet.sendAmountSatsto the edited amount (SendAmountView.swift:377) before it quotes. If the hold invoice goes out before the new quote lands,isSendCurrent()still passes and Pending is pushed (:1422). On settlement, SendSuccess then shows the draft amount (SendSuccess.swift:47), not the amount paid. - Swap records survive a successful wallet wipe (Bitkit.xcodeproj/project.pbxproj:1244). After a routed payment, the in-app wallet wipe leaves its swap records behind. The pinned release writes contracts, destination addresses, amounts and signed claims to
core/pubky_swaps/*.json. Itswipe_all_databases()clears only activity and Blocktank tables, whileAppResetdeletes only LDK storage. Swap history and any saved claim preimage therefore survive a successful wipe. Nothing in this round touches the wipe: AppReset and the pinned bitkit-core are unchanged. It is tracked in bitkit-core#197 and still stands here.
new since the last review
worth doing, does not block
- Going back to Amount during the swap commit can still pay a second swap (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1406-1424)
- Fast hold-payment failures are missed before pending registration (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1415-1424)
jvsena42
left a comment
There was a problem hiding this comment.
Stage 1 review at 4bee733: no new findings. Everything wrong that I traced is already tracked: #1262, #1263, #1266, #1267, bitkit-core#197, and the open "old swap results overwrite an edited payment" thread. The feature is dev/QA-facing, since it needs a debug regtest build with PUBKY_SWAPS_ROUTES.
- Checked and clean:
deliveredSatmust equal the send amount;- a quote whose generation, context, route or amount changed is dropped;
- the pending payment is registered before navigation;
- the claim driver runs in unstructured tasks that outlive the view;
- tags are created and deleted symmetrically;
fundingSourcesreturns Spending only while a route is set.
- Parity with Android #1732: iOS keeps the error screen with Retry from Amount on a refused commit, and its claim driver survives the view; Android doesn't do either yet (LOWs there). Android is ahead on the early hold failure, which iOS tracks in #1267.
- Device gate pending: same provider setup as Android. I'll run J1 and J2 next.
Reviewer model: Claude Fable 5.1 (claude-fable-5-1), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
|
@coreyphillips bd19f96 only moves this PR to bitkit-core |
|
@coreyphillips one more version step: bitkit-core |
coreyphillips
left a comment
There was a problem hiding this comment.
Rechecked after 2 new commits (bd19f96, 76f8374). Nothing blocking remains.
still open
- Background claim advancing stops after an hour, not at confirmation (Bitkit/Services/PubkySwapsRouteService.swift:21). The PR says the claim keeps advancing in the background until it confirms, but the loop stops after 60 minutes.
confirmationTimeoutis 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop inawaitClaimthen logs and exits. The claim fee is a fixed 2 sat/vB with no bump, so a claim that drops out of mempools after that hour is never rebroadcast. The provider settles the hold invoice as soon as it sees the claim. If the claim then does not confirm before the lock'stimeout_block_height, the provider can also refund its lockup. The user pays and the contact receives nothing. This cannot happen today: the flag is regtest-only and bitkit-core refuses mainnet commits. It does need to change before the flag goes, and the description currently says otherwise. No app code changed since 4bee733.confirmationTimeoutis still 60 minutes (PubkySwapsRouteService.swift:28), andadvanceInBackgroundstill stops there. The description now says "up to an hour" and #1262 tracks the behaviour, but the doc comments at :25 and :135 still say the claim is rebroadcast until it confirms. - Swap restore runs once and is skipped if the wallet start fails (Bitkit/AppScene.swift:1268).
restorePubkySwaps()runs right afterstartWallet(completingBackupRestore: true)(AppScene.swift:1268), whether or not the node actually started.startWalletreturns early when offline and swallows start errors. In that caserestoreSwapsthrows (no node fornewAddress/listPayments), and the error is only logged.retryWalletStart(AppScene.swift:1300) and later wallet starts never call it again. In those cases the swap J2 covers is never claimed and waits for the provider's timeout instead. Funds return through that timeout, so this is not urgent. A retry once the node is running would close the gap. AppScene.swift:1267-1268 still callsrestorePubkySwaps()once, whateverstartWalletdid.retryWalletStart(:1301-1310) still never calls it. Deferring it to #1263 tracks the gap without closing it. - Old swap results overwrite an edited payment in the same sheet (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1381-1382). While a 20,000-sat commit waits, the user can return to Amount and continue with 30,000 sats in the same sheet. The unchanged presentation ID accepts the old result: success opens Bitcoin Sent displaying the new draft amount, while failure clears the replacement quote. Both branches reproduced with the extracted payment method and a suspended commit. The quote check in
isSwapRouteResultCurrent(SendConfirmationView.swift:1372-1379) settles the failure branch, and the case where the new quote has already landed. One path remains: Q1'ssendreturns after Continue on Amount but before Q2 lands. Q1 is then still the active quote, so Pending (:1422) and Bitcoin Sent showwallet.sendAmountSats, which SendAmountView.swift:377 has already set to the new amount. I traced this but did not run it. - Swap records survive a successful wallet wipe (Bitkit.xcodeproj/project.pbxproj:1244). After a routed payment, the in-app wallet wipe leaves its swap records behind. The pinned release writes contracts, destination addresses, amounts and signed claims to
core/pubky_swaps/*.json. Itswipe_all_databases()clears only activity and Blocktank tables, whileAppResetdeletes only LDK storage. Swap history and any saved claim preimage therefore survive a successful wipe. The new pin'swipe_all_databases(bitkit-core src/lib.rs:2080 at 4f3e215) still clears only the activity and Blocktank stores, and AppReset is unchanged. Swap records still survive a wipe. Tracked in bitkit-core#197. - Going back to Amount during the swap commit can still pay a second swap (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1406-1424). 4bee733 removes the way back to Amount once the hold invoice is paid. Before that point, during
commit,decodeandsend(SendConfirmationView.swift:1406-1415), the user can still go back, request a fresh quote and pay a second swap. WhileisSubmittingPaymentis true, the header back button (showBackButton: !navigationPath.isEmpty, :336) and the amount tap (navigateToAmount, gated only bycanEditAmount, :253) both stay live. On Amount, Continue startsquoteSwapRoute, which asks every contact for an offer, so it usually takes longer than the commit to one provider. What follows, in order: 1. Q1'ssendreturns while the user is on Amount.isSendCurrent()still passes because Q1 is still the active quote, so.pendingis appended on top of Amount (:1422). 2.performSwapRoutedPaymentreturns, which releasesisSubmittingPayment. 3. Q2 arrives. Its guard (SendAmountView.swift:474-476) still passes, because nothing cleared Q1. It stores Q2 and appends.confirmon top of Pending (:409). 4. That Confirm's swipe is enabled. A swipe commits and pays a second swap, so the contact is paid twice. If Q2 lands before Q1'ssendreturns, the outcome is the same and quieter.isSendCurrent()fails, so Q1 is paid with no navigation and no toast. Q2's swipe unlocks as soon as Q1's call returns. I confirmed this by tracing the code above, andSwipeButtonruns its action in an unstructuredTaskthat is not cancelled when Confirm is popped. I did not run it: there is no provider or simulator here. It is non-blocking only because the route is behind the regtest debug flag. Two possible fixes: - Hide back and amount editing on Confirm while a swap payment is submitting. - HavequoteSwapRoutedrop its result when a payment is submitting or when the path no longer ends at Amount. The reply defers this to #1266 and does not dispute it.canEditAmount(SendConfirmationView.swift:253-260) andshowBackButton(:336) still ignoreisSubmittingPayment, so going back during the commit can still lead to a second swap. - Fast hold-payment failures are missed before pending registration (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1415-1424). A hold payment that fails before submission returns now opens Pending without a failure resolution. The failure listener is installed in
driveSwapClaimafter the awaited send, and pending registration also happens afterward. I reproduced this with the extracted methods by deliveringpaymentFailedduring submission: HEAD retained the failed hash without a resolution, while d2621c6 opened Failure. The reply defers this to #1267 and agrees it is a regression.send(SendConfirmationView.swift:1415) still runs before bothaddPendingPaymentHash(:1420) and thepaymentFailedlistener indriveSwapClaim(:1463).
jvsena42
left a comment
There was a problem hiding this comment.
Delta review of bd19f96 and 76f8374 (bitkit-core 0.8.0-swaps.9 → 0.8.0-swaps.11): no findings. No Swift changed.
- What the core diff adds:
store::create, which refuses a swap whose id is already saved. It runs incommit_routebefore the app pays the hold invoice, so a refusal moves no funds and lands on the existing "refused commit → Retry from Amount" path. Inrestore_swapsa known record stillcontinues beforecreate. - What it leaves alone:
SwapRecordand its JSON, key derivation, hold-invoice validation and claim logic. So swaps that testers saved under swaps.9 still restore. - Other core changes: the API breaks are all in wake, which the app doesn't call. The swap FFI signatures are unchanged.
- Twin note: Android #1732 still pins
swaps.9. Noswaps.10orswaps.11Android artifact is published yet, so the twins differ on this refusal until one is.
The device gate is still pending.
Reviewer model: Claude Fable 5.1 (claude-fable-5-1), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
|
@coreyphillips moved bitkit-core from the |
coreyphillips
left a comment
There was a problem hiding this comment.
Rechecked after 1 new commit (d57e2bc). Nothing blocking remains.
still open
- Background claim advancing stops after an hour, not at confirmation (Bitkit/Services/PubkySwapsRouteService.swift:21). The PR says the claim keeps advancing in the background until it confirms, but the loop stops after 60 minutes.
confirmationTimeoutis 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop inawaitClaimthen logs and exits. The claim fee is a fixed 2 sat/vB with no bump, so a claim that drops out of mempools after that hour is never rebroadcast. The provider settles the hold invoice as soon as it sees the claim. If the claim then does not confirm before the lock'stimeout_block_height, the provider can also refund its lockup. The user pays and the contact receives nothing. This cannot happen today: the flag is regtest-only and bitkit-core refuses mainnet commits. It does need to change before the flag goes, and the description currently says otherwise. The description now says the claim advances for up to an hour, which matches the code, and #1262 tracks the rest. The code is unchanged:confirmationTimeoutis still 60 minutes (PubkySwapsRouteService.swift:28), and the detached loop at :150-153 still stops there with a fixed 2 sat/vB claim, so this still needs fixing before the flag goes. - Swap restore runs once and is skipped if the wallet start fails (Bitkit/AppScene.swift:1268).
restorePubkySwaps()runs right afterstartWallet(completingBackupRestore: true)(AppScene.swift:1268), whether or not the node actually started.startWalletreturns early when offline and swallows start errors. In that caserestoreSwapsthrows (no node fornewAddress/listPayments), and the error is only logged.retryWalletStart(AppScene.swift:1300) and later wallet starts never call it again. In those cases the swap J2 covers is never claimed and waits for the provider's timeout instead. Funds return through that timeout, so this is not urgent. A retry once the node is running would close the gap. AppScene.swift:1267-1268 still callsrestorePubkySwaps()once afterstartWallet(completingBackupRestore: true), whether or not the node started.retryWalletStart(:1301) still never calls it. Tracking it in #1263 defers the point but does not fix or rebut it. - Old swap results overwrite an edited payment in the same sheet (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1381-1382). While a 20,000-sat commit waits, the user can return to Amount and continue with 30,000 sats in the same sheet. The unchanged presentation ID accepts the old result: success opens Bitcoin Sent displaying the new draft amount, while failure clears the replacement quote. Both branches reproduced with the extracted payment method and a suspended commit. Claude still sees it: The quote-ID check (SendConfirmationView.swift:1367-1376, 1393-1400) helps only once Q2 is stored.
onContinuesetswallet.sendAmountSatsto the new amount (SendAmountView.swift:377) beforequoteSwapRoutestores Q2, so Q1 still passesisSendCurrent()in that window. Q1's success therefore pushes Pending, and later Bitcoin Sent, with the edited amount. Q1's failure nils the route's quote (:1443), which makes Q2'sapp.paykitSwapRoute == routeguard drop the replacement quote silently. - Swap records survive a successful wallet wipe (Bitkit.xcodeproj/project.pbxproj:1244). After a routed payment, the in-app wallet wipe leaves its swap records behind. The pinned release writes contracts, destination addresses, amounts and signed claims to
core/pubky_swaps/*.json. Itswipe_all_databases()clears only activity and Blocktank tables, whileAppResetdeletes only LDK storage. Swap history and any saved claim preimage therefore survive a successful wipe. I checked rc14 (8fbf1d75):wipe_all_databases()in src/lib.rs:2092 still wipes only the activity and Blocktank databases. The swap store opened at lib.rs:524 is untouched, so the records still survive a wipe. This is tracked in synonymdev/bitkit-core#197, not fixed. - Going back to Amount during the swap commit can still pay a second swap (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1406-1424). 4bee733 removes the way back to Amount once the hold invoice is paid. Before that point, during
commit,decodeandsend(SendConfirmationView.swift:1406-1415), the user can still go back, request a fresh quote and pay a second swap. WhileisSubmittingPaymentis true, the header back button (showBackButton: !navigationPath.isEmpty, :336) and the amount tap (navigateToAmount, gated only bycanEditAmount, :253) both stay live. On Amount, Continue startsquoteSwapRoute, which asks every contact for an offer, so it usually takes longer than the commit to one provider. What follows, in order: 1. Q1'ssendreturns while the user is on Amount.isSendCurrent()still passes because Q1 is still the active quote, so.pendingis appended on top of Amount (:1422). 2.performSwapRoutedPaymentreturns, which releasesisSubmittingPayment. 3. Q2 arrives. Its guard (SendAmountView.swift:474-476) still passes, because nothing cleared Q1. It stores Q2 and appends.confirmon top of Pending (:409). 4. That Confirm's swipe is enabled. A swipe commits and pays a second swap, so the contact is paid twice. If Q2 lands before Q1'ssendreturns, the outcome is the same and quieter.isSendCurrent()fails, so Q1 is paid with no navigation and no toast. Q2's swipe unlocks as soon as Q1's call returns. I confirmed this by tracing the code above, andSwipeButtonruns its action in an unstructuredTaskthat is not cancelled when Confirm is popped. I did not run it: there is no provider or simulator here. It is non-blocking only because the route is behind the regtest debug flag. Two possible fixes: - Hide back and amount editing on Confirm while a swap payment is submitting. - HavequoteSwapRoutedrop its result when a payment is submitting or when the path no longer ends at Amount. Neither gate changed.showBackButton: !navigationPath.isEmpty(SendConfirmationView.swift:336) andcanEditAmount(:253) still ignoreisSubmittingPayment.quoteSwapRoute(SendAmountView.swift:474-476) still accepts Q2 while Q1 is the active quote, so Q2's Confirm can be pushed over Pending. Tracked in #1266, not fixed. - Fast hold-payment failures are missed before pending registration (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1415-1424). A hold payment that fails before submission returns now opens Pending without a failure resolution. The failure listener is installed in
driveSwapClaimafter the awaited send, and pending registration also happens afterward. I reproduced this with the extracted methods by deliveringpaymentFailedduring submission: HEAD retained the failed hash without a resolution, while d2621c6 opened Failure.send(bolt11:)is still awaited at SendConfirmationView.swift:1415, beforeaddPendingPaymentHash(:1419) and before thepaymentFailedlistener indriveSwapClaim(:1463). A failure delivered during the send is still missed. Tracked in #1267, not fixed.
jvsena42
left a comment
There was a problem hiding this comment.
Delta review of d57e2bc1b (bitkit-core 0.8.0-swaps.11 → 0.8.0-rc14): no findings.
v0.8.0-swaps.11is an ancestor ofv0.8.0-rc14, and the swaps merge (core#182) has a tree identical to swaps.11. So no swap code is dropped, and swaps saved under swaps.11 restore as before.- rc14 adds only regtest basic auth (core#201, #203): a trailing
auth: String? = nilon the regtest calls. TheCoreServicecallers compile unchanged, andbuild-localpasses. - The
Package.swiftchecksum matches the rc14BitkitCore.xcframework.zip.
coreyphillips/pubky-swap f45e204) against bitkit-docker's LND and the marketplace stack. I'll run it 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
# Conflicts: # Bitkit.xcodeproj/project.pbxproj # Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
|
Merged |
coreyphillips
left a comment
There was a problem hiding this comment.
Rechecked after 42 new commits (5aafcfd, 75fe13b, 265389e, and 39 more). Nothing blocking remains.
still open
- Background claim advancing stops after an hour, not at confirmation (Bitkit/Services/PubkySwapsRouteService.swift:21). The PR says the claim keeps advancing in the background until it confirms, but the loop stops after 60 minutes.
confirmationTimeoutis 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop inawaitClaimthen logs and exits. The claim fee is a fixed 2 sat/vB with no bump, so a claim that drops out of mempools after that hour is never rebroadcast. The provider settles the hold invoice as soon as it sees the claim. If the claim then does not confirm before the lock'stimeout_block_height, the provider can also refund its lockup. The user pays and the contact receives nothing. This cannot happen today: the flag is regtest-only and bitkit-core refuses mainnet commits. It does need to change before the flag goes, and the description currently says otherwise.confirmationTimeoutis still 60 minutes (PubkySwapsRouteService.swift:28), and the detached loop at :149-160 still logs and exits. The description now says "up to an hour" and defers the rest to #1262, which settles the description mismatch. The doc comments at :25 and :133-135 still promise rebroadcasts "until it confirms", and the behaviour itself is unchanged. - Swap restore runs once and is skipped if the wallet start fails (Bitkit/AppScene.swift:1268).
restorePubkySwaps()runs right afterstartWallet(completingBackupRestore: true)(AppScene.swift:1268), whether or not the node actually started.startWalletreturns early when offline and swallows start errors. In that caserestoreSwapsthrows (no node fornewAddress/listPayments), and the error is only logged.retryWalletStart(AppScene.swift:1300) and later wallet starts never call it again. In those cases the swap J2 covers is never claimed and waits for the provider's timeout instead. Funds return through that timeout, so this is not urgent. A retry once the node is running would close the gap.restorePubkySwaps()is still called once, right afterstartWallet(completingBackupRestore:)(AppScene.swift:1268), andretryWalletStart(:1301-1310) still does not call it. This is deferred to #1263, which is open; no code changed. - Old swap results overwrite an edited payment in the same sheet (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1381-1382). While a 20,000-sat commit waits, the user can return to Amount and continue with 30,000 sats in the same sheet. The unchanged presentation ID accepts the old result: success opens Bitcoin Sent displaying the new draft amount, while failure clears the replacement quote. Both branches reproduced with the extracted payment method and a suspended commit. Part of this is fixed. The quote-ID check (SendConfirmationView.swift:1372-1378, used at :1394-1400) stops an old result from navigating or clearing the quote once the edited payment's fresh quote is stored. The window between Continue on Amount and the fresh quote landing is still open. In it, SendAmountView.swift:377 has already set
wallet.sendAmountSatsto the edited amount, while the old quote is still the active one. The old send then passesisSendCurrent()and pushes Pending (:1422). Pending (SendPendingScreen.swift:110-111) and SendSuccess (:47-48) both show the edited amount. #1266 now records this window, so it closes with point 5. - Swap records survive a successful wallet wipe (Bitkit.xcodeproj/project.pbxproj:1244). After a routed payment, the in-app wallet wipe leaves its swap records behind. The pinned release writes contracts, destination addresses, amounts and signed claims to
core/pubky_swaps/*.json. Itswipe_all_databases()clears only activity and Blocktank tables, whileAppResetdeletes only LDK storage. Swap history and any saved claim preimage therefore survive a successful wipe. Nothing changed. The merge did not touchAppReset, and per the delta review core rc14 only adds regtest auth on top of swaps.11, sowipe_all_databases()still leavespubky_swaps/*.json. This is deferred to bitkit-core#197, which is open. - Going back to Amount during the swap commit can still pay a second swap (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1406-1424). 4bee733 removes the way back to Amount once the hold invoice is paid. Before that point, during
commit,decodeandsend(SendConfirmationView.swift:1406-1415), the user can still go back, request a fresh quote and pay a second swap. WhileisSubmittingPaymentis true, the header back button (showBackButton: !navigationPath.isEmpty, :336) and the amount tap (navigateToAmount, gated only bycanEditAmount, :253) both stay live. On Amount, Continue startsquoteSwapRoute, which asks every contact for an offer, so it usually takes longer than the commit to one provider. What follows, in order: 1. Q1'ssendreturns while the user is on Amount.isSendCurrent()still passes because Q1 is still the active quote, so.pendingis appended on top of Amount (:1422). 2.performSwapRoutedPaymentreturns, which releasesisSubmittingPayment. 3. Q2 arrives. Its guard (SendAmountView.swift:474-476) still passes, because nothing cleared Q1. It stores Q2 and appends.confirmon top of Pending (:409). 4. That Confirm's swipe is enabled. A swipe commits and pays a second swap, so the contact is paid twice. If Q2 lands before Q1'ssendreturns, the outcome is the same and quieter.isSendCurrent()fails, so Q1 is paid with no navigation and no toast. Q2's swipe unlocks as soon as Q1's call returns. I confirmed this by tracing the code above, andSwipeButtonruns its action in an unstructuredTaskthat is not cancelled when Confirm is popped. I did not run it: there is no provider or simulator here. It is non-blocking only because the route is behind the regtest debug flag. Two possible fixes: - Hide back and amount editing on Confirm while a swap payment is submitting. - HavequoteSwapRoutedrop its result when a payment is submitting or when the path no longer ends at Amount. Both ways back are still live during the commit. The header back button is shown on!navigationPath.isEmpty(SendConfirmationView.swift:336), andnavigateToAmount(:2102-2105) is gated only bycanEditAmount(:253-260), which ignoresisSubmittingPayment. The quote guard in SendAmountView.swift:473-475 still does not check for a submitting payment. Deferred to #1266, which is open. - Fast hold-payment failures are missed before pending registration (Bitkit/Views/Wallets/Send/SendConfirmationView.swift:1415-1424). A hold payment that fails before submission returns now opens Pending without a failure resolution. The failure listener is installed in
driveSwapClaimafter the awaited send, and pending registration also happens afterward. I reproduced this with the extracted methods by deliveringpaymentFailedduring submission: HEAD retained the failed hash without a resolution, while d2621c6 opened Failure. The order is unchanged.send(bolt11:)is at SendConfirmationView.swift:1415 andaddPendingPaymentHashat :1420. ThepaymentFailedlistener is only installed indriveSwapClaimat :1463, so a failure handled before the send returns is still dropped. Deferred to #1267, which is open.
Closes #1253
Twin: synonymdev/bitkit-android#1732
Dependency: synonymdev/bitkit-core#182
Refs:
This PR routes a Paykit contact payment through a Pubky Swaps provider when the contact only accepts on-chain bitcoin and the wallet holds only a spending balance.
Description
PUBKY_SWAPS_PROVIDERSadds extra candidates for testing, asked first.paykit-cap.*and refuses a reply not signed by the provider's key or the key its records delegate to.Route: Pubky Swaps · <provider>, with the whole swap cost in the fee row.PUBKY_SWAPS_ROUTES(environment or launch argument) on regtest debug builds only.paykit-swap-route.xmljourney from Android.Builds against the bitkit-core release
0.8.0-rc14, which includes synonymdev/bitkit-core#182 (it also links Network.framework for the swap transport). The router lets services such as Spark compete with the providers as route legs; this PR passes none, and the Spark leg comes in #1191.Why this journey
What it needs to ship
swap/1in Paykit: the signedpaykit-cap.*negotiation runs over iroh; over Paykit Encrypted Links it needs the additive paykit-rs SDK API for extension kinds (Design review: Paykit Capabilities, a general capability and intent layer for Paykit peers (answers Paykit Intents question 8) pubky/paykit-rs#229, §17).Out of Scope
wipe_all_databases(). Tracked in fix: wipe swap records with the wallet bitkit-core#197 (thread).Design
N/A — no design available.
Preview
A wallet with only a spending balance pays a contact who accepts only on-chain bitcoin. The router finds a Pubky Swaps provider among the wallet's contacts, the confirm screen shows the route, the quote and contract are signed, and the contact receives exactly 20,000 sats on chain (iPhone simulator (iOS 27.0), 2x speed, regtest).
ios-signed-swaps-route-2x.mp4
After a wallet restore from the seed, the swap the wallet left in flight before its claim is found at its provider and claimed to Savings, with the "Payment returned to Savings" notice (iPhone simulator (iOS 26.5), 2x speed, regtest; the phrase entry is not recorded). Recorded on bitkit-core
0.8.0-swaps.4, before restores took the node's payments.ios-restore-j22x.mp4
QA Notes
Setup
mainwith the marketplace, payment-requests and lnurl-pay profiles; coreyphillips/pubky-swap atf45e204run with--wallet lnd --no-homeserver, so it signs with its own key; bitkit-docker'sfixture-issueras the contact).xcodebuild build -project Bitkit.xcodeproj -scheme Bitkit -configuration Debug -destination 'platform=iOS Simulator,name=<simulator>' 'SWIFT_ACTIVE_COMPILATION_CONDITIONS=$(inherited) E2E_BUILD' ARCHS=arm64 E2E_BACKEND=local E2E_NETWORK=regtest E2E_HOMESERVER_PUBKY=$(./pubky-marketplace info | jq -r .homeserver_z32), installed on a simulator on the same Mac as bitkit-docker and launched withxcrun simctl launch <udid> to.bitkit -PUBKY_SWAPS_ROUTES=true. The simulator reaches the host loopback directly.curl -H 'Host: 127.0.0.1:23010' 127.0.0.1:23010/generate/channel(500,000 sats to spending, opened withxcrun simctl openurl <udid> lightning:<lnurl>); leave savings empty; create a Pubky profile; save the provider's and the fixture-issuer's pubkys as contacts (bitkit://contact?pubky=<pubky>)../pubky-marketplacerestarts the testnet container, restarthomeserver-proxy,paykit-server,fixture-issuerandrc56-peertoo: they share its network namespace, and profile creation fails with "complete Pubky auth flow" otherwise.Journeys
paykit-swap-route.xml— a spending-only wallet pays an on-chain-only contact through a Pubky Swaps provider found among its contacts, and the contact receives exactly 20,000 satspaykit-swap-restore.xml— a swap route left in flight before its claim is finished after a wipe and a restore from the recovery phrase: claimed to Savings, with the "Payment returned to Savings" noticeManual Tests
N/A
Automated Checks
PubkySwapsRouteServiceTests.swift— configured providers are asked first and each key onceContactsManagerTests.swift— swap restore waits for a running contact load, or its timeoutSendConfirmationViewTests.swift— swap warnings read the quote's debit from Spending; a late swap result reaches only the sheet and quote that started it; only a swap bitkit-core failed ends a sent hold paymentSendConfirmationViewTestsandPubkySwapsRouteServiceTestson an iPhone simulator (iOS 27.0) atd2621c6: pass;ContactsManagerTestspassed ate878153; the app builds for the simulator (arm64) atd2621c6Evidence
efc33faon a regtest iPhone simulator (iOS 27.0), bitkit-core0.8.0-swaps.9, provider coreyphillips/pubky-swapf45e204with--no-homeserver, and noPUBKY_SWAPS_PROVIDERS, so the provider came only from the wallet's contacts (the recording above):Route: Pubky Swaps · udi3...ez6yand a fee of ₿ 986 for 20,000 sats;hold invoice accepted,HTLC funded; awaiting client claimand, after one block,client claimed; hold invoice settled;66fed8c1caaea25aedd3151d679baa2944c1ee3e17beddddaab74ddee2461c0a, has one input, the provider's HTLC, and one output of exactly 0.0002 BTC to the contact's address; Spending went from ₿ 500,000 to ₿ 479,014.recovery_required. That is a fixture artefact, unrelated to the route; the missing fail-fast is tracked in bug: saved contact payment stalls instead of failing #1254.fab6cf8since finishes the send on the broadcast claim and adds the review fixes; it was built and unit-tested, not rerun on the simulator.83940d3, bitkit-core0.8.0-swaps.4, provider coreyphillips/pubky-swapmainwith--no-homeserver:-PUBKY_SWAPS_PROVIDERSnaming the provider, in case contacts had not reloaded when the restore ran.0.8.0-swaps.2and noPUBKY_SWAPS_PROVIDERS, so the provider came only from the wallet's contacts (the recording above). The build was7315045before its formatting-only change to the new service file:Route: Pubky Swaps · 5no8...pquoand a fee of ₿ 986 for 20,000 sats;HTLC funded; awaiting client claimand, after one block,client claimed; hold invoice settled;quote_expired, and nothing was paid.01198d3, on bitkit-core0.8.0-swaps.3, the unit test selection passes apart from threePaykitPaymentProofServiceTestscases whose billing periods ended on 1 Nov;masterfixed them in421e152("test: keep proof billing periods in the current month"), and this PR's CI runs on the merge withmaster.08cc333, on bitkit-core0.8.0-swaps.4, the unit test selection passes apart from the same three date-boundPaykitPaymentProofServiceTestscases; the restore after a wallet restore is not covered by a unit test or a device run yet.unit-tests.ymlpassed at7315045; SwiftFormat lint is clean on the changed files apart from findings already onmaster, andvalidate-translations.jspasses.Gaps
0.8.0-swaps.9, whose restore takes the node's payments.fab6cf8; the fixes since (claim broadcast, warnings, restore contacts, retry with a fresh quote, late results) are unit-tested only.Models used
claude-opus-5-5(reasoning:medium)claude-opus-5-5(reasoning:medium)