diff --git a/bdk_demo/lib/core/router/app_router.dart b/bdk_demo/lib/core/router/app_router.dart index 3d37272..957a9ae 100644 --- a/bdk_demo/lib/core/router/app_router.dart +++ b/bdk_demo/lib/core/router/app_router.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/misc.dart'; import 'package:go_router/go_router.dart'; import 'package:bdk_demo/features/home/home_page.dart'; import 'package:bdk_demo/features/receive/receive_page.dart'; +import 'package:bdk_demo/features/send/send_page.dart'; import 'package:bdk_demo/features/transactions/transaction_detail_page.dart'; import 'package:bdk_demo/features/transactions/transactions_list_page.dart'; import 'package:bdk_demo/features/shared/widgets/placeholder_page.dart'; @@ -78,7 +79,7 @@ GoRouter createRouter(RouterRead read) => GoRouter( path: AppRoutes.send, name: 'send', redirect: (context, state) => _sendRouteRedirect(read), - builder: (context, state) => const PlaceholderPage(title: 'Send'), + builder: (context, state) => const SendPage(), ), GoRoute( path: AppRoutes.transactionHistory, diff --git a/bdk_demo/lib/features/send/send_page.dart b/bdk_demo/lib/features/send/send_page.dart new file mode 100644 index 0000000..abeb518 --- /dev/null +++ b/bdk_demo/lib/features/send/send_page.dart @@ -0,0 +1,502 @@ +import 'dart:math' as math; +import 'package:bdk_demo/core/router/app_router.dart'; +import 'package:bdk_demo/features/shared/widgets/secondary_app_bar.dart'; +import 'package:bdk_demo/features/shared/widgets/wallet_ui_helpers.dart'; +import 'package:bdk_demo/providers/blockchain_providers.dart'; +import 'package:bdk_demo/providers/connectivity_provider.dart'; +import 'package:bdk_demo/providers/send_providers.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +enum _SendAmountUnit { sats, bitcoin } + +class SendPage extends ConsumerStatefulWidget { + const SendPage({super.key}); + + @override + ConsumerState createState() => _SendPageState(); +} + +class _SendPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _addressController = TextEditingController(); + final _amountController = TextEditingController(); + final _feeRateController = TextEditingController(); + _SendAmountUnit _amountUnit = _SendAmountUnit.sats; + var _isBuilding = false; + var _isBroadcasting = false; + + @override + void dispose() { + _addressController.dispose(); + _amountController.dispose(); + _feeRateController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final record = ref.watch(activeWalletRecordProvider); + final wallet = ref.watch(activeWalletProvider); + final isOnline = ref.watch(isOnlineProvider); + final feeEstimates = ref.watch(feeEstimatesProvider); + final isBusy = _isBuilding || _isBroadcasting; + final canReview = + record != null && + wallet != null && + isOnline && + _hasValidInput && + !isBusy; + + return Scaffold( + appBar: const SecondaryAppBar(title: 'Send'), + body: SafeArea( + child: record == null || wallet == null + ? const WalletStateCard( + icon: Icons.account_balance_wallet_outlined, + title: 'No active wallet', + message: 'Load a wallet before sending bitcoin.', + centered: true, + ) + : ListView( + padding: const EdgeInsets.all(24), + children: [ + if (!isOnline) ...[ + const WalletStateCard( + icon: Icons.wifi_off_outlined, + title: 'Offline', + message: 'Connect to the internet before sending.', + ), + const SizedBox(height: 16), + ], + Text( + 'Send bitcoin', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Create a transaction for one recipient. Review and broadcast are handled in the next step.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + key: const Key('send-recipient-field'), + controller: _addressController, + decoration: const InputDecoration( + labelText: 'Recipient address', + border: OutlineInputBorder(), + ), + minLines: 1, + maxLines: 3, + textInputAction: TextInputAction.next, + validator: _validateAddress, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + TextFormField( + key: const Key('send-amount-field'), + controller: _amountController, + decoration: InputDecoration( + labelText: 'Amount ($_amountUnitLabel)', + border: const OutlineInputBorder(), + ), + keyboardType: _amountUnit == _SendAmountUnit.sats + ? TextInputType.number + : const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: _amountUnit == _SendAmountUnit.sats + ? [FilteringTextInputFormatter.digitsOnly] + : [ + FilteringTextInputFormatter.allow( + RegExp(r'[0-9.]'), + ), + ], + textInputAction: TextInputAction.next, + validator: _validateAmount, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 8), + SegmentedButton<_SendAmountUnit>( + key: const Key('send-amount-unit-switcher'), + segments: const [ + ButtonSegment( + value: _SendAmountUnit.sats, + label: Text('sats'), + ), + ButtonSegment( + value: _SendAmountUnit.bitcoin, + label: Text('BTC'), + ), + ], + selected: {_amountUnit}, + onSelectionChanged: (selection) => + _setAmountUnit(selection.single), + ), + const SizedBox(height: 16), + TextFormField( + key: const Key('send-fee-rate-field'), + controller: _feeRateController, + decoration: const InputDecoration( + labelText: 'Fee rate (sat/vB)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + textInputAction: TextInputAction.done, + validator: (value) => + _validatePositiveInt(value, 'Fee rate'), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + _FeeSuggestions( + feeEstimates: feeEstimates, + onSelected: _applyFeeRate, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: canReview ? _handleReview : null, + child: Text(_reviewButtonLabel), + ), + ], + ), + ), + ], + ), + ), + ); + } + + static const _satsPerBitcoin = 100000000; + + String get _reviewButtonLabel { + if (_isBuilding) return 'Building transaction...'; + if (_isBroadcasting) return 'Broadcasting...'; + return 'Review transaction'; + } + + String get _amountUnitLabel => + _amountUnit == _SendAmountUnit.sats ? 'sats' : 'BTC'; + + bool get _hasValidInput => + _validateAddress(_addressController.text) == null && + _validateAmount(_amountController.text) == null && + _validatePositiveInt(_feeRateController.text, 'Fee rate') == null; + + String? _validateAddress(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Recipient address is required.'; + } + return null; + } + + String? _validatePositiveInt(String? value, String label) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return '$label is required.'; + final parsed = int.tryParse(trimmed); + if (parsed == null || parsed <= 0) { + return '$label must be greater than zero.'; + } + return null; + } + + String? _validateAmount(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Amount is required.'; + if (_amountUnit == _SendAmountUnit.sats) { + final parsed = int.tryParse(trimmed); + if (parsed == null || parsed <= 0) { + return 'Amount must be greater than zero.'; + } + return null; + } + + if (!_isValidBtcInputShape(trimmed)) { + return 'Amount must be a valid BTC value.'; + } + final parts = trimmed.split('.'); + if (parts.length == 2 && parts[1].length > 8) { + return 'BTC amount cannot exceed 8 decimal places.'; + } + final sat = _tryParseBtcToSat(trimmed); + if (sat == null || sat <= 0) { + return 'Amount must be greater than zero.'; + } + return null; + } + + bool _isValidBtcInputShape(String value) { + if (value == '.') return false; + return RegExp(r'^\d+(\.\d*)?$').hasMatch(value); + } + + int? _tryParseAmountSat() { + final trimmed = _amountController.text.trim(); + if (_amountUnit == _SendAmountUnit.sats) { + return int.tryParse(trimmed); + } + return _tryParseBtcToSat(trimmed); + } + + int? _tryParseBtcToSat(String value) { + if (!_isValidBtcInputShape(value)) return null; + final parts = value.split('.'); + if (parts.length > 2) return null; + final whole = int.tryParse(parts[0]); + if (whole == null) return null; + final fraction = parts.length == 2 ? parts[1] : ''; + if (fraction.length > 8) return null; + final fractionSat = int.parse(fraction.padRight(8, '0')); + return whole * _satsPerBitcoin + fractionSat; + } + + String _formatBtc(int amountSat) { + final whole = amountSat ~/ _satsPerBitcoin; + final fraction = amountSat % _satsPerBitcoin; + if (fraction == 0) return '$whole'; + final fractionText = fraction.toString().padLeft(8, '0'); + return '$whole.${fractionText.replaceFirst(RegExp(r'0+$'), '')}'; + } + + void _setAmountUnit(_SendAmountUnit unit) { + if (unit == _amountUnit) return; + final amountSat = _tryParseAmountSat(); + if (amountSat != null && amountSat > 0) { + _amountController.text = unit == _SendAmountUnit.sats + ? amountSat.toString() + : _formatBtc(amountSat); + } + setState(() { + _amountUnit = unit; + }); + } + + void _applyFeeRate(double feeRate) { + _feeRateController.text = math.max(1, feeRate.ceil()).toString(); + setState(() {}); + } + + Future _handleReview() async { + final isValid = _formKey.currentState?.validate() ?? false; + if (!isValid) return; + + final record = ref.read(activeWalletRecordProvider); + final wallet = ref.read(activeWalletProvider); + final isOnline = ref.read(isOnlineProvider); + final amountSat = _tryParseAmountSat(); + final feeRateSatPerVb = int.tryParse(_feeRateController.text.trim()); + + if (record == null || wallet == null) { + _showSnackBar('Load a wallet before sending bitcoin.'); + return; + } + if (!isOnline) { + _showSnackBar('Connect to the internet before sending bitcoin.'); + return; + } + if (amountSat == null || amountSat <= 0 || feeRateSatPerVb == null) { + _showSnackBar('Check the amount and fee rate before continuing.'); + return; + } + + setState(() => _isBuilding = true); + SendTransactionDraft draft; + try { + draft = await ref.read(sendTransactionDraftBuilderProvider)( + record: record, + wallet: wallet, + recipientAddress: _addressController.text, + amountSat: amountSat, + feeRateSatPerVb: feeRateSatPerVb, + ); + } catch (_) { + if (!mounted) return; + setState(() => _isBuilding = false); + _showSnackBar( + 'Could not build transaction. Check the address, amount, and fee rate.', + ); + return; + } + if (!mounted) return; + setState(() => _isBuilding = false); + + final confirmed = await _confirmTransaction( + recipientAddress: _addressController.text.trim(), + amountSat: amountSat, + feeRateSatPerVb: feeRateSatPerVb, + feeSat: draft.feeSat, + ); + if (confirmed != true || !mounted) return; + + setState(() => _isBroadcasting = true); + final client = ref + .read(blockchainClientFactoryProvider) + .call(record.network); + try { + await draft.broadcast(client); + if (!mounted) return; + ref + .read(balanceSnapshotProvider.notifier) + .applyFromWallet(wallet, record.id); + _showSnackBar('Transaction broadcast successfully.'); + context.go(AppRoutes.home); + } catch (_) { + if (!mounted) return; + _showSnackBar( + 'Could not broadcast transaction. Check your connection and try again.', + ); + } finally { + client.dispose(); + if (mounted) { + setState(() => _isBroadcasting = false); + } + } + } + + Future _confirmTransaction({ + required String recipientAddress, + required int amountSat, + required int feeRateSatPerVb, + required int? feeSat, + }) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Review transaction'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ConfirmationRow(label: 'Recipient', value: recipientAddress), + _ConfirmationRow(label: 'Amount', value: _amountSummary(amountSat)), + _ConfirmationRow( + label: 'Fee rate', + value: '$feeRateSatPerVb sat/vB', + ), + _ConfirmationRow( + label: 'Fee', + value: feeSat == null ? 'Unavailable' : '$feeSat sats', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Confirm'), + ), + ], + ), + ); + } + + String _amountSummary(int amountSat) { + if (_amountUnit == _SendAmountUnit.sats) { + return '$amountSat sats'; + } + return '${_formatBtc(amountSat)} BTC ($amountSat sats)'; + } + + void _showSnackBar(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _ConfirmationRow extends StatelessWidget { + const _ConfirmationRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + SelectableText(value), + ], + ), + ); + } +} + +class _FeeSuggestions extends StatelessWidget { + const _FeeSuggestions({required this.feeEstimates, required this.onSelected}); + + final AsyncValue> feeEstimates; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return feeEstimates.when( + data: (estimates) { + if (estimates.isEmpty) { + return const Text('Fee suggestions unavailable.'); + } + + final entries = estimates.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Fee suggestions', + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in entries) + ActionChip( + label: Text( + '${entry.key} ${entry.key == 1 ? 'block' : 'blocks'} · ${entry.value.ceil()} sat/vB', + ), + onPressed: () => onSelected(entry.value), + ), + ], + ), + ], + ); + }, + loading: () => const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Loading fee suggestions...'), + ], + ), + error: (_, __) => const Text('Could not load fee suggestions.'), + ); + } +} diff --git a/bdk_demo/lib/providers/send_providers.dart b/bdk_demo/lib/providers/send_providers.dart new file mode 100644 index 0000000..14cf47e --- /dev/null +++ b/bdk_demo/lib/providers/send_providers.dart @@ -0,0 +1,78 @@ +import 'package:bdk_dart/bdk.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/network_endpoint_providers.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:bdk_demo/services/blockchain_service.dart'; +import 'package:bdk_demo/services/fee_estimates_job.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +typedef BlockchainClientFactory = + BlockchainClient Function(WalletNetwork network); + +final blockchainClientFactoryProvider = Provider( + (ref) => BlockchainService.createClient, +); + +final feeEstimatesJobRunnerProvider = Provider( + (ref) => defaultFeeEstimatesJobRunner, +); + +class SendTransactionDraft { + const SendTransactionDraft({required this.feeSat, required this.broadcast}); + + final int? feeSat; + final Future Function(BlockchainClient client) broadcast; +} + +typedef SendTransactionDraftBuilder = + Future Function({ + required WalletRecord record, + required Wallet wallet, + required String recipientAddress, + required int amountSat, + required int feeRateSatPerVb, + }); + +final sendTransactionDraftBuilderProvider = + Provider((ref) { + final walletService = ref.read(walletServiceProvider); + return ({ + required record, + required wallet, + required recipientAddress, + required amountSat, + required feeRateSatPerVb, + }) async { + final psbt = await walletService.buildTransaction( + record, + wallet, + recipientAddress, + amountSat, + feeRateSatPerVb, + ); + return SendTransactionDraft( + feeSat: psbt.fee(), + broadcast: (client) async => (await walletService.signAndBroadcast( + record, + wallet, + psbt, + client, + )).toString(), + ); + }; + }); + +final feeEstimatesProvider = FutureProvider>((ref) async { + final record = ref.watch(activeWalletRecordProvider); + if (record == null) return const {}; + + final endpoint = ref.watch(endpointConfigProvider(record.network)); + final runner = ref.read(feeEstimatesJobRunnerProvider); + return runner( + FeeEstimatesRequest( + clientTypeName: endpoint.clientType.name, + url: endpoint.url, + timeoutSeconds: defaultFeeEstimatesTimeoutSeconds(), + ), + ); +}); diff --git a/bdk_demo/lib/services/blockchain_service.dart b/bdk_demo/lib/services/blockchain_service.dart index d9fec5f..444800a 100644 --- a/bdk_demo/lib/services/blockchain_service.dart +++ b/bdk_demo/lib/services/blockchain_service.dart @@ -58,6 +58,8 @@ abstract final class BlockchainFeeNormalizer { abstract interface class BlockchainClient { BlockchainBackend get backend; + void broadcast(Transaction transaction); + Map getFeeEstimates(); int getTipHeight(); @@ -73,6 +75,10 @@ final class EsploraBlockchainClient implements BlockchainClient { @override BlockchainBackend get backend => BlockchainBackend.esplora; + @override + void broadcast(Transaction transaction) => + _client.broadcast(transaction: transaction); + @override Map getFeeEstimates() => BlockchainFeeNormalizer.stableFeesFromEsploraRaw( @@ -94,6 +100,10 @@ final class ElectrumBlockchainClient implements BlockchainClient { @override BlockchainBackend get backend => BlockchainBackend.electrum; + @override + void broadcast(Transaction transaction) => + _client.transactionBroadcast(tx: transaction); + @override Map getFeeEstimates() => BlockchainFeeNormalizer.stableFeesFromElectrumEstimates( diff --git a/bdk_demo/lib/services/fee_estimates_job.dart b/bdk_demo/lib/services/fee_estimates_job.dart new file mode 100644 index 0000000..6259826 --- /dev/null +++ b/bdk_demo/lib/services/fee_estimates_job.dart @@ -0,0 +1,61 @@ +import 'dart:isolate'; +import 'package:bdk_dart/bdk.dart'; +import 'package:bdk_demo/core/constants/app_constants.dart'; +import 'package:bdk_demo/services/blockchain_service.dart'; + +class FeeEstimatesRequest { + const FeeEstimatesRequest({ + required this.clientTypeName, + required this.url, + required this.timeoutSeconds, + }); + + final String clientTypeName; + final String url; + final int timeoutSeconds; +} + +typedef FeeEstimatesJobRunner = + Future> Function(FeeEstimatesRequest request); + +Future> defaultFeeEstimatesJobRunner( + FeeEstimatesRequest request, +) { + return Isolate.run(() => executeFeeEstimatesFetch(request)); +} + +Map executeFeeEstimatesFetch(FeeEstimatesRequest request) { + final clientType = ClientType.values.byName(request.clientTypeName); + final endpoint = EndpointConfig(clientType: clientType, url: request.url); + final client = _createBlockchainClient( + endpoint, + timeoutSeconds: request.timeoutSeconds, + ); + try { + return client.getFeeEstimates(); + } finally { + client.dispose(); + } +} + +BlockchainClient _createBlockchainClient( + EndpointConfig endpoint, { + required int timeoutSeconds, +}) { + return switch (endpoint.clientType) { + ClientType.esplora => EsploraBlockchainClient( + EsploraClient(url: endpoint.url, proxy: null), + ), + ClientType.electrum => ElectrumBlockchainClient( + ElectrumClient( + url: endpoint.url, + socks5: null, + timeout: timeoutSeconds, + retry: null, + validateDomain: true, + ), + ), + }; +} + +int defaultFeeEstimatesTimeoutSeconds() => AppConstants.syncTimeout.inSeconds; diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index 60ec5f2..c006ed7 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -6,6 +6,7 @@ import 'package:bdk_demo/core/constants/app_constants.dart'; import 'package:bdk_demo/core/constants/bip39_wordlist.dart'; import 'package:bdk_demo/core/utils/wallet_storage_paths.dart'; import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/services/blockchain_service.dart'; import 'package:bdk_demo/services/storage_service.dart'; import 'package:bdk_demo/services/wallet_network_mapper.dart'; import 'package:bdk_demo/services/wallet_sqlite_persistence.dart'; @@ -363,6 +364,69 @@ class WalletService { ); } + Future buildTransaction( + WalletRecord record, + Wallet wallet, + String recipientAddress, + int amountSat, + int feeRateSatPerVb, + ) async { + final trimmedAddress = recipientAddress.trim(); + if (trimmedAddress.isEmpty) { + throw ArgumentError('Recipient address must not be empty.'); + } + if (amountSat <= 0) { + throw ArgumentError('Amount must be greater than zero.'); + } + if (feeRateSatPerVb <= 0) { + throw ArgumentError('Fee rate must be greater than zero.'); + } + + final network = wallet.network(); + final address = Address(address: trimmedAddress, network: network); + if (!address.isValidForNetwork(network: network)) { + throw ArgumentError('Recipient address is not valid for this network.'); + } + + final psbt = TxBuilder() + .addRecipient( + script: address.scriptPubkey(), + amount: Amount.fromSat(satoshi: amountSat), + ) + .feeRate(feeRate: FeeRate.fromSatPerVb(satVb: feeRateSatPerVb)) + .finish(wallet: wallet); + + await _persistStagedWalletChanges(record, wallet); + return psbt; + } + + Future signAndBroadcast( + WalletRecord record, + Wallet wallet, + Psbt psbt, + BlockchainClient blockchainClient, + ) async { + final signed = wallet.sign(psbt: psbt, signOptions: null); + if (!signed) { + throw StateError('Could not sign transaction.'); + } + + final transaction = psbt.extractTx(); + blockchainClient.broadcast(transaction); + wallet.applyUnconfirmedTxs( + unconfirmedTxs: [ + UnconfirmedTx( + tx: transaction, + lastSeen: + DateTime.now().millisecondsSinceEpoch ~/ + Duration.millisecondsPerSecond, + ), + ], + ); + await _persistStagedWalletChanges(record, wallet); + return transaction.computeTxid(); + } + Future<(AddressInfo, Wallet)> generateAddress(WalletRecord record) async { final secrets = await _storage.getSecrets(record.id); if (secrets == null) { @@ -481,6 +545,44 @@ class WalletService { return wallet; } + Future _persistStagedWalletChanges( + WalletRecord record, + Wallet wallet, + ) async { + if (wallet.staged() == null) return; + + final secrets = await _storage.getSecrets(record.id); + if (secrets == null) { + throw StateError( + 'No secrets found for wallet "${record.name}" (${record.id}). ' + 'Cannot persist transaction state.', + ); + } + + final bdkNetworkKind = record.network.toBdkNetworkKind(); + final descriptor = Descriptor( + descriptor: secrets.descriptor, + networkKind: bdkNetworkKind, + ); + final changeDescriptor = Descriptor( + descriptor: secrets.changeDescriptor, + networkKind: bdkNetworkKind, + ); + final dbPath = await WalletStoragePaths.sqlitePathForWallet(record.id); + final persister = Persister.newSqlite(path: dbPath); + try { + await _ensureWalletPersistedToSqlite( + wallet, + persister, + descriptor, + changeDescriptor, + dbPath, + ); + } finally { + persister.dispose(); + } + } + Future _reseedWalletToFallbackSqlite({ required String walletId, required Descriptor descriptor, diff --git a/bdk_demo/test/presentation/router_wiring_test.dart b/bdk_demo/test/presentation/router_wiring_test.dart index 6d8ba29..414c659 100644 --- a/bdk_demo/test/presentation/router_wiring_test.dart +++ b/bdk_demo/test/presentation/router_wiring_test.dart @@ -2,6 +2,7 @@ import 'package:bdk_dart/bdk.dart'; import 'package:bdk_demo/core/router/app_router.dart'; import 'package:bdk_demo/features/home/home_page.dart'; import 'package:bdk_demo/features/receive/receive_page.dart'; +import 'package:bdk_demo/features/send/send_page.dart'; import 'package:bdk_demo/features/transactions/transactions_list_page.dart'; import 'package:bdk_demo/features/shared/widgets/placeholder_page.dart'; import 'package:bdk_demo/models/wallet_record.dart'; @@ -9,6 +10,7 @@ import 'package:bdk_demo/features/wallet_setup/active_wallets_page.dart'; import 'package:bdk_demo/features/wallet_setup/create_wallet_page.dart'; import 'package:bdk_demo/features/wallet_setup/recover_wallet_page.dart'; import 'package:bdk_demo/providers/connectivity_provider.dart'; +import 'package:bdk_demo/providers/send_providers.dart'; import 'package:bdk_demo/providers/settings_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:bdk_demo/services/storage_service.dart'; @@ -50,6 +52,7 @@ void main() { ConnectivityResult.wifi, ], bool seedActiveWallet = false, + bool? isOnline, }) async { SharedPreferences.setMockInitialValues({}); FlutterSecureStorage.setMockInitialValues({}); @@ -58,9 +61,13 @@ void main() { final container = ProviderContainer( overrides: [ storageServiceProvider.overrideWithValue(storage), + feeEstimatesJobRunnerProvider.overrideWithValue( + (_) async => const {1: 1.0}, + ), connectivityProvider.overrideWith( (ref) => Stream.value(connectivityResults), ), + if (isOnline != null) isOnlineProvider.overrideWith((ref) => isOnline), ], ); addTearDown(container.dispose); @@ -145,6 +152,20 @@ void main() { expect(find.text('Send'), findsOneWidget); }); + testWidgets('/send resolves to SendPage when online with active wallet', ( + tester, + ) async { + await pumpRouterAt( + tester, + AppRoutes.send, + seedActiveWallet: true, + isOnline: true, + ); + + expect(find.byType(SendPage), findsOneWidget); + expect(find.byType(PlaceholderPage), findsNothing); + }); + testWidgets('/recover-wallet resolves to RecoverWalletPage', (tester) async { await pumpRouterAt(tester, AppRoutes.recoverWallet); diff --git a/bdk_demo/test/presentation/send_page_test.dart b/bdk_demo/test/presentation/send_page_test.dart new file mode 100644 index 0000000..5e62c8b --- /dev/null +++ b/bdk_demo/test/presentation/send_page_test.dart @@ -0,0 +1,487 @@ +import 'package:bdk_dart/bdk.dart' hide Key; +import 'package:bdk_demo/core/router/app_router.dart'; +import 'package:bdk_demo/features/send/send_page.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/connectivity_provider.dart'; +import 'package:bdk_demo/providers/send_providers.dart'; +import 'package:bdk_demo/providers/settings_providers.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:bdk_demo/services/blockchain_service.dart'; +import 'package:bdk_demo/services/storage_service.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _testExtendedPrivKey = + 'tprv8ZgxMBicQKsPf2qfrEygW6fdYseJDDrVnDv26PH5BHdvSuG6ecCbHqLVof9yZcMoM31z9ur3tTYbSnr1WBqbGX97CbXcmp5H6qeMpyvx35B'; + +Wallet _createTestWallet() { + final descriptor = Descriptor( + descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/0/*)', + networkKind: NetworkKind.test, + ); + final changeDescriptor = Descriptor( + descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/1/*)', + networkKind: NetworkKind.test, + ); + return Wallet( + descriptor: descriptor, + changeDescriptor: changeDescriptor, + network: Network.testnet, + persister: Persister.newInMemory(), + lookahead: 25, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future createContainer({ + Map feeEstimates = const {1: 2.2, 3: 1.4, 6: 1.0}, + bool seedActiveWallet = true, + SendTransactionDraftBuilder? draftBuilder, + BlockchainClientFactory? blockchainClientFactory, + }) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = StorageService(prefs: prefs); + final container = ProviderContainer( + overrides: [ + storageServiceProvider.overrideWithValue(storage), + connectivityProvider.overrideWith( + (ref) => Stream.value(const [ConnectivityResult.wifi]), + ), + feeEstimatesJobRunnerProvider.overrideWithValue( + (_) async => feeEstimates, + ), + if (draftBuilder != null) + sendTransactionDraftBuilderProvider.overrideWithValue(draftBuilder), + if (blockchainClientFactory != null) + blockchainClientFactoryProvider.overrideWithValue( + blockchainClientFactory, + ), + ], + ); + addTearDown(container.dispose); + + if (seedActiveWallet) { + container + .read(activeWalletRecordProvider.notifier) + .set( + const WalletRecord( + id: 'send-wallet', + name: 'Send Wallet', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ), + ); + container.read(activeWalletProvider.notifier).set(_createTestWallet()); + } + + return container; + } + + Future pumpSendPage( + WidgetTester tester, + ProviderContainer container, + ) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: SendPage()), + ), + ); + await tester.pumpAndSettle(); + } + + Future pumpSendPageWithRouter( + WidgetTester tester, + ProviderContainer container, + ) async { + final router = GoRouter( + initialLocation: AppRoutes.send, + routes: [ + GoRoute( + path: AppRoutes.send, + builder: (context, state) => const SendPage(), + ), + GoRoute( + path: AppRoutes.home, + builder: (context, state) => const Text('Home route'), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + } + + Future fillSendForm( + WidgetTester tester, { + String address = 'tb1qexampleaddress', + String amount = '1000', + String feeRate = '2', + }) async { + await tester.enterText( + find.byKey(const Key('send-recipient-field')), + address, + ); + await tester.enterText(find.byKey(const Key('send-amount-field')), amount); + await tester.enterText( + find.byKey(const Key('send-fee-rate-field')), + feeRate, + ); + await tester.pump(); + } + + Future tapReview(WidgetTester tester) async { + final reviewButton = find.widgetWithText( + FilledButton, + 'Review transaction', + ); + await tester.drag(find.byType(ListView), const Offset(0, -250)); + await tester.pump(); + await tester.tap(reviewButton); + } + + testWidgets('renders address, amount, fee rate, and review button', ( + tester, + ) async { + final container = await createContainer(); + + await pumpSendPage(tester, container); + + expect(find.text('Recipient address'), findsOneWidget); + expect(find.text('Amount (sats)'), findsOneWidget); + expect(find.text('Fee rate (sat/vB)'), findsOneWidget); + expect( + find.widgetWithText(FilledButton, 'Review transaction'), + findsOneWidget, + ); + }); + + testWidgets('empty form hides validation messages and disables review', ( + tester, + ) async { + final container = await createContainer(); + + await pumpSendPage(tester, container); + + expect(find.text('Recipient address is required.'), findsNothing); + expect(find.text('Amount is required.'), findsNothing); + expect(find.text('Fee rate is required.'), findsNothing); + final button = tester.widget( + find.widgetWithText(FilledButton, 'Review transaction'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('fee suggestion fills the fee-rate field', (tester) async { + final container = await createContainer(feeEstimates: const {3: 2.2}); + + await pumpSendPage(tester, container); + + await tester.tap(find.text('3 blocks · 3 sat/vB')); + await tester.pump(); + + expect( + tester.widget( + find.byKey(const Key('send-fee-rate-field')), + ), + isA().having( + (field) => field.controller?.text, + 'fee rate', + '3', + ), + ); + }); + + testWidgets('invalid numeric input keeps review disabled', (tester) async { + final container = await createContainer(); + + await pumpSendPage(tester, container); + + await tester.enterText( + find.byKey(const Key('send-recipient-field')), + 'tb1qexampleaddress', + ); + await tester.enterText(find.byKey(const Key('send-amount-field')), '0'); + await tester.enterText(find.byKey(const Key('send-fee-rate-field')), '0'); + await tester.pump(); + + expect(find.text('Amount must be greater than zero.'), findsOneWidget); + expect(find.text('Fee rate must be greater than zero.'), findsOneWidget); + final button = tester.widget( + find.widgetWithText(FilledButton, 'Review transaction'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('valid form enables review without broadcasting', (tester) async { + final container = await createContainer(); + + await pumpSendPage(tester, container); + + await tester.enterText( + find.byKey(const Key('send-recipient-field')), + 'tb1qexampleaddress', + ); + await tester.enterText(find.byKey(const Key('send-amount-field')), '1000'); + await tester.enterText(find.byKey(const Key('send-fee-rate-field')), '2'); + await tester.pump(); + + final button = tester.widget( + find.widgetWithText(FilledButton, 'Review transaction'), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('amount unit switcher converts between sats and BTC', ( + tester, + ) async { + final container = await createContainer(); + + await pumpSendPage(tester, container); + await tester.enterText( + find.byKey(const Key('send-amount-field')), + '100000000', + ); + await tester.tap(find.text('BTC')); + await tester.pump(); + + expect( + tester.widget(find.byKey(const Key('send-amount-field'))), + isA().having( + (field) => field.controller?.text, + 'amount', + '1', + ), + ); + + await tester.tap(find.text('sats')); + await tester.pump(); + + expect( + tester.widget(find.byKey(const Key('send-amount-field'))), + isA().having( + (field) => field.controller?.text, + 'amount', + '100000000', + ), + ); + }); + + testWidgets('BTC amount input converts to exact sats for build', ( + tester, + ) async { + final fake = _SendFlowFake(); + final container = await createContainer(draftBuilder: fake.build); + + await pumpSendPage(tester, container); + await tester.tap(find.text('BTC')); + await tester.pump(); + await fillSendForm(tester, amount: '0.00001000'); + + await tapReview(tester); + await tester.pumpAndSettle(); + + expect(fake.builtAmountSat, 1000); + expect( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Review transaction'), + ), + findsOneWidget, + ); + expect(find.text('0.00001 BTC (1000 sats)'), findsOneWidget); + }); + + testWidgets('BTC input with more than 8 decimals keeps review disabled', ( + tester, + ) async { + final container = await createContainer(); + + await pumpSendPage(tester, container); + await tester.tap(find.text('BTC')); + await tester.pump(); + await fillSendForm(tester, amount: '0.000000001'); + + expect( + find.text('BTC amount cannot exceed 8 decimal places.'), + findsOneWidget, + ); + final button = tester.widget( + find.widgetWithText(FilledButton, 'Review transaction'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('valid submit opens confirmation dialog', (tester) async { + final fake = _SendFlowFake(); + final container = await createContainer(draftBuilder: fake.build); + + await pumpSendPage(tester, container); + await fillSendForm(tester); + await tapReview(tester); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('Review transaction'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(AlertDialog), + matching: find.text('tb1qexampleaddress'), + ), + findsOneWidget, + ); + expect(find.text('1000 sats'), findsOneWidget); + expect(find.text('2 sat/vB'), findsOneWidget); + expect(find.text('321 sats'), findsOneWidget); + expect(fake.buildCount, 1); + }); + + testWidgets('cancel dismisses dialog and does not broadcast', (tester) async { + final fake = _SendFlowFake(); + final container = await createContainer(draftBuilder: fake.build); + + await pumpSendPage(tester, container); + await fillSendForm(tester); + await tapReview(tester); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + expect(fake.broadcastCount, 0); + }); + + testWidgets('confirm broadcasts and navigates home', (tester) async { + final fake = _SendFlowFake(); + final container = await createContainer( + draftBuilder: fake.build, + blockchainClientFactory: (_) => _FakeBlockchainClient(), + ); + + await pumpSendPageWithRouter(tester, container); + await fillSendForm(tester); + await tapReview(tester); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Confirm')); + await tester.pumpAndSettle(); + + expect(fake.buildCount, 1); + expect(fake.broadcastCount, 1); + expect(find.text('Home route'), findsOneWidget); + }); + + testWidgets('build failure shows friendly snackbar and stays on SendPage', ( + tester, + ) async { + final fake = _SendFlowFake(failBuild: true); + final container = await createContainer(draftBuilder: fake.build); + + await pumpSendPage(tester, container); + await fillSendForm(tester); + await tapReview(tester); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Could not build transaction. Check the address, amount, and fee rate.', + ), + findsOneWidget, + ); + expect(find.byType(SendPage), findsOneWidget); + }); + + testWidgets('broadcast failure shows friendly snackbar and stays on SendPage', ( + tester, + ) async { + final fake = _SendFlowFake(failBroadcast: true); + final container = await createContainer( + draftBuilder: fake.build, + blockchainClientFactory: (_) => _FakeBlockchainClient(), + ); + + await pumpSendPage(tester, container); + await fillSendForm(tester); + await tapReview(tester); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Confirm')); + await tester.pumpAndSettle(); + + expect(fake.broadcastCount, 1); + expect( + find.text( + 'Could not broadcast transaction. Check your connection and try again.', + ), + findsOneWidget, + ); + expect(find.byType(SendPage), findsOneWidget); + }); +} + +final class _SendFlowFake { + _SendFlowFake({this.failBuild = false, this.failBroadcast = false}); + + final bool failBuild; + final bool failBroadcast; + int buildCount = 0; + int broadcastCount = 0; + int? builtAmountSat; + + Future build({ + required WalletRecord record, + required Wallet wallet, + required String recipientAddress, + required int amountSat, + required int feeRateSatPerVb, + }) async { + buildCount++; + builtAmountSat = amountSat; + if (failBuild) { + throw StateError('build failed'); + } + return SendTransactionDraft( + feeSat: 321, + broadcast: (_) async { + broadcastCount++; + if (failBroadcast) { + throw StateError('broadcast failed'); + } + return 'fake-txid'; + }, + ); + } +} + +final class _FakeBlockchainClient implements BlockchainClient { + @override + BlockchainBackend get backend => BlockchainBackend.electrum; + + @override + void broadcast(Transaction transaction) {} + + @override + void dispose() {} + + @override + Map getFeeEstimates() => const {}; + + @override + int getTipHeight() => 0; +} diff --git a/bdk_demo/test/services/blockchain_service_test.dart b/bdk_demo/test/services/blockchain_service_test.dart index f67900c..0c12525 100644 --- a/bdk_demo/test/services/blockchain_service_test.dart +++ b/bdk_demo/test/services/blockchain_service_test.dart @@ -135,6 +135,9 @@ final class _FakeBlockchainClient implements BlockchainClient { final BlockchainBackend backend; final String url; + @override + void broadcast(Transaction transaction) {} + @override void dispose() {} diff --git a/bdk_demo/test/services/wallet_service_test.dart b/bdk_demo/test/services/wallet_service_test.dart index 28dd78f..42483ea 100644 --- a/bdk_demo/test/services/wallet_service_test.dart +++ b/bdk_demo/test/services/wallet_service_test.dart @@ -435,6 +435,102 @@ void main() { }); }); + group('WalletService.buildTransaction()', () { + setUp(_initServices); + tearDown(_tearDownServices); + + test('empty recipient address throws ArgumentError', () async { + final (record, wallet) = await walletService.createWallet( + 'Send Validation', + WalletNetwork.testnet, + ScriptType.p2wpkh, + ); + addTearDown(wallet.dispose); + + await expectLater( + walletService.buildTransaction(record, wallet, '', 1000, 1), + throwsArgumentError, + ); + }); + + test('zero amount throws ArgumentError', () async { + final (record, wallet) = await walletService.createWallet( + 'Zero Amount', + WalletNetwork.testnet, + ScriptType.p2wpkh, + ); + addTearDown(wallet.dispose); + final recipient = wallet + .nextUnusedAddress(keychain: KeychainKind.external_) + .address + .toString(); + + await expectLater( + walletService.buildTransaction(record, wallet, recipient, 0, 1), + throwsArgumentError, + ); + }); + + test('zero fee rate throws ArgumentError', () async { + final (record, wallet) = await walletService.createWallet( + 'Zero Fee', + WalletNetwork.testnet, + ScriptType.p2wpkh, + ); + addTearDown(wallet.dispose); + final recipient = wallet + .nextUnusedAddress(keychain: KeychainKind.external_) + .address + .toString(); + + await expectLater( + walletService.buildTransaction(record, wallet, recipient, 1000, 0), + throwsArgumentError, + ); + }); + + test( + 'invalid recipient address is rejected before coin selection', + () async { + final (record, wallet) = await walletService.createWallet( + 'Invalid Recipient', + WalletNetwork.testnet, + ScriptType.p2wpkh, + ); + addTearDown(wallet.dispose); + + await expectLater( + walletService.buildTransaction( + record, + wallet, + 'not-a-bitcoin-address', + 1000, + 1, + ), + throwsA(isA()), + ); + }, + ); + + test('valid recipient reaches BDK transaction creation', () async { + final (record, wallet) = await walletService.createWallet( + 'Unfunded Send', + WalletNetwork.testnet, + ScriptType.p2wpkh, + ); + addTearDown(wallet.dispose); + final recipient = wallet + .nextUnusedAddress(keychain: KeychainKind.external_) + .address + .toString(); + + await expectLater( + walletService.buildTransaction(record, wallet, recipient, 1000, 1), + throwsA(isA()), + ); + }); + }); + group('WalletService.generateAddress()', () { setUp(_initServices); tearDown(_tearDownServices);