View Issue Details

IDProjectCategoryView StatusLast Update
0011933Talerwallet (iOS App)public2026-11-04 13:02
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
PrioritynormalSeveritycrashReproducibilityhave not tried
Status planningResolutionopen 
Summary0011933: Terms of Service / Balances: app terminates formatting an amount when the locale has no region (Fatal error at CurrencySpecification.swift:204)
DescriptionThe app terminates with a Swift runtime trap as soon as it formats an amount,
whenever the active locale carries no region ("de" rather than "de_DE").

    CurrencySpecification.swift:204: Fatal error: Unexpectedly found nil while
    implicitly unwrapping an Optional value

CurrencySpecification.currencyString(_:useISO:) passes
NumberFormatter.currencySymbol and NumberFormatter.currencyCode straight into
String.replacingOccurrences(of:with:). Both are implicitly-unwrapped
optionals (String!), and both are nil when the formatter's locale has no
region, because a bare language identifies no currency.

CurrencyFormatter.init() sets self.locale = Locale.autoupdatingCurrent, so the
formatter inherits whatever the process was launched with. Launching with
-AppleLanguages (de) or -AppleLocale de therefore produces a formatter whose
currencyCode is nil, and the first amount that reaches currencyString() takes
the app down.

Three call sites share the pattern: lines 187-188, 193-194 and 203-204 of
TalerCommon/Helper/CurrencySpecification.swift. The crash observed was at 204,
but the other two are equally exposed.

Who is affected
---------------

Anyone running the app with a language override that carries no region. That
is exactly what Xcode's scheme option "Application Language" does, and what a
UI-test or screenshot run does when it sets -AppleLanguages. It is reached
before any user interaction beyond opening a screen that shows a balance, so
for those runs the app is unusable.

A device whose Settings give a language *and* a region is unaffected, which is
the normal end-user configuration, so the end-user exposure is narrower than
the crash severity alone suggests. It still terminates the process, and the
same nil can be reached by any future code path that builds a formatter from a
region-less locale.

Evidence that it is the missing region, not the language
--------------------------------------------------------

Same walk, same tape, four locales:

    locale steps completed result
    de 2 CRASH at CurrencySpecification.swift:204
    en 2 CRASH at CurrencySpecification.swift:204
    de_DE 40 no crash
    (no override) 40 no crash

Both bare languages crash and both region-bearing configurations do not, so the
language is irrelevant and the missing region is the cause.

How to reproduce
----------------
Click sequence (all of it steered automatically by the harness):

    1. launch the app with -AppleLanguages (de) -AppleLocale de
    2. onboarding -> "Withdraw CHF" (button.onboarding.withdrawReal)
    3. -> "Terms of Service" (button.withdraw.tos.open)
    4. -> "Accept Terms of Service" (button.withdraw.tos.accept)
    5. the balances list redraws; the app terminates ~700 ms later

No fault injection is involved; this reproduces on a plain run against the
normal demo services.

Reproducing with the exploration harness
----------------------------------------

The harness lives on branch dev/phoenix-bot/fuzzbang (local name
dev/bot-cg/fuzzbang), under harness/. From harness/ on a Mac with Xcode:

    python3 -m taler_fuzz build
    python3 -m taler_fuzz --locale de \
        --prefer "demo cash,withdraw,confirm,accept,terms,balance,settings" \
        replay --tape "0,5,0,5,0,5,0,5,0,5,0,5,9,15,11,6,4,3,11,13,10,6,15,14,8,1,0,2,12,0,15,10,7,10,2,6,7,7,4,14" \
        --label crash --shots

Expected on an unpatched build: the run stops after 2 steps and reports

    [critical] app-exit: the app stopped running during exploration

and ~/fuzzwork/out/replay-crash/oslog.ndjson contains the Fatal error line.
Repeat with --locale de_DE to see the same tape complete all 40 steps.

The --prefer list matters: the tape alone does not determine the walk, because
the steering decision consumes tape values.

Patch
-----

Attached: fix-currencycode-crash.patch (one file, +27/-10), against master.
The three call sites now go through one helper that substitutes whichever
markers the formatter actually has:

    private func replacingCurrencyMarkers(in aString: String,
                                          with replacement: String) -> String {
        var result = aString
        if let symbol = formatter.currencySymbol, !symbol.isEmpty {
            result = result.replacingOccurrences(of: symbol, with: replacement)
        }
        if let code = formatter.currencyCode, !code.isEmpty {
            result = result.replacingOccurrences(of: code, with: replacement)
        }
        return result
    }

Verified: with the patch applied, the same tape completes all 40 steps under
both --locale de and --locale en, with no CurrencySpecification fatal error.
Also available as branch dev/phoenix-bot/fix-currencycode-crash.

Note for review: the patch makes the app survive, but a region-less locale also
means there is no sensible currency symbol to substitute, so amounts will be
rendered with the raw currency name. If that is not the wanted presentation,
the better fix may be to give CurrencyFormatter an explicit locale rather than
Locale.autoupdatingCurrent -- the "TODO: Yikes, might override my currency!"
already on that line suggests this was known to be fragile.

Estimated severity
------------------
Critical - the app stops working (crash, exit or wedged UI); a user hitting this loses the flow they were in.

Estimated effort to fix
-----------------------
Small - about an hour. The fix is one private helper and three call sites in a single file, with no API change and no behavioural change for locales that do carry a region. Most of the time goes into deciding what amounts should look like when no currency symbol is available, and into checking the other NumberFormatter IUO properties in that class for the same pattern.

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
fix-currencycode-crash.patch (3,636 bytes)   
diff --git a/TalerCommon/Helper/CurrencySpecification.swift b/TalerCommon/Helper/CurrencySpecification.swift
index 5a6811ee..5ea2b6ac 100644
--- a/TalerCommon/Helper/CurrencySpecification.swift
+++ b/TalerCommon/Helper/CurrencySpecification.swift
@@ -181,29 +181,46 @@ public struct CurrencyInfo: Sendable {
         }
     }
 
+    /// Replace whichever currency markers the formatter actually has.
+    ///
+    /// `NumberFormatter.currencySymbol` and `.currencyCode` are
+    /// implicitly-unwrapped optionals, and both are nil when the formatter's
+    /// locale carries no region -- `de` rather than `de_DE`.  `CurrencyFormatter`
+    /// takes its locale from `Locale.autoupdatingCurrent`, so that happens
+    /// whenever the app runs with `-AppleLanguages`/`-AppleLocale` set to a bare
+    /// language, which is what Xcode's "Application Language" scheme option and
+    /// UI-test runs do.  Passing such a value straight to
+    /// `replacingOccurrences(of:)` traps and takes the app down.
+    private func replacingCurrencyMarkers(in aString: String,
+                                          with replacement: String) -> String {
+        var result = aString
+        if let symbol = formatter.currencySymbol, !symbol.isEmpty {
+            result = result.replacingOccurrences(of: symbol, with: replacement)
+        }
+        if let code = formatter.currencyCode, !code.isEmpty {
+            result = result.replacingOccurrences(of: code, with: replacement)
+        }
+        return result
+    }
+
     func currencyString(_ aString: String, useISO: Bool = false) -> String {
         if !useISO {
             if let aSymbol = altUnitSymbol {
-                let symbolString = aString.replacingOccurrences(of: formatter.currencySymbol, with: aSymbol)
-                return symbolString.replacingOccurrences(of: formatter.currencyCode, with: aSymbol)
+                return replacingCurrencyMarkers(in: aString, with: aSymbol)
             }
             if let aName = altUnitName0 {
                 let spacedName = formatter.leadingCurrencySymbol ? aName + SPACE
                                                                  : SPACE + aName
-                let spacedString1 = aString.replacingOccurrences(of: formatter.currencySymbol, with: spacedName)
-                let spacedString2 = spacedString1.replacingOccurrences(of: formatter.currencyCode, with: spacedName)
-                let spacedString3 = spacedString2.replacingOccurrences(of: "  ", with: " ")  // ensure we have only 1 space
-                return spacedString3
+                let spaced = replacingCurrencyMarkers(in: aString, with: spacedName)
+                return spaced.replacingOccurrences(of: "  ", with: " ")  // ensure we have only 1 space
             }
         }
         let currency = formatter.currency
         if !currency.isEmpty {
             let spacedName = formatter.leadingCurrencySymbol ? currency + SPACE
                                                              : SPACE + currency
-            let spacedString1 = aString.replacingOccurrences(of: formatter.currencySymbol, with: spacedName)
-            let spacedString2 = spacedString1.replacingOccurrences(of: formatter.currencyCode, with: spacedName)
-            let spacedString3 = spacedString2.replacingOccurrences(of: "  ", with: " ")  // ensure we have only 1 space
-            return spacedString3
+            let spaced = replacingCurrencyMarkers(in: aString, with: spacedName)
+            return spaced.replacingOccurrences(of: "  ", with: " ")  // ensure we have only 1 space
         }
         return aString
     }
fix-currencycode-crash.patch (3,636 bytes)   
after-survives-de-locale.png (181,127 bytes)   
after-survives-de-locale.png (181,127 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

bot-cg [agent]

2026-11-04 02:01

reporter   ~0030220

Follow-up evidence on the presentation question raised with the patch.

With the patch applied the app no longer crashes, and a locale-sweep across
de_DE, fr_FR, es_ES, pl_PL, uk_UA, en_US, de and fr shows what it renders
instead: amounts in a region-less locale come out with the ISO 4217 "no
currency" code XXX.

    locale occurrences of "XXX" on screen example
    de 290 'Kurzwahl 5,00 XXX'
    fr 126 'Raccourci 5,00 XXX'
    de_DE 0
    fr_FR 0
    es_ES 0
    pl_PL 0
    uk_UA 0
    en_US 0

Exactly the locales without a region, and no others. So the force-unwrap and
the XXX rendering are the same root cause seen twice: NumberFormatter has no
currency to work with, nil for currencyCode and XXX once a value is asked for.

The clamp in the attached patch is therefore necessary but not sufficient if
the wanted behaviour is a sensible amount rather than a surviving app. Giving
CurrencyFormatter an explicit locale, instead of Locale.autoupdatingCurrent,
would address both -- the `// TODO: Yikes, might override my currency! Needs
testing!` already sits on that line.

Reproduced with the exploration harness:

    python3 -m taler_fuzz --locale de explore -n 2 --run-id loc-de
    python3 -m taler_fuzz --locale de_DE explore -n 2 --run-id loc-de_DE

then compare the recorded screens for "XXX".

bot-cg [agent]

2026-11-04 09:03

reporter   ~0030231

Patch verified on the running app, by A/B.

One walk, the `de` locale, two builds. The baseline is the exploration
harness branch with this very fix *reverted*, because the fix is already
merged into that branch and a plain checkout of it no longer crashes:

    without the fix (dev/phoenix-bot/crash-baseline)
        [critical] app-exit: the app stopped running during exploration
        app-not-running at t=7321 ms, after 2 steps, 3 recorded screens
        last screen: "Allgemeine Geschäftsbedingungen"

    with the fix (dev/phoenix-bot/fuzzbang)
        8 steps, 10 recorded screens, no app-exit, no findings
        last screen: "Fr. abheben" with the amount shortcuts rendered

To re-run, on branch dev/phoenix-bot/fuzzbang under harness/:

    python3 -m taler_fuzz build
    python3 -m taler_fuzz --locale de --max-steps 8 \
        --prefer "withdraw,accept,terms,balance,abheben" \
        replay --tape "1,0,1,0,2,1,0,1,0" --shots --label crash-check

`--locale de` is what matters: a bare language with no region, which is what
Xcode's "Application Language" scheme option and UI-test runs produce. With
`de_DE` the app does not crash.

Something the fix revealed, filed separately
--------------------------------------------
The attached screenshot after-survives-de-locale.png is the patched build,
and it shows the next defect in the same area: the navigation title reads
"Fr. abheben", so the app knows the currency, while every amount on the
screen reads

    0,00 XXX 5,00 XXX 10,00 XXX 25,00 XXX 50,00 XXX

That is not caused by this patch -- it was simply unreachable before, because
the app died first. Filed as its own issue rather than folded in here.

Added by an agent (bot-cg) on behalf of Christian Grothoff.

Issue History

Date ModifiedUsernameFieldChange
2026-11-03 23:45bot-cgNew Issue
2026-11-03 23:45bot-cgFile Added: fix-currencycode-crash.patch
2026-11-04 02:01bot-cgNote Added: 0030220
2026-11-04 09:03bot-cgFile Added: after-survives-de-locale.png
2026-11-04 09:03bot-cgNote Added: 0030231
2026-11-04 13:02Christian GrothoffImplementer => MarcS
2026-11-04 13:02Christian GrothoffReviewer => Christian Grothoff
2026-11-04 13:02Christian GrothoffRelease signed by => MarcS
2026-11-04 13:02Christian GrothoffDeployed by => MarcS
2026-11-04 13:02Christian GrothoffVerifier => Christian Grothoff
2026-11-04 13:02Christian GrothoffStatusnew => planning
2026-11-04 13:02Christian GrothoffDescription Updated