CW-1073 Implement Monero wallet definition URI scheme (#2323)

* feat: add optional parameter to customize address extraction pattern * refactor: add parameter to control address extraction surrounding whitespace validation * fix: ensure proper handling of unmounted context in address extraction logic * test: add comprehensive unit tests for AddressResolver and AddressValidator classes

Konstantin Ullrich committed Jun 18, 2025 at 16:20 UTC 150becb679b88173cc60187dfdb1b31d2a850b83
6 files changed +401 -30
lib/core/address_validator.dart
+3 -7
@@ -38,9 +38,9 @@ class AddressValidator extends TextValidator {
38 '|[0-9a-zA-Z]{105}|addr1[0-9a-zA-Z]{98}';
39 case CryptoCurrency.btc:
40 pattern =
41 - '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${RegExp(r'(bc|tb)1q[ac-hj-np-z02-9]{25,39}}').pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}';
41 + '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${P2wpkhAddress.regex.pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}';
42 case CryptoCurrency.ltc:
43 - pattern = '^${RegExp(r'ltc1q[ac-hj-np-z02-9]{25,39}').pattern}\$|^${MwebAddress.regex.pattern}\$';
43 + pattern = '${P2wpkhAddress.regex.pattern}|${MwebAddress.regex.pattern}';
44 case CryptoCurrency.nano:
45 pattern = '[0-9a-zA-Z_]+';
46 case CryptoCurrency.banano:
@@ -335,10 +335,6 @@ class AddressValidator extends TextValidator {
335 }
336 }
337
338 - if (pattern != null) {
339 - return "$BEFORE_REGEX($pattern)$AFTER_REGEX";
340 - }
341 -
342 - return null;
338 + return pattern != null ? "($pattern)" : null;
339 }
340 }
lib/entities/parse_address_from_domain.dart
+8 -2
@@ -165,13 +165,19 @@ class AddressResolver {
165 "zone"
166 ];
167
168 - static String? extractAddressByType({required String raw, required CryptoCurrency type}) {
169 - final addressPattern = AddressValidator.getAddressFromStringPattern(type);
168 + static String? extractAddressByType(
169 + {required String raw,
170 + required CryptoCurrency type,
171 + bool requireSurroundingWhitespaces = true}) {
172 + var addressPattern = AddressValidator.getAddressFromStringPattern(type);
173
174 if (addressPattern == null) {
175 throw Exception('Unexpected token: $type for getAddressFromStringPattern');
176 }
177
178 + if (requireSurroundingWhitespaces)
179 + addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX";
180 +
181 final match = RegExp(addressPattern, multiLine: true).firstMatch(raw);
182 return match?.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'),
183 (Match match) {
lib/src/screens/send/widgets/extract_address_from_parsed.dart
+21 -20
@@ -8,6 +8,8 @@ import 'choose_yat_address_alert.dart';
8 Future<String> extractAddressFromParsed(
9 BuildContext context,
10 ParsedAddress parsedAddress) async {
11 + if (!context.mounted) return parsedAddress.addresses.first;
12 +
13 var title = '';
14 var content = '';
15 var address = '';
@@ -95,16 +97,17 @@ Future<String> extractAddressFromParsed(
97 content += S.of(context).choose_address;
98
99 address = await showPopUp<String?>(
98 - context: context,
99 - builder: (BuildContext context) {
100 -
101 - return WillPopScope(
100 + context: context,
101 + builder: (context) => PopScope(
102 child: ChooseYatAddressAlert(
103 alertTitle: title,
104 alertContent: content,
105 - addresses: parsedAddress.addresses),
106 - onWillPop: () async => false);
107 - }) ?? '';
105 + addresses: parsedAddress.addresses,
106 + ),
107 + canPop: false,
108 + ),
109 + ) ??
110 + '';
111
112 if (address.isEmpty) {
113 return parsedAddress.name;
@@ -113,22 +116,20 @@ Future<String> extractAddressFromParsed(
116 return address;
117 case ParseFrom.contact:
118 case ParseFrom.notParsed:
116 - address = parsedAddress.addresses.first;
117 - return address;
119 + return parsedAddress.addresses.first;
120 }
121
122 await showPopUp<void>(
121 - context: context,
122 - builder: (BuildContext context) {
123 -
124 - return AlertWithOneAction(
125 - alertTitle: title,
126 - headerTitleText: profileName.isEmpty ? null : profileName,
127 - headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl,
128 - alertContent: content,
129 - buttonText: S.of(context).ok,
130 - buttonAction: () => Navigator.of(context).pop());
131 - });
123 + context: context,
124 + builder: (context) => AlertWithOneAction(
125 + alertTitle: title,
126 + headerTitleText: profileName.isEmpty ? null : profileName,
127 + headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl,
128 + alertContent: content,
129 + buttonText: S.of(context).ok,
130 + buttonAction: () => Navigator.of(context).pop(),
131 + ),
132 + );
133
134 return address;
135 }
lib/view_model/restore/wallet_restore_from_qr_code.dart
+4 -1
@@ -74,7 +74,10 @@ class WalletRestoreFromQRCode {
74 static String? _extractAddressFromUrl(String rawString, WalletType type) {
75 try {
76 return AddressResolver.extractAddressByType(
77 - raw: rawString, type: walletTypeToCryptoCurrency(type));
77 + raw: rawString,
78 + type: walletTypeToCryptoCurrency(type),
79 + requireSurroundingWhitespaces: false,
80 + );
81 } catch (_) {
82 return null;
83 }
test/core/address_validator_test.dart new
+178
@@ -0,0 +1,178 @@
1 +import 'package:cake_wallet/core/address_validator.dart';
2 +import 'package:cake_wallet/generated/i18n.dart';
3 +import 'package:cw_core/crypto_currency.dart';
4 +import 'package:flutter_test/flutter_test.dart';
5 +
6 +void main() {
7 + group('AddressValidator', () {
8 + setUpAll(() {
9 + S.current = S();
10 + });
11 + group('getPattern', () {
12 + test('returns correct pattern for Bitcoin', () {
13 + final pattern = AddressValidator.getPattern(CryptoCurrency.btc);
14 + expect(pattern, isNotEmpty);
15 + expect(pattern, contains('(bc|tb)1q'));
16 + });
17 +
18 + test('returns correct pattern for Ethereum', () {
19 + final pattern = AddressValidator.getPattern(CryptoCurrency.eth);
20 + expect(pattern, isNotEmpty);
21 + expect(pattern, contains('0x[0-9a-zA-Z]+'));
22 + });
23 +
24 + test('returns correct pattern for Monero', () {
25 + final pattern = AddressValidator.getPattern(CryptoCurrency.xmr);
26 + expect(pattern, isNotEmpty);
27 + expect(pattern,
28 + contains('4[0-9a-zA-Z]{94}|8[0-9a-zA-Z]{94}|[0-9a-zA-Z]{106}'));
29 + });
30 +
31 + test('returns correct pattern for Litecoin', () {
32 + final pattern = AddressValidator.getPattern(CryptoCurrency.ltc);
33 + expect(pattern, isNotEmpty);
34 + expect(
35 + pattern,
36 + contains(
37 + '(bc|tb|ltc)1q[ac-hj-np-z02-9]{25,39}|(ltc|t)mweb1q[ac-hj-np-z02-9]{90,120}'));
38 + });
39 +
40 + test('returns empty string for unknown currency', () {
41 + final pattern = AddressValidator.getPattern(CryptoCurrency.btcln);
42 + expect(pattern, isNotEmpty);
43 + });
44 + });
45 +
46 + group('getLength', () {
47 + test('returns correct length for Bitcoin', () {
48 + final length = AddressValidator.getLength(CryptoCurrency.btc);
49 + expect(length, isNull);
50 + });
51 +
52 + test('returns correct length for Ethereum', () {
53 + final length = AddressValidator.getLength(CryptoCurrency.eth);
54 + expect(length, equals([42]));
55 + });
56 +
57 + test('returns correct length for Monero', () {
58 + final length = AddressValidator.getLength(CryptoCurrency.xmr);
59 + expect(length, isNull);
60 + });
61 +
62 + test('returns correct length for Dash', () {
63 + final length = AddressValidator.getLength(CryptoCurrency.dash);
64 + expect(length, equals([34]));
65 + });
66 + });
67 +
68 + group('getAddressFromStringPattern', () {
69 + test('returns correct pattern for Bitcoin', () {
70 + final pattern =
71 + AddressValidator.getAddressFromStringPattern(CryptoCurrency.btc);
72 + expect(pattern, isNotNull);
73 + expect(pattern, contains('(bc|tb)1q'));
74 + });
75 +
76 + test('returns correct pattern for Ethereum', () {
77 + final pattern =
78 + AddressValidator.getAddressFromStringPattern(CryptoCurrency.eth);
79 + expect(pattern, isNotNull);
80 + expect(pattern, contains('0x[0-9a-zA-Z]+'));
81 + });
82 +
83 + test('returns correct pattern for Monero', () {
84 + final pattern =
85 + AddressValidator.getAddressFromStringPattern(CryptoCurrency.xmr);
86 + expect(pattern, isNotNull);
87 + expect(pattern, contains('(4[0-9a-zA-Z]{94})'));
88 + });
89 +
90 + test('returns null for unsupported currency', () {
91 + final pattern =
92 + AddressValidator.getAddressFromStringPattern(CryptoCurrency.dash);
93 + expect(pattern, isNull);
94 + });
95 + });
96 + // 0.000058158099999999995 BTC
97 + group('validation', () {
98 + test('validates valid Bitcoin address', () {
99 + final validator = AddressValidator(type: CryptoCurrency.btc);
100 + expect(validator.isValid('bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq'),
101 + isTrue);
102 + expect(validator.isValid('3AD1Btx1MzYGmdpNpeujCfuvU5SsU2LX88'), isTrue);
103 + expect(validator.isValid('1HARAhFcvz8ZQp5MhnLFeUynC4bkha3Hv8'), isTrue);
104 + });
105 +
106 + test('rejects invalid Bitcoin address', () {
107 + final validator = AddressValidator(type: CryptoCurrency.btc);
108 + expect(validator.isValid('invalid_address'), isFalse);
109 + expect(validator.isValid('bc1qhg4l43pmq5v5atmtlr7gnwyuxs043CakeWallet'),
110 + isFalse);
111 + });
112 +
113 + test('validates valid Ethereum address', () {
114 + final validator = AddressValidator(type: CryptoCurrency.eth);
115 + expect(validator.isValid('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'),
116 + isTrue); // WETH contract
117 + });
118 +
119 + test('rejects invalid Ethereum address', () {
120 + final validator = AddressValidator(type: CryptoCurrency.eth);
121 + expect(validator.isValid('invalid_address'), isFalse);
122 + expect(validator.isValid('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc'),
123 + isFalse); // Too short
124 + expect(validator.isValid('C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'),
125 + isFalse); // Missing 0x prefix
126 + });
127 +
128 + test('validates valid Monero address', () {
129 + final validator = AddressValidator(type: CryptoCurrency.xmr);
130 + expect(
131 + validator.isValid(
132 + '85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNqh'),
133 + isTrue);
134 + });
135 +
136 + test('rejects invalid Monero address', () {
137 + final validator = AddressValidator(type: CryptoCurrency.xmr);
138 + expect(validator.isValid('invalid_address'), isFalse);
139 + expect(
140 + validator.isValid(
141 + '85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNq'),
142 + isFalse); // Too short
143 + });
144 +
145 + test('validates valid Litecoin address', () {
146 + final validator = AddressValidator(type: CryptoCurrency.ltc);
147 + expect(validator.isValid('ltc1qzvxlvlk8wsmue0np20eh3d3qxsusx9jstf8qw8'),
148 + isTrue);
149 + expect(
150 + validator.isValid(
151 + 'ltcmweb1qqt9hqch2d0vfdsvt4tf27gullem2tcd57xxrvta9xwvfmwdkn4927q6d8sq6ftw7lkqdkr5g36eqn7w06edgq8tz7gy0nv5d4lhajctkzuath23a'),
152 + isTrue);
153 + });
154 +
155 + test('rejects invalid Litecoin address', () {
156 + final validator = AddressValidator(type: CryptoCurrency.ltc);
157 + expect(validator.isValid('invalid_address'), isFalse);
158 + expect(
159 + validator.isValid('ltc1qzvxlvlk8wsmue0np20eh3d3qxsusxCakeWallet'),
160 + isFalse);
161 + });
162 + });
163 +
164 + group('silentPaymentAddressPattern', () {
165 + test('returns a non-empty pattern', () {
166 + final pattern = AddressValidator.silentPaymentAddressPattern;
167 + expect(pattern, isNotEmpty);
168 + });
169 + });
170 +
171 + group('mWebAddressPattern', () {
172 + test('returns a non-empty pattern', () {
173 + final pattern = AddressValidator.mWebAddressPattern;
174 + expect(pattern, isNotEmpty);
175 + });
176 + });
177 + });
178 +}
test/entities/parse_address_from_domain_test.dart new
+187
@@ -0,0 +1,187 @@
1 +import 'package:cake_wallet/entities/parse_address_from_domain.dart';
2 +import 'package:cw_core/crypto_currency.dart';
3 +import 'package:flutter_test/flutter_test.dart';
4 +
5 +void main() {
6 + group('AddressResolver', () {
7 + // late MockYatService mockYatService;
8 + // late MockWalletBase mockWallet;
9 + // late MockSettingsStore mockSettingsStore;
10 + // late MockBuildContext mockContext;
11 + // late AddressResolver addressResolver;
12 + //
13 + // setUp(() {
14 + // mockYatService = MockYatService();
15 + // mockWallet = MockWalletBase();
16 + // mockSettingsStore = MockSettingsStore();
17 + // mockContext = MockBuildContext();
18 + //
19 + // when(mockWallet.type).thenReturn(WalletType.bitcoin);
20 + // when(mockWallet.currency).thenReturn(CryptoCurrency.btc);
21 + //
22 + // addressResolver = AddressResolver(
23 + // yatService: mockYatService,
24 + // wallet: mockWallet,
25 + // settingsStore: mockSettingsStore,
26 + // );
27 + // });
28 +
29 + group('extractAddressByType', () {
30 + test('extracts Bitcoin address correctly', () {
31 + final raw =
32 + 'My Bitcoin address is bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq please use it';
33 + final result = AddressResolver.extractAddressByType(
34 + raw: raw,
35 + type: CryptoCurrency.btc,
36 + );
37 + expect(result, 'bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq');
38 + });
39 +
40 + test('extracts Ethereum address correctly', () {
41 + final raw =
42 + 'Send ETH to 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 thanks';
43 + final result = AddressResolver.extractAddressByType(
44 + raw: raw,
45 + type: CryptoCurrency.eth,
46 + );
47 + expect(result, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2');
48 + });
49 +
50 + test('extracts Monero address correctly', () {
51 + final raw =
52 + 'XMR: 85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNqh';
53 + final result = AddressResolver.extractAddressByType(
54 + raw: raw,
55 + type: CryptoCurrency.xmr,
56 + );
57 + expect(result,
58 + '85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNqh');
59 + });
60 +
61 + test('extracts Bitcoin Cash address correctly', () {
62 + final raw =
63 + 'BCH: bitcoincash:qr2z7dusk64qnq97azhg0u0hlf7qgwwfzyj92jgmqj';
64 + final result = AddressResolver.extractAddressByType(
65 + raw: raw,
66 + type: CryptoCurrency.bch,
67 + );
68 + expect(
69 + result, 'bitcoincash:qr2z7dusk64qnq97azhg0u0hlf7qgwwfzyj92jgmqj');
70 + });
71 +
72 + test('extracts Nano address correctly', () {
73 + final raw =
74 + 'NANO: nano_1natrium1o3z5519ifou7xii8crpxpk8y65qmkih8e8bpsjri651oza8imdd';
75 + final result = AddressResolver.extractAddressByType(
76 + raw: raw,
77 + type: CryptoCurrency.nano,
78 + );
79 + expect(result,
80 + 'nano_1natrium1o3z5519ifou7xii8crpxpk8y65qmkih8e8bpsjri651oza8imdd');
81 + });
82 +
83 + test('returns null for unsupported currency', () {
84 + final raw = 'Some text without an address';
85 + expect(
86 + () => AddressResolver.extractAddressByType(
87 + raw: raw,
88 + type: CryptoCurrency.btc,
89 + ),
90 + returnsNormally);
91 +
92 + final result = AddressResolver.extractAddressByType(
93 + raw: raw,
94 + type: CryptoCurrency.btc,
95 + );
96 + expect(result, isNull);
97 + });
98 +
99 + test('extracts monero address from URI', () {
100 + final raw =
101 + 'monero_wallet:467iotZU5tvG26k2xdZWkJ7gwATFVhfbuV3yDoWx5jHoPwxEi4f5BuJQwkP6GpCb1sZvUVB7nbSkgEuW8NKrh9KKRRga5qz?spend_key=029c559cd7669f14e91fd835144916009f8697ab5ac5c7f7c06e1ff869c17b0b&view_key=afaf646edbff3d3bcee8efd3383ffe5d20c947040f74e1110b70ca0fbb0ef90d';
102 + final result = AddressResolver.extractAddressByType(
103 + raw: raw,
104 + type: CryptoCurrency.xmr,
105 + requireSurroundingWhitespaces: false);
106 + expect(result,
107 + '467iotZU5tvG26k2xdZWkJ7gwATFVhfbuV3yDoWx5jHoPwxEi4f5BuJQwkP6GpCb1sZvUVB7nbSkgEuW8NKrh9KKRRga5qz');
108 + });
109 +
110 + test('extracts monero address from Tweet', () {
111 + final raw = '''
112 +#XMR
113 +89bH6i3ftaWSWuPJJYSQuuApWJ8xzinCEbbnAXN1Z3mGGUuAFdpBUg82R9MvJDSheJ6kW2dyMQEFUGM4tsZqRb2Q75UXqvc
114 +
115 +#BTC Silent Payments
116 +sp1qq0avpawwjg4l66p6lqafj0vlvm6rlhdc6qt0r6dfual835vhs3gvkq63pechaqezvn7j7uj2jucwj5k7nenpw2r86wf42xv6wqdvxuk5rggrul45
117 +
118 +#LTC MWEB
119 +ltcmweb1qq0at62jjucmawxp78qutn0cqwkwahcfx7fxls0r2ma5llg5w6wyy2qe20gxa3rku2658j88zg9d2j4ttpw35k0a5nrg93h5nq3wyvkcgwc3q4dgc
120 + ''';
121 + final resultXmr = AddressResolver.extractAddressByType(
122 + raw: raw, type: CryptoCurrency.xmr);
123 + expect(resultXmr,
124 + '89bH6i3ftaWSWuPJJYSQuuApWJ8xzinCEbbnAXN1Z3mGGUuAFdpBUg82R9MvJDSheJ6kW2dyMQEFUGM4tsZqRb2Q75UXqvc');
125 + final resultBtc = AddressResolver.extractAddressByType(
126 + raw: raw, type: CryptoCurrency.btc);
127 + expect(resultBtc,
128 + 'sp1qq0avpawwjg4l66p6lqafj0vlvm6rlhdc6qt0r6dfual835vhs3gvkq63pechaqezvn7j7uj2jucwj5k7nenpw2r86wf42xv6wqdvxuk5rggrul45');
129 + final resultLtc = AddressResolver.extractAddressByType(
130 + raw: raw, type: CryptoCurrency.ltc);
131 + expect(resultLtc,
132 + 'ltcmweb1qq0at62jjucmawxp78qutn0cqwkwahcfx7fxls0r2ma5llg5w6wyy2qe20gxa3rku2658j88zg9d2j4ttpw35k0a5nrg93h5nq3wyvkcgwc3q4dgc');
133 + });
134 +
135 + // test('throws exception for unexpected token', () {
136 + // // Create a custom crypto currency that won't have a pattern
137 + // final customCurrency = CryptoCurrency('CUSTOM', 'Custom');
138 + // expect(() => AddressResolver.extractAddressByType(
139 + // raw: 'Some text',
140 + // type: customCurrency,
141 + // ), throwsException);
142 + // });
143 + });
144 + //
145 + // group('isEmailFormat', () {
146 + // test('returns true for valid email format', () {
147 + // expect(addressResolver.isEmailFormat('user@example.com'), isTrue);
148 + // expect(addressResolver.isEmailFormat('name.surname@domain.co.uk'), isTrue);
149 + // expect(addressResolver.isEmailFormat('user123@subdomain.example.org'), isTrue);
150 + // });
151 + //
152 + // test('returns false for invalid email format', () {
153 + // expect(addressResolver.isEmailFormat('user@'), isFalse);
154 + // expect(addressResolver.isEmailFormat('@domain.com'), isFalse);
155 + // expect(addressResolver.isEmailFormat('user@domain'), isFalse);
156 + // expect(addressResolver.isEmailFormat('user.domain.com'), isFalse);
157 + // expect(addressResolver.isEmailFormat('user@domain@com'), isFalse);
158 + // expect(addressResolver.isEmailFormat('bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq'), isFalse);
159 + // });
160 + // });
161 + //
162 + // group('resolve', () {
163 + // test('returns ParsedAddress with original text when no resolution is possible', () async {
164 + // final text = 'bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq';
165 + // final result = await addressResolver.resolve(mockContext, text, CryptoCurrency.btc);
166 + //
167 + // expect(result, isA<ParsedAddress>());
168 + // expect(result.addresses, [text]);
169 + // });
170 + //
171 + // // Note: More comprehensive tests for the resolve method would require
172 + // // mocking all the external services and APIs that the method calls.
173 + // // This would be quite extensive and would require setting up mock
174 + // // responses for each type of address resolution.
175 + // });
176 +
177 + group('unstoppableDomains', () {
178 + test('contains expected TLDs', () {
179 + expect(AddressResolver.unstoppableDomains, contains('crypto'));
180 + expect(AddressResolver.unstoppableDomains, contains('eth'));
181 + expect(AddressResolver.unstoppableDomains, contains('bitcoin'));
182 + expect(AddressResolver.unstoppableDomains, contains('x'));
183 + expect(AddressResolver.unstoppableDomains, contains('wallet'));
184 + });
185 + });
186 + });
187 +}