Skip to content

Payment channels

A payment channel lets a client pay a server many small amounts while only two transactions reach the chain: the funding and the settlement. This page shows how to configure channels, open and accept one, pay, close it, and recover the client’s funds when the server does not settle.

The client locks funds in a 2-of-2 output shared with the server. Before it broadcasts that funding, it holds a refund the server has signed, which returns everything to the client once the channel’s lock time passes.

Each payment is a new transaction spending the funding output, signed by the client and paying the server a larger share. The server checks what it pays, its fee and the client’s signature, and keeps its own signature to itself: only the server ever holds a fully signed payment, so the client cannot broadcast an older state.

The server settles by broadcasting its latest payment. It does this when either side closes the channel, and on its own when the settlement margin before the lock time begins.

A node that takes part in channels needs a peer id on your transport and a ChannelTiming. There is no default timing: without one, the node requests, accepts and pays no channels.

await libspiffy.initialize(
dataDirectory: './wallet-data',
arcConfig: arcConfig,
channelPeerId: myPeerId, // how your transport names this node
channelTiming: ChannelTiming(
settlementMargin: const Duration(hours: 1),
minimumLifetime: const Duration(days: 1),
),
);

ChannelTiming (lib/src/models/channel_timing.dart):

  • settlementMargin: within this time of the lock time, no payment is made or acknowledged, and the server settles. It must cover the broadcast, clock skew between the parties, and that the network judges a time lock against the median time of the last eleven blocks, not the clock. It must be positive.
  • minimumLifetime: a channel is requested or accepted only with at least this long to run. It must be longer than the margin. A server accepts only a lock time that is a time, not 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.

libspiffy owns no transport. The channel protocol speaks through two coordinator messages:

  • Outgoing: the coordinator emits a ChannelP2PMessageToSendEvent (toPeerId, messageType, payload). Send it to that peer.
  • Incoming: wrap what arrives in a P2PMessageReceived (fromPeerId, messageType, payload) and tell it to the coordinator. ChannelP2PReceived is the same message under its older name; both work.
// A sketch: `transport` is your own messaging layer.
libspiffy.coordinator
.on<P2PMessageToSendEvent>()
.listen((m) => transport.send(m.toPeerId, m.messageType, jsonEncode(m.payload)));
transport.onMessage((fromPeerId, messageType, body) {
libspiffy.coordinator.tell(P2PMessageReceived(
fromPeerId: fromPeerId,
messageType: messageType,
payload: (jsonDecode(body) as Map).cast<String, dynamic>(),
));
});

Listening for the base class P2PMessageToSendEvent also carries the merkle-proof recovery messages (proof_request, proof_response), which are not channel messages. Deliver messages in the order they were sent. fromPeerId must name the sender the same way channelPeerId names it on its own node: a message about a channel is accepted only from that channel’s counterparty, in that counterparty’s role.

The channel message types are channel_request, channel_accept, channel_reject, refund_sign_request, refund_signed, channel_open, payment_update, payment_ack, channel_close, channel_closed and channel_error. Treat the payloads as opaque.

Every channel command is a CoordinatorRequest: coordinator.ask sends it and completes with its own reply (see Waiting for an answer). A step that fails, including the server’s channel_reject or channel_error during an open, arrives as an ErrorEvent naming the request, and ask throws CoordinatorFailure.

Request Reply Default timeout
OpenChannelCommand ChannelOpenedEvent 5 minutes
AcceptChannelCommand ChannelAcceptedEvent 1 minute
RejectChannelCommand ChannelRejectedEvent 1 minute
ChannelPayCommand ChannelPaymentEvent 1 minute
CloseChannelCommand ChannelClosedEvent 5 minutes
ClaimChannelRefundCommand ChannelRefundClaimedEvent 3 minutes
ExpireChannelCommand ChannelExpiredEvent 3 minutes
RetryChannelFundingCommand ChannelFundingRetriedEvent 3 minutes
ResendChannelOpenCommand ChannelOpenResentEvent 1 minute

The open and the close wait on the counterparty over your transport, so their timeout is longer. A timeout does not cancel the step. Requests of one kind for one channel are answered in the order you made them.

The other side’s events are not replies to anything it asked: the server hears the channel open, each payment and the client’s close. Follow those with coordinator.on<E>(). Their requestId is null.

final channel = await alice.coordinator.ask(OpenChannelCommand(
walletId: 'alice-wallet',
serverPeerId: bobPeerId,
fundingAmountSats: 100000,
lockTimeDurationSeconds: 7 * 24 * 3600,
counterpartyMarker: 'bob', // optional; defaults to the server's peer id
));
final channelId = channel.channelId; // use it from now on

The client sends channel_request, gets the refund signed, then broadcasts the funding transaction with its unconfirmed ancestors. Once the network holds the funding, the channel is open on the client: ask returns its ChannelOpenedEvent (walletId, requestId, channelId, fundingTxId, fundingAmountSats), and the client sends channel_open. The server SPV-validates the funding, submits it to ARC, and opens the channel once ARC reports the network holds it. It then emits its own ChannelOpenedEvent.

The server’s app hears ChannelRequestReceivedEvent and decides:

bob.coordinator.on<ChannelRequestReceivedEvent>().listen((r) async {
try {
if (!wantsChannel(r)) {
await bob.coordinator.ask(RejectChannelCommand(channelId: r.channelId, reason: 'not now'));
return;
}
await bob.coordinator.ask(AcceptChannelCommand(
channelId: r.channelId,
walletId: 'bob-wallet',
clientPeerId: r.clientPeerId,
clientPubKey: r.clientPubKey,
clientAddress: r.clientAddress,
fundingAmountSats: r.fundingAmountSats,
lockTimeUnix: r.lockTimeUnix,
));
} on CoordinatorFailure catch (failure) {
print('Channel ${r.channelId}: ${failure.message}');
}
});
bob.coordinator.on<ChannelOpenedEvent>(walletId: 'bob-wallet').listen((o) {
print('Channel ${o.channelId} open, funded by ${o.fundingTxId}');
});

wantsChannel is your own policy. ChannelAcceptedEvent means the acceptance is journaled and channel_accept handed to your transport; the channel opens when the client funds it. ChannelRejectedEvent.clientTold says whether a request from that client was held and the client told.

final paid = await alice.coordinator.ask(ChannelPayCommand(
channelId: channelId,
walletId: 'alice-wallet',
amountSats: 1000,
purpose: 'room service', // optional
));
print('payment ${paid.sequence}: client ${paid.clientBalance}, server ${paid.serverBalance}');

Both sides emit ChannelPaymentEvent (channelId, amountSats, sequence, clientBalance, serverBalance): the client when it records the payment and sends it, as the reply to its ask; the server when it acknowledges it. sequence counts payments from 1. On the client the event means the payment was sent; on the server it means the payment was checked and accepted. A payment the channel refuses, such as one above the client’s balance, throws.

bob.coordinator.on<ChannelPaymentEvent>().where((p) => p.channelId == channelId).listen((p) {
print('received payment ${p.sequence}: ${p.amountSats} sats');
});

Each party’s share is paid as long as it is at least PaymentChannelBuilder.minimumOutputSats (1 sat); only a share of nothing is left out. BSV has no dust limit, and since 4.6.1 a small share is no longer dropped. The server refuses a payment whose output to the server does not carry its whole balance.

Either side can close:

final closed = await alice.coordinator.ask(CloseChannelCommand(channelId: channelId, reason: 'checkout'));
print('settled in ${closed.settlementTxId}');

The server broadcasts its latest payment as the settlement and hands it to the client in channel_closed; the client records its share. Both sides emit ChannelClosedEvent (channelId, reason, settlementTxId); the side that asked gets it as its reply. If nobody closes, the server settles by itself when the settlement margin begins. That close answers no request: follow it with on<ChannelClosedEvent>().

A settlement ARC does not take leaves the channel closing. The failure is an ErrorEvent: it names your close request, so ask throws CoordinatorFailure, and a failure no request caused (the settlement timer’s) carries no requestId. Closing again retries it, and so does a restart.

Since 4.7.0 the settlement goes to ARC with a BEEF of the funding transaction and the spend. Before, it went without its parent, and Arcade refused it (HTTP 460).

Recover the funds when the server does not settle (client)

Section titled “Recover the funds when the server does not settle (client)”
final claimed = await alice.coordinator.ask(ClaimChannelRefundCommand(channelId: channelId));
print('refund ${claimed.refundTxId} broadcast');

ChannelRefundClaimedEvent carries channelId, refundTxId, success and error; a claim that fails throws.

The library broadcasts the fully signed refund the client holds, journals the claim and records the refund’s output in the wallet as an unproven receive. Pass refundTxHex only to name a specific refund; it must spend the channel’s funding output.

Claim it only once the chain’s median time past, read from your 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, nodes treat the refund as non-final and drop it for any final spend of the funding, such as the server’s settlement. A refund rejected as a double spend is final: nothing is journaled as claimed, and it is never retried at a higher fee.

Since 4.7.0 the refund, like the settlement, goes to ARC with a BEEF of the funding transaction.

await alice.coordinator.ask(ExpireChannelCommand(
channelId: channelId,
observedBy: 'client', // or 'server'
settlementOrRefundTxId: txid, // optional: the transaction you saw end it
));

libspiffy does not watch lock times for you. Your app sends ExpireChannelCommand when it sees a channel past its lock time. It journals the expiry and records the transaction that ended the channel and pays this side (the client’s refund, or the server’s settlement) in the wallet, without broadcasting anything. It converges with ClaimChannelRefundCommand: whichever runs first records the refund, and the other records nothing new. The reply is ChannelExpiredEvent; an expiry that cannot be recorded is a reply with success: false, and ask throws.

Repair an open that did not finish (client)

Section titled “Repair an open that did not finish (client)”

At startup the coordinator emits UnfinishedChannelsFoundEvent (follow it with on<UnfinishedChannelsFoundEvent>()) (walletId, channels) for each wallet with channels that started opening and never reached open. Each UnfinishedChannel has channelId, state (opening or funding), counterpartyPeerId, fundingAmountSats and lockTimeUnix. The event only reports; nothing is retried for you. Your options:

Situation Command Answer
The funding broadcast failed (channel in refundSigned) RetryChannelFundingCommand(channelId:) ChannelFundingRetriedEvent
The funding is on the network but the server never got channel_open ResendChannelOpenCommand(channelId:) ChannelOpenResentEvent
You want the funding inputs back CancelDeferredPaymentCommand on the funding txid DeferredPaymentCancelledEvent

A retry re-broadcasts the same funding transaction read from the channel’s journal; its inputs stay reserved until ARC accepts it, so it cannot double-spend them. A resend journals nothing and changes nothing about the channel. Neither sends the counterparty a channel_error. See Deferred payments for cancelling.

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 fails the step and nothing is recorded; repeating the step retries it. When ARC answers while still processing, the library asks again until it gives a verdict.