diff --git a/doc/library-development/publish.md b/doc/library-development/publish.md index 19b698038..66203f167 100644 --- a/doc/library-development/publish.md +++ b/doc/library-development/publish.md @@ -8,6 +8,10 @@ order: 100 ## publish to github +make sure ndk version.dart is up to date with version from pubspec.yaml, if not run: + +`dart run build_runner build --delete-conflicting-outputs` + Create a tag `git tag -a v1.2.3 -m "Release v1.2.3"` diff --git a/packages/drift/lib/src/drift_cache_manager.dart b/packages/drift/lib/src/drift_cache_manager.dart index 898b8d59c..30816f0f8 100644 --- a/packages/drift/lib/src/drift_cache_manager.dart +++ b/packages/drift/lib/src/drift_cache_manager.dart @@ -10,12 +10,7 @@ import 'package:ndk/domain_layer/entities/nip_65.dart'; import 'package:ndk/domain_layer/entities/pubkey_mapping.dart'; import 'package:ndk/domain_layer/entities/read_write_marker.dart'; import 'package:ndk/domain_layer/entities/user_relay_list.dart'; -import 'package:ndk/domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/wallet.dart'; -import 'package:ndk/domain_layer/entities/wallet/wallet_transaction.dart'; -import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_factory.dart'; import 'package:ndk/domain_layer/repositories/wallets_repo.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip01/event_kind_classification.dart'; @@ -1968,37 +1963,13 @@ class DriftCacheManager extends WalletsRepo implements CacheManager { .map((e) => e.toString()) .toSet(); - switch (type) { - case WalletType.CASHU: - return CashuWallet( - id: row.id, - name: row.name, - supportedUnits: supportedUnits, - mintUrl: metadata['mintUrl'] as String, - mintInfo: CashuMintInfo.fromJson( - metadata['mintInfo'] as Map, - mintUrl: metadata['mintUrl'] as String, - ), - ); - case WalletType.NWC: - return NwcWallet( - id: row.id, - name: row.name, - supportedUnits: supportedUnits, - nwcUrl: metadata['nwcUrl'] as String, - ); - case WalletType.LNURL: - return LnurlWallet( - id: row.id, - name: row.name, - supportedUnits: supportedUnits, - identifier: metadata['identifier'] as String, - lnurlPayUrl: metadata['lnurlPayUrl'] as String, - minSendable: metadata['minSendable'] as int?, - maxSendable: metadata['maxSendable'] as int?, - metadataFetchedAt: metadata['metadataFetchedAt'] as int?, - ); - } + return WalletFactory.fromStorage( + id: row.id, + name: row.name, + type: type, + supportedUnits: supportedUnits, + metadata: metadata, + ); } @override diff --git a/packages/drift/test/drift_cache_manager_test.dart b/packages/drift/test/drift_cache_manager_test.dart index fd5676527..ac7a4931a 100644 --- a/packages/drift/test/drift_cache_manager_test.dart +++ b/packages/drift/test/drift_cache_manager_test.dart @@ -1,10 +1,48 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ndk/domain_layer/entities/event_cache_records.dart'; +import 'package:ndk/entities.dart'; import 'package:ndk_drift/ndk_drift.dart'; import 'package:ndk_cache_manager_test_suite/ndk_cache_manager_test_suite.dart'; void main() { + test('persists and restores a BOLT12 wallet', () async { + final db = NdkCacheDatabase.forTesting(NativeDatabase.memory()); + final cacheManager = DriftCacheManager(db); + const offer = + 'lno1pqqq5xj5wajkcan9gdshx6pq23jhxarfdenjqstyv3ex2umnzcss80xkrjkyrjk43u5dgu8f6a450fg2cnjtg7lhg76c3gtk5gdhshns'; + final wallet = Bolt12Wallet( + id: 'bolt12-test', + name: 'BOLT12 test wallet', + supportedUnits: const {'sat'}, + offer: offer, + source: 'alice@example.com', + bip353Address: 'alice@example.com', + description: 'Test offer', + offerId: 'offer-id', + issuer: 'Test issuer', + currency: 'USD', + expiresAt: 2000000000, + quantityMax: 10, + hasBlindedPaths: true, + metadata: const {'cardColor': 123}, + ); + + await cacheManager.storeWallet(wallet); + final restored = await cacheManager.getWallet(wallet.id) as Bolt12Wallet; + + expect(restored.offer, offer); + expect(restored.bip353Address, wallet.bip353Address); + expect(restored.description, wallet.description); + expect(restored.issuer, wallet.issuer); + expect(restored.currency, wallet.currency); + expect(restored.expiresAt, wallet.expiresAt); + expect(restored.quantityMax, wallet.quantityMax); + expect(restored.hasBlindedPaths, isTrue); + expect(restored.metadata['cardColor'], 123); + + await cacheManager.close(); + }); + test('persists full event delivery record state', () async { final db = NdkCacheDatabase.forTesting(NativeDatabase.memory()); final cacheManager = DriftCacheManager(db); diff --git a/packages/ndk/example/nwc/README.md b/packages/ndk/example/nwc/README.md index 7e3c5a537..5a37fc360 100644 --- a/packages/ndk/example/nwc/README.md +++ b/packages/ndk/example/nwc/README.md @@ -9,3 +9,19 @@ see https://github.com/getAlby/awesome-nwc for more info how to get a wallet sup for more logging `NWC_URI=nostr+walletconnect://.... dart --enable-asserts connect_get_info.dart` + +## NWC-321 pay and receive + +Pay a BOLT11 invoice through a BIP-321 `lightning` instruction: + +`NWC_URI=nostr+walletconnect://.... INVOICE=lnbc... dart pay.dart` + +If the invoice has no amount, also provide `AMOUNT_MSAT`: + +`NWC_URI=nostr+walletconnect://.... INVOICE=lnbc... AMOUNT_MSAT=21000 dart pay.dart` + +Create a fixed-amount BIP-321 URI containing a BOLT11 instruction: + +`NWC_URI=nostr+walletconnect://.... AMOUNT_MSAT=21000 DESCRIPTION=hello dart receive.dart` + +Omit `AMOUNT_MSAT` to request a variable-amount URI. diff --git a/packages/ndk/example/nwc/connect_get_info.dart b/packages/ndk/example/nwc/connect_get_info.dart index e459b25c0..73af47456 100644 --- a/packages/ndk/example/nwc/connect_get_info.dart +++ b/packages/ndk/example/nwc/connect_get_info.dart @@ -17,6 +17,7 @@ void main() async { if (connection.info != null) { print("alias: ${connection.info!.alias}"); + print("methods: ${connection.info!.methods}"); if (connection.info!.pubkey != null) { print("pubkey: ${connection.info!.pubkey}"); } diff --git a/packages/ndk/example/nwc/pay.dart b/packages/ndk/example/nwc/pay.dart new file mode 100644 index 000000000..faf57fef0 --- /dev/null +++ b/packages/ndk/example/nwc/pay.dart @@ -0,0 +1,39 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:ndk/ndk.dart'; + +void main() async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + // Provide an NWC connection URI and the BOLT11 invoice to pay. + final nwcUri = Platform.environment['NWC_URI']!; + final invoice = Platform.environment['INVOICE']!; + final amountMsat = int.tryParse( + Platform.environment['AMOUNT_MSAT'] ?? '', + ); + + final connection = await ndk.nwc.connect(nwcUri); + + // NWC-321 expects a BIP-321 URI. This example contains only a BOLT11 + // `lightning` instruction. + final payment = Bip321.fromBolt11(invoice); + + final response = await ndk.nwc.pay( + connection, + payment: payment, + // Required only when the BOLT11 invoice has no amount. + amountMsat: amountMsat, + payerNote: Platform.environment['PAYER_NOTE'], + ); + + print('transaction id: ${response.transactionId}'); + print('state: ${response.state}'); + print('instruction type: ${response.instructionType}'); + print('amount: ${response.amountMsat} msats'); + print('fees paid: ${response.feesPaid} msats'); + print('preimage: ${response.preimage}'); + + await ndk.destroy(); +} diff --git a/packages/ndk/example/nwc/receive.dart b/packages/ndk/example/nwc/receive.dart new file mode 100644 index 000000000..dd9b7b9bf --- /dev/null +++ b/packages/ndk/example/nwc/receive.dart @@ -0,0 +1,29 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:ndk/ndk.dart'; + +void main() async { + final ndk = Ndk.emptyBootstrapRelaysConfig(); + + // Provide an NWC connection URI. Omit AMOUNT_MSAT for a variable amount. + final nwcUri = Platform.environment['NWC_URI']!; + final amountMsat = int.tryParse( + Platform.environment['AMOUNT_MSAT'] ?? '', + ); + + final connection = await ndk.nwc.connect(nwcUri); + final response = await ndk.nwc.receive( + connection, + amountMsat: amountMsat, + description: Platform.environment['DESCRIPTION'], + ); + + // For now, use a wallet whose `receive` implementation returns a BOLT11 + // `lightning` instruction in this BIP-321 URI. + print('BIP-321 URI: ${response.bip321}'); + print('transaction id: ${response.transactionId}'); + + await ndk.destroy(); +} diff --git a/packages/ndk/example/wallets/send.dart b/packages/ndk/example/wallets/send.dart index 519555513..87c666f7e 100644 --- a/packages/ndk/example/wallets/send.dart +++ b/packages/ndk/example/wallets/send.dart @@ -7,7 +7,8 @@ import 'package:ndk/domain_layer/entities/cashu/cashu_user_seedphrase.dart'; import 'package:ndk/ndk.dart'; Future main() async { - final invoice = Platform.environment['INVOICE']!; + final payment = Platform.environment['PAYMENT']!; + final amountSats = int.parse(Platform.environment['AMOUNT'] ?? '1000'); final ndk = Ndk( NdkConfig( @@ -29,10 +30,11 @@ Future main() async { final walletId = Platform.environment['WALLET_ID'] ?? wallets.first.id; - final result = await ndk.wallets.send(walletId: walletId, invoice: invoice); + final result = await ndk.wallets.payBip321(walletId: walletId, payment: payment, amountMsat: amountSats * 1000); print('Payment result:'); print('- preimage: ${result.preimage}'); + print('- payerProof: ${result.payerProof}'); print('- fees paid: ${result.feesPaid / 1000} sats'); if (result.errorCode != null || result.errorMessage != null) { print('- error code: ${result.errorCode}'); diff --git a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart index 9dfa27605..51922efbc 100644 --- a/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart +++ b/packages/ndk/lib/data_layer/models/wallet_transaction_model.dart @@ -19,6 +19,7 @@ class WalletTransactionModel { case WalletType.NWC: return NwcWalletTransactionModel.fromJson(json); case WalletType.LNURL: + case WalletType.BOLT12: return LnurlWalletTransactionModel.fromJson(json); } } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart new file mode 100644 index 000000000..49e68cc5c --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/bip321.dart @@ -0,0 +1,86 @@ +/// Helpers for BIP-321 URIs containing BOLT11 payment instructions. +class Bip321 { + Bip321._(); + + /// Creates a BIP-321 URI containing one BOLT11 `lightning` instruction. + static String fromBolt11(String invoice) { + if (!invoice.toLowerCase().startsWith('ln')) { + throw ArgumentError.value(invoice, 'invoice', 'Must be a BOLT11 invoice'); + } + return Uri( + scheme: 'bitcoin', + queryParameters: {'lightning': invoice}, + ).toString(); + } + + /// Selects the single BOLT11 `lightning` instruction from [payment]. + static String getBolt11(String payment) { + final Uri uri; + try { + uri = Uri.parse(payment); + } on FormatException { + throw FormatException('Invalid BIP-321 URI', payment); + } + + if (uri.scheme.toLowerCase() != 'bitcoin') { + throw FormatException('BIP-321 URI must use the bitcoin scheme', payment); + } + + final requiredParameters = uri.queryParametersAll.keys.where( + (key) => key.startsWith('req-'), + ); + if (requiredParameters.isNotEmpty) { + throw UnsupportedError( + 'Unsupported required BIP-321 parameter: ' + '${requiredParameters.first}', + ); + } + + final instructions = uri.queryParametersAll['lightning']; + if (instructions == null || + instructions.length != 1 || + instructions.single.isEmpty) { + throw const FormatException( + 'BIP-321 URI must contain one lightning instruction', + ); + } + + final invoice = instructions.single; + if (!invoice.toLowerCase().startsWith('ln')) { + throw const FormatException('Invalid BOLT11 lightning instruction'); + } + return invoice; + } + + /// Returns the BOLT11 amount in millisatoshis, or null when amountless. + static int? getBolt11AmountMsat(String invoice) { + final separator = invoice.toLowerCase().lastIndexOf('1'); + if (separator < 0) { + throw const FormatException('Invalid BOLT11 invoice'); + } + + final hrp = invoice.toLowerCase().substring(0, separator); + final match = RegExp( + r'^ln(?:bcrt|bc|tb|sb)([0-9]*)([munp]?)$', + ).firstMatch(hrp); + if (match == null) { + throw const FormatException('Invalid BOLT11 invoice prefix'); + } + + final digits = match.group(1)!; + if (digits.isEmpty) return null; + + final amount = int.parse(digits); + return switch (match.group(2)!) { + '' => amount * 100000000000, + 'm' => amount * 100000000, + 'u' => amount * 100000, + 'n' => amount * 100, + 'p' when amount % 10 == 0 => amount ~/ 10, + 'p' => throw const FormatException( + 'BOLT11 pico-bitcoin amount is not a whole millisatoshi', + ), + _ => throw const FormatException('Invalid BOLT11 amount multiplier'), + }; + } +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart new file mode 100644 index 000000000..2d19ff30b --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart @@ -0,0 +1,117 @@ +import '../../wallet.dart'; +import '../../wallet_type.dart'; + +/// A receive-only wallet backed by a reusable BOLT12 offer. +class Bolt12Wallet extends Wallet { + /// Canonical, lowercase `lno...` offer. + final String offer; + + /// The value originally entered or scanned by the user. + final String source; + + /// BIP353 address used to resolve [offer], when applicable. + final String? bip353Address; + + final String? description; + final String? nodeId; + final String? offerId; + final String? amount; + final String? issuer; + final String? currency; + final int? expiresAt; + final int? quantityMax; + final bool hasBlindedPaths; + + Bolt12Wallet({ + required super.id, + required super.name, + super.type = WalletType.BOLT12, + required super.supportedUnits, + required this.offer, + required this.source, + this.bip353Address, + this.description, + this.nodeId, + this.offerId, + this.amount, + this.issuer, + this.currency, + this.expiresAt, + this.quantityMax, + this.hasBlindedPaths = false, + Map? metadata, + }) : super( + metadata: Map.unmodifiable({ + ...(metadata ?? const {}), + 'offer': offer, + 'source': source, + 'bip353Address': bip353Address, + 'description': description, + 'nodeId': nodeId, + 'offerId': offerId, + 'amount': amount, + 'issuer': issuer, + 'currency': currency, + 'expiresAt': expiresAt, + 'quantityMax': quantityMax, + 'hasBlindedPaths': hasBlindedPaths, + }), + ); + + @override + Map toMetadata() => metadata; + + static Bolt12Wallet fromStorage({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) { + final offer = metadata['offer'] as String?; + if (offer == null || offer.isEmpty) { + throw ArgumentError('Bolt12Wallet storage requires metadata["offer"]'); + } + + return Bolt12Wallet( + id: id, + name: name, + supportedUnits: supportedUnits, + offer: offer, + source: metadata['source'] as String? ?? offer, + bip353Address: metadata['bip353Address'] as String?, + description: metadata['description'] as String?, + nodeId: metadata['nodeId'] as String?, + offerId: metadata['offerId'] as String?, + amount: metadata['amount']?.toString(), + issuer: metadata['issuer'] as String?, + currency: metadata['currency'] as String?, + expiresAt: _readInt(metadata['expiresAt']), + quantityMax: _readInt(metadata['quantityMax']), + hasBlindedPaths: metadata['hasBlindedPaths'] == true, + metadata: metadata, + ); + } + + @override + bool get canReceive => true; + + @override + bool get canSend => false; + + @override + Set get receivePaymentProtocols => const { + WalletPaymentProtocol.bolt12, + }; + + @override + bool get supportsBip321Receive => true; + + @override + bool get supportsBolt11InvoiceReceive => false; + + static int? _readInt(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart new file mode 100644 index 000000000..737d167e5 --- /dev/null +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart @@ -0,0 +1,568 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dart_bip353/dart_bip353.dart'; +import 'package:dart_bolt12_decoder/dart_bolt12_decoder.dart'; + +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import '../../wallet.dart'; +import '../../wallet_balance.dart'; +import '../../wallet_provider.dart'; +import '../../wallet_transaction.dart'; +import '../../wallet_type.dart'; +import 'bolt12_wallet.dart'; + +typedef Bip353OfferResolver = Future Function(String address); + +class Bolt12ResolvedOffer { + final String offer; + final String source; + final String? bip353Address; + final Map decoded; + + const Bolt12ResolvedOffer({ + required this.offer, + required this.source, + required this.decoded, + this.bip353Address, + }); + + Map toMetadata() => { + 'offer': offer, + 'source': source, + 'bip353Address': bip353Address, + 'description': _nonEmptyString(decoded['offer_description']), + 'nodeId': _nonEmptyString(decoded['offer_node_id']), + 'offerId': _nonEmptyString(decoded['offer_id']), + 'amount': _nonEmptyString(decoded['offer_amount']), + 'issuer': _nonEmptyString(decoded['offer_issuer']), + 'currency': _nonEmptyString(decoded['offer_currency']), + 'expiresAt': _intValue(decoded['offer_absolute_expiry']), + 'quantityMax': _intValue(decoded['offer_quantity_max']), + 'hasBlindedPaths': decoded['has_blinded_paths'] == true || + _hasValues(decoded['offer_paths']), + }; + + static String? _nonEmptyString(Object? value) { + final normalized = value?.toString().trim(); + return normalized == null || normalized.isEmpty ? null : normalized; + } + + static int? _intValue(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } + + static bool _hasValues(Object? value) { + if (value is Iterable) return value.isNotEmpty; + return value != null; + } +} + +/// Provider for receive-only BOLT12 offer wallets. +class Bolt12WalletProvider implements WalletProvider { + const Bolt12WalletProvider(); + + @override + WalletType get type => WalletType.BOLT12; + + /// Whether [input] has the shape of a supported BOLT12 input. + /// + /// Full offer validation and BIP353 DNS resolution happen in [resolveInput]. + static bool isSupportedInput(String input) { + final value = input.trim(); + if (value.isEmpty) return false; + if (_directOffer(value) != null) return true; + return _bip353Address(value) != null; + } + + /// Resolves a direct offer, a BIP321 `bitcoin:?lno=...` URI, or a BIP353 + /// address into a validated canonical BOLT12 offer. + static Future resolveInput( + String input, { + Bip353OfferResolver? bip353Resolver, + }) async { + final source = input.trim(); + if (source.isEmpty) { + throw const FormatException('BOLT12 offer or BIP353 address is required'); + } + + final direct = _directOffer(source); + if (direct != null) { + return _validate(offer: direct, source: source); + } + + final address = _bip353Address(source); + if (address == null) { + throw const FormatException( + 'Expected an lno offer, bitcoin:?lno=... URI, or BIP353 address', + ); + } + + final resolver = bip353Resolver ?? _resolveBip353; + final resolvedOffer = await resolver(address); + if (resolvedOffer == null || resolvedOffer.trim().isEmpty) { + throw FormatException( + 'BIP353 address $address does not publish a BOLT12 offer', + ); + } + + return _validate( + offer: resolvedOffer, + source: source, + bip353Address: address, + ); + } + + static Future _resolveBip353(String address) async { + final response = await Bip353.getAdressResolve(address); + return response.offer; + } + + static Bolt12ResolvedOffer _validate({ + required String offer, + required String source, + String? bip353Address, + }) { + final envelope = _Bolt12OfferEnvelope.parse(offer); + final canonicalOffer = envelope.canonicalOffer; + + // dart_bolt12_decoder 0.8.0 predates the current blinded-path encoding + // and also requires description + issuer id, which modern offers may omit. + // Use it for the offer shapes it understands and retain strict structural + // validation for current-spec offers. + Map decoded = envelope.details; + if (envelope.isSupportedByDetailDecoder) { + final packageDecoded = Bolt12Decoder.decode(canonicalOffer); + if (packageDecoded != null && + packageDecoded['type'] == 'offer' && + packageDecoded['valid'] == true) { + decoded = {...decoded, ...packageDecoded}; + } + } + + return Bolt12ResolvedOffer( + offer: canonicalOffer, + source: source, + bip353Address: bip353Address, + decoded: Map.unmodifiable(decoded), + ); + } + + static String? _directOffer(String input) { + final value = input.trim(); + if (value.toLowerCase().startsWith('lno1')) return value; + + Uri uri; + try { + final normalizedValue = value.toLowerCase().startsWith('bitcoin?') + ? 'bitcoin:${value.substring('bitcoin'.length)}' + : value; + uri = Uri.parse(normalizedValue); + } on FormatException { + return null; + } + if (uri.scheme.toLowerCase() != 'bitcoin') return null; + + for (final entry in uri.queryParameters.entries) { + if (entry.key.toLowerCase() == 'lno' && entry.value.isNotEmpty) { + return entry.value; + } + } + return null; + } + + static String? _bip353Address(String input) { + var value = input.trim(); + if (value.startsWith('₿')) value = value.substring(1); + final parts = value.split('@'); + if (parts.length != 2 || + parts[0].isEmpty || + parts[1].isEmpty || + value.contains(RegExp(r'\s'))) { + return null; + } + return value; + } + + @override + Wallet createWallet({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) { + final offer = metadata['offer'] as String?; + if (offer == null || offer.isEmpty) { + throw ArgumentError( + 'Bolt12Wallet requires resolved metadata from resolveInput()', + ); + } + + final validated = _validate( + offer: offer, + source: metadata['source'] as String? ?? offer, + bip353Address: metadata['bip353Address'] as String?, + ); + final resolvedMetadata = { + ...metadata, + ...validated.toMetadata(), + }; + + return Bolt12Wallet( + id: id, + name: name, + supportedUnits: supportedUnits, + offer: validated.offer, + source: validated.source, + bip353Address: validated.bip353Address, + description: resolvedMetadata['description'] as String?, + nodeId: resolvedMetadata['nodeId'] as String?, + offerId: resolvedMetadata['offerId'] as String?, + amount: resolvedMetadata['amount']?.toString(), + issuer: resolvedMetadata['issuer'] as String?, + currency: resolvedMetadata['currency'] as String?, + expiresAt: Bolt12ResolvedOffer._intValue( + resolvedMetadata['expiresAt'], + ), + quantityMax: Bolt12ResolvedOffer._intValue( + resolvedMetadata['quantityMax'], + ), + hasBlindedPaths: resolvedMetadata['hasBlindedPaths'] == true, + metadata: resolvedMetadata, + ); + } + + @override + Future initialize(Wallet wallet) async { + final bolt12Wallet = wallet as Bolt12Wallet; + _validate(offer: bolt12Wallet.offer, source: bolt12Wallet.source); + return null; + } + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Stream> getBalances(Wallet wallet) => Stream.value([]); + + @override + Stream> getPendingTransactions(Wallet wallet) => + Stream.value([]); + + @override + Stream> getRecentTransactions(Wallet wallet) => + Stream.value([]); + + @override + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) { + throw UnsupportedError( + 'BOLT12 wallet is receive-only and cannot pay invoices', + ); + } + + @override + Future receive(Wallet wallet, int amountSats) async => + (wallet as Bolt12Wallet).offer; + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) { + throw UnsupportedError( + 'BOLT12 wallet is receive-only and cannot pay BIP-321 instructions', + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + if (description?.isNotEmpty == true) { + throw UnsupportedError( + 'A BOLT12 offer controls its own payment description', + ); + } + final offer = (wallet as Bolt12Wallet).offer; + return ReceiveResponse( + resultType: 'receive', + bip321: Uri( + scheme: 'bitcoin', + queryParameters: {'lno': offer}, + ).toString(), + ); + } + + @override + Stream> get discoveredWallets => Stream.value([]); +} + +class _Bolt12OfferEnvelope { + static const _alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + static const _knownOfferTypes = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22}; + + final String canonicalOffer; + final Map> fields; + final Map details; + + const _Bolt12OfferEnvelope({ + required this.canonicalOffer, + required this.fields, + required this.details, + }); + + bool get isSupportedByDetailDecoder => + !fields.containsKey(16) && + fields.containsKey(10) && + fields.containsKey(22); + + static _Bolt12OfferEnvelope parse(String input) { + final withoutContinuations = input.trim().replaceAll( + RegExp(r'\+\s*'), + '', + ); + final letters = withoutContinuations.replaceAll(RegExp('[^A-Za-z]'), ''); + if (letters != letters.toLowerCase() && letters != letters.toUpperCase()) { + throw const FormatException( + 'BOLT12 strings must not mix uppercase and lowercase', + ); + } + + final canonical = withoutContinuations.toLowerCase(); + if (!canonical.startsWith('lno1')) { + throw const FormatException('BOLT12 offers must start with lno1'); + } + final encoded = canonical.substring(4); + if (encoded.isEmpty) { + throw const FormatException('BOLT12 offer has no encoded data'); + } + + final values = []; + for (final codeUnit in encoded.codeUnits) { + final value = _alphabet.indexOf(String.fromCharCode(codeUnit)); + if (value < 0) { + throw const FormatException('Invalid character in BOLT12 offer'); + } + values.add(value); + } + + final bytes = _convertFiveToEightBits(values); + final fields = >{}; + var offset = 0; + var previousType = -1; + while (offset < bytes.length) { + final typeResult = _readBigSize(bytes, offset); + final type = typeResult.value; + offset = typeResult.nextOffset; + final lengthResult = _readBigSize(bytes, offset); + final length = lengthResult.value; + offset = lengthResult.nextOffset; + + if (type <= previousType) { + throw const FormatException( + 'BOLT12 TLV fields must be unique and ordered', + ); + } + if (!((type >= 1 && type <= 79) || + (type >= 1000000000 && type <= 1999999999))) { + throw FormatException('Invalid BOLT12 offer field type $type'); + } + if (type <= 79 && type.isEven && !_knownOfferTypes.contains(type)) { + throw FormatException('Unknown required BOLT12 offer field $type'); + } + if (length < 0 || length > bytes.length - offset) { + throw const FormatException('Truncated BOLT12 offer field'); + } + + fields[type] = bytes.sublist(offset, offset + length); + offset += length; + previousType = type; + } + + _validateOfferFields(fields); + return _Bolt12OfferEnvelope( + canonicalOffer: canonical, + fields: Map.unmodifiable(fields), + details: Map.unmodifiable(_basicDetails(fields)), + ); + } + + static void _validateOfferFields(Map> fields) { + final paths = fields[16]; + final issuerId = fields[22]; + if ((paths == null || paths.isEmpty) && issuerId == null) { + throw const FormatException( + 'BOLT12 offer requires offer_paths or offer_issuer_id', + ); + } + if (issuerId != null && issuerId.length != 33) { + throw const FormatException('Invalid BOLT12 offer_issuer_id'); + } + + final chains = fields[2]; + if (chains != null && (chains.isEmpty || chains.length % 32 != 0)) { + throw const FormatException('Invalid BOLT12 offer_chains'); + } + final currency = fields[6]; + if (currency != null && currency.length != 3) { + throw const FormatException('Invalid BOLT12 offer_currency'); + } + + final amountBytes = fields[8]; + if (amountBytes != null) { + _readTu64(amountBytes); + if (!fields.containsKey(10)) { + throw const FormatException( + 'BOLT12 offer_amount requires offer_description', + ); + } + } else if (currency != null) { + throw const FormatException( + 'BOLT12 offer_currency requires offer_amount', + ); + } + + final expiryBytes = fields[14]; + if (expiryBytes != null) { + final expiry = _readTu64(expiryBytes); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (expiry < now) { + throw const FormatException('BOLT12 offer has expired'); + } + } + } + + static Map _basicDetails(Map> fields) { + final details = {'type': 'offer', 'valid': true}; + final currency = fields[6]; + if (currency != null) { + try { + details['offer_currency'] = utf8.decode(currency); + } on FormatException { + throw const FormatException('Invalid UTF-8 in BOLT12 currency'); + } + } + final amount = fields[8]; + if (amount != null) details['offer_amount'] = _readTu64(amount).toString(); + final description = fields[10]; + if (description != null) { + try { + details['offer_description'] = utf8.decode(description); + } on FormatException { + throw const FormatException('Invalid UTF-8 in BOLT12 description'); + } + } + final issuer = fields[18]; + if (issuer != null) { + try { + details['offer_issuer'] = utf8.decode(issuer); + } on FormatException { + throw const FormatException('Invalid UTF-8 in BOLT12 issuer'); + } + } + final expiry = fields[14]; + if (expiry != null) { + details['offer_absolute_expiry'] = _readTu64(expiry); + } + final paths = fields[16]; + if (paths != null && paths.isNotEmpty) { + details['has_blinded_paths'] = true; + } + final quantityMax = fields[20]; + if (quantityMax != null) { + details['offer_quantity_max'] = _readTu64(quantityMax); + } + final issuerId = fields[22]; + if (issuerId != null) { + details['offer_node_id'] = + issuerId.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + } + return details; + } + + static List _convertFiveToEightBits(List values) { + const maxAccumulator = (1 << 12) - 1; + var accumulator = 0; + var bits = 0; + final result = []; + for (final value in values) { + accumulator = ((accumulator << 5) | value) & maxAccumulator; + bits += 5; + while (bits >= 8) { + bits -= 8; + result.add((accumulator >> bits) & 0xff); + } + } + if (bits >= 5 || ((accumulator << (8 - bits)) & 0xff) != 0) { + throw const FormatException('Invalid BOLT12 data padding'); + } + return result; + } + + static _BigSizeResult _readBigSize(List bytes, int offset) { + if (offset >= bytes.length) { + throw const FormatException('Truncated BOLT12 bigsize'); + } + final first = bytes[offset++]; + if (first < 0xfd) return _BigSizeResult(first, offset); + + final byteCount = first == 0xfd + ? 2 + : first == 0xfe + ? 4 + : 8; + if (offset + byteCount > bytes.length) { + throw const FormatException('Truncated BOLT12 bigsize'); + } + var value = 0; + for (var index = 0; index < byteCount; index++) { + value = value * 256 + bytes[offset + index]; + if (value > 1999999999 && byteCount == 8) { + throw const FormatException('BOLT12 bigsize is too large'); + } + } + final minimum = byteCount == 2 + ? 0xfd + : byteCount == 4 + ? 0x10000 + : 0x100000000; + if (value < minimum) { + throw const FormatException('Non-canonical BOLT12 bigsize'); + } + return _BigSizeResult(value, offset + byteCount); + } + + static int _readTu64(List bytes) { + if (bytes.length > 8 || (bytes.isNotEmpty && bytes.first == 0)) { + throw const FormatException('Invalid BOLT12 truncated integer'); + } + var value = 0; + for (final byte in bytes) { + value = value * 256 + byte; + } + return value; + } +} + +class _BigSizeResult { + final int value; + final int nextOffset; + + const _BigSizeResult(this.value, this.nextOffset); +} diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart index 093d9eba1..d8caa8fd3 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart @@ -2,7 +2,10 @@ import 'dart:async'; import '../../../../usecases/cashu/cashu.dart'; import '../../../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../../../usecases/nwc/responses/pay_response.dart'; +import '../../../../usecases/nwc/responses/receive_response.dart'; import '../../../cashu/cashu_mint_info.dart'; +import '../../bip321.dart'; import '../../wallet.dart'; import '../../wallet_balance.dart'; import '../../wallet_provider.dart'; @@ -119,6 +122,16 @@ class CashuWalletProvider implements WalletProvider { throw ArgumentError('Expected a CashuWallet'); } + final result = await _payBolt11(wallet, invoice, timeout: timeout); + return result.legacyResponse; + } + + Future<_CashuBolt11Payment> _payBolt11( + CashuWallet wallet, + String invoice, { + int? expectedAmountMsat, + Duration? timeout, + }) async { final draftTransaction = await _cashuUseCase.initiateRedeem( mintUrl: wallet.mintUrl, request: invoice, @@ -126,9 +139,22 @@ class CashuWalletProvider implements WalletProvider { method: 'bolt11', ); - await for (final transaction in _cashuUseCase.redeem( + final amountMsat = draftTransaction.qouteMelt!.amount * 1000; + if (expectedAmountMsat != null && amountMsat != expectedAmountMsat) { + throw ArgumentError( + 'BIP-321 amount $expectedAmountMsat msats conflicts with ' + 'the BOLT11 invoice amount $amountMsat msats', + ); + } + + var transactions = _cashuUseCase.redeem( draftRedeemTransaction: draftTransaction, - )) { + ); + if (timeout != null) { + transactions = transactions.timeout(timeout); + } + + await for (final transaction in transactions) { if (transaction.state == WalletTransactionState.completed) { final int feesPaid; if (draftTransaction.qouteMelt?.feeReserve != null) { @@ -137,11 +163,19 @@ class CashuWalletProvider implements WalletProvider { feesPaid = 0; } - return PayInvoiceResponse( + final legacyResponse = PayInvoiceResponse( resultType: 'pay_invoice', preimage: null, feesPaid: feesPaid, ); + return _CashuBolt11Payment( + legacyResponse: legacyResponse, + transactionId: draftTransaction.id, + amountMsat: amountMsat, + createdAt: draftTransaction.initiatedDate ?? + DateTime.now().millisecondsSinceEpoch ~/ 1000, + settledAt: transaction.transactionDate, + ); } else if (transaction.state == WalletTransactionState.failed) { throw Exception('Cashu payment failed: ${transaction.completionMsg}'); } @@ -195,4 +229,111 @@ class CashuWalletProvider implements WalletProvider { return invoice; } + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + if (wallet is! CashuWallet) { + throw ArgumentError('Expected a CashuWallet'); + } + if (payerNote?.isNotEmpty == true) { + throw UnsupportedError('BOLT11 does not support payer notes'); + } + + final invoice = Bip321.getBolt11(payment); + final invoiceAmountMsat = Bip321.getBolt11AmountMsat(invoice); + if (invoiceAmountMsat == null) { + throw UnsupportedError( + 'Cashu does not support paying amountless BOLT11 invoices', + ); + } + if (invoiceAmountMsat % 1000 != 0) { + throw UnsupportedError( + 'Cashu only supports whole-satoshi BOLT11 amounts', + ); + } + if (amountMsat != null && amountMsat != invoiceAmountMsat) { + throw ArgumentError( + 'BIP-321 amount $amountMsat msats conflicts with ' + 'the BOLT11 invoice amount $invoiceAmountMsat msats', + ); + } + + final result = await _payBolt11( + wallet, + invoice, + expectedAmountMsat: invoiceAmountMsat, + timeout: timeout, + ); + return PayResponse( + resultType: 'pay', + transactionId: result.transactionId, + state: 'settled', + instructionType: 'bolt11', + amountMsat: result.amountMsat, + feesPaid: result.legacyResponse.feesPaid, + preimage: result.legacyResponse.preimage, + createdAt: result.createdAt, + settledAt: result.settledAt, + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + if (amountMsat == null) { + throw UnsupportedError( + 'Cashu does not support variable-amount BOLT11 invoices', + ); + } + if (amountMsat <= 0 || amountMsat % 1000 != 0) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'Cashu requires a positive whole-satoshi amount', + ); + } + if (description?.isNotEmpty == true) { + throw UnsupportedError( + 'Cashu does not support setting a BOLT11 description', + ); + } + + var invoiceFuture = receive(wallet, amountMsat ~/ 1000); + if (timeout != null) { + invoiceFuture = invoiceFuture.timeout(timeout); + } + final invoice = await invoiceFuture; + return ReceiveResponse( + resultType: 'receive', + bip321: Bip321.fromBolt11(invoice), + ); + } +} + +class _CashuBolt11Payment { + final PayInvoiceResponse legacyResponse; + final String transactionId; + final int amountMsat; + final int createdAt; + final int? settledAt; + + const _CashuBolt11Payment({ + required this.legacyResponse, + required this.transactionId, + required this.amountMsat, + required this.createdAt, + required this.settledAt, + }); } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart index 81a2a7d63..7a6a0be0f 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart @@ -4,7 +4,9 @@ import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl_response.dart'; import 'package:ndk/shared/logger/logger.dart'; import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; - +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import '../../bip321.dart'; import '../../wallet.dart'; import '../../wallet_balance.dart'; import '../../wallet_provider.dart'; @@ -166,6 +168,57 @@ class LnurlWalletProvider implements WalletProvider { return invoiceResponse.invoice; } + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + throw UnsupportedError( + 'LNURL wallet is receive-only and cannot pay BIP-321 instructions', + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + if (amountMsat == null) { + throw UnsupportedError( + 'LNURL does not support variable-amount BOLT11 invoices', + ); + } + if (amountMsat <= 0 || amountMsat % 1000 != 0) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'LNURL requires a positive whole-satoshi amount', + ); + } + if (description?.isNotEmpty == true) { + throw UnsupportedError( + 'LNURL does not support overriding the BOLT11 description', + ); + } + + var invoiceFuture = receive(wallet, amountMsat ~/ 1000); + if (timeout != null) { + invoiceFuture = invoiceFuture.timeout(timeout); + } + final invoice = await invoiceFuture; + return ReceiveResponse( + resultType: 'receive', + bip321: Bip321.fromBolt11(invoice), + ); + } + @override Stream> get discoveredWallets { // LNURL wallets are not auto-discovered diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart index 41f6b3867..05f37e16f 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart @@ -94,16 +94,51 @@ class NwcWallet extends Wallet { return {}; } - Set get _effectivePermissions => + /// Permissions advertised by the live connection, falling back to the + /// persisted capability snapshot while the connection initializes. + Set get effectivePermissions => connection?.permissions.isNotEmpty == true ? connection!.permissions : cachedPermissions; + bool supportsMethod(NwcMethod method) => + effectivePermissions.contains(method.name); + @override bool get canReceive => - _effectivePermissions.contains(NwcMethod.MAKE_INVOICE.name); + supportsMethod(NwcMethod.MAKE_INVOICE) || + supportsMethod(NwcMethod.RECEIVE); @override bool get canSend => - _effectivePermissions.contains(NwcMethod.PAY_INVOICE.name); + supportsMethod(NwcMethod.PAY_INVOICE) || supportsMethod(NwcMethod.PAY); + + @override + Set get sendPaymentProtocols => { + if (supportsMethod(NwcMethod.PAY_INVOICE) || + supportsMethod(NwcMethod.PAY)) + WalletPaymentProtocol.bolt11, + if (supportsMethod(NwcMethod.PAY)) WalletPaymentProtocol.bolt12, + }; + + @override + Set get receivePaymentProtocols => { + if (supportsMethod(NwcMethod.MAKE_INVOICE) || + supportsMethod(NwcMethod.RECEIVE)) + WalletPaymentProtocol.bolt11, + if (supportsMethod(NwcMethod.RECEIVE)) WalletPaymentProtocol.bolt12, + }; + + @override + bool get supportsBip321Pay => supportsMethod(NwcMethod.PAY); + + @override + bool get supportsBip321Receive => supportsMethod(NwcMethod.RECEIVE); + + @override + bool get supportsBolt11InvoicePay => supportsMethod(NwcMethod.PAY_INVOICE); + + @override + bool get supportsBolt11InvoiceReceive => + supportsMethod(NwcMethod.MAKE_INVOICE); } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart index e81ab5980..f9ca23483 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart @@ -6,6 +6,8 @@ import '../../../../usecases/nwc/consts/nwc_method.dart'; import '../../../../usecases/nwc/nwc.dart'; import '../../../../usecases/nwc/nwc_connection.dart'; import '../../../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../../../usecases/nwc/responses/pay_response.dart'; +import '../../../../usecases/nwc/responses/receive_response.dart'; import '../../wallet.dart'; import '../../wallet_balance.dart'; import '../../wallet_provider.dart'; @@ -186,6 +188,58 @@ class NwcWalletProvider implements WalletProvider { return response.invoice; } + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + final nwcWallet = wallet as NwcWallet; + + await initialize(wallet); + final connection = _connectionOrThrow(nwcWallet); + + final response = await _nwcUseCase.pay( + connection, + payment: payment, + amountMsat: amountMsat, + payerNote: payerNote, + metadata: metadata, + timeout: timeout, + ); + try { + await _refreshAll(nwcWallet); + } catch (_) { + // The payment succeeded; optional refresh methods must not hide it. + } + return response; + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + final nwcWallet = wallet as NwcWallet; + + await initialize(wallet); + final connection = _connectionOrThrow(nwcWallet); + + return _nwcUseCase.receive( + connection, + amountMsat: amountMsat, + description: description, + metadata: metadata, + timeout: timeout, + ); + } + Future _refreshAll(NwcWallet wallet) async { if (_refreshInFlight[wallet.id] == true) { return; diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart index 937629ece..1e3b28106 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet.dart @@ -1,5 +1,8 @@ import 'wallet_type.dart'; +/// Lightning payment protocols a wallet can use for internal transfers. +enum WalletPaymentProtocol { bolt11, bolt12 } + /// Base interface for all wallet types /// Provides common properties and methods that all wallets must implement abstract class Wallet { @@ -44,4 +47,29 @@ abstract class Wallet { /// Indicates if the wallet can send funds bool get canSend; + + /// Payment protocols this wallet can send. + /// + /// Wallets keep BOLT11 as the compatibility default. Wallet types with + /// richer or dynamic capabilities should override this getter. + Set get sendPaymentProtocols => canSend + ? const {WalletPaymentProtocol.bolt11} + : const {}; + + /// Payment protocols this wallet can receive. + Set get receivePaymentProtocols => canReceive + ? const {WalletPaymentProtocol.bolt11} + : const {}; + + /// Whether this wallet can use the NWC-321/BIP-321 `pay` operation. + bool get supportsBip321Pay => false; + + /// Whether this wallet can use the NWC-321/BIP-321 `receive` operation. + bool get supportsBip321Receive => false; + + /// Whether this wallet can directly pay a BOLT11 invoice. + bool get supportsBolt11InvoicePay => canSend; + + /// Whether this wallet can directly create a BOLT11 invoice. + bool get supportsBolt11InvoiceReceive => canReceive; } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart index 921c86af1..c58164e50 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_factory.dart @@ -17,10 +17,14 @@ export 'providers/nwc/nwc_wallet_provider.dart'; export 'providers/lnurl/lnurl_wallet.dart'; export 'providers/lnurl/lnurl_wallet_provider.dart'; +export 'providers/bolt12/bolt12_wallet.dart'; +export 'providers/bolt12/bolt12_wallet_provider.dart'; + // Then: imports needed for WalletFactory import 'providers/cashu/cashu_wallet.dart'; import 'providers/nwc/nwc_wallet.dart'; import 'providers/lnurl/lnurl_wallet.dart'; +import 'providers/bolt12/bolt12_wallet.dart'; import 'wallet.dart'; import 'wallet_type.dart'; @@ -59,6 +63,13 @@ class WalletFactory { supportedUnits: supportedUnits, metadata: metadata, ); + case WalletType.BOLT12: + return Bolt12Wallet.fromStorage( + id: id, + name: name, + supportedUnits: supportedUnits, + metadata: metadata, + ); } } } diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart index 78175f125..c063820a0 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_provider.dart @@ -1,4 +1,6 @@ import '../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../usecases/nwc/responses/pay_response.dart'; +import '../../usecases/nwc/responses/receive_response.dart'; import 'wallet.dart'; import 'wallet_balance.dart'; import 'wallet_transaction.dart'; @@ -49,6 +51,25 @@ abstract class WalletProvider { /// Receive by creating a Lightning Invoice Future receive(Wallet wallet, int amountSats); + /// Pays a payment instruction selected from a BIP-321 URI. + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }); + + /// Creates a BIP-321 URI using a provider-supported payment instruction. + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }); + /// Stream of wallets discovered by this provider /// For auto-discovery (e.g., Cashu mints, NWC connections from events) Stream> get discoveredWallets; diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart index 201526423..cb0085c15 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_transaction.dart @@ -101,6 +101,7 @@ abstract class WalletTransaction { initiatedDate: initiatedDate, ); case WalletType.LNURL: + case WalletType.BOLT12: return LnurlWalletTransaction( id: id, walletId: walletId, diff --git a/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart index 3413275c0..512a2f7a5 100644 --- a/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart +++ b/packages/ndk/lib/domain_layer/entities/wallet/wallet_type.dart @@ -4,7 +4,9 @@ enum WalletType { // ignore: constant_identifier_names CASHU('cashu'), // ignore: constant_identifier_names - LNURL('lnurl'); + LNURL('lnurl'), + // ignore: constant_identifier_names + BOLT12('bolt12'); final String value; diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart index f6b94a140..8cb43a3c9 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/error_code.dart @@ -1,4 +1,13 @@ enum ErrorCode { + badRequest('BAD_REQUEST', 'The request contains an invalid parameter.'), + unsupportedPaymentInstruction( + 'UNSUPPORTED_PAYMENT_INSTRUCTION', + 'The wallet cannot select a supported payment instruction.', + ), + unsupportedNetwork( + 'UNSUPPORTED_NETWORK', + 'The payment instruction uses a different Bitcoin network.', + ), rateLimited( 'RATE_LIMITED', 'The client is sending commands too fast. It should retry in a few seconds.', @@ -29,6 +38,10 @@ enum ErrorCode { ), unauthorized('UNAUTHORIZED', 'This public key has no wallet connected.'), internal('INTERNAL', 'An internal error.'), + feeLimitExceeded( + 'FEE_LIMIT_EXCEEDED', + 'No route fit the max_fee budget and no payment was attempted.', + ), other('OTHER', 'Other error.'); final String value; diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart b/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart index 459d7f383..2c60fedb5 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/consts/nwc_method.dart @@ -10,6 +10,8 @@ class NwcMethod { static const NwcMethod GET_INFO = NwcMethod('get_info'); static const NwcMethod GET_BALANCE = NwcMethod('get_balance'); static const NwcMethod GET_BUDGET = NwcMethod('get_budget'); + static const NwcMethod PAY = NwcMethod('pay'); + static const NwcMethod RECEIVE = NwcMethod('receive'); static const NwcMethod PAY_INVOICE = NwcMethod('pay_invoice'); static const NwcMethod MULTI_PAY_INVOICE = NwcMethod('multi_pay_invoice'); static const NwcMethod PAY_KEYSEND = NwcMethod('pay_keysend'); @@ -25,6 +27,8 @@ class NwcMethod { // Registry to store all methods by their plaintext static final Map _methodsRegistry = { + PAY.name: PAY, + RECEIVE.name: RECEIVE, PAY_INVOICE.name: PAY_INVOICE, MULTI_PAY_INVOICE.name: MULTI_PAY_INVOICE, PAY_KEYSEND.name: PAY_KEYSEND, diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart index a73d6eba1..085866ea9 100644 --- a/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart +++ b/packages/ndk/lib/domain_layer/usecases/nwc/nwc.dart @@ -18,7 +18,9 @@ import 'requests/make_hold_invoice.dart'; // Add import for MakeHoldInvoiceReque import 'requests/cancel_hold_invoice.dart'; // Add import for CancelHoldInvoiceRequest import 'requests/settle_hold_invoice.dart'; // Add import for SettleHoldInvoiceRequest import 'requests/nwc_request.dart'; +import 'requests/pay.dart'; import 'requests/pay_invoice.dart'; +import 'requests/receive.dart'; import 'responses/nwc_response.dart'; /// Main entry point for the NWC (Nostr Wallet Connect - NIP47 ) usecase @@ -211,6 +213,10 @@ class Nwc { response = MakeInvoiceResponse.deserialize(data); } else if (data['result_type'] == NwcMethod.PAY_INVOICE.name) { response = PayInvoiceResponse.deserialize(data); + } else if (data['result_type'] == NwcMethod.PAY.name) { + response = PayResponse.deserialize(data); + } else if (data['result_type'] == NwcMethod.RECEIVE.name) { + response = ReceiveResponse.deserialize(data); } else if (data['result_type'] == NwcMethod.LIST_TRANSACTIONS.name) { response = ListTransactionsResponse.deserialize(data); } else if (data['result_type'] == NwcMethod.LOOKUP_INVOICE.name) { @@ -467,6 +473,7 @@ class Nwc { String? descriptionHash, int? expiry, required String paymentHash, + Duration? timeout, }) async { return _executeRequest( connection, @@ -477,6 +484,7 @@ class Nwc { expiry: expiry, paymentHash: paymentHash, ), + timeout: timeout, ); } @@ -515,6 +523,48 @@ class Nwc { ); } + /// Pays a Lightning instruction from a BIP-321 URI using NWC-321. + Future pay( + NwcConnection connection, { + required String payment, + int? amountMsat, + int? maxFeeMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + return _executeRequest( + connection, + PayRequest( + payment: payment, + amountMsat: amountMsat, + maxFeeMsat: maxFeeMsat, + payerNote: payerNote, + metadata: metadata, + ), + timeout: timeout, + ); + } + + /// Creates a BIP-321 URI containing a Lightning receive instruction. + Future receive( + NwcConnection connection, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + return _executeRequest( + connection, + ReceiveRequest( + amountMsat: amountMsat, + description: description, + metadata: metadata, + ), + timeout: timeout, + ); + } + /// Does a `lookup_invoice` request Future lookupInvoice( NwcConnection connection, { diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart new file mode 100644 index 000000000..ef83770d8 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/pay.dart @@ -0,0 +1,43 @@ +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; + +import 'nwc_request.dart'; + +/// Request to pay a Lightning instruction from a BIP-321 URI. +class PayRequest extends NwcRequest { + /// The BIP-321 payment URI. + final String payment; + + /// The amount to pay in millisatoshis when the instruction has no amount. + final int? amountMsat; + + /// The maximum routing fee the sender is willing to pay, in millisatoshis. + final int? maxFeeMsat; + + /// An optional message from the payer. + final String? payerNote; + + /// Optional application-defined metadata. + final Map? metadata; + + const PayRequest({ + required this.payment, + this.amountMsat, + this.maxFeeMsat, + this.payerNote, + this.metadata, + }) : super(method: NwcMethod.PAY); + + @override + Map toMap() { + return { + ...super.toMap(), + 'params': { + 'payment': payment, + if (amountMsat != null) 'amount': amountMsat, + if (maxFeeMsat != null) 'max_fee': maxFeeMsat, + if (payerNote != null) 'payer_note': payerNote, + if (metadata != null) 'metadata': metadata, + }, + }; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/requests/receive.dart b/packages/ndk/lib/domain_layer/usecases/nwc/requests/receive.dart new file mode 100644 index 000000000..e4f635f7d --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/requests/receive.dart @@ -0,0 +1,30 @@ +import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; + +import 'nwc_request.dart'; + +/// Request to create a BIP-321 URI containing a Lightning receive instruction. +class ReceiveRequest extends NwcRequest { + /// The requested amount in millisatoshis, or null for a variable amount. + final int? amountMsat; + + /// An optional description for the payment instruction. + final String? description; + + /// Optional application-defined metadata. + final Map? metadata; + + const ReceiveRequest({this.amountMsat, this.description, this.metadata}) + : super(method: NwcMethod.RECEIVE); + + @override + Map toMap() { + return { + ...super.toMap(), + 'params': { + if (amountMsat != null) 'amount': amountMsat, + if (description != null) 'description': description, + if (metadata != null) 'metadata': metadata, + }, + }; + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_response.dart new file mode 100644 index 000000000..dd7b4a972 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/pay_response.dart @@ -0,0 +1,83 @@ +import 'nwc_response.dart'; + +/// Represents the result of a NWC-321 `pay` response. +class PayResponse extends NwcResponse { + /// Wallet-scoped transaction identifier. + final String transactionId; + + /// Payment state: `pending`, `settled`, or `failed`. + final String state; + + /// Selected instruction type. Currently only `bolt11` is supported. + final String instructionType; + + /// Paid amount in millisatoshis. + final int amountMsat; + + /// Paid fees in millisatoshis. + final int feesPaid; + + /// Payment hash, when available. + final String? paymentHash; + + /// Payment preimage, when available. + final String? preimage; + + /// Proof supplied by the selected instruction, when available. + final String? payerProof; + + /// On-chain transaction identifier, when available. + final String? txid; + + /// Failure details. This is expected when [state] is `failed`. + final String? failureReason; + + /// Unix timestamp when the transaction was created. + final int createdAt; + + /// Unix timestamp when the transaction settled, when applicable. + final int? settledAt; + + PayResponse({ + required super.resultType, + required this.transactionId, + required this.state, + required this.instructionType, + required this.amountMsat, + required this.feesPaid, + required this.createdAt, + this.paymentHash, + this.preimage, + this.payerProof, + this.txid, + this.failureReason, + this.settledAt, + }); + + /// Paid amount rounded down to satoshis. + int get amountSat => amountMsat ~/ 1000; + + factory PayResponse.deserialize(Map input) { + if (!input.containsKey('result')) { + throw Exception('Invalid input'); + } + + final result = input['result'] as Map; + + return PayResponse( + resultType: input['result_type'] as String, + transactionId: result['transaction_id'] as String, + state: result['state'] as String, + instructionType: result['instruction_type'] as String, + amountMsat: result['amount'] as int, + feesPaid: result['fees_paid'] as int, + paymentHash: result['payment_hash'] as String?, + preimage: result['preimage'] as String?, + payerProof: result['payer_proof'] as String?, + txid: result['txid'] as String?, + failureReason: result['failure_reason'] as String?, + createdAt: result['created_at'] as int, + settledAt: result['settled_at'] as int?, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/nwc/responses/receive_response.dart b/packages/ndk/lib/domain_layer/usecases/nwc/responses/receive_response.dart new file mode 100644 index 000000000..427837d20 --- /dev/null +++ b/packages/ndk/lib/domain_layer/usecases/nwc/responses/receive_response.dart @@ -0,0 +1,30 @@ +import 'nwc_response.dart'; + +/// Represents the result of a NWC-321 `receive` response. +class ReceiveResponse extends NwcResponse { + /// BIP-321 URI containing one or more receive instructions. + final String bip321; + + /// Wallet-scoped transaction identifier, when one was allocated. + final String? transactionId; + + ReceiveResponse({ + required super.resultType, + required this.bip321, + this.transactionId, + }); + + factory ReceiveResponse.deserialize(Map input) { + if (!input.containsKey('result')) { + throw Exception('Invalid input'); + } + + final result = input['result'] as Map; + + return ReceiveResponse( + resultType: input['result_type'] as String, + bip321: result['bip321'] as String, + transactionId: result['transaction_id'] as String?, + ); + } +} diff --git a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart index f79b0b367..39c66fac3 100644 --- a/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart +++ b/packages/ndk/lib/domain_layer/usecases/wallets/wallets.dart @@ -5,11 +5,14 @@ import 'package:rxdart/rxdart.dart'; import '../../entities/wallet/wallet.dart'; import '../../entities/wallet/wallet_balance.dart'; +import '../../entities/wallet/bip321.dart'; import '../../entities/wallet/wallet_provider.dart'; import '../../entities/wallet/wallet_transaction.dart'; import '../../entities/wallet/wallet_type.dart'; import '../../repositories/wallets_repo.dart'; import '../../usecases/nwc/responses/pay_invoice_response.dart'; +import '../../usecases/nwc/responses/pay_response.dart'; +import '../../usecases/nwc/responses/receive_response.dart'; /// Unified wallet system that handles multiple wallet types (NWC, Cashu, etc.) /// Uses WalletProvider pattern for pluggability @@ -257,35 +260,28 @@ class Wallets { /// Add a new wallet to the system Future addWallet(Wallet wallet) async { - await _repository.storeWallet(wallet); - await _addWalletToMemory(wallet); - - // Initialize with provider + // Initialize before persisting so failed setup (for example, an + // unreachable LNURL endpoint) cannot leave a partially added wallet. final provider = _providers[wallet.type]; + var walletToStore = wallet; if (provider != null) { final updatedWallet = await provider.initialize(wallet); if (updatedWallet != null) { - // Replace old wallet with updated one while preserving order - final list = _wallets.toList(); - final existingIndex = list.indexWhere((w) => w.id == wallet.id); - if (existingIndex >= 0) { - list[existingIndex] = updatedWallet; - _wallets.clear(); - _wallets.addAll(list); - _safeAddWallets(list); - } - // Also update in repository (addWallet handles updates too) - await _repository.storeWallet(updatedWallet); + walletToStore = updatedWallet; } } - if (wallet.canReceive && + await _repository.storeWallet(walletToStore); + await _addWalletToMemory(walletToStore); + + if (walletToStore.canReceive && _repository.getDefaultWalletIdForReceiving() == null) { - _repository.setDefaultWalletForReceiving(wallet.id); + _repository.setDefaultWalletForReceiving(walletToStore.id); } - if (wallet.canSend && _repository.getDefaultWalletIdForSending() == null) { - _repository.setDefaultWalletForSending(wallet.id); + if (walletToStore.canSend && + _repository.getDefaultWalletIdForSending() == null) { + _repository.setDefaultWalletForSending(walletToStore.id); } _updateCombinedStreams(); @@ -575,6 +571,211 @@ class Wallets { return provider.receive(wallet, amountSats); } + /// Pays an instruction from a BIP-321 URI using the selected wallet. + Future payBip321({ + String? walletId, + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + await _initializationFuture; + walletId ??= _repository.getDefaultWalletIdForSending(); + if (walletId == null) { + throw StateError('No default wallet set'); + } + final wallet = await _getWalletForOperation(walletId); + final provider = _providers[wallet.type]; + if (provider == null) { + throw ArgumentError('No provider for wallet type: ${wallet.type}'); + } + return provider.payBip321( + wallet, + payment: payment, + amountMsat: amountMsat, + payerNote: payerNote, + metadata: metadata, + timeout: timeout, + ); + } + + /// Creates a BIP-321 URI using the selected receiving wallet. + Future receiveBip321({ + String? walletId, + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + await _initializationFuture; + walletId ??= _repository.getDefaultWalletIdForReceiving(); + if (walletId == null) { + throw StateError('No default wallet set'); + } + final wallet = await _getWalletForOperation(walletId); + final provider = _providers[wallet.type]; + if (provider == null) { + throw ArgumentError('No provider for wallet type: ${wallet.type}'); + } + return provider.receiveBip321( + wallet, + amountMsat: amountMsat, + description: description, + metadata: metadata, + timeout: timeout, + ); + } + + /// Returns the protocol that can transfer funds from [source] to + /// [destination], or null when the wallets have no compatible payment path. + WalletPaymentProtocol? compatibleTransferProtocol({ + required Wallet source, + required Wallet destination, + }) { + if (source.id == destination.id || + !source.canSend || + !destination.canReceive || + !source.supportedUnits.contains('sat') || + !destination.supportedUnits.contains('sat')) { + return null; + } + + final common = source.sendPaymentProtocols.intersection( + destination.receivePaymentProtocols, + ); + + // A BOLT12-only destination must use the reusable offer through BIP-321. + if (destination.receivePaymentProtocols.length == 1 && + destination.receivePaymentProtocols.contains( + WalletPaymentProtocol.bolt12, + ) && + common.contains(WalletPaymentProtocol.bolt12) && + source.supportsBip321Pay && + destination.supportsBip321Receive) { + return WalletPaymentProtocol.bolt12; + } + + if (common.contains(WalletPaymentProtocol.bolt11)) { + final genericPath = source.supportsBip321Pay && + (destination.supportsBip321Receive || + destination.supportsBolt11InvoiceReceive); + final invoicePath = source.supportsBolt11InvoicePay && + destination.supportsBolt11InvoiceReceive; + if (genericPath || invoicePath) return WalletPaymentProtocol.bolt11; + } + + if (common.contains(WalletPaymentProtocol.bolt12) && + source.supportsBip321Pay && + destination.supportsBip321Receive) { + return WalletPaymentProtocol.bolt12; + } + return null; + } + + /// Transfers funds directly between two configured wallets. + /// + /// BOLT11-capable wallets exchange a fresh invoice. A BOLT12-only receiver + /// exposes its reusable offer and requires a BIP-321-capable sender. + Future transfer({ + required String sourceWalletId, + required String destinationWalletId, + int? amountMsat, + Duration? timeout, + }) async { + await _initializationFuture; + final source = await _getWalletForOperation(sourceWalletId); + final destination = await _getWalletForOperation(destinationWalletId); + final protocol = compatibleTransferProtocol( + source: source, + destination: destination, + ); + if (protocol == null) { + throw UnsupportedError( + 'The selected wallets have no compatible payment protocol', + ); + } + if (amountMsat != null && amountMsat <= 0) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'Transfer amount must be positive', + ); + } + if (protocol == WalletPaymentProtocol.bolt11 && + (amountMsat == null || amountMsat % 1000 != 0)) { + throw ArgumentError.value( + amountMsat, + 'amountMsat', + 'BOLT11 wallet transfers require a positive whole-satoshi amount', + ); + } + + ReceiveResponse? receiveResponse; + late final String payment; + if (destination.supportsBip321Receive) { + receiveResponse = await receiveBip321( + walletId: destination.id, + amountMsat: amountMsat, + timeout: timeout, + ); + payment = receiveResponse.bip321; + } else { + final invoice = await receive( + walletId: destination.id, + amountSats: amountMsat! ~/ 1000, + ); + payment = Bip321.fromBolt11(invoice); + } + + if (source.supportsBip321Pay) { + final payResponse = await payBip321( + walletId: source.id, + payment: payment, + amountMsat: amountMsat, + timeout: timeout, + ); + if (payResponse.errorCode != null || payResponse.state == 'failed') { + throw StateError( + payResponse.errorMessage ?? + payResponse.failureReason ?? + 'Wallet transfer failed', + ); + } + final selectedProtocol = payResponse.instructionType == 'bolt12' + ? WalletPaymentProtocol.bolt12 + : WalletPaymentProtocol.bolt11; + return WalletTransferResult( + sourceWalletId: source.id, + destinationWalletId: destination.id, + protocol: selectedProtocol, + payment: payment, + receiveResponse: receiveResponse, + payResponse: payResponse, + ); + } + + final invoice = Bip321.getBolt11(payment); + final payInvoiceResponse = await send( + walletId: source.id, + invoice: invoice, + timeout: timeout, + ); + if (payInvoiceResponse.errorCode != null) { + throw StateError( + payInvoiceResponse.errorMessage ?? 'Wallet transfer failed', + ); + } + return WalletTransferResult( + sourceWalletId: source.id, + destinationWalletId: destination.id, + protocol: WalletPaymentProtocol.bolt11, + payment: payment, + receiveResponse: receiveResponse, + payInvoiceResponse: payInvoiceResponse, + ); + } + Future _getWalletForOperation(String walletId) async { final inMemory = _wallets.firstWhereOrNull( (wallet) => wallet.id == walletId, @@ -665,3 +866,24 @@ class Wallets { _walletsSubject.add(wallets); } } + +/// Result of a completed or submitted wallet-to-wallet transfer. +class WalletTransferResult { + final String sourceWalletId; + final String destinationWalletId; + final WalletPaymentProtocol protocol; + final String payment; + final ReceiveResponse? receiveResponse; + final PayResponse? payResponse; + final PayInvoiceResponse? payInvoiceResponse; + + const WalletTransferResult({ + required this.sourceWalletId, + required this.destinationWalletId, + required this.protocol, + required this.payment, + this.receiveResponse, + this.payResponse, + this.payInvoiceResponse, + }); +} diff --git a/packages/ndk/lib/entities.dart b/packages/ndk/lib/entities.dart index 605ddab40..ed3a16678 100644 --- a/packages/ndk/lib/entities.dart +++ b/packages/ndk/lib/entities.dart @@ -57,10 +57,13 @@ export 'domain_layer/entities/wallet/wallet.dart'; export 'domain_layer/entities/wallet/wallet_transaction.dart'; export 'domain_layer/entities/wallet/wallet_type.dart'; export 'domain_layer/entities/wallet/wallet_balance.dart'; +export 'domain_layer/entities/wallet/bip321.dart'; export 'domain_layer/entities/wallet/wallet_factory.dart'; export 'domain_layer/entities/wallet/providers/cashu/cashu_wallet.dart'; export 'domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart'; export 'domain_layer/entities/wallet/providers/lnurl/lnurl_wallet.dart'; +export 'domain_layer/entities/wallet/providers/bolt12/bolt12_wallet.dart'; +export 'domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart'; // testing export 'domain_layer/usecases/wallets/wallets.dart'; diff --git a/packages/ndk/lib/ndk.dart b/packages/ndk/lib/ndk.dart index 99c1bf9d3..e048d0136 100644 --- a/packages/ndk/lib/ndk.dart +++ b/packages/ndk/lib/ndk.dart @@ -36,6 +36,9 @@ export 'domain_layer/usecases/nwc/responses/get_budget_response.dart'; export 'domain_layer/usecases/nwc/responses/get_info_response.dart'; export 'domain_layer/usecases/nwc/responses/make_invoice_response.dart'; export 'domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +export 'domain_layer/usecases/nwc/responses/pay_response.dart'; +export 'domain_layer/usecases/nwc/responses/receive_response.dart'; +export 'domain_layer/entities/wallet/bip321.dart'; export 'domain_layer/usecases/nwc/responses/list_transactions_response.dart'; export 'domain_layer/usecases/nwc/responses/lookup_invoice_response.dart'; export 'domain_layer/usecases/nwc/nwc_connection.dart'; diff --git a/packages/ndk/lib/presentation_layer/init.dart b/packages/ndk/lib/presentation_layer/init.dart index e47423777..60705b675 100644 --- a/packages/ndk/lib/presentation_layer/init.dart +++ b/packages/ndk/lib/presentation_layer/init.dart @@ -19,6 +19,7 @@ import '../domain_layer/entities/relay_connectivity.dart'; import '../domain_layer/entities/wallet/providers/cashu/cashu_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/nwc/nwc_wallet_provider.dart'; import '../domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart'; +import '../domain_layer/entities/wallet/providers/bolt12/bolt12_wallet_provider.dart'; import '../domain_layer/repositories/blossom.dart'; import '../domain_layer/repositories/cashu_repo.dart'; import '../domain_layer/repositories/lnurl_transport.dart'; @@ -313,6 +314,7 @@ class Initialization { // Create LNURL wallet provider after lnurl is initialized final lnurlProvider = LnurlWalletProvider(lnurl); + const bolt12Provider = Bolt12WalletProvider(); zaps = Zaps(requests: requests, nwc: nwc, lnurl: lnurl); @@ -358,7 +360,7 @@ class Initialization { connectivity = Connectivy(relayManager); wallets = Wallets( - providers: [cashuProvider, nwcProvider, lnurlProvider], + providers: [cashuProvider, nwcProvider, lnurlProvider, bolt12Provider], repository: _ndkConfig.walletsRepo!, ); proofOfWork = ProofOfWork(); diff --git a/packages/ndk/pubspec.yaml b/packages/ndk/pubspec.yaml index fa246d1c5..4278ebb1a 100644 --- a/packages/ndk/pubspec.yaml +++ b/packages/ndk/pubspec.yaml @@ -24,6 +24,8 @@ platforms: windows: dependencies: + dart_bip353: ^0.8.0 + dart_bolt12_decoder: ^0.8.0 bip32_keys: ^3.1.4 http: ^1.6.0 bip340: ">=0.3.0 <0.4.0" diff --git a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart index a9c095688..dbd5b18a6 100644 --- a/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart +++ b/packages/ndk/test/data_layer/cache_manager/mem_cache_manager_test.mocks.dart @@ -34,23 +34,43 @@ import 'package:ndk/domain_layer/entities/user_relay_list.dart' as _i6; // ignore_for_file: invalid_use_of_internal_member class _FakeNip65_0 extends _i1.SmartFake implements _i2.Nip65 { - _FakeNip65_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip65_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeNip01Event_1 extends _i1.SmartFake implements _i3.Nip01Event { - _FakeNip01Event_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip01Event_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeMetadata_2 extends _i1.SmartFake implements _i4.Metadata { - _FakeMetadata_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeMetadata_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeNip05_3 extends _i1.SmartFake implements _i5.Nip05 { - _FakeNip05_3(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip05_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [UserRelayList]. @@ -64,13 +84,17 @@ class MockUserRelayList extends _i1.Mock implements _i6.UserRelayList { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override int get refreshedTimestamp => (super.noSuchMethod( @@ -85,43 +109,72 @@ class MockUserRelayList extends _i1.Mock implements _i6.UserRelayList { ) as Map); @override - Iterable get urls => - (super.noSuchMethod(Invocation.getter(#urls), returnValue: []) - as Iterable); + Iterable get urls => (super.noSuchMethod( + Invocation.getter(#urls), + returnValue: [], + ) as Iterable); @override - Iterable get readUrls => - (super.noSuchMethod(Invocation.getter(#readUrls), returnValue: []) - as Iterable); + Iterable get readUrls => (super.noSuchMethod( + Invocation.getter(#readUrls), + returnValue: [], + ) as Iterable); + + @override + Iterable get writeUrls => (super.noSuchMethod( + Invocation.getter(#writeUrls), + returnValue: [], + ) as Iterable); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @override set refreshedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter(#refreshedTimestamp, value), + Invocation.setter( + #refreshedTimestamp, + value, + ), returnValueForMissingStub: null, ); @override set relays(Map? value) => super.noSuchMethod( - Invocation.setter(#relays, value), + Invocation.setter( + #relays, + value, + ), returnValueForMissingStub: null, ); @override _i2.Nip65 toNip65() => (super.noSuchMethod( - Invocation.method(#toNip65, []), - returnValue: _FakeNip65_0(this, Invocation.method(#toNip65, [])), + Invocation.method( + #toNip65, + [], + ), + returnValue: _FakeNip65_0( + this, + Invocation.method( + #toNip65, + [], + ), + ), ) as _i2.Nip65); } @@ -136,24 +189,34 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { @override String get id => (super.noSuchMethod( Invocation.getter(#id), - returnValue: _i7.dummyValue(this, Invocation.getter(#id)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#id), + ), ) as String); @override - Iterable get urls => - (super.noSuchMethod(Invocation.getter(#urls), returnValue: []) - as Iterable); + Iterable get urls => (super.noSuchMethod( + Invocation.getter(#urls), + returnValue: [], + ) as Iterable); @override String get name => (super.noSuchMethod( Invocation.getter(#name), - returnValue: _i7.dummyValue(this, Invocation.getter(#name)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#name), + ), ) as String); @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override @@ -188,45 +251,66 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { @override set name(String? value) => super.noSuchMethod( - Invocation.setter(#name, value), + Invocation.setter( + #name, + value, + ), returnValueForMissingStub: null, ); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set relayMinCountPerPubkey(int? value) => super.noSuchMethod( - Invocation.setter(#relayMinCountPerPubkey, value), + Invocation.setter( + #relayMinCountPerPubkey, + value, + ), returnValueForMissingStub: null, ); @override set direction(_i10.RelayDirection? value) => super.noSuchMethod( - Invocation.setter(#direction, value), + Invocation.setter( + #direction, + value, + ), returnValueForMissingStub: null, ); @override set relaysMap(Map>? value) => super.noSuchMethod( - Invocation.setter(#relaysMap, value), + Invocation.setter( + #relaysMap, + value, + ), returnValueForMissingStub: null, ); @override set fallbackToBootstrapRelays(bool? value) => super.noSuchMethod( - Invocation.setter(#fallbackToBootstrapRelays, value), + Invocation.setter( + #fallbackToBootstrapRelays, + value, + ), returnValueForMissingStub: null, ); @override set notCoveredPubkeys(List<_i9.NotCoveredPubKey>? value) => super.noSuchMethod( - Invocation.setter(#notCoveredPubkeys, value), + Invocation.setter( + #notCoveredPubkeys, + value, + ), returnValueForMissingStub: null, ); @@ -236,14 +320,23 @@ class MockRelaySet extends _i1.Mock implements _i9.RelaySet { _i13.RequestState? groupRequest, ) => super.noSuchMethod( - Invocation.method(#splitIntoRequests, [filter, groupRequest]), + Invocation.method( + #splitIntoRequests, + [ + filter, + groupRequest, + ], + ), returnValueForMissingStub: null, ); @override void addMoreRelays(Map>? more) => super.noSuchMethod( - Invocation.method(#addMoreRelays, [more]), + Invocation.method( + #addMoreRelays, + [more], + ), returnValueForMissingStub: null, ); } @@ -259,13 +352,17 @@ class MockContactList extends _i1.Mock implements _i14.ContactList { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - List get contacts => - (super.noSuchMethod(Invocation.getter(#contacts), returnValue: []) - as List); + List get contacts => (super.noSuchMethod( + Invocation.getter(#contacts), + returnValue: [], + ) as List); @override List get contactRelays => (super.noSuchMethod( @@ -274,9 +371,10 @@ class MockContactList extends _i1.Mock implements _i14.ContactList { ) as List); @override - List get petnames => - (super.noSuchMethod(Invocation.getter(#petnames), returnValue: []) - as List); + List get petnames => (super.noSuchMethod( + Invocation.getter(#petnames), + returnValue: [], + ) as List); @override List get followedTags => (super.noSuchMethod( @@ -297,92 +395,145 @@ class MockContactList extends _i1.Mock implements _i14.ContactList { ) as List); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set contacts(List? value) => super.noSuchMethod( - Invocation.setter(#contacts, value), + Invocation.setter( + #contacts, + value, + ), returnValueForMissingStub: null, ); @override set contactRelays(List? value) => super.noSuchMethod( - Invocation.setter(#contactRelays, value), + Invocation.setter( + #contactRelays, + value, + ), returnValueForMissingStub: null, ); @override set petnames(List? value) => super.noSuchMethod( - Invocation.setter(#petnames, value), + Invocation.setter( + #petnames, + value, + ), returnValueForMissingStub: null, ); @override set followedTags(List? value) => super.noSuchMethod( - Invocation.setter(#followedTags, value), + Invocation.setter( + #followedTags, + value, + ), returnValueForMissingStub: null, ); @override set followedCommunities(List? value) => super.noSuchMethod( - Invocation.setter(#followedCommunities, value), + Invocation.setter( + #followedCommunities, + value, + ), returnValueForMissingStub: null, ); @override set followedEvents(List? value) => super.noSuchMethod( - Invocation.setter(#followedEvents, value), + Invocation.setter( + #followedEvents, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @override set loadedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter(#loadedTimestamp, value), + Invocation.setter( + #loadedTimestamp, + value, + ), returnValueForMissingStub: null, ); @override set sources(List? value) => super.noSuchMethod( - Invocation.setter(#sources, value), + Invocation.setter( + #sources, + value, + ), returnValueForMissingStub: null, ); @override List> contactsToJson() => (super.noSuchMethod( - Invocation.method(#contactsToJson, []), + Invocation.method( + #contactsToJson, + [], + ), returnValue: >[], ) as List>); @override - List> tagListToJson(List? list, String? tag) => + List> tagListToJson( + List? list, + String? tag, + ) => (super.noSuchMethod( - Invocation.method(#tagListToJson, [list, tag]), + Invocation.method( + #tagListToJson, + [ + list, + tag, + ], + ), returnValue: >[], ) as List>); @override _i3.Nip01Event toEvent() => (super.noSuchMethod( - Invocation.method(#toEvent, []), - returnValue: _FakeNip01Event_1(this, Invocation.method(#toEvent, [])), + Invocation.method( + #toEvent, + [], + ), + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #toEvent, + [], + ), + ), ) as _i3.Nip01Event); } @@ -397,7 +548,10 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override @@ -407,9 +561,10 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { ) as Map); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override List> get tags => (super.noSuchMethod( @@ -419,126 +574,206 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set content(Map? value) => super.noSuchMethod( - Invocation.setter(#content, value), + Invocation.setter( + #content, + value, + ), returnValueForMissingStub: null, ); @override set name(String? value) => super.noSuchMethod( - Invocation.setter(#name, value), + Invocation.setter( + #name, + value, + ), returnValueForMissingStub: null, ); @override set displayName(String? value) => super.noSuchMethod( - Invocation.setter(#displayName, value), + Invocation.setter( + #displayName, + value, + ), returnValueForMissingStub: null, ); @override set picture(String? value) => super.noSuchMethod( - Invocation.setter(#picture, value), + Invocation.setter( + #picture, + value, + ), returnValueForMissingStub: null, ); @override set banner(String? value) => super.noSuchMethod( - Invocation.setter(#banner, value), + Invocation.setter( + #banner, + value, + ), returnValueForMissingStub: null, ); @override set website(String? value) => super.noSuchMethod( - Invocation.setter(#website, value), + Invocation.setter( + #website, + value, + ), returnValueForMissingStub: null, ); @override set about(String? value) => super.noSuchMethod( - Invocation.setter(#about, value), + Invocation.setter( + #about, + value, + ), returnValueForMissingStub: null, ); @override set nip05(String? value) => super.noSuchMethod( - Invocation.setter(#nip05, value), + Invocation.setter( + #nip05, + value, + ), returnValueForMissingStub: null, ); @override set lud16(String? value) => super.noSuchMethod( - Invocation.setter(#lud16, value), + Invocation.setter( + #lud16, + value, + ), returnValueForMissingStub: null, ); @override set lud06(String? value) => super.noSuchMethod( - Invocation.setter(#lud06, value), + Invocation.setter( + #lud06, + value, + ), returnValueForMissingStub: null, ); @override set updatedAt(int? value) => super.noSuchMethod( - Invocation.setter(#updatedAt, value), + Invocation.setter( + #updatedAt, + value, + ), returnValueForMissingStub: null, ); @override set refreshedTimestamp(int? value) => super.noSuchMethod( - Invocation.setter(#refreshedTimestamp, value), + Invocation.setter( + #refreshedTimestamp, + value, + ), returnValueForMissingStub: null, ); @override set sources(List? value) => super.noSuchMethod( - Invocation.setter(#sources, value), + Invocation.setter( + #sources, + value, + ), returnValueForMissingStub: null, ); @override set tags(List>? value) => super.noSuchMethod( - Invocation.setter(#tags, value), + Invocation.setter( + #tags, + value, + ), returnValueForMissingStub: null, ); @override Map toJson() => (super.noSuchMethod( - Invocation.method(#toJson, []), + Invocation.method( + #toJson, + [], + ), returnValue: {}, ) as Map); @override _i3.Nip01Event toEvent() => (super.noSuchMethod( - Invocation.method(#toEvent, []), - returnValue: _FakeNip01Event_1(this, Invocation.method(#toEvent, [])), + Invocation.method( + #toEvent, + [], + ), + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #toEvent, + [], + ), + ), ) as _i3.Nip01Event); @override - void setCustomField(String? key, dynamic value) => super.noSuchMethod( - Invocation.method(#setCustomField, [key, value]), + void setCustomField( + String? key, + dynamic value, + ) => + super.noSuchMethod( + Invocation.method( + #setCustomField, + [ + key, + value, + ], + ), returnValueForMissingStub: null, ); @override - dynamic getCustomField(String? key) => - super.noSuchMethod(Invocation.method(#getCustomField, [key])); + dynamic getCustomField(String? key) => super.noSuchMethod(Invocation.method( + #getCustomField, + [key], + )); @override String getName() => (super.noSuchMethod( - Invocation.method(#getName, []), - returnValue: - _i7.dummyValue(this, Invocation.method(#getName, [])), + Invocation.method( + #getName, + [], + ), + returnValue: _i7.dummyValue( + this, + Invocation.method( + #getName, + [], + ), + ), ) as String); @override bool matchesSearch(String? str) => (super.noSuchMethod( - Invocation.method(#matchesSearch, [str]), + Invocation.method( + #matchesSearch, + [str], + ), returnValue: false, ) as bool); @@ -561,26 +796,10 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { Map? content, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #pubKey: pubKey, - #name: name, - #displayName: displayName, - #picture: picture, - #banner: banner, - #website: website, - #about: about, - #nip05: nip05, - #lud16: lud16, - #lud06: lud06, - #updatedAt: updatedAt, - #refreshedTimestamp: refreshedTimestamp, - #sources: sources, - #tags: tags, - #content: content, - }), - returnValue: _FakeMetadata_2( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #pubKey: pubKey, #name: name, #displayName: displayName, @@ -596,7 +815,31 @@ class MockMetadata extends _i1.Mock implements _i4.Metadata { #sources: sources, #tags: tags, #content: content, - }), + }, + ), + returnValue: _FakeMetadata_2( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #name: name, + #displayName: displayName, + #picture: picture, + #banner: banner, + #website: website, + #about: about, + #nip05: nip05, + #lud16: lud16, + #lud06: lud06, + #updatedAt: updatedAt, + #refreshedTimestamp: refreshedTimestamp, + #sources: sources, + #tags: tags, + #content: content, + }, + ), ), ) as _i4.Metadata); } @@ -612,23 +855,32 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { @override String get id => (super.noSuchMethod( Invocation.getter(#id), - returnValue: _i7.dummyValue(this, Invocation.getter(#id)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#id), + ), ) as String); @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override - int get kind => - (super.noSuchMethod(Invocation.getter(#kind), returnValue: 0) as int); + int get kind => (super.noSuchMethod( + Invocation.getter(#kind), + returnValue: 0, + ) as int); @override List> get tags => (super.noSuchMethod( @@ -639,23 +891,29 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { @override String get content => (super.noSuchMethod( Invocation.getter(#content), - returnValue: _i7.dummyValue(this, Invocation.getter(#content)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#content), + ), ) as String); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override - List get tTags => - (super.noSuchMethod(Invocation.getter(#tTags), returnValue: []) - as List); + List get tTags => (super.noSuchMethod( + Invocation.getter(#tTags), + returnValue: [], + ) as List); @override - List get pTags => - (super.noSuchMethod(Invocation.getter(#pTags), returnValue: []) - as List); + List get pTags => (super.noSuchMethod( + Invocation.getter(#pTags), + returnValue: [], + ) as List); @override List get replyETags => (super.noSuchMethod( @@ -665,13 +923,19 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { @override set id(String? value) => super.noSuchMethod( - Invocation.setter(#id, value), + Invocation.setter( + #id, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @@ -688,20 +952,10 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { List? sources, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #id: id, - #pubKey: pubKey, - #createdAt: createdAt, - #kind: kind, - #tags: tags, - #content: content, - #sig: sig, - #validSig: validSig, - #sources: sources, - }), - returnValue: _FakeNip01Event_1( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #id: id, #pubKey: pubKey, #createdAt: createdAt, @@ -711,19 +965,42 @@ class MockNip01Event extends _i1.Mock implements _i3.Nip01Event { #sig: sig, #validSig: validSig, #sources: sources, - }), + }, + ), + returnValue: _FakeNip01Event_1( + this, + Invocation.method( + #copyWith, + [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, + ), ), ) as _i3.Nip01Event); @override List getTags(String? tag) => (super.noSuchMethod( - Invocation.method(#getTags, [tag]), + Invocation.method( + #getTags, + [tag], + ), returnValue: [], ) as List); @override - String? getFirstTag(String? name) => - (super.noSuchMethod(Invocation.method(#getFirstTag, [name])) as String?); + String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( + #getFirstTag, + [name], + )) as String?); } /// A class which mocks [Nip05]. @@ -737,47 +1014,69 @@ class MockNip05 extends _i1.Mock implements _i5.Nip05 { @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i7.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override String get nip05 => (super.noSuchMethod( Invocation.getter(#nip05), - returnValue: _i7.dummyValue(this, Invocation.getter(#nip05)), + returnValue: _i7.dummyValue( + this, + Invocation.getter(#nip05), + ), ) as String); @override - bool get valid => - (super.noSuchMethod(Invocation.getter(#valid), returnValue: false) - as bool); + bool get valid => (super.noSuchMethod( + Invocation.getter(#valid), + returnValue: false, + ) as bool); @override set pubKey(String? value) => super.noSuchMethod( - Invocation.setter(#pubKey, value), + Invocation.setter( + #pubKey, + value, + ), returnValueForMissingStub: null, ); @override set nip05(String? value) => super.noSuchMethod( - Invocation.setter(#nip05, value), + Invocation.setter( + #nip05, + value, + ), returnValueForMissingStub: null, ); @override set valid(bool? value) => super.noSuchMethod( - Invocation.setter(#valid, value), + Invocation.setter( + #valid, + value, + ), returnValueForMissingStub: null, ); @override set networkFetchTime(int? value) => super.noSuchMethod( - Invocation.setter(#networkFetchTime, value), + Invocation.setter( + #networkFetchTime, + value, + ), returnValueForMissingStub: null, ); @override set relays(List? value) => super.noSuchMethod( - Invocation.setter(#relays, value), + Invocation.setter( + #relays, + value, + ), returnValueForMissingStub: null, ); @@ -790,22 +1089,30 @@ class MockNip05 extends _i1.Mock implements _i5.Nip05 { List? relays, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #pubKey: pubKey, - #nip05: nip05, - #valid: valid, - #networkFetchTime: networkFetchTime, - #relays: relays, - }), - returnValue: _FakeNip05_3( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #pubKey: pubKey, #nip05: nip05, #valid: valid, #networkFetchTime: networkFetchTime, #relays: relays, - }), + }, + ), + returnValue: _FakeNip05_3( + this, + Invocation.method( + #copyWith, + [], + { + #pubKey: pubKey, + #nip05: nip05, + #valid: valid, + #networkFetchTime: networkFetchTime, + #relays: relays, + }, + ), ), ) as _i5.Nip05); } diff --git a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart index bfa38602a..42ee6f861 100644 --- a/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart +++ b/packages/ndk/test/data_layer/nostr_transport/websocket_nostr_transport_test.mocks.dart @@ -26,14 +26,24 @@ import 'package:web_socket_channel/web_socket_channel.dart' as _i2; class _FakeWebSocketChannel_0 extends _i1.SmartFake implements _i2.WebSocketChannel { - _FakeWebSocketChannel_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeWebSocketChannel_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamSubscription_1 extends _i1.SmartFake implements _i3.StreamSubscription { - _FakeStreamSubscription_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamSubscription_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [WebsocketDS]. @@ -61,13 +71,22 @@ class MockWebsocketDS extends _i1.Mock implements _i4.WebsocketDS { }) => (super.noSuchMethod( Invocation.method( - #listen, [onData], {#onError: onError, #onDone: onDone}), + #listen, + [onData], + { + #onError: onError, + #onDone: onDone, + }, + ), returnValue: _FakeStreamSubscription_1( this, Invocation.method( #listen, [onData], - {#onError: onError, #onDone: onDone}, + { + #onError: onError, + #onDone: onDone, + }, ), ), returnValueForMissingStub: _FakeStreamSubscription_1( @@ -75,34 +94,49 @@ class MockWebsocketDS extends _i1.Mock implements _i4.WebsocketDS { Invocation.method( #listen, [onData], - {#onError: onError, #onDone: onDone}, + { + #onError: onError, + #onDone: onDone, + }, ), ), ) as _i3.StreamSubscription); @override void send(dynamic data) => super.noSuchMethod( - Invocation.method(#send, [data]), + Invocation.method( + #send, + [data], + ), returnValueForMissingStub: null, ); @override _i3.Future ready() => (super.noSuchMethod( - Invocation.method(#ready, []), + Invocation.method( + #ready, + [], + ), returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); @override _i3.Future close() => (super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValue: _i3.Future.value(), returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); @override bool isOpen() => (super.noSuchMethod( - Invocation.method(#isOpen, []), + Invocation.method( + #isOpen, + [], + ), returnValue: false, returnValueForMissingStub: false, ) as bool); diff --git a/packages/ndk/test/entities/bip321_test.dart b/packages/ndk/test/entities/bip321_test.dart new file mode 100644 index 000000000..ab500e2b4 --- /dev/null +++ b/packages/ndk/test/entities/bip321_test.dart @@ -0,0 +1,46 @@ +import 'package:ndk/domain_layer/entities/wallet/bip321.dart'; +import 'package:test/test.dart'; + +void main() { + group('Bip321', () { + test('round-trips a BOLT11 instruction', () { + const invoice = 'lnbc210n1paymentdata'; + + final payment = Bip321.fromBolt11(invoice); + + expect(payment, 'bitcoin:?lightning=lnbc210n1paymentdata'); + expect(Bip321.getBolt11(payment), invoice); + }); + + test('decodes BOLT11 amounts in millisatoshis', () { + expect(Bip321.getBolt11AmountMsat('lnbc1paymentdata'), isNull); + expect(Bip321.getBolt11AmountMsat('lnbc210n1paymentdata'), 21000); + expect(Bip321.getBolt11AmountMsat('lnbc2m1paymentdata'), 200000000); + expect(Bip321.getBolt11AmountMsat('lntb3u1paymentdata'), 300000); + expect(Bip321.getBolt11AmountMsat('lnbcrt4n1paymentdata'), 400); + expect(Bip321.getBolt11AmountMsat('lnsb50p1paymentdata'), 5); + }); + + test('rejects unknown required parameters', () { + expect( + () => Bip321.getBolt11( + 'bitcoin:?lightning=lnbc1paymentdata&req-example=value', + ), + throwsUnsupportedError, + ); + }); + + test('rejects missing and duplicate lightning instructions', () { + expect( + () => Bip321.getBolt11('bitcoin:?amount=1'), + throwsFormatException, + ); + expect( + () => Bip321.getBolt11( + 'bitcoin:?lightning=lnbc1first&lightning=lnbc1second', + ), + throwsFormatException, + ); + }); + }); +} diff --git a/packages/ndk/test/entities/bolt12_wallet_test.dart b/packages/ndk/test/entities/bolt12_wallet_test.dart new file mode 100644 index 000000000..1ecf9c07a --- /dev/null +++ b/packages/ndk/test/entities/bolt12_wallet_test.dart @@ -0,0 +1,115 @@ +import 'package:ndk/entities.dart'; +import 'package:test/test.dart'; + +const _offer = + 'lno1pqqq5xj5wajkcan9gdshx6pq23jhxarfdenjqstyv3ex2umnzcss80xkrjkyrjk43u5dgu8f6a450fg2cnjtg7lhg76c3gtk5gdhshns'; +const _blindedPathOffer = + 'lno1pgqppmsrse80qf0aara4slvcjxrvu6j2rp5ftmjy4yntlsmsutpkvkt6878sx37ttar5fpecarm57v2y2can2uxq02l7k0er7czs6gsuzkdhe4tlqgpat4k4mrvvjwla3whdhmkvdtfq98w4jlg8wgsf26cndmndd0c33fqqx0y9hunesw4caaxfnw3uam5yy4kxtuqvujapdx93sd24wt7mdpeukuw46tp5zugxceqrr2ffkzpjcen3p77sy8jk8v7h04wlp9lg6ls76xqcn3nethq7e7553xn3vugt5vzlea2sqqedvc6k8r8hetzw9tvnlnw9muh4vaywdn5jgvj80ad3r9600ang39vvjnvn0aytg07ss05v6g9ru45p2srs'; + +void main() { + group('Bolt12WalletProvider input resolution', () { + test('accepts and decodes a direct offer', () async { + final resolved = await Bolt12WalletProvider.resolveInput(_offer); + + expect(resolved.offer, _offer); + expect(resolved.decoded['type'], 'offer'); + expect(resolved.decoded['valid'], isTrue); + expect(resolved.decoded['offer_description'], isNotEmpty); + }); + + test('extracts an offer from a BIP321 URI', () async { + final resolved = await Bolt12WalletProvider.resolveInput( + 'bitcoin:?amount=1&lno=${_offer.toUpperCase()}', + ); + + expect(resolved.offer, _offer); + expect(resolved.bip353Address, isNull); + }); + + test('accepts the commonly scanned bitcoin?lno shorthand', () async { + final resolved = await Bolt12WalletProvider.resolveInput( + 'bitcoin?lno=$_offer', + ); + + expect(resolved.offer, _offer); + }); + + test('accepts a current blinded-path offer in BIP321', () async { + final resolved = await Bolt12WalletProvider.resolveInput( + 'bitcoin:?lno=$_blindedPathOffer', + ); + + expect(resolved.offer, _blindedPathOffer); + expect(resolved.decoded['type'], 'offer'); + expect(resolved.decoded['valid'], isTrue); + expect(resolved.toMetadata()['hasBlindedPaths'], isTrue); + expect(resolved.toMetadata()['description'], isNull); + expect(resolved.toMetadata()['amount'], isNull); + }); + + test('resolves BIP353 and remembers its address', () async { + String? requestedAddress; + final resolved = await Bolt12WalletProvider.resolveInput( + '₿alice@example.com', + bip353Resolver: (address) async { + requestedAddress = address; + return _offer; + }, + ); + + expect(requestedAddress, 'alice@example.com'); + expect(resolved.offer, _offer); + expect(resolved.bip353Address, 'alice@example.com'); + }); + + test('rejects BIP353 records without an offer', () async { + expect( + () => Bolt12WalletProvider.resolveInput( + 'alice@example.com', + bip353Resolver: (_) async => null, + ), + throwsA(isA()), + ); + }); + + test('rejects malformed input', () async { + expect( + () => Bolt12WalletProvider.resolveInput('not a payment target'), + throwsA(isA()), + ); + }); + }); + + test('wallet is receive-only and round-trips through storage', () async { + final resolved = await Bolt12WalletProvider.resolveInput(_offer); + const provider = Bolt12WalletProvider(); + final wallet = provider.createWallet( + id: 'bolt12-1', + name: 'Donations', + supportedUnits: {'sat'}, + metadata: resolved.toMetadata(), + ) as Bolt12Wallet; + + expect(wallet.canReceive, isTrue); + expect(wallet.canSend, isFalse); + expect(await provider.receive(wallet, 123), _offer); + final bip321 = await provider.receiveBip321(wallet); + expect(bip321.bip321, 'bitcoin:?lno=$_offer'); + expect( + () => provider.send(wallet, 'lnbc...'), + throwsA(isA()), + ); + + final restored = WalletFactory.fromStorage( + id: wallet.id, + name: wallet.name, + type: wallet.type, + supportedUnits: wallet.supportedUnits, + metadata: wallet.toMetadata(), + ) as Bolt12Wallet; + expect(restored.offer, wallet.offer); + expect(restored.description, wallet.description); + expect(restored.issuer, wallet.issuer); + expect(restored.hasBlindedPaths, wallet.hasBlindedPaths); + }); +} diff --git a/packages/ndk/test/entities/nwc_wallet_test.dart b/packages/ndk/test/entities/nwc_wallet_test.dart index b8c25d01c..85dd3f140 100644 --- a/packages/ndk/test/entities/nwc_wallet_test.dart +++ b/packages/ndk/test/entities/nwc_wallet_test.dart @@ -1,4 +1,5 @@ import 'package:ndk/domain_layer/entities/wallet/providers/nwc/nwc_wallet.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet.dart'; import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart'; import 'package:test/test.dart'; @@ -46,5 +47,34 @@ void main() { NwcMethod.PAY_INVOICE.name, ]); }); + + test('pay and receive permissions enable wallet operations', () { + final wallet = NwcWallet.fromStorage( + id: 'w1', + name: 'NWC', + supportedUnits: {'sat'}, + metadata: { + 'nwcUrl': + 'nostr+walletconnect://a?relay=wss://relay.example&secret=secret', + NwcWallet.kPermissionsMetadataKey: [ + NwcMethod.PAY.name, + NwcMethod.RECEIVE.name, + ], + }, + ); + + expect(wallet.canSend, isTrue); + expect(wallet.canReceive, isTrue); + expect(wallet.supportsBip321Pay, isTrue); + expect(wallet.supportsBip321Receive, isTrue); + expect( + wallet.sendPaymentProtocols, + containsAll(WalletPaymentProtocol.values), + ); + expect( + wallet.receivePaymentProtocols, + containsAll(WalletPaymentProtocol.values), + ); + }); }); } diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.dart index 4ef4ad564..d301d6b09 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.dart @@ -5,7 +5,11 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:ndk/data_layer/data_sources/http_request.dart'; import 'package:ndk/data_layer/repositories/lnurl_http_impl.dart'; +import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; +import 'package:ndk/domain_layer/entities/wallet/providers/lnurl/lnurl_wallet_provider.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; import 'package:ndk/domain_layer/usecases/lnurl/lnurl.dart'; +import 'package:ndk/domain_layer/usecases/wallets/wallets.dart'; import 'package:test/test.dart'; import 'lnurl_test.mocks.dart'; @@ -59,6 +63,36 @@ void main() { expect(lnurlResponse, isNull); }); + test('failed metadata fetch does not add an LNURL wallet', () async { + final client = MockClient(); + final transport = LnurlTransportHttpImpl(HttpRequestDS(client)); + final lnurl = Lnurl(transport: transport); + final repository = MemWalletsRepo(); + final wallets = Wallets( + providers: [LnurlWalletProvider(lnurl)], + repository: repository, + ); + addTearDown(wallets.dispose); + await wallets.getWallets(); + + const identifier = 'name@domain.com'; + final link = Lnurl.getLud16LinkFromLud16(identifier)!; + when(client.get(Uri.parse(link), headers: {'Accept': 'application/json'})) + .thenAnswer((_) async => http.Response('not found', 404)); + + final wallet = wallets.createWallet( + id: 'lnurl-wallet', + name: identifier, + type: WalletType.LNURL, + supportedUnits: {'sat'}, + metadata: {'identifier': identifier}, + ); + + await expectLater(wallets.addWallet(wallet), throwsException); + expect(await repository.getWallets(), isEmpty); + expect(await wallets.getWallets(), isEmpty); + }); + test('getAmountFromBolt11 returns correct amount for valid input', () { final amount = Lnurl.getAmountFromBolt11( 'lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs', diff --git a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart index 041fdb98f..31c776521 100644 --- a/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart +++ b/packages/ndk/test/usecases/lnurl/lnurl_test.mocks.dart @@ -27,14 +27,24 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Client]. @@ -46,27 +56,45 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#head, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#head, [url], {#headers: headers}), - ), + Invocation.method( + #head, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#get, [url], {#headers: headers}), - ), + Invocation.method( + #get, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override @@ -80,18 +108,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #post, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #post, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -105,18 +139,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #put, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #put, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -130,18 +170,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #patch, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -155,30 +201,45 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #delete, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future read(Uri? url, {Map? headers}) => + _i3.Future read( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#read, [url], {#headers: headers}), - returnValue: _i3.Future.value( - _i5.dummyValue( - this, - Invocation.method(#read, [url], {#headers: headers}), - ), + Invocation.method( + #read, + [url], + {#headers: headers}, ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future); @override @@ -187,22 +248,37 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method(#readBytes, [url], {#headers: headers}), + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method(#send, [request]), - returnValue: _i3.Future<_i2.StreamedResponse>.value( - _FakeStreamedResponse_1(this, Invocation.method(#send, [request])), + Invocation.method( + #send, + [request], ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), ) as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValueForMissingStub: null, ); } diff --git a/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart b/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart index 86bbf33d3..8b14136e5 100644 --- a/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart +++ b/packages/ndk/test/usecases/nip05/nip05_network_test.mocks.dart @@ -27,14 +27,24 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Client]. @@ -46,27 +56,45 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#head, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#head, [url], {#headers: headers}), - ), + Invocation.method( + #head, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#get, [url], {#headers: headers}), - ), + Invocation.method( + #get, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override @@ -80,18 +108,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #post, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #post, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -105,18 +139,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #put, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #put, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -130,18 +170,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #patch, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -155,30 +201,45 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #delete, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future read(Uri? url, {Map? headers}) => + _i3.Future read( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#read, [url], {#headers: headers}), - returnValue: _i3.Future.value( - _i5.dummyValue( - this, - Invocation.method(#read, [url], {#headers: headers}), - ), + Invocation.method( + #read, + [url], + {#headers: headers}, ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future); @override @@ -187,22 +248,37 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method(#readBytes, [url], {#headers: headers}), + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method(#send, [request]), - returnValue: _i3.Future<_i2.StreamedResponse>.value( - _FakeStreamedResponse_1(this, Invocation.method(#send, [request])), + Invocation.method( + #send, + [request], ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), ) as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValueForMissingStub: null, ); } diff --git a/packages/ndk/test/usecases/nwc/nwc_321_test.dart b/packages/ndk/test/usecases/nwc/nwc_321_test.dart new file mode 100644 index 000000000..80c7f82e7 --- /dev/null +++ b/packages/ndk/test/usecases/nwc/nwc_321_test.dart @@ -0,0 +1,162 @@ +import 'package:ndk/domain_layer/usecases/nwc/consts/error_code.dart'; +import 'package:ndk/domain_layer/usecases/nwc/requests/pay.dart'; +import 'package:ndk/domain_layer/usecases/nwc/requests/receive.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import 'package:test/test.dart'; + +void main() { + group('NWC-321 errors', () { + test('maps extension error codes', () { + expect(ErrorCode.fromValue('BAD_REQUEST'), ErrorCode.badRequest); + expect( + ErrorCode.fromValue('UNSUPPORTED_PAYMENT_INSTRUCTION'), + ErrorCode.unsupportedPaymentInstruction, + ); + expect( + ErrorCode.fromValue('UNSUPPORTED_NETWORK'), + ErrorCode.unsupportedNetwork, + ); + }); + }); + + group('PayRequest', () { + test('serializes all NWC-321 parameters', () { + const request = PayRequest( + payment: 'bitcoin:?lightning=lnbc1invoice', + amountMsat: 123000, + payerNote: 'Thanks', + metadata: {'order_id': '123'}, + ); + + expect(request.toMap(), { + 'method': 'pay', + 'params': { + 'payment': 'bitcoin:?lightning=lnbc1invoice', + 'amount': 123000, + 'payer_note': 'Thanks', + 'metadata': {'order_id': '123'}, + }, + }); + }); + + test('omits optional parameters', () { + const request = PayRequest( + payment: 'bitcoin:?lightning=lnbc1invoice', + ); + + expect(request.toMap(), { + 'method': 'pay', + 'params': {'payment': 'bitcoin:?lightning=lnbc1invoice'}, + }); + }); + }); + + group('ReceiveRequest', () { + test('serializes all NWC-321 parameters', () { + const request = ReceiveRequest( + amountMsat: 123000, + description: 'Coffee', + metadata: {'order_id': '123'}, + ); + + expect(request.toMap(), { + 'method': 'receive', + 'params': { + 'amount': 123000, + 'description': 'Coffee', + 'metadata': {'order_id': '123'}, + }, + }); + }); + + test('omits amount for a variable-amount instruction', () { + const request = ReceiveRequest(); + + expect(request.toMap(), {'method': 'receive', 'params': {}}); + }); + }); + + group('PayResponse', () { + test('deserializes a settled bolt11 payment', () { + final response = PayResponse.deserialize({ + 'result_type': 'pay', + 'result': { + 'transaction_id': 'transaction-1', + 'state': 'settled', + 'instruction_type': 'bolt11', + 'amount': 123456, + 'fees_paid': 1000, + 'payment_hash': 'payment-hash', + 'preimage': 'preimage', + 'payer_proof': 'proof', + 'txid': 'txid', + 'failure_reason': null, + 'created_at': 1700000000, + 'settled_at': 1700000001, + }, + }); + + expect(response.resultType, 'pay'); + expect(response.transactionId, 'transaction-1'); + expect(response.state, 'settled'); + expect(response.instructionType, 'bolt11'); + expect(response.amountMsat, 123456); + expect(response.amountSat, 123); + expect(response.feesPaid, 1000); + expect(response.paymentHash, 'payment-hash'); + expect(response.preimage, 'preimage'); + expect(response.payerProof, 'proof'); + expect(response.txid, 'txid'); + expect(response.failureReason, isNull); + expect(response.createdAt, 1700000000); + expect(response.settledAt, 1700000001); + }); + + test('deserializes optional fields when absent', () { + final response = PayResponse.deserialize({ + 'result_type': 'pay', + 'result': { + 'transaction_id': 'transaction-1', + 'state': 'pending', + 'instruction_type': 'bolt11', + 'amount': 123000, + 'fees_paid': 0, + 'created_at': 1700000000, + }, + }); + + expect(response.paymentHash, isNull); + expect(response.preimage, isNull); + expect(response.payerProof, isNull); + expect(response.txid, isNull); + expect(response.failureReason, isNull); + expect(response.settledAt, isNull); + }); + }); + + group('ReceiveResponse', () { + test('deserializes a bolt11 BIP-321 URI', () { + final response = ReceiveResponse.deserialize({ + 'result_type': 'receive', + 'result': { + 'bip321': 'bitcoin:?lightning=lnbc1invoice', + 'transaction_id': 'transaction-1', + }, + }); + + expect(response.resultType, 'receive'); + expect(response.bip321, 'bitcoin:?lightning=lnbc1invoice'); + expect(response.transactionId, 'transaction-1'); + }); + + test('allows an absent transaction identifier', () { + final response = ReceiveResponse.deserialize({ + 'result_type': 'receive', + 'result': {'bip321': 'bitcoin:?lightning=lnbc1invoice'}, + }); + + expect(response.transactionId, isNull); + }); + }); +} diff --git a/packages/ndk/test/usecases/nwc/nwc_method_test.dart b/packages/ndk/test/usecases/nwc/nwc_method_test.dart index 013876492..11115c703 100644 --- a/packages/ndk/test/usecases/nwc/nwc_method_test.dart +++ b/packages/ndk/test/usecases/nwc/nwc_method_test.dart @@ -13,6 +13,8 @@ void main() { NwcMethod.fromPlaintext('get_budget'), equals(NwcMethod.GET_BUDGET), ); + expect(NwcMethod.fromPlaintext('pay'), equals(NwcMethod.PAY)); + expect(NwcMethod.fromPlaintext('receive'), equals(NwcMethod.RECEIVE)); expect( NwcMethod.fromPlaintext('pay_invoice'), equals(NwcMethod.PAY_INVOICE), diff --git a/packages/ndk/test/usecases/wallets_bip321_test.dart b/packages/ndk/test/usecases/wallets_bip321_test.dart new file mode 100644 index 000000000..c8608bd03 --- /dev/null +++ b/packages/ndk/test/usecases/wallets_bip321_test.dart @@ -0,0 +1,189 @@ +import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_balance.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_provider.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_transaction.dart'; +import 'package:ndk/domain_layer/entities/wallet/wallet_type.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import 'package:ndk/domain_layer/usecases/wallets/wallets.dart'; +import 'package:test/test.dart'; + +void main() { + test('Wallets delegates BIP-321 pay and receive to the wallet provider', + () async { + final wallet = _TestWallet(); + final repository = MemWalletsRepo(); + await repository.storeWallet(wallet); + repository.setDefaultWalletForSending(wallet.id); + repository.setDefaultWalletForReceiving(wallet.id); + + final provider = _TestWalletProvider(wallet); + final wallets = Wallets(providers: [provider], repository: repository); + addTearDown(wallets.dispose); + + final payResponse = await wallets.payBip321( + payment: 'bitcoin:?lightning=lnbc1invoice', + amountMsat: 21000, + payerNote: 'Thanks', + metadata: {'order_id': '123'}, + timeout: const Duration(seconds: 10), + ); + + expect(payResponse, same(provider.payResponse)); + expect(provider.paidWithWallet, same(wallet)); + expect(provider.payment, 'bitcoin:?lightning=lnbc1invoice'); + expect(provider.payAmountMsat, 21000); + expect(provider.payerNote, 'Thanks'); + expect(provider.payMetadata, {'order_id': '123'}); + expect(provider.payTimeout, const Duration(seconds: 10)); + + final receiveResponse = await wallets.receiveBip321( + amountMsat: 42000, + description: 'Coffee', + metadata: {'order_id': '456'}, + timeout: const Duration(seconds: 15), + ); + + expect(receiveResponse, same(provider.receiveResponse)); + expect(provider.receivedWithWallet, same(wallet)); + expect(provider.receiveAmountMsat, 42000); + expect(provider.description, 'Coffee'); + expect(provider.receiveMetadata, {'order_id': '456'}); + expect(provider.receiveTimeout, const Duration(seconds: 15)); + }); +} + +class _TestWallet extends Wallet { + _TestWallet() + : super( + id: 'wallet-1', + name: 'Test wallet', + type: WalletType.NWC, + supportedUnits: const {'sat'}, + metadata: const {}, + ); + + @override + bool get canReceive => true; + + @override + bool get canSend => true; + + @override + Map toMetadata() => metadata; +} + +class _TestWalletProvider extends WalletProvider { + final Wallet wallet; + + _TestWalletProvider(this.wallet); + + final payResponse = PayResponse( + resultType: 'pay', + transactionId: 'pay-transaction', + state: 'settled', + instructionType: 'bolt11', + amountMsat: 21000, + feesPaid: 1000, + createdAt: 1700000000, + ); + + final receiveResponse = ReceiveResponse( + resultType: 'receive', + bip321: 'bitcoin:?lightning=lnbc1invoice', + transactionId: 'receive-transaction', + ); + + Wallet? paidWithWallet; + String? payment; + int? payAmountMsat; + String? payerNote; + Map? payMetadata; + Duration? payTimeout; + + Wallet? receivedWithWallet; + int? receiveAmountMsat; + String? description; + Map? receiveMetadata; + Duration? receiveTimeout; + + @override + WalletType get type => WalletType.NWC; + + @override + Wallet createWallet({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) => + wallet; + + @override + Stream> get discoveredWallets => Stream.value(const []); + + @override + Stream> getBalances(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getPendingTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getRecentTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Future initialize(Wallet wallet) async => null; + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async => + PayInvoiceResponse(resultType: 'pay_invoice', feesPaid: 0); + + @override + Future receive(Wallet wallet, int amountSats) async => 'invoice'; + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + paidWithWallet = wallet; + this.payment = payment; + payAmountMsat = amountMsat; + this.payerNote = payerNote; + payMetadata = metadata; + payTimeout = timeout; + return payResponse; + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + receivedWithWallet = wallet; + receiveAmountMsat = amountMsat; + this.description = description; + receiveMetadata = metadata; + receiveTimeout = timeout; + return receiveResponse; + } +} diff --git a/packages/ndk/test/usecases/wallets_transfer_test.dart b/packages/ndk/test/usecases/wallets_transfer_test.dart new file mode 100644 index 000000000..03cf13222 --- /dev/null +++ b/packages/ndk/test/usecases/wallets_transfer_test.dart @@ -0,0 +1,285 @@ +import 'package:ndk/data_layer/repositories/wallets/mem_wallets_repo.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_invoice_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/pay_response.dart'; +import 'package:ndk/domain_layer/usecases/nwc/responses/receive_response.dart'; +import 'package:test/test.dart'; + +void main() { + test('transfers to a BOLT12-only wallet through BIP-321', () async { + final source = _TestWallet( + id: 'nwc-source', + type: WalletType.NWC, + canSendValue: true, + sendProtocols: const {WalletPaymentProtocol.bolt12}, + supportsBip321PayValue: true, + ); + final destination = _TestWallet( + id: 'bolt12-destination', + type: WalletType.BOLT12, + canReceiveValue: true, + receiveProtocols: const {WalletPaymentProtocol.bolt12}, + supportsBip321ReceiveValue: true, + supportsBolt11InvoiceReceiveValue: false, + ); + final sourceProvider = _TestWalletProvider(source.type); + final destinationProvider = _TestWalletProvider(destination.type) + ..bip321ToReceive = 'bitcoin:?lno=lno1offer'; + final wallets = await _wallets( + [source, destination], + [sourceProvider, destinationProvider], + ); + addTearDown(wallets.dispose); + + expect( + wallets.compatibleTransferProtocol( + source: source, + destination: destination, + ), + WalletPaymentProtocol.bolt12, + ); + + final result = await wallets.transfer( + sourceWalletId: source.id, + destinationWalletId: destination.id, + amountMsat: 21000, + ); + + expect(result.protocol, WalletPaymentProtocol.bolt12); + expect(destinationProvider.receivedAmountMsat, 21000); + expect(destinationProvider.receivedMetadata, isNull); + expect(sourceProvider.paidPayment, 'bitcoin:?lno=lno1offer'); + expect(sourceProvider.paidAmountMsat, 21000); + expect(sourceProvider.paidMetadata, isNull); + }); + + test('transfers between legacy wallets with a fresh BOLT11 invoice', + () async { + final source = _TestWallet( + id: 'cashu-source', + type: WalletType.CASHU, + canSendValue: true, + ); + final destination = _TestWallet( + id: 'lnurl-destination', + type: WalletType.LNURL, + canReceiveValue: true, + ); + final sourceProvider = _TestWalletProvider(source.type); + final destinationProvider = _TestWalletProvider(destination.type) + ..invoiceToReceive = 'lnbc1internaltransfer'; + final wallets = await _wallets( + [source, destination], + [sourceProvider, destinationProvider], + ); + addTearDown(wallets.dispose); + + final result = await wallets.transfer( + sourceWalletId: source.id, + destinationWalletId: destination.id, + amountMsat: 42000, + ); + + expect(result.protocol, WalletPaymentProtocol.bolt11); + expect(destinationProvider.receivedAmountSats, 42); + expect(sourceProvider.paidInvoice, 'lnbc1internaltransfer'); + expect( + result.payment, + 'bitcoin:?lightning=lnbc1internaltransfer', + ); + }); + + test('does not offer a BOLT12 destination to a legacy-only sender', () { + final source = _TestWallet( + id: 'legacy-source', + type: WalletType.CASHU, + canSendValue: true, + ); + final destination = _TestWallet( + id: 'bolt12-destination', + type: WalletType.BOLT12, + canReceiveValue: true, + receiveProtocols: const {WalletPaymentProtocol.bolt12}, + supportsBip321ReceiveValue: true, + supportsBolt11InvoiceReceiveValue: false, + ); + final wallets = Wallets( + providers: const [], + repository: MemWalletsRepo(), + ); + addTearDown(wallets.dispose); + + expect( + wallets.compatibleTransferProtocol( + source: source, + destination: destination, + ), + isNull, + ); + }); +} + +Future _wallets( + List walletList, + List providers, +) async { + final repository = MemWalletsRepo(); + for (final wallet in walletList) { + await repository.storeWallet(wallet); + } + final wallets = Wallets(providers: providers, repository: repository); + await wallets.getWallets(); + return wallets; +} + +class _TestWallet extends Wallet { + final bool canSendValue; + final bool canReceiveValue; + final Set? sendProtocols; + final Set? receiveProtocols; + final bool supportsBip321PayValue; + final bool supportsBip321ReceiveValue; + final bool? supportsBolt11InvoiceReceiveValue; + + _TestWallet({ + required super.id, + required super.type, + this.canSendValue = false, + this.canReceiveValue = false, + this.sendProtocols, + this.receiveProtocols, + this.supportsBip321PayValue = false, + this.supportsBip321ReceiveValue = false, + this.supportsBolt11InvoiceReceiveValue, + }) : super( + name: id, + supportedUnits: const {'sat'}, + metadata: const {}, + ); + + @override + bool get canReceive => canReceiveValue; + + @override + bool get canSend => canSendValue; + + @override + Set get sendPaymentProtocols => + sendProtocols ?? super.sendPaymentProtocols; + + @override + Set get receivePaymentProtocols => + receiveProtocols ?? super.receivePaymentProtocols; + + @override + bool get supportsBip321Pay => supportsBip321PayValue; + + @override + bool get supportsBip321Receive => supportsBip321ReceiveValue; + + @override + bool get supportsBolt11InvoiceReceive => + supportsBolt11InvoiceReceiveValue ?? super.supportsBolt11InvoiceReceive; + + @override + Map toMetadata() => metadata; +} + +class _TestWalletProvider implements WalletProvider { + @override + final WalletType type; + + String invoiceToReceive = 'lnbc1invoice'; + String bip321ToReceive = 'bitcoin:?lightning=lnbc1invoice'; + int? receivedAmountSats; + int? receivedAmountMsat; + String? paidInvoice; + String? paidPayment; + int? paidAmountMsat; + Map? paidMetadata; + Map? receivedMetadata; + + _TestWalletProvider(this.type); + + @override + Wallet createWallet({ + required String id, + required String name, + required Set supportedUnits, + required Map metadata, + }) => + throw UnimplementedError(); + + @override + Stream> get discoveredWallets => Stream.value(const []); + + @override + Stream> getBalances(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getPendingTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Stream> getRecentTransactions(Wallet wallet) => + Stream.value(const []); + + @override + Future initialize(Wallet wallet) async => null; + + @override + Future removeWallet(Wallet wallet) async {} + + @override + Future send( + Wallet wallet, + String invoice, { + Duration? timeout, + }) async { + paidInvoice = invoice; + return PayInvoiceResponse(resultType: 'pay_invoice', feesPaid: 0); + } + + @override + Future receive(Wallet wallet, int amountSats) async { + receivedAmountSats = amountSats; + return invoiceToReceive; + } + + @override + Future payBip321( + Wallet wallet, { + required String payment, + int? amountMsat, + String? payerNote, + Map? metadata, + Duration? timeout, + }) async { + paidPayment = payment; + paidAmountMsat = amountMsat; + paidMetadata = metadata; + return PayResponse( + resultType: 'pay', + transactionId: 'transfer', + state: 'settled', + instructionType: payment.contains('lno=') ? 'bolt12' : 'bolt11', + amountMsat: amountMsat ?? 0, + feesPaid: 0, + createdAt: 1700000000, + ); + } + + @override + Future receiveBip321( + Wallet wallet, { + int? amountMsat, + String? description, + Map? metadata, + Duration? timeout, + }) async { + receivedAmountMsat = amountMsat; + receivedMetadata = metadata; + return ReceiveResponse(resultType: 'receive', bip321: bip321ToReceive); + } +} diff --git a/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart b/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart index 8105fb162..f9138f597 100644 --- a/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zap_receipt_test.mocks.dart @@ -23,8 +23,13 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart' as _i2; // ignore_for_file: invalid_use_of_internal_member class _FakeNip01Event_0 extends _i1.SmartFake implements _i2.Nip01Event { - _FakeNip01Event_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeNip01Event_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Nip01Event]. @@ -38,23 +43,32 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { @override String get id => (super.noSuchMethod( Invocation.getter(#id), - returnValue: _i3.dummyValue(this, Invocation.getter(#id)), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#id), + ), ) as String); @override String get pubKey => (super.noSuchMethod( Invocation.getter(#pubKey), - returnValue: _i3.dummyValue(this, Invocation.getter(#pubKey)), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#pubKey), + ), ) as String); @override - int get createdAt => - (super.noSuchMethod(Invocation.getter(#createdAt), returnValue: 0) - as int); + int get createdAt => (super.noSuchMethod( + Invocation.getter(#createdAt), + returnValue: 0, + ) as int); @override - int get kind => - (super.noSuchMethod(Invocation.getter(#kind), returnValue: 0) as int); + int get kind => (super.noSuchMethod( + Invocation.getter(#kind), + returnValue: 0, + ) as int); @override List> get tags => (super.noSuchMethod( @@ -65,23 +79,29 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { @override String get content => (super.noSuchMethod( Invocation.getter(#content), - returnValue: _i3.dummyValue(this, Invocation.getter(#content)), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#content), + ), ) as String); @override - List get sources => - (super.noSuchMethod(Invocation.getter(#sources), returnValue: []) - as List); + List get sources => (super.noSuchMethod( + Invocation.getter(#sources), + returnValue: [], + ) as List); @override - List get tTags => - (super.noSuchMethod(Invocation.getter(#tTags), returnValue: []) - as List); + List get tTags => (super.noSuchMethod( + Invocation.getter(#tTags), + returnValue: [], + ) as List); @override - List get pTags => - (super.noSuchMethod(Invocation.getter(#pTags), returnValue: []) - as List); + List get pTags => (super.noSuchMethod( + Invocation.getter(#pTags), + returnValue: [], + ) as List); @override List get replyETags => (super.noSuchMethod( @@ -91,13 +111,19 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { @override set id(String? value) => super.noSuchMethod( - Invocation.setter(#id, value), + Invocation.setter( + #id, + value, + ), returnValueForMissingStub: null, ); @override set createdAt(int? value) => super.noSuchMethod( - Invocation.setter(#createdAt, value), + Invocation.setter( + #createdAt, + value, + ), returnValueForMissingStub: null, ); @@ -114,20 +140,10 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { List? sources, }) => (super.noSuchMethod( - Invocation.method(#copyWith, [], { - #id: id, - #pubKey: pubKey, - #createdAt: createdAt, - #kind: kind, - #tags: tags, - #content: content, - #sig: sig, - #validSig: validSig, - #sources: sources, - }), - returnValue: _FakeNip01Event_0( - this, - Invocation.method(#copyWith, [], { + Invocation.method( + #copyWith, + [], + { #id: id, #pubKey: pubKey, #createdAt: createdAt, @@ -137,17 +153,40 @@ class MockNip01Event extends _i1.Mock implements _i2.Nip01Event { #sig: sig, #validSig: validSig, #sources: sources, - }), + }, + ), + returnValue: _FakeNip01Event_0( + this, + Invocation.method( + #copyWith, + [], + { + #id: id, + #pubKey: pubKey, + #createdAt: createdAt, + #kind: kind, + #tags: tags, + #content: content, + #sig: sig, + #validSig: validSig, + #sources: sources, + }, + ), ), ) as _i2.Nip01Event); @override List getTags(String? tag) => (super.noSuchMethod( - Invocation.method(#getTags, [tag]), + Invocation.method( + #getTags, + [tag], + ), returnValue: [], ) as List); @override - String? getFirstTag(String? name) => - (super.noSuchMethod(Invocation.method(#getFirstTag, [name])) as String?); + String? getFirstTag(String? name) => (super.noSuchMethod(Invocation.method( + #getFirstTag, + [name], + )) as String?); } diff --git a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart index df1cc4cbc..7a49df125 100644 --- a/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart +++ b/packages/ndk/test/usecases/zaps/zaps_test.mocks.dart @@ -27,14 +27,24 @@ import 'package:mockito/src/dummies.dart' as _i5; // ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { - _FakeResponse_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeStreamedResponse_1 extends _i1.SmartFake implements _i2.StreamedResponse { - _FakeStreamedResponse_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeStreamedResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [Client]. @@ -46,27 +56,45 @@ class MockClient extends _i1.Mock implements _i2.Client { } @override - _i3.Future<_i2.Response> head(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> head( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#head, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#head, [url], {#headers: headers}), - ), + Invocation.method( + #head, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #head, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future<_i2.Response> get(Uri? url, {Map? headers}) => + _i3.Future<_i2.Response> get( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method(#get, [url], {#headers: headers}), - ), + Invocation.method( + #get, + [url], + {#headers: headers}, ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #get, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future<_i2.Response>); @override @@ -80,18 +108,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #post, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #post, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -105,18 +139,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #put, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #put, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #put, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -130,18 +170,24 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #patch, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #patch, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #patch, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override @@ -155,30 +201,45 @@ class MockClient extends _i1.Mock implements _i2.Client { Invocation.method( #delete, [url], - {#headers: headers, #body: body, #encoding: encoding}, + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - returnValue: _i3.Future<_i2.Response>.value( - _FakeResponse_0( - this, - Invocation.method( - #delete, - [url], - {#headers: headers, #body: body, #encoding: encoding}, - ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #delete, + [url], + { + #headers: headers, + #body: body, + #encoding: encoding, + }, ), - ), + )), ) as _i3.Future<_i2.Response>); @override - _i3.Future read(Uri? url, {Map? headers}) => + _i3.Future read( + Uri? url, { + Map? headers, + }) => (super.noSuchMethod( - Invocation.method(#read, [url], {#headers: headers}), - returnValue: _i3.Future.value( - _i5.dummyValue( - this, - Invocation.method(#read, [url], {#headers: headers}), - ), + Invocation.method( + #read, + [url], + {#headers: headers}, ), + returnValue: _i3.Future.value(_i5.dummyValue( + this, + Invocation.method( + #read, + [url], + {#headers: headers}, + ), + )), ) as _i3.Future); @override @@ -187,22 +248,37 @@ class MockClient extends _i1.Mock implements _i2.Client { Map? headers, }) => (super.noSuchMethod( - Invocation.method(#readBytes, [url], {#headers: headers}), + Invocation.method( + #readBytes, + [url], + {#headers: headers}, + ), returnValue: _i3.Future<_i6.Uint8List>.value(_i6.Uint8List(0)), ) as _i3.Future<_i6.Uint8List>); @override _i3.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) => (super.noSuchMethod( - Invocation.method(#send, [request]), - returnValue: _i3.Future<_i2.StreamedResponse>.value( - _FakeStreamedResponse_1(this, Invocation.method(#send, [request])), + Invocation.method( + #send, + [request], ), + returnValue: + _i3.Future<_i2.StreamedResponse>.value(_FakeStreamedResponse_1( + this, + Invocation.method( + #send, + [request], + ), + )), ) as _i3.Future<_i2.StreamedResponse>); @override void close() => super.noSuchMethod( - Invocation.method(#close, []), + Invocation.method( + #close, + [], + ), returnValueForMissingStub: null, ); } diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb index 96e4b575c..c28dfbc3f 100644 --- a/packages/ndk_flutter/lib/l10n/app_en.arb +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -1015,6 +1015,19 @@ "description": "Description for send by Lightning option" }, "payInvoiceTitle": "Pay Invoice", + "sendToWallet": "Send to Wallet", + "sendToWalletDescription": "Transfer to another compatible wallet", + "noCompatibleReceivingWallets": "No compatible receiving wallets", + "noCompatibleReceivingWalletsDescription": "Add or connect another wallet that can receive a payment supported by this wallet.", + "destinationWallet": "Destination wallet", + "walletTransferSubmitted": "Payment sent to {walletName}", + "@walletTransferSubmitted": { + "placeholders": { + "walletName": { + "type": "String" + } + } + }, "@payInvoiceTitle": { "description": "Title for pay invoice dialog" }, @@ -1496,5 +1509,58 @@ "description": "Number of restored proofs" } } - } + }, + "bolt12Wallet": "BOLT12 Wallet", + "bolt12WalletSubtitle": "Reusable Lightning offer", + "bolt12PrivateOfferSubtitle": "Reusable private offer", + "anyAmount": "Any amount", + "blindedRoute": "Blinded", + "fromAmountSats": "From {amount} sats", + "@fromAmountSats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "fromAmountMsats": "From {amount} msats", + "@fromAmountMsats": { + "placeholders": { + "amount": { + "type": "String" + } + } + }, + "fromCurrencyAmount": "From {amount} {currency}", + "@fromCurrencyAmount": { + "placeholders": { + "amount": { + "type": "String" + }, + "currency": { + "type": "String" + } + } + }, + "bolt12Expires": "Expires {date}", + "@bolt12Expires": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "bolt12WalletTypeTitle": "BOLT12 Offer", + "bolt12WalletTypeSubtitle": "Receive-only wallet using a reusable offer", + "addBolt12WalletTitle": "Add BOLT12 Wallet", + "enterBolt12Input": "Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.", + "bolt12Input": "BOLT12 payment target", + "bolt12InputHint": "lno1..., bitcoin:?lno=..., or user@domain.com", + "walletNameOptional": "Wallet name (optional)", + "scanBolt12QrCodeTitle": "Scan BOLT12 QR code", + "invalidBolt12QrCode": "The QR code is not a BOLT12, BIP321, or BIP353 payment target.", + "pleaseEnterBolt12Input": "Please enter a BOLT12 offer or BIP353 address.", + "bolt12WalletAdded": "BOLT12 wallet added successfully!", + "bolt12OfferTitle": "Receive with BOLT12", + "bolt12OfferInstructions": "Share this reusable offer to receive a Lightning payment." } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart index b12e95fa2..1e5821e32 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -1619,6 +1619,42 @@ abstract class AppLocalizations { /// **'Pay Invoice'** String get payInvoiceTitle; + /// No description provided for @sendToWallet. + /// + /// In en, this message translates to: + /// **'Send to Wallet'** + String get sendToWallet; + + /// No description provided for @sendToWalletDescription. + /// + /// In en, this message translates to: + /// **'Transfer to another compatible wallet'** + String get sendToWalletDescription; + + /// No description provided for @noCompatibleReceivingWallets. + /// + /// In en, this message translates to: + /// **'No compatible receiving wallets'** + String get noCompatibleReceivingWallets; + + /// No description provided for @noCompatibleReceivingWalletsDescription. + /// + /// In en, this message translates to: + /// **'Add or connect another wallet that can receive a payment supported by this wallet.'** + String get noCompatibleReceivingWalletsDescription; + + /// No description provided for @destinationWallet. + /// + /// In en, this message translates to: + /// **'Destination wallet'** + String get destinationWallet; + + /// No description provided for @walletTransferSubmitted. + /// + /// In en, this message translates to: + /// **'Payment sent to {walletName}'** + String walletTransferSubmitted(String walletName); + /// Label for invoice input /// /// In en, this message translates to: @@ -2290,6 +2326,138 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Restored {count} proofs from backup'** String restoreSuccess(int count); + + /// No description provided for @bolt12Wallet. + /// + /// In en, this message translates to: + /// **'BOLT12 Wallet'** + String get bolt12Wallet; + + /// No description provided for @bolt12WalletSubtitle. + /// + /// In en, this message translates to: + /// **'Reusable Lightning offer'** + String get bolt12WalletSubtitle; + + /// No description provided for @bolt12PrivateOfferSubtitle. + /// + /// In en, this message translates to: + /// **'Reusable private offer'** + String get bolt12PrivateOfferSubtitle; + + /// No description provided for @anyAmount. + /// + /// In en, this message translates to: + /// **'Any amount'** + String get anyAmount; + + /// No description provided for @blindedRoute. + /// + /// In en, this message translates to: + /// **'Blinded'** + String get blindedRoute; + + /// No description provided for @fromAmountSats. + /// + /// In en, this message translates to: + /// **'From {amount} sats'** + String fromAmountSats(String amount); + + /// No description provided for @fromAmountMsats. + /// + /// In en, this message translates to: + /// **'From {amount} msats'** + String fromAmountMsats(String amount); + + /// No description provided for @fromCurrencyAmount. + /// + /// In en, this message translates to: + /// **'From {amount} {currency}'** + String fromCurrencyAmount(String amount, String currency); + + /// No description provided for @bolt12Expires. + /// + /// In en, this message translates to: + /// **'Expires {date}'** + String bolt12Expires(String date); + + /// No description provided for @bolt12WalletTypeTitle. + /// + /// In en, this message translates to: + /// **'BOLT12 Offer'** + String get bolt12WalletTypeTitle; + + /// No description provided for @bolt12WalletTypeSubtitle. + /// + /// In en, this message translates to: + /// **'Receive-only wallet using a reusable offer'** + String get bolt12WalletTypeSubtitle; + + /// No description provided for @addBolt12WalletTitle. + /// + /// In en, this message translates to: + /// **'Add BOLT12 Wallet'** + String get addBolt12WalletTitle; + + /// No description provided for @enterBolt12Input. + /// + /// In en, this message translates to: + /// **'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'** + String get enterBolt12Input; + + /// No description provided for @bolt12Input. + /// + /// In en, this message translates to: + /// **'BOLT12 payment target'** + String get bolt12Input; + + /// No description provided for @bolt12InputHint. + /// + /// In en, this message translates to: + /// **'lno1..., bitcoin:?lno=..., or user@domain.com'** + String get bolt12InputHint; + + /// No description provided for @walletNameOptional. + /// + /// In en, this message translates to: + /// **'Wallet name (optional)'** + String get walletNameOptional; + + /// No description provided for @scanBolt12QrCodeTitle. + /// + /// In en, this message translates to: + /// **'Scan BOLT12 QR code'** + String get scanBolt12QrCodeTitle; + + /// No description provided for @invalidBolt12QrCode. + /// + /// In en, this message translates to: + /// **'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'** + String get invalidBolt12QrCode; + + /// No description provided for @pleaseEnterBolt12Input. + /// + /// In en, this message translates to: + /// **'Please enter a BOLT12 offer or BIP353 address.'** + String get pleaseEnterBolt12Input; + + /// No description provided for @bolt12WalletAdded. + /// + /// In en, this message translates to: + /// **'BOLT12 wallet added successfully!'** + String get bolt12WalletAdded; + + /// No description provided for @bolt12OfferTitle. + /// + /// In en, this message translates to: + /// **'Receive with BOLT12'** + String get bolt12OfferTitle; + + /// No description provided for @bolt12OfferInstructions. + /// + /// In en, this message translates to: + /// **'Share this reusable offer to receive a Lightning payment.'** + String get bolt12OfferInstructions; } class _AppLocalizationsDelegate diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart index a09aa411d..48a7536d0 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_de.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_de.dart @@ -771,6 +771,27 @@ class AppLocalizationsDe extends AppLocalizations { @override String get payInvoiceTitle => 'Rechnung bezahlen'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Rechnung'; @@ -1135,4 +1156,83 @@ class AppLocalizationsDe extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart index d5ac75667..3783ee751 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -770,6 +770,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get payInvoiceTitle => 'Pay Invoice'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Invoice'; @@ -1131,4 +1152,83 @@ class AppLocalizationsEn extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart index 49f1c9a79..3af077a19 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -773,6 +773,27 @@ class AppLocalizationsEs extends AppLocalizations { @override String get payInvoiceTitle => 'Pagar Factura'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Factura'; @@ -1136,4 +1157,83 @@ class AppLocalizationsEs extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart index f36e18d78..02136ee93 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fi.dart @@ -771,6 +771,27 @@ class AppLocalizationsFi extends AppLocalizations { @override String get payInvoiceTitle => 'Maksa lasku'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Lasku'; @@ -1133,4 +1154,83 @@ class AppLocalizationsFi extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart index 7dd0b8df0..eed077410 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -772,6 +772,27 @@ class AppLocalizationsFr extends AppLocalizations { @override String get payInvoiceTitle => 'Payer la Facture'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Facture'; @@ -1136,4 +1157,83 @@ class AppLocalizationsFr extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart index 865182eb7..cadef2105 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_it.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_it.dart @@ -774,6 +774,27 @@ class AppLocalizationsIt extends AppLocalizations { @override String get payInvoiceTitle => 'Paga fattura'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Fattura'; @@ -1137,4 +1158,83 @@ class AppLocalizationsIt extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart index ea6e13eb9..737f7765d 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -764,6 +764,27 @@ class AppLocalizationsJa extends AppLocalizations { @override String get payInvoiceTitle => '請求書を支払う'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => '請求書'; @@ -1122,4 +1143,83 @@ class AppLocalizationsJa extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart index 6820fc488..81d31fb62 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pl.dart @@ -774,6 +774,27 @@ class AppLocalizationsPl extends AppLocalizations { @override String get payInvoiceTitle => 'Zapłać fakturę'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Faktura'; @@ -1135,4 +1156,83 @@ class AppLocalizationsPl extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart index 6f3f422b8..65dd1e198 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_pt.dart @@ -775,6 +775,27 @@ class AppLocalizationsPt extends AppLocalizations { @override String get payInvoiceTitle => 'Pagar fatura'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Fatura'; @@ -1139,6 +1160,85 @@ class AppLocalizationsPt extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart index e65906eb2..7d523643f 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -770,6 +770,27 @@ class AppLocalizationsRu extends AppLocalizations { @override String get payInvoiceTitle => 'Оплатить Счёт'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Счёт'; @@ -1134,4 +1155,83 @@ class AppLocalizationsRu extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart index 82581ca95..6f6ea6c4e 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_sk.dart @@ -771,6 +771,27 @@ class AppLocalizationsSk extends AppLocalizations { @override String get payInvoiceTitle => 'Zaplatiť faktúru'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => 'Faktúra'; @@ -1133,4 +1154,83 @@ class AppLocalizationsSk extends AppLocalizations { String restoreSuccess(int count) { return 'Obnovených $count dôkazov zo zálohy'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart index cb51dea18..0dc6fb56b 100644 --- a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -764,6 +764,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get payInvoiceTitle => '支付发票'; + @override + String get sendToWallet => 'Send to Wallet'; + + @override + String get sendToWalletDescription => 'Transfer to another compatible wallet'; + + @override + String get noCompatibleReceivingWallets => 'No compatible receiving wallets'; + + @override + String get noCompatibleReceivingWalletsDescription => + 'Add or connect another wallet that can receive a payment supported by this wallet.'; + + @override + String get destinationWallet => 'Destination wallet'; + + @override + String walletTransferSubmitted(String walletName) { + return 'Payment sent to $walletName'; + } + @override String get invoice => '发票'; @@ -1121,4 +1142,83 @@ class AppLocalizationsZh extends AppLocalizations { String restoreSuccess(int count) { return 'Restored $count proofs from backup'; } + + @override + String get bolt12Wallet => 'BOLT12 Wallet'; + + @override + String get bolt12WalletSubtitle => 'Reusable Lightning offer'; + + @override + String get bolt12PrivateOfferSubtitle => 'Reusable private offer'; + + @override + String get anyAmount => 'Any amount'; + + @override + String get blindedRoute => 'Blinded'; + + @override + String fromAmountSats(String amount) { + return 'From $amount sats'; + } + + @override + String fromAmountMsats(String amount) { + return 'From $amount msats'; + } + + @override + String fromCurrencyAmount(String amount, String currency) { + return 'From $amount $currency'; + } + + @override + String bolt12Expires(String date) { + return 'Expires $date'; + } + + @override + String get bolt12WalletTypeTitle => 'BOLT12 Offer'; + + @override + String get bolt12WalletTypeSubtitle => + 'Receive-only wallet using a reusable offer'; + + @override + String get addBolt12WalletTitle => 'Add BOLT12 Wallet'; + + @override + String get enterBolt12Input => + 'Enter or scan an lno offer, a bitcoin:?lno=... URI, or a BIP353 address.'; + + @override + String get bolt12Input => 'BOLT12 payment target'; + + @override + String get bolt12InputHint => 'lno1..., bitcoin:?lno=..., or user@domain.com'; + + @override + String get walletNameOptional => 'Wallet name (optional)'; + + @override + String get scanBolt12QrCodeTitle => 'Scan BOLT12 QR code'; + + @override + String get invalidBolt12QrCode => + 'The QR code is not a BOLT12, BIP321, or BIP353 payment target.'; + + @override + String get pleaseEnterBolt12Input => + 'Please enter a BOLT12 offer or BIP353 address.'; + + @override + String get bolt12WalletAdded => 'BOLT12 wallet added successfully!'; + + @override + String get bolt12OfferTitle => 'Receive with BOLT12'; + + @override + String get bolt12OfferInstructions => + 'Share this reusable offer to receive a Lightning payment.'; } diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart index f33a164a0..7036cac48 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_add_wallet_dialogs.dart @@ -22,6 +22,9 @@ const String _dialogBackResult = '__back__'; /// Opens a host-provided NWC QR scanner and returns the scanned URI. typedef NwcUriScanner = Future Function(BuildContext context); +/// Opens a host-provided scanner and returns a BOLT12, BIP321, or BIP353 input. +typedef Bolt12InputScanner = Future Function(BuildContext context); + enum AlbyGoConnectMethod { walletAuth, nostrNwcCallback } const List _defaultAlbyGoRequestMethods = [ @@ -968,7 +971,219 @@ class _AddLnurlWalletDialogState extends State<_AddLnurlWalletDialog> { } } -/// Shows a dialog to choose wallet type (Cashu, NWC, or LNURL). +/// Shows a dialog to add a receive-only BOLT12 offer wallet. +Future showAddBolt12WalletDialog( + BuildContext context, + NdkFlutter ndkFlutter, { + bool returnToWalletType = false, + AlbyGoConnectConfig albyGoConnectConfig = kDefaultAlbyGoConnectConfig, + NwcWalletAuthCoordinator? nwcWalletAuthCoordinator, + NwcUriScanner? nwcUriScanner, + Bolt12InputScanner? bolt12InputScanner, +}) { + return showDialog( + context: context, + builder: (dialogContext) => _AddBolt12WalletDialog( + ndkFlutter: ndkFlutter, + parentContext: context, + returnToWalletType: returnToWalletType, + albyGoConnectConfig: albyGoConnectConfig, + nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, + nwcUriScanner: nwcUriScanner, + bolt12InputScanner: bolt12InputScanner, + ), + ); +} + +class _AddBolt12WalletDialog extends StatefulWidget { + final NdkFlutter ndkFlutter; + final BuildContext parentContext; + final bool returnToWalletType; + final AlbyGoConnectConfig albyGoConnectConfig; + final NwcWalletAuthCoordinator? nwcWalletAuthCoordinator; + final NwcUriScanner? nwcUriScanner; + final Bolt12InputScanner? bolt12InputScanner; + + const _AddBolt12WalletDialog({ + required this.ndkFlutter, + required this.parentContext, + required this.returnToWalletType, + required this.albyGoConnectConfig, + required this.nwcWalletAuthCoordinator, + required this.nwcUriScanner, + required this.bolt12InputScanner, + }); + + @override + State<_AddBolt12WalletDialog> createState() => _AddBolt12WalletDialogState(); +} + +class _AddBolt12WalletDialogState extends State<_AddBolt12WalletDialog> { + final _inputController = TextEditingController(); + final _nameController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _inputController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _scan() async { + final scanner = widget.bolt12InputScanner; + if (scanner == null) return; + final value = await scanner(context); + if (!mounted || value == null) return; + + if (Bolt12WalletProvider.isSupportedInput(value)) { + _inputController.text = value.trim(); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.invalidBolt12QrCode), + backgroundColor: Colors.red, + ), + ); + } + + Future _add() async { + final l10n = AppLocalizations.of(context)!; + final scaffoldMessenger = ScaffoldMessenger.of(context); + final input = _inputController.text.trim(); + if (input.isEmpty) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.pleaseEnterBolt12Input), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() => _isLoading = true); + try { + final resolved = await Bolt12WalletProvider.resolveInput(input); + final requestedName = _nameController.text.trim(); + final description = resolved.decoded['offer_description'] as String?; + final name = requestedName.isNotEmpty + ? requestedName + : resolved.bip353Address ?? description ?? l10n.bolt12Wallet; + final wallet = + widget.ndkFlutter.ndk.wallets.createWallet( + id: 'bolt12-${DateTime.now().microsecondsSinceEpoch}', + name: name, + type: WalletType.BOLT12, + supportedUnits: {'sat'}, + metadata: resolved.toMetadata(), + ) + as Bolt12Wallet; + await widget.ndkFlutter.ndk.wallets.addWallet(wallet); + + if (!mounted) return; + Navigator.of(context).pop(wallet); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.bolt12WalletAdded), + backgroundColor: Colors.green, + ), + ); + } catch (error) { + scaffoldMessenger.showSnackBar( + SnackBar(content: Text(error.toString()), backgroundColor: Colors.red), + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return AlertDialog( + title: Row( + children: [ + IconButton( + onPressed: () async { + Navigator.of(context).pop(); + if (widget.returnToWalletType && widget.parentContext.mounted) { + await showAddWalletTypeDialog( + widget.parentContext, + widget.ndkFlutter, + albyGoConnectConfig: widget.albyGoConnectConfig, + nwcWalletAuthCoordinator: widget.nwcWalletAuthCoordinator, + nwcUriScanner: widget.nwcUriScanner, + bolt12InputScanner: widget.bolt12InputScanner, + ); + } + }, + icon: const Icon(Icons.arrow_back), + ), + Expanded(child: Text(l10n.addBolt12WalletTitle)), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + content: SizedBox( + width: 520, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.enterBolt12Input), + const SizedBox(height: 16), + TextField( + controller: _inputController, + minLines: 2, + maxLines: 4, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.bolt12Input, + hintText: l10n.bolt12InputHint, + suffixIcon: widget.bolt12InputScanner == null + ? null + : IconButton( + onPressed: _scan, + icon: const Icon(Icons.qr_code_scanner), + tooltip: l10n.scanBolt12QrCodeTitle, + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _nameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.walletNameOptional, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: _isLoading ? null : _add, + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.add), + ), + ], + ); + } +} + +/// Shows a dialog to choose wallet type. /// /// Returns true if a wallet type was selected, false if cancelled. /// Use [albyGoConnectConfig] to override Alby Go app metadata. @@ -978,6 +1193,7 @@ Future showAddWalletTypeDialog( AlbyGoConnectConfig albyGoConnectConfig = kDefaultAlbyGoConnectConfig, NwcWalletAuthCoordinator? nwcWalletAuthCoordinator, NwcUriScanner? nwcUriScanner, + Bolt12InputScanner? bolt12InputScanner, }) async { final l10n = AppLocalizations.of(context)!; @@ -1057,6 +1273,25 @@ Future showAddWalletTypeDialog( }, ), const SizedBox(height: 12), + _WalletTypeListOption( + icon: Icons.electric_bolt, + title: l10n.bolt12WalletTypeTitle, + subtitle: l10n.bolt12WalletTypeSubtitle, + infoUrl: 'https://bolt12.org/', + onTap: () async { + Navigator.of(dialogContext).pop(true); + await showAddBolt12WalletDialog( + context, + ndkFlutter, + returnToWalletType: true, + albyGoConnectConfig: albyGoConnectConfig, + nwcWalletAuthCoordinator: nwcWalletAuthCoordinator, + nwcUriScanner: nwcUriScanner, + bolt12InputScanner: bolt12InputScanner, + ); + }, + ), + const SizedBox(height: 12), _WalletTypeListOption( imageAsset: 'assets/images/cashu.png', title: l10n.cashuWalletTypeTitle, diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart index a2d9696b8..9eddb8186 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_actions.dart @@ -64,6 +64,7 @@ class _NWalletActionsState extends State final bool isCashu = wallet is CashuWallet; final bool isNwc = wallet is NwcWallet; + final bool isBolt12 = wallet is Bolt12Wallet; final bool canSend = wallet.canSend; final bool canReceive = wallet.canReceive; final bool condensed = widget.condensed; @@ -101,6 +102,8 @@ class _NWalletActionsState extends State return const Icon(Icons.cloud, color: Colors.blue); }, ) + else if (isBolt12) + const Icon(Icons.electric_bolt, color: Colors.green) else const Icon(Icons.bolt, color: Colors.purple), const SizedBox(width: 8), @@ -109,6 +112,8 @@ class _NWalletActionsState extends State ? l10n.cashuWallet : isNwc ? l10n.nwcWallet + : isBolt12 + ? l10n.bolt12Wallet : l10n.lnurlWallet, style: Theme.of(context).textTheme.titleMedium, ), diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart index 11ced4ef2..d3f26a9d9 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card.dart @@ -58,6 +58,9 @@ class NWalletCard extends StatefulWidget { /// Custom icon configuration for LNURL wallets final WalletIconConfig? lnurlIcon; + /// Custom icon configuration for BOLT12 wallets + final WalletIconConfig? bolt12Icon; + const NWalletCard({ super.key, required this.wallet, @@ -73,6 +76,7 @@ class NWalletCard extends StatefulWidget { this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, }); @override @@ -247,6 +251,7 @@ class _NWalletCardState extends State final bool isCashu = widget.wallet is CashuWallet; final bool isNwc = widget.wallet is NwcWallet; final bool isLnurl = widget.wallet is LnurlWallet; + final bool isBolt12 = widget.wallet is Bolt12Wallet; final nwcPermissions = isNwc ? _nwcPermissions(widget.wallet as NwcWallet) : const {}; @@ -267,6 +272,8 @@ class _NWalletCardState extends State walletName = (widget.wallet as NwcWallet).name; } else if (isLnurl) { walletName = (widget.wallet as LnurlWallet).name; + } else if (isBolt12) { + walletName = (widget.wallet as Bolt12Wallet).name; } else { walletName = l10n.unknownWalletType; } @@ -284,6 +291,14 @@ class _NWalletCardState extends State subtitle = lnurlWallet.identifier == lnurlWallet.name ? '' : lnurlWallet.identifier; + } else if (isBolt12) { + final bolt12Wallet = widget.wallet as Bolt12Wallet; + subtitle = + _nonEmpty(bolt12Wallet.bip353Address) ?? + _nonEmpty(bolt12Wallet.issuer) ?? + (bolt12Wallet.hasBlindedPaths + ? l10n.bolt12PrivateOfferSubtitle + : l10n.bolt12WalletSubtitle); } else { subtitle = ''; } @@ -303,7 +318,12 @@ class _NWalletCardState extends State .toColor(); gradientColors = [color, lighterColor]; } else { - gradientColors = _getDefaultGradientColors(isCashu, isNwc, isLnurl); + gradientColors = _getDefaultGradientColors( + isCashu, + isNwc, + isLnurl, + isBolt12, + ); } } final Color shadowColor = gradientColors[0]; @@ -324,6 +344,10 @@ class _NWalletCardState extends State iconConfig = widget.lnurlIcon ?? const WalletIconConfig(); defaultAssetName = null; // LNURL uses bolt icon, not PNG fallbackIcon = Icons.bolt; + } else if (isBolt12) { + iconConfig = widget.bolt12Icon ?? const WalletIconConfig(); + defaultAssetName = null; + fallbackIcon = Icons.electric_bolt; } else { iconConfig = const WalletIconConfig(); defaultAssetName = 'wallet.png'; @@ -478,6 +502,11 @@ class _NWalletCardState extends State context, widget.wallet as LnurlWallet, ) + : isBolt12 + ? _buildBolt12Info( + context, + widget.wallet as Bolt12Wallet, + ) : (canShowNwcBalance ? _buildBalance(context) : const SizedBox.shrink()), @@ -805,6 +834,7 @@ class _NWalletCardState extends State bool isCashu, bool isNwc, bool isLnurl, + bool isBolt12, ) { if (isCashu) { return [const Color(0xFF7F38CA), const Color(0xFF9B5AD8)]; @@ -815,6 +845,8 @@ class _NWalletCardState extends State ]; } else if (isLnurl) { return [const Color(0xFFFFB300), const Color(0xFFFFC107)]; + } else if (isBolt12) { + return [const Color(0xFF1B5E20), const Color(0xFF43A047)]; } else { return [Colors.grey[700]!, Colors.grey[400]!]; } @@ -1018,6 +1050,26 @@ class _NWalletCardState extends State metadataFetchedAt: w.metadataFetchedAt, metadata: updatedMetadata, ); + } else if (widget.wallet is Bolt12Wallet) { + final w = widget.wallet as Bolt12Wallet; + updatedWallet = Bolt12Wallet( + id: w.id, + name: w.name, + supportedUnits: w.supportedUnits, + offer: w.offer, + source: w.source, + bip353Address: w.bip353Address, + description: w.description, + nodeId: w.nodeId, + offerId: w.offerId, + amount: w.amount, + issuer: w.issuer, + currency: w.currency, + expiresAt: w.expiresAt, + quantityMax: w.quantityMax, + hasBlindedPaths: w.hasBlindedPaths, + metadata: updatedMetadata, + ); } else { throw UnsupportedError('Unknown wallet type'); } @@ -1068,6 +1120,79 @@ class _NWalletCardState extends State ); } + Widget _buildBolt12Info(BuildContext context, Bolt12Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final description = _nonEmpty(wallet.description); + final summary = [ + l10n.receiveOnlyWallet, + _formatBolt12Amount(context, wallet), + if (wallet.hasBlindedPaths) l10n.blindedRoute, + if (wallet.expiresAt != null) + l10n.bolt12Expires( + DateFormat.yMd(Localizations.localeOf(context).toString()).format( + DateTime.fromMillisecondsSinceEpoch( + wallet.expiresAt! * 1000, + isUtc: true, + ).toLocal(), + ), + ), + ].join(' · '); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + description ?? _shortBolt12Offer(wallet.offer), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + summary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white.withAlpha(200), fontSize: 12), + ), + ], + ); + } + + String _formatBolt12Amount(BuildContext context, Bolt12Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final rawAmount = _nonEmpty(wallet.amount); + final amount = rawAmount == null ? null : int.tryParse(rawAmount); + if (rawAmount == null || amount == 0) return l10n.anyAmount; + + final formatter = NumberFormat.decimalPattern( + Localizations.localeOf(context).toString(), + ); + final formattedAmount = amount == null + ? rawAmount + : formatter.format(amount); + final currency = _nonEmpty(wallet.currency); + if (currency != null) { + return l10n.fromCurrencyAmount(formattedAmount, currency.toUpperCase()); + } + if (amount != null && amount % 1000 == 0) { + return l10n.fromAmountSats(formatter.format(amount ~/ 1000)); + } + return l10n.fromAmountMsats(formattedAmount); + } + + String _shortBolt12Offer(String offer) { + if (offer.length <= 18) return offer; + return '${offer.substring(0, 9)}…${offer.substring(offer.length - 6)}'; + } + + String? _nonEmpty(String? value) { + final normalized = value?.trim(); + return normalized == null || normalized.isEmpty ? null : normalized; + } + Widget _buildBalance(BuildContext context) { final l10n = AppLocalizations.of(context)!; final numberFormatter = NumberFormat.decimalPattern( diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart index 1478077fb..583f92c19 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallet_card_list.dart @@ -29,6 +29,9 @@ class NWalletCardList extends StatefulWidget { /// Custom icon configuration for LNURL wallets final WalletIconConfig? lnurlIcon; + /// Custom icon configuration for BOLT12 wallets + final WalletIconConfig? bolt12Icon; + /// Whether to show the add-wallet template card. final bool showAddWalletCard; @@ -44,6 +47,7 @@ class NWalletCardList extends StatefulWidget { this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, this.showAddWalletCard = true, }); @@ -135,6 +139,26 @@ class _NWalletCardListState extends State { metadata: metadata, ); } + if (wallet is Bolt12Wallet) { + return Bolt12Wallet( + id: wallet.id, + name: wallet.name, + supportedUnits: wallet.supportedUnits, + offer: wallet.offer, + source: wallet.source, + bip353Address: wallet.bip353Address, + description: wallet.description, + nodeId: wallet.nodeId, + offerId: wallet.offerId, + amount: wallet.amount, + issuer: wallet.issuer, + currency: wallet.currency, + expiresAt: wallet.expiresAt, + quantityMax: wallet.quantityMax, + hasBlindedPaths: wallet.hasBlindedPaths, + metadata: metadata, + ); + } throw UnsupportedError('Unknown wallet type'); } @@ -253,6 +277,7 @@ class _NWalletCardListState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ); }, diff --git a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart index c080b4e50..227ea1ab6 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/n_wallets.dart @@ -67,6 +67,9 @@ class NWallets extends StatefulWidget { /// Optional host-provided scanner for NWC QR codes. final NwcUriScanner? nwcUriScanner; + /// Optional host-provided scanner for BOLT12/BIP321/BIP353 QR codes. + final Bolt12InputScanner? bolt12InputScanner; + /// Custom icon configuration for Cashu wallets final WalletIconConfig? cashuIcon; @@ -76,6 +79,9 @@ class NWallets extends StatefulWidget { /// Custom icon configuration for LNURL wallets final WalletIconConfig? lnurlIcon; + /// Custom icon configuration for BOLT12 wallets + final WalletIconConfig? bolt12Icon; + const NWallets({ super.key, required this.ndkFlutter, @@ -98,9 +104,11 @@ class NWallets extends StatefulWidget { this.albyGoConnectConfig = kDefaultAlbyGoConnectConfig, this.nwcWalletAuthCoordinator, this.nwcUriScanner, + this.bolt12InputScanner, this.cashuIcon, this.nwcIcon, this.lnurlIcon, + this.bolt12Icon, }); @override @@ -181,6 +189,7 @@ class NWalletsState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ), ], @@ -216,6 +225,7 @@ class NWalletsState extends State { cashuIcon: widget.cashuIcon, nwcIcon: widget.nwcIcon, lnurlIcon: widget.lnurlIcon, + bolt12Icon: widget.bolt12Icon, ), ), if (showActionsSection) ...[ @@ -269,6 +279,7 @@ class NWalletsState extends State { albyGoConnectConfig: widget.albyGoConnectConfig, nwcWalletAuthCoordinator: _nwcWalletAuthCoordinator, nwcUriScanner: widget.nwcUriScanner, + bolt12InputScanner: widget.bolt12InputScanner, ); } } diff --git a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart index 752b814a5..898fb2bdc 100644 --- a/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart +++ b/packages/ndk_flutter/lib/widgets/wallets/wallet_action_dialogs.dart @@ -6,6 +6,8 @@ import 'package:pretty_qr_code/pretty_qr_code.dart'; import '../../l10n/app_localizations.dart'; +enum _WalletSendAction { token, invoice, transfer } + /// Funding transactions reclaimable via [Cashu.retrieveFunds]: they carry a /// mint quote, method and used keysets. Pending sends/redeems have none and are /// skipped. Optionally filtered to a single [mintUrl]. @@ -167,13 +169,83 @@ mixin WalletActionDialogsMixin on State { /// Receive flow that picks the right dialog per wallet type. void showReceiveFlow(BuildContext context, Wallet wallet) { - if (wallet is NwcWallet || wallet is LnurlWallet) { + if (wallet is Bolt12Wallet) { + _showBolt12OfferDialog(context, wallet); + } else if (wallet is NwcWallet || wallet is LnurlWallet) { _showCreateInvoiceDialog(context, wallet); } else { _showReceiveDialog(context, wallet); } } + void _showBolt12OfferDialog(BuildContext context, Bolt12Wallet wallet) { + final l10n = AppLocalizations.of(context)!; + final scaffoldMessenger = ScaffoldMessenger.of(context); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.bolt12OfferTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.bolt12OfferInstructions), + const SizedBox(height: 12), + SizedBox( + width: 220, + child: PrettyQrView.data( + data: wallet.offer.toUpperCase(), + errorCorrectLevel: QrErrorCorrectLevel.M, + decoration: const PrettyQrDecoration( + quietZone: PrettyQrQuietZone.standard, + background: Colors.white, + shape: PrettyQrSmoothSymbol( + color: Colors.black, + roundFactor: 0.3, + ), + ), + ), + ), + const SizedBox(height: 12), + Container( + constraints: const BoxConstraints(maxHeight: 120), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: SelectableText( + wallet.offer, + style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + TextButton.icon( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: wallet.offer)); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text(l10n.copied), + backgroundColor: Colors.green, + ), + ); + }, + icon: const Icon(Icons.copy), + label: Text(l10n.copy), + ), + ], + ), + ); + } + /// Reclaims all reclaimable pending funding transactions of [wallet]. Future showReclaimPending( BuildContext context, @@ -376,15 +448,29 @@ mixin WalletActionDialogsMixin on State { ); } - void showSendDialog(BuildContext context, Wallet wallet) { + Future showSendDialog(BuildContext context, Wallet wallet) async { final l10n = AppLocalizations.of(context)!; - showModalBottomSheet( + final wallets = await ndkFlutter.ndk.wallets.getWallets(); + if (!context.mounted) return; + final destinations = wallets + .where( + (destination) => + destination.id != wallet.id && + ndkFlutter.ndk.wallets.compatibleTransferProtocol( + source: wallet, + destination: destination, + ) != + null, + ) + .toList(); + + final action = await showModalBottomSheet<_WalletSendAction>( context: context, isScrollControlled: true, - builder: (context) { + builder: (sheetContext) { return Padding( padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, left: 16, right: 16, top: 16, @@ -395,7 +481,7 @@ mixin WalletActionDialogsMixin on State { children: [ Text( l10n.sendOptionsTitle, - style: Theme.of(context).textTheme.headlineSmall, + style: Theme.of(sheetContext).textTheme.headlineSmall, ), const SizedBox(height: 16), if (wallet is CashuWallet) ...[ @@ -403,36 +489,264 @@ mixin WalletActionDialogsMixin on State { leading: const Icon(Icons.receipt), title: Text(l10n.sendByToken), subtitle: Text(l10n.sendByTokenDescription), - onTap: () { - Navigator.pop(context); - _showSendTokenDialog(context, wallet); - }, + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.token), ), ListTile( leading: const Icon(Icons.flash_on), title: Text(l10n.sendByLightning), subtitle: Text(l10n.sendByLightningDescription), - onTap: () { - Navigator.pop(context); - _showPayInvoiceDialog(context, wallet); - }, + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.invoice), ), ] else if (wallet is NwcWallet) ...[ ListTile( leading: const Icon(Icons.flash_on), title: Text(l10n.payInvoiceTitle), - onTap: () { - Navigator.pop(context); - _showPayInvoiceDialog(context, wallet); - }, + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.invoice), ), ], + ListTile( + leading: const Icon(Icons.swap_horiz), + title: Text(l10n.sendToWallet), + subtitle: Text( + destinations.isEmpty + ? l10n.noCompatibleReceivingWallets + : l10n.sendToWalletDescription, + ), + onTap: () => + Navigator.pop(sheetContext, _WalletSendAction.transfer), + ), const SizedBox(height: 16), ], ), ); }, ); + + if (!context.mounted || action == null) return; + switch (action) { + case _WalletSendAction.token: + _showSendTokenDialog(context, wallet as CashuWallet); + case _WalletSendAction.invoice: + _showPayInvoiceDialog(context, wallet); + case _WalletSendAction.transfer: + if (destinations.isEmpty) { + await _showNoCompatibleWalletsDialog(context); + } else { + await _showWalletTransferDialog(context, wallet, destinations); + } + } + } + + Future _showNoCompatibleWalletsDialog(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.noCompatibleReceivingWallets), + content: Text(l10n.noCompatibleReceivingWalletsDescription), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + ], + ), + ); + } + + Future _showWalletTransferDialog( + BuildContext context, + Wallet source, + List destinations, + ) async { + final l10n = AppLocalizations.of(context)!; + final amountController = TextEditingController(); + var selectedDestination = destinations.first; + var sending = false; + + await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) { + final offerAmount = _bolt12OfferAmount(selectedDestination); + return AlertDialog( + title: Text(l10n.sendToWallet), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: selectedDestination.id, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.destinationWallet, + ), + isExpanded: true, + items: [ + for (final destination in destinations) + DropdownMenuItem( + value: destination.id, + child: Text( + _walletDisplayName(l10n, destination), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: sending + ? null + : (walletId) { + if (walletId == null) return; + setDialogState(() { + selectedDestination = destinations.firstWhere( + (wallet) => wallet.id == walletId, + ); + amountController.clear(); + }); + }, + ), + const SizedBox(height: 16), + if (offerAmount != null) + InputDecorator( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.amount, + ), + child: Text( + _bolt12OfferAmountLabel(selectedDestination, offerAmount), + ), + ) + else + TextField( + controller: amountController, + enabled: !sending, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.amount, + suffixText: l10n.sats, + hintText: l10n.amountHint, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: sending + ? null + : () => Navigator.of(dialogContext).pop(), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: sending + ? null + : () async { + final fixedOfferAmount = _bolt12OfferAmount( + selectedDestination, + ); + final int? amountMsat; + if (fixedOfferAmount != null) { + // The offer already defines its amount. Omitting the + // pay parameter avoids conflicting with it. + amountMsat = null; + } else { + final amountSats = int.tryParse( + amountController.text.trim(), + ); + if (amountSats == null || amountSats <= 0) { + displayError(l10n.pleaseEnterValidAmount); + return; + } + amountMsat = amountSats * 1000; + } + + setDialogState(() => sending = true); + try { + await ndkFlutter.ndk.wallets.transfer( + sourceWalletId: source.id, + destinationWalletId: selectedDestination.id, + amountMsat: amountMsat, + ); + if (!mounted || !dialogContext.mounted) return; + Navigator.of(dialogContext).pop(); + displaySuccess( + l10n.walletTransferSubmitted( + _walletDisplayName(l10n, selectedDestination), + ), + ); + } catch (error) { + if (!mounted || !dialogContext.mounted) return; + setDialogState(() => sending = false); + displayError(error.toString()); + } + }, + child: sending + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.send), + ), + ], + ); + }, + ), + ); + amountController.dispose(); + } + + int? _bolt12OfferAmount(Wallet wallet) { + if (wallet is! Bolt12Wallet) return null; + final amount = int.tryParse(wallet.amount ?? ''); + return amount != null && amount > 0 ? amount : null; + } + + String _bolt12OfferAmountLabel(Wallet wallet, int amount) { + if (wallet is Bolt12Wallet && wallet.currency?.isNotEmpty == true) { + return '$amount ${wallet.currency!.toUpperCase()}'; + } + if (amount % 1000 == 0) return '${amount ~/ 1000} sats'; + return '$amount msats'; + } + + String _walletDisplayName(AppLocalizations l10n, Wallet wallet) { + final name = wallet.name.trim(); + if (name.isNotEmpty) return name; + + if (wallet is CashuWallet) { + final mintName = wallet.mintInfo.name?.trim(); + if (mintName?.isNotEmpty == true) return mintName!; + final mintUri = Uri.tryParse(wallet.mintUrl); + if (mintUri?.host.isNotEmpty == true) return mintUri!.host; + return '${l10n.cashuWallet} · ${_shortWalletIdentifier(wallet.id)}'; + } + if (wallet is LnurlWallet) { + final identifier = wallet.identifier.trim(); + if (identifier.isNotEmpty) return identifier; + return '${l10n.lnurlWallet} · ${_shortWalletIdentifier(wallet.id)}'; + } + if (wallet is Bolt12Wallet) { + final bip353Address = wallet.bip353Address?.trim(); + if (bip353Address?.isNotEmpty == true) return bip353Address!; + final issuer = wallet.issuer?.trim(); + if (issuer?.isNotEmpty == true) return issuer!; + return '${l10n.bolt12Wallet} · ${_shortWalletIdentifier(wallet.offer)}'; + } + if (wallet is NwcWallet) { + return '${l10n.nwcWallet} · ${_shortWalletIdentifier(wallet.id)}'; + } + return _shortWalletIdentifier(wallet.id); + } + + String _shortWalletIdentifier(String value) { + final normalized = value.trim(); + if (normalized.isEmpty) return '—'; + if (normalized.length <= 18) return normalized; + return '${normalized.substring(0, 9)}…' + '${normalized.substring(normalized.length - 6)}'; } void _showReceiveDialog(BuildContext context, Wallet wallet) { diff --git a/packages/sample-app/lib/bolt12_qr_scanner.dart b/packages/sample-app/lib/bolt12_qr_scanner.dart new file mode 100644 index 000000000..866ce43bd --- /dev/null +++ b/packages/sample-app/lib/bolt12_qr_scanner.dart @@ -0,0 +1,175 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:ndk/entities.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_l10n; + +Future scanBolt12Input(BuildContext context) { + return showDialog( + context: context, + builder: (context) => const _Bolt12QrScannerDialog(), + ); +} + +class _Bolt12QrScannerDialog extends StatefulWidget { + const _Bolt12QrScannerDialog(); + + @override + State<_Bolt12QrScannerDialog> createState() => _Bolt12QrScannerDialogState(); +} + +class _Bolt12QrScannerDialogState extends State<_Bolt12QrScannerDialog> { + MobileScannerController? _controller; + bool _hasScanned = false; + String? _error; + + bool get _hasCamera => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS); + + @override + void initState() { + super.initState(); + if (_hasCamera) { + _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + bool _accept(String? rawValue) { + if (rawValue == null) return false; + final value = rawValue.trim(); + if (!Bolt12WalletProvider.isSupportedInput(value)) return false; + _hasScanned = true; + Navigator.of(context).pop(value); + return true; + } + + void _onDetect(BarcodeCapture capture) { + if (_hasScanned) return; + for (final barcode in capture.barcodes) { + if (_accept(barcode.rawValue)) return; + } + setState(() { + _error = ndk_l10n.AppLocalizations.of(context)!.invalidBolt12QrCode; + }); + } + + Future _paste() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + if (!mounted || _accept(data?.text)) return; + setState(() { + _error = ndk_l10n.AppLocalizations.of(context)!.invalidBolt12QrCode; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = ndk_l10n.AppLocalizations.of(context)!; + return Dialog( + backgroundColor: Colors.black, + child: SizedBox( + width: 400, + height: _hasCamera ? 560 : 240, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + Expanded( + child: Text( + l10n.scanBolt12QrCodeTitle, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + Expanded( + child: Stack( + children: [ + if (_hasCamera) + MobileScanner(controller: _controller!, onDetect: _onDetect) + else + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + l10n.cameraNotAvailable, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ), + ), + if (_hasCamera) + Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + if (_error != null) + Positioned( + top: 20, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: ElevatedButton.icon( + onPressed: _paste, + icon: const Icon(Icons.paste), + label: Text(l10n.paste), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/sample-app/lib/wallets.dart b/packages/sample-app/lib/wallets.dart index 36e5717d9..d14057676 100644 --- a/packages/sample-app/lib/wallets.dart +++ b/packages/sample-app/lib/wallets.dart @@ -3,6 +3,7 @@ import 'package:ndk_demo/l10n/app_localizations_context.dart'; import 'package:ndk_flutter/ndk_flutter.dart'; import 'main.dart'; +import 'bolt12_qr_scanner.dart'; import 'nwc_qr_scanner.dart'; class WalletsPage extends StatefulWidget { @@ -73,6 +74,7 @@ class WalletsPageState extends State with WidgetsBindingObserver { key: _walletsKey, ndkFlutter: ndkFlutter, nwcUriScanner: scanNwcUri, + bolt12InputScanner: scanBolt12Input, ), ); } diff --git a/packages/sample-app/pubspec.lock b/packages/sample-app/pubspec.lock index 3602f73cb..b41159cdd 100644 --- a/packages/sample-app/pubspec.lock +++ b/packages/sample-app/pubspec.lock @@ -169,6 +169,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_bip353: + dependency: transitive + description: + name: dart_bip353 + sha256: "269daf722556e66c56cf62f7c7d584bd8f43e7480113f31001f382135f8899c5" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + dart_bolt12_decoder: + dependency: transitive + description: + name: dart_bolt12_decoder + sha256: c61b34b6922c5f64d6f50d16c479bd3f0189a093557d59f4eb69424c51dd3d57 + url: "https://pub.dev" + source: hosted + version: "0.8.0" dbus: dependency: transitive description: @@ -587,21 +603,21 @@ packages: path: "../ndk" relative: true source: path - version: "0.8.4-dev.11" + version: "0.9.0" ndk_drift: dependency: "direct main" description: path: "../drift" relative: true source: path - version: "0.1.1-dev.13" + version: "0.1.1+1" ndk_flutter: dependency: "direct main" description: path: "../ndk_flutter" relative: true source: path - version: "0.8.4-dev.14" + version: "0.9.0" nested: dependency: transitive description: