Skip to content

Quick start

This page gets libspiffy running in one Dart file: you start the actor system, create a wallet, create an invoice, query the balance, handle a failure and shut down. For a full payment between two wallets, follow the tutorial.

libspiffy.coordinator is a WalletCoordinator. It has three methods:

Method What it does
ask(request, {timeout}) Sends a command or query and returns a Future of its own reply.
tell(command) Sends without waiting. A reply, if there is one, arrives only on the event stream.
on<E>({walletId}) A Stream<E> of one kind of event, of one wallet when you name it. Use it for what happens without a request: a balance change, an invoice paid, a channel request from a peer.

Every command or query with an answer is a CoordinatorRequest<R>. It names its reply type R (for CreateWalletCommand, a WalletCreatedEvent), and its requestId is fixed when you create it: generated, unless you pass one. The coordinator answers each request with exactly one R carrying that requestId, so two requests of the same kind running at once each get their own reply.

// Sketch: the shape of every interaction.
final balance = await libspiffy.coordinator.ask(GetBalanceQuery(walletId: 'shop'));
print(balance.totalBalance);

This program runs against testnet settings with P2P off, so it needs no network to finish. It writes its event store to a fresh temporary directory, so you can run it again.

bin/quick_start.dart
import 'dart:io';
import 'package:libspiffy/coordinator.dart';
import 'package:libspiffy/libspiffy.dart';
Future<void> main() async {
final libspiffy = LibSpiffyActorSystem();
await libspiffy.initialize(
dataDirectory: Directory.systemTemp.createTempSync('quick_start_').path,
networkType: 'test',
arcConfig: ArcServiceConfig.taalTestnet(),
enableP2P: false,
);
final coordinator = libspiffy.coordinator;
// Events nobody asked for: a balance change, a failure no request caused.
final balances = coordinator
.on<BalanceUpdatedEvent>(walletId: 'shop')
.listen((e) => print('shop balance is now ${e.totalBalance} sats'));
final errors = coordinator
.on<ErrorEvent>()
.listen((e) => print('error from ${e.source}: ${e.message}'));
// 1. Create an HD wallet. The app supplies the mnemonic and must keep it.
final mnemonic = await libspiffy.cryptoService.generateMnemonic();
final wallet = await coordinator.ask(CreateWalletCommand(
walletId: 'shop',
name: 'Shop wallet',
mnemonic: mnemonic,
));
print('Root address: ${wallet.rootAddress}');
// 2. Create an invoice for 10,000 sats.
final invoice = await coordinator.ask(CreateInvoiceCommand(
walletId: 'shop',
amount: BigInt.from(10000),
description: 'Order 1001',
expiresIn: const Duration(hours: 1),
));
print('Invoice ${invoice.invoiceId}: pay ${invoice.amount} sats '
'to ${invoice.addresses.first}');
// 3. Read the balance.
final balance = await coordinator.ask(GetBalanceQuery(walletId: 'shop'));
print('Spendable: ${balance.totalBalance} sats '
'(${balance.confirmedBalance} confirmed, '
'${balance.unconfirmedBalance} unconfirmed)');
// 4. A request that fails throws CoordinatorFailure.
try {
await coordinator.ask(CreateWalletCommand(
walletId: 'shop', // already exists
name: 'Shop wallet',
mnemonic: mnemonic,
));
} on CoordinatorFailure catch (failure) {
print('Not created: ${failure.message}'); // "Wallet already exists"
}
// 5. Shut down: stops the actors and closes the stores libspiffy opened.
await balances.cancel();
await errors.cancel();
await libspiffy.shutdown();
}

A reply that ask returns is always a success: you do not check success on it. WalletCreatedEvent arrives once the read model holds the wallet and its rootAddress, and InvoiceCreatedEvent once it holds the invoice, so a query you send next sees them.

ask throws CoordinatorFailure when the request failed. It has three fields and one getter:

Member What it holds
requestId The failed request’s requestId.
message Why it failed.
event What reported the failure: the request’s own reply (for example a WalletCreatedEvent with success: false), or an ErrorEvent that names the request. Null when the coordinator stopped first.
closed True when the coordinator stopped (shutdown()) before it answered.
// Sketch: tell a refusal from a shutdown.
try {
await coordinator.ask(DeleteWalletCommand(walletId: 'old-wallet'));
} on CoordinatorFailure catch (failure) {
if (failure.closed) return; // libspiffy is shutting down
showError(failure.message); // showError is your own UI code
}

Failures that no request caused, such as a broadcast retried later or a channel step the counterparty started, arrive as an ErrorEvent with a null requestId. Follow them with coordinator.on<ErrorEvent>(), as the program above does. source names the operation that failed and walletId, when set, the wallet.

ask throws TimeoutException (from dart:async) when no reply arrives in time. Each request has its own default, its replyTimeout: one minute for most requests, more for those that wait on the network (CoordinatorRequest.paymentTimeout is 3 minutes, receiveTimeout 5, importTimeout an hour). Pass timeout: to choose your own.

// Sketch: wait at most 10 seconds for the balance.
try {
final balance = await coordinator.ask(
GetBalanceQuery(walletId: 'shop'),
timeout: const Duration(seconds: 10),
);
render(balance); // render is your own UI code
} on TimeoutException {
showStale(); // the request may still finish
}

on<E>() filters the coordinator’s event stream by type, and by walletId when you pass one. Replies are published there too, so on<InvoiceCreatedEvent>() sees every invoice created, whoever asked. libspiffy.coordinatorEvents is the whole stream, if you want every event in one listener.

// Sketch: what an app usually follows.
coordinator.on<InvoicePaidEvent>(walletId: 'shop').listen(markOrderPaid);
coordinator.on<BalanceUpdatedEvent>(walletId: 'shop').listen(showBalance);
coordinator.on<ErrorEvent>().listen(logError);

A reply type can also arrive with no request behind it, for example a payment replayed when its block header arrives. Its requestId is null then.

LibSpiffyActorSystem.initialize() takes only named parameters. The ones you use first:

Parameter Default What it does
networkType 'test' 'main', 'test' or 'regtest' ('mainnet', 'livenet' and 'testnet' are accepted too). Selects the genesis header, DNS seeds and address encoding.
arcConfig ArcServiceConfig.gorillaPoolMainnet() on mainnet, otherwise ArcServiceConfig.taalTestnet() The ARC or Arcade endpoint for broadcasts and fee rates. Presets: taalMainnet, taalTestnet, gorillaPoolMainnet, gorillaPoolTestnet, bsvaArcadeTestnet, or ArcServiceConfig.custom(baseUrl: ...).
enableP2P true Connect to BSV peers and sync block headers.
peerAddresses the network’s DNS seeds Peers as 'host:port'. Regtest has no seeds, so name your peers there.
userAgent '/LibSpiffy-BSV:1.0/' Sent in the version handshake. Teranode bans peers whose user agent lacks BSV or Bitcoin SV.
startHeight none The height reported to peers in the handshake. Header sync does not start from it; it continues from the stored chain’s tip.
isar none An Isar instance your app opened with LibSpiffySchemas.allSchemas. libspiffy stores events, read models, headers and the broadcast retry queue in it.
dataDirectory './data' when needed Used only when you pass no isar: libspiffy opens its own Isar event store here.
secureStorage InMemorySecureStorage() Where mnemonics, WIFs and xprivs live. The default loses them on restart.
storageBackend StorageBackend.isar isar, postgres (with postgresConfig) or inMemory.
blockchainDataSource none Where ImportWalletCommand fetches a wallet’s history, for example a WhatsOnChainDataSource. Without one, an import fails.
cdnBaseUrl none A header CDN to sync from before peers. See header sync via CDN.

The full list, including payment channel and deferred payment settings, is in the API docs.

initialize() returns after the actors start, the optional CDN header sync finishes, and the P2P layer has connected to at least one peer. On a first install with a header CDN that can take minutes.

You do not have to wait that long to send commands. libspiffy.ready is a Future that completes as soon as the actors take commands, before header sync; libspiffy.isReady tells you whether it has. Creating or importing a wallet needs no headers, and a received payment whose block header has not arrived waits for it.

// Sketch: start in the background, act as soon as the actors run.
final started = libspiffy.initialize(networkType: 'test', isar: isar);
await libspiffy.ready;
final wallet = await libspiffy.coordinator.ask(
CreateWalletCommand(walletId: 'shop', name: 'Shop', mnemonic: mnemonic));
await started; // header sync and P2P are up

If enableP2P is true and no peer can be reached, initialize() throws a StateError (“P2P initialization failed …”) after the actors have started. The system stays usable without P2P; catch the error if your app can live with that.

ask is what most code needs. If you would rather send and move on, tell the request and find its reply on the stream by requestId. Pass your own requestId, or read the generated one from the request:

// Sketch: fire and forget, then match the reply yourself.
final query = GetBalanceQuery(walletId: 'shop');
coordinator
.on<BalanceResponse>(walletId: 'shop')
.where((r) => r.requestId == query.requestId)
.first
.then((r) => print('balance ${r.totalBalance}'));
coordinator.tell(query);

With tell, a failure is on the reply itself: success and error on most replies, and CoordinatorReply.failure gives the reason for any reply type. An ErrorEvent that a request caused carries the request’s requestId. Subscribe before you tell, or the reply can pass before you listen.

await libspiffy.shutdown() disconnects from peers, stops the projections and actors, and closes what libspiffy opened. A request still waiting in ask then fails with a CoordinatorFailure whose closed is true. shutdown() does not shut down an actor system or close an Isar instance that your app passed in. A shut-down LibSpiffyActorSystem cannot be initialized again; create a new instance.

libspiffy 5.0.0 changes how you talk to the coordinator. The breaking changes an app usually hits:

  • libspiffy.coordinator is a WalletCoordinator, not an ActorRef. tell works as before. Change any variable or field typed ActorRef that holds it.
  • queryId is now requestId on GetBalanceQuery, GetTransactionsQuery, GetTransactionDetailQuery, ExportTransactionQuery, GetDeferredPaymentsQuery, GetHeaderSyncStatusQuery and their replies. On a reply, requestId is nullable.
  • Replace your own “send, then wait for the matching event” helpers with ask. Each request now gets exactly one reply carrying its requestId, and an ErrorEvent it causes carries it too.
  • Some commands have new replies. DeleteWalletCommand is answered with WalletDeletedEvent, ReleaseUTXOsCommand with UTXOsReleasedEvent, AcceptChannelCommand with ChannelAcceptedEvent, RejectChannelCommand with ChannelRejectedEvent and ExpireChannelCommand with ChannelExpiredEvent. A refusal of DeleteWalletCommand, ReleaseUTXOsCommand or RecordOutgoingCommand is the reply’s failure, no longer an ErrorEvent.
  • ImportWalletCommand with a mnemonic imports the wallet’s history, answered with ImportCompleteEvent. In 4.x it only created the wallet. Without a blockchainDataSource it fails.
  • TimestampCommand is answered with ARC’s answer to the broadcast, not when the broadcast is handed to ARC.
  • Removed: RefreshWalletCommand, NodeRpcDataSource (use WhatsOnChainDataSource or your own BlockchainDataSource), LibSpiffyActorSystem.broadcastChannelEvent, the PaymentReadyEvent.error and ProvisioningCompleteEvent.error constructors, and CdnHeaderSyncConfig.concurrentDownloads.

The full list, including the removed Isar where clauses and actor constructor parameters, is in the 5.0.0 entry of the changelog.