Skip to content

Multi-output invoices

A multi-output invoice asks the payer for one transaction with several outputs: split payments, a multisig escrow, an OP_RETURN data record, or an output whose script a plugin builds. You describe each output with an InvoiceOutputSpec and pass the list as outputs to CreateInvoiceCommand and PayInvoiceCommand.

The basic invoice flow is on Invoices & SPV. This page covers what changes when an invoice has outputs.

InvoiceOutputSpec is a sealed class with four subclasses (see lib/src/models/invoice_output_spec.dart). Every spec has an amount in satoshis and an optional label.

The specs are exported from package:libspiffy/libspiffy.dart, not from package:libspiffy/coordinator.dart. Some names in the two libraries collide, so import the specs by name next to the coordinator API:

import 'package:libspiffy/coordinator.dart';
import 'package:libspiffy/libspiffy.dart'
show InvoiceOutputSpec, P2PKHOutputSpec, P2MSOutputSpec, OPReturnOutputSpec, PluginOutputSpec;
Spec Fields Builds Amount
P2PKHOutputSpec address A payment to an address Must be positive
P2MSOutputSpec publicKeys (hex), threshold A bare m-of-n multisig output Must be positive
OPReturnOutputSpec dataChunks, separateOutputs An unspendable OP_FALSE OP_RETURN data output Always zero
PluginOutputSpec pluginId, pluginScriptType, params An output whose locking script a registered plugin builds Must be positive

When an invoice has outputs, they take precedence over amount and numberOfAddresses, and the invoice’s amount is the sum of the outputs.

Give an address to pay a fixed address, such as a platform’s fee address. Give an empty address ('') and the payee’s wallet generates a fresh receive address for that output when it creates the invoice.

P2PKHOutputSpec(address: '', amount: BigInt.from(80000), label: 'Merchant');
P2PKHOutputSpec(address: platformFeeAddress, amount: BigInt.from(5000), label: 'Platform fee');

InvoiceCreatedEvent.outputs holds the specs with the generated addresses filled in, and InvoiceCreatedEvent.issuedAddresses lists only the addresses the wallet generated, not the ones you supplied.

// 2-of-3 escrow: buyer, seller, arbitrator.
P2MSOutputSpec(
publicKeys: [buyerKeyHex, sellerKeyHex, arbitratorKeyHex],
threshold: 2,
amount: BigInt.from(15000),
label: 'Escrow',
);

The invoice is refused (ask throws CoordinatorFailure) unless isValid holds: the threshold is at least 1 and at most the number of keys, there are at most 16 keys, and each key is a valid secp256k1 public key in hex (66 characters compressed or 130 uncompressed). totalKeys is the n of m-of-n.

The payer builds the script with the keys sorted (BIP67), and the payee matches it by comparing the key set and threshold, so the order you list the keys in does not matter.

import 'dart:convert';
OPReturnOutputSpec(dataChunks: [utf8.encode('order-12345')]);

Each chunk is a separate data push. By default all chunks go in one output; with separateOutputs: true each chunk gets its own output. The data must be non-empty and at most OPReturnOutputSpec.maxTotalDataSize (99,000) bytes in total. The amount is always zero.

PluginOutputSpec hands the locking script to a registered script plugin: the payer’s wallet calls the plugin’s createLockBuilder(spec). If the plugin is a TransactionBuilderPlugin and params holds an action it supports, the plugin builds the whole transaction instead.

PluginOutputSpec(
pluginId: 'mytoken',
pluginScriptType: 'token_v1',
params: {'tokenId': tokenId, 'ownerPKH': ownerPkhHex},
amount: BigInt.one,
);

The payment fails when no plugin with that pluginId is registered on the payer’s side.

final invoice = await bob.coordinator.ask(CreateInvoiceCommand(
walletId: 'bob-wallet',
outputs: [
P2PKHOutputSpec(address: '', amount: BigInt.from(80000), label: 'Merchant'),
P2PKHOutputSpec(address: platformFeeAddress, amount: BigInt.from(5000), label: 'Platform fee'),
P2MSOutputSpec(
publicKeys: [buyerKeyHex, sellerKeyHex, arbitratorKeyHex],
threshold: 2,
amount: BigInt.from(15000),
label: 'Escrow',
),
],
description: 'Order #12345',
expiresIn: const Duration(hours: 24),
));
// invoice.amount is 100000; invoice.addresses holds the P2PKH addresses only.

To send the specs to the payer, serialize each with toMap() and rebuild it with InvoiceOutputSpec.fromMap(map). The map’s type is p2pkh, p2ms, op_return or plugin; amounts are decimal strings and OP_RETURN chunks are hex.

The payer passes the specs back as outputs:

final payment = await alice.coordinator.ask(PayInvoiceCommand(
walletId: 'alice-wallet',
invoiceId: invoiceId,
addresses: addresses, // the invoice's P2PKH addresses
amount: amount, // the invoice total
outputs: outputs, // rebuilt with InvoiceOutputSpec.fromMap
));

The payment coordinator creates one transaction output per spec (or one per chunk for an OPReturnOutputSpec with separateOutputs), adds change, and answers with PaymentReadyEvent as for any payment. A payment it cannot build throws CoordinatorFailure; see Waiting for an answer. A P2PKH address of the other network (a testnet address in a mainnet wallet, or the reverse) is refused.

When the payee runs ValidateBEEFCommand with the invoice id, the SPV actor counts an output towards the invoice when:

  • P2PKH: it pays one of the invoice’s addresses;
  • P2MS: its threshold and key set equal those of one of the invoice’s multisig outputs;
  • plugin: a registered plugin recognises the script and the ownerAddress it reports is one of the invoice’s addresses.

The outputs that pay the invoice must add up to at least the invoice amount. OP_RETURN outputs pay nothing and are not counted.

A multisig output pays the invoice even when the payee’s wallet cannot spend it alone. It becomes a wallet UTXO only when the wallet holds at least m of its keys; otherwise the transaction is recorded but the output is not in the balance.