Skip to content

feat: pay contacts via pubky swaps - #1170

Open
ovitrif wants to merge 22 commits into
masterfrom
feat/pubky-swaps-route
Open

ovitrif wants to merge 22 commits into
masterfrom
feat/pubky-swaps-route

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • Routes a Paykit contact payment through a Pubky Swaps provider when the contact's only endpoints are on-chain and savings is empty (at or below dust) while spending is not, so a wallet holding only spending can still pay that contact. The user never picks a swap: Bitkit asks bitkit-core's router for the route.
  • Finds providers in the user's own contact graph: every saved contact is asked for its swap offer over iroh, and those that run no provider are skipped. PUBKY_SWAPS_PROVIDERS adds extra candidates for testing, asked first.
  • Takes only signed quotes and contracts: bitkit-core negotiates over paykit-cap.* and refuses a reply not signed by the provider's key or the key its records delegate to.
  • Offers only Spending as the source for such a contact, quotes the route on Continue, and shows it on the confirm screen as Route: Pubky Swaps · <provider>, with the whole swap cost in the fee row.
  • On swipe, commits the quote for the confirmed amount, pays the provider's hold invoice from spending with the chosen tags, and waits until bitkit-core has broadcast the claim of the provider's on-chain lock straight to the contact's address. The contact receives exactly the amount entered.
  • Once the hold invoice is paid, waits on it on the Pending screen, with no way back to a fresh quote that could pay twice. Shows Bitcoin Sent when the hold payment settles, which the provider does on seeing the claim, with the contact on its activity. A refused commit, a failed hold payment or a swap bitkit-core failed shows the payment error screen with Retry from Amount. The claim keeps advancing in the background for up to an hour while the app runs, and a claim not broadcast within 10 minutes leaves the send pending. A quote that returns for a replaced send or another amount is dropped, a swap result reaches only the sheet and quote that started it, and a Direct answer from the router moves the payment to Savings.
  • Behind PUBKY_SWAPS_ROUTES (environment or launch argument) on regtest debug builds only.
  • After a wallet restore from the seed, rebuilds the swap routes the wallet left in flight from each provider's listing, claims them to a fresh Savings address (the contact's address does not survive the restore), and tells the user the payment was returned to Savings. The restore passes the Lightning payments the node sent, so bitkit-core checks each listed lockup against what was paid. Swaps already on record are driven too, without that notice, since they keep the contact's address.
  • Ports the paykit-swap-route.xml journey 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

  • Paying a contact over a rail you don't hold is the journey Pubky Swaps supports today, in its Bitcoin form: a Lightning balance pays a contact who only accepts on-chain.
  • The USDT forms are blocked: Pubky Swaps is Bitcoin and Lightning only, so USDT needs a second, provider-trusted leg such as NEAR 1Click, which has no testnet (Paykit Capabilities §13.1).

What it needs to ship

  • Providers: mainnet Pubky Swaps providers with Lightning and on-chain liquidity among users' contacts. Asking every contact for an offer tells each of them you are looking for a swap (no amount or destination); published Capability Descriptors would replace the probing.
  • swap/1 in Paykit: the signed paykit-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).
  • Resume: if the app stops between paying and claiming, nothing resumes the claim until the next restore; the funds come back through the timeouts (the provider cancels its hold invoice, or refunds its own lock). A restore rebuilds swaps from providers' listings, within their retention window.
  • Fees: the fee row shows the provider's service fee, its on-chain cost and the wallet's claim fee under the Lightning "Instant" label, which reads as a Lightning fee. Lightning routing to the provider is on top. The claim fee rate is fixed at 2 sat/vB and should come from Bitkit's fee estimates.

Out of Scope

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

  • Chain, provider and contact: as in feat: pay contacts via pubky swaps bitkit-android#1732 (bitkit-docker main with the marketplace, payment-requests and lnurl-pay profiles; coreyphillips/pubky-swap at f45e204 run with --wallet lnd --no-homeserver, so it signs with its own key; bitkit-docker's fixture-issuer as the contact).
  • App: 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 with xcrun simctl launch <udid> to.bitkit -PUBKY_SWAPS_ROUTES=true. The simulator reaches the host loopback directly.
  • Wallet: a new wallet; open the LNURL channel from curl -H 'Host: 127.0.0.1:23010' 127.0.0.1:23010/generate/channel (500,000 sats to spending, opened with xcrun 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>).
  • If ./pubky-marketplace restarts the testnet container, restart homeserver-proxy, paykit-server, fixture-issuer and rc56-peer too: they share its network namespace, and profile creation fails with "complete Pubky auth flow" otherwise.

Journeys

  • J1: new 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 sats
  • J2: new paykit-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" notice

Manual Tests

N/A

Automated Checks

  • added PubkySwapsRouteServiceTests.swift — configured providers are asked first and each key once
  • updated ContactsManagerTests.swift — swap restore waits for a running contact load, or its timeout
  • updated SendConfirmationViewTests.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 payment
  • ran SendConfirmationViewTests and PubkySwapsRouteServiceTests on an iPhone simulator (iOS 27.0) at d2621c6: pass; ContactsManagerTests passed at e878153; the app builds for the simulator (arm64) at d2621c6

Evidence

  • J1 at efc33fa on a regtest iPhone simulator (iOS 27.0), bitkit-core 0.8.0-swaps.9, provider coreyphillips/pubky-swap f45e204 with --no-homeserver, and no PUBKY_SWAPS_PROVIDERS, so the provider came only from the wallet's contacts (the recording above):
    • the confirm screen showed Route: Pubky Swaps · udi3...ez6y and a fee of ₿ 986 for 20,000 sats;
    • the provider's record of the swap holds the contract signed over the wallet's commit, signed by its own key;
    • the provider logged hold invoice accepted, HTLC funded; awaiting client claim and, after one block, client claimed; hold invoice settled;
    • the claim, 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.
    • The contact was paid as a pubky entered on the Send screen: the fixture-issuer gets a new pubky when it restarts, so the saved contact pointed at its old identity and ended in 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.
    • fab6cf8 since finishes the send on the broadcast claim and adds the review fixes; it was built and unit-tested, not rerun on the simulator.
  • J2 on a regtest iPhone simulator (iOS 26.5) at 83940d3, bitkit-core 0.8.0-swaps.4, provider coreyphillips/pubky-swap main with --no-homeserver:
    • the swap was started, the provider logged "HTLC funded; awaiting client claim", and the app was killed before any block; the wallet was then wiped and restored from its throwaway regtest recovery phrase;
    • the restore listed the swap at the provider, the claim went out after one block, and the provider logged "client claimed; hold invoice settled";
    • Savings went from 0 to ₿ 20,000, the "Payment returned to Savings" notice showed, and the swap's activity turned to Sent.
    • The notice showed before the "Received Bitcoin" sheet, which iOS opens once the claim confirms.
    • The app was launched with -PUBKY_SWAPS_PROVIDERS naming the provider, in case contacts had not reloaded when the restore ran.
  • J1 earlier on a regtest iPhone simulator (iOS 26.5), before quotes were signed, with bitkit-core 0.8.0-swaps.2 and no PUBKY_SWAPS_PROVIDERS, so the provider came only from the wallet's contacts (the recording above). The build was 7315045 before its formatting-only change to the new service file:
    • the amount screen offered only Spending; the confirm screen showed Route: Pubky Swaps · 5no8...pquo and a fee of ₿ 986 for 20,000 sats;
    • the provider logged HTLC funded; awaiting client claim and, after one block, client claimed; hold invoice settled;
    • the claim transaction has one input, the provider's HTLC, and one output of exactly 0.0002 BTC to the contact's address;
    • Bitcoin Sent showed ₿ 20,000, and the activity list shows the Lightning payment of ₿ 20,986.
  • A second take of J1 paid the same way.
  • An earlier run whose swipe came over two minutes after the quote ended in the payment error screen: the provider refused the commit with quote_expired, and nothing was paid.
  • At 01198d3, on bitkit-core 0.8.0-swaps.3, the unit test selection passes apart from three PaykitPaymentProofServiceTests cases whose billing periods ended on 1 Nov; master fixed them in 421e152 ("test: keep proof billing periods in the current month"), and this PR's CI runs on the merge with master.
  • At 08cc333, on bitkit-core 0.8.0-swaps.4, the unit test selection passes apart from the same three date-bound PaykitPaymentProofServiceTests cases; the restore after a wallet restore is not covered by a unit test or a device run yet.
  • The unit and UI test selection of unit-tests.yml passed at 7315045; SwiftFormat lint is clean on the changed files apart from findings already on master, and validate-translations.js passes.

Gaps

  • J2 was not rerun on bitkit-core 0.8.0-swaps.9, whose restore takes the node's payments.
  • J1 was not rerun after fab6cf8; the fixes since (claim broadcast, warnings, restore contacts, retry with a fresh quote, late results) are unit-tested only.
  • The provider's contact entry has no Pubky profile, so the route shows its key rather than a name.
  • A quote that expires on the confirm screen needs a trip back to the amount screen for a new one; the error screen's Retry was not tried.
  • Unlike Android, Bitcoin Sent opens the Lightning payment's activity, since the claim transaction is not the wallet's own.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: medium)
  • Implementation: claude-opus-5-5 (reasoning: medium)
  • Review: Not performed

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.
@ovitrif ovitrif self-assigned this Oct 30, 2026
@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 01198d3: moves to bitkit-core 0.8.0-swaps.3, whose Paykit router takes service legs (synonymdev/bitkit-core#184).

  • Passes no service legs yet, so routing behaves as before; Arkade and Spark legs come in their own PRs on top of the Arkade and Spark work.
  • Handles the router's new Service route, which cannot be returned while no legs are passed.
  • The unit test selection passes apart from three date-bound PaykitPaymentProofServiceTests cases that master fixed in 421e152.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 08cc333: after a wallet restore, rebuilds in-flight swap routes from each provider's listing (bitkit-core 0.8.0-swaps.4, pubky_swaps_restore from synonymdev/bitkit-core#182).

  • Restored swaps claim to a fresh Savings address, since the contact's address isn't in the listing. Once a claim confirms, a toast tells the user the payment was returned to Savings. The two strings are added in every locale.
  • The unit test selection passes apart from the three date-bound proof tests master fixed in 421e152; translations validate. Not covered yet: a unit test or device run of the restore path.

@ovitrif

ovitrif commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 83940d3: adds the paykit-swap-restore.xml journey (J2), recorded on an iOS 26.5 simulator: a swap left in flight before its claim is finished after a wipe and restore, claimed to Savings, and the "Payment returned to Savings" notice shows. The recording and evidence are in the description.

@ovitrif ovitrif changed the title feat: pay contacts through pubky swaps feat: pay contacts via pubky swaps Nov 3, 2026
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.
@ovitrif
ovitrif marked this pull request as ready for review November 9, 2026 22:26
@ovitrif
ovitrif requested review from a team, ben-kaufman and coreyphillips and removed request for a team November 9, 2026 22:26
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T22:29:04.155691Z fab6cf8 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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 coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:20 now checks the toast right after the first block, before the provider log and the second block. journeys/README.md:255-257 records the iOS order. This matches AppScene.swift:1294-1295, which shows the toast when awaitClaim returns 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-146 now starts advanceInBackground, so a lockup that confirms late is still claimed. SendConfirmationView.swift:1440-1450 pushes .pending on 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.md is deleted, and git diff origin/master...HEAD -- changelog.d is 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). isDefiniteSwapFailure returns false for it, so SendConfirmationView.swift:1440-1450 shows Pending instead of failure with Retry. For a Lightning hash, SendPendingScreen offers only Details and Close (its retry buttons need an on-chain attempt, which loads only when paymentHash == nil). It goes to failure only on the hold payment's own paymentFailed. In core v0.8.0-swaps.9 claim.rs, Failed with 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. confirmationTimeout is 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop in awaitClaim then 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's timeout_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 after startWallet(completingBackupRestore: true) (AppScene.swift:1268), whether or not the node actually started. startWallet returns early when offline and swallows start errors. In that case restoreSwaps throws (no node for newAddress/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) before quoteSwapRoute runs. While that quote is in flight, or after it fails (.none or a network error), app.paykitSwapRoute?.quote still holds the committed quote. The old result then passes isSendCurrent(), and Bitcoin Sent shows the edited amount (SendSuccess.swift:47). Comparing quote.deliveredSat with wallet.sendAmountSats in isSendCurrent, 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. Its wipe_all_databases() clears only activity and Blocktank tables, while AppReset deletes 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.
@ovitrif

ovitrif commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

@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 coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 send returns. The claim only starts after that, in driveSwapClaim (:1424). A settlement needs the preimage that the claim reveals, so paymentSuccessful always 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 pendingPaymentHashes before any claim exists (SendConfirmationView.swift:1420), so an early settlement can no longer arrive unregistered. The pending screen, which is already shown, resolves through sendSheetPendingResolution (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. confirmationTimeout is 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop in awaitClaim then 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's timeout_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: confirmationTimeout is 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 after startWallet(completingBackupRestore: true) (AppScene.swift:1268), whether or not the node actually started. startWallet returns early when offline and swallows start errors. In that case restoreSwaps throws (no node for newAddress/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, and retryWalletStart (: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.sendAmountSats to 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. Its wipe_all_databases() clears only activity and Blocktank tables, while AppReset deletes 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 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
    • deliveredSat must 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;
    • fundingSources returns 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

@ovitrif

ovitrif commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips bd19f96 only moves this PR to bitkit-core 0.8.0-swaps.10. That prerelease is synonymdev/bitkit-core#182 after its master merge, so the published XCFramework matches the merged Swift wrapper. No app code changed since your approval at 4bee733, so this needs your approval again only for the version bump.

@ovitrif

ovitrif commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips one more version step: bitkit-core 0.8.0-swaps.11 adds a fix from Ben's review of synonymdev/bitkit-core#182, which refuses a new swap whose id is already on file. Still no app code changed since 4bee733.

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. confirmationTimeout is 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop in awaitClaim then 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's timeout_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. confirmationTimeout is still 60 minutes (PubkySwapsRouteService.swift:28), and advanceInBackground still 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 after startWallet(completingBackupRestore: true) (AppScene.swift:1268), whether or not the node actually started. startWallet returns early when offline and swallows start errors. In that case restoreSwaps throws (no node for newAddress/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 calls restorePubkySwaps() once, whatever startWallet did. 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's send returns after Continue on Amount but before Q2 lands. Q1 is then still the active quote, so Pending (:1422) and Bitcoin Sent show wallet.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. Its wipe_all_databases() clears only activity and Blocktank tables, while AppReset deletes only LDK storage. Swap history and any saved claim preimage therefore survive a successful wipe. The new pin's wipe_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, decode and send (SendConfirmationView.swift:1406-1415), the user can still go back, request a fresh quote and pay a second swap. While isSubmittingPayment is true, the header back button (showBackButton: !navigationPath.isEmpty, :336) and the amount tap (navigateToAmount, gated only by canEditAmount, :253) both stay live. On Amount, Continue starts quoteSwapRoute, which asks every contact for an offer, so it usually takes longer than the commit to one provider. What follows, in order: 1. Q1's send returns while the user is on Amount. isSendCurrent() still passes because Q1 is still the active quote, so .pending is appended on top of Amount (:1422). 2. performSwapRoutedPayment returns, which releases isSubmittingPayment. 3. Q2 arrives. Its guard (SendAmountView.swift:474-476) still passes, because nothing cleared Q1. It stores Q2 and appends .confirm on 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's send returns, 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, and SwipeButton runs its action in an unstructured Task that 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. - Have quoteSwapRoute drop 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) and showBackButton (:336) still ignore isSubmittingPayment, 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 driveSwapClaim after the awaited send, and pending registration also happens afterward. I reproduced this with the extracted methods by delivering paymentFailed during 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 both addPendingPaymentHash (:1420) and the paymentFailed listener in driveSwapClaim (:1463).

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in commit_route before the app pays the hold invoice, so a refusal moves no funds and lands on the existing "refused commit → Retry from Amount" path. In restore_swaps a known record still continues before create.
  • What it leaves alone: SwapRecord and 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. No swaps.10 or swaps.11 Android 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

@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

@coreyphillips moved bitkit-core from the 0.8.0-swaps.11 prerelease to the 0.8.0-rc14 release in d57e2bc, now that synonymdev/bitkit-core#182 is merged. Only the pin changed, so a re-approval is needed only because of the dependency bump.

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. confirmationTimeout is 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop in awaitClaim then 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's timeout_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: confirmationTimeout is 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 after startWallet(completingBackupRestore: true) (AppScene.swift:1268), whether or not the node actually started. startWallet returns early when offline and swallows start errors. In that case restoreSwaps throws (no node for newAddress/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 calls restorePubkySwaps() once after startWallet(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. onContinue sets wallet.sendAmountSats to the new amount (SendAmountView.swift:377) before quoteSwapRoute stores Q2, so Q1 still passes isSendCurrent() 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's app.paykitSwapRoute == route guard 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. Its wipe_all_databases() clears only activity and Blocktank tables, while AppReset deletes 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, decode and send (SendConfirmationView.swift:1406-1415), the user can still go back, request a fresh quote and pay a second swap. While isSubmittingPayment is true, the header back button (showBackButton: !navigationPath.isEmpty, :336) and the amount tap (navigateToAmount, gated only by canEditAmount, :253) both stay live. On Amount, Continue starts quoteSwapRoute, which asks every contact for an offer, so it usually takes longer than the commit to one provider. What follows, in order: 1. Q1's send returns while the user is on Amount. isSendCurrent() still passes because Q1 is still the active quote, so .pending is appended on top of Amount (:1422). 2. performSwapRoutedPayment returns, which releases isSubmittingPayment. 3. Q2 arrives. Its guard (SendAmountView.swift:474-476) still passes, because nothing cleared Q1. It stores Q2 and appends .confirm on 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's send returns, 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, and SwipeButton runs its action in an unstructured Task that 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. - Have quoteSwapRoute drop 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) and canEditAmount (:253) still ignore isSubmittingPayment. 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 driveSwapClaim after the awaited send, and pending registration also happens afterward. I reproduced this with the extracted methods by delivering paymentFailed during submission: HEAD retained the failed hash without a resolution, while d2621c6 opened Failure. send(bolt11:) is still awaited at SendConfirmationView.swift:1415, before addPendingPaymentHash (:1419) and before the paymentFailed listener in driveSwapClaim (:1463). A failure delivered during the send is still missed. Tracked in #1267, not fixed.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta review of d57e2bc1b (bitkit-core 0.8.0-swaps.11 → 0.8.0-rc14): no findings.

  • v0.8.0-swaps.11 is an ancestor of v0.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? = nil on the regtest calls. The CoreService callers compile unchanged, and build-local passes.
  • The Package.swift checksum matches the rc14 BitkitCore.xcframework.zip.

⚠️ Device gate: still not run — J1/J2 need the pubky-swap provider (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
@ovitrif

ovitrif commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Merged master in 6f1859b. The only conflicts were the package pins: bitkit-core stays on 0.8.0-rc14, which contains master's 0.8.0-rc13, and paykit, ldk-node and vss take master's versions. No code changed, so a re-approval is needed only because of the base merge.

@coreyphillips coreyphillips left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. confirmationTimeout is 60 minutes (PubkySwapsRouteService.swift:21), and the detached loop in awaitClaim then 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's timeout_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. confirmationTimeout is 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 after startWallet(completingBackupRestore: true) (AppScene.swift:1268), whether or not the node actually started. startWallet returns early when offline and swallows start errors. In that case restoreSwaps throws (no node for newAddress/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 after startWallet(completingBackupRestore:) (AppScene.swift:1268), and retryWalletStart (: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.sendAmountSats to the edited amount, while the old quote is still the active one. The old send then passes isSendCurrent() 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. Its wipe_all_databases() clears only activity and Blocktank tables, while AppReset deletes only LDK storage. Swap history and any saved claim preimage therefore survive a successful wipe. Nothing changed. The merge did not touch AppReset, and per the delta review core rc14 only adds regtest auth on top of swaps.11, so wipe_all_databases() still leaves pubky_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, decode and send (SendConfirmationView.swift:1406-1415), the user can still go back, request a fresh quote and pay a second swap. While isSubmittingPayment is true, the header back button (showBackButton: !navigationPath.isEmpty, :336) and the amount tap (navigateToAmount, gated only by canEditAmount, :253) both stay live. On Amount, Continue starts quoteSwapRoute, which asks every contact for an offer, so it usually takes longer than the commit to one provider. What follows, in order: 1. Q1's send returns while the user is on Amount. isSendCurrent() still passes because Q1 is still the active quote, so .pending is appended on top of Amount (:1422). 2. performSwapRoutedPayment returns, which releases isSubmittingPayment. 3. Q2 arrives. Its guard (SendAmountView.swift:474-476) still passes, because nothing cleared Q1. It stores Q2 and appends .confirm on 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's send returns, 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, and SwipeButton runs its action in an unstructured Task that 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. - Have quoteSwapRoute drop 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), and navigateToAmount (:2102-2105) is gated only by canEditAmount (:253-260), which ignores isSubmittingPayment. 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 driveSwapClaim after the awaited send, and pending registration also happens afterward. I reproduced this with the extracted methods by delivering paymentFailed during submission: HEAD retained the failed hash without a resolution, while d2621c6 opened Failure. The order is unchanged. send(bolt11:) is at SendConfirmationView.swift:1415 and addPendingPaymentHash at :1420. The paymentFailed listener is only installed in driveSwapClaim at :1463, so a failure handled before the send returns is still dropped. Deferred to #1267, which is open.

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.

feat: pay on-chain contacts from spending

5 participants