View Issue Details

IDProjectCategoryView StatusLast Update
0011938Talerwallet (iOS App)public2026-11-04 12:59
Reporterbot-cg [agent] Assigned ToResolution deadline
ImplementerMarcS ReviewerChristian Grothoff  
Release signed byMarcS Deployed byMarcS  
VerifierMarcS Communicator 
Data protection authority notification requiredNo  
CSIRT notification requiredNo  
Estimated effort (hours)Actual effort (hours)0 
Fast-trackNo
Work packagenone
PrioritynormalSeverityminorReproducibilityhave not tried
Status planningResolutionopen 
Summary0011938: Withdraw / amount entry: amounts render as "0,00 XXX" when the locale has no region, while the title shows the right currency
DescriptionWithdraw / Amount entry: every amount on the screen reads "XXX" instead of
the currency, when the app runs in a locale that has no region.

What is shown
-------------
In `de` (a bare language, no region), the withdrawal amount screen renders:

    Fr. abheben <- navigation title, knows the currency
    Von der Bank abzuhebender Betrag:
    0,00 XXX <- the amount field
    5,00 XXX 10,00 XXX
    25,00 XXX 50,00 XXX <- the shortcuts
    Keine Gebühr

Attached: xxx-currency-de.png. The title and the amounts disagree on the
same screen, which is what makes it obvious rather than merely wrong.

XXX is ISO 4217's code for "no currency". Foundation substitutes it when a
NumberFormatter's locale carries no region to take a currency from, and the
wallet then fails to replace it.

Why the replacement does not happen
-----------------------------------
TalerCommon/Helper/CurrencySpecification.swift, currencyString(_:useISO:)
tries three sources in order and gives up:

    if !useISO {
        if let aSymbol = altUnitSymbol { ... } // nil here
        if let aName = altUnitName0 { ... } // nil here
    }
    let currency = formatter.currency
    if !currency.isEmpty { ... } // empty here
    return aString // <- XXX leaves here

All three are empty or nil precisely because the locale has no region, so the
formatted string is returned untouched, placeholder and all.

Deliberately no patch
---------------------
I wrote one and it did not work, so I am not attaching it. It extended the
helper that performs the replacement to also match the "XXX" placeholder --
but that helper is never called on this path, because currencyString returns
before reaching it. Measured: with and without the change the screen still
reads "Kurzwahl 5,00 XXX". The attempt and its withdrawal are recorded on
branch dev/phoenix-bot/fix-xxx-currency so nobody repeats it.

The real fix has to decide what that last branch should display when
Foundation offers the formatter no currency at all. The wallet does know --
the Amount being rendered carries its own currency, and the navigation title
is built from it -- so the information is available; it is a question of
which of these layers should supply it. That is a decision for whoever owns
this file.

How to reproduce
----------------
The harness is 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 xxx-check

and the shortcut labels are in the recorded tree:

    python3 -c 'import json;
      [print([e["label"] for e in json.loads(l).get("elements",[])
              if "Kurzwahl" in (e.get("label") or "")])
       for l in open("$work_dir/out/replay-xxx-check/screens.jsonl") if l.strip()]'

By hand: run the app with the scheme option Application Language = German
(not "German (Germany)"), tap Withdraw, accept the terms, and look at the
amount shortcuts.

With `de_DE` instead of `de` the currency renders correctly, so a region is
all that separates the two.

Related
-------
Found while verifying the fix for issue 11933, which is the crash on this
same screen in this same locale. Until that fix lands the app terminates
before this screen can be seen, so this is only reachable with 11933 applied.
It is a separate defect with a separate cause: 11933 was an unguarded nil,
this is a fall-through that returns an unsubstituted string.

Estimated severity
------------------
Minor - every amount in the app is unreadable as money in such a locale, but
only in locales set without a region, which is mostly developers and UI tests
rather than users from the App Store.

Estimated effort to fix
-----------------------
Small, once the question above is answered: one branch in one function. The
decision of which layer supplies the currency is the work; the edit is not.

Found by automated UI exploration. Filed by an agent (bot-cg) on behalf of
Christian Grothoff. Left unassigned deliberately.
TagsNo tags attached.
Attached Files
xxx-currency-de.png (181,127 bytes)   
xxx-currency-de.png (181,127 bytes)   
fix-currency-no-region.patch (4,370 bytes)   
From 814618c30329e64b43ee4a98576e06ef1eed67c2 Mon Sep 17 00:00:00 2001
From: Christian Grothoff <christian@grothoff.org>
Date: Wed, 4 Nov 2026 10:18:57 +0100
Subject: [PATCH] AI: give the formatter the currency the locale has not got
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

With a locale that carries no region -- `de` rather than `de_DE` --
NumberFormatter has no currency to format with, so Foundation renders ISO
4217's XXX, its code for "no currency", and every amount in the app reads
"5,00 XXX" while the navigation title above it correctly says "Fr. abheben".

Nothing downstream can repair that.  currencyString() rewrites whichever
markers the formatter reports, and in that locale currencySymbol is the
generic placeholder "¤", which does not occur in the formatted string at all,
while currencyCode is nil.  An earlier attempt to special-case the literal
"XXX" there was measured and did nothing, for exactly this reason.

The currency belongs to the amount rather than to the locale, and the factory
already has it, so hand it to the formatter.  currencySymbol and currencyCode
then carry real markers and the existing replacement works as it does for a
locale with a region.  A locale that does supply a currency keeps it.

The guard in replacingCurrencyMarkers is required by the above: once both
markers read "CHF", rewriting the same marker twice inserted the
replacement's leading space twice and produced "5,00  CHF".

Verified by A/B on the simulator, same tape, 10 screens each:

    without, --locale de     5 screens contain XXX; "Kurzwahl 5,00 XXX"
    with,    --locale de     0 screens contain XXX; "Kurzwahl 5,00 CHF"
    with,    --locale de_DE  0; identical to de_DE before the patch

Assisted by LLM
---
 .../Helper/CurrencySpecification.swift        | 20 ++++++++++++++++++-
 1 file changed, 19 insertions(+), 1 deletion(-)

diff --git a/TalerCommon/Helper/CurrencySpecification.swift b/TalerCommon/Helper/CurrencySpecification.swift
index 5ea2b6ac..30ad151d 100644
--- a/TalerCommon/Helper/CurrencySpecification.swift
+++ b/TalerCommon/Helper/CurrencySpecification.swift
@@ -197,7 +197,11 @@ public struct CurrencyInfo: Sendable {
         if let symbol = formatter.currencySymbol, !symbol.isEmpty {
             result = result.replacingOccurrences(of: symbol, with: replacement)
         }
-        if let code = formatter.currencyCode, !code.isEmpty {
+        // Only if it is a different marker: for a currency the locale knows nothing
+        // about, ICU reports the code as the symbol too, and rewriting the same marker
+        // a second time inserts the replacement's leading space twice ("5,00  CHF").
+        if let code = formatter.currencyCode, !code.isEmpty,
+           code != formatter.currencySymbol {
             result = result.replacingOccurrences(of: code, with: replacement)
         }
         return result
@@ -371,6 +375,20 @@ public class CurrencyFormatter: NumberFormatter, @unchecked Sendable {
         }
         formatter.name = specs.name
         formatter.currency = currency
+        // `NumberFormatter´ takes the currency it formats with from its locale, and a
+        // locale with no region -- `de´ rather than `de_DE´ -- has none, so Foundation
+        // falls back to ISO 4217's code for "no currency" and every amount reads
+        // "5,00 XXX".  Nothing downstream can repair that: `currencyString´ rewrites
+        // whichever markers the formatter reports, and `currencySymbol´ is then the
+        // generic "¤", which does not occur in the formatted string at all.
+        // The currency belongs to the amount, not to the locale, and it is right here --
+        // so hand it to the formatter.  `currencySymbol´ and `currencyCode´ then carry
+        // real markers, which `currencyString´ replaces with the alternative unit
+        // symbol, name or ISO code, exactly as it does for a locale that has a region.
+        // A locale which does supply a currency keeps it.
+        if (formatter.currencyCode as String?)?.isEmpty ?? true, !currency.isEmpty {
+            formatter.currencyCode = currency
+        }
 //        formatter.setCode(to: EUR_4217)
 //        formatter.setSymbol(to: "€")
         // clamp: specs comes from the exchange and is untrusted; an out-of-range or negative
-- 
2.47.3

fix-currency-no-region.patch (4,370 bytes)   
after-currency-fixed-de.png (175,230 bytes)   
after-currency-fixed-de.png (175,230 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 09:45

updater   ~0030232

Patch attached and verified, and one thing in the report above is wrong --
correcting it here rather than leaving it to mislead.

Correction to the diagnosis
---------------------------
The report says "all three are empty or nil" and that the formatted string is
returned untouched from the final `return aString`. The second half is right;
the first is not. Checked directly against Foundation on the simulator:

    locale "de" currencySymbol = "¤" currencyCode = nil
                    string(for: 5.0) = "5,00 XXX"
    locale "de_DE" currencySymbol = "€" currencyCode = "EUR"

So `currencySymbol` is *not* nil: it is ICU's generic currency placeholder
"¤", which simply never occurs in the formatted string, so replacing it
achieves nothing. That is also the real reason the earlier attempt failed --
not that the helper was unreachable, as I wrote, but that it ran and matched
nothing.

The cause, stated properly
--------------------------
`NumberFormatter` takes the currency it formats with from its *locale*. A
locale with no region has none, so Foundation falls back to ISO 4217's XXX.
No amount of rewriting downstream can fix that, because the formatter never
knew what the currency was.

The fix
-------
Attached: fix-currency-no-region.patch (one file, +19/-1), against master;
also branch dev/phoenix-bot/fix-currency-no-region.

The currency belongs to the amount, not to the locale, and `CurrencyFormatter`'s
factory already receives it -- so hand it to the formatter when the locale did
not supply one:

    if (formatter.currencyCode as String?)?.isEmpty ?? true, !currency.isEmpty {
        formatter.currencyCode = currency
    }

`currencySymbol` and `currencyCode` then carry real markers and the existing
replacement in `currencyString` works exactly as it does for a locale with a
region. A locale that does supply a currency keeps it.

The second hunk is required by the first: once both markers read "CHF",
`replacingCurrencyMarkers` rewrote the same marker twice and inserted the
replacement's leading space twice, giving "5,00 CHF". It now skips the code
when it is identical to the symbol.

Verification
------------
A/B on the simulator, same tape, same walk, 10 recorded screens each:

    without the patch, --locale de 5 screens contain XXX
                                       "Kurzwahl 5,00 XXX" (and 10/25/50)
    with the patch, --locale de 0 screens contain XXX
                                       "Kurzwahl 5,00 CHF" (and 10/25/50)
    with the patch, --locale de_DE 0 screens contain XXX; renders
                                       identically to de_DE before the patch

The third row is the regression check: the normal case, where the locale does
carry a region, is unaffected.

Attached after-currency-fixed-de.png is the patched build in `de`: the amount
field and all four shortcuts now read "Fr." where they read "XXX", and the
navigation title is unchanged. The screenshot in the report above is the same
screen before the patch.

To re-run, on branch dev/phoenix-bot/fuzzbang under harness/
(dev/phoenix-bot/verify-currency is that harness with this patch applied):

    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 cur-check

    python3 -c 'import json;
      [print([e["label"] for e in json.loads(l).get("elements",[])
              if "Kurzwahl" in (e.get("label") or "")])
       for l in open("$work_dir/out/replay-cur-check/screens.jsonl") if l.strip()]'

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

Issue History

Date ModifiedUsernameFieldChange
2026-11-04 09:04bot-cgNew Issue
2026-11-04 09:04bot-cgFile Added: xxx-currency-de.png
2026-11-04 09:45bot-cgFile Added: fix-currency-no-region.patch
2026-11-04 09:45bot-cgFile Added: after-currency-fixed-de.png
2026-11-04 09:45bot-cgNote Added: 0030232
2026-11-04 10:02Christian GrothoffImplementer => MarcS
2026-11-04 10:02Christian GrothoffReviewer => Christian Grothoff
2026-11-04 10:02Christian GrothoffRelease signed by => MarcS
2026-11-04 10:02Christian GrothoffDeployed by => MarcS
2026-11-04 10:02Christian GrothoffVerifier => MarcS
2026-11-04 10:02Christian GrothoffCommunicatorCM_7 =>
2026-11-04 10:02Christian GrothoffDescription Updated
2026-11-04 12:59Christian GrothoffStatusnew => planning