CZAC (Cross-Zone Asynchronous Channels) is Sigil’s settlement-layer payment channel primitive. Open once on-chain, exchange signed off-chain updates indefinitely, settle the final balance on-chain. Channels are bidirectional and bounded by the initial deposit. For background see CZAC_SPECIFICATION.md.

When to use this

  • Streaming micropayments where on-chain fees would exceed the payment value.
  • Per-call billing between agents or services.
  • Pre-funded escrow with multi-step settlement.

Prerequisites

  • Both counterparties have funded DIDs.
  • An off-chain channel between the two parties to exchange signed updates (HTTPS, WebSocket, or Sigil Mail).

Recipe

1

Open the channel

Either party opens it. The opener deposits the entire channel capacity up front; the receiver may top up later via CzacDeposit.
await signAndSend({
  CzacOpen: {
    channel_id: 'ch:alice-bob-1',
    counterparty_did: 'did:oas:sigil:agent:bob...',
    initial_balance_a: 100_000_000,     // sender's side, 100 MINT
    initial_balance_b: 0,                // receiver's side
    timeout_blocks: 100_800,             // ~1 week at 6s blocks
    nonce_seed: 0,
  },
});
On success the chain locks the deposit in escrow and returns a channel id.
2

Exchange off-chain updates

Each update is a signed message that supersedes the previous one. The canonical update transcript is BLAKE3 of:
"czac/update/v1\n"
|| channel_id
|| u64 update_nonce         (big-endian)
|| u64 balance_a            (big-endian)
|| u64 balance_b            (big-endian)
Both parties sign each update. Persist the most recent fully-signed update — it’s your proof during settlement.
import { blake3 } from '@noble/hashes/blake3';
import * as ed25519 from '@noble/ed25519';
import { bytesToHex } from '@noble/ciphers/utils';

function updateDigest(channelId: string, nonce: bigint, balA: bigint, balB: bigint): Uint8Array {
  const enc = new TextEncoder();
  const buf = new ArrayBuffer(8 * 3);
  const view = new DataView(buf);
  view.setBigUint64(0, nonce, false);
  view.setBigUint64(8, balA, false);
  view.setBigUint64(16, balB, false);

  const tag = enc.encode('czac/update/v1\n');
  const idBytes = enc.encode(channelId);
  const numeric = new Uint8Array(buf);
  const transcript = new Uint8Array(tag.length + idBytes.length + numeric.length);
  transcript.set(tag, 0);
  transcript.set(idBytes, tag.length);
  transcript.set(numeric, tag.length + idBytes.length);
  return blake3(transcript);
}

async function signUpdate(privateKey: Uint8Array, ...args: Parameters<typeof updateDigest>) {
  const digest = updateDigest(...args);
  const sig = await ed25519.sign(digest, privateKey);
  return bytesToHex(sig);
}
Off-chain protocol (pseudocode):
  1. Sender drafts { nonce: N, balance_a: prev_a - amount, balance_b: prev_b + amount }.
  2. Sender signs and transmits.
  3. Receiver verifies, counter-signs, and transmits back.
  4. Both parties persist the doubly-signed state.
3

Cooperative close

When both parties agree to settle, either submits the most recent doubly-signed update.
await signAndSend({
  CzacClose: {
    channel_id: 'ch:alice-bob-1',
    final_nonce: 42,
    final_balance_a: 30_000_000,
    final_balance_b: 70_000_000,
    signature_a: signatureAHex,    // signature over the digest above
    signature_b: signatureBHex,
  },
});
The chain pays out the channel reserves accordingly.
4

Uncooperative dispute (optional)

If a counterparty disappears or tries to settle on a stale state, the other party submits the highest-nonce state they have:
await signAndSend({
  CzacDispute: {
    channel_id: 'ch:alice-bob-1',
    update: {
      nonce: 42,
      balance_a: 30_000_000,
      balance_b: 70_000_000,
    },
    signature_a: signatureAHex,
    signature_b: signatureBHex,
  },
});
A dispute window of timeout_blocks / 4 opens. If the counterparty posts a higher-nonce state during the window, that one settles instead. After the window, the highest-nonce state on chain pays out.

Common errors

SymptomCauseFix
channel already openReused idUse unique ids — e.g. include block height in the suffix
signature verification failedWrong public key or message bytesMatch the transcript byte-for-byte; both sides must use the same big-endian encoding
update nonce not strictly increasingReplayed an older stateSubmit the highest-nonce signed update you hold
timeout expiredChannel went staleOpen a new channel; the old escrow returns to the opener

See also