In this workshop, we will set up a Flutter project and implement the basic functionalities of a Bitcoin on-chain wallet, like:
- Generating a new wallet
- Displaying the balance
- Receiving a transaction
- Testing locally with Polar
- Displaying the transaction history
- Sending a transaction
And if time permits:
- Backing up the wallet
- Importing an existing wallet
We will use the Bitcoin Development Kit (BDK) to implement this functionality.
Start by creating a new Flutter project using the flutter create
command. You can choose any name for the project, but for the sake of this workshop, we will use mobile_dev_workshops
, so run flutter create mobile_dev_workshops
where you want to create the project.
From the created project folder, you should now be able to run the app on a connected device or emulator using flutter run
.
If an app with a counter button is shown, everything is working correctly.
We will only be building for Android and iOS during this workshop, so you can remove the other platforms' folders from the project, manually or by running the command rm -rf linux macos web windows
.
The main.dart
file is the entry point of the app and is where we will start. It is located in the lib
folder of the project.
As you see, there is already some code in there for the counter app that was created by default. We will remove the MyHomePage widget as we will create our own widget for the home screen of the app.
In the MyApp widget, we will change the title to "Bitcoin Flutter App", you can change the color to our beloved Orange color and the home to our own widget that we will create in the next step and will call HomeScreen.
We will create a folder for all features of the app, called features
. We can consider having a Home to be a feature and thus create a folder for it in the features folder, called home
, and a file home_screen.dart
inside it for the view of our home page. For now, we just create a simple view saying 'Home Screen' in the center of the screen.
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('Home Screen'),
),
);
}
}
In the next steps we will add the initial layout and components for our Bitcoin wallet to the Home Screen.
Our Bitcoin wallet will have a simple, but scalable, layout with an app bar, an horizontal list of balances to add Lightning or other balances in the future, a list of transactions and a floating button to send and receive Bitcoin.
The app bar will just have a menu drawer icon on the right that in the future can be used to open a drawer with some options, like settings, seed backup, etc.
Just add it with the following two lines in the Scaffold widget of the HomeScreen:
// In the Scaffold widget ...
appBar: AppBar(),
endDrawer: const Drawer(),
// ...
We will use a horizontal list of balances of the wallets created in the app, so that we can easily add more wallets in the future, like Lightning, etc. For now, we will only have an on-chain Bitcoin wallet, but we will add a Lightning balance in the next workshop.
In the HomeScreen
widget, change the complete body for a ListView
. This will allow us to add different components one above the other and scroll if the screen is not big enough to show all of them.
// In the Scaffold widget ...
body: ListView(
children: [
// ...
],
),
The first component we will add is the list of balances. We will create a new widget for it, called WalletCardsList
, and add it to the Column
widget. The list will be a horizontal list, so it can grow unlimitedly and be scrolled horizontally if more wallets are added in the future.
To constrain it vertically, we will wrap it in a SizedBox
widget with a fixed height.
The homescreen now looks like this:
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
endDrawer: const Drawer(),
body: ListView(
children: const [
SizedBox(
height: kSpacingUnit * 24,
child: WalletCardsList(),
),
],
),
);
}
}
Before we create the WalletCardsList
itself, let's create the items that will be displayed in the list. We will create a new folder for widgets to separate them from the screens, as they could possibly be reused in other screens in the future. Inside the lib
folder, create a new folder called widgets
and inside it a new folder called wallets
and three files inside it: add_new_wallet_card.dart
, wallet_balance_card.dart
and wallet_cards_list.dart
.
The add_new_wallet_card.dart
file will contain a widget that will be displayed in the list to add a new wallet. For now, it will just be a card with a plus icon and a label in the center. We will use the InkWell
widget to make it tappable:
class AddNewWalletCard extends StatelessWidget {
const AddNewWalletCard({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
// Todo: Navigate to add a new wallet
print('Add a new wallet');
},
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add,
),
Text('Add a new wallet'),
],
),
),
);
}
}
The wallet_balance_card.dart
file will contain a widget that will be displayed in the list for each wallet. For now, it will just be a card with an icon indicating the type of wallet, a label and the balance underneath. It will also have a closing button in the upper corner to easily delete the wallet just for testing now. To be able to show the icon, we will use the SvgPicture
widget from the flutter_svg
package, so make sure to do flutter pub add flutter_svg
from the command line. The specific icons will be placed in a folder in the root of the project, called assets
, and added to the pubspec.yaml
file to be able to use them in the app. The wallet_balance_card.dart
widget will now look like this:
import 'package:mobile_dev_workshops/constants.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class WalletBalanceCard extends StatelessWidget {
const WalletBalanceCard({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kSpacingUnit),
),
clipBehavior: Clip.hardEdge,
child: InkWell(
borderRadius: BorderRadius.circular(kSpacingUnit),
onTap: () {
// Todo: Navigate to wallet
print('Go to wallet');
},
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: kSpacingUnit * 12,
width: double.infinity,
color: theme.colorScheme.primaryContainer,
child: SvgPicture.asset(
'assets/icons/bitcoin_savings.svg',
fit: BoxFit.none, // Don't scale the SVG, keep it at its original size
),
),
// Expanded to take up all the space of the height the list is constrained to
Expanded(
child: Padding(
padding: const EdgeInsets.all(kSpacingUnit),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Savings wallet',
style: theme.textTheme.labelMedium,
),
const SizedBox(height: kSpacingUnit),
Text(
'0.00000000 BTC',
style: theme.textTheme.bodyMedium,
),
],
),
),
)
],
),
Positioned(
top: 0,
right: 0,
child: CloseButton(
onPressed: () {
print('Delete wallet');
},
style: ButtonStyle(
padding: MaterialStateProperty.all(
EdgeInsets.zero,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
iconSize: MaterialStateProperty.all(
kSpacingUnit * 2,
),
),
),
),
],
),
),
);
}
}
Now we can create the list itself in the wallet_cards_list.dart
file. Just create a ListView
with a ScrollPhysics
to make it scrollable horizontally and add the two widgets we just created to it. The WalletCardsList
widget will now look like this:
class WalletCardsList extends StatelessWidget {
const WalletCardsList({
super.key,
});
final count = 2;
@override
Widget build(context) {
return ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: count,
itemExtent: kSpacingUnit * 20,
itemBuilder: (BuildContext context, int index) {
if (index == count - 1) {
return const AddNewWalletCard();
} else {
return const WalletBalanceCard();
}
},
);
}
}
You can play with the count variable to see how the list grows horizontally and the last item is always the add new wallet card.
To show the transaction history, we will create a new widget called TransactionsList
that vertically lists TransactionsListItems
for every transaction that was done with the wallet. Place both in a new folder lib/widgets/transactions
. The TransactionsListItem
will just be a List tile with a leading icon to show the direction of the transaction, a description and the time of the transaction and an amount:
class TransactionsListItem extends StatelessWidget {
const TransactionsListItem({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
leading: const CircleAvatar(
child: Icon(Icons.arrow_downward),
),
title: Text('Received funds', style: theme.textTheme.titleMedium),
subtitle: Text('14-02-2021 12:00', style: theme.textTheme.bodySmall),
trailing: Text('+0.00000001 BTC', style: theme.textTheme.bodyMedium),
);
}
}
The TransactionsList
will be a ListView
with scrolling disabled and shrinkWrap
on true, since it will be placed in an already scrollable parent with infite height, the ListView
of the HomeScreen
. The TransactionsList
widget will now look like this:
class TransactionsList extends StatelessWidget {
const TransactionsList({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Transactions',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
ListView.builder(
shrinkWrap:
true, // To set constraints on the ListView in an infinite height parent (ListView in HomeScreen)
physics:
const NeverScrollableScrollPhysics(), // Scrolling is handled by the parent (ListView in HomeScreen)
itemBuilder: (ctx, index) {
return const TransactionsListItem();
},
itemCount: 10,
),
],
);
}
}
Now for the actions like receiving and sending we would want to do with a Bitcoin wallet, we will add a floating button that opens a bottom sheet modal with possible actions. For now, we will only add the options to receive and send bitcoin.
To add a floating button, just add the following to the Scaffold
widget:
// HomeScreen Scaffold widget ...
floatingActionButton: FloatingActionButton(
onPressed: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const WalletActionsBottomSheet(),
),
child: SvgPicture.asset(
'assets/icons/in_out_arrows.svg',
),
),
// ...
For the wallet actions, we will create a separate features folder lib/features/wallet_actions
and add the WalletActionsBottomSheet
widget to it in the wallet_actions_bottom_sheet.dart
file:
class WalletActionsBottomSheet extends StatelessWidget {
const WalletActionsBottomSheet({Key? key}) : super(key: key);
static const List<Tab> actionTabs = <Tab>[
Tab(
icon: Icon(Icons.arrow_downward),
text: 'Receive funds',
),
Tab(
icon: Icon(Icons.arrow_upward),
text: 'Send funds',
),
];
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: actionTabs.length,
child: Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
actions: const [
CloseButton(),
],
bottom: const TabBar(
tabs: actionTabs,
),
),
resizeToAvoidBottomInset: false,
body: Padding(
padding: const EdgeInsets.all(kSpacingUnit * 4),
child: TabBarView(
children: [
const ReceiveTab(),
Container(),
],
),
),
),
);
}
}
This widget will show a tab bar with two tabs, one for receiving and one for sending funds. We will now replace the Container
widgets with the actual widgets for receiving and sending funds.
In the lib/features/wallet_actions
folder, create a folder for the receive feature, called receive
, and a folder for the send feature, called send
. In the receive
folder, create a file called receive_tab.dart
and in the send
folder, create a file called send_tab.dart
.
In the receive_tab.dart
file we will create the widget ReceiveTab
. A Bitcoin address itself doesn't encode any amount or label or description, but most Bitcoin wallets currently support the BIP21 URI scheme, which allows to pass a label, description and amount along with the address. This permits the reading wallet to pre-fill the amount and remember the paying user who and/or what he is paying for again. We will use this scheme to generate a QR code that can be scanned by other wallets to easily send funds. So the ReceiveTab
widget will have a TextField
for the amount, a TextField
for the label and a TextField
for the description or message.
Aside from those input fields, we will need to be able to show an error message in case the amount is valid or if no wallet is available to generate an address for yet. At last we will need a button to confirm the input and generate the Bitcoin address. Adding these components to the ReceiveTab
widget will look like this:
class ReceiveTab extends StatelessWidget {
const ReceiveTab({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: kSpacingUnit * 2),
// Amount Field
const SizedBox(
width: 250,
child: TextField(
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Amount (optional)',
hintText: '0',
helperText: 'The amount you want to receive in BTC.',
),
onChanged: null,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Label Field
const SizedBox(
width: 250,
child: TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Label (optional)',
hintText: 'Alice',
helperText: 'A name the payer knows you by.',
),
onChanged: null,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Message Field
const SizedBox(
width: 250,
child: TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Message (optional)',
hintText: 'Payback for dinner.',
helperText: 'A note to the payer.',
),
onChanged: null,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Error message
const SizedBox(
height: kSpacingUnit * 2,
child: Text(
true
? 'You need to create a wallet first.'
: false
? 'Please enter a valid amount.'
: '',
style: TextStyle(
color: Colors.red,
),
),
),
const SizedBox(height: kSpacingUnit * 2),
// Generate invoice Button
ElevatedButton.icon(
onPressed: null,
label: const Text('Generate invoice'),
icon: const Icon(Icons.qr_code),
),
],
);
}
}
The SendTab
widget will be very similar, it will have a TextField
for the amount to send, a TextField
for the address to send to, a Slider
to select the fee rate, a Text
widget to show any errors and a button to send the transaction. Implemented the SendTab
widget will look like this:
class SendTab extends StatelessWidget {
const SendTab({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: kSpacingUnit * 2),
// Amount Field
const SizedBox(
width: 250,
child: TextField(
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Amount',
hintText: '0',
helperText: 'The amount you want to send in BTC.',
),
onChanged: null,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Invoice Field
const SizedBox(
width: 250,
child: TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Invoice',
hintText: '1bc1q2c3...',
helperText: 'The invoice to pay.',
),
onChanged: null,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Fee rate slider
SizedBox(
width: 250,
child: Column(
children: [
const Slider(
value: 0.5,
onChanged: null,
),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: kSpacingUnit * 2),
child: Text(
'The fee rate (sat/vB) to pay for this transaction.',
style: theme.textTheme.bodySmall,
),
),
],
),
),
const SizedBox(height: kSpacingUnit * 2),
// Error message
const SizedBox(
height: kSpacingUnit * 2,
child: Text(
false
? 'Please enter a valid amount.'
: true
? 'Not enough funds available.'
: false
? 'Please enter a valid invoice.'
: '',
style: TextStyle(
color: Colors.red,
),
),
),
const SizedBox(height: kSpacingUnit * 2),
// Send funds Button
ElevatedButton.icon(
onPressed: null,
label: const Text('Send funds'),
icon: const Icon(Icons.send),
),
],
);
}
}
Now replace the Container
widgets in the WalletActionsBottomSheet
widget with the ReceiveTab
and SendTab
widgets:
// WalletActionsBottomSheet widget ...
TabBarView(
children: [
const ReceiveTab(),
const SendTab(),
],
),
// ...
With this, we have the basic layout of our Bitcoin wallet app. It should look something like this now:
All data is hardcoded for now, but in the next steps we will add the functionality to generate a new wallet and display the balance and transactions.
To generate a new wallet, we will use the Bitcoin Development Kit (BDK). It is a library that provides a Rust API with various Bitcoin functionalities, like generating a new wallet, creating addresses, transactions, etc. A Flutter package exists that wraps the Rust API in a Dart API, so that we can use it in our Flutter app. The package is called bdk_flutter and we can install it by running flutter pub add bdk_flutter
from the command line.
Make sure the Minimum SDK version in the android/app/build.gradle
file is at least 23, as the BDK library requires it. For iOS, the minimum version should be 12.0, so make sure the
podfile in the ios
folder has the following:
platform :ios, '12.0'
And the ios/Runner/Info.plist
file has the following:
<key>MinimumOSVersion</key>
<string>12.0</string>
Then run cd ios && pod install && cd ..
from the command line for the changes to take effect.
To start easy, let's just use the bdk package directly in our AddNewWalletCard
widget to generate a BIP39 seed phrase, also know as recovery phrase or mnemonic, when we press the button and just print it out for now.
In the AddNewWalletCard
widget change the onTap
callback of the InkWell
widget:
// AddNewWalletCard widget ...
onTap: () async {
print('Add a new wallet');
final mnemonic = await Mnemonic.create(WordCount.Words12);
print('Mnemonic created: ${mnemonic.asString()}');
},
// ...
If you run the app now and press the add new wallet card, you should see a new seed phrase printed to the console every time you press it.
Now that we can generate a new wallet, we need to store it securely. For this, we will use the Flutter Secure Storage package. It is a package that allows us to store data securely in the device's keychain or keystore. To install it, run flutter pub add flutter_secure_storage
from the command line.
To separate the logic concerning the storage and retrieval of the mnemonic, we will create a new class called MnemonicRepository
in the lib/repositories
folder. This class will have three initial methods, setting or storing a new mnemonic, retrieving the mnemonic and deleting the mnemonic. We create an abstract class with these methods so that we can easily create a mock implementation for testing purposes in the future.
abstract class MnemonicRepository {
Future<void> setMnemonic(String mnemonic);
Future<String?> getMnemonic();
Future<void> deleteMnemonic();
}
Then we create a new class called SecureStorageMnemonicRepository
that will be a concrete implementation to store the mnemonic in secure storage. The SecureStorageMnemonicRepository
class will look like this:
class SecureStorageMnemonicRepository implements MnemonicRepository {
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
static const String _mnemonicKey = 'mnemonic';
@override
Future<void> setMnemonic(String mnemonic) async {
await _secureStorage.write(key: _mnemonicKey, value: mnemonic);
}
@override
Future<String?> getMnemonic() {
return _secureStorage.read(key: _mnemonicKey);
}
@override
Future<void> deleteMnemonic() {
return _secureStorage.delete(key: _mnemonicKey);
}
}
This class can now be used to take care of the storage and retrieval of the mnemonic. Let's bring together the generation and storage in a service class.
Since we might add other types of wallets in the future, like Lightning, we will first define an abstract class WalletService
and add two simple methods to start, one to add a wallet and one to delete the wallet through the service:
abstract class WalletService {
Future<void> addWallet();
Future<void> deleteWallet();
}
For the concrete implementation of our on-chain wallet we will create a new class called BitcoinWalletService
that will use bdk_flutter
to generate a new wallet and SecureStorageMnemonicRepository
to store and delete the mnemonic. The BitcoinWalletService
will look like this:
class BitcoinWalletService implements WalletService {
final MnemonicRepository _mnemonicRepository;
BitcoinWalletService({required MnemonicRepository mnemonicRepository})
: _mnemonicRepository = mnemonicRepository;
@override
Future<void> addWallet() async {
final mnemonic = await Mnemonic.create(WordCount.Words12);
await _mnemonicRepository.setMnemonic(mnemonic.asString());
}
@override
Future<void> deleteWallet() async {
await _mnemonicRepository.deleteMnemonic();
}
}
What is now missing is a way to bring together the UI and the service without mixing up the logic. For this, we could use state management packages like provider, riverpod, etc., but for now we will just manage the state in the HomeScreen
widget itself and create a controller class that will be responsible of updating it.
To be able to manage state, we first need to define the fields or data that forms part of the state.
As we need to show the wallet balance card in the UI if a wallet was added, we will create a model for this data called WalletBalance
in the lib/view_models
folder:
@immutable
class WalletBalance extends Equatable {
const WalletBalance({
required this.walletName,
});
final String walletName;
@override
List<Object> get props => [
walletName,
];
}
Currently it only holds a name for the wallet, since we have not implemented a way to get the balance yet. We will add this in the next steps. In the future when we add Lightning, it can also hold the type of wallet to know which icon to show, etc.
Now we can use it as a field of the HomeState class that will hold the state of the HomeScreen widget. Just as the WalletBalance, we will annotate it with @immutable
and extend it with Equatable of the equatable
package so that Flutter can easily know when the state has changed and rebuild the widget. Since it is part of the home feature only, just put it in the lib/features/home
folder:
@immutable
class HomeState extends Equatable {
const HomeState({
this.walletBalance,
});
final WalletBalance? walletBalance;
HomeState copyWith({
WalletBalance? walletBalance,
bool clearWalletBalance = false,
}) {
return HomeState(
walletBalance:
clearWalletBalance ? null : walletBalance ?? this.walletBalance,
);
}
@override
List<Object?> get props => [walletBalance];
}
The copyWith method is used to create a new instance of the state with the same values and only update the fields that are passed to it instead of mutating an existing instance. This is important for Flutter to know when the state has changed and rebuild the widget.
Now we can create the controller. In the same folder of the home feature, next to the screen and state, add the home_controller.dart
file. The controller should be able to get the current state and update it. To do this, we will pass two callbacks to the controller, one to get the current state and one to update the state. This way, the controller does not need to know about the UI and the UI does not need to know about the controller. It should also have a dependency to the WalletService
to be able to add and delete wallets. The HomeController
will look like this:
class HomeController {
final HomeState Function() _getState;
final Function(HomeState state) _updateState;
final WalletService _bitcoinWalletService;
static const walletName =
'Savings'; // For a real app, the name should be dynamic and be set by the user when adding the wallet and stored in some local storage.
HomeController({
required getState,
required updateState,
required bitcoinWalletService,
}) : _getState = getState,
_updateState = updateState,
_bitcoinWalletService = bitcoinWalletService;
Future<void> addNewWallet() async {
try {
await _bitcoinWalletService.addWallet();
_updateState(
_getState().copyWith(
walletBalance: WalletBalance(
walletName: walletName,
),
),
);
} catch (e) {
print(e);
}
}
Future<void> deleteWallet() async {
try {
await _bitcoinWalletService.deleteWallet();
_updateState(_getState().copyWith(clearWalletBalance: true));
} catch (e) {
print(e);
}
}
}
Now it is time to bring everything together again. The WalletService
's will generally be services that are instantiated and initialized only once and be shared/available throughout the whole app.
In productive code, you would probably use a Provider or other dependency injection frameworks to do this, but to keep the code and dependencies as unopiniated as possible for the workshop, we will just instantiate and the BitcoinWalletService
in the main
function and pass it down to the HomeScreen
widget. The main.dart
file will now look like this:
void main() async {
WidgetsFlutterBinding.ensureInitialized(); // Add this
final bitcoinWalletService = BitcoinWalletService(
mnemonicRepository: SecureStorageMnemonicRepository(),
); // Add this
runApp(MyApp(
bitcoinWalletService: bitcoinWalletService, // Add this
));
}
class MyApp extends StatelessWidget {
const MyApp({required this.bitcoinWalletService, super.key}); // Add this
final WalletService bitcoinWalletService; // Add this
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bitcoin Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
),
home: HomeScreen(
bitcoinWalletService: bitcoinWalletService, // Add this
),
);
}
}
Now we can add the state and the controller in the HomeScreen
widget. First, we will add the state to the widget. To do this, we will need to change from a StatelessWidget
to a StatefulWidget
widget. This way, we can use the setState
method to update the state and let Flutter rebuild the widget.
With the state and controller in place, we can now use the state to show the correct widgets in the UI. We will use the walletBalance
field of the state to show the WalletBalanceCard
if it is not null and the AddNewWalletCard
otherwise. For this we need to pass the walletBalance
to the WalletCardsList
widget and add callbacks to the WalletCardsList
and WalletBalanceCard
widgets to be able to add and delete wallets. Also the WalletCardsList
, WalletBalanceCard
and AddNewWalletCard
widgets will be updated.
The HomeScreen
widget will now look like this:
// Changed to a StatefulWidget
class HomeScreen extends StatefulWidget {
const HomeScreen({required this.bitcoinWalletService, super.key});
final WalletService bitcoinWalletService;
@override
HomeScreenState createState() => HomeScreenState();
}
class HomeScreenState extends State<HomeScreen> {
HomeState _state = const HomeState(); // Added the state
late HomeController _controller; // Added the controller
@override
void initState() {
super.initState();
_controller = HomeController(
getState: () => _state,
updateState: (HomeState state) => setState(() => _state = state),
bitcoinWalletService: widget.bitcoinWalletService,
);
} // Set the controller in the initState method
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
endDrawer: const Drawer(),
body: ListView(
children: [
SizedBox(
height: kSpacingUnit * 24,
child: WalletCardsList(
_state.walletBalance == null ? [] : [_state.walletBalance!], // Use the state here
onAddNewWallet: _controller.addNewWallet, // Added callback from the controller
onDeleteWallet: _controller.deleteWallet, // Added callback from the controller
),
),
// ... rest of the HomeScreen widget
And the WalletCardsList
, WalletBalanceCard
and AddNewWalletCard
widgets will be updated as follow:
// in wallet_cards_list.dart
class WalletCardsList extends StatelessWidget {
const WalletCardsList(
this.walletBalances, {
required this.onAddNewWallet,
required this.onDeleteWallet,
super.key,
});
final List<WalletBalance> walletBalances;
final VoidCallback onAddNewWallet;
final VoidCallback onDeleteWallet;
@override
Widget build(context) {
return ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: walletBalances.isEmpty ? 1 : walletBalances.length,
itemExtent: kSpacingUnit * 20,
itemBuilder: (BuildContext context, int index) {
if (walletBalances.isEmpty) {
return AddNewWalletCard(onPressed: onAddNewWallet);
} else {
return WalletBalanceCard(
walletBalances[index],
onDelete: onDeleteWallet,
);
}
},
);
}
}
// in wallet_balance_card.dart
class WalletBalanceCard extends StatelessWidget {
const WalletBalanceCard(this.walletBalance,
{required this.onDelete, Key? key})
: super(key: key);
final WalletBalance walletBalance;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
// ... rest of components
Expanded(
child: Padding(
padding: const EdgeInsets.all(kSpacingUnit),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
walletBalance.walletName // Used the wallet balance model of the state here
style: theme.textTheme.labelMedium,
),
const SizedBox(height: kSpacingUnit),
Text(
'0 BTC',
style: theme.textTheme.bodyMedium,
),
],
),
),
)
],
),
Positioned(
top: 0,
right: 0,
child: CloseButton(
onPressed: onDelete, // Added callback
style: ButtonStyle(
padding: MaterialStateProperty.all(
EdgeInsets.zero,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
iconSize: MaterialStateProperty.all(
kSpacingUnit * 2,
),
),
),
),
],
),
),
);
}
}
// add_new_wallet_card.dart
class AddNewWalletCard extends StatelessWidget {
const AddNewWalletCard({required this.onPressed, Key? key}) : super(key: key);
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(kSpacingUnit),
onTap: onPressed,
// ... rest of wdiget
)
);
}
}
To obtain the balance we will also use the Bitcoin Development Kit, through bdk_flutter
, but to do this we need to dive a bit deeper in how this package works.
We will need to create a BDK Wallet
and a Blockchain
instance. The Wallet
is to be able to derive addresses and create and sign transactions and the Blockchain
instance is needed to sync with the Bitcoin blockchain to obtain the needed data, like the utxo's and with that the balance, and to broadcast our own transactions later.
We will add them both as fields of our BitcoinWalletService
, since this is the class using BDK and responsible for the on-chain Bitcoin wallet.
class BitcoinWalletService implements WalletService {
final MnemonicRepository _mnemonicRepository;
Wallet? _wallet;
late Blockchain _blockchain;
// ... rest of class
}
We declare the wallet as nullable, since we want to be able to know if a wallet is existing or not as to set the correct Card in the UI.
The Blockchain
instance is a late field, since its initialization is asynchronous. To initialize both the Wallet
and the Blockchain
instance, we will create some helper functions in the BitcoinWalletService
class. To initialize the Blockchain
instance we just need to configure a Bitcoin node or source where he can get the Bitcoin blockchain data from. To start we will use a public Electrum server from Blockstream for Testnet. Later we will see how to use a local regtest node to not need testnet coins for testing. We will add the following helper function to the BitcoinWalletService
class:
Future<void> _initBlockchain() async {
_blockchain = await Blockchain.create(
config: const BlockchainConfig.electrum(
config: ElectrumConfig(
retry: 5,
url: "ssl://electrum.blockstream.info:60002",
validateDomain: false,
stopGap: 10,
),
),
);
}
BDK creates a Wallet
based on Output Descriptors. Output Descriptors in Bitcoin are a way to describe collections of output scripts, like Pay-to-witness-script-hash scripts (P2WSH) or Pay-to-taproot outputs (P2TR), etc. With the descriptor defined, the derivation path of the wallet is know and it can derive addresses of the desired type.
A different output descriptor for external/receive addresses should be used than for internal/change addresses. This is to be able to differentiate between incoming and outgoing transactions (track or audit what you received without revealing what you've spend) and to be able to derive the correct addresses for change outputs of transactions. For this wallet we will create native segwit addresses based on the BIP84 standard. We add the following helper functions to the BitcoinWalletService
class:
Future<void> _initWallet(Mnemonic mnemonic) async {
final descriptors = await _getBip84TemplateDescriptors(mnemonic);
_wallet = await Wallet.create(
descriptor: descriptors.$1,
changeDescriptor: descriptors.$2,
network: Network.Testnet,
databaseConfig: const DatabaseConfig
.memory(), // Txs and UTXOs related to the wallet will be stored in memory
);
}
Future<(Descriptor receive, Descriptor change)> _getBip84TemplateDescriptors(
Mnemonic mnemonic,
) async {
const network = Network.Testnet;
final secretKey =
await DescriptorSecretKey.create(network: network, mnemonic: mnemonic);
final receivingDescriptor = await Descriptor.newBip84(
secretKey: secretKey,
network: network,
keychain: KeychainKind.External);
final changeDescriptor = await Descriptor.newBip84(
secretKey: secretKey,
network: network,
keychain: KeychainKind.Internal);
return (receivingDescriptor, changeDescriptor);
}
Now in the addWallet
method of the BitcoinWalletService
class, we can call these helper functions to initialize the Wallet
when a new wallet is added:
@override
Future<void> addWallet() async {
final mnemonic = await Mnemonic.create(WordCount.Words12);
await _mnemonicRepository.setMnemonic(mnemonic.asString());
await _initWallet(mnemonic); // Add this
print(
'Wallet added with mnemonic: ${mnemonic.asString()} and initialized!');
}
Since BDK is ment to be as flexible and unopiniated as possible to support different types of applications, platforms and resources, syncing the blockchain data for a wallet is not done automatically in the back. The developer using BDK should decide when and how to sync the blockchain data. To do this we add the sync function to the BitcoinWalletService
class:
Future<void> sync() async {
await _wallet!.sync(_blockchain);
}
This way we can control when the wallet should sync with the blockchain and not use resources when it is not needed.
To be able to sync a wallet, a Blockchain
instance should already be available. So let's think about when and how to initialize everything.
To not complicate things, let's just initialize the Blockchain
instance at start when the BitcoinWalletService
is instantiated and initialize the Wallet
when a new wallet is added or when the app is restarted and a wallet is already existing. To do this, we will add a new method to the BitcoinWalletService
class:
Future<void> init() async {
print('Initializing BitcoinWalletService...');
await _initBlockchain();
print('Blockchain initialized!');
final mnemonic = await _mnemonicRepository.getMnemonic();
if (mnemonic != null && mnemonic.isNotEmpty) {
await _initWallet(await Mnemonic.fromString(mnemonic));
await sync();
print('Wallet with mnemonic $mnemonic found, initialized and synced!');
} else {
print('No wallet found!');
}
}
Now just call this method in the main after the BitcoinWalletService
is instantiated:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final bitcoinWalletService = BitcoinWalletService(
mnemonicRepository: SecureStorageMnemonicRepository(),
);
await bitcoinWalletService.init(); // Add this
runApp(MyApp(
bitcoinWalletService: bitcoinWalletService,
));
}
Now our Blockchain
instance is always created at start and our Wallet
instance is also created and synced at startup if a wallet was already existing, or when a new wallet is added.
We should make sure the UI also reflects this at startup. To know if a Wallet
was already existing, we can check if the Wallet
field of the BitcoinWalletService
is null or not. If it is null, we show the AddNewWalletCard
and if it is not null, we show the WalletBalanceCard
. We will add a getter to the BitcoinWalletService
and a new method to the HomeController
to do this:
// ... In `BitcoinWalletService` class
bool get hasWallet => _wallet != null;
// ... rest of class
// ... In `HomeController` class
Future<void> init() async {
if ((_bitcoinWalletService as BitcoinWalletService).hasWallet) {
_updateState(
_getState().copyWith(
walletBalance: WalletBalance(
walletName: walletName,
),
),
);
} else {
_updateState(_getState().copyWith(clearWalletBalance: true));
}
}
// ... rest of class
And call this method in the initState
method of the HomeScreen
widget:
// ... In `HomeScreenState` class
@override
void initState() {
super.initState();
_controller = HomeController(
getState: () => _state,
updateState: (HomeState state) => setState(() => _state = state),
bitcoinWalletService: widget.bitcoinWalletService,
);
_controller.init(); // Add this
}
// ... rest of class
With all of this in place, we can finally use the Wallet
instance to obtain the balance. For this we will add a new method to the BitcoinWalletService
class:
// ... In `BitcoinWalletService` class
@override
Future<int> getSpendableBalanceSat() async {
final balance = await _wallet!.getBalance();
return balance.spendable;
}
// ... rest of class
As can be observed, we added an @override annotation to the method. This is because we will add a new abstract method to the WalletService
interface to be able to obtain spendable balance,since this is something any type of wallet should be able to do. The WalletService
interface will now look like this:
abstract class WalletService {
Future<void> addWallet();
Future<void> deleteWallet();
Future<int> getSpendableBalanceSat(); // Added this
}
In our WalletBalance
view model, we will add a new field to hold the balance in satoshis and a getter to obtain the balance in BTC. The WalletBalance
class will now look like this:
@immutable
class WalletBalance extends Equatable {
const WalletBalance({
required this.walletName,
required this.balanceSat,
});
//final WalletType walletType;
final String walletName;
final int balanceSat;
double get balanceBtc => balanceSat / 100000000;
@override
List<Object> get props => [
walletName,
balanceSat,
];
}
Now we have to update the HomeController
to set the balance in the states it updates. Add the following to the WalletBalance
instances in the HomeController
:
balanceSat: await _bitcoinWalletService.getSpendableBalanceSat(),
Now in the WalletBalanceCard
where the balance should be really shown in the UI, we can just get it from the walletBalance
field like this:
// ... In `WalletBalanceCard` class
Expanded(
child: Padding(
padding: const EdgeInsets.all(kSpacingUnit),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
walletBalance.walletName,
style: theme.textTheme.labelMedium,
),
const SizedBox(height: kSpacingUnit),
Text(
'${walletBalance.balanceBtc} BTC', // Changed here
style: theme.textTheme.bodyMedium,
),
],
),
),
)
// ... rest of class
Try it out and you should see a balance of 0.0 BTC for an added wallet in the UI.
To be able to receive funds, we need to be able to generate Bitcoin addresses. For this we will add a new method to the BitcoinWalletService
class. Since any type of wallet needs a way to receive funds and generate addresses, we will add a new abstract method to the WalletService
interface. Lightning wallets use BOLT11 invoices instead of Bitcoin addresses, and Bitcoin addresses were also called invoices in the past, so we will call the method generateInvoice
:
abstract class WalletService {
Future<void> addWallet();
Future<void> deleteWallet();
Future<int> getSpendableBalanceSat();
Future<String> generateInvoice(); // Added this
}
In the BitcoinWalletService
class, we will add a concrete implementation for this method, again using the BDK Wallet
instance we already have available at this time:
// ... in BitcoinWalletService class
@override
Future<String> generateInvoice() async {
final invoice = await _wallet!.getAddress(
addressIndex: const AddressIndex(),
);
return invoice.address;
}
// ... rest of class
By using a new AddressIndex
instance, the descriptor index is incremented so that we can generate a new address every time we call this method. This is important for privacy reasons, since we don't want to reuse addresses.
Now from our ReceiveTab
we would like to call this generateInvoice
method when the button is pressed. Let's apply the same pattern as with the HomeScreen
and add a receive_controller.dart
and a receive_state.dart
file in the lib/features/receive
folder.
The ReceiveState
class will be used to hold the input field values, possible error and loading flags, and the generated invoice . It should look like this:
@immutable
class ReceiveState extends Equatable {
const ReceiveState({
this.amountSat,
this.isInvalidAmount = false,
this.label,
this.message,
this.bitcoinInvoice,
this.isGeneratingInvoice = false,
});
final int? amountSat;
final bool isInvalidAmount;
final String? label;
final String? message;
final String? bitcoinInvoice;
final bool isGeneratingInvoice;
double? get amountBtc {
if (amountSat == null) {
return null;
}
return amountSat! / 100000000;
}
String? get bip21Uri {
if (bitcoinInvoice == null) {
return null;
}
if (amountSat == null && label == null && message == null) {
return bitcoinInvoice;
}
return 'bitcoin:$bitcoinInvoice?'
'${amountBtc != null ? 'amount=$amountBtc' : ''}'
'${label != null ? '&label=$label' : ''}'
'${message != null ? '&message=$message' : ''}';
}
ReceiveState copyWith({
int? amountSat,
bool? isInvalidAmount,
String? label,
String? message,
String? bitcoinInvoice,
bool? isGeneratingInvoice,
}) {
return ReceiveState(
amountSat: amountSat ?? this.amountSat,
isInvalidAmount: isInvalidAmount ?? this.isInvalidAmount,
label: label ?? this.label,
message: message ?? this.message,
bitcoinInvoice: bitcoinInvoice ?? this.bitcoinInvoice,
isGeneratingInvoice: isGeneratingInvoice ?? this.isGeneratingInvoice,
);
}
@override
List<Object?> get props => [
amountSat,
isInvalidAmount,
label,
message,
bitcoinInvoice,
isGeneratingInvoice,
];
}
As you can see, the bip21Uri
and the amountBtc
can be derived from the other fields, so getters are used for them instead of adding them as extra fields, saving us the need to set and update them separately.
The ReceiveController
class will be used to handle and validate the input field changes and update the state accordingly. Also the button press to generate the invoice should be handled here and the generateInvoice
method of the BitcoinWalletService
should be called, so it should be possible to pass the BitcoinWalletService
as a dependency. It should look like this:
class ReceiveController {
final ReceiveState Function() _getState;
final Function(ReceiveState state) _updateState;
final WalletService _bitcoinWalletService;
ReceiveController({
required getState,
required updateState,
required bitcoinWalletService,
}) : _getState = getState,
_updateState = updateState,
_bitcoinWalletService = bitcoinWalletService;
void amountChangeHandler(String? amount) async {
try {
if (amount == null || amount.isEmpty) {
_updateState(
_getState().copyWith(amountSat: 0, isInvalidAmount: false));
} else {
final amountBtc = double.parse(amount);
final int amountSat = (amountBtc * 100000000).round();
_updateState(
_getState().copyWith(amountSat: amountSat, isInvalidAmount: false));
}
} catch (e) {
print(e);
_updateState(_getState().copyWith(isInvalidAmount: true));
}
}
void labelChangeHandler(String? label) async {
if (label == null || label.isEmpty) {
_updateState(_getState().copyWith(label: ''));
} else {
_updateState(_getState().copyWith(label: label));
}
}
void messageChangeHandler(String? message) async {
if (message == null || message.isEmpty) {
_updateState(_getState().copyWith(message: ''));
} else {
_updateState(_getState().copyWith(message: message));
}
}
Future<void> generateInvoice() async {
try {
_updateState(_getState().copyWith(isGeneratingInvoice: true));
final invoice = await _bitcoinWalletService.generateInvoice();
_updateState(_getState().copyWith(bitcoinInvoice: invoice));
} catch (e) {
print(e);
} finally {
_updateState(_getState().copyWith(isGeneratingInvoice: false));
}
}
void editInvoice() {
_updateState(const ReceiveState());
}
}
Now let's connect the logic with the UI again. To add the state and controller to the ReceiveTab
widget, we will need to change it from a StatelessWidget
to a StatefulWidget
widget and initialize the state and the controller just like we did in the HomeScreen
widget.
// ... in `ReceiveTab` class
class ReceiveTab extends StatefulWidget {
const ReceiveTab({required this.bitcoinWalletService, super.key});
final WalletService bitcoinWalletService;
@override
ReceiveTabState createState() => ReceiveTabState();
}
class ReceiveTabState extends State<ReceiveTab> {
ReceiveState _state = const ReceiveState();
late ReceiveController _controller;
@override
void initState() {
super.initState();
_controller = ReceiveController(
getState: () => _state,
updateState: (ReceiveState state) => setState(() => _state = state),
bitcoinWalletService: widget.bitcoinWalletService,
);
}
@override
Widget build(BuildContext context) {
// ... rest of widget
Now for the build
method we can also take the isGeneratingInvoice
flag into account to show a loading indicator while the invoice is being generated and we can also check the availability of the bitcoinInvoice
or bip21Uri
to show a QR code and a text field to scan and copy the invoice. To do this, let's extract the input fields into a new widget called ReceiveTabInputFields
and create a new widget called ReceiveTabInvoice
to show the QR code and the text field. Using the state and controller, the build
method of the ReceiveTab
widget will now look like this:
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_state.isGeneratingInvoice
? const CircularProgressIndicator()
: _state.bip21Uri == null || _state.bip21Uri!.isEmpty
? ReceiveTabInputFields(
canGenerateInvoice:
(widget.bitcoinWalletService as BitcoinWalletService)
.hasWallet,
amountChangeHandler: _controller.amountChangeHandler,
labelChangeHandler: _controller.labelChangeHandler,
messageChangeHandler: _controller.messageChangeHandler,
isInvalidAmount: _state.isInvalidAmount,
generateInvoiceHandler: _controller.generateInvoice,
)
: ReceiveTabInvoice(
bip21Uri: _state.bip21Uri!,
editInvoiceHandler: _controller.editInvoice,
),
],
);
}
The ReceiveTabInputFields
widget will look like this:
class ReceiveTabInputFields extends StatelessWidget {
const ReceiveTabInputFields({
Key? key,
required this.canGenerateInvoice,
required this.amountChangeHandler,
required this.labelChangeHandler,
required this.messageChangeHandler,
required this.isInvalidAmount,
required this.generateInvoiceHandler,
}) : super(key: key);
final bool canGenerateInvoice;
final Function(String?) amountChangeHandler;
final Function(String?) labelChangeHandler;
final Function(String?) messageChangeHandler;
final bool isInvalidAmount;
final Future<void> Function() generateInvoiceHandler;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: kSpacingUnit * 2),
// Amount Field
SizedBox(
width: 250,
child: TextField(
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Amount (optional)',
hintText: '0',
helperText: 'The amount you want to receive in BTC.',
),
onChanged: amountChangeHandler,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Label Field
SizedBox(
width: 250,
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Label (optional)',
hintText: 'Alice',
helperText: 'A name the payer knows you by.',
),
onChanged: labelChangeHandler,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Message Field
SizedBox(
width: 250,
child: TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Message (optional)',
hintText: 'Payback for dinner.',
helperText: 'A note to the payer.',
),
onChanged: messageChangeHandler,
),
),
const SizedBox(height: kSpacingUnit * 2),
// Error message
SizedBox(
height: kSpacingUnit * 2,
child: Text(
!canGenerateInvoice
? 'You need to create a wallet first.'
: isInvalidAmount
? 'Please enter a valid amount.'
: '',
style: const TextStyle(
color: Colors.red,
),
),
),
const SizedBox(height: kSpacingUnit * 2),
// Generate invoice Button
ElevatedButton.icon(
onPressed: !canGenerateInvoice || isInvalidAmount
? null
: () async {
await generateInvoiceHandler();
},
label: const Text('Generate invoice'),
icon: const Icon(Icons.qr_code),
),
],
);
}
}
Let's create the ReceiveTabInvoice
widget that will be shown only when an invoice is generated. To show a QR code, we use the package qr_flutter
, run flutter pub add qr_flutter
to add it to the project. Under the QR and the bip21Uri
as text, we add two buttons, one to go back to edit the input fields of the invoice again and one to copy the bip21Uri
to the clipboard. The ReceiveTabInvoice
widget will look like this:
class ReceiveTabInvoice extends StatelessWidget {
const ReceiveTabInvoice({
super.key,
required this.bip21Uri,
required this.editInvoiceHandler,
});
final String bip21Uri;
final Function() editInvoiceHandler;
@override
Widget build(BuildContext context) {
return Column(
children: [
// QR Code
QrImageView(
data: bip21Uri,
),
const SizedBox(height: kSpacingUnit * 2),
// Invoice
Text(bip21Uri),
const SizedBox(height: kSpacingUnit * 2),
// Button Row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Edit Button
ElevatedButton.icon(
onPressed: editInvoiceHandler,
label: const Text('Edit'),
icon: const Icon(Icons.edit),
),
// Copy Button
ElevatedButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: bip21Uri)).then(
(_) {
// Optionally, show a confirmation message to the user.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invoice copied to clipboard!'),
),
);
},
);
},
label: const Text('Copy'),
icon: const Icon(Icons.copy),
),
],
),
],
);
}
}
As you will notice, the edit button is more of a clearing of the invoice than really being able to edit the existing values, since we are updating the state with an empty ReceiveState
instance. To be able to keep the input fields values when the user goes back to edit the invoice, we will need to use the TextEditingController
class to control the text in the input fields, and in the edit button handler, we should only clear the bitcoinInvoice
field of the state. But this is something we will not do in the workshop, since it is not the main focus of the workshop and it is not that important for the UX. You can do it yourself as an exercise if you want.
The only thing left now is passing down the WalletService
to the ReceiveTab
widget. First to the WalletActionsBottomSheet
in the HomeScreen
and then to the ReceiveTab
widget in the WalletActionsBottomSheet
:
// ... in the `HomeScreen` widget
floatingActionButton: FloatingActionButton(
onPressed: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => WalletActionsBottomSheet(
bitcoinWalletService: widget.bitcoinWalletService, // Add this
),
),
child: SvgPicture.asset(
'assets/icons/in_out_arrows.svg',
),
),
// ... rest of widget
In the WalletActionsBottomSheet
:
class WalletActionsBottomSheet extends StatelessWidget {
const WalletActionsBottomSheet({
required WalletService bitcoinWalletService, // Add this
Key? key,
}) : _bitcoinWalletService = bitcoinWalletService, // Add this
super(key: key);
final WalletService _bitcoinWalletService; // Add this
static const List<Tab> actionTabs = <Tab>[
Tab(
icon: Icon(Icons.arrow_downward),
text: 'Receive funds',
),
Tab(
icon: Icon(Icons.arrow_upward),
text: 'Send funds',
),
];
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: actionTabs.length,
child: Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
actions: const [
CloseButton(),
],
bottom: const TabBar(
tabs: actionTabs,
),
),
resizeToAvoidBottomInset: false,
body: Padding(
padding: const EdgeInsets.all(kSpacingUnit * 4),
child: TabBarView(
children: [
ReceiveTab(
bitcoinWalletService: _bitcoinWalletService, // Add this
),
const SendTab(),
],
),
),
),
);
}
}
If everything went well, you should be able to generate Bitcoin Testnet invoices and see the QR code and the text field with the invoice, like this when not passing any bip21 parameters:
And like this when passing some bip21 parameters:
Now that we can receive funds to our wallet, we should see the balance change when we send funds to the generated invoice. We could do this on Testnet as we have configured as the network in our wallet, but this would require us to get some Testnet coins first, which are not always easy to get and not unlimited to do as many tests as we want. Testing with real Bitcoin on Mainnet is also not a good idea, since we could lose our funds if we make a mistake and since we would be spending real money on fees innecesarily. Therefore, we will use a local Bitcoin node running in regtest mode to test our wallet. This way we can generate as many coins as we want and we can also control the block generation to be able to mine blocks on demand and see the transactions confirmed in the blockchain. We will use two tools for this, the first one is Polar to spin up a local Bitcoin node in Regtest mode, and the second one is Esplora, a blockchain index engine to be able to query the blockchain data from our local node.
If you haven't done so yet, download and install Polar from the official website or [github] (https://github.com/jamaljsr/polar) following the instructions for your operating system.
This includes installing Docker Desktop for MacOS and Windows or Docker Server for Linux users, since Polar spins up de local regtest node in a Docker container.
Once installed, open Polar and click on the Create a Lightning Network
button.
Since we are not integrating a Lightning node in our app yet, we will not use the Lightning node functionality of Polar now, so set all Lightning nodes (LND, Core Lightning and Eclair) to 0, and just leave the Bitcoin Core node at 1 before clicking on Create Network
:
Now click the Start
button and wait till the network is Started
up. This should look something like this:
We now have a local Bitcoin node running in regtest mode.
To be able to connect to the Bitcoin node from the BDK library, an Electrum Server needs to run alongside the node, since the Polar node itself only exposes the Bitcoin Core RPC interface and not an Electrum RPC interface. To do this, we will use the Esplora Server implementation of mempool, named electrs.
To set it up, just clone the repo in a location of your preference with the following command, enter in the cloned folder and checkout the mempool
branch:
git clone https://github.com/mempool/electrs && cd electrs
git checkout mempool
Assuming the Rust toolchain is installed as required in the prerequisites, we can now run the server with the following command specifying the path to the .bitcoin
directory of your Bitcoin Core node in Polar and specify the network, which is regtest:
cargo run --release --bin electrs -- -vvvv --daemon-dir ~/.polar/networks/1/volumes/bitcoind/backend1/ --network=regtest
If you already created more networks in Polar, the 1 in the path of the `--daemon-dir`` parameter might be different. To find the correct path, check out the Mounts in Docker for the Bitcoin Core (bitcoind) container. It should be the one mounted to the internal /home/bitcoin/.bitcoin path.
If it is the first time you run it, first some dependencies will be downloaded and installed, but then the server should start and you should see something like this:
DEBUG - Server listening on 127.0.0.1:24224
DEBUG - Running accept thread
...
INFO - Electrum RPC server running on 127.0.0.1:60401
INFO - REST server running on 127.0.0.1:3002
The HTTP REST server is what we need to connect to from the app. Check the port it is running on, in most cases it will be on port 3002, since it is the default port.
We use this port to connect to the server from our app. In the BitcoinWalletService
we will change the configurations of the Blockchain
instance and change the network to Network.Regtest
wherever we used Network.Testnet
before.
In production code, it would be better to extract these node configurations to a separate file and load them from there, so that we can easily switch between networks and nodes without having to change the code. But for now, we will just hardcode them in the BitcoinWalletService
class. The Blockchain
instance should now be initialized like this:
Future<void> _initBlockchain() async {
_blockchain = await Blockchain.create(
config: const BlockchainConfig.esplora(
config: EsploraConfig(
baseUrl: "http://10.0.2.2:3002",
stopGap: 10,
),
),
);
}
And the Wallet
instance and its descriptors should now be initialized with the Network.Regtest
network:
Future<void> _initWallet(Mnemonic mnemonic) async {
final descriptors = await _getBip84TemplateDescriptors(mnemonic);
_wallet = await Wallet.create(
descriptor: descriptors.$1,
changeDescriptor: descriptors.$2,
network: Network.Regtest, // Changed here
databaseConfig: const DatabaseConfig
.memory(),
);
}
Future<(Descriptor receive, Descriptor change)> _getBip84TemplateDescriptors(
Mnemonic mnemonic,
) async {
const network = Network.Regtest; // Changed here
final secretKey =
await DescriptorSecretKey.create(network: network, mnemonic: mnemonic);
final receivingDescriptor = await Descriptor.newBip84(
secretKey: secretKey,
network: network,
keychain: KeychainKind.External);
final changeDescriptor = await Descriptor.newBip84(
secretKey: secretKey,
network: network,
keychain: KeychainKind.Internal);
return (receivingDescriptor, changeDescriptor);
}
Now run the app, add a wallet if you don't have one yet and generate an invoice (receive address).
Then go to the Polar app and in the Actions
tab of the Bitcoin node, click on the mine
button to mine some blocks and get some coins. Make sure you mine at least 100 blocks to have some coins that are mature and can be spent. Then click on the button underneath saying Send coins
:
Now you can paste the Bitcoin invoice/address you generated in the app to send some coins to it. Make sure you have the box with Automatically mine 6 blocks to confirm the transaction
checked and click on the Send
button:
We haven't implemented any streams to listen to incoming transactions yet, neither do we have a way to refresh the balance yet, we will implement the refresh action in the next step. For now, just restart the app and your wallet should now show the balance of the coins you sent to it:
To be able to refresh the balance, we will add a new method to the HomeController
class, which will make use of the sync
method of the BitcoinWalletService
class to obtain the latest data from the blockchain:
// Add to `HomeController` class
Future<void> refresh() async {
try {
final state = _getState();
if (state.walletBalance == null) {
// No wallet to refresh
return;
}
await (_bitcoinWalletService as BitcoinWalletService).sync();
final balance = await _bitcoinWalletService.getSpendableBalanceSat();
_updateState(
state.copyWith(
walletBalance: WalletBalanceViewModel(
walletName: state.walletBalance!.walletName,
balanceSat: balance,
),
),
);
} catch (e) {
print(e);
// ToDo: handle and set error state
}
}
Flutter already has a RefreshIndicator
widget that allows to call a refresh method when the user pulls down the screen. We will use this to call the refresh
method of the HomeController
when the user pulls down the HomeScreen
. To do this, we will wrap the body
of the HomeScreen
with the RefreshIndicator
widget and call the refresh
method of the HomeController
in the onRefresh
callback:
// ... In `HomeScreen` widget
body: RefreshIndicator(
onRefresh: () async {
await _controller.refresh();
},
child: ListView(
// ... rest of widget
),
),
That's it, now you should be able to pull down the HomeScreen
to refresh the balance.
Try it out by sending some more coins to your wallet and then pull down the HomeScreen
to refresh the balance.
Now that we have some receiving transactions, we can use them to make the transaction history have real and dynamic data. For this we will add a new method to the BitcoinWalletService
class getTransactions
to obtain the transaction history:
abstract class WalletService {
Future<void> addWallet();
Future<void> deleteWallet();
Future<int> getSpendableBalanceSat();
Future<String> generateInvoice();
Future<List<TransactionEntity>> getTransactions(); // Add this
}
As you see we want it to return a List of transactions as TransactionEntity
instances. We will create a new TransactionEntity
class to hold the transaction data as we receive it from the blockchain. Create a folder called entities
in the lib
folder and create a new file called transaction_entity.dart
in it.
We will not keep track of all fields or data of a transaction for now, but just the most basic fields like: transaction id, the amount of received utxo's, the amount of sent utxo's and the confirmation time. We will structure this data as follow:
@immutable
class TransactionEntity extends Equatable {
final String id;
final int receivedAmountSat;
final int sentAmountSat;
final int? timestamp;
const TransactionEntity({
required this.id,
this.receivedAmountSat = 0,
this.sentAmountSat = 0,
required this.timestamp,
});
@override
List<Object?> get props => [
id,
receivedAmountSat,
sentAmountSat,
timestamp,
];
}
Since we will use this TransactionEntity
in the future for other types of transactions, for example to get the history of Lightning payments, we put the default value for receivedAmountSat
and sentAmountSat
to 0, since in Lightning payments there is no concept of received and sent utxo's in a single transaction, but just of received or sent amounts.
Now we can add a concrete implementation of the getTransactions
method to the BitcoinWalletService
class. We will use the listTransactions
method of our BDK Wallet
instance to obtain the transactions and map them to our own TransactionEntity
instances to not depend too much on the BDK library in the rest of the app:
@override
Future<List<TransactionEntity>> getTransactions() async {
final transactions = await _wallet!.listTransactions(true);
return transactions.map((tx) {
return TransactionEntity(
id: tx.txid,
receivedAmountSat: tx.received,
sentAmountSat: tx.sent,
timestamp: tx.confirmationTime?.timestamp,
);
}).toList();
}
With that we can fetch the transaction history we need. Now we need to show that data in the UI. We previously created a widget for an item in the transaction list already, but it uses hardcoded mock data. Let's add a new view model to represent the data of a transaction that we want to show in this widget. Taking a look at the widget again, we need to show the following data dynamically for each transaction in the list:
- An arrow icon, pointing up or down, depending on if the user received or sent out more funds in the transaction.
- A title, also depending on if the user received more funds or sent out more funds in the transaction.
- A subtitle with the date and time of the transaction.
- The transaction amount positive or negative, depending on if the user received or sent out more funds in the transaction. It should be denominated in BTC for now.
Both the arrow icon and the title can be derived from the amount, since the direction of the transaction can be derived from it. So we only need one field for the amount in the view model instead of a separate one for the icon and title. We also do not need a separate field for the direction of the transaction, but we can add getter functions isIncoming
and isOutgoing
to the view model to derive this from the amount.
The view model should have an identifier of the transaction, this can be the same transaction id as the entity. Also the timestamp can be obtained from the timestamp of the entities we receive from the blockchain. The amount can be derived from the receivedAmountSat
and sentAmountSat
fields of the entity and we can add a getter again to convert it from sats to BTC. We can add a constructor to create the view model from a TransactionEntity
instance, so we can easily map the entities to the view models.
The timestamp should be shown in a human readable format, so we can add another getter function to the view model to format the timestamp to a string.
Taking all of the above into account, create a file transactions_list_item_view_model.dart
in the view_models
folder and add the class TransactionsListItemViewModel
with its fields and methods like this:
import 'package:mobile_dev_workshops/entities/transaction_entity.dart';
import 'package:equatable/equatable.dart';
class TransactionsListItemViewModel extends Equatable {
final String id;
final int amountSat;
final int? timestamp;
const TransactionsListItemViewModel({
required this.id,
required this.amountSat,
this.timestamp,
});
TransactionsListItemViewModel.fromTransactionEntity(TransactionEntity entity)
: id = entity.id,
amountSat = entity.receivedAmountSat - entity.sentAmountSat,
timestamp = entity.timestamp!;
bool get isIncoming => amountSat > 0;
bool get isOutgoing => amountSat < 0;
double get amountBtc => amountSat / 100000000;
String? get formattedTimestamp {
if (timestamp == null) {
return null;
}
final date = DateTime.fromMillisecondsSinceEpoch(timestamp! * 1000);
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute}';
}
@override
List<Object?> get props => [id, amountSat, timestamp];
}
We can now change the hardcoded data in the widget to use data of a view model instead. Add a field and parameter to TransactionsListItem
widget and use it to show the data in the UI:
class TransactionsListItem extends StatelessWidget {
const TransactionsListItem({super.key, required this.transaction}); // Add the transaction parameter
final TransactionsListItemViewModel transaction; // Add this
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
leading: CircleAvatar(
child: Icon(
transaction.isIncoming ? Icons.arrow_downward : Icons.arrow_upward, // Use the view model data
),
),
title: Text(
transaction.isIncoming ? 'Received funds' : 'Sent funds', // Use the view model data
style: theme.textTheme.titleMedium,
),
subtitle: Text(
transaction.formattedTimestamp ?? 'Pending', // Use the view model data or 'Pending' in case of no timestamp
style: theme.textTheme.bodySmall,
),
trailing: Text(
'${transaction.isIncoming ? '+' : ''}${transaction.amountBtc} BTC', // Use the view model data
style: theme.textTheme.bodyMedium),
);
}
}
Now the TransactionList
widget should also be updated to have a list of TransactionsListItemViewModel
instances as a parameter and use them to build the list of the transactions dynamically. The itemCount
of the list should be the length of the list of view models and the itemBuilder
should take the view model at the current index to build the TransactionsListItem
widget:
class TransactionsList extends StatelessWidget {
const TransactionsList({super.key, required this.transactions}); // Add the transactions parameter
final List<TransactionsListItemViewModel> transactions; // Add this
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Transactions',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
ListView.builder(
shrinkWrap:
true,
physics:
const NeverScrollableScrollPhysics(),
itemBuilder: (ctx, index) {
return TransactionsListItem(
transaction: transactions[index], // Get the view model at the current index
);
},
itemCount: transactions.length, // Use the length of the list of view models
),
],
);
}
}
Now the HomeScreen
widget should be able to obtain the transactions from the BitcoinWalletService
to pass it to the TransactionsList
widget. This is where our state and controller comes in again to connect the data and logic with the UI. The controller updates the state with the data from the service and the UI consumes the data from the state.
First, let's add a field to the HomeState
class to hold the fetched list of transactions:
@immutable
class HomeState extends Equatable {
const HomeState({
this.walletBalance,
this.transactions = const [], // Add this
});
final WalletBalanceViewModel? walletBalance;
final List<TransactionsListItemViewModel> transactions; // Add this
HomeState copyWith({
WalletBalanceViewModel? walletBalance,
bool clearWalletBalance = false,
List<TransactionsListItemViewModel>? transactions, // Add this
}) {
return HomeState(
walletBalance:
clearWalletBalance ? null : walletBalance ?? this.walletBalance,
transactions: transactions ?? this.transactions, // Add this
);
}
@override
List<Object?> get props => [walletBalance, transactions]; // Add transactions to the props
}
In the HomeController
class, create a private method _getTransactions
to fetch the transactions from the BitcoinWalletService
, map them to the view models and sort them by timestamp in descending order as needed for the UI as BDK does not guarantee the order of the transactions in the list it returns:
Future<List<TransactionsListItemViewModel>> _getTransactions() async {
// Get transaction entities from the wallet
final transactionEntities = await _bitcoinWalletService.getTransactions();
// Map transaction entities to view models
final transactions = transactionEntities
.map((entity) =>
TransactionsListItemViewModel.fromTransactionEntity(entity))
.toList();
// Sort transactions by timestamp in descending order
transactions.sort((t1, t2) {
if (t1.timestamp == null && t2.timestamp == null) {
return 0;
}
if (t1.timestamp == null) {
return -1;
}
if (t2.timestamp == null) {
return 1;
}
return t2.timestamp!.compareTo(t1.timestamp!);
});
return transactions;
}
As with the balance of the wallet, the transaction history should be fetched when the home screen is initialized and when the user pulls down the screen to refresh the data.
So in the init
and refresh
methods of the HomeController
we can call our recently created _getTransactions
function to add the transactions list to the state:
// ... in `HomeController` class
Future<void> init() async {
if ((_bitcoinWalletService as BitcoinWalletService).hasWallet) {
_updateState(
_getState().copyWith(
walletBalance: WalletBalanceViewModel(
walletName: walletName,
balanceSat: await _bitcoinWalletService.getSpendableBalanceSat(),
),
transactions: _getTransactions(), // Add this
),
);
} else {
_updateState(_getState().copyWith(
clearWalletBalance: true,
transactions: [], // Add this
));
}
}
// ... rest of class
Future<void> refresh() async {
try {
final state = _getState();
if (state.walletBalance == null) {
return;
}
await (_bitcoinWalletService as BitcoinWalletService).sync();
final balance = await _bitcoinWalletService.getSpendableBalanceSat();
_updateState(
state.copyWith(
walletBalance: WalletBalanceViewModel(
walletName: state.walletBalance!.walletName,
balanceSat: balance,
),
transactions: await _getTransactions(), // Add this
),
);
} catch (e) {
print(e);
}
}
// ... rest of class
You can observe that we map the entities to the view models in the init
and refresh
methods and update the state with the list of view models. This is because we want to keep the entities as close to the data we receive from the blockchain as possible and only convert it to view models when we need to show it in the UI. This way we can easily change the view model without having to change the entities and the other way around. Our constructor fromTransactionEntity
in our TransactionsListItemViewModel
comes in handy here to easily map the entities to the view models.
With this in place, our transaction history is ready in the state to be consumed by the UI, only thing left is updating the TransactionsList
in the HomeScreen
:
// ... in `HomeScreen` widget
TransactionsList(
transactions: _state.transactions, // Add this
),
// ... rest of widget
Now you should be able to see the real transaction history in the HomeScreen
when you run the app and have some transactions in your wallet:
You can now test the app by receiving some more coins and then refresh or restart the app to see the new transactions appear in the list.
In a production app, you could set up some streams or other way to refresh automatically when a new transaction was send or arrived. You would also want to add a way to load more transactions when the user scrolls to the bottom of the list, since the list of transactions can be very long and we don't want to load all of them at once. Also while the transactions and balance fetching is in process, you could add a loading indicator. To keep our focus on the Bitcoin functionalities and the BDK library, these are all things we will not do in this workshop, but you can do it yourself as an exercise if you want.
For now we will continue with our final part of this first workshop and finish our on-chain Bitcoin wallet by adding the ability to send funds.
Now that we are able to receive funds and show our transactions, we will add the ability to send funds to another address ourselves.
Let's start by working from the BitcoinWalletService
back to the UI again. We will add a new method to the WalletService
interface to send funds. To keep our WalletService
interface generic for other transactions later, let's name the new function pay
and let it take an invoice
parameter of type String
. This invoice can later be parsed as a normal Bitcoin address or BIP21 URI, as a Lightning Invoice, as an LNURL etc. For now we will only implement paying to a regular Bitcoin address.
abstract class WalletService {
Future<void> addWallet();
Future<void> deleteWallet();
Future<int> getSpendableBalanceSat();
Future<String> generateInvoice();
Future<List<TransactionEntity>> getTransactions();
Future<String> pay(
String invoice, {
int? amountSat,
double? satPerVbyte,
int? absoluteFeeSat,
}); // Add this pay method
}
The BDK library offers a TxBuilder
that is quite powerful because of the flexibility to build all sort of different Bitcoin transactions. We will use this to build the transaction, then we will use our wallet to sign it and broadcast it to the blockchain. Now let's use all of that to add a concrete implementation of the pay
method to the BitcoinWalletService
class:
@override
Future<String> pay(
String invoice, {
int? amountSat,
double? satPerVbyte,
int? absoluteFeeSat,
}) async {
if (amountSat == null) {
throw Exception('Amount is required for a bitcoin on-chain transaction!');
}
// Convert the invoice to an address
final address = await Address.create(address: invoice);
final script = await address
.scriptPubKey(); // Creates the output scripts so that the wallet that generated the address can spend the funds
var txBuilder = TxBuilder().addRecipient(script, amountSat);
// Set the fee rate for the transaction
if (satPerVbyte != null) {
txBuilder = txBuilder.feeRate(satPerVbyte);
} else if (absoluteFeeSat != null) {
txBuilder = txBuilder.feeAbsolute(absoluteFeeSat);
}
final txBuilderResult = await txBuilder.finish(_wallet!);
final sbt = await _wallet!.sign(psbt: txBuilderResult.psbt);
final tx = await sbt.extractTx();
await _blockchain.broadcast(tx);
return tx.txid();
}
As you can see, we added some parameters to the pay
method to be able to set the fee rate for the transaction. The amountSat
parameter is required, since we need to know the amount to send. The satPerVbyte
parameter is optional and can be used to set the fee rate in satoshis per vbyte. The absoluteFeeSat
parameter is also optional and can be used to set the absolute fee in satoshis.
At the end we return the transaction id of the broadcasted transaction to be able to show it in the UI or to handle it in any other way.
Now to get the data from the send tab UI to the BitcoinWalletService
, we will also add a state and controller for the send feature, similar to what we did for the receive feature.
In the lib/features/wallet_actions/send
folder, add two new files: send_state.dart
and send_controller.dart
.
In the send_state.dart
file, add the SendState
class with fields for the inputs like the amount, the address and the fee rate, as well as some fields to show in the UI like possible errors and loading:
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
@immutable
class SendState extends Equatable {
const SendState({
this.amountSat,
this.invoice,
this.satPerVbyte,
this.isMakingPayment = false,
this.error,
this.txId,
});
final int? amountSat;
final String? invoice;
final double? satPerVbyte;
final bool isMakingPayment;
final Exception? error;
final String? txId;
double? get amountBtc {
if (amountSat == null) {
return null;
}
return amountSat! / 100000000;
}
SendState copyWith({
int? amountSat,
String? invoice,
double? satPerVbyte,
bool? isMakingPayment,
Exception? error,
bool? clearError,
String? txId,
}) {
return SendState(
amountSat: amountSat ?? this.amountSat,
invoice: invoice ?? this.invoice,
satPerVbyte: satPerVbyte ?? this.satPerVbyte,
isMakingPayment: isMakingPayment ?? this.isMakingPayment,
error: clearError == true ? null : error ?? this.error,
txId: txId ?? this.txId,
);
}
String? get partialTxId {
if (txId == null) {
return null;
}
return '${txId!.substring(0, 8)}...${txId!.substring(txId!.length - 8)}';
}
@override
List<Object?> get props => [
amountSat,
invoice,
satPerVbyte,
isMakingPayment,
error,
txId,
];
}
In the send_controller.dart
file, add the SendController
class. This controller, like our HomeController
and ReceiveController
, also needs access to the state, the SendState
in this case, and the BitcoinWalletService
to be able to send the payment. So also add the getState
, updateState
and the WalletService
fields and set them in the constructor.
Add the methods to handle the input changes, for now we will skip the fee slider and only implement the amount and address input fields. Also add a method to handle the send button press:
import 'package:mobile_dev_workshops/features/wallet_actions/send/send_state.dart';
import 'package:mobile_dev_workshops/services/wallets/wallet_service.dart';
class SendController {
final SendState Function() _getState;
final Function(SendState state) _updateState;
final WalletService _bitcoinWalletService;
SendController({
required getState,
required updateState,
required bitcoinWalletService,
}) : _getState = getState,
_updateState = updateState,
_bitcoinWalletService = bitcoinWalletService;
void amountChangeHandler(String? amount) async {
final state = _getState();
try {
if (amount == null || amount.isEmpty) {
_updateState(state.copyWith(amountSat: 0, clearError: true));
} else {
final amountBtc = double.parse(amount);
final int amountSat = (amountBtc * 100000000).round();
if (amountSat > await _bitcoinWalletService.getSpendableBalanceSat()) {
_updateState(state.copyWith(
error: NotEnoughFundsException(),
));
} else {
_updateState(state.copyWith(amountSat: amountSat, clearError: true));
}
}
} catch (e) {
print(e);
_updateState(state.copyWith(
error: InvalidAmountException(),
));
}
}
void invoiceChangeHandler(String? invoice) async {
if (invoice == null || invoice.isEmpty) {
_updateState(_getState().copyWith(invoice: ''));
} else {
_updateState(_getState().copyWith(invoice: invoice));
}
}
Future<void> makePayment() async {
final state = _getState();
try {
_updateState(state.copyWith(isMakingPayment: true));
final txId = await _bitcoinWalletService.pay(
state.invoice!,
amountSat: state.amountSat,
satPerVbyte: state.satPerVbyte,
);
_updateState(state.copyWith(
isMakingPayment: false,
txId: txId,
));
} catch (e) {
print(e);
_updateState(state.copyWith(
isMakingPayment: false,
error: PaymentException(),
));
}
}
}
class InvalidAmountException implements Exception {}
class NotEnoughFundsException implements Exception {}
class PaymentException implements Exception {}
Now we can use this in our SendTab
UI. However, like with the previous screens, we have to change the SendTab
to a StatefulWidget
to be able to use the state and controller:
// ... in `send_tab.dart` change the `SendTab` class to a `StatefulWidget`
class SendTab extends StatefulWidget {
const SendTab({super.key});
@override
SendTabState createState() => SendTabState();
}
class SendTabState extends State<SendTab> {
// ... rest of class
Now we can add the state, the controller, initializing it in the initState
method and the WalletService
to the SendTabState
class:
class SendTab extends StatefulWidget {
const SendTab({required this.bitcoinWalletService, super.key}); // Add the bitcoinWalletService parameter
final WalletService bitcoinWalletService; // Add this
@override
SendTabState createState() => SendTabState();
}
class SendTabState extends State<SendTab> {
SendState _state = const SendState(); // Add the state
late SendController _controller; // Add the controller
// Add the initState method to initialize the controller
@override
void initState() {
super.initState();
_controller = SendController(
getState: () => _state,
updateState: (SendState state) => setState(() => _state = state),
bitcoinWalletService: widget.bitcoinWalletService,
);
}
// ... rest of class
Now we can use the state and controller in the UI of the SendTab
. For the onChanged
property of the amount and invoice TextField
s we can set the amountChangeHandler
and invoiceChangeHandler
methods of the controller like this respectively: onChanged: _controller.amountChangeHandler,
and onChanged: _controller.invoiceChangeHandler,
.
For the onPressed
property of the send button we can call the makePayment
method of the controller. Taking into account that the button should only be active when an amount and invoice are entered, the amount is not higher than the spendable balance and there is no error, we can set the onPressed
property of the button like this:
onPressed: _state.amountSat == null ||
_state.amountSat == 0 ||
_state.invoice == null ||
_state.invoice!.isEmpty ||
_state.error is InvalidAmountException ||
_state.error is NotEnoughFundsException ||
_state.isMakingPayment
? null
: () => _controller.makePayment().then(
(_) {
if (_state.txId != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Payment successful. Tx ID: ${_state.partialTxId}'),
),
);
Navigator.pop(context);
}
},
),
We also disabled the button while the payment is in process by checking the isMakingPayment
field of the state. To make it even more clear, let's change the icon of the button to also show a loading indicator when the payment is in process:
icon: _state.isMakingPayment
? const CircularProgressIndicator()
: const Icon(Icons.send),
With the error field in the state, we can now also show the correct error message dynamically instead of always the same. Changes the condition in the Text
widget to show the error message to this:
_state.error is InvalidAmountException
? 'Please enter a valid amount.'
: _state.error is NotEnoughFundsException
? 'Not enough funds available.'
: _state.error is PaymentException
? 'Failed to make payment. Please try again.'
: '',
That should be it for the SendTab
UI for now. Only thing left is making sure it gets the WalletService
passed to it. Therefore just add it in the wallet_actions_bottom_sheet.dart
file where the SendTab
is used. Now both the ReceiveTab
and the SendTab
should have the WalletService
passed to them:
child: TabBarView(
children: [
ReceiveTab(
bitcoinWalletService: _bitcoinWalletService,
),
SendTab(
bitcoinWalletService: _bitcoinWalletService, // Add this
),
],
),
With no errors left, we can now make a payment. To do this, we need an address to send to. We can generate one on our simulated Bitcoin Core node in Polar. Bitcoin Core per default has wallet functionality. Polar doesn't expose all of this in the user interface, but it permits us to open a terminal by right clicking on the node and selecting Launch Terminal
:
In the terminal we can use the bitcoin-cli
command line interface to generate a new address. We can do this by running the following command:
bitcoin-cli getnewaddress
This should return us a new address like this:
Now copy the address and go back to our app. In the app, tap on the floating action button, select the Send
tab, enter an amount and paste the generated address. Now click on the button to send the payment. If you have some coins in your wallet, the payment should be broadcasted to the blockchain and you should see the transaction id printed to the console. When you refresh the app, you should see a pending transaction at the top of the list of transactions. If you mine some blocks in Polar, the transaction should be confirmed and the balance should be updated.
You may also notice that the amount is slightly more than the amount you entered. This is because of the fee that is added to the transaction. Since we didn't explicitly set the fee rate, the BDK library will calculate a minimum fee rate for us. In a production app, you would want to give the user the option to set the fee rate themselves, as different situations require different fee rates.
Even more, you could give the user the option to bump the fee of a pending transaction, to cancel a pending transaction or to replace a pending transaction with a new one. This is all possible with the BDK library and although we will not implement it in this workshop, let's explore the API of the TxBuilder a bit more to see how you could do this and some other interesting things if you'd like.
It can happen that you put a fee that is too low and your transaction is not getting confirmed. This can happen for a lot of reasons and be accidentally or you could have put the fee low on purpose to save on fees and because you were not in a hurry to have it confirmed. In any case, you can bump the fee of a pending transaction at the moment you do need it confirmed sooner by creating a new transaction that spends the same utxo's but with a higher fee.
The original transaction does have to be flagged as replaceable by fee to be able to do this. Otherwise the mempool will not accept the new transaction. So if you want to make a transaction replaceable by fee, you should set the RBF (Replace-By-Fee) flag in the transaction:
TxBuilder().enableRbf();
If you then later need to replace this transaction, you can do this by using the BumpFeeTxBuilder
class instead of the TxBuilder
to build a transaction that will spend the same utxo's as the one build with TxBuilder
originally. This method takes a Txid
as a parameter, which is the transaction id of the transaction you want to bump the fee of. It also takes a FeeRate
as a parameter, which is the new fee rate you want to use for the new transaction and should off course be higher than the fee rate of the original transaction:
BumpFeeTxBuilder(txid: <txId>, feeRate: <feeRate>);
The use of the BumpFeeTxBuilder
is very similar to the TxBuilder
and the finish
method of the BumpFeeTxBuilder
returns a TxBuilderResult
that can be used to sign and broadcast the new transaction with the Wallet
and Blockchain
instance respectively.
There is one thing to keep in mind when bumping the fee of a transaction and that is that the new transaction will have a different transaction id than the original transaction. This means that the new transaction will be a different transaction than the original and the original will not be confirmed. This is because the transaction id is a hash of the transaction data and the transaction data includes the fee. So if the fee changes, the transaction id changes.
Coin selection is the process of selecting which utxo's to spend in a transaction. How you select the utxo's can be based on different strategies and can be done manually or automatically.
For privacy reasons it is considered a good practice to consciously select the utxo's to spend in a transaction. This is because the utxo's you spend in a transaction can be linked to each other and to you. If you spend utxo's that are not linked to each other and to you, it is harder for an observer to link them to you. You could for example not use a certain utxo in a transaction because it is linked to a certain other utxo that you don't want the receiver to know is yours. Or you may not want to use a big utxo in a small transaction because you don't want the receiver to know you have such a big utxo.
There are many things to take into consideration. If your users are not privacy conscious, you could use the default coin selection strategy of the BDK library, which is the Branch and Bound algorithm. This algorithm selects the utxo's that minimize the amount of change and the number of utxo's used by looking for a combination of utxo's that gives the exact amount needed in the transaction. This is a good strategy for most users, but is focused more on reducing fees and “dust” (or, worthless coins), not on optimizing privacy.
For users that do care about privacy, BDK does offer us the flexibility to implement our own coin selection strategy or implement a way to let the user select the utxo's manually. This is a bit more advanced and we will not implement it in this workshop, but it is good to know that it is possible. There are different methods available for this in the TxBuilder
and Wallet
classes of the BDK library:
TxBuilder().addUtxo(outpoint); // Add a specific utxo to spend in the transaction
TxBuilder().addUtxos(outpoints); // Add a list of specific utxo's to spend in the transaction
TxBuilder().doNotSpendChange(); // Makes sure no change utxo's are spent in the transaction
TxBuilder().addUnSpendable(unSpendable); // Add a specific utxo to not spend in the transaction
TxBuilder().manuallySelectedOnly(); // Makes sure only manually selected utxo's are spent in the transaction
TxBuilder().onlySpendChange(); // Makes sure only change utxo's are spent in the transaction
_wallet.listUnspent(); // List all utxo's of the wallet (_wallet is a Wallet instance)
Manual coin selection generally goes hand in hand with coin labeling. Coin labeling is the process of labeling utxo's with metadata to be able to select them manually. This metadata can be anything you want, for example a label to know the provenance of a utxo, like for example "payment dinner from Alice", "withdraw from exchange X" etc. This can help people to remember which utxo's are linked to each other and to them and to select them manually in a transaction. Some Bitcoiners use this to maintain a KYC-free utxo set, which is a set of utxo's that are not linked to their identity. Labeling utxo's is something not supported by the BDK library, but it is possible to implement it yourself by using the Wallet
instance to list the utxo's and store the metadata in a database or file.
To get an idea of how the UX coin selection could be implemented, you can get inspired by the Bitcoin Design Guide's chapter on Coin Selection.
Another interesting thing to mention is the drain
method of the Wallet
class. This method is used to spend all utxo's of the wallet (minus the ones added to the unspendable list) in a single transaction. This can be useful for example when you want to move all funds to a new wallet or to a new address. It can also be useful to consolidate utxo's to reduce the number of utxo's and to reduce the amount of change utxo's. This can be useful to reduce fees and to reduce the amount of dust in the wallet.
TxBuilder().draiWallet();
The method drainTo
is a variant that will send the change utxo's of the transaction, in case you add utxo's that make the total amount exceed the amount to send to the recipient address, to a specific address instead of back to the wallet:
TxBuilder().drainTo(<script>);
We already have the code in place to set the fee rate for a transaction in the pay
method of the BitcoinWalletService
. We can set the fee rate in satoshis per vbyte or we can set the absolute fee in satoshis. But how do we know what the fee rate to set should be? How does our user now what fee to set? This is a very important question and a very difficult one to answer. The fee rate is a very dynamic thing and depends on a lot of factors. It is not only the size of the transaction that determines the fee rate, but also the demand for block space. The demand for block space can change from minute to minute and so it is difficult to predict. This is why it is very difficult to set the fee rate and why it is a good practice to let the user set the fee rate themselves.
Let's do a quick intermezzo about fee rates in Bitcoin to understand this better.
In Bitcoin, when making a transaction you are competing with other transactions to be included in a block. Bitcoin has a limited block size and miners try to maximize the profit they can make with the limited space they have in a block. So generally you will have to pay a higher absolute fee for a bigger transaction (for example a tx with more inputs or outputs) then for a smaller transaction both wanting to be confirmed at a certain instance. So with the fee you are actually paying for the space you are taking up in a block. That's why fee rates are expressed in satoshis per vbyte. Like this you can compare the fee rates of different transactions, even if they have different sizes and different absolute fees.
Before SegWit, the size of a transaction was the size of the transaction in bytes and a block could only contain one megabyte of transactions. This meant that the fee rate was calculated by dividing the fee in satoshis by the size of the transaction in real bytes.
With SegWit, vbytes got introduced as the measuring unit instead. A vbyte is a virtual byte and one byte in a legacy transaction is equivalent to 4 weight units in a SegWit transaction. This is because the witness data of a SegWit transaction is discounted in the fee calculation, since it is not stored in the blockchain, but kept by the nodes that validate the transactions. Implicitly increasing the size that all transactions in a block can have to 4 megabytes, without increasing the block size limit itself, hereby avoiding a hard fork.
Except for making SegWit transactions occupy less space in a block and thus be cheaper, it also solved a problem of transaction malleability and made it possible to implement the Lightning Network. Diving deeper into SegWit is out of scope for this workshop, but it is good to know that the fee rate is expressed in satoshis per vbyte and that the fee rate is calculated by dividing the fee in satoshis by the size of the transaction in vbytes.
Although different factors can influence the fee rate one needs or wants to set, we can use data from the mempool to estimate the fee rate. The mempool is the place on every node where all unconfirmed transactions are stored and so how much they are offering to pay in fees. The node our application is connected to through the BDK library also has its own copy of the mempool and can give us access to this data. This data can be used to estimate the fee rate we should set for our transaction to be confirmed in a certain amount of blocks. BDK exposes this data through the Blockchain
class and its estimateFee
method.
The estimateFee
method takes a Target
as a parameter, which is the amount of blocks you want to be confirmed in. Of course this is an estimation and not a guarantee, but it can give you an idea of what fee rate you should set.
We want to use this method to give the user an idea of what fee rate to set based on if his transaction is urgent or not. So let's create an entity class to hold different fee rates based on the priority of the transaction compared to other transactions in the mempool. In the lib/entities
folder, add a new file recommended_fee_rates_entity.dart
and add the RecommendedFeeRatesEntity
class:
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
@immutable
class RecommendedFeeRatesEntity extends Equatable {
final double highPriority;
final double mediumPriority;
final double lowPriority;
final double noPriority;
const RecommendedFeeRatesEntity({
required this.highPriority,
required this.mediumPriority,
required this.lowPriority,
required this.noPriority,
});
@override
List<Object> get props => [
highPriority,
mediumPriority,
lowPriority,
noPriority,
];
}
Now we can use BDK's estimateFee
method in our BitcoinWalletService
to estimate the fee rates for different priorities and return them as a RecommendedFeeRatesEntity
. Let's add a new method called calculateFeeRates
to the BitcoinWalletService
. We will not add this to the WalletService
interface, because this way of returning the fee rates is specific to the Bitcoin blockchain and not to the Lightning Network for example.
Future<RecommendedFeeRatesEntity> calculateFeeRates() async {
final [highPriority, mediumPriority, lowPriority, noPriority] =
await Future.wait(
[
_blockchain.estimateFee(1),
_blockchain.estimateFee(2),
_blockchain.estimateFee(3),
_blockchain.estimateFee(4),
],
);
return RecommendedFeeRatesEntity(
highPriority: highPriority.asSatPerVb(),
mediumPriority: mediumPriority.asSatPerVb(),
lowPriority: lowPriority.asSatPerVb(),
noPriority: noPriority.asSatPerVb(),
);
}
As the targets of blocks to get confirmed in we just took 1, 2, 3 and 4 blocks. This is just an example and you could use different targets based on your own criteria or based on the backend you are connected to. This latter is something important to mention, because the mempool of different nodes can have different data based on their mempool policies, and the way they calculate fees can also be different. So it is important to know which node you are connected to and to know how it calculates fees.
Since our local Polar RegTest node does not have pending transactions in a mempool, we will not be able to test this feature very well. So let's change the network and Bitcoin backend back to the Bitcoin mainnet where fee dynamics are the most interesting. Change the _initBlockchain
method in the BitcoinWalletService
to use Blockstream's Esplora API for the Bitcoin mainnet:
Future<void> _initBlockchain() async {
_blockchain = await Blockchain.create(
config: const BlockchainConfig.electrum(
config: ElectrumConfig(
retry: 5,
url: "ssl://electrum.blockstream.info:50002", // The only difference with the testnet backend is the port number, 50002 instead of 60002
validateDomain: false,
stopGap: 10,
),
),
);
}
Also change Network.Regtest
to Network.Bitcoin
in the other places where the network is used in the BitcoinWalletService
.
Since we use Blockstream's Esplora backend, let's check the documentation to know how its fee estimation API works: https://github.com/Blockstream/esplora/blob/master/API.md#fee-estimates.
As you can see, it also returns the fee rates for different target blocks, but with a limited set of targets:
The available confirmation targets are 1-25, 144, 504 and 1008 blocks.
Also, if we navigate to https://blockstream.info/api/fee-estimates
in our browser, we can see the fee rates for the different targets. Here are two observations you can check for yourself: Some block targets have the same fee rates and the proposed fee rates are different from the ones proposed by mempool.space
. This is important to notice, because it shows that different backends can have different fee rates and that the fee rates can be different for different targets. Using the fee rates of Blockstream's Esplora as-is, your users might overpay for the priority they want. This is something to take into account when building a production app and it is important to know that the fee rates are not a guarantee and that they are just an estimation, so giving the user the option to set the fee rate themselves is a good practice.
Too have the most chance on having different fee rates without complicating ourself too much with the calculations, lets change the block targets in the calculateFeeRates
method to 5, 144, 504 and 1008 blocks, using the available targets in the API response. This way we can see the difference in fee rates for different targets and we can use this to show the user the difference in fee rates for different priorities.
_blockchain.estimateFee(5),
_blockchain.estimateFee(144),
_blockchain.estimateFee(504),
_blockchain.estimateFee(1008),
Now we can use the calculateFeeRates
method to show different fee rates in the SendTab
UI. We can add a new method to the SendController
to call the calculateFeeRates
method and update the state with the fee rates. First we have to add a new field to the SendState
to hold the fee rates. To keep it simple, just add a list of doubles to the SendState
:
final List<double>? recommendedFeeRates;
Then we can add a new method to the SendController
to call the calculateFeeRates
method and update the state with the fee rates:
Future<void> fetchRecommendedFeeRates() async {
final state = _getState();
try {
final fetchedRates = await (_bitcoinWalletService as BitcoinWalletService)
.calculateFeeRates();
final recommendedFeeRates = {
fetchedRates.highPriority,
fetchedRates.mediumPriority,
fetchedRates.lowPriority,
fetchedRates.noPriority
}.toList();
_updateState(
state.copyWith(
recommendedFeeRates: recommendedFeeRates,
satPerVbyte: fetchedRates.mediumPriority,
),
);
} catch (e) {
print(e);
_updateState(state.copyWith(
error: FeeRecommendationNotAvailableException(),
));
}
}
In this method, we fetch the fees using the BitcoinWalletService
, create a set to eliminate duplicate values and convert it to a list to set the recommendedFeeRates
field of the state. We also set the satPerVbyte
field of the state to the fee rate for the medium priority. We also catch any errors and set the error field of the state to show the user that the fee recommendation is not available.
Now we can call this method in the initState
method of the SendTab
to fetch the fee rates when the SendTab
is opened:
@override
void initState() {
super.initState();
_controller = SendController(
getState: () => _state,
updateState: (SendState state) => setState(() => _state = state),
bitcoinWalletService: widget.bitcoinWalletService,
);
_controller.fetchRecommendedFeeRates(); // Add this
}
Now we can use the state to make the Slider
work and have the selected fee rate be shown:
// ... SendTab widget
// Fee rate slider
_state.recommendedFeeRates == null
? const CircularProgressIndicator()
: SizedBox(
width: 250,
child: Column(
children: [
Slider(
value: _state.satPerVbyte ?? 0,
onChanged: null,
divisions: _state.recommendedFeeRates!.length - 1,
min: _state.recommendedFeeRates!.last,
max: _state.recommendedFeeRates!.first,
label: _state.satPerVbyte! <=
_state.recommendedFeeRates!.last
? 'low priority'
: _state.satPerVbyte! >=
_state.recommendedFeeRates!.first
? 'high priority'
: 'medium priority',
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpacingUnit * 1.5,
),
child: Text(
'The fee rate to pay for this transaction: ${_state.satPerVbyte ?? 0} sat/vB.',
style: theme.textTheme.bodySmall,
),
),
],
),
),
// ... rest of SendTab widget
The Slider
will not work yet though, we still need the onChanged
property to be set to a method that updates the state with the selected fee rate. We can add a new method to the SendController
to do this and then add it to the onChanged
property of the Slider
:
// ... in the SendController class
void feeRateChangeHandler(double feeRate) {
_updateState(_getState().copyWith(satPerVbyte: feeRate));
}
// ... in the Slider of the SendTab widget
onChanged: _controller.feeRateChangeHandler,
Now the Slider
should work and the selected fee rate should be selected and shown like this:
The Slider experience might not be the best experience for a production app, but I hope you at least get the idea of how you could use the fee rates to show the user the difference in fee rates for different priorities and how you could let the user set the fee rate themselves.
If time permits during the workshop we will add a way to show the mnemonic recovery phrase of the wallet from the menu and a way to recover a wallet from a mnemonic recovery phrase instead of only being able to create a new wallet.