From 6933310a042aff91b4e7451eb91abc31bf393700 Mon Sep 17 00:00:00 2001 From: Johnosezele Date: Sun, 14 Jun 2026 00:29:20 +0100 Subject: [PATCH 1/5] add transaction build and broadcast services --- bdk_demo/lib/services/blockchain_service.dart | 10 ++ bdk_demo/lib/services/wallet_service.dart | 90 +++++++++++++++++ .../services/blockchain_service_test.dart | 3 + .../test/services/wallet_service_test.dart | 96 +++++++++++++++++++ 4 files changed, 199 insertions(+) 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/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index 60ec5f2..5002346 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,57 @@ 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; + } + + Txid signAndBroadcast( + Wallet wallet, + Psbt psbt, + BlockchainClient blockchainClient, + ) { + final signed = wallet.sign(psbt: psbt, signOptions: null); + if (!signed) { + throw StateError('Could not sign transaction.'); + } + + final transaction = psbt.extractTx(); + blockchainClient.broadcast(transaction); + return transaction.computeTxid(); + } + Future<(AddressInfo, Wallet)> generateAddress(WalletRecord record) async { final secrets = await _storage.getSecrets(record.id); if (secrets == null) { @@ -481,6 +533,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/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); From c6c6ed583f2884c392faa4059bd9a7937934b17f Mon Sep 17 00:00:00 2001 From: Johnosezele Date: Sun, 14 Jun 2026 02:00:43 +0100 Subject: [PATCH 2/5] add SendPage form with fee suggestions --- bdk_demo/lib/core/router/app_router.dart | 3 +- bdk_demo/lib/features/send/send_page.dart | 234 ++++++++++++++++++ bdk_demo/lib/providers/send_providers.dart | 23 ++ .../test/presentation/router_wiring_test.dart | 39 +++ .../test/presentation/send_page_test.dart | 197 +++++++++++++++ 5 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 bdk_demo/lib/features/send/send_page.dart create mode 100644 bdk_demo/lib/providers/send_providers.dart create mode 100644 bdk_demo/test/presentation/send_page_test.dart 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..c24f49c --- /dev/null +++ b/bdk_demo/lib/features/send/send_page.dart @@ -0,0 +1,234 @@ +import 'dart:math' as math; +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/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'; + +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(); + + @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 canReview = + record != null && wallet != null && isOnline && _hasValidInput; + + 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.always, + 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: const InputDecoration( + labelText: 'Amount (sats)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + textInputAction: TextInputAction.next, + validator: (value) => + _validatePositiveInt(value, 'Amount'), + onChanged: (_) => setState(() {}), + ), + 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: const Text('Review transaction'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + bool get _hasValidInput => + _validateAddress(_addressController.text) == null && + _validatePositiveInt(_amountController.text, 'Amount') == 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; + } + + void _applyFeeRate(double feeRate) { + _feeRateController.text = math.max(1, feeRate.ceil()).toString(); + setState(() {}); + } + + void _handleReview() { + _formKey.currentState?.validate(); + } +} + +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..df348a0 --- /dev/null +++ b/bdk_demo/lib/providers/send_providers.dart @@ -0,0 +1,23 @@ +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:bdk_demo/services/blockchain_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +typedef BlockchainClientFactory = + BlockchainClient Function(WalletNetwork network); + +final blockchainClientFactoryProvider = Provider( + (ref) => BlockchainService.createClient, +); + +final feeEstimatesProvider = FutureProvider>((ref) async { + final record = ref.watch(activeWalletRecordProvider); + if (record == null) return const {}; + + final client = ref.read(blockchainClientFactoryProvider).call(record.network); + try { + return client.getFeeEstimates(); + } finally { + client.dispose(); + } +}); diff --git a/bdk_demo/test/presentation/router_wiring_test.dart b/bdk_demo/test/presentation/router_wiring_test.dart index 6d8ba29..dceab86 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,8 +10,10 @@ 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/blockchain_service.dart'; import 'package:bdk_demo/services/storage_service.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; @@ -50,6 +53,7 @@ void main() { ConnectivityResult.wifi, ], bool seedActiveWallet = false, + bool? isOnline, }) async { SharedPreferences.setMockInitialValues({}); FlutterSecureStorage.setMockInitialValues({}); @@ -58,9 +62,13 @@ void main() { final container = ProviderContainer( overrides: [ storageServiceProvider.overrideWithValue(storage), + blockchainClientFactoryProvider.overrideWithValue( + (network) => _FakeBlockchainClient(), + ), connectivityProvider.overrideWith( (ref) => Stream.value(connectivityResults), ), + if (isOnline != null) isOnlineProvider.overrideWith((ref) => isOnline), ], ); addTearDown(container.dispose); @@ -145,6 +153,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); @@ -153,3 +175,20 @@ void main() { expect(find.text('Recover Wallet'), findsOneWidget); }); } + +final class _FakeBlockchainClient implements BlockchainClient { + @override + BlockchainBackend get backend => BlockchainBackend.electrum; + + @override + void broadcast(Transaction transaction) {} + + @override + void dispose() {} + + @override + Map getFeeEstimates() => const {1: 1.0}; + + @override + int getTipHeight() => 0; +} 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..37b8bb9 --- /dev/null +++ b/bdk_demo/test/presentation/send_page_test.dart @@ -0,0 +1,197 @@ +import 'package:bdk_dart/bdk.dart' hide Key; +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/wallet_providers.dart'; +import 'package:bdk_demo/services/blockchain_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'; + +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, + }) async { + final container = ProviderContainer( + overrides: [ + connectivityProvider.overrideWith( + (ref) => Stream.value(const [ConnectivityResult.wifi]), + ), + blockchainClientFactoryProvider.overrideWithValue( + (network) => _FakeBlockchainClient(feeEstimates), + ), + ], + ); + 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(); + } + + 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 shows validation messages and disabled review', ( + tester, + ) async { + final container = await createContainer(); + + await pumpSendPage(tester, container); + + expect(find.text('Recipient address is required.'), findsOneWidget); + expect(find.text('Amount is required.'), findsOneWidget); + expect(find.text('Fee rate is required.'), findsOneWidget); + 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); + }); +} + +final class _FakeBlockchainClient implements BlockchainClient { + _FakeBlockchainClient(this._feeEstimates); + + final Map _feeEstimates; + + @override + BlockchainBackend get backend => BlockchainBackend.electrum; + + @override + void broadcast(Transaction transaction) { + throw StateError('Broadcast should not be called in commit 2.'); + } + + @override + void dispose() {} + + @override + Map getFeeEstimates() => _feeEstimates; + + @override + int getTipHeight() => 0; +} From 6f06d593f4eacfc3bbdcb274ddab353342cfa9a4 Mon Sep 17 00:00:00 2001 From: Johnosezele Date: Sun, 14 Jun 2026 06:20:27 +0100 Subject: [PATCH 3/5] Fetch Send fee suggestions through an injectable isolate job runner and delay validation --- bdk_demo/lib/features/send/send_page.dart | 2 +- bdk_demo/lib/providers/send_providers.dart | 21 +++++-- bdk_demo/lib/services/fee_estimates_job.dart | 61 +++++++++++++++++++ .../test/presentation/router_wiring_test.dart | 22 +------ .../test/presentation/send_page_test.dart | 43 ++++--------- 5 files changed, 92 insertions(+), 57 deletions(-) create mode 100644 bdk_demo/lib/services/fee_estimates_job.dart diff --git a/bdk_demo/lib/features/send/send_page.dart b/bdk_demo/lib/features/send/send_page.dart index c24f49c..9bbbcfa 100644 --- a/bdk_demo/lib/features/send/send_page.dart +++ b/bdk_demo/lib/features/send/send_page.dart @@ -73,7 +73,7 @@ class _SendPageState extends ConsumerState { const SizedBox(height: 24), Form( key: _formKey, - autovalidateMode: AutovalidateMode.always, + autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/bdk_demo/lib/providers/send_providers.dart b/bdk_demo/lib/providers/send_providers.dart index df348a0..e96b8e7 100644 --- a/bdk_demo/lib/providers/send_providers.dart +++ b/bdk_demo/lib/providers/send_providers.dart @@ -1,6 +1,8 @@ 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 = @@ -10,14 +12,21 @@ final blockchainClientFactoryProvider = Provider( (ref) => BlockchainService.createClient, ); +final feeEstimatesJobRunnerProvider = Provider( + (ref) => defaultFeeEstimatesJobRunner, +); + final feeEstimatesProvider = FutureProvider>((ref) async { final record = ref.watch(activeWalletRecordProvider); if (record == null) return const {}; - final client = ref.read(blockchainClientFactoryProvider).call(record.network); - try { - return client.getFeeEstimates(); - } finally { - client.dispose(); - } + 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/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/test/presentation/router_wiring_test.dart b/bdk_demo/test/presentation/router_wiring_test.dart index dceab86..414c659 100644 --- a/bdk_demo/test/presentation/router_wiring_test.dart +++ b/bdk_demo/test/presentation/router_wiring_test.dart @@ -13,7 +13,6 @@ 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'; @@ -62,8 +61,8 @@ void main() { final container = ProviderContainer( overrides: [ storageServiceProvider.overrideWithValue(storage), - blockchainClientFactoryProvider.overrideWithValue( - (network) => _FakeBlockchainClient(), + feeEstimatesJobRunnerProvider.overrideWithValue( + (_) async => const {1: 1.0}, ), connectivityProvider.overrideWith( (ref) => Stream.value(connectivityResults), @@ -175,20 +174,3 @@ void main() { expect(find.text('Recover Wallet'), findsOneWidget); }); } - -final class _FakeBlockchainClient implements BlockchainClient { - @override - BlockchainBackend get backend => BlockchainBackend.electrum; - - @override - void broadcast(Transaction transaction) {} - - @override - void dispose() {} - - @override - Map getFeeEstimates() => const {1: 1.0}; - - @override - int getTipHeight() => 0; -} diff --git a/bdk_demo/test/presentation/send_page_test.dart b/bdk_demo/test/presentation/send_page_test.dart index 37b8bb9..96289d7 100644 --- a/bdk_demo/test/presentation/send_page_test.dart +++ b/bdk_demo/test/presentation/send_page_test.dart @@ -3,12 +3,14 @@ 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:shared_preferences/shared_preferences.dart'; const _testExtendedPrivKey = 'tprv8ZgxMBicQKsPf2qfrEygW6fdYseJDDrVnDv26PH5BHdvSuG6ecCbHqLVof9yZcMoM31z9ur3tTYbSnr1WBqbGX97CbXcmp5H6qeMpyvx35B'; @@ -38,13 +40,17 @@ void main() { Map feeEstimates = const {1: 2.2, 3: 1.4, 6: 1.0}, bool seedActiveWallet = true, }) 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]), ), - blockchainClientFactoryProvider.overrideWithValue( - (network) => _FakeBlockchainClient(feeEstimates), + feeEstimatesJobRunnerProvider.overrideWithValue( + (_) async => feeEstimates, ), ], ); @@ -96,16 +102,16 @@ void main() { ); }); - testWidgets('empty form shows validation messages and disabled review', ( + 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.'), findsOneWidget); - expect(find.text('Amount is required.'), findsOneWidget); - expect(find.text('Fee rate is required.'), findsOneWidget); + 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'), ); @@ -172,26 +178,3 @@ void main() { expect(button.onPressed, isNotNull); }); } - -final class _FakeBlockchainClient implements BlockchainClient { - _FakeBlockchainClient(this._feeEstimates); - - final Map _feeEstimates; - - @override - BlockchainBackend get backend => BlockchainBackend.electrum; - - @override - void broadcast(Transaction transaction) { - throw StateError('Broadcast should not be called in commit 2.'); - } - - @override - void dispose() {} - - @override - Map getFeeEstimates() => _feeEstimates; - - @override - int getTipHeight() => 0; -} From 5c7492be6e179db82f55b1aefb122a6c7acb347d Mon Sep 17 00:00:00 2001 From: Johnosezele Date: Tue, 16 Jun 2026 02:21:02 +0300 Subject: [PATCH 4/5] add Send confirmation and broadcast flow --- bdk_demo/lib/features/send/send_page.dart | 296 ++++++++++++++++- bdk_demo/lib/providers/send_providers.dart | 42 +++ .../test/presentation/send_page_test.dart | 307 ++++++++++++++++++ 3 files changed, 631 insertions(+), 14 deletions(-) diff --git a/bdk_demo/lib/features/send/send_page.dart b/bdk_demo/lib/features/send/send_page.dart index 9bbbcfa..abeb518 100644 --- a/bdk_demo/lib/features/send/send_page.dart +++ b/bdk_demo/lib/features/send/send_page.dart @@ -1,12 +1,17 @@ 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}); @@ -20,6 +25,9 @@ class _SendPageState extends ConsumerState { final _addressController = TextEditingController(); final _amountController = TextEditingController(); final _feeRateController = TextEditingController(); + _SendAmountUnit _amountUnit = _SendAmountUnit.sats; + var _isBuilding = false; + var _isBroadcasting = false; @override void dispose() { @@ -35,8 +43,13 @@ class _SendPageState extends ConsumerState { 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; + record != null && + wallet != null && + isOnline && + _hasValidInput && + !isBusy; return Scaffold( appBar: const SecondaryAppBar(title: 'Send'), @@ -94,19 +107,43 @@ class _SendPageState extends ConsumerState { TextFormField( key: const Key('send-amount-field'), controller: _amountController, - decoration: const InputDecoration( - labelText: 'Amount (sats)', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: 'Amount ($_amountUnitLabel)', + border: const OutlineInputBorder(), ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], + 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: (value) => - _validatePositiveInt(value, 'Amount'), + 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'), @@ -132,7 +169,7 @@ class _SendPageState extends ConsumerState { const SizedBox(height: 24), FilledButton( onPressed: canReview ? _handleReview : null, - child: const Text('Review transaction'), + child: Text(_reviewButtonLabel), ), ], ), @@ -143,9 +180,20 @@ class _SendPageState extends ConsumerState { ); } + 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 && - _validatePositiveInt(_amountController.text, 'Amount') == null && + _validateAmount(_amountController.text) == null && _validatePositiveInt(_feeRateController.text, 'Fee rate') == null; String? _validateAddress(String? value) { @@ -165,13 +213,233 @@ class _SendPageState extends ConsumerState { 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(() {}); } - void _handleReview() { - _formKey.currentState?.validate(); + 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), + ], + ), + ); } } diff --git a/bdk_demo/lib/providers/send_providers.dart b/bdk_demo/lib/providers/send_providers.dart index e96b8e7..a4c6fc8 100644 --- a/bdk_demo/lib/providers/send_providers.dart +++ b/bdk_demo/lib/providers/send_providers.dart @@ -1,3 +1,4 @@ +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'; @@ -16,6 +17,47 @@ 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 => + walletService.signAndBroadcast(wallet, psbt, client).toString(), + ); + }; + }); + final feeEstimatesProvider = FutureProvider>((ref) async { final record = ref.watch(activeWalletRecordProvider); if (record == null) return const {}; diff --git a/bdk_demo/test/presentation/send_page_test.dart b/bdk_demo/test/presentation/send_page_test.dart index 96289d7..5e62c8b 100644 --- a/bdk_demo/test/presentation/send_page_test.dart +++ b/bdk_demo/test/presentation/send_page_test.dart @@ -1,15 +1,18 @@ 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 = @@ -39,6 +42,8 @@ void main() { 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(); @@ -52,6 +57,12 @@ void main() { feeEstimatesJobRunnerProvider.overrideWithValue( (_) async => feeEstimates, ), + if (draftBuilder != null) + sendTransactionDraftBuilderProvider.overrideWithValue(draftBuilder), + if (blockchainClientFactory != null) + blockchainClientFactoryProvider.overrideWithValue( + blockchainClientFactory, + ), ], ); addTearDown(container.dispose); @@ -86,6 +97,61 @@ void main() { 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 { @@ -177,4 +243,245 @@ void main() { ); 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; } From 2319ac3198cc711d4644f4cfd87ee163bc79cc49 Mon Sep 17 00:00:00 2001 From: Johnosezele Date: Wed, 17 Jun 2026 07:42:37 +0300 Subject: [PATCH 5/5] fix(bdk_demo): persist unconfirmed send after broadcast --- bdk_demo/lib/providers/send_providers.dart | 8 ++++++-- bdk_demo/lib/services/wallet_service.dart | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/bdk_demo/lib/providers/send_providers.dart b/bdk_demo/lib/providers/send_providers.dart index a4c6fc8..14cf47e 100644 --- a/bdk_demo/lib/providers/send_providers.dart +++ b/bdk_demo/lib/providers/send_providers.dart @@ -52,8 +52,12 @@ final sendTransactionDraftBuilderProvider = ); return SendTransactionDraft( feeSat: psbt.fee(), - broadcast: (client) async => - walletService.signAndBroadcast(wallet, psbt, client).toString(), + broadcast: (client) async => (await walletService.signAndBroadcast( + record, + wallet, + psbt, + client, + )).toString(), ); }; }); diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index 5002346..c006ed7 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -400,11 +400,12 @@ class WalletService { return psbt; } - Txid signAndBroadcast( + 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.'); @@ -412,6 +413,17 @@ class WalletService { 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(); }