From 07bd48406244c6c7763585502645f053a85f9d2c Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 11:23:42 +0200 Subject: [PATCH 01/13] fix(registration): compose canonical E.164 before signing the registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registration phone number was composed exactly as typed, so the customary Swiss trunk-zero spelling (prefix `+41`, national part `079…`) was EIP-712 signed as `+410791234567`. The server normalises the field to E.164 before it verifies the signature, so verification ran over different bytes than the client signed and a valid submission was rejected. Compose the canonical value in the field instead: - Remove exactly one national trunk zero, and only for the dial codes whose numbering plan has one (`+41`, `+49`, `+43`). - Match on the fully composed value rather than per field, so the result is the same however the digits are split between the free-form dial-code field and the national part. - Apply the same composition to a pre-filled value before any user interaction. - Reject a residual `+410…`, `+490…` or `+430…` so it cannot advance through the form. Italian and Liechtenstein leading zeroes are significant and stay untouched; `+423` is deliberately absent from the trunk-zero list because Liechtenstein has no national trunk zero. --- lib/widgets/form/phone_number_field.dart | 32 +++- .../widgets/form/phone_number_field_test.dart | 159 ++++++++++++++++-- 2 files changed, 177 insertions(+), 14 deletions(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 023bd6d99..70b5e4b53 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -16,6 +16,9 @@ class _PhoneNumberFieldState extends State { // Used only to decompose a seeded value. Input is free-form and not limited to this list. // `+41` stays first: it is the fallback default (`prefix ??= prefixes.first`). final prefixes = ['+41', '+49', '+43', '+423']; + // CH/DE/AT drop a leading national trunk 0. Italy's leading 0 is significant, + // and Liechtenstein has no national trunk 0. + static const _trunkZeroPrefixes = ['+41', '+49', '+43']; String? prefix; String? number; @@ -40,13 +43,27 @@ class _PhoneNumberFieldState extends State { // what the user typed. Fall back to the first prefix; the number field starts empty, // so the validator still blocks submit until it is re-entered. prefix ??= prefixes.first; + + // Seeded values must use the same trunk-0 composition as later edits. + updatePhoneNumber(); } void updatePhoneNumber() { - if (prefix != null && number != null) { - final value = '$prefix$number'; - widget.controller.value = value; + final prefix = this.prefix; + final number = this.number; + if (prefix == null || number == null) return; + + widget.controller.value = _canonicalize('$prefix$number'); + } + + static String _canonicalize(String value) { + for (final countryPrefix in _trunkZeroPrefixes) { + final trunkPrefix = '${countryPrefix}0'; + if (value.startsWith(trunkPrefix)) { + return '$countryPrefix${value.substring(trunkPrefix.length)}'; + } } + return value; } @override @@ -115,7 +132,14 @@ class _PhoneNumberFieldState extends State { if (!RegExp(r'^[0-9]+$').hasMatch(value)) { return S.of(context).registerPhoneNumberOnlyDigits; } - // Length is validated by the API (libphonenumber); the client + final canonical = _canonicalize('$prefix$value'); + if (_trunkZeroPrefixes.any( + (countryPrefix) => canonical.startsWith('${countryPrefix}0'), + )) { + return S.of(context).registerPhoneNumberInvalid; + } + // Apart from the explicit trunk-zero canonicality check above, + // length is validated by the API (libphonenumber); the client // must not gate on it — see CONTRIBUTING "the API decides". return null; }, diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index 656657bb4..868c2ad58 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -98,14 +98,16 @@ void main() { ); }); - // The client performs format hygiene only (non-empty + digits). It must not - // gate on length: the API validates the number with libphonenumber, so the - // app accepts any non-empty, digits-only national part regardless of length - // and lets the backend accept or reject it (CONTRIBUTING: "the API decides… - // the app must not block it pre-emptively"). These cases guard against a - // length gate being re-introduced. - testWidgets('accepts a short +41 national number and defers the length to the API', - (tester) async { + // The client enforces basic format (non-empty + digits) and the explicit + // CH/DE/AT trunk-zero canonicality invariant. All other phone validity, + // including length and dial-code existence, remains backend-owned, so the + // app accepts non-empty, digits-only national parts regardless of length + // and lets the backend accept or reject them (CONTRIBUTING: "the API + // decides"; the app must not block them pre-emptively). These cases guard + // against a length gate being re-introduced. + testWidgets('accepts a short +41 national number and defers the length to the API', ( + tester, + ) async { final harness = await _pumpPhoneField(tester); final isValid = await _enterAndValidate(tester, harness, '12345'); @@ -123,8 +125,145 @@ void main() { expect(isValid, isTrue); }); - testWidgets('accepts a 9-digit +49 national number (valid per the API, not a length error)', - (tester) async { + testWidgets('strips a leading Swiss trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '0791234567'); + + expect(harness.controller.value, '+41791234567'); + expect(isValid, isTrue); + }); + + testWidgets('canonicalizes a Swiss number when the prefix contains extra digits', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '410'); + final isValid = await _enterAndValidate(tester, harness, '791234567'); + + expect(harness.controller.value, '+41791234567'); + expect(isValid, isTrue); + }); + + testWidgets('canonicalizes a Swiss number when the national field contains the prefix digit', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '4'); + final isValid = await _enterAndValidate(tester, harness, '10791234567'); + + expect(harness.controller.value, '+41791234567'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading German trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+49'); + + final isValid = await _enterAndValidate(tester, harness, '0691234567'); + + expect(harness.controller.value, '+49691234567'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading Austrian trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '43'); + final isValid = await _enterAndValidate(tester, harness, '06641234567'); + + expect(harness.controller.value, '+436641234567'); + expect(isValid, isTrue); + }); + + testWidgets('keeps a leading Italian zero in the stored number', (tester) async { + // For +39 the leading 0 is significant. Stripping it would make landlines + // such as 0666982 invalid. + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '39'); + final isValid = await _enterAndValidate(tester, harness, '0666982'); + + expect(harness.controller.value, '+390666982'); + expect(isValid, isTrue); + }); + + testWidgets('keeps a leading Liechtenstein zero in the stored number', (tester) async { + // Liechtenstein has no national trunk 0, so the entered leading zero + // must remain part of the national number. + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '423'); + final isValid = await _enterAndValidate(tester, harness, '0123456'); + + expect(harness.controller.value, '+4230123456'); + expect(isValid, isTrue); + }); + + testWidgets('does not strip a zero that is not at the start of the national number', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '790123456'); + + expect(harness.controller.value, '+41790123456'); + expect(isValid, isTrue); + }); + + testWidgets('rejects multiple leading trunk zeros', (tester) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '00791234567'); + + expect(harness.controller.value, '+410791234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + findsOneWidget, + ); + }); + + testWidgets('strips a leading trunk zero when the country prefix changes', (tester) async { + final harness = await _pumpPhoneField(tester); + await tester.enterText(_numberField(), '0791234567'); + await tester.pump(); + + await tester.enterText(_prefixField(), '49'); + await tester.pump(); + + expect(harness.controller.value, '+49791234567'); + }); + + testWidgets('strips a leading trunk zero from a pre-filled value without user interaction', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+410791234567'); + await tester.pump(); + + expect(harness.controller.value, '+41791234567'); + }); + + testWidgets('rejects a pre-filled number with multiple leading trunk zeros', ( + tester, + ) async { + final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+4100791234567'); + + final isValid = harness.formKey.currentState!.validate(); + await tester.pump(); + + expect(harness.controller.value, '+410791234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + findsOneWidget, + ); + }); + + testWidgets('accepts a 9-digit +49 national number (valid per the API, not a length error)', ( + tester, + ) async { // A 9-digit German national number (e.g. a Frankfurt landline, 069 …) is // valid for libphonenumber; the client must not reject it on length. final harness = await _pumpPhoneField(tester, initialPhoneNumber: '+49'); From ea47870bf1b0190858d51ebd11bb155a47b96d41 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 11:30:50 +0200 Subject: [PATCH 02/13] fix(registration): name the leading zero in the phone field error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trunk-zero rejection returned `registerPhoneNumberInvalid`, which reads "Phone number is required" / "Telefonnummer ist erforderlich" — the same string the empty-field branch twelve lines above uses. Someone who typed `00791234567` therefore saw "is required" over a visibly filled field, with no hint that the leading zero was the cause. Add `registerPhoneNumberLeadingZero` and use it for that branch only; the empty-field branch keeps the old key. The two rejection tests now assert the new string. That also sharpens them: while both branches returned the same text, neither test could tell a trunk-zero rejection from an empty-field one. --- assets/languages/strings_de.arb | 1 + assets/languages/strings_en.arb | 1 + lib/widgets/form/phone_number_field.dart | 2 +- test/widgets/form/phone_number_field_test.dart | 4 ++-- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 7ed95d199..3695ff81f 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -286,6 +286,7 @@ "registerEmailVerificationFailed": "Sie haben Ihre E-Mail noch nicht bestätigt.", "registerEmailVerificationTitle": "Willkommen zurück!", "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", + "registerPhoneNumberLeadingZero": "Telefonnummer ohne führende Null eingeben", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", "registerPhoneNumberPrefixFormat": "Vorwahl muss aus 1 bis 3 Ziffern bestehen", "registerPhoneNumberPrefixInvalid": "Vorwahl ist erforderlich", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 44db6efe9..0a0585d1a 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -286,6 +286,7 @@ "registerEmailVerificationFailed": "You have not yet confirmed your email address.", "registerEmailVerificationTitle": "Welcome back!", "registerPhoneNumberInvalid": "Phone number is required", + "registerPhoneNumberLeadingZero": "Enter the phone number without the leading zero", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", "registerPhoneNumberPrefixFormat": "Country code must be 1 to 3 digits", "registerPhoneNumberPrefixInvalid": "Country code is required", diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 70b5e4b53..d0ee76256 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -136,7 +136,7 @@ class _PhoneNumberFieldState extends State { if (_trunkZeroPrefixes.any( (countryPrefix) => canonical.startsWith('${countryPrefix}0'), )) { - return S.of(context).registerPhoneNumberInvalid; + return S.of(context).registerPhoneNumberLeadingZero; } // Apart from the explicit trunk-zero canonicality check above, // length is validated by the API (libphonenumber); the client diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index 868c2ad58..5fe880b7e 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -220,7 +220,7 @@ void main() { expect(harness.controller.value, '+410791234567'); expect(isValid, isFalse); expect( - find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), findsOneWidget, ); }); @@ -256,7 +256,7 @@ void main() { expect(harness.controller.value, '+410791234567'); expect(isValid, isFalse); expect( - find.text(_phoneError(tester, (s) => s.registerPhoneNumberInvalid)), + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), findsOneWidget, ); }); From 2d6c6efd0f6417ce93ffe80fd49797f870636609 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 12:38:29 +0200 Subject: [PATCH 03/13] fix(registration): canonicalise the phone number from libphonenumber metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trunk-zero strip was driven by a hand-kept list of three dial codes, so every other numbering plan with a national trunk zero still composed a value the backend rejects. Measured with `libphonenumber-js` 1.12.25, the version the API resolves: `+330612345678` must be `+33612345678`, `+4407911123456` must be `+447911123456`, `+310612345678` must be `+31612345678`. Derive the canonical form from the same metadata the API uses instead, via `dlibphonenumber`. Measured over 490 cases built from libphonenumber's own example numbers across 245 countries — the canonical mobile number per country and the same number with a zero inserted after the dial code — the package agrees with `libphonenumber-js` on 490 of 490. `phone_numbers_parser` was measured too and differs in 5 of those cases, which is why it is not the one used here. `parse` throws while the national part is still being typed, so the raw value is passed through unchanged; the app must not gate on validity or length. `_trunkZeroPrefixes` stays, but only to report a surviving second leading zero in the field rather than letting it become a 400: `+4100791234567` is a fixed point of the canonicalisation, so the metadata alone does not catch it. --- lib/widgets/form/phone_number_field.dart | 19 ++++---- pubspec.lock | 16 +++++++ pubspec.yaml | 1 + .../widgets/form/phone_number_field_test.dart | 43 ++++++++++++++++++- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index d0ee76256..7fc19e36f 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -1,3 +1,4 @@ +import 'package:dlibphonenumber/dlibphonenumber.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:realunit_wallet/generated/i18n.dart'; @@ -16,8 +17,9 @@ class _PhoneNumberFieldState extends State { // Used only to decompose a seeded value. Input is free-form and not limited to this list. // `+41` stays first: it is the fallback default (`prefix ??= prefixes.first`). final prefixes = ['+41', '+49', '+43', '+423']; - // CH/DE/AT drop a leading national trunk 0. Italy's leading 0 is significant, - // and Liechtenstein has no national trunk 0. + // Canonicalization uses libphonenumber metadata, not this list. These main-market + // prefixes remain only to report a surviving second leading zero in the field + // instead of letting the API return a 400. static const _trunkZeroPrefixes = ['+41', '+49', '+43']; String? prefix; String? number; @@ -57,13 +59,14 @@ class _PhoneNumberFieldState extends State { } static String _canonicalize(String value) { - for (final countryPrefix in _trunkZeroPrefixes) { - final trunkPrefix = '${countryPrefix}0'; - if (value.startsWith(trunkPrefix)) { - return '$countryPrefix${value.substring(trunkPrefix.length)}'; - } + try { + final util = PhoneNumberUtil.instance; + return util.format(util.parse(value, null), PhoneNumberFormat.e164); + } catch (_) { + // `parse` throws NumberParseException for incomplete input while the user is + // typing; preserve the raw value and let the API decide validity on submit. + return value; } - return value; } @override diff --git a/pubspec.lock b/pubspec.lock index a68f0b590..c9a07d60f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -298,6 +298,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + dlibphonenumber: + dependency: "direct main" + description: + name: dlibphonenumber + sha256: b467588e1d09972b5b650517de484c6f9beed23a27dcc78dbde901f99db8899e + url: "https://pub.dev" + source: hosted + version: "1.1.70" drift: dependency: "direct main" description: @@ -1239,6 +1247,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" + url: "https://pub.dev" + source: hosted + version: "6.0.0" provider: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 775cd3438..d7e3088e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: clock: ^1.1.2 collection: ^1.19.0 convert: ^3.1.2 + dlibphonenumber: ^1.1.70 drift: ^2.32.1 eth_sig_util_plus: ^0.0.10 eip7702: diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index 5fe880b7e..f6990a121 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -177,6 +177,36 @@ void main() { expect(isValid, isTrue); }); + testWidgets('strips a leading French trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '33'); + final isValid = await _enterAndValidate(tester, harness, '0612345678'); + + expect(harness.controller.value, '+33612345678'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading UK trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '44'); + final isValid = await _enterAndValidate(tester, harness, '07911123456'); + + expect(harness.controller.value, '+447911123456'); + expect(isValid, isTrue); + }); + + testWidgets('strips a leading Dutch trunk zero from the national number', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '31'); + final isValid = await _enterAndValidate(tester, harness, '0612345678'); + + expect(harness.controller.value, '+31612345678'); + expect(isValid, isTrue); + }); + testWidgets('keeps a leading Italian zero in the stored number', (tester) async { // For +39 the leading 0 is significant. Stripping it would make landlines // such as 0666982 invalid. @@ -212,12 +242,21 @@ void main() { expect(isValid, isTrue); }); + testWidgets('preserves incomplete input when phone-number parsing fails', (tester) async { + final harness = await _pumpPhoneField(tester); + + final isValid = await _enterAndValidate(tester, harness, '7'); + + expect(harness.controller.value, '+417'); + expect(isValid, isTrue); + }); + testWidgets('rejects multiple leading trunk zeros', (tester) async { final harness = await _pumpPhoneField(tester); final isValid = await _enterAndValidate(tester, harness, '00791234567'); - expect(harness.controller.value, '+410791234567'); + expect(harness.controller.value, '+4100791234567'); expect(isValid, isFalse); expect( find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), @@ -253,7 +292,7 @@ void main() { final isValid = harness.formKey.currentState!.validate(); await tester.pump(); - expect(harness.controller.value, '+410791234567'); + expect(harness.controller.value, '+4100791234567'); expect(isValid, isFalse); expect( find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), From 26a2b9ba54566ac7d3e8ae992d4ce6cd2343625a Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 27 Aug 2026 13:07:42 +0200 Subject: [PATCH 04/13] fix(registration): catch only the parse failure, not every exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_canonicalize` caught every exception and returned the raw value. The comment named the one case that is expected — incomplete input while the national part is still being typed — but the code also swallowed a genuine defect in the metadata library, which would then reach the API as a 400 instead of failing visibly. Narrow it to `NumberParseException`, the type measured for `+41`, `+417`, `+410`, `+49` and `+423`. Anything else now propagates. The incomplete-input test stays green, which is what pins that this is the type actually thrown. --- lib/widgets/form/phone_number_field.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index 7fc19e36f..d1aa89055 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -62,9 +62,10 @@ class _PhoneNumberFieldState extends State { try { final util = PhoneNumberUtil.instance; return util.format(util.parse(value, null), PhoneNumberFormat.e164); - } catch (_) { + } on NumberParseException { // `parse` throws NumberParseException for incomplete input while the user is // typing; preserve the raw value and let the API decide validity on submit. + // Other exceptions are intentionally not caught. return value; } } From 1ad452debb06a3e7b4cd1e875566da597e35cfb4 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 2 Sep 2026 17:46:09 +0200 Subject: [PATCH 05/13] fix(registration): pin every trunk-zero prefix and correct the LI claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double-zero guard listed +41, +49 and +43, but only +41 was covered by a test: removing the other two entries left the suite green. Adds a case for each. The Liechtenstein test claimed LI has no national trunk 0. It has one — the zero is stripped as soon as the result has a possible length. The test only passed because its number was too short for that, so the comment described a rule that does not exist. Corrects it and pins the actual behaviour. Also moves the dlibphonenumber import behind the flutter imports, per the import order in CONTRIBUTING.md. --- lib/widgets/form/phone_number_field.dart | 5 +- .../widgets/form/phone_number_field_test.dart | 47 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/widgets/form/phone_number_field.dart b/lib/widgets/form/phone_number_field.dart index d1aa89055..02ce210e3 100644 --- a/lib/widgets/form/phone_number_field.dart +++ b/lib/widgets/form/phone_number_field.dart @@ -1,6 +1,6 @@ -import 'package:dlibphonenumber/dlibphonenumber.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:dlibphonenumber/dlibphonenumber.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; @@ -46,7 +46,8 @@ class _PhoneNumberFieldState extends State { // so the validator still blocks submit until it is re-entered. prefix ??= prefixes.first; - // Seeded values must use the same trunk-0 composition as later edits. + // Canonicalization here only applies when the loop above split the seed. + // An unrecognized dial code leaves number null, so updatePhoneNumber() writes nothing. updatePhoneNumber(); } diff --git a/test/widgets/form/phone_number_field_test.dart b/test/widgets/form/phone_number_field_test.dart index f6990a121..6b4f968cd 100644 --- a/test/widgets/form/phone_number_field_test.dart +++ b/test/widgets/form/phone_number_field_test.dart @@ -220,8 +220,8 @@ void main() { }); testWidgets('keeps a leading Liechtenstein zero in the stored number', (tester) async { - // Liechtenstein has no national trunk 0, so the entered leading zero - // must remain part of the national number. + // Length check, not a missing trunk prefix: stripping 0 would leave a + // length that is not possible for LI, so libphonenumber keeps the raw value. final harness = await _pumpPhoneField(tester); await tester.enterText(_prefixField(), '423'); @@ -231,6 +231,19 @@ void main() { expect(isValid, isTrue); }); + testWidgets('strips a leading Liechtenstein trunk zero when the result has a valid length', ( + tester, + ) async { + // LI has a trunk prefix; the zero is stripped once the result has a valid length. + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '423'); + final isValid = await _enterAndValidate(tester, harness, '07912345'); + + expect(harness.controller.value, '+4237912345'); + expect(isValid, isTrue); + }); + testWidgets('does not strip a zero that is not at the start of the national number', ( tester, ) async { @@ -264,6 +277,36 @@ void main() { ); }); + testWidgets('rejects multiple leading German trunk zeros', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '49'); + final isValid = await _enterAndValidate(tester, harness, '00691234567'); + + // One of the two zeros is stripped; the surviving one leaves +490… and trips the validator. + expect(harness.controller.value, '+490691234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), + findsOneWidget, + ); + }); + + testWidgets('rejects multiple leading Austrian trunk zeros', (tester) async { + final harness = await _pumpPhoneField(tester); + + await tester.enterText(_prefixField(), '43'); + final isValid = await _enterAndValidate(tester, harness, '006641234567'); + + // One of the two zeros is stripped; the surviving one leaves +430… and trips the validator. + expect(harness.controller.value, '+4306641234567'); + expect(isValid, isFalse); + expect( + find.text(_phoneError(tester, (s) => s.registerPhoneNumberLeadingZero)), + findsOneWidget, + ); + }); + testWidgets('strips a leading trunk zero when the country prefix changes', (tester) async { final harness = await _pumpPhoneField(tester); await tester.enterText(_numberField(), '0791234567'); From c6655c6d00c28a7b9ec690882133bfbefa9d6a6a Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 3 Sep 2026 10:08:54 +0200 Subject: [PATCH 06/13] fix(registration): make the leading-zero message fit the field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new message was cut off in the field: German rendered as "Telefonnummer ohne fuehrende ...", and the English string was longer still. The four widget tests could not see it — find.text matches the resolved string whether or not it fits on screen. Shortens both strings and pins the error state with a golden, so the next message that outgrows the row fails a check instead of shipping truncated. --- assets/languages/strings_de.arb | 2 +- assets/languages/strings_en.arb | 2 +- .../phone_number_field_leading_zero_error.png | Bin 0 -> 11776 bytes .../form/phone_number_field_golden_test.dart | 30 ++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error.png diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 3695ff81f..59e30edf2 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -286,7 +286,7 @@ "registerEmailVerificationFailed": "Sie haben Ihre E-Mail noch nicht bestätigt.", "registerEmailVerificationTitle": "Willkommen zurück!", "registerPhoneNumberInvalid": "Telefonnummer ist erforderlich", - "registerPhoneNumberLeadingZero": "Telefonnummer ohne führende Null eingeben", + "registerPhoneNumberLeadingZero": "Ohne führende Null eingeben", "registerPhoneNumberOnlyDigits": "Nur Zahlen sind erlaubt", "registerPhoneNumberPrefixFormat": "Vorwahl muss aus 1 bis 3 Ziffern bestehen", "registerPhoneNumberPrefixInvalid": "Vorwahl ist erforderlich", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 0a0585d1a..fe666c307 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -286,7 +286,7 @@ "registerEmailVerificationFailed": "You have not yet confirmed your email address.", "registerEmailVerificationTitle": "Welcome back!", "registerPhoneNumberInvalid": "Phone number is required", - "registerPhoneNumberLeadingZero": "Enter the phone number without the leading zero", + "registerPhoneNumberLeadingZero": "Leave out the leading zero", "registerPhoneNumberOnlyDigits": "Only numbers are allowed", "registerPhoneNumberPrefixFormat": "Country code must be 1 to 3 digits", "registerPhoneNumberPrefixInvalid": "Country code is required", diff --git a/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error.png b/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error.png new file mode 100644 index 0000000000000000000000000000000000000000..edb90e2e32ba4a9b5ea70e4ec341e1beec953296 GIT binary patch literal 11776 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 literal 0 HcmV?d00001 diff --git a/test/goldens/widgets/form/phone_number_field_golden_test.dart b/test/goldens/widgets/form/phone_number_field_golden_test.dart index 12562fd4a..7aa337f13 100644 --- a/test/goldens/widgets/form/phone_number_field_golden_test.dart +++ b/test/goldens/widgets/form/phone_number_field_golden_test.dart @@ -21,5 +21,35 @@ void main() { ), ), ); + + // This is the longest message the field displays. + // Alchemist's wrapper pushes a MaterialPageRoute; the default + // pumpBeforeTest (precacheImages) settles it. Replacing that default with + // validate() plus a single pump() runs Form.validate() before FormField.build + // has registered the fields, so _fields is empty and the error never paints. + final formKey = GlobalKey(); + goldenTest( + 'leading-zero error phone number field', + fileName: 'phone_number_field_leading_zero_error', + constraints: phoneConstraints, + pumpBeforeTest: (tester) async { + await tester.pumpAndSettle(); + formKey.currentState!.validate(); + await tester.pumpAndSettle(); + }, + builder: () => wrapForGolden( + Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: formKey, + child: PhoneNumberField( + controller: ValueNotifier('+4100791234567'), + ), + ), + ), + ), + ), + ); }); } From d2ea90c5ab7aa208fbdf1e8404cb4e32d7ab5d0b Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 3 Sep 2026 10:08:54 +0200 Subject: [PATCH 07/13] docs(handbook): describe the phone prefix field as it is today Block 268 still described a prefix dropdown offering +41 or +49. #909 replaced that with a free field taking one to three digits. Also names the leading-zero message, which this branch introduces. --- docs/handbook/de/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index d2f10836e..920c68ca4 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -8056,7 +8056,7 @@

78Telefonnummer-Eingabe

/>
- Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das Vorwahl-Dropdown mit der Standardvorwahl +41 (Schweiz, alternativ +49 für Deutschland) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. + Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das frei eingebbare Vorwahlfeld mit der Standardvorwahl +41 (1 bis 3 Ziffern) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. Eine führende Null wird beim Zusammenbauen der Nummer entfernt; bleibt trotzdem eine übrig, meldet das Feld Ohne führende Null eingeben.
From e6860ea2c0b472243287067724959ae338532c7a Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 4 Sep 2026 10:40:58 +0200 Subject: [PATCH 08/13] test(registration): render the English leading-zero message too The golden only covered German, so the English string was shortened on an estimate rather than a measurement. wrapForGolden already takes a locale; this pins both. Each test now holds its own form key instead of sharing one. --- ...one_number_field_leading_zero_error_en.png | Bin 0 -> 12171 bytes .../form/phone_number_field_golden_test.dart | 57 ++++++++++++------ 2 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error_en.png diff --git a/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error_en.png b/test/goldens/widgets/form/goldens/macos/phone_number_field_leading_zero_error_en.png new file mode 100644 index 0000000000000000000000000000000000000000..151bed6ab20e94ee94b534938f1bb73f49523411 GIT binary patch literal 12171 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 P4>C2jyotJgKlXnCGJxvs literal 0 HcmV?d00001 diff --git a/test/goldens/widgets/form/phone_number_field_golden_test.dart b/test/goldens/widgets/form/phone_number_field_golden_test.dart index 7aa337f13..fbb5aafe8 100644 --- a/test/goldens/widgets/form/phone_number_field_golden_test.dart +++ b/test/goldens/widgets/form/phone_number_field_golden_test.dart @@ -4,6 +4,29 @@ import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; import '../../../helper/helper.dart'; +Widget _leadingZeroErrorField(GlobalKey formKey, {Locale locale = const Locale('de')}) { + return wrapForGolden( + Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: formKey, + child: PhoneNumberField( + controller: ValueNotifier('+4100791234567'), + ), + ), + ), + ), + locale: locale, + ); +} + +Future _validateThenSettle(WidgetTester tester, GlobalKey formKey) async { + await tester.pumpAndSettle(); + formKey.currentState!.validate(); + await tester.pumpAndSettle(); +} + void main() { group('$PhoneNumberField', () { goldenTest( @@ -22,34 +45,28 @@ void main() { ), ); - // This is the longest message the field displays. + // Pins the only error state this PR adds; an earlier wording of the + // message overflowed the field's single error line. // Alchemist's wrapper pushes a MaterialPageRoute; the default // pumpBeforeTest (precacheImages) settles it. Replacing that default with // validate() plus a single pump() runs Form.validate() before FormField.build // has registered the fields, so _fields is empty and the error never paints. - final formKey = GlobalKey(); + final deErrorKey = GlobalKey(); goldenTest( 'leading-zero error phone number field', fileName: 'phone_number_field_leading_zero_error', constraints: phoneConstraints, - pumpBeforeTest: (tester) async { - await tester.pumpAndSettle(); - formKey.currentState!.validate(); - await tester.pumpAndSettle(); - }, - builder: () => wrapForGolden( - Scaffold( - body: Padding( - padding: const EdgeInsets.all(16), - child: Form( - key: formKey, - child: PhoneNumberField( - controller: ValueNotifier('+4100791234567'), - ), - ), - ), - ), - ), + pumpBeforeTest: (tester) => _validateThenSettle(tester, deErrorKey), + builder: () => _leadingZeroErrorField(deErrorKey), + ); + + final enErrorKey = GlobalKey(); + goldenTest( + 'leading-zero error phone number field in English', + fileName: 'phone_number_field_leading_zero_error_en', + constraints: phoneConstraints, + pumpBeforeTest: (tester) => _validateThenSettle(tester, enErrorKey), + builder: () => _leadingZeroErrorField(enErrorKey, locale: const Locale('en')), ); }); } From 7ca257df241cbb4363f3a95d44643bc203174ece Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 4 Sep 2026 10:40:58 +0200 Subject: [PATCH 09/13] docs: stop claiming more than the phone field does The section intro still called the prefix a selection, and block 237 carried the same wording #909 made obsolete. Block 268 described an error message its own screenshot does not show, and stated the leading zero is always removed: it stays for Italy, and whenever stripping would leave an impossible length. screens.md counted one baseline under the shared-widget path; there are three, of which one carries the handbook slot. --- docs/handbook/de/index.html | 6 +++--- docs/screens.md | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 920c68ca4..997bddd9c 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -7280,7 +7280,7 @@

68Daten bearbeiten — Formulare

/>
- Das Formular Telefonnummer ändern im Ausgangszustand: eine Länderauswahl mit Vorwahl +41 und das Eingabefeld für die Nummer (Platzhalter 1231234567), darunter Speichern. Hier trägt der Nutzer seine neue Telefonnummer ein. + Das Formular Telefonnummer ändern im Ausgangszustand: ein frei eingebbares Vorwahlfeld mit der Standardvorwahl +41 und das Eingabefeld für die Nummer (Platzhalter 1231234567), darunter Speichern. Hier trägt der Nutzer seine neue Telefonnummer ein.
@@ -8039,7 +8039,7 @@

78Telefonnummer-Eingabe

- Das Telefonnummer-Feld kombiniert eine Vorwahl-Auswahl mit einem separaten Eingabefeld für die Rufnummer. Es wird bei der Registrierung und in Formularen verwendet, in denen eine Telefonnummer erfasst wird. + Das Telefonnummer-Feld kombiniert ein frei eingebbares Vorwahlfeld (1 bis 3 Ziffern) mit einem separaten Eingabefeld für die Rufnummer. Es wird bei der Registrierung und in Formularen verwendet, in denen eine Telefonnummer erfasst wird.

@@ -8056,7 +8056,7 @@

78Telefonnummer-Eingabe

/>
- Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das frei eingebbare Vorwahlfeld mit der Standardvorwahl +41 (1 bis 3 Ziffern) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. Eine führende Null wird beim Zusammenbauen der Nummer entfernt; bleibt trotzdem eine übrig, meldet das Feld Ohne führende Null eingeben. + Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das frei eingebbare Vorwahlfeld mit der Standardvorwahl +41 (1 bis 3 Ziffern) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. Eine führende Null wird beim Zusammenbauen nur entfernt, wenn der Nummernplan des Landes eine Verkehrsausscheidungsziffer kennt und die Nummer danach eine mögliche Länge behält; italienische Festnetznummern behalten ihre Null.
diff --git a/docs/screens.md b/docs/screens.md index d72bcaabc..4a8fb8790 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -28,8 +28,10 @@ Column meaning: and `debugAuth` (a `kDebugMode`-only dev tool). Each row lists **all** the slots whose Golden renders that widget — a screen usually has several (its default plus its state variants), so most cells now carry a range rather - than a single anchor. One Golden is a shared form widget rather than a - screen: slot `268` is `PhoneNumberField` under `test/goldens/widgets/form/`. + than a single anchor. Exactly one handbook slot is a shared form widget + rather than a screen: slot `268` is `PhoneNumberField` under + `test/goldens/widgets/form/`. That path also holds two error-state + baselines (German and English) that are not mapped to a handbook slot. Only `WebViewPage` (no active Golden) and `KycPageManager` (the orchestrator has no Golden of its own — its states are the individual KYC pages) still carry `—`. Slot ↔ Golden mapping in @@ -117,7 +119,9 @@ Column meaning: | Shared widgets | `PhoneNumberField` | — | — | `268` | 76 screens — 44 routed (`GoRoute`) + 32 non-routed. The table also carries -one shared form-widget baseline (`PhoneNumberField`), which is not a screen. +one shared form-widget slot (`PhoneNumberField`), which is not a screen; +three Golden baselines sit under `test/goldens/widgets/form/`, of which +only slot `268` is a handbook entry. ## Notes From 73a860996ba6240029fbbd5c1842924f55546bf6 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 4 Sep 2026 21:04:10 +0200 Subject: [PATCH 10/13] test(registration): pin that the canonical number reaches the submit call Every test so far stopped at the widget's ValueNotifier. The submit tests captured phoneNumber as any(), so replacing the notifier with a raw value in kyc_registration_page would have gone unnoticed and quietly undone the fix. Types a Swiss number with its trunk zero into the page and asserts the cubit receives exactly +41791234567. Also drops two documentation claims that this branch made untrue: screens.md said it covers every tested state variant while the same file now lists two unmapped error baselines, and the handbook made the leading-zero rule depend on the resulting length, which is not how the metadata decides. --- docs/handbook/de/index.html | 2 +- docs/screens.md | 4 +- .../kyc/steps/kyc_registration_page_test.dart | 58 +++++++++++++------ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index 997bddd9c..51768f757 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -8056,7 +8056,7 @@

78Telefonnummer-Eingabe

/>
- Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das frei eingebbare Vorwahlfeld mit der Standardvorwahl +41 (1 bis 3 Ziffern) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. Eine führende Null wird beim Zusammenbauen nur entfernt, wenn der Nummernplan des Landes eine Verkehrsausscheidungsziffer kennt und die Nummer danach eine mögliche Länge behält; italienische Festnetznummern behalten ihre Null. + Zeigt den Ausgangszustand des Telefonnummer-Felds: die Überschrift Telefonnummer, links das frei eingebbare Vorwahlfeld mit der Standardvorwahl +41 (1 bis 3 Ziffern) und rechts das noch leere Rufnummernfeld mit dem Platzhalter 1231234567. So sehen Nutzer das Feld, bevor sie eine Nummer eingeben; die Länge der Nummer wird erst serverseitig geprüft. Die Nummer wird nach den Regeln von libphonenumber in die internationale Form gebracht; wo der Nummernplan keine Verkehrsausscheidungsziffer kennt — bei italienischen Festnetznummern etwa —, bleibt die Null erhalten.
diff --git a/docs/screens.md b/docs/screens.md index 4a8fb8790..8d01a75c6 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -16,8 +16,8 @@ Column meaning: screen, or `—` if the screen has no Golden baseline. Each slot is a Visual-Regression Golden under `test/goldens/`, mapped to its handbook position by `scripts/assemble-handbook-screenshots.sh`. The handbook now - covers **all 284 Golden baselines** — every screen **plus every tested - state variant** (Default / Loading / Error / Snackbar / Dropdown / + covers **all 284 Golden baselines mapped to the handbook** — every mapped + screen and mapped state variant (Default / Loading / Error / Snackbar / Dropdown / Validation / Confirm / Success / Failure …), including the areas that were previously absent: Support (email capture, tickets, chat), Settings User-Data and its edit sub-pages, Settings Security, Receive, the BitBox diff --git a/test/screens/kyc/steps/kyc_registration_page_test.dart b/test/screens/kyc/steps/kyc_registration_page_test.dart index ad9182df6..b4016efc3 100644 --- a/test/screens/kyc/steps/kyc_registration_page_test.dart +++ b/test/screens/kyc/steps/kyc_registration_page_test.dart @@ -38,6 +38,7 @@ import 'package:realunit_wallet/screens/kyc/steps/registration/steps/kyc_registr import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/form/labeled_text_field.dart'; +import 'package:realunit_wallet/widgets/form/phone_number_field.dart'; import '../../../helper/helper.dart'; @@ -826,6 +827,7 @@ void main() { Future showTaxStep( WidgetTester tester, { RealUnitUserDataDto dto = initialUserData, + String? nationalPhoneNumber, }) async { await tester.pumpApp( buildSubject(KycRegistrationView(initialUserData: dto)), @@ -833,6 +835,15 @@ void main() { // Let the seeded country lookups resolve before we jump to the tax page. await tester.pumpAndSettle(); + if (nationalPhoneNumber != null) { + final phoneNumberField = find.descendant( + of: find.byType(PhoneNumberField), + matching: find.byType(TextFormField), + ); + await tester.enterText(phoneNumberField.at(1), nationalPhoneNumber); + await tester.pump(); + } + final index = registrationStepCubit.state.index; (tester.widget(find.byType(PageView)) as PageView).controller?.jumpToPage(index); await tester.pumpAndSettle(); @@ -857,7 +868,7 @@ void main() { type: any(named: 'type'), firstName: any(named: 'firstName'), lastName: any(named: 'lastName'), - phoneNumber: any(named: 'phoneNumber'), + phoneNumber: captureAny(named: 'phoneNumber'), birthday: any(named: 'birthday'), nationality: any(named: 'nationality'), addressStreet: any(named: 'addressStreet'), @@ -912,6 +923,15 @@ void main() { await tester.pumpAndSettle(); } + testWidgets('submits the canonical Swiss phone number', (tester) async { + await showTaxStep(tester, nationalPhoneNumber: '0791234567'); + + await tapComplete(tester); + + final captured = captureSubmit(); + expect(captured[0], '+41791234567'); + }); + // S1 — Address CH, tax: CH only → swiss=true, countryAndTINs=null testWidgets( 'S1 CH only: locked Swiss address → swissTaxResidence true, null countryAndTINs', @@ -921,9 +941,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); - expect(captured[1], isTrue); - expect(captured[2], isNull); + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[2], isTrue); + expect(captured[3], isNull); }, ); @@ -939,9 +959,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); - expect(captured[1], isFalse); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[2], isFalse); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); expect(tins.single.tin, '12 345 678 901'); @@ -960,9 +980,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); - expect(captured[1], isTrue); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[2], isTrue); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'FR'); expect(tins.single.tin, 'FR999'); @@ -983,9 +1003,9 @@ void main() { final captured = captureSubmit(); // DE (address, with TIN) + CH (additional) → swissTaxResidence true, // countryAndTINs only carries the non-CH entry. - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); - expect(captured[1], isTrue); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[2], isTrue); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); expect(tins.single.tin, 'DE111'); @@ -1008,9 +1028,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'DE')); - expect(captured[1], isFalse); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'DE')); + expect(captured[2], isFalse); + final tins = captured[3] as List; expect(tins, hasLength(3)); expect(tins[0].country, 'DE'); expect(tins[0].tin, 'DE111'); @@ -1068,9 +1088,9 @@ void main() { await tapComplete(tester); final captured = captureSubmit(); - expect(captured[0], isA().having((c) => c.symbol, 'symbol', 'CH')); - expect(captured[1], isTrue); - final tins = captured[2] as List; + expect(captured[1], isA().having((c) => c.symbol, 'symbol', 'CH')); + expect(captured[2], isTrue); + final tins = captured[3] as List; expect(tins, hasLength(1)); expect(tins.single.country, 'DE'); expect(tins.single.tin, 'DE123'); From eedf243405b9f0a8546a2bc9625000cdabdd1b79 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 4 Sep 2026 21:34:33 +0200 Subject: [PATCH 11/13] test(registration): pin that the signed and the submitted number are one string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the sentence the whole PR exists for, and nothing pinned it. The page test stops at a mocked cubit, and the service tests only checked the signature's length — so appending a space to the value handed to the signer would have gone unnoticed while the request still carried the correct number. That is exactly the failure #905 reports. Signs with a fixed key, rebuilds the EIP-712 message from the transmitted body and recovers the signer. It only matches if both sides hung on the same bytes. --- ..._unit_registration_service_happy_test.dart | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart index 101a31fce..4ebdcb200 100644 --- a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:eth_sig_util_plus/eth_sig_util_plus.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -127,6 +128,84 @@ void main() { ); group('completeRegistration happy path', () { + test( + 'signs and transmits phoneNumber as the same exact string', + () async { + const expectedPhoneNumber = '+41 79 000 00 00'; + Map? body; + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } + body = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'status': 'completed'}), 201); + }); + + await build(client).completeRegistration(buildRegistration()); + + expect(body!['phoneNumber'], expectedPhoneNumber); + + // Keep this EIP-712 message in sync with Eip712Signer.signRegistration. + // It catches mutations such as signing '$phoneNumber ' but sending phoneNumber. + final typedData = { + 'types': { + 'EIP712Domain': [ + {'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}, + ], + 'RealUnitUser': [ + {'name': 'email', 'type': 'string'}, + {'name': 'name', 'type': 'string'}, + {'name': 'type', 'type': 'string'}, + {'name': 'phoneNumber', 'type': 'string'}, + {'name': 'birthday', 'type': 'string'}, + {'name': 'nationality', 'type': 'string'}, + {'name': 'addressStreet', 'type': 'string'}, + {'name': 'addressPostalCode', 'type': 'string'}, + {'name': 'addressCity', 'type': 'string'}, + {'name': 'addressCountry', 'type': 'string'}, + {'name': 'swissTaxResidence', 'type': 'bool'}, + {'name': 'registrationDate', 'type': 'string'}, + {'name': 'walletAddress', 'type': 'address'}, + ], + }, + 'primaryType': 'RealUnitUser', + 'domain': { + 'name': 'RealUnitUser', + 'version': '1', + }, + 'message': { + 'email': body!['email'], + 'name': body!['name'], + 'type': body!['type'], + 'phoneNumber': body!['phoneNumber'], + 'birthday': body!['birthday'], + 'nationality': body!['nationality'], + 'addressStreet': body!['addressStreet'], + 'addressPostalCode': body!['addressPostalCode'], + 'addressCity': body!['addressCity'], + 'addressCountry': body!['addressCountry'], + 'swissTaxResidence': body!['swissTaxResidence'], + 'registrationDate': body!['registrationDate'], + 'walletAddress': body!['walletAddress'], + }, + }; + final messageHash = TypedDataUtil.hashMessage( + jsonData: jsonEncode(typedData), + version: TypedDataVersion.V4, + ); + final recoveredSigner = EthSigUtil.recoverSignature( + signature: body!['signature'] as String, + message: messageHash, + ); + + expect( + recoveredSigner.toLowerCase(), + _privKey.address.hexEip55.toLowerCase(), + ); + }, + ); + test( 'POSTs to /v1/realunit/register/complete with the ASCII-transliterated ' 'envelope, the EIP-712 signature, and the original KYC personal data', From b134c0cd53bd1cb45b3403f1affaf47f4bd1b334 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Fri, 4 Sep 2026 23:36:14 +0200 Subject: [PATCH 12/13] test(registration): pin the canonical value, not just that both sides match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signature test inherited this file's fixture, `+41 79 000 00 00` — which is not canonical E.164. It proved signed and sent bytes were equal, and would have stayed green if no canonicalisation had happened at all, so it covered half of what it was written for. Uses the canonical number in that one case and asserts the transmitted value literally; the other tests keep the old fixture through a defaulted parameter. Also moves the eth_sig_util_plus import behind the flutter ones, per the import order in CONTRIBUTING.md. --- ..._unit_registration_service_happy_test.dart | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart index 4ebdcb200..890c5ecd3 100644 --- a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'package:eth_sig_util_plus/eth_sig_util_plus.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:eth_sig_util_plus/eth_sig_util_plus.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:mocktail/mocktail.dart'; @@ -70,6 +70,7 @@ void main() { Registration buildRegistration({ bool swissTaxResidence = true, List? countryAndTINs, + String phoneNumber = '+41 79 000 00 00', }) => Registration( type: RegistrationUserType.human, email: 'AdA@ExAmPlE.COM', @@ -78,7 +79,7 @@ void main() { // round-trip below. firstName: 'Adä', lastName: 'Loveläce', - phoneNumber: '+41 79 000 00 00', + phoneNumber: phoneNumber, birthday: '1815-12-10', nationality: const Country( id: 41, @@ -128,10 +129,16 @@ void main() { ); group('completeRegistration happy path', () { + // Equality of signed and sent alone is not enough: both sides could + // agree on a non-canonical value and the test would still pass. The + // fixture below is the canonical E.164 form (no spaces, no trunk zero) + // — the shape the phone number field already produces before a + // Registration is ever built — so this test pins that value, not just + // that the two sides match each other. test( 'signs and transmits phoneNumber as the same exact string', () async { - const expectedPhoneNumber = '+41 79 000 00 00'; + const expectedPhoneNumber = '+41791234567'; Map? body; final client = MockClient((request) async { if (request.url.path == '/v1/realunit/register/date') { @@ -141,9 +148,12 @@ void main() { return http.Response(jsonEncode({'status': 'completed'}), 201); }); - await build(client).completeRegistration(buildRegistration()); + await build(client).completeRegistration( + buildRegistration(phoneNumber: expectedPhoneNumber), + ); - expect(body!['phoneNumber'], expectedPhoneNumber); + // Pins the exact canonical E.164 value that was transmitted. + expect(body!['phoneNumber'], '+41791234567'); // Keep this EIP-712 message in sync with Eip712Signer.signRegistration. // It catches mutations such as signing '$phoneNumber ' but sending phoneNumber. From 4022a3f8e1725fe9194a61599805b08c6a144dbd Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Sat, 5 Sep 2026 13:14:25 +0200 Subject: [PATCH 13/13] test(registration): pin the number across the last unguarded handover The value travels widget to page to cubit to service. The page test stops at a mocked cubit and the service test starts at the service, so the cubit itself was the one station where the number could change unnoticed: appending a space where it builds the Registration would have kept both neighbouring tests green. The existing cubit test already captures that object; this asserts its phone number is exactly the canonical string. --- .../kyc_registration_submit_cubit_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart index 7cc90a373..c7824fa44 100644 --- a/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart +++ b/test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart @@ -127,6 +127,9 @@ void main() { () => registrationService.completeRegistration(captureAny()), ).captured.single as Registration; + // Guard against changing the phone number while assembling the Registration. + // The mocked Page test and direct Service test would not observe that mutation. + expect(captured.phoneNumber, '+41791234567'); expect(captured.swissTaxResidence, isFalse); expect(captured.countryAndTINs, hasLength(1)); expect(captured.countryAndTINs!.single.country, 'DE');