Skip to content

fix: open confirm for requests with a fixed amount - #1198

Open
jvsena42 wants to merge 4 commits into
masterfrom
fix/scan-amount-to-confirm
Open

jvsena42 wants to merge 4 commits into
masterfrom
fix/scan-amount-to-confirm

Conversation

@jvsena42

@jvsena42 jvsena42 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Related: #1014 (open) fixes the same Paykit routing root cause with a wider scope. The Paykit commit here is separate so it can be dropped if #1014 merges first.

This PR:

  1. Opens Confirm directly when an on-chain BIP21 request with an amount is scanned, pasted or opened as a link
  2. Opens Confirm directly for a fixed-price Payment Request that can only be paid in Bitcoin, instead of a locked Amount screen

Description

  • Routes an on-chain BIP21 request with an amount to Confirm instead of Amount, because the amount is fixed and the balance is already checked when it is scanned. Zero-amount requests and contact payments without an incoming request still open Amount.
  • Runs the saved automatic coin selection on Confirm when it was reached without Amount, so fees and inputs match the Amount route. Manual coin selection still opens UTXO selection on swipe. If coin selection fails, for example when the amount plus fee exceeds the balance, Confirm shows the error and opens Amount. The swipe stays disabled until those coins are chosen and, on every on-chain Confirm, until the fee calculation finishes, so a swipe can no longer skip the fee warnings or record a zero fee.
  • Resets the amount, max-send flag and coin choices from an earlier recipient when a scan inside the send sheet opens Confirm directly, so the invoice amount is the one sent.
  • Binds the issuer's Bitcoin terms when a Payment Request priced in another currency (or with fixed conversion rates) cannot be paid in USDT, so it opens Confirm after preparation. Before this, requiresAssetSelection sent every converted request to Amount while no terms were bound, even with USDT off.

Motivation for the Paykit fix, from Slack:

A merchant/Paykit payment request priced in dollars (a lock priced at $2.50), with USDT not enabled, paying in BTC. The app showed a loading spinner on the swipe slider for a few seconds, then landed on the Amount screen with most of the UI disabled, showing the BTC amount for the dollar value. Requests priced in BTC go to Confirm as expected.

Reproduced on iOS with the fixture issuer: the preparing confirmation sheet resolved its route to .amount because the converted request had no bound terms.

Out of Scope

  • Bitkit/Views/Subscriptions, SendSheet embedded retry: initial subscription payments with converted terms keep their Amount review.
  • Requests offering both LNURL and on-chain keep Amount, because LNURL confirmation has no funding selector.
  • Lightning bolt11 and USDT routing are unchanged.
  • Paykit coin selection on Confirm: incoming requests keep their current coin handling (fix: confirm fixed Paykit requests directly #1014 covers it).

Design

N/A — no UI changes. Existing Amount and Confirm screens are reused.

Preview

BIP21 with amount opens Confirm and sends 10 000 sats:

01-bip21-amount-opens-confirm-and-sends.mp4

Editing the amount from Confirm, then sending:

01b-bip21-amount-edit-amount-from-confirm.mp4

Zero-amount BIP21 still opens Amount:

02-bip21-no-amount-opens-amount.mp4

Full-balance BIP21: coin selection fails on Confirm and Amount opens with Continue disabled:

04-bip21-full-balance-falls-back-to-amount.mp4

USD-priced Payment Request ($2.50), USDT off, before the fix (locked Amount):

06-before-fix-usd-priced-request-opens-amount.mp4

Same request after the fix (Confirm, 2 500 sats, Savings):

05-usd-priced-request-opens-confirm.mp4

QA Notes

Setup

  • BIP21 journeys: bitkit-docker on local regtest, E2E_BUILD debug build on a fresh simulator, wallet funded with 500 000 sats from the local bitcoind. Clipboard set with xcrun simctl pbcopy.
  • Payment Request journeys: plain Debug build (staging Pubky and staging regtest), a wallet with a Pubky profile and Savings funds, USDT off, and tools/paykit-fixture-sender from bitkit-e2e-tests linked as a contact. Requests sent with ./sender send <payer> --asset usd --amount 2.5 --rate btc=0.00001 --address <unused bcrt1q> and the usd/btc presets.

Journeys

  • J1: new scan-onchain-amount-to-confirm.xml — pasted BIP21 with an amount opens Confirm, the amount can still be edited, and the send completes
  • J2: new scan-onchain-no-amount-to-amount.xml — pasted BIP21 without an amount still opens Amount
  • J3: updated onchain-accepted-result.xml — the fixed-amount bitcoin: link opens Confirm directly
  • J4: updated rbf-own-send.xml — the bitcoin: link with an amount opens the review screen directly
  • J5: updated fixed-price-bitcoin.xml — USD-priced and rate-priced requests open confirmation after preparation, without Amount

Manual Tests

N/A

Automated Checks

  • added PaymentNavigationHelperTests.swift — on-chain BIP21 with an amount opens Confirm from both entry points, zero amount opens Amount, contact payments keep Amount, incoming on-chain requests open Confirm
  • added PaykitPaymentRequestServiceTests.swift — converted requests bind Bitcoin terms only when USDT cannot pay them, and route to Confirm once bound
  • updated SendConfirmationSwipeTests.swift — the swipe stays disabled while on-chain coins or the fee are still being prepared
  • review round 1 (cc8fce9, edefd08): SendConfirmationSwipeTests.swift, SendConfirmationLifecycleTests.swift, PaymentNavigationHelperTests.swift and SendSheetLifecycleTests.swift re-run, 63 passed, 0 failed
  • ran 14 send, scan, QuickPay and Paykit test classes locally on iPhone 17 (iOS 26.5), including PaymentNavigationHelperTests.swift, PaykitPaymentRequestServiceTests.swift, SendConfirmationLifecycleTests.swift, SendSheetLifecycleTests.swift and ScannerManagerTests.swift: 485 passed, 0 failed. The full suite was not run locally; its integration tests start a node and were too slow here. SendConfirmationLifecycleTests first failed two wallet-switch cases because the new coin selection also ran during a Savings/Spending switch; it now skips while a switch is running.

Evidence

  • J1, J2: driven on a fresh iPhone 17 simulator, local regtest. J1 sent 10 000 sats; the log shows Autopilot branch-and-bound selection on Confirm. Re-pasting a 20 000 sat BIP21 inside the same sheet after entering 10 000 on a zero-amount request showed 20 000 on Confirm.
  • Review round 1 (cc8fce9), fresh wallet on local regtest: the Confirm sheet appeared at 14:08:28.000, coins were chosen at .560 and the 143-sat fee at .572, and the swipe then sent 10 000 sats with the selected coin (tx d313a2…, 143 sats fee in the mempool). Video in the PR comments.
  • Review round 1 (edefd08): Amount → Continue → immediate swipe; coins chosen at 14:20:49.933, the 143-sat fee at .974, and the send at 50.955 (tx 2df859…, 143 sats fee). Video in the PR comments.
  • Root Confirm (deep link): no back button, tapping the amount pushes Amount, Continue returns to Confirm with the edited amount.
  • J5: driven with the fixture issuer: $2.50 at 0.00001 → 2 500 sats, usd preset → 21 000 sats, btc preset → 50 000 sats. All opened Payment Request confirmation with Savings selected and no Amount step. The same $2.50 request with the routing fix removed reproduced the locked Amount screen.

Gaps

  • e2e: send.e2e.ts @send_1 and @send_2 are updated in test: expect review screen for bip21 with an amount bitkit-e2e-tests#290, on the same-named branch the gate picks up; not driven locally.
  • USDT request with an amount: the wallet has no USDT, so the auto-quote fails with "Not enough USDT" and stays on Amount. The review step was not reached locally.
  • Funding-source switching on Confirm for a Payment Request was not driven: the test wallet has no Spending balance.
  • Hardware wallet send with a BIP21 amount was not driven (no Trezor emulator run).
  • J3 and J4 were not driven end to end; only their changed first steps (link → Confirm) were checked.
  • The node-not-running case relies on the existing sync overlay and post-sync balance check; it was not driven.
  • bitkit-e2e-tests send.e2e.ts expects Amount after a BIP21 with an amount in @send_1 (unified1, manual entry) and @send_2 (unified2, Lightning-insufficient fallback to on-chain). Both need updating for this change.

Models used

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

🤖 Generated with Claude Code

jvsena42 and others added 2 commits November 3, 2026 10:31
A BIP21 request with an amount now opens Confirm instead of Amount when
scanned, pasted or opened as a link. Confirm runs the saved automatic coin
selection when it is reached without Amount and opens Amount if selection
fails. In-sheet scans that open Confirm directly drop the amount and coin
choices left by an earlier recipient. Zero-amount requests and contact
payments still open Amount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Payment Request priced in another currency, or with fixed conversion
rates, was always routed to Amount while no terms were bound, even when
USDT could not pay it. The preparing confirmation then resolved to a locked
Amount screen. Bind the issuer's Bitcoin terms when USDT is not a real
choice so the request opens Confirm after preparation. Requests that also
offer LNURL and on-chain keep Amount, since LNURL confirmation has no
funding selector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ovitrif ovitrif added this to the 3.2.0 milestone Nov 3, 2026
@ovitrif

ovitrif commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Added to the 3.2.0 milestone with its Android twin synonymdev/bitkit-android#1763: this fix was opened after the 3.1.0 cut and closes no issue, so this PR carries the milestone itself.

@greptile-apps

greptile-apps Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new blocking issue was found.

Summary

Fixed-amount Bitcoin requests now open Confirm directly.

  • On-chain requests with an amount open Confirm directly.
  • Bitcoin-only fixed-price requests open Confirm after preparation.

Previous finding #1 is addressed for the new direct-Confirm route. jvsena42 deferred the older Amount-to-Confirm fee window as a separate issue.

Reviews (2) · Last reviewed commit: "fix: keep swipe disabled while direct co..." · Reviewed by Greptile

@jvsena42 jvsena42 self-assigned this Nov 3, 2026
@jvsena42
jvsena42 requested review from a team, ben-kaufman and pwltr and removed request for a team November 3, 2026 13:55
@jvsena42
jvsena42 marked this pull request as draft November 3, 2026 13:56
@jvsena42
jvsena42 requested a review from talosmachina November 3, 2026 13:57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@talosmachina talosmachina 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.

1 finding (1 P1). Routes a BIP21 request with an amount, and a converted Payment Request that only Bitcoin can pay, straight to Confirm. Reviewed f751a46, full tier, statically: iOS does not build on this reviewer's Linux host, so nothing was compiled or run here.

What I checked, and 5 candidates I ruled out

Read: the full diff, plus PaymentNavigationHelper (all three route functions), AppViewModel.handleScannedData (contact and BIP21 branches), SendSheet setup, cleanup and performPaymentValidationAfterSync, SendConfirmationView task, fee calculation and wallet switch, SendAmountView.onContinue, WalletViewModel.setUtxoSelection
Call sites traced: appropriateSendRoute (ScannerManager, SendEnterManuallyView, AppScene), openPaymentSheet (scanner, clipboard, deep link, Shop), paykitPaymentTerms readers
CI: lint, translations and build-local green; unit tests, integration tests and the @ios_gate e2e shard still running at review time

Ruled out

  • Stale amount from an earlier recipient on the deep link, clipboard and Shop paths: each opens a new sheet whose setUpSend calls wallet.resetSendState, and Confirm's ensureSendAmountFromScannedInvoicesIfNeeded then fills the invoice amount. Only the in-sheet scan reuses wallet state, and that is the path prepareFixedAmountOnchainSend covers. Manual entry resets before decoding.
  • Hardware wallet send skips the preview Amount used to build: calculateTransactionFee calls hwSend.preparePreview on Confirm itself, and the new coin selection guards on !hwSend.isActive.
  • Bound Bitcoin terms rejected later by the amount checks: fixedBitcoinPaymentTerms returns exactly request.payment(using:) for the same method, so acceptsPaymentAmount(..., paymentTerms:) and returnToAmountReviewIfNeeded compare equal values; an expired quote is refused by isValid and falls back to Amount.
  • Terms bound while USDT can still pay: the guard requires USDT off or no USDT endpoint, and the binding only runs when paykitUsesUsdt is false; the tests cover both sides.
  • Swipe racing the new coin selection: real, but already raised in Greptile's thread on SendConfirmationView.swift:428, so not repeated here.

Merge confidence: 3/5, one P1 (the @ios_gate e2e spec still expects the Amount step), and unit and e2e CI had not finished.

Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the review runtime does not report an effort setting)


/// A BIP21 request with an amount skips Amount; the balance was already checked when it was scanned.
/// Contact payments keep Amount because it records the reviewed amount for the payment.
static func onchainInvoiceRoute(app: AppViewModel) -> SendRoute {

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.

P1: the @ios_gate e2e spec still expects Amount here, and no matching e2e branch exists

With this route, a BIP21 with an amount opens Confirm, but bitkit-e2e-tests test/specs/send.e2e.ts on main still drives the old screen in two @ios_gate cases, which is the shard this repo's e2e-tests.yml runs on every PR:

  • @send_1: after typing unified1 (bitcoin:...?amount=0.0001) it taps AddressContinue, then verifyAmountToSend(10_000) and waits for ContinueAmount.
  • @send_2: unified2 has a 200 000 sat Lightning invoice the wallet cannot pay, so handleScannedData falls back to on-chain and clears scannedLightningInvoice; this function then returns .confirm, and the spec's tap('ContinueAmount') has nothing to tap.

determine-e2e-branch looks for a bitkit-e2e-tests branch named after this PR's head (fix/scan-amount-to-confirm), and that branch does not exist, so the run falls back to main's specs. The PR's Gaps section already names both cases; the fix is a companion e2e branch with the same name, so this PR's gate runs the updated specs instead of failing on them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, this would fail the gate. The companion branch fix/scan-amount-to-confirm now exists in bitkit-e2e-tests, with PR synonymdev/bitkit-e2e-tests#290 (1b28071):

  • @send_1: after AddressContinue, the spec expects the review screen at 10 000. iOS pops back to manual entry with one Back. Android goes through Amount first.
  • @send_2: unified2 opens the review screen at 20 000 directly. The AssetButton-savings / ContinueAmount steps are removed.

Prettier and tsc are clean for the spec. Leaving this open until the @ios_gate shard has run against that branch, since it was not driven locally.

@jvsena42

jvsena42 commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Direct Confirm from bitcoin:…?amount=0.0001 after cc8fce9 (local regtest, fresh wallet with 500 000 sats): Confirm opens, the swipe stays disabled until Branch and Bound coin selection and the 143-sat fee finish (about 0.57 s after the sheet appears), then the swipe sends 10 000 sats with the selected coin.

direct-confirm-swipe.mp4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jvsena42

jvsena42 commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Amount → Confirm after edefd08 (local regtest): Continue, then an immediate swipe. Coins were chosen at 14:20:49.933 and the 143-sat fee at .974, and the send went out at 50.955. Tx 2df859… reached the mempool with 143 sats fee.

amount-confirm-early-swipe.mp4

// A swipe before the fee is known would skip the fee warnings and record a zero fee.
isCalculatingOnchainFee = true
defer {
if feeCalculationId == requestId {

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.

If the funding source changes to Spending or hardware while a Savings fee calculation is suspended, the newer calculateTransactionFee increments feeCalculationId but returns through a path that never clears isCalculatingOnchainFee. The older call then skips this defer, leaving the swipe disabled on the new funding source. Could we make every new calculation take ownership of and reset the flag, including the Lightning and hardware paths?

@pwltr pwltr 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.

Reviewed commit edefd08 through independent general and security review, targeted finding verification and peer comparison. The Debug simulator build, lint and 39 scoped tests passed.

[P2] Switching funding sources during fee calculation can leave sending disabled

If a Savings fee calculation is still running when the user switches to Spending or hardware, the newer calculation advances feeCalculationId without clearing isCalculatingOnchainFee. The old calculation then refuses to clear it because its ID is stale. The retained flag disables both the swipe and manual submission. Clear or transfer the loading state when a calculation is superseded, including early-return paths. This is the same issue already reported by ben-kaufman, confirmed separately by source tracing.

[P3] Direct confirmation skips the app’s minimum-amount check

A positive plain BIP21 amount now routes directly to Confirm, bypassing Amount’s 547-sat minimum. With manual coin selection, neither Confirm nor the picker restores that validation gate before submission. Apply the same minimum validation to direct confirmation. This confirms the missing application check; it does not establish how the native library will reject a sub-minimum transaction.

Review details

General review
Model: Claude Opus 5.5
Reasoning effort: xhigh

Security review
Model: Kimi K3
Reasoning effort: high

Finding verification, peer comparison and scoped validation
Model: GPT-6.1-sol
Reasoning effort: xhigh

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.

5 participants