Skip to content

Deferred payments

This page shows how to manage a payment after PayInvoiceCommand has built it and you have handed the BEEF to the recipient: check what the network knows, broadcast it yourself, cancel it, reclaim the coins, or complete a transaction the counterparty co-signs.

PayInvoiceCommand signs a payment but does not broadcast it. The recipient normally does (see Invoices & SPV). Until the network has the transaction, the payer’s wallet holds its inputs: they are reserved for that transaction with no expiry, and neither the reservation cleanup nor another payment can take them. Its change is not spendable yet either.

The hold ends when:

  • ARC reports the transaction SEEN_ON_NETWORK, SEEN_MULTIPLE_NODES or MINED: the inputs are spent and the change becomes available;
  • ARC reports REJECTED, or the wallet proves an input was already spent in a block (INPUT_SPENT): the payment fails and the inputs are released;
  • you cancel it, or your reclaim of it reaches the network.

DOUBLE_SPEND_ATTEMPTED is not a verdict: either transaction may still be mined, so the payment stays outstanding with its inputs held. Every step is journaled, and nothing is deleted: resolved payments stay listable.

DeferredPaymentState:

State Meaning
outstanding Signed and handed over; not known to be on the network. Inputs held.
seen On the network. Not yet a confirmation.
mined Confirmed by a merkle proof checked against your header chain.
failed REJECTED (or an input proven spent); inputs released.
cancelled You cancelled it while the network did not know it; inputs released.
reclaimed Your self-spend of its inputs is on the network.
completed A half-signed payment the counterparty completed; the completed transaction took over the hold.

Each command on this page is a CoordinatorRequest: send it with coordinator.ask, which completes with its own reply (see Waiting for an answer). The reply carries the command’s requestId, which you can pass or leave to be generated.

Request Reply Default timeout
GetDeferredPaymentsQuery DeferredPaymentsResponse 1 minute
CheckDeferredPaymentStatusCommand DeferredPaymentStatusEvent 3 minutes
BroadcastDeferredPaymentCommand DeferredPaymentBroadcastEvent 3 minutes
CancelDeferredPaymentCommand DeferredPaymentCancelledEvent 3 minutes
ReclaimDeferredPaymentCommand DeferredPaymentReclaimedEvent 6 minutes
CompleteDeferredPaymentCommand DeferredPaymentCompletedEvent 1 minute

A reply with success: false makes ask throw CoordinatorFailure. To read the fields of a failed reply (the network status, the competing txids), take it from the failure’s event:

try {
final cancelled = await alice.coordinator.ask(CancelDeferredPaymentCommand(
walletId: 'alice-wallet',
txid: txid,
reason: 'customer left',
));
print('released ${cancelled.releasedUtxoKeys}');
} on CoordinatorFailure catch (failure) {
final refused = failure.event;
if (refused is DeferredPaymentCancelledEvent) {
print('not cancelled (${refused.networkStatus}): ${refused.error}');
}
}

Each answer is emitted once the wallet’s read model shows its effect, so a query you make on hearing it sees the new status, the spent inputs and the change.

final page = await alice.coordinator.ask(GetDeferredPaymentsQuery(
walletId: 'alice-wallet',
olderThan: const Duration(hours: 1), // not broadcast after an hour
));
for (final p in page.payments) {
print('${p.txid} ${p.state} ${p.lastNetworkStatus}');
}
// page.nextCursor: pass it as `cursor` for the next page; null on the last one.

By default the query lists outstanding payments only, newest first, 50 per page (limit, 1 to 1000), each with its BEEF rebuilt from storage (includeBeef: true). Filters: states or includeResolved, createdBefore, createdAfter, olderThan, lastNetworkStatuses, invoiceId, recipientAddress, dueBefore (deadline at or before), cursor and oldestFirst.

Each DeferredPaymentDetail has txid, invoiceId, recipientAddresses, amount, fee, createdAt, heldInputs, lastNetworkStatus, lastCheckedAt, competingTxids, state, purpose, resolutionReason, rawTxHex, beef and beefError. A reclaim’s own self-spend is listed too: isReclaim is true and reclaimsTxid names the payment it reclaims.

The wallet’s ARC actor asks ARC about outstanding payments on a schedule. To ask now:

final status = await alice.coordinator.ask(
CheckDeferredPaymentStatusCommand(walletId: 'alice-wallet', txid: txid));
print('${status.networkStatus} via ${status.source}, confirmed: ${status.confirmed}');

via chooses the source: DeferredPaymentNetworkSource.arc (the default), dataSource (the configured BlockchainDataSource), or arcThenDataSource. The wallet is updated as for a scheduled check. A MINED answer confirms the payment only when its merkle proof matches your local headers, whichever source gave it.

DeferredPaymentStatusEvent carries success (a source answered), networkStatus, source, blockHeight, proofStatus (verified, headerUnknown, rootMismatch, malformed, or null), confirmed, competingTxids and error. NOT_FOUND is not a failure: the recipient may still broadcast. ask throws only when no source answered.

When the recipient is slow:

final sent = await alice.coordinator.ask(
BroadcastDeferredPaymentCommand(walletId: 'alice-wallet', txid: txid));
print('${sent.networkStatus} via ${sent.source}');

The payment’s unproven ancestors, from the BEEF rebuilt from storage, go first. The command is idempotent: a transaction the network already has is reported as such. With via including the data source, a transaction ARC refuses is submitted to the BlockchainDataSource.

success means the network holds the transaction. If ARC answers while still processing (Arcade answers every submission RECEIVED), the wallet asks again after 1, 2, 4, 8 and 15 seconds, about 30 seconds in all, and stops at the first answer that is a verdict. A transaction still in flight then, in the orphan mempool, contested or rejected is reported with success: false and the reason in error, so ask throws. willRetry on that failed reply says the broadcast was queued for a durable retry.

The sample under Asking and answers cancels a payment. On success, DeferredPaymentCancelledEvent.releasedUtxoKeys lists the inputs released.

The network is checked first. The cancellation is refused when the network knows the transaction (any status except NOT_FOUND and DOUBLE_SPEND_ATTEMPTED), and when the check fails, unless you pass force: true. force never cancels a transaction the network knows.

try {
final reclaim = await alice.coordinator.ask(ReclaimDeferredPaymentCommand(
walletId: 'alice-wallet',
txid: txid,
reason: 'recipient never broadcast it', // optional, recorded as the resolution reason
));
print('${reclaim.reclaimedSatoshis} sats back in ${reclaim.reclaimTxid}, fee ${reclaim.fee}');
} on CoordinatorFailure catch (failure) {
final lost = failure.event;
if (lost is DeferredPaymentReclaimedEvent) {
print('reclaim ${lost.reclaimTxid} not taken; competing: ${lost.competingTxids}');
}
}

A reclaim spends the held inputs back to a wallet address and broadcasts that self-spend at once. There is no confirmation step and no dry run: warn your user first if your app wants that. The self-spend pays ARC’s standard policy fee. BSV has no replace-by-fee, and the first spend seen wins: if the recipient’s copy reached the network first, the reclaim is the one refused, and competingTxids names their transaction.

DeferredPaymentReclaimedEvent carries txid, requestId, reclaimTxid, success, reclaimedUtxoKeys, reclaimedSatoshis (the held satoshis less the fee), fee, toAddress, networkStatus, source, competingTxids and error.

The payment becomes reclaimed only when the network has the self-spend, never at broadcast. If the reclaim’s answer is not a success (ask throws), the payment stays outstanding with its inputs now held by the self-spend. It resolves as reclaimed if the network reports the self-spend later; watch reclaimTxid with CheckDeferredPaymentStatusCommand. If the self-spend fails (a competing spend wins, or ARC answers REJECTED), its inputs go back to the payment it reclaimed, which is plain outstanding again.

Give PayInvoiceCommand a deadline (a UTC DateTime) and the wallet reclaims the payment by itself if it is still outstanding then. The coordinator sweeps every wallet once a minute (LibSpiffyActorSystem.initialize(deadlineSweepInterval:)) and answers each reclaim with a DeferredPaymentReclaimedEvent whose requestId is deadline-<txid>. No request of yours asked for it, so follow these with on:

alice.coordinator
.on<DeferredPaymentReclaimedEvent>(walletId: 'alice-wallet')
.where((e) => e.requestId?.startsWith('deadline-') ?? false)
.listen((e) => print('deadline reclaim of ${e.txid}: ${e.success ? 'taken' : e.error}'));
``` A payment the network took, that its
counterparty completed, or that you cancelled before the deadline is left alone. Until the reclaim is mined,
the holder can still broadcast the original, and the two race as any reclaim does.
## Complete a half-signed payment
A sale in one transaction is signed by two wallets. The first signs its own inputs and records a half that
cannot be broadcast yet; it holds those inputs as a deferred payment. When the counterparty returns the
transaction with their signatures added:
```dart
final completed = await alice.coordinator.ask(CompleteDeferredPaymentCommand(
walletId: 'alice-wallet',
txid: halfTxid,
rawHex: completedTxHex,
));
print('the hold moved to ${completed.completedTxid}');

The wallet refuses unless the completed transaction has the same version, lock time, inputs, sequences and outputs, and your own unlocking scripts unchanged. The completed transaction takes over the hold and the half becomes completed. Nothing is broadcast: settle the completed transaction as any other deferred payment, or let the counterparty broadcast it.

Arcade, the Teranode-era ARC, refuses a later double spend outright with REJECTED, where ARC held it as DOUBLE_SPEND_ATTEMPTED. It likewise refuses a spend of an output already spent in a block. The localnet tests (test/integration/localnet_deferred_e2e_test.dart) show what each wallet sees:

  • You reclaim first, the recipient submits later. Your reclaim succeeds and the payment is reclaimed. The recipient’s ValidateBEEFCommand still answers valid: true (the BEEF is well formed), but with networkStatus: REJECTED; their balance does not count it and their invoice is not paid.
  • The recipient submits first, you reclaim later. Your reclaim answers success: false, so ask throws. Either the wallet already heard the payment is on the network and builds no reclaim (“not outstanding”), or the reclaim is refused as REJECTED or DOUBLE_SPEND_ATTEMPTED with the payment in competingTxids. When the block arrives, both wallets record the recipient’s copy.
  • You cancel, the recipient broadcasts later. Your funds are released at once. Once the recipient’s copy is on the network, a status check of the payment finds it there: the payment becomes seen, your inputs are spent and only the change is yours.

A REJECTED answer is final: the payment is failed and its inputs released. DOUBLE_SPEND_ATTEMPTED keeps the payment outstanding, so you may see either depending on which ARC you use.