diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 911e939d1..c79ac0980 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -38,9 +38,9 @@ class AddressValidator extends TextValidator { '|[0-9a-zA-Z]{105}|addr1[0-9a-zA-Z]{98}'; case CryptoCurrency.btc: pattern = - '${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}'; + '${P2pkhAddress.regex.pattern}|${P2shAddress.regex.pattern}|${P2wpkhAddress.regex.pattern}|${P2trAddress.regex.pattern}|${P2wshAddress.regex.pattern}|${SilentPaymentAddress.regex.pattern}'; case CryptoCurrency.ltc: - pattern = '^${RegExp(r'ltc1q[ac-hj-np-z02-9]{25,39}').pattern}\$|^${MwebAddress.regex.pattern}\$'; + pattern = '${P2wpkhAddress.regex.pattern}|${MwebAddress.regex.pattern}'; case CryptoCurrency.nano: pattern = '[0-9a-zA-Z_]+'; case CryptoCurrency.banano: @@ -335,10 +335,6 @@ class AddressValidator extends TextValidator { } } - if (pattern != null) { - return "$BEFORE_REGEX($pattern)$AFTER_REGEX"; - } - - return null; + return pattern != null ? "($pattern)" : null; } } diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart index 3fbbe0709..01e6322cc 100644 --- a/lib/entities/parse_address_from_domain.dart +++ b/lib/entities/parse_address_from_domain.dart @@ -165,13 +165,19 @@ class AddressResolver { "zone" ]; - static String? extractAddressByType({required String raw, required CryptoCurrency type}) { - final addressPattern = AddressValidator.getAddressFromStringPattern(type); + static String? extractAddressByType( + {required String raw, + required CryptoCurrency type, + bool requireSurroundingWhitespaces = true}) { + var addressPattern = AddressValidator.getAddressFromStringPattern(type); if (addressPattern == null) { throw Exception('Unexpected token: $type for getAddressFromStringPattern'); } + if (requireSurroundingWhitespaces) + addressPattern = "$BEFORE_REGEX$addressPattern$AFTER_REGEX"; + final match = RegExp(addressPattern, multiLine: true).firstMatch(raw); return match?.group(0)?.replaceAllMapped(RegExp('[^0-9a-zA-Z]|bitcoincash:|nano_|ban_'), (Match match) { diff --git a/lib/src/screens/send/widgets/extract_address_from_parsed.dart b/lib/src/screens/send/widgets/extract_address_from_parsed.dart index 29c4eb793..b37f87b7f 100644 --- a/lib/src/screens/send/widgets/extract_address_from_parsed.dart +++ b/lib/src/screens/send/widgets/extract_address_from_parsed.dart @@ -8,6 +8,8 @@ import 'choose_yat_address_alert.dart'; Future extractAddressFromParsed( BuildContext context, ParsedAddress parsedAddress) async { + if (!context.mounted) return parsedAddress.addresses.first; + var title = ''; var content = ''; var address = ''; @@ -95,16 +97,17 @@ Future extractAddressFromParsed( content += S.of(context).choose_address; address = await showPopUp( - context: context, - builder: (BuildContext context) { - - return WillPopScope( + context: context, + builder: (context) => PopScope( child: ChooseYatAddressAlert( alertTitle: title, alertContent: content, - addresses: parsedAddress.addresses), - onWillPop: () async => false); - }) ?? ''; + addresses: parsedAddress.addresses, + ), + canPop: false, + ), + ) ?? + ''; if (address.isEmpty) { return parsedAddress.name; @@ -113,22 +116,20 @@ Future extractAddressFromParsed( return address; case ParseFrom.contact: case ParseFrom.notParsed: - address = parsedAddress.addresses.first; - return address; + return parsedAddress.addresses.first; } await showPopUp( - context: context, - builder: (BuildContext context) { - - return AlertWithOneAction( - alertTitle: title, - headerTitleText: profileName.isEmpty ? null : profileName, - headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl, - alertContent: content, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop()); - }); + context: context, + builder: (context) => AlertWithOneAction( + alertTitle: title, + headerTitleText: profileName.isEmpty ? null : profileName, + headerImageProfileUrl: profileImageUrl.isEmpty ? null : profileImageUrl, + alertContent: content, + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(), + ), + ); return address; } diff --git a/lib/view_model/restore/wallet_restore_from_qr_code.dart b/lib/view_model/restore/wallet_restore_from_qr_code.dart index c8ff81acc..530ba1725 100644 --- a/lib/view_model/restore/wallet_restore_from_qr_code.dart +++ b/lib/view_model/restore/wallet_restore_from_qr_code.dart @@ -74,7 +74,10 @@ class WalletRestoreFromQRCode { static String? _extractAddressFromUrl(String rawString, WalletType type) { try { return AddressResolver.extractAddressByType( - raw: rawString, type: walletTypeToCryptoCurrency(type)); + raw: rawString, + type: walletTypeToCryptoCurrency(type), + requireSurroundingWhitespaces: false, + ); } catch (_) { return null; } diff --git a/test/core/address_validator_test.dart b/test/core/address_validator_test.dart new file mode 100644 index 000000000..08dd5a9e2 --- /dev/null +++ b/test/core/address_validator_test.dart @@ -0,0 +1,178 @@ +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AddressValidator', () { + setUpAll(() { + S.current = S(); + }); + group('getPattern', () { + test('returns correct pattern for Bitcoin', () { + final pattern = AddressValidator.getPattern(CryptoCurrency.btc); + expect(pattern, isNotEmpty); + expect(pattern, contains('(bc|tb)1q')); + }); + + test('returns correct pattern for Ethereum', () { + final pattern = AddressValidator.getPattern(CryptoCurrency.eth); + expect(pattern, isNotEmpty); + expect(pattern, contains('0x[0-9a-zA-Z]+')); + }); + + test('returns correct pattern for Monero', () { + final pattern = AddressValidator.getPattern(CryptoCurrency.xmr); + expect(pattern, isNotEmpty); + expect(pattern, + contains('4[0-9a-zA-Z]{94}|8[0-9a-zA-Z]{94}|[0-9a-zA-Z]{106}')); + }); + + test('returns correct pattern for Litecoin', () { + final pattern = AddressValidator.getPattern(CryptoCurrency.ltc); + expect(pattern, isNotEmpty); + expect( + pattern, + contains( + '(bc|tb|ltc)1q[ac-hj-np-z02-9]{25,39}|(ltc|t)mweb1q[ac-hj-np-z02-9]{90,120}')); + }); + + test('returns empty string for unknown currency', () { + final pattern = AddressValidator.getPattern(CryptoCurrency.btcln); + expect(pattern, isNotEmpty); + }); + }); + + group('getLength', () { + test('returns correct length for Bitcoin', () { + final length = AddressValidator.getLength(CryptoCurrency.btc); + expect(length, isNull); + }); + + test('returns correct length for Ethereum', () { + final length = AddressValidator.getLength(CryptoCurrency.eth); + expect(length, equals([42])); + }); + + test('returns correct length for Monero', () { + final length = AddressValidator.getLength(CryptoCurrency.xmr); + expect(length, isNull); + }); + + test('returns correct length for Dash', () { + final length = AddressValidator.getLength(CryptoCurrency.dash); + expect(length, equals([34])); + }); + }); + + group('getAddressFromStringPattern', () { + test('returns correct pattern for Bitcoin', () { + final pattern = + AddressValidator.getAddressFromStringPattern(CryptoCurrency.btc); + expect(pattern, isNotNull); + expect(pattern, contains('(bc|tb)1q')); + }); + + test('returns correct pattern for Ethereum', () { + final pattern = + AddressValidator.getAddressFromStringPattern(CryptoCurrency.eth); + expect(pattern, isNotNull); + expect(pattern, contains('0x[0-9a-zA-Z]+')); + }); + + test('returns correct pattern for Monero', () { + final pattern = + AddressValidator.getAddressFromStringPattern(CryptoCurrency.xmr); + expect(pattern, isNotNull); + expect(pattern, contains('(4[0-9a-zA-Z]{94})')); + }); + + test('returns null for unsupported currency', () { + final pattern = + AddressValidator.getAddressFromStringPattern(CryptoCurrency.dash); + expect(pattern, isNull); + }); + }); + // 0.000058158099999999995 BTC + group('validation', () { + test('validates valid Bitcoin address', () { + final validator = AddressValidator(type: CryptoCurrency.btc); + expect(validator.isValid('bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq'), + isTrue); + expect(validator.isValid('3AD1Btx1MzYGmdpNpeujCfuvU5SsU2LX88'), isTrue); + expect(validator.isValid('1HARAhFcvz8ZQp5MhnLFeUynC4bkha3Hv8'), isTrue); + }); + + test('rejects invalid Bitcoin address', () { + final validator = AddressValidator(type: CryptoCurrency.btc); + expect(validator.isValid('invalid_address'), isFalse); + expect(validator.isValid('bc1qhg4l43pmq5v5atmtlr7gnwyuxs043CakeWallet'), + isFalse); + }); + + test('validates valid Ethereum address', () { + final validator = AddressValidator(type: CryptoCurrency.eth); + expect(validator.isValid('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), + isTrue); // WETH contract + }); + + test('rejects invalid Ethereum address', () { + final validator = AddressValidator(type: CryptoCurrency.eth); + expect(validator.isValid('invalid_address'), isFalse); + expect(validator.isValid('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc'), + isFalse); // Too short + expect(validator.isValid('C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), + isFalse); // Missing 0x prefix + }); + + test('validates valid Monero address', () { + final validator = AddressValidator(type: CryptoCurrency.xmr); + expect( + validator.isValid( + '85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNqh'), + isTrue); + }); + + test('rejects invalid Monero address', () { + final validator = AddressValidator(type: CryptoCurrency.xmr); + expect(validator.isValid('invalid_address'), isFalse); + expect( + validator.isValid( + '85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNq'), + isFalse); // Too short + }); + + test('validates valid Litecoin address', () { + final validator = AddressValidator(type: CryptoCurrency.ltc); + expect(validator.isValid('ltc1qzvxlvlk8wsmue0np20eh3d3qxsusx9jstf8qw8'), + isTrue); + expect( + validator.isValid( + 'ltcmweb1qqt9hqch2d0vfdsvt4tf27gullem2tcd57xxrvta9xwvfmwdkn4927q6d8sq6ftw7lkqdkr5g36eqn7w06edgq8tz7gy0nv5d4lhajctkzuath23a'), + isTrue); + }); + + test('rejects invalid Litecoin address', () { + final validator = AddressValidator(type: CryptoCurrency.ltc); + expect(validator.isValid('invalid_address'), isFalse); + expect( + validator.isValid('ltc1qzvxlvlk8wsmue0np20eh3d3qxsusxCakeWallet'), + isFalse); + }); + }); + + group('silentPaymentAddressPattern', () { + test('returns a non-empty pattern', () { + final pattern = AddressValidator.silentPaymentAddressPattern; + expect(pattern, isNotEmpty); + }); + }); + + group('mWebAddressPattern', () { + test('returns a non-empty pattern', () { + final pattern = AddressValidator.mWebAddressPattern; + expect(pattern, isNotEmpty); + }); + }); + }); +} diff --git a/test/entities/parse_address_from_domain_test.dart b/test/entities/parse_address_from_domain_test.dart new file mode 100644 index 000000000..4ed7c8b5f --- /dev/null +++ b/test/entities/parse_address_from_domain_test.dart @@ -0,0 +1,187 @@ +import 'package:cake_wallet/entities/parse_address_from_domain.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AddressResolver', () { + // late MockYatService mockYatService; + // late MockWalletBase mockWallet; + // late MockSettingsStore mockSettingsStore; + // late MockBuildContext mockContext; + // late AddressResolver addressResolver; + // + // setUp(() { + // mockYatService = MockYatService(); + // mockWallet = MockWalletBase(); + // mockSettingsStore = MockSettingsStore(); + // mockContext = MockBuildContext(); + // + // when(mockWallet.type).thenReturn(WalletType.bitcoin); + // when(mockWallet.currency).thenReturn(CryptoCurrency.btc); + // + // addressResolver = AddressResolver( + // yatService: mockYatService, + // wallet: mockWallet, + // settingsStore: mockSettingsStore, + // ); + // }); + + group('extractAddressByType', () { + test('extracts Bitcoin address correctly', () { + final raw = + 'My Bitcoin address is bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq please use it'; + final result = AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.btc, + ); + expect(result, 'bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq'); + }); + + test('extracts Ethereum address correctly', () { + final raw = + 'Send ETH to 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 thanks'; + final result = AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.eth, + ); + expect(result, '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'); + }); + + test('extracts Monero address correctly', () { + final raw = + 'XMR: 85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNqh'; + final result = AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.xmr, + ); + expect(result, + '85s6zfxGAkdCN21h566R8EFDSfThxCrFiEkhw3JEtaXN2DDfahABLXTjRj385Ro7om5saGWJG7iuE6EyW5MYcoz93DLvNqh'); + }); + + test('extracts Bitcoin Cash address correctly', () { + final raw = + 'BCH: bitcoincash:qr2z7dusk64qnq97azhg0u0hlf7qgwwfzyj92jgmqj'; + final result = AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.bch, + ); + expect( + result, 'bitcoincash:qr2z7dusk64qnq97azhg0u0hlf7qgwwfzyj92jgmqj'); + }); + + test('extracts Nano address correctly', () { + final raw = + 'NANO: nano_1natrium1o3z5519ifou7xii8crpxpk8y65qmkih8e8bpsjri651oza8imdd'; + final result = AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.nano, + ); + expect(result, + 'nano_1natrium1o3z5519ifou7xii8crpxpk8y65qmkih8e8bpsjri651oza8imdd'); + }); + + test('returns null for unsupported currency', () { + final raw = 'Some text without an address'; + expect( + () => AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.btc, + ), + returnsNormally); + + final result = AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.btc, + ); + expect(result, isNull); + }); + + test('extracts monero address from URI', () { + final raw = + 'monero_wallet:467iotZU5tvG26k2xdZWkJ7gwATFVhfbuV3yDoWx5jHoPwxEi4f5BuJQwkP6GpCb1sZvUVB7nbSkgEuW8NKrh9KKRRga5qz?spend_key=029c559cd7669f14e91fd835144916009f8697ab5ac5c7f7c06e1ff869c17b0b&view_key=afaf646edbff3d3bcee8efd3383ffe5d20c947040f74e1110b70ca0fbb0ef90d'; + final result = AddressResolver.extractAddressByType( + raw: raw, + type: CryptoCurrency.xmr, + requireSurroundingWhitespaces: false); + expect(result, + '467iotZU5tvG26k2xdZWkJ7gwATFVhfbuV3yDoWx5jHoPwxEi4f5BuJQwkP6GpCb1sZvUVB7nbSkgEuW8NKrh9KKRRga5qz'); + }); + + test('extracts monero address from Tweet', () { + final raw = ''' +#XMR +89bH6i3ftaWSWuPJJYSQuuApWJ8xzinCEbbnAXN1Z3mGGUuAFdpBUg82R9MvJDSheJ6kW2dyMQEFUGM4tsZqRb2Q75UXqvc + +#BTC Silent Payments +sp1qq0avpawwjg4l66p6lqafj0vlvm6rlhdc6qt0r6dfual835vhs3gvkq63pechaqezvn7j7uj2jucwj5k7nenpw2r86wf42xv6wqdvxuk5rggrul45 + +#LTC MWEB +ltcmweb1qq0at62jjucmawxp78qutn0cqwkwahcfx7fxls0r2ma5llg5w6wyy2qe20gxa3rku2658j88zg9d2j4ttpw35k0a5nrg93h5nq3wyvkcgwc3q4dgc + '''; + final resultXmr = AddressResolver.extractAddressByType( + raw: raw, type: CryptoCurrency.xmr); + expect(resultXmr, + '89bH6i3ftaWSWuPJJYSQuuApWJ8xzinCEbbnAXN1Z3mGGUuAFdpBUg82R9MvJDSheJ6kW2dyMQEFUGM4tsZqRb2Q75UXqvc'); + final resultBtc = AddressResolver.extractAddressByType( + raw: raw, type: CryptoCurrency.btc); + expect(resultBtc, + 'sp1qq0avpawwjg4l66p6lqafj0vlvm6rlhdc6qt0r6dfual835vhs3gvkq63pechaqezvn7j7uj2jucwj5k7nenpw2r86wf42xv6wqdvxuk5rggrul45'); + final resultLtc = AddressResolver.extractAddressByType( + raw: raw, type: CryptoCurrency.ltc); + expect(resultLtc, + 'ltcmweb1qq0at62jjucmawxp78qutn0cqwkwahcfx7fxls0r2ma5llg5w6wyy2qe20gxa3rku2658j88zg9d2j4ttpw35k0a5nrg93h5nq3wyvkcgwc3q4dgc'); + }); + + // test('throws exception for unexpected token', () { + // // Create a custom crypto currency that won't have a pattern + // final customCurrency = CryptoCurrency('CUSTOM', 'Custom'); + // expect(() => AddressResolver.extractAddressByType( + // raw: 'Some text', + // type: customCurrency, + // ), throwsException); + // }); + }); + // + // group('isEmailFormat', () { + // test('returns true for valid email format', () { + // expect(addressResolver.isEmailFormat('user@example.com'), isTrue); + // expect(addressResolver.isEmailFormat('name.surname@domain.co.uk'), isTrue); + // expect(addressResolver.isEmailFormat('user123@subdomain.example.org'), isTrue); + // }); + // + // test('returns false for invalid email format', () { + // expect(addressResolver.isEmailFormat('user@'), isFalse); + // expect(addressResolver.isEmailFormat('@domain.com'), isFalse); + // expect(addressResolver.isEmailFormat('user@domain'), isFalse); + // expect(addressResolver.isEmailFormat('user.domain.com'), isFalse); + // expect(addressResolver.isEmailFormat('user@domain@com'), isFalse); + // expect(addressResolver.isEmailFormat('bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq'), isFalse); + // }); + // }); + // + // group('resolve', () { + // test('returns ParsedAddress with original text when no resolution is possible', () async { + // final text = 'bc1qhg4l43pmq5v5atmtlr7gnwyuxs043cvrut5hkq'; + // final result = await addressResolver.resolve(mockContext, text, CryptoCurrency.btc); + // + // expect(result, isA()); + // expect(result.addresses, [text]); + // }); + // + // // Note: More comprehensive tests for the resolve method would require + // // mocking all the external services and APIs that the method calls. + // // This would be quite extensive and would require setting up mock + // // responses for each type of address resolution. + // }); + + group('unstoppableDomains', () { + test('contains expected TLDs', () { + expect(AddressResolver.unstoppableDomains, contains('crypto')); + expect(AddressResolver.unstoppableDomains, contains('eth')); + expect(AddressResolver.unstoppableDomains, contains('bitcoin')); + expect(AddressResolver.unstoppableDomains, contains('x')); + expect(AddressResolver.unstoppableDomains, contains('wallet')); + }); + }); + }); +}