Skip to content

Invoices & SPV

This page shows how to take a payment from another wallet: the payee creates an invoice, the payer builds a signed transaction and hands it over as a BEEF, and the payee checks it against its own block headers and broadcasts it. Everything goes through the coordinator (package:libspiffy/coordinator.dart).

Step Who Command Answer
1 Payee CreateInvoiceCommand InvoiceCreatedEvent
2 Payer PayInvoiceCommand PaymentReadyEvent (a BEEF; nothing is broadcast)
3 Both apps your own transport the payer sends the BEEF to the payee
4 Payee ValidateBEEFCommand BEEFValidationResultEvent
5 Payee (automatic) InvoicePaidEvent once ARC says the network holds the payment

libspiffy owns no transport. Your app moves the invoice details from payee to payer, and the BEEF from payer to payee, over whatever channel it already has.

libspiffy.coordinator is a WalletCoordinator. Its ask(request) sends a command and completes with that command’s own reply. Every command on this page is a CoordinatorRequest<R> that names its reply type R: ask(CreateInvoiceCommand(...)) returns a Future<InvoiceCreatedEvent>.

// A sketch: `bob` is an initialized LibSpiffyActorSystem.
try {
final invoice = await bob.coordinator.ask(CreateInvoiceCommand(
walletId: 'bob-wallet',
amount: BigInt.from(100000),
));
print(invoice.invoiceId);
} on CoordinatorFailure catch (failure) {
// failure.message says why. failure.event is the reply that reported it, or an ErrorEvent.
// failure.closed is true when the coordinator stopped before it answered.
print('Refused: ${failure.message}');
}
  • A reply you get back is a success. When the reply reports a failure (success: false, or valid: false for a BEEF), or an ErrorEvent names the request, ask throws CoordinatorFailure instead.
  • Each request waits for its own reply. A request gets a requestId when you create it: the one you pass, or a generated one. Its reply carries the same id, and so does an ErrorEvent it causes. Two invoices created for one wallet at the same time each get their own answer. If you pass your own requestId, use a new one for each request.
  • A timeout does not cancel. ask throws TimeoutException when no reply arrives within the request’s replyTimeout. Pass timeout: to change it. The request keeps running, and its reply then arrives only on the event stream.
Request Reply Default timeout
CreateInvoiceCommand InvoiceCreatedEvent 1 minute (CoordinatorRequest.defaultTimeout)
PayInvoiceCommand PaymentReadyEvent 3 minutes (paymentTimeout)
ValidateBEEFCommand BEEFValidationResultEvent 5 minutes (receiveTimeout)
SettleBEEFCommand BEEFSettledEvent 3 minutes (networkTimeout)

Events that no request asked for, such as InvoicePaidEvent, come from coordinator.on<E>({walletId}). Replies are published on libspiffy.coordinatorEvents too.

final invoice = await bob.coordinator.ask(CreateInvoiceCommand(
walletId: 'bob-wallet',
amount: BigInt.from(100000), // satoshis
description: 'Payment for services',
numberOfAddresses: 1, // the default
expiresIn: const Duration(hours: 1),
));
// Send these to the payer: invoice.invoiceId, invoice.addresses, invoice.amount

CreateInvoiceCommand takes:

  • walletId (required): the wallet that receives the payment. It generates a fresh receive address for each of numberOfAddresses.
  • amount: the total, in satoshis. Or give outputs instead; see Multi-output invoices.
  • description, invoiceMetadata: your own labels.
  • expiresIn (a Duration) or expiresInSeconds: when the invoice expires. If you give neither, the invoice has no expiry (expiresAt is null).
  • requestId: optional; see above.

InvoiceCreatedEvent carries walletId, requestId, invoiceId, addresses, amount, outputs, issuedAddresses (each address with its chain and derivation index), description, expiresAt, success and error.

The invoice actor checks pending invoices every 5 minutes and marks each one whose expiresAt has passed as expired (InvoiceCoordinatorActor, expirySweepInterval). A payment for an invoice that is no longer pending fails validation with “Invoice … is not pending”.

The coordinator has no command to cancel an invoice in 5.0.0. The invoice actor does accept one: CancelInvoiceMessage(invoiceId:, reason:) from package:libspiffy/libspiffy.dart, sent to libspiffy.invoiceCoordinator. Only a pending invoice can be cancelled.

The payer does not need the invoice in its own wallet. It passes the invoice id the payee sent, and the addresses and amount to pay.

final payment = await alice.coordinator.ask(PayInvoiceCommand(
walletId: 'alice-wallet',
invoiceId: invoiceId,
addresses: addresses,
amount: amount,
memo: 'Thanks!', // optional note journaled with the payment
));
// Send payment.beefBytes (and payment.txid) to the payee.

A payment that cannot be built (not enough funds, a plugin that fails) throws CoordinatorFailure; its event is the PaymentReadyEvent with success: false, or an ErrorEvent naming the request.

The coordinator selects coins, builds and signs the transaction, and collects the ancestors and merkle proofs the payee needs. It does not broadcast. Until the network holds the transaction, the wallet keeps its inputs on hold; see Deferred payments.

PayInvoiceCommand also takes outputs, changeAddress (by default the change goes to a fresh address on the change chain), paymentMetadata, counterpartyMarker (your own id for the payee), deadline (a UTC instant after which the wallet reclaims an outstanding payment) and privacy (a PaymentPrivacy that splits change and spreads inputs).

PaymentReadyEvent carries:

Field Meaning
invoiceId The invoice id you passed
txid The payment transaction
beefBytes The BEEF: the transaction, its unproven ancestors and their merkle proofs
amountPaid, changeAmount Satoshis paid and returned as change (all change parts together)
ancestorCount Ancestors in the BEEF
witnessTxid, witnessBeefBytes A paired witness transaction, when a plugin builds one; otherwise null
walletId, requestId The paying wallet, and the id of the PayInvoiceCommand
success, error Whether the payment was built, and why not
import 'package:convert/convert.dart'; // hex
try {
final verdict = await bob.coordinator.ask(ValidateBEEFCommand(
walletId: 'bob-wallet',
beefHex: hex.encode(beefBytes),
invoiceId: invoiceId,
fromCounterparty: 'alice', // optional, your own id for the payer
memo: 'Thanks!', // optional, the payer's note
));
if (!verdict.broadcasted) {
print('Recorded, not taken by ARC: ${verdict.broadcastError ?? verdict.networkStatus}');
}
} on CoordinatorFailure catch (failure) {
final answer = failure.event;
if (answer is BEEFValidationResultEvent && answer.awaitingHeader) {
// Not a verdict: the payment waits for a block header. See below.
} else {
print('Refused: ${failure.message}');
}
}

A payment that does not validate throws CoordinatorFailure, and so does one whose proofs name a block header you have not synced yet. That second case is not a refusal: its BEEFValidationResultEvent says awaitingHeader: true. The receive is stored, and when the header arrives (after a restart too) a second BEEFValidationResultEvent follows with the verdict. Nobody asked for that one, so its requestId is null. Follow it with on:

final verdict = await bob.coordinator
.on<BEEFValidationResultEvent>(walletId: 'bob-wallet')
.firstWhere((e) => e.txid == txid && !e.awaitingHeader);

SPVActor (see lib/src/actors/spv_actor.dart) runs these checks in order. The first one that fails ends the receive with valid: false.

  1. The transaction is in the BEEF.
  2. Merkle proofs against your headers.
    • If the payment carries its own merkle proof (it is mined), that proof must match the header you hold at its height.
    • If it does not, every proof in the BEEF must match your headers, and every input of the payment must chain back through the BEEF to a proven transaction. A BEEF that holds one real mined transaction next to a payment spending made-up outpoints fails here.
    • If you have no header yet at a proof’s height, nothing is decided. The BEEF is stored, the event says awaitingHeader: true, and the receive is replayed when the header arrives, after a restart too.
  3. Scripts. Each input of the payment must correctly spend the output it names, run through the script interpreter with the funding transaction taken from the BEEF.
  4. Which outputs pay you. With an invoiceId, an output pays the invoice when it pays one of the invoice’s addresses (P2PKH), or matches one of its multisig outputs. Without one, the wallet decides from its own addresses.
  5. The invoice. It must be pending (a repeat delivery of the payment that already paid it is accepted), and the outputs that pay it must add up to at least the invoice amount. More is accepted.
  6. Something in it is yours. A transaction that pays none of the wallet’s addresses and spends none of its outputs is refused.

The fee is worked out from the BEEF (inputs, read from the parent transactions in it, minus outputs) when the transaction spends outputs of this wallet.

Once the payment validates and the wallet’s read model holds it, the payee’s coordinator submits it to ARC with the BEEF it came in (in Extended Format). A payment that arrived with its own proof is already mined and is submitted nowhere.

BEEFValidationResultEvent is emitted after both steps:

Field Meaning
valid The payment is recorded and queryable
broadcasted ARC accepted the submission
networkStatus ARC’s status by its wire name (SEEN_ON_NETWORK, MINED, REJECTED, …)
broadcastError Why the submission failed, when it did
awaitingHeader No verdict yet; a second event follows
spendableUTXOs, unreadableOutputs The outputs credited, and outputs whose script could not be read
walletId, invoiceId, txid, error As named
requestId The ValidateBEEFCommand’s id; null for a verdict that followed a header

The invoice is marked paid only when the network holds the payment (SEEN_ON_NETWORK, SEEN_MULTIPLE_NODES or MINED), and not before. InvoicePaidEvent (walletId, invoiceId, txid, amountReceived) is emitted then. No request asks for it, so listen for it:

bob.coordinator.on<InvoicePaidEvent>(walletId: 'bob-wallet').listen((paid) {
print('Invoice ${paid.invoiceId} paid by ${paid.txid}: ${paid.amountReceived} sats');
});

If ARC answers while still processing, the invoice is marked paid later, when ARC reports the network has the payment.

SettleBEEFCommand(walletId:, beefHex:, txid:) broadcasts every transaction in a BEEF that has no merkle proof, in dependency order, through ARC. Use it when your own wallet is the one that should broadcast:

  • Self-pay operations such as a token issuance or an identity anchor, where there is no counterparty to hand the BEEF to.
  • A payment you settle yourself, such as a purchase that spends a counterparty’s output (a token listing). Hand the complete BEEF, the counterparty’s transactions included: each transaction goes to ARC with the BEEF, and Arcade refuses one it cannot extend (HTTP 460).

For a classic payment, the payee validates and broadcasts it (step 3), so the payer does not settle.

try {
final s = await alice.coordinator.ask(SettleBEEFCommand(
walletId: 'alice-wallet',
beefHex: hex.encode(payment.beefBytes),
txid: payment.txid,
));
print('submitted ${s.submittedCount}, already mined ${s.skippedCount}');
} on CoordinatorFailure catch (failure) {
final s = failure.event;
if (s is BEEFSettledEvent) print('failed ${s.failedTxids}: ${s.failureErrors}');
}

BEEFSettledEvent carries txid, requestId, success, error, submittedCount, skippedCount (transactions that already had a proof), failedCount, and failedTxids with failureErrors (same order). A settlement with any failed transaction is not a success, so ask throws. A settlement still waiting on ARC after 60 seconds counts the rest as failed. A second settlement of the same txid while one is running is refused.

Since 4.7.0, when the BEEF’s subject is a transaction your wallet recorded, settling also keeps the ancestry the BEEF carries for it, back to proven transactions with their merkle proofs. A later spend of the payment’s change can then build its own BEEF before the payment is mined. The settlement answers once the read model holds that ancestry.