View Issue Details

IDProjectCategoryView StatusLast Update
0011950Talerwallet (iOS App)public2026-11-05 08:26
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerMarcS ReviewerChristian Grothoff  
Release signed byMarcS Deployed byMarcS  
VerifierChristian Grothoff CommunicatorCM_7  
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PriorityurgentSeveritymajorReproducibilityalways
Status planningResolutionopen 
Summary0011950: Payment sheet: the bottom button changes from “Done” to “Cancel” to “Pay … now” in the same place within ~120 ms
DescriptionScanning a taler://pay/ URI opens the payment sheet, and the single button at
the bottom of that sheet changes what it does twice while the user is looking
at it, without moving:

  1. "Done" — while talerTX is still the dummy transaction
  2. "Cancel" — once it is a pay dialog but the amount is not known
  3. "Pay <amount> now" — once wallet-core has returned the amount

All three are drawn in the same rectangle by the same .safeAreaInset, so the
user's thumb is already on its way to one of them when it becomes another.

Measured by UI automation (iPhone 17, iOS 27.2, GNU_Taler debug build): a
control at [32, 792, 338, 47] changed from "Cancel" to "Pay KUDOS 0.10 now"
in 118 ms, in the same place and at the same size, while the sheet's title
changed from "Payment" to "Confirm Payment". 118 ms is well under the time
between deciding to press something and pressing it.

Both directions cost the user something real:

  * aiming for "Done" — meaning "do not pay" — and pressing "Pay": money
    leaves the wallet on a decision the user had made the other way;
  * aiming for "Pay", because that is where a Pay button normally is, and
    pressing "Done": the payment is not made, the order is filed away among
    the scanned QR codes, and finding it again is not obvious.

The duration of each state is one network round trip, so this is worst on a
connection fast enough that the swap happens after the user has aimed, and on
a slow one the misleading state is simply shown for longer.

The same arrangement, elsewhere
-------------------------------
This is not one screen's problem. Four views give the bottom bar a single
slot whose occupant switches between committing and dismissing, each switch
driven by something the network decides:

  TransactionSummaryList.swift:350 "Done" / "Cancel" / "Pay <amount> now"
  P2pPayURIView.swift:60 the confirm button / "Done",
                                     on peerPullDebitResponse.txState.isConfirmed
  P2pReceiveURIView.swift:96 the accept button / "Done",
                                     on peerPushCreditResponse.txState.isConfirmed
  WithdrawAcceptView.swift:119 empty / "Confirm Withdrawal", on tosAccepted

The attached patch fixes the first, which is the one that was reported and the
one where a mis-tap moves money on a decision the user made the other way.
The other three deserve the same treatment, and would be better served by one
shared bottom-bar component than by three copies of the fix — which is a
design call for whoever owns these views, not something to settle in a patch
from outside.

What this campaign did and did not see
--------------------------------------
The 118 ms measurement above is from a run against this same app source; the
file has not changed since. A later campaign did **not** re-reproduce the
full Done -> Cancel -> "Pay <amount> now" sequence, and it is only honest to
say so and why:

  * most walks ended before the payment sheet opened at all -- the sheet is
    reached by handing the app a URI some seconds in, and episodes were being
    cut short (see issue 11949);
  * on the one run where the sheet did open, the wallet had no balance, so it
    read "Payment not possible" and no "Pay" button was ever constructed --
    there was nothing for the dismiss control to turn into.

What the campaign did observe is that this one rectangle holds different
controls in different states, which is the mechanism rather than the timing:

    [32, 793, 338, 47] "Done" on 'Wire Transfer'
    [32, 793, 338, 47] "Cancel" on 'Loading...'
    [32, 762, 326, 47] "Cancel" on 'Payment not possible'

So the geometry and the code are confirmed; the measured interval between two
specific labels is from the earlier run. Anyone reproducing this should fund
the wallet first, because an unfunded wallet never builds the commit button.

Cause
-----
TalerWallet1/Views/Transactions/TransactionSummaryList.swift, the
.safeAreaInset(edge: .bottom). One slot, whose content is chosen by
`isPayDialog` and then by whether `effective` and `contractTerms` have
arrived; `PaymentScan` passes showDone: .prominent, so before wallet-core
answers the slot holds "Done".

Suggested fix (patch attached, compiles against current master)
---------------------------------------------------------------
Give the payment sheet two slots that never trade places: the commit action
on top, the dismiss action below it. The commit slot exists from the first
frame and is merely disabled until the amount is known, so nothing changes
meaning under the user's thumb. `expectPayDialog` is how a caller says a
sheet is going to become a payment dialog before wallet-core has confirmed
it, which is exactly the window in which the slot used to say "Done". A
sheet waiting on that answer stays swipe-dismissable so that a disabled
commit button is never a trap.

The patch adds one string, "Pay…", which needs translating.

A smaller mitigation, if the two-slot layout is unwanted: ignore taps on the
bottom button for a few hundred milliseconds after its action changes. That
treats the symptom rather than the cause, but it does prevent the mis-tap.

How to reproduce
----------------
1. Fresh install of the GNU_Taler scheme on an iPhone simulator (iOS 27.2).
2. Give the wallet a balance (Actions -> Withdraw, demo cash).
3. Hand the app a taler://pay/ URI for a small amount, e.g.

       xcrun simctl openurl booted 'taler://pay/...'

4. Watch the button at the bottom of the sheet: "Done", then "Cancel", then
   "Pay <amount> now", all in the same rectangle, each for as long as one
   network round trip.

With the UI fuzzing harness (branch dev/phoenix-bot/fuzzbang) the churn oracle
reports it as "control-churn", high, with the measured frame and interval.

Filed by an agent (bot-cg) on behalf of Christian Grothoff, from an automated
UI exploration campaign. Left unassigned deliberately.
TagsNo tags attached.
Attached Files
two-slot-bottom-bar.diff (6,689 bytes)   
diff --git a/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift b/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift
index 6cf7b899..95c8d184 100644
--- a/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift
+++ b/TalerWallet1/Views/Sheets/Payment/PaymentScan.swift
@@ -103,7 +103,8 @@ struct PaymentScan: View, Sendable {
                                      hasDone: true,                 // conclude payment
                                     showDone: .prominent,
                                          url: url,
-                                 withActions: false)
+                                 withActions: false,
+                             expectPayDialog: true)   // reserve the commit slot
 #if TALER_NIGHTLY2
 //            if let preparePayResult {
                 let status = preparePayResult.status
diff --git a/TalerWallet1/Views/Transactions/TransactionSummaryList.swift b/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
index d475186d..7a609769 100755
--- a/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
+++ b/TalerWallet1/Views/Transactions/TransactionSummaryList.swift
@@ -35,6 +35,14 @@ struct TransactionSummaryList: View {
     let showDone: TalerButtonStyleType?
     let url: URL?                           // the scanned talerURL from PaymentView
     let withActions: Bool                   // true if called from pending or history, or refund
+    /// true when the caller already knows this sheet is going to be a payment
+    /// dialog, before wallet-core has said so.
+    ///
+    /// Without it the bar cannot reserve the commit slot: for the first few
+    /// hundred milliseconds `talerTX` is still the dummy transaction, so
+    /// `isPayDialog` is false and the bar shows "Done" -- in the very place
+    /// that is about to hold "Pay <amount> now".
+    var expectPayDialog: Bool = false
 
     @EnvironmentObject private var controller: Controller
     @EnvironmentObject private var model: WalletModel
@@ -346,26 +354,58 @@ struct TransactionSummaryList: View {
             .listStyle(myListStyle.style).anyView
             .background(FullBackground())
             .navigationBarBackButtonHidden(hasDone)
-            .interactiveDismissDisabled(hasDone && !isPayDialog)     // can only use "Done" button to dismiss
+            // "Done" is the only way out of a concluded transaction -- but a
+            // payment sheet whose commit button is still disabled must not be a
+            // trap, so a sheet the caller expects to become a pay dialog stays
+            // swipe-dismissable.
+            .interactiveDismissDisabled(hasDone && !isPayDialog && !expectPayDialog)
             .safeAreaInset(edge: .bottom) {
                 let cancelButton = Button("Cancel") { dismissTop(stack.push()) }
                     .buttonStyle(TalerButtonStyle(type: .bordered))
                     .padding(is26 ? .horizontal : .all)
+                // A payment sheet's bar has two slots that never trade places:
+                // the commit action on top, the dismiss action below it.
+                //
+                // It used to have one slot, and that slot showed "Done", then
+                // "Cancel", then "Pay <amount> now" as wallet-core answered --
+                // 118 ms apart in a recorded run, which is less than the time
+                // between deciding to press something and pressing it.  Both
+                // directions hurt: aiming for "Done" and paying, or aiming for
+                // "Pay" and instead filing the order away unpaid among the
+                // scanned codes, where it is hard to find again.
+                //
+                // So the commit slot exists from the start and is merely
+                // disabled until the amount is known, and "Cancel" has a place
+                // of its own instead of borrowing the commit slot.
+                let payPlaceholder = Button(String(localized: "Pay…",
+                        comment: "disabled until wallet-core returns the amount")) { }
+                    .buttonStyle(TalerButtonStyle(type: .prominent, disabled: true))
+                    .disabled(true)
+                    .accessibilityHint(Text("Waiting for the amount to pay.",
+                                            comment: "a11y"))
+                    .padding(is26 ? .horizontal : .all)
                 let bottomButton = ZStack {
-                    if isPayDialog, case .payment(let paymentTransaction) = talerTX {
-                        let details = paymentTransaction.details
-                        if let effective, let terms = details.contractTerms, isFinished == false {
-                            let formatted = effective.formatted(currencyInfo)
-                            PaySafeArea(symLog: symLog,
-                                         stack: stack.push(),
-                                         terms: terms,
-                                  amountString: formatted.0,
-                                    amountA11y: formatted.1,
-                                    isFinished: $isFinished,
-                                        payNow: $payNow)
-                        } else {
+                    if expectPayDialog || isPayDialog {
+                        VStack(spacing: 0) {
+                            if isPayDialog, case .payment(let paymentTransaction) = talerTX {
+                                let details = paymentTransaction.details
+                                if let effective, let terms = details.contractTerms, isFinished == false {
+                                    let formatted = effective.formatted(currencyInfo)
+                                    PaySafeArea(symLog: symLog,
+                                                 stack: stack.push(),
+                                                 terms: terms,
+                                          amountString: formatted.0,
+                                            amountA11y: formatted.1,
+                                            isFinished: $isFinished,
+                                                payNow: $payNow)
+                                } else {
+                                    payPlaceholder
+                                }
+                            } else {
+                                payPlaceholder
+                            }
                             cancelButton
-                        } // Cancel
+                        }
                     } else if let showDone {
                         Button("Done") { dismissTop(stack.push()) }
                             .buttonStyle(TalerButtonStyle(type: showDone))
two-slot-bottom-bar.diff (6,689 bytes)   

Required notifications

Address or linkRemarkAdded byNotifiedActions

The plan

IDSummaryStatusAssigned ToEstimated effort (hours)
This issue has no child issue. Work that is big enough to be divided is divided here, and each part is planned on its own.
This issue (hours)
Its children (hours)0
Together (hours)0
Nothing is missing: the plan can be finished.

Activities

Issue History

Date ModifiedUsernameFieldChange
2026-11-05 07:07bot-cgNew Issue
2026-11-05 07:07bot-cgFile Added: two-slot-bottom-bar.diff
2026-11-05 08:26Christian GrothoffImplementer => MarcS
2026-11-05 08:26Christian GrothoffReviewer => Christian Grothoff
2026-11-05 08:26Christian GrothoffRelease signed by => MarcS
2026-11-05 08:26Christian GrothoffDeployed by => MarcS
2026-11-05 08:26Christian GrothoffVerifier => Christian Grothoff
2026-11-05 08:26Christian GrothoffPrioritynormal => urgent
2026-11-05 08:26Christian GrothoffStatusnew => planning