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).
The flow at a glance
Section titled “The flow at a glance”| 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.
Waiting for an answer
Section titled “Waiting for an answer”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, orvalid: falsefor a BEEF), or anErrorEventnames the request,askthrowsCoordinatorFailureinstead. - Each request waits for its own reply. A request gets a
requestIdwhen you create it: the one you pass, or a generated one. Its reply carries the same id, and so does anErrorEventit causes. Two invoices created for one wallet at the same time each get their own answer. If you pass your ownrequestId, use a new one for each request. - A timeout does not cancel.
askthrowsTimeoutExceptionwhen no reply arrives within the request’sreplyTimeout. Passtimeout: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.
1. The payee creates an invoice
Section titled “1. The payee creates an invoice”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.amountCreateInvoiceCommand takes:
walletId(required): the wallet that receives the payment. It generates a fresh receive address for each ofnumberOfAddresses.amount: the total, in satoshis. Or giveoutputsinstead; see Multi-output invoices.description,invoiceMetadata: your own labels.expiresIn(aDuration) orexpiresInSeconds: when the invoice expires. If you give neither, the invoice has no expiry (expiresAtis 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.
Expiry and cancellation
Section titled “Expiry and cancellation”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.
2. The payer builds the payment
Section titled “2. The payer builds the payment”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 |
3. The payee validates the BEEF
Section titled “3. The payee validates the BEEF”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);What the SPV actor checks
Section titled “What the SPV actor checks”SPVActor (see lib/src/actors/spv_actor.dart) runs these checks in order. The first one that fails ends
the receive with valid: false.
- The transaction is in the BEEF.
- 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.
- 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.
- 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. - 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.
- 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.
Broadcast and InvoicePaidEvent
Section titled “Broadcast and InvoicePaidEvent”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.
When to use SettleBEEFCommand instead
Section titled “When to use SettleBEEFCommand instead”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.
Related
Section titled “Related”- Multi-output invoices
- Deferred payments: what the payer’s wallet does until the payment is on the network
- API reference