libspiffy 2.0.0 copy "libspiffy: ^2.0.0" to clipboard
libspiffy: ^2.0.0 copied to clipboard

An actor-based Bitcoin SV SPV wallet library: event-sourced wallets, BEEF payments with merkle-proof validation, ARC broadcast, payment channels and pluggable token scripts.

LibSpiffy - Event-Sourced Bitcoin Wallet #

An actor-based Bitcoin wallet implementation using event sourcing, CQRS, and SPV (Simplified Payment Verification) built with the Dactor/Eventador/DuraQ stack.

Architecture Overview #

LibSpiffy implements a sophisticated Bitcoin wallet system using modern architectural patterns:

  • Event Sourcing: All wallet state changes are captured as immutable events
  • CQRS: Clear separation between commands (write operations) and queries (read operations)
  • Actor Model: Concurrent, fault-tolerant processing using Dactor
  • SPV: Lightweight Bitcoin verification using merkle proofs
  • Hybrid Stack: Combines Dactor (actors), Eventador (event store), and DuraQ (workflows)

Key Features #

Core Wallet Functionality #

  • HD wallet address generation and management
  • Wallet import (xpub watch-only, WIF private key)
  • UTXO tracking with confirmation status
  • Transaction creation and signing
  • SPV transaction verification with merkle proofs (BEEF/BUMP)
  • Multi-wallet support with isolation
  • Event-sourced state with full audit trail

Advanced Features #

  • Invoice-based payment system for simplified SPV validation
  • Multi-output invoices (P2PKH, P2MS multisig, OP_RETURN metadata, Plugin-delegated outputs)
  • Plugin system for custom script types and token protocols (ScriptPlugin, TransactionBuilderPlugin)
  • Payment channels for off-chain micropayments with on-chain settlement
  • Benford distribution UTXO splitting for transaction privacy
  • UTXO holds and reservations with automatic cleanup
  • Transaction lifecycle management with pending transaction recovery
  • Snapshot support for performance optimization
  • Real-time balance calculations
  • ARC (Authoritative Response Component) service integration for broadcasting and the policy fee rate every transaction pays
  • Transaction fee calculation from BEEF data

Storage & Deployment #

  • Isar embedded database for mobile/desktop
  • PostgreSQL backend for server-side deployments
  • In-memory storage for development and testing
  • AES-256-GCM encrypted key storage (xpub/xpriv)
  • Pluggable storage interfaces for custom backends

Network Integration #

  • Bitcoin P2P network connectivity via SpiffyNode
  • Block header synchronization (P2P network and CDN-based fast sync)
  • BEEF (Background Evaluation Extended Format) transaction validation
  • BUMP (BSV Universal Merkle Path) merkle proof validation
  • Merkle proof validation against header chain
  • ARC (Authoritative Response Component) service integration
  • WhatsOnChain blockchain data source for wallet import

System Architecture #

LibSpiffy implements a CQRS (Command Query Responsibility Segregation) architecture with complete separation between write operations (commands → events → EventStore) and read operations (queries → ReadModels).

┌───────────────────────────────────────────────────────────────────────────┐
│                        LibSpiffy CQRS Architecture                        │
├───────────────────────────────────────────────────────────────────────────┤
│                                                                           │
│  PUBLIC API (import 'package:libspiffy/coordinator.dart')                 │
│  ┌────────────────────────────────────────────────────────────────────┐   │
│  │  WalletCoordinatorActor (Unified Facade)                           │   │
│  │  • Send: CreateWalletCommand, PayInvoiceCommand, GetBalanceQuery   │   │
│  │  • Recv: WalletCreatedEvent, PaymentReadyEvent, BalanceResponse    │   │
│  │  • Handles correlation tracking, error routing, channel P2P        │   │
│  └────────────────────────────────┬───────────────────────────────────┘   │
│                                   │ Internal delegation                   │
│  COMMAND SIDE (Write Operations)  │                                       │
│  ┌──────────────────┐      ┌──────┴─────────────┐                         │
│  │ Wallet Manager   │─────▶│ Invoice Coordinator│                         │
│  │ Actor            │      │ Actor              │                         │
│  │ • Routes cmds    │      │ • Routes invoice   │                         │
│  │ • Spawns aggr.   │      │   commands         │                         │
│  │ • Multi-wallet   │      │ • Spawns invoice   │                         │
│  └────────┬─────────┘      │   aggregates       │                         │
│           │                └──────────┬─────────┘                         │
│           ▼                           ▼                                   │
│  ┌────────────────┐        ┌────────────────┐                             │
│  │ Wallet         │        │ Invoice        │                             │
│  │ Aggregate      │        │ Aggregate      │                             │
│  │ • Validates    │        │ • Validates    │                             │
│  │ • Emits events │        │ • Emits events │                             │
│  └────────┬───────┘        └────────┬───────┘                             │
│           └────────────┬────────────┘                                     │
│                        ▼                                                  │
│              ┌─────────────────┐                                          │
│              │   Event Store   │  (Write-Only from Aggregates)            │
│              │   (Eventador)   │                                          │
│              └────────┬────────┘                                          │
│  ═════════════════════╪═══════════════════════════════════════════════    │
│                       ▼  Event Stream                                     │
│              ┌─────────────────┐                                          │
│              │ Projection Mgr  │  (Read-Only from EventStore)             │
│              └────────┬────────┘                                          │
│         ┌─────────────┴─────────────┐                                     │
│         ▼                           ▼                                     │
│  ┌──────────────┐          ┌──────────────┐                               │
│  │   Wallet     │          │   Invoice    │                               │
│  │  Projection  │          │  Projection  │                               │
│  └──────┬───────┘          └──────┬───────┘                               │
│         └────────────┬────────────┘                                       │
│                      ▼                                                    │
│           ┌─────────────────────┐                                         │
│           │  Read Model Storage │  (Isar / PostgreSQL / In-Memory)        │
│           └─────────────────────┘                                         │
│                      ▲                                                    │
│  QUERY SIDE (Read Operations)                                             │
│  ┌──────────────────┐     ┌────────────────┐     ┌─────────────────┐      │
│  │   SPV Actor      │     │   ARC Actor    │     │ Header Sync     │      │
│  │ • BEEF/BUMP val. │     │ • Broadcast    │     │ • Block headers │      │
│  │ • Invoice match  │     │ • Fee estimate │     │ • Merkle proofs │      │
│  │ • Fee calc       │     │ • Policy query │     │ • Chain valid.  │      │
│  └──────────────────┘     └────────────────┘     └─────────────────┘      │
│                                                                           │
└───────────────────────────────────────────────────────────────────────────┘

Key CQRS Principles in LibSpiffy #

Write Side (Commands)

  • Commands routed through Coordinator Actors
  • Aggregates (event-sourced) validate and emit events
  • Events persisted to EventStore (immutable, append-only)
  • No direct storage writes by application code

Read Side (Queries)

  • Projections subscribe to EventStore event stream
  • Projections update denormalized ReadModels in Isar
  • Queries read from ReadModels (never EventStore)
  • Optimized for fast lookups without joins

Benefits

  • Performance: Reads optimized separately from writes
  • Scalability: Read/write can scale independently
  • Audit Trail: Complete event history in EventStore
  • Eventual Consistency: Projections update asynchronously
  • Flexibility: Multiple read models from same events

Quick Start #

Prerequisites #

  • Dart SDK 3.5.1 or later
  • Dependencies: dactor, eventador, duraq (+ duraq_isar for the Isar backend), dartsv

⚠️ Critical: Event Type Registration #

Before using LibSpiffy, you must understand that all event types must be registered with Eventador's EventRegistry for proper CBOR deserialization after system restarts.

LibSpiffy handles this automatically during initialization via _registerEventTypes(), but if you're extending LibSpiffy with custom events, you'll need to register them:

import 'package:eventador/eventador.dart';

// Register custom event types BEFORE initializing LibSpiffy
void registerMyCustomEvents() {
  EventRegistry.register<MyCustomWalletEvent>(
    'MyCustomWalletEvent',
    (map) => MyCustomWalletEvent.fromMap(map),
  );
}

// Then initialize LibSpiffy
await initializeLibSpiffy(dataDirectory: './wallet-data');

What LibSpiffy registers automatically:

  • 11 Wallet events (WalletCreatedEvent, AddressGeneratedEvent, UTXOReceivedEvent, etc.)
  • 5 Invoice events (InvoiceCreatedEvent, InvoicePaidEvent, etc.)

Why this matters:

  • Events are stored in CBOR format in the EventStore
  • After restart, Eventador needs to deserialize events back into Dart objects
  • Without registration: ArgumentError: Event type 'XYZ' not registered

See the eventador package for complete details on event registration.

Installation #

# Clone the repository
git clone <repository-url>
cd libspiffy

# Install dependencies
dart pub get

# Run the example
dart run example/bitcoin_wallet_example.dart

The WalletCoordinatorActor is the canonical public interface for third-party apps. It provides a unified command/event API that handles all internal actor orchestration, correlation tracking, and async response routing.

import 'package:libspiffy/libspiffy.dart';
import 'package:libspiffy/coordinator.dart';

// Initialize LibSpiffy
final libspiffy = LibSpiffyActorSystem();
await libspiffy.initialize(
  dataDirectory: './wallet-data',
  arcConfig: ArcServiceConfig.taalMainnet(),
  enableP2P: true,
);

// Use the coordinator - THE single entry point
final coordinator = libspiffy.coordinator;

// Subscribe to events
libspiffy.coordinatorEvents?.listen((event) {
  if (event is WalletCreatedEvent) {
    print('Wallet created: ${event.walletId}');
  } else if (event is PaymentReadyEvent) {
    print('BEEF ready to send: ${event.txid}');
  } else if (event is BalanceResponse) {
    print('Balance: ${event.totalBalance} sats');
  } else if (event is BalanceUpdatedEvent) {
    // Announced when the balance changes; no need to poll GetBalanceQuery.
    print('Balance now: ${event.totalBalance} sats');
  }
});

// Send commands
coordinator.tell(CreateWalletCommand(
  walletId: 'my-wallet',
  name: 'My Bitcoin Wallet',
));

coordinator.tell(CreateInvoiceCommand(
  walletId: 'my-wallet',
  amount: BigInt.from(100000),
  description: 'Payment for services',
));

coordinator.tell(GetBalanceQuery(walletId: 'my-wallet'));

// Cleanup
await libspiffy.shutdown();

Direct Actor Access (Advanced) #

For advanced use cases, you can access internal actors directly:

import 'package:libspiffy/libspiffy.dart';

await initializeLibSpiffy(dataDirectory: './wallet-data');

// Direct actor access (advanced - most apps should use the coordinator)
final walletManager = getLibSpiffySystem().walletManager;
final spvActor = getLibSpiffySystem().spvActor;

walletManager.tell(CreateWalletMessage('my-wallet', 'My Wallet'));

await shutdownLibSpiffy();

Integration with Host Actor System #

If your application already uses Dactor actors, LibSpiffy can integrate seamlessly:

import 'package:dactor/dactor.dart';
import 'package:libspiffy/libspiffy.dart';

// Your application's actor system
final hostActorSystem = LocalActorSystem(ActorSystemConfig());

// Initialize LibSpiffy using your actor system
await initializeLibSpiffy(
  actorSystem: hostActorSystem,  // LibSpiffy actors join your system!
  dataDirectory: './wallet-data',
);

// Now all actors are in the same system
// You can spawn your own actors that interact with LibSpiffy
final myActor = await hostActorSystem.spawn(
  'payment-processor',
  () => PaymentProcessorActor(
    walletManager: getLibSpiffySystem().walletManager,
    invoiceCoordinator: getLibSpiffySystem().invoiceCoordinator,
  ),
);

// When shutting down, LibSpiffy won't shutdown the host's actor system
await shutdownLibSpiffy(); // Only closes LibSpiffy's resources

// Host manages its own actor system
await hostActorSystem.shutdown();

Benefits of Shared Actor System #

  • Unified Supervision: Single supervision tree for all actors
  • Better Resource Efficiency: One message dispatcher instead of two
  • Clearer Failure Propagation: Unified error handling and recovery
  • Natural Actor Hierarchy: LibSpiffy actors integrate into your supervision strategy
  • Direct Communication: No cross-system message overhead

Actor System Integration Patterns #

Pattern 1: Standalone Mode #

Use LibSpiffy as a complete, self-contained wallet system:

// LibSpiffy manages everything
await initializeLibSpiffy(dataDirectory: './data');

// Use wallet functionality
final walletManager = getLibSpiffySystem().walletManager;
walletManager.tell(CreateWalletMessage(...));

// LibSpiffy handles its own lifecycle
await shutdownLibSpiffy();

Best for: Simple applications, microservices, or when LibSpiffy is the primary component.

Pattern 2: Integrated Mode #

Integrate LibSpiffy into an existing actor-based application:

// Host application setup
final app = MyActorBasedApp();
await app.initialize();

// LibSpiffy joins the host's actor system
await initializeLibSpiffy(
  actorSystem: app.actorSystem,
  dataDirectory: './data',
);

// Your actors can directly communicate with LibSpiffy actors
final paymentProcessor = await app.actorSystem.spawn(
  'payment-processor',
  () => PaymentProcessorActor(
    walletManager: getLibSpiffySystem().walletManager,
    spvActor: getLibSpiffySystem().spvActor,
  ),
);

// Lifecycle management
await shutdownLibSpiffy();  // Closes LibSpiffy resources only
await app.shutdown();       // Host manages actor system shutdown

Best for: Complex applications with multiple actor-based subsystems.

Pattern 3: Coordinator (Built-in Gateway) #

LibSpiffy provides a built-in WalletCoordinatorActor that serves as the canonical gateway. You no longer need to build your own:

import 'package:libspiffy/coordinator.dart';

// The coordinator IS the gateway - no custom actor needed
final coordinator = getLibSpiffySystem().coordinator;

// Send any command
coordinator.tell(CreateWalletCommand(walletId: 'my-wallet', name: 'My Wallet'));
coordinator.tell(PayInvoiceCommand(walletId: 'my-wallet', invoiceId: '...', ...));
coordinator.tell(GetBalanceQuery(walletId: 'my-wallet'));

// Subscribe to all events
getLibSpiffySystem().coordinatorEvents?.listen((event) {
  switch (event) {
    case WalletCreatedEvent e: handleWalletCreated(e);
    case PaymentReadyEvent e: handlePaymentReady(e);
    case BEEFValidationResultEvent e: handleBEEFValidated(e);
    case ErrorEvent e: handleError(e);
    default: break;
  }
});

Best for: All third-party integrations. This is the recommended pattern for most applications.

Checking Integration Mode #

// Check if LibSpiffy owns its actor system
if (getLibSpiffySystem().ownsActorSystem) {
  print('LibSpiffy is running in standalone mode');
} else {
  print('LibSpiffy is integrated with host actor system');
}

// Access the underlying actor system if needed
final actorSystem = getLibSpiffySystem().actorSystem;

CQRS Event Sourcing Flow #

LibSpiffy implements a complete CQRS (Command Query Responsibility Segregation) architecture with event sourcing. Understanding this flow is crucial for working with the system.

Complete Flow Diagram #

Commands (Write)                      Events (Immutable)                 Queries (Read)
     │                                      │                                │
     ▼                                      ▼                                ▼
┌─────────────┐                    ┌──────────────┐                 ┌──────────────┐
│   Command   │                    │    Event     │                 │    Query     │
│   (Intent)  │                    │  (Fact)      │                 │  (Question)  │
└──────┬──────┘                    └──────┬───────┘                 └──────┬───────┘
       │                                  │                                │
       │ 1. Route                         │ 3. Persist                     │ 7. Read
       ▼                                  ▼                                ▼
┌─────────────────┐              ┌──────────────┐               ┌──────────────────┐
│  Coordinator    │              │  EventStore  │               │  ReadModel       │
│  Actor          │              │  (Isar CBOR) │               │  Storage (Isar)  │
│ • Wallet Mgr    │              │              │               │                  │
│ • Invoice Coord │              │ • Immutable  │               │ • Denormalized   │
└────────┬────────┘              │ • Append-only│               │ • Fast lookups   │
         │                       │ • Recovery   │               │ • No joins       │
         │ 2. Spawn/Tell         └──────┬───────┘               └────────▲─────────┘
         ▼                              │                                │
┌──────────────────┐                    │ 4. Stream                      │ 6. Update
│   Aggregate      │                    ▼                                │
│   (Domain Logic) │            ┌──────────────┐                         │
│ • Wallet         │            │  Projection  │                         │
│ • Invoice        │            │  Manager     │                         │
│                  │            │              │                         │
│ • Validate       │            │ • Subscribe  │                         │
│ • Emit Events    │◀───────────│ • Route      │                         │
└──────────────────┘ 5. Replay  │ • Checkpoint │                         │
                                └──────┬───────┘                         │
                                       │                                 │
                                       │ 5. Route by type               │
                                       ▼                                 │
                            ┌──────────────────────┐                     │
                            │    Projections       │─────────────────────┘
                            │ • WalletProjection   │
                            │ • InvoiceProjection  │
                            └──────────────────────┘

Step-by-Step Flow #

Write Side (Commands → Events → EventStore)

Step 1: Command Routing

// User sends a command to coordinator
invoiceCoordinator.tell(CreateInvoiceMessage(
  walletId: 'wallet-001',
  amount: BigInt.from(100000),
  description: 'Payment for services',
));

Step 2: Aggregate Spawning/Routing

// Coordinator spawns or retrieves aggregate actor
final invoiceAggregateRef = await actorSystem.spawn(
  'Invoice_$invoiceId',
  () => InvoiceAggregate(
    persistenceId: 'Invoice_$invoiceId',
    eventStore: eventStore,
  ),
);

// Sends command to aggregate
invoiceAggregateRef.tell(CreateInvoiceCommand(...));

Step 3: Event Emission

// Inside InvoiceAggregate.handleCommand()
@override
Future<List<Event>> handleCommand(InvoiceState currentState, Command command) async {
  if (command is CreateInvoiceCommand) {
    // Validate business rules
    if (command.amount <= BigInt.zero) {
      throw ArgumentError('Amount must be positive');
    }
    
    // Return events (not state changes!)
    return [InvoiceCreatedEvent(
      invoiceId: command.invoiceId,
      walletId: command.walletId,
      addresses: command.addresses,
      amount: command.amount,
      // ... other fields
    )];
  }
}

Step 4: Event Persistence

// AggregateRoot base class automatically persists events to EventStore
// Events stored as CBOR in Isar
// This happens BEFORE eventHandler is called

Step 5: Event Application

// Inside InvoiceAggregate.eventHandler()
@override
void eventHandler(Event event) {
  ensureStateInitialized(); // Critical for recovery
  
  if (event is InvoiceCreatedEvent) {
    // Mutate currentState directly (new in Eventador)
    currentState.status = InvoiceStatus.pending;
    currentState.amount = event.amount;
    currentState.addresses = event.addresses;
    currentState.createdAt = event.timestamp;
    currentState.version++;
  }
}

Read Side (EventStore → Projections → ReadModels)

Step 6: Event Streaming

// ProjectionManager subscribes to EventStore
// Streams events to registered projections
await projectionManager.start(); // Starts event stream processing

Step 7: Projection Handling

// InvoiceProjection.handle() receives events
@override
Future<bool> handle(Event event) async {
  if (event is InvoiceCreatedEvent) {
    // Create denormalized read model
    final invoice = Invoice(
      invoiceId: event.invoiceId,
      walletId: event.walletId,
      addresses: event.addresses,
      amount: event.amount,
      status: InvoiceStatus.pending,
      createdAt: event.createdAt,
      // ... optimized for queries
    );
    
    // Write to ReadModelStorage (Isar)
    await storage.storeInvoice(invoice);
    
    // Update checkpoint for idempotent replay
    await updateCheckpoint(event.version);
    
    return true; // Event handled
  }
  return false; // Event not handled by this projection
}

Step 8: Query Execution

// Queries NEVER touch EventStore, only ReadModelStorage
invoiceCoordinator.tell(CheckInvoiceMessage(invoiceId));

// Inside coordinator
Future<void> _handleCheckInvoice(CheckInvoiceMessage msg) async {
  // Query read model storage (fast!)
  final invoice = await storage.getInvoice(msg.invoiceId);
  
  context.sender?.tell(InvoiceDetailsResponse(
    invoiceId: invoice.invoiceId,
    status: invoice.status,
    // ... all denormalized data
  ));
}

Recovery After Restart #

When the system restarts, aggregates recover their state by replaying events:

// 1. Aggregate spawned during recovery
final aggregate = InvoiceAggregate(
  persistenceId: 'Invoice_abc123',
  eventStore: eventStore,
);

// 2. AggregateRoot.preStart() automatically replays events from EventStore
// 3. Events applied via eventHandler() to rebuild state
// 4. Aggregate ready to process new commands with correct state

// Projections also replay from their last checkpoint
// This ensures ReadModels are eventually consistent

Key Principles #

✅ DO:

  • Route all commands through coordinators
  • Let aggregates emit events (never mutate storage directly)
  • Use projections to build read models
  • Query read models for fast lookups
  • Register event types before system startup

❌ DON'T:

  • Write to storage from aggregates or coordinators
  • Read from EventStore for queries (use ReadModels)
  • Skip event registration (causes deserialization errors)
  • Mutate events after creation (they're immutable)
  • Query EventStore for business logic

Storage Separation #

LibSpiffy uses separate databases for events and read models:

EventStore (Write-Only by Aggregates)

  • Schema: EventEnvelope, SnapshotEnvelope (from Eventador)
  • Format: CBOR-serialized events
  • Access: AggregateRoot base class only
  • Purpose: Immutable audit trail, recovery

ReadModelStorage (Write-Only by Projections, Read by App)

  • Schema: InvoiceEntity, BitcoinUtxoEntity, BitcoinTransactionEntity, PaymentChannelEntity
  • Format: Denormalized domain objects
  • Access: Projections write, application reads
  • Purpose: Fast queries, optimized for reads

Storage Backends:

  • Isar (mobile/desktop) — embedded, zero-config
  • PostgreSQL (server) — connection pooling, migrations, SSL support
  • In-memory (development/testing)

This separation ensures:

  • Clear CQRS boundaries
  • Independent scaling of read/write
  • No accidental EventStore queries
  • Optimized storage formats for each use case
  • Deployment flexibility across mobile, desktop, and server

Invoice-Based SPV Payments #

LibSpiffy implements a streamlined SPV payment verification system using invoices:

Creating and Paying Invoices #

// 1. Receiver creates an invoice with payment addresses
final invoice = await createInvoice(
  walletId: 'bob-wallet',
  amount: BigInt.from(100000), // satoshis
  description: 'Payment for services',
  numberOfAddresses: 1, // Can request multiple addresses
);

// Invoice contains:
// - invoiceId: Unique identifier
// - addresses: Pre-generated payment addresses
// - amount: Expected payment amount
// - expiresAt: Invoice expiration time

// 2. Sender creates transaction paying to invoice address(es)
final tx = await createTransaction(
  fromWallet: 'alice-wallet',
  toAddresses: [invoice.addresses.first],
  amount: invoice.amount,
);

// 3. Sender broadcasts transaction with BEEF (includes merkle proof)
await broadcastTransaction(
  transaction: tx,
  beef: beef, // Contains tx + parent txs + merkle proof
  invoiceId: invoice.invoiceId, // Links tx to invoice
);

// 4. SPV Actor validates the transaction:
//    - Verifies merkle proof against block header chain
//    - Confirms outputs match invoice addresses
//    - Validates payment amount
//    - Calculates transaction fee from BEEF data

// 5. Receiver's wallet is automatically updated with new UTXOs, and the
//    invoice is marked paid once ARC says the network holds the payment

SPV Validation Flow #

  1. Transaction Received: SPV Actor receives transaction with BEEF and invoice ID
  2. Merkle Proof Validation: Validates transaction is in a valid block
  3. Invoice Lookup: Retrieves expected payment addresses from Invoice Manager
  4. Output Verification: Confirms transaction pays to invoice addresses
  5. Amount Validation: Verifies payment amount matches invoice
  6. UTXO Extraction: Identifies new spendable UTXOs and spent UTXOs
  7. Fee Calculation: Computes transaction fee from input/output values in BEEF
  8. State Update: Wallet state updated via event sourcing
  9. Invoice Marking: Invoice marked as paid

Payments Handed to the Recipient (Deferred Payments) #

A payment built with PayInvoiceCommand is handed to the recipient, who normally broadcasts it. Until the network has it, the wallet holds its inputs: they are reserved by the transaction with no expiry, and neither the reservation cleanup nor another payment can take them. The hold ends when ARC reports the transaction SEEN_ON_NETWORK or MINED (inputs spent), when ARC reports it REJECTED (payment failed, inputs released), when you cancel it, or when the network holds your reclaim of it. DOUBLE_SPEND_ATTEMPTED (another transaction spends an input) is no verdict: the payment stays outstanding, its inputs held, until one of the two is mined. Every step is journaled; resolved payments stay listable.

Each answer below comes once the wallet's read model shows it, so a query made on hearing it sees the status, the spent inputs and the change. A broadcast or a reclaim succeeds only when the network holds the transaction: a status ARC gives while still processing is followed for up to 30 s until it gives a verdict, and one still processing then, in the orphan mempool (an input unknown or already spent in a block), contested or rejected is reported unsuccessful with the reason.

// Payments the recipient has not broadcast after an hour
coordinator.tell(GetDeferredPaymentsQuery(
  walletId: 'alice-wallet',
  olderThan: const Duration(hours: 1),
  queryId: 'stale',
));
// -> DeferredPaymentsResponse: txid, invoice, recipients, amount, fee, held
//    inputs, last network status and check time, state, raw tx, BEEF

// Ask the network now (ARC; or via: DeferredPaymentNetworkSource.arcThenDataSource)
coordinator.tell(CheckDeferredPaymentStatusCommand(walletId: 'alice-wallet', txid: txid));
// -> DeferredPaymentStatusEvent (MINED confirms only when the merkle proof
//    matches the local header chain)

// Broadcast it yourself
coordinator.tell(BroadcastDeferredPaymentCommand(walletId: 'alice-wallet', txid: txid));
// -> DeferredPaymentBroadcastEvent

// Give up on it: refused if the network knows the transaction
coordinator.tell(CancelDeferredPaymentCommand(walletId: 'alice-wallet', txid: txid, reason: 'expired'));
// -> DeferredPaymentCancelledEvent

// Revoke it: spend its inputs back to the wallet at ARC's policy fee
coordinator.tell(ReclaimDeferredPaymentCommand(walletId: 'alice-wallet', txid: txid));
// -> DeferredPaymentReclaimedEvent (reclaimTxid, fee, competing txids)

Cancelling does not revoke the signed transaction the recipient holds: if they broadcast it later and it still reaches miners, it spends those inputs, and a later payment that reused them fails. Reclaiming does revoke it, if the reclaim reaches the network first: first seen wins, and no fee changes that. If the recipient's copy got there first, the reclaim is rejected and names it.

Benefits #

  • Simplified Verification: No need to scan entire blockchain for transactions
  • Immediate Validation: SPV validation completes in milliseconds
  • Privacy: Addresses are single-use and linked to specific invoices
  • Security: Merkle proofs provide cryptographic assurance
  • Efficient: Only block headers needed, not full blocks

Core Components #

1. Bitcoin Wallet Aggregate (Event-Sourced) #

The core domain logic for wallet operations - an Eventador AggregateRoot:

// Spawned automatically by WalletManagerActor
// Each wallet is an independent aggregate actor
class BitcoinWalletAggregate extends AggregateRoot<WalletState> {
  @override
  Future<List<Event>> handleCommand(WalletState currentState, Command command) async {
    // Validate business rules and return events
    if (command is GenerateAddressCommand) {
      return [AddressGeneratedEvent(
        walletId: persistenceId,
        address: generatedAddress,
        derivationIndex: currentState.nextDerivationIndex,
        // ...
      )];
    }
    // ... other command handlers
  }
  
  @override
  void eventHandler(Event event) {
    ensureStateInitialized(); // Critical!
    
    // Mutate currentState directly based on events
    if (event is AddressGeneratedEvent) {
      currentState.addresses[event.address] = event.derivationIndex;
      currentState.nextDerivationIndex++;
      currentState.version++;
    }
    // ... other event handlers
  }
}

Key Features:

  • Event-sourced: All state changes via events
  • Validation: Business rules enforced before events
  • Recovery: Rebuilds state from events after restart
  • Snapshots: Configurable for performance

2. Wallet Manager Actor (Coordinator) #

Long-lived coordinator that manages multiple wallet aggregates:

// Create wallet (spawns BitcoinWalletAggregate actor)
walletManager.tell(CreateWalletMessage(
  walletId: 'wallet-001',
  name: 'My Bitcoin Wallet',
));

// Send command to wallet aggregate
walletManager.tell(WalletCommandMessage(
  walletId: 'wallet-001',
  command: GenerateAddressCommand(
    walletId: 'wallet-001',
    metadata: {'purpose': 'receiving'},
  ),
));

// Query wallet (reads from ReadModel, not EventStore)
walletManager.tell(GetWalletBalanceMessage(
  walletId: 'wallet-001',
));

Responsibilities:

  • Routes commands to appropriate wallet aggregates
  • Spawns wallet aggregate actors on-demand
  • Manages wallet lifecycle
  • Automated UTXO reservation cleanup

3. Invoice Aggregate (Event-Sourced) #

Domain logic for invoice lifecycle - an Eventador AggregateRoot:

// Spawned by InvoiceCoordinatorActor per invoice
class InvoiceAggregate extends AggregateRoot<InvoiceState> {
  @override
  Future<List<Event>> handleCommand(InvoiceState currentState, Command command) async {
    if (command is CreateInvoiceCommand) {
      return [InvoiceCreatedEvent(
        invoiceId: persistenceId,
        walletId: command.walletId,
        addresses: command.addresses,
        amount: command.amount,
        createdAt: DateTime.now(),
        // ...
      )];
    }
    
    if (command is MarkInvoicePaidCommand) {
      // Business rule validation
      if (currentState.status != InvoiceStatus.pending) {
        throw StateError('Invoice is not pending');
      }
      
      return [InvoicePaidEvent(
        invoiceId: persistenceId,
        paidAt: DateTime.now(),
        txid: command.txid,
        amountReceived: command.amountReceived,
      )];
    }
    // ... other commands
  }
  
  @override
  void eventHandler(Event event) {
    ensureStateInitialized();
    
    if (event is InvoiceCreatedEvent) {
      currentState.status = InvoiceStatus.pending;
      currentState.amount = event.amount;
      currentState.addresses = event.addresses;
      // ...
    }
    
    if (event is InvoicePaidEvent) {
      currentState.status = InvoiceStatus.paid;
      currentState.paidAt = event.paidAt;
      currentState.paymentTxid = event.txid;
      // ...
    }
  }
}

Key Features:

  • Invoice state machine (pending → paid/expired/cancelled)
  • Business rule enforcement
  • Complete audit trail of invoice lifecycle

4. Invoice Coordinator Actor (Coordinator) #

Long-lived coordinator for invoice operations (replaces old InvoiceManagerActor):

// Create an invoice (spawns InvoiceAggregate, requests addresses from wallet)
invoiceCoordinator.tell(CreateInvoiceMessage(
  walletId: 'wallet-001',
  amount: BigInt.from(100000),
  description: 'Payment for services',
  numberOfAddresses: 1,
));

// Mark invoice as paid (routes to InvoiceAggregate)
invoiceCoordinator.tell(MarkInvoicePaidMessage(
  invoiceId: 'invoice-123',
  txid: 'transaction-hex',
  amountReceived: BigInt.from(100000),
  addressesPaidTo: ['address1'],
));

// Check invoice status (queries ReadModel)
invoiceCoordinator.tell(CheckInvoiceMessage(
  invoiceId: 'invoice-123',
));

// List invoices (queries ReadModel with optional filter)
invoiceCoordinator.tell(ListInvoicesMessage(
  walletId: 'wallet-001',
  status: InvoiceStatus.pending, // Optional
));

// Cancel invoice (routes to InvoiceAggregate)
invoiceCoordinator.tell(CancelInvoiceMessage(
  invoiceId: 'invoice-123',
));

Responsibilities:

  • Routes commands to InvoiceAggregate actors
  • Coordinates with WalletManager for address generation
  • Queries ReadModelStorage for invoice lookups
  • Periodic expiration checks
  • Does NOT write to storage (projections do that!)

5. Projections (Read-Side Event Handlers) #

Projections listen to EventStore and update ReadModels:

// WalletProjection - Updates wallet read models
class WalletProjection extends Projection<WalletReadModel> {
  @override
  Future<bool> handle(Event event) async {
    if (event is UTXOReceivedEvent) {
      // Update denormalized UTXO view in Isar
      await storage.storeUTXO(BitcoinUtxo.fromEvent(event));
      return true;
    }
    // ... other wallet events
  }
}

// InvoiceProjection - Updates invoice read models  
class InvoiceProjection extends Projection<InvoiceReadModel> {
  @override
  Future<bool> handle(Event event) async {
    if (event is InvoiceCreatedEvent) {
      // Check for existing invoice (idempotent replay)
      final existing = await storage.getInvoice(event.invoiceId);
      if (existing == null) {
        await storage.storeInvoice(Invoice.fromEvent(event));
      }
      return true;
    }
    
    if (event is InvoicePaidEvent) {
      // Update existing invoice status
      await storage.updateInvoiceStatus(
        event.invoiceId,
        InvoiceStatus.paid,
        paidAt: event.paidAt,
        txid: event.txid,
      );
      return true;
    }
    // ... other invoice events
  }
}

Key Features:

  • Subscribes to event stream from EventStore
  • Builds denormalized read models
  • Checkpointing for idempotent replay
  • Eventual consistency (async updates)

6. Projection Manager (CQRS Orchestration) #

Coordinates event streaming to all projections:

// Initialized automatically by LibSpiffyActorSystem
final projectionManager = ProjectionManager(eventStore);

// Register projections
await projectionManager.registerProjection(walletProjection);
await projectionManager.registerProjection(invoiceProjection);

// Start event streaming
await projectionManager.start();

// ProjectionManager:
// - Streams events from EventStore
// - Routes events to interested projections
// - Manages checkpoints for each projection
// - Handles projection failures gracefully

7. SPV Actor #

Handles SPV transaction validation with BEEF/BUMP merkle proofs:

// Receive and validate a transaction
spvActor.tell(ReceiveTransactionMessage(
  transactionId: 'txid-hex',
  beef: beefData, // Contains tx + parents + merkle proof
  fromCounterparty: 'alice',
  targetWalletId: 'bob-wallet',
  invoiceId: 'invoice-123', // Links to invoice
));

// SPV Actor will:
// 1. Validate merkle proof against block headers
// 2. Verify outputs match invoice addresses
// 3. Calculate transaction fee
// 4. Extract spendable UTXOs
// 5. Update wallet state via commands
// The coordinator then submits the payment to ARC and marks the invoice
// paid once ARC says the network holds it (SEEN_ON_NETWORK or MINED), not
// on validation alone: a payment the network refuses pays nothing.

8. ARC Actor #

Interfaces with ARC for the transactions the wallet broadcasts (its own, and the payments it receives) and for the fee rate every transaction pays:

// Broadcast a transaction; the reply says how far ARC got with it
// (BroadcastSuccessMessage.networkStatus) or why it failed.
arcActor.tell(BroadcastTransactionMessage(walletId, rawTxHex, txid));

// ARC's published policy rate. Every fee the wallet pays is this rate on the
// transaction's signed size; a rate ARC could not be asked for is a failure,
// never a guessed one. There is no replace-by-fee on BSV, so nothing is ever
// paid above it.
final quote = await arcActor.ask<FeeRateQuote>(GetFeeRateMessage(), const Duration(seconds: 30));
final fee = quote.rate?.feeFor(signedSizeBytes);

// How far a transaction the wallet broadcast has got
arcActor.tell(CheckTransactionStatusMessage(txid));

9. Block Header Sync Actor #

Manages block headers and validates merkle proofs:

// Start header sync
headerSync.tell(StartHeaderSyncMessage(
  startHeight: 0,
  targetHeight: null, // null = sync to tip
));

// Validate merkle proof
headerSync.tell(ValidateMerkleProofMessage(
  requestId: 'validate-1',
  merkleProof: proof,
  txid: 'transaction-id',
));

10. Plugin System #

LibSpiffy provides an extensible plugin architecture for custom Bitcoin script types and token protocols. Plugins are decoupled from the core library — no compile-time dependency on token implementations.

// Register a plugin (e.g., tstokenlib for TSL1 tokens)
final registry = PluginRegistry();
registry.register(myTokenPlugin);

// Plugins provide:
// - Script identification: recognize custom script types in UTXOs
// - Metadata extraction: parse protocol-specific data from scripts
// - Lock/unlock builders: construct locking and unlocking scripts
// - Transaction builders: build complete multi-output protocol transactions

// Send a plugin-based payment via coordinator
coordinator.tell(PayInvoiceCommand(
  walletId: 'my-wallet',
  invoiceId: 'invoice-123',
  pluginOutputs: [PluginOutputSpec(pluginId: 'tsl1', params: {...})],
));

The CallbackTransactionSigner enables plugins to sign transactions without exposing private keys — the wallet aggregate retains exclusive control of key material.

See Plugin API Guide for the full interface reference.

11. Payment Channels #

One-way payment channels: a client locks funds in a 2-of-2 output shared with a server, then pays the server in small increments off-chain. The server settles on-chain once.

// A node that does channels says when they stop taking payments and how long
// they must run. There is no default: without it, no channel is requested,
// accepted or paid.
await libspiffy.initialize(
  // ...
  channelPeerId: myPeerId,
  channelTiming: ChannelTiming(
    settlementMargin: const Duration(hours: 1),
    minimumLifetime: const Duration(days: 1),
  ),
);

// Client: open, pay, close.
coordinator.tell(OpenChannelCommand(
  walletId: 'my-wallet',
  serverPeerId: serverPeerId,
  fundingAmountSats: 1000000,
  lockTimeDurationSeconds: 7 * 24 * 3600,
));
coordinator.tell(ChannelPayCommand(channelId: channelId, walletId: 'my-wallet', amountSats: 1000));
coordinator.tell(CloseChannelCommand(channelId: channelId));

Peer messages travel over the app's own transport: deliver what arrives as ChannelP2PReceived, and send what the coordinator emits as ChannelP2PMessageToSendEvent.

How the protocol protects each side.

  • Funding: before the client broadcasts its funding, it holds a refund that the server has signed. The refund returns everything to the client once the lock time passes. The server opens the channel only after it has SPV-validated the funding and submitted it to ARC, and ARC reports the network holds it (see "What counts as on the network" below).
  • Payments: each payment is a transaction spending the funding output, signed by the client. The server checks what it pays, its fee, and the client's signature. The server keeps its own signature: only the server ever holds a fully signed payment, so the client cannot broadcast an earlier state that pays the server less.
  • Settlement: the server settles by broadcasting its latest payment. It does this when either side closes the channel, and on its own once the settlement margin begins. It then hands the settlement to the client, which records its share.
  • Refund: the client claims its refund (ClaimChannelRefundCommand) only once the chain's median time past, read from its block headers, is after the lock time. The network judges the lock time against that, not the clock, and on mainnet it trails the clock by about an hour. Until then a node holds the refund only as non-final, and drops it for any final spend of the funding output, such as the server's settlement.
  • What counts as on the network: a channel transaction the library submits (the server's funding submission, the settlement, the refund) counts as held only when ARC reports SEEN_ON_NETWORK or MINED. A double spend, or an orphan (an input the node cannot connect, or one already spent in a block), fails the step, and nothing is recorded; repeating the step retries it. A status ARC gives while still processing is no verdict: the library asks ARC again until it gives one, for up to the manager's inFlightTimeout (30 s by default), and fails the step if it has not.
  • Peers: messages about a channel are accepted only from the channel's counterparty, and only in that counterparty's role.

Timing (ChannelTiming).

  • Within settlementMargin of the lock time, no payment is made or acknowledged, and the server settles. The margin must cover:
    • the broadcast itself;
    • clock skew between the parties;
    • that the network judges a time lock against the median time of the last eleven blocks, not the clock.
  • A server accepts a channel only if it has at least minimumLifetime to run, and only if its lock time is a time rather than a block height.
  • A client should ask for comfortably more than its server's minimum, because the server measures the remaining time when it accepts.
  • A settlement ARC does not take leaves the channel closing, and the host is told. Closing again retries it, and so does a restart.

Known risk: fee changes. A payment's fee is checked against ARC's policy rate when the server acknowledges it, and the settlement is that payment. If the policy rate rises before the server settles, ARC may refuse the settlement. The server cannot re-sign it alone, and BSV has no replacement. Network fee changes are announced well in advance, so operators can settle their channels before a change takes effect.

12. Additional Coordinators #

  • PaymentCoordinatorActor: Orchestrates multi-step payment flows including plugin-based transactions
  • BenfordCoordinatorActor: UTXO splitting using Benford's Law distribution for transaction privacy
  • TransactionLifecycleCoordinatorActor: Tracks pending transactions and recovers them on restart
  • ImportActor: Wallet import from blockchain via address discovery

Event Sourcing Flow #

Commands → Events → State #

// 1. Command represents user intention
final command = GenerateAddressCommand(
  commandId: 'gen-addr-1',
  walletId: 'wallet-001',
  purpose: AddressPurpose.receiving,
);

// 2. Command handler produces events
final events = [
  AddressGeneratedEvent(
    eventId: 'event-1',
    walletId: 'wallet-001',
    address: 'bc1q...',
    derivationPath: "m/44'/0'/0'/0/0",
    purpose: AddressPurpose.receiving,
    timestamp: DateTime.now(),
  ),
];

// 3. Events are applied to update state
final newState = currentState.applyEvent(events.first);

Event Types #

All events are persisted to EventStore and streamed to Projections for read-model updates.

Wallet Events (BitcoinWalletAggregate)

  • WalletCreatedEvent: New wallet initialized
  • AddressGeneratedEvent: New address created
  • AddressLabelUpdatedEvent: Address label changed
  • UTXOReceivedEvent: Incoming UTXO detected
  • UTXOSpentEvent: UTXO consumed in transaction
  • UTXOConfirmationUpdatedEvent: UTXO confirmation count changed
  • TransactionAddedEvent: Transaction added to wallet
  • SpendingTransactionCreatedEvent: Outgoing transaction created
  • TransactionBroadcastEvent: Transaction sent to network

UTXO Reservation Events (BitcoinWalletAggregate)

  • UTXOReservationPlacedEvent: UTXO reserved for future use
  • UTXOReservationReleasedEvent: UTXO reservation removed
  • UTXOReservationExpiredEvent: UTXO reservation timed out
  • UTXOReservedEvent: UTXO marked as reserved
  • UTXOReleasedEvent: UTXO released from reservation
  • UTXOReservationRenewedEvent: UTXO reservation extended

Deferred Payment Events (BitcoinWalletAggregate)

  • TransactionSpendDeferredEvent (wallet.transaction.spend_deferred): inputs of a handed-over payment held
  • TransactionNetworkStatusCheckedEvent (wallet.transaction.network_status_checked): network status observed
  • DeferredTransactionFailedEvent (wallet.transaction.deferred_failed): ARC rejected it; inputs released
  • DeferredTransactionCancelledEvent (wallet.transaction.deferred_cancelled): cancelled; inputs released

Invoice Events (InvoiceAggregate)

  • InvoiceCreatedEvent: Invoice created with payment addresses
  • InvoiceStatusChangedEvent: Invoice status transition
  • InvoicePaidEvent: Invoice marked as paid after SPV validation
  • InvoiceExpiredEvent: Invoice expired before payment
  • InvoiceCancelledEvent: Invoice cancelled by user

Payment Channel Events (PaymentChannelAggregate)

  • ChannelRequestedEvent / ChannelAcceptedEvent / ChannelRejectedEvent: a channel proposed, accepted by the server, or refused
  • RefundBuiltEvent / RefundCountersignedEvent: the client's refund, and the server's signature on it
  • FundingBroadcastStartedEvent / FundingBroadcastFailedEvent / FundingRecordedInWalletEvent: the client's funding reaching the network and its wallet
  • ChannelOpenedEvent: the funding output the channel spends
  • PaymentRecordedEvent (client) / PaymentAcknowledgedEvent (server): a payment
  • ChannelClosingEvent / ChannelClosedEvent: a cooperative close, and its settlement
  • ChannelExpiredEvent / RefundClaimedEvent: the lock time passed; the client's refund broadcast
  • ReturnLegRecordedInWalletEvent: the transaction that ended the channel is in this side's wallet
  • PaymentCountersignedEvent: deprecated, replayed from older client journals only

Note: All domain events are persisted to EventStore and streamed to their respective projections (WalletProjection, InvoiceProjection, ChannelProjection) for read model updates.

Security Features #

SPV Verification #

  • BEEF (Background Evaluation Extended Format): Validates transactions with parent transaction context
  • BUMP (BSV Universal Merkle Path): Efficient merkle proof format for SPV validation
  • Merkle Proof Validation: Cryptographic verification against block header chain
  • Header Chain Validation: Ensures block headers form a valid chain
  • Invoice-Based Address Verification: Confirms payments to expected addresses only
  • Transaction Authenticity: Verification without full blockchain download

UTXO Management #

  • Atomic UTXO Selection: Reservation prevents double-spending
  • Automatic Cleanup: Expired reservations released automatically
  • Deferred-Payment Holds: Inputs of a payment handed to a recipient are never released by expiry; only the network's answer or a cancellation ends the hold
  • Event-Sourced Tracking: Full history of UTXO lifecycle
  • Fee Calculation: Accurate fee computation from BEEF data

Payment Verification #

  • Invoice System: Pre-allocated addresses for expected payments
  • Amount Validation: Confirms payment matches invoice amount
  • Expiration Handling: Time-limited invoices prevent indefinite address monitoring
  • Privacy: Single-use addresses linked to specific payments

Event Integrity #

  • Immutable Event Log: All state changes permanently recorded
  • Complete Audit Trail: Full history of wallet operations
  • Snapshot Support: Performance optimization with integrity checks
  • Idempotent Commands: Safe command replay and retry

Configuration #

Storage Backend #

// Mobile/Desktop — Isar embedded database
final libspiffy = LibSpiffyActorSystem();
await libspiffy.initialize(dataDirectory: './wallet-data');

// Server — PostgreSQL
await libspiffy.initialize(
  storageBackend: StorageBackend.postgres,
  // SSL is required by default; a local server without TLS needs
  // '?sslmode=disable'. Use sslmode=verify-full in production.
  postgresConfig: PostgresConfig.fromConnectionString(
    'postgresql://user:pass@localhost:5432/wallets?sslmode=disable',
  ),
);

// Development — In-memory
await libspiffy.initialize(
  storageBackend: StorageBackend.inMemory,
);

Network Configuration #

// ARC Service (for transaction broadcasting)
final arcConfig = ArcServiceConfig(
  baseUrl: 'https://arc.taal.com',
  apiKey: 'your-api-key',
  network: 'mainnet',
);
// Or use presets:
ArcServiceConfig.taalMainnet();
ArcServiceConfig.taalTestnet();

// CDN-based fast header sync
final cdnConfig = CdnHeaderSyncConfig(
  baseUrl: 'https://cdn.example.com/headers',
  concurrentDownloads: 4,
);

Monitoring and Observability #

Actor Metrics #

  • Message processing rates
  • Error rates and types
  • Actor lifecycle events
  • Memory usage and performance

Wallet Metrics #

  • Balance changes over time
  • Transaction volume and fees
  • UTXO set size and distribution
  • Address generation patterns
  • Event store size and growth

Invoice Metrics #

  • Invoice creation rate
  • Payment success rate
  • Invoice expiration rate
  • Average payment time
  • Active vs. paid vs. expired invoices

SPV Validation Metrics #

  • Merkle proof validation success rate
  • BEEF/BUMP processing time
  • Fee calculation accuracy
  • Address verification success rate
  • Block header sync progress

Network Metrics #

  • ARC service response times
  • Transaction broadcast success rate
  • Block header sync status
  • Network fee rates

Testing #

# Run all tests
dart test

# Run by category
dart test test/unit/                    # Unit tests
dart test test/integration/             # Integration tests
dart test test/services/                # Service tests
dart test test/core_models/             # Domain model tests

# PostgreSQL storage (needs a running PostgreSQL; see
# test/storage/postgres/postgres_integration_test.dart)
POSTGRES_DATABASE=libspiffy_test dart test --tags=postgres test/storage/postgres/

# Against a real regtest network and ARC (needs the localnet stack: node
# RPC :18332, node P2P :18333, ARC :9090). Skipped unless asked for; they
# mine blocks on the stack's shared chain. LOCALNET_LOG=1 prints the
# library's logs, tagged by node.
dart test -P localnet test/integration/localnet_node_e2e_test.dart      # header sync, restarts
dart test -P localnet test/integration/localnet_payment_e2e_test.dart   # invoices and payments
dart test -P localnet test/integration/localnet_channel_e2e_test.dart   # payment channels
dart test -P localnet test/integration/localnet_deferred_e2e_test.dart  # deferred payments, double spends
dart test -P localnet test/integration/localnet_reorg_e2e_test.dart     # chain reorganizations
dart test -P localnet test/integration/node_rpc_data_source_test.dart  # node RPC data source
dart test -P localnet test/integration/node_rpc_wif_import_test.dart   # WIF import from the node

What the suite covers #

  • Integration tests: end-to-end flows including the coordinator API, P2P payments and payment channels (also against a real regtest node and ARC), SPV validation, token lifecycle, invoice persistence, wallet import, header sync
  • Unit tests: plugin registry, output specs, encryption, CDN sync, script builders
  • Service tests: ARC service, payment channels, address discovery, node RPC merkle proofs, WhatsOnChain TSC proofs
  • Core model tests: UTXO, transaction, wallet state, commands, events
  • Storage tests: Isar schemas, wallet storage, PostgreSQL integration
  • Actor and aggregate tests: the actors, the channel, wallet and invoice aggregates, and their replies
  • Format tests: BEEF/BUMP parsing, format equivalence, SPV validation
  • Crypto tests: DartSV crypto service, key derivation

Development #

Project Structure #

lib/
├── libspiffy.dart                       # Primary barrel file (~100 exports)
├── coordinator.dart                     # Public API (WalletCoordinatorActor)
└── src/
    ├── actors/                          # Actor System
    │   ├── libspiffy_actor_system.dart      # System initialization & event registration
    │   ├── wallet_coordinator_actor.dart     # Unified public API facade
    │   ├── wallet_manager_actor.dart         # Wallet aggregate coordinator
    │   ├── invoice_coordinator_actor.dart    # Invoice aggregate coordinator
    │   ├── payment_coordinator_actor.dart    # Payment flow orchestration
    │   ├── spv_actor.dart                    # SPV validation with BEEF/BUMP
    │   ├── arc_actor.dart                    # ARC service integration
    │   ├── header_sync_actor.dart            # Block header synchronization
    │   ├── benford_coordinator_actor.dart    # Privacy-preserving UTXO splitting
    │   ├── transaction_lifecycle_coordinator_actor.dart  # Pending tx recovery
    │   ├── import_actor.dart                 # Wallet import from blockchain
    │   ├── channel_p2p_adapter.dart          # Payment channel P2P communication
    │   ├── coordinator_messages.dart         # Public API commands/events
    │   ├── wallet_messages.dart              # Wallet actor messages
    │   ├── invoice_messages.dart             # Invoice actor messages
    │   ├── payment_messages.dart             # Payment flow messages
    │   ├── payment_channel_messages.dart     # Channel protocol messages
    │   └── spv_messages.dart                 # SPV validation messages
    ├── core/                            # Domain Aggregates (Write Side)
    │   ├── bitcoin_wallet_aggregate.dart     # Event-sourced wallet
    │   ├── invoice_aggregate.dart            # Event-sourced invoices
    │   ├── payment_channel_aggregate.dart    # Event-sourced payment channels
    │   ├── wallet_commands.dart              # Wallet command definitions
    │   ├── wallet_events.dart                # Wallet event definitions
    │   ├── invoice_commands.dart             # Invoice command definitions
    │   ├── invoice_events.dart               # Invoice event definitions
    │   ├── channel_commands.dart             # Channel command definitions
    │   ├── channel_events.dart               # Channel event definitions
    │   └── channel_state.dart                # Channel aggregate state
    ├── plugin/                          # Extensible Plugin System
    │   ├── script_plugin.dart               # Base plugin interface
    │   ├── transaction_builder_plugin.dart   # Multi-output transaction builder
    │   ├── plugin_registry.dart             # Plugin discovery & management
    │   └── plugin_types.dart                # Plugin data structures
    ├── projections/                     # CQRS Read Side
    │   ├── wallet_projection.dart           # Wallet read model updates
    │   ├── invoice_projection.dart          # Invoice read model updates
    │   └── channel_projection.dart          # Channel read model updates
    ├── models/                          # Domain Models
    │   ├── wallet_state.dart                # Wallet aggregate state
    │   ├── wallet_read_model.dart           # Wallet query model
    │   ├── invoice_state.dart               # Invoice aggregate state
    │   ├── invoice_read_model.dart          # Invoice query model
    │   ├── invoice_output_spec.dart         # Multi-output specs (P2PKH, P2MS, OP_RETURN, Plugin)
    │   ├── bitcoin_utxo.dart                # UTXO model with plugin metadata
    │   ├── bitcoin_transaction.dart         # Transaction model
    │   ├── address_metadata.dart            # Address with script type and usage
    │   ├── blockchain_data_models.dart      # Blockchain API response models
    │   ├── payment_channel.dart             # Channel read model
    │   ├── transaction_address_link.dart    # Transaction-address junction
    │   └── wallet_type.dart                 # Enum: HD, WIF, XPRIV, XPUB
    ├── spv/                             # SPV Validation
    │   ├── beef.dart                        # BEEF format implementation
    │   ├── bump.dart                        # BUMP merkle path implementation
    │   ├── block_header_chain.dart          # Header chain management
    │   ├── cdn_header_sync_service.dart     # Fast CDN-based header sync
    │   ├── cdn_header_sync_config.dart      # CDN sync configuration
    │   └── cdn_manifest.dart                # CDN manifest structures
    ├── storage/                         # Persistence Layer
    │   ├── read_model_storage.dart          # Read model interface
    │   ├── event_storage.dart               # Event store interface
    │   ├── secure_storage.dart              # Encrypted key storage interface
    │   ├── storage_backend.dart             # Backend enum & factory
    │   ├── isar_wallet_storage.dart         # Isar implementation (mobile/desktop)
    │   ├── in_memory_wallet_storage.dart    # In-memory (dev/test)
    │   ├── in_memory_secure_storage.dart    # In-memory key storage (dev/test)
    │   ├── libspiffy_schemas.dart           # Isar schema definitions
    │   ├── payment_channel_entity.dart      # Channel Isar entity
    │   └── postgres/                        # PostgreSQL backend (server)
    │       ├── postgres_config.dart             # Connection & pool config
    │       ├── postgres_wallet_storage.dart      # Read model store
    │       ├── postgres_event_store.dart         # Event sourcing store
    │       ├── postgres_secure_storage.dart      # Encrypted key storage
    │       ├── postgres_migrations.dart          # Migration infrastructure
    │       └── migrations/                      # Schema versions
    │           ├── v001_initial_schema.dart
    │           └── v002_secure_secrets.dart
    ├── services/                        # Business Logic Services
    │   ├── crypto_service.dart              # Cryptographic interface (BIP32/39/44)
    │   ├── dartsv_crypto_service.dart       # DartSV crypto implementation
    │   ├── callback_transaction_signer.dart # Secure signer for plugins
    │   ├── arc_service.dart                 # ARC API client
    │   ├── arc_service_config.dart          # ARC configuration
    │   ├── ancestor_chain_service.dart      # Transaction ancestry chains
    │   ├── payment_channel_builder.dart     # Channel transaction builder
    │   ├── address_discovery_service.dart   # Hierarchical address discovery
    │   ├── script_type_registry.dart        # Script type identification
    │   ├── transaction_analyzer.dart        # Two-phase UTXO analysis
    │   ├── transaction_import_service.dart  # Historical transaction import
    │   ├── blockchain_data_source.dart      # Blockchain API interface
    │   ├── whatsonchain_data_source.dart    # WhatsOnChain implementation
    │   └── transaction/builder/             # Lock/unlock script builders
    │       ├── p2pkh_lockbuilder.dart           # Standard P2PKH
    │       ├── p2pkh_unlockbuilder.dart
    │       ├── hodl_lockbuilder.dart            # Time-locked scripts
    │       ├── hodl_unlockbuilder.dart
    │       ├── op_return_lockbuilder.dart       # OP_RETURN metadata
    │       └── ...                              # AIP, BMAP, B://, PP1, PP2
    ├── crypto/                          # Encryption
    │   └── encryption_service.dart          # AES-256-GCM with HKDF
    ├── integration/                     # External System Bridges
    │   └── spiffynode_bridge.dart           # SpiffyNode P2P bridge
    └── utils/                           # Utilities
        ├── beef.dart                        # BEEF format parsing
        ├── bump.dart                        # BUMP merkle path utilities
        ├── benford_distribution.dart        # Benford's Law splitting
        ├── crypto_utils.dart                # Cryptographic helpers
        ├── hex_utils.dart                   # Hex conversion
        └── tsc_converter.dart               # Token/satoshi conversion

Key Architectural Layers:

  • actors/: Long-lived coordinators that route commands; WalletCoordinatorActor is the single public entry point
  • core/: Event-sourced aggregates (write-side domain logic)
  • plugin/: Extensible system for custom script types and token protocols
  • projections/: Read-side event handlers (update read models)
  • models/: Separated into aggregate state (mutable) and read models (denormalized)
  • spv/: BEEF/BUMP validation and block header synchronization
  • storage/: Read model persistence — Isar (mobile), PostgreSQL (server), in-memory (dev); EventStore managed by Eventador

Adding New Features #

Adding Wallet Commands/Events (CQRS Pattern)

Follow these steps to add new functionality using proper CQRS patterns:

Step 1: Define Command

// Add to lib/src/core/wallet_commands.dart
class MyNewCommand extends Command {
  final String walletId;
  final String someParameter;
  
  MyNewCommand({
    required this.walletId,
    required this.someParameter,
  });
}

Step 2: Define Event

// Add to lib/src/core/wallet_events.dart
class MyNewEvent extends WalletEvent {
  final String someData;
  
  MyNewEvent({
    required String walletId,
    required this.someData,
    String? eventId,
    DateTime? timestamp,
    int? version,
  }) : super(
    walletId: walletId,
    eventId: eventId,
    timestamp: timestamp,
    version: version,
  );
  
  @override
  Map<String, dynamic> getEventData() {
    return {
      'someData': someData,
    };
  }
  
  // CRITICAL: fromMap for deserialization after restart
  static MyNewEvent fromMap(Map<String, dynamic> map) {
    return MyNewEvent(
      walletId: map['walletId'] as String,
      someData: map['someData'] as String,
      eventId: map['eventId'] as String?,
      timestamp: map['timestamp'] != null
          ? (map['timestamp'] is String
              ? DateTime.parse(map['timestamp'])
              : map['timestamp'] as DateTime)
          : null,
      version: map['version'] as int?,
    );
  }
}

Step 3: Register Event Type

// Add to lib/src/actors/libspiffy_actor_system.dart → _registerEventTypes()
EventRegistry.register<MyNewEvent>(
  'MyNewEvent',
  (map) => MyNewEvent.fromMap(map),
);

Step 4: Add Command Handler in BitcoinWalletAggregate

// In lib/src/core/bitcoin_wallet_aggregate.dart → handleCommand()
@override
Future<List<Event>> handleCommand(WalletState currentState, Command command) async {
  return switch (command.runtimeType) {
    MyNewCommand => _handleMyNewCommand(currentState, command as MyNewCommand),
    // ... other commands
    _ => throw ArgumentError('Unknown command: ${command.runtimeType}'),
  };
}

List<Event> _handleMyNewCommand(WalletState state, MyNewCommand cmd) {
  // Validate business rules
  if (!state.isCreated) {
    throw StateError('Wallet not yet created');
  }
  
  // Perform business logic
  final result = performSomeOperation(cmd.someParameter);
  
  // Return events (not state changes!)
  return [
    MyNewEvent(
      walletId: cmd.walletId,
      someData: result,
    ),
  ];
}

Step 5: Add Event Handler in BitcoinWalletAggregate

// In lib/src/core/bitcoin_wallet_aggregate.dart → eventHandler()
@override
void eventHandler(Event event) {
  ensureStateInitialized(); // CRITICAL!
  
  if (event is! WalletEvent) {
    throw ArgumentError('Expected WalletEvent, got ${event.runtimeType}');
  }

  switch (event.runtimeType) {
    case MyNewEvent:
      _applyMyNewEvent(event as MyNewEvent);
      break;
    // ... other events
    default:
      throw ArgumentError('Unknown event: ${event.runtimeType}');
  }
}

// Mutate currentState directly (new Eventador pattern)
void _applyMyNewEvent(MyNewEvent event) {
  currentState.someField = event.someData;
  currentState.version++;
  currentState.lastModified = event.timestamp;
}

Step 6: Update Projection (if needed)

// In lib/src/projections/wallet_projection.dart → handle()
@override
Future<bool> handle(Event event) async {
  if (event is MyNewEvent) {
    // Update read model in Isar
    await _storage.updateSomeReadModel(
      event.walletId,
      event.someData,
    );
    return true;
  }
  // ... other events
}

Key Points:

  • ✅ Commands go to aggregates via coordinators
  • ✅ Aggregates validate and emit events
  • ✅ Events are persisted to EventStore automatically
  • ✅ Projections update read models asynchronously
  • ✅ All event types MUST be registered for deserialization
  • ❌ Never write to storage from aggregates or coordinators

Adding Actor Messages

  1. Define Message in wallet_messages.dart or custom file

    class MyNewMessage implements Message {
      final String data;
         
      MyNewMessage(this.data);
         
      @override
      String? get correlationId => null;
      @override
      Map<String, dynamic> get metadata => {'data': data};
    }
    
  2. Handle Message in Actor

    @override
    Future<void> onMessage(dynamic message) async {
      switch (message.runtimeType) {
        case MyNewMessage:
          await _handleMyNewMessage(message as MyNewMessage);
          break;
        // ... other cases
      }
    }
       
    Future<void> _handleMyNewMessage(MyNewMessage msg) async {
      // Process message
      // Optionally send response
      context.sender?.tell(MyResponseMessage(...));
    }
    

Best Practices #

Event Sourcing Patterns #

✅ DO:

  • Keep events immutable and descriptive
  • Store business intent in events, not just data changes
  • Use event versioning for schema evolution
  • Apply events in order to rebuild state

❌ DON'T:

  • Query the EventStore directly for business logic
  • Modify events after they're persisted
  • Store computed values in events (recalculate from state)
  • Skip event application during replay

Actor Communication #

✅ DO:

  • Use message-passing for all actor communication
  • Implement proper command-response patterns
  • Handle timeouts and failures gracefully
  • Keep messages immutable

❌ DON'T:

  • Access actors' internal state directly
  • Use fire-and-forget for operations requiring confirmation
  • Block waiting for responses (use async patterns)
  • Share mutable state between actors

Actor System Integration #

✅ DO:

  • Use integrated mode when building actor-based applications
  • Let the host application manage actor system lifecycle
  • Use standalone mode for simple use cases or microservices
  • Check ownsActorSystem if lifecycle management is unclear
  • Provide custom storage/crypto implementations via initialization

❌ DON'T:

  • Create multiple actor systems unnecessarily
  • Shutdown the host's actor system from LibSpiffy
  • Mix standalone and integrated modes in same application
  • Assume LibSpiffy owns the actor system without checking

SPV Validation #

✅ DO:

  • Always validate merkle proofs against block headers
  • Verify payment amounts match invoices
  • Calculate fees from BEEF data
  • Use invoice-based address verification

❌ DON'T:

  • Trust transaction data without merkle proof
  • Accept payments to unexpected addresses
  • Skip block header chain validation
  • Process transactions without proper BEEF context

UTXO Management #

✅ DO:

  • Reserve UTXOs before transaction creation
  • Set expiration times on reservations
  • Release reservations after transaction broadcast
  • Track UTXO lifecycle through events

❌ DON'T:

  • Spend UTXOs without reservation
  • Keep indefinite reservations
  • Manually track UTXO state outside events
  • Modify UTXO state without commands/events

Documentation #

Acknowledgments #

  • Dactor: Actor model framework for Dart
  • Eventador: Event sourcing and CQRS library
  • DuraQ: Operational workflow management
  • DartSV: Bitcoin SV library for Dart
  • SpiffyNode: SPV chain tracking and P2P connectivity

Further Reading #

Architecture Patterns #

Bitcoin & BSV #

License #

This project is licensed under the MIT License - see the LICENSE file for details.

1
likes
50
points
917
downloads

Documentation

API reference

Publisher

verified publishertwostack.org

Weekly Downloads

An actor-based Bitcoin SV SPV wallet library: event-sourced wallets, BEEF payments with merkle-proof validation, ARC broadcast, payment channels and pluggable token scripts.

Repository (GitHub)
View/report issues

Topics

#bitcoin #wallet #spv #event-sourcing #actors

License

MIT (license)

Dependencies

buffer, cbor, collection, convert, crypto, cryptography, dactor, dartsv, duraq, duraq_isar, eventador, hex, http, isar, logging, meta, pointycastle, postgres, spiffynode, synchronized, uuid

More

Packages that depend on libspiffy