Chio/Docs
LOGIN · JOIN

Payment Channels

Reserve capacity for repeated calls within one funded channel and settle its cumulative signed balance when the channel closes.

Concept chio@5f63e6e

On this page

Fund once, then pay for many small calls: a payment channel lets a buyer and a provider meter each call against one deposit and settle a single signed balance when they finish.

Per-call settlement suits occasional calls. For a high-frequency, low-value stream between two fixed parties, the settlement cost of each call can exceed its value, so the channel funds one bounded escrow up front, reserves before each dispatch, accumulates one cumulative amount, and releases once at close. The running example funds 1,000 units and pays for two calls.

One deposit carries every call, one reservation at a time

The funded bound is 1,000 units. Each call reserves its maximum before it runs, and its charge adds to one cumulative total.

One funded bound, two callsChoose a move.

One live reservation at a time

Each call reserves against the channel’s funded bound of 1,000 before it runs: 100 for call one, 150 for call two. The channel holds one live reservation at a time. Reserve before the call.

Two sequential calls against a 1,000-unit funded channel bound, in protocol units. The amounts are illustrative; the asset conversion into token base units is left out.

Call amounts are protocol units. The channel’s asset binding converts the bound and every total into the escrow’s token base units, and amounts cross between the two only through that binding. The two calls run in sequence:

  1. Open. Bound 1,000, cumulative 0, available 1,000. The funding evidence, both parties’ open consent, the funding acknowledgement, and the admitted open come first.
  2. Reserve call 1. The payer signs a one-shot reservation of at most 100 against the expected state and fence, with an expiry, the request, and the service binding.
  3. Complete call 1. The charge is 85, so the cumulative total is 85 and 915 remain. The receipt binding and the payee-signed successor state complete the call, with no payer signature after service.
  4. Reserve and complete call 2. A new reservation of 150 against the successor state, then a second verified transition: charge 115, cumulative 200, available 800.
  5. Close. The close allocates 200 to the beneficiary and the remaining 800 to the refund destination, in token base units. Release and refund execution, and their reconciliation, follow as separate steps.

The payer consents before dispatch, and the route is fixed then

Before a covered dispatch, the payer signs a chio.channel.reservation.v1 for the exact next state, persisted before the tool runs. It binds the request and operation ids, the next sequence, the expected prior state digest, the service binding, a maximum charge, an expiry, and the state version and lifecycle fence it expects. Its id derives from the channel id, the open digest, the request id, the sequence, and the prior state digest, so a body naming another sequence hashes to another id.

The maximum is at most bound.units - prior.cumulative_owed.units, computed with checked arithmetic. The lifecycle head admits one live reservation per channel: its status is open with liveReservationId and operationId both absent. Consent given before service closes the gap a post-service payer signature would open, where a payer consumes the tool, withholds the update, and recovers the value at expiry.

The settlement route is chosen at the same moment, because a post-persist observer runs after the tool has delivered value. A positive-charge channelized receipt commits to one immutable ObligationAtomV1 whose disposition is Channelized, carrying the channel and reservation ids, in the shared obligation store. The per-call settlement observer never builds a per-call observation from a receipt with valid ChannelReceiptMetadataV1; with no channel handler configured it returns the permanent failure channel settlement handler is not configured, and malformed metadata fails as channel metadata is malformed.

Each admitted call appends one payee-signed cumulative state

A chio.channel.state.v1 body carries the running total, the appended receipt-id root, and every digest the transition proved. The consumed reservation is the payer’s authority, so the wrapper carries only the payee signature. The protocol-unit total is a MonetaryAmount capped at the 53-bit I-JSON safe integer; the token-base-unit total is a string parsed to u128, at most 39 digits with no leading zero. A later state is admitted when:

  1. seq == prior.seq + 1 under checked arithmetic capped at the safe-integer bound, and the payer-signed reservation names that sequence and the prior digest.
  2. prevStateDigest equals the prior state’s digest: the signed wrapper’s once a payee signature exists, the bare body’s at sequence zero.
  3. The receipt-id root is the prior root and the new receipt id hashed under the receipt-root domain, appending exactly the reservation-bound trusted-kernel receipt.
  4. cumulativeOwed is the prior total plus the receipt’s cost_charged, with the charge inside the reservation’s maximum and the total inside the bound in both protocol units and token base units.
  5. The whole body equals what build_channel_state_transition rebuilds from the prior state, the reservation, the receipt binding, and the open consent. The verifier compares structs, and a state that differs in any field is rejected.

The receipt binding is checked first: the trusted kernel key and a verifying signature, channel metadata matching the reservation field for field, settlement mode Channelized, and no payment reference. A zero-charge receipt is admitted when it is allowed or denied with settlement status NotApplicable; a charging receipt only when it is allowed with status Pending. A zero-charge or denied call consumes its reservation and advances the chain with an unchanged total, because the state carries an obligation digest exactly when the charge is non-zero.

An interrupted call resolves on its own reservation

One ChannelLifecycleViewV1, the lifecycle head, is the single concurrency fence for service admission and close. Each interruption resolves through it:

  • Another reservation is live. Resolve its terminal outcome before reserving the next call. Calls run in sequence, and available balance never bypasses the live-reservation fence.
  • A denied or zero-charge call completes. Commit the verified terminal outcome and successor state. The reservation is consumed and the sequence advances; the cumulative charge stays where it was and no charge obligation is created.
  • The response is lost after dispatch. Recover the original operation, reservation, and effect slot, then reconcile the observed outcome. A new request id proves nothing about whether the first dispatch took effect, and protocol replay leaves deduplication of an arbitrary external tool to that tool.
  • A close races outstanding work. Resolve the live reservation and read a current verified lifecycle snapshot. Close construction rejects a live reservation or a mismatched head and fence.

A retry lands on the same effect slot: derive_channel_service_dispatch_idempotency_key hashes the operation id, the reservation id, and the sequence under a dedicated domain. A competing state at the same sequence loses to the admitted one, because admission compares the candidate with the head’s latestStateDigest and latestSequence and requires the state version and fence to be the reservation’s expected values plus one.

Close settles one balance, by agreement or under dispute

The payee closes over the newest contiguous, reservation-backed, trusted-receipt-derived state and always signs the close body, so a payer who disappears after dispatch leaves the closeable balance intact.

  • Cooperative close. A body whose closeKind is cooperative requires the payer signature. The payer signs the same body, whose final cumulative amount is copied from the state the head already points at.
  • Contested close. A contested body opens the dispute duration committed in the open intent. A chio.channel.dispute.v1 replaces the posted state only with a strictly higher sequence, proved by a state chain rooted at the close’s own final state digest.

The close body is computed from the head. The head must be open with no live reservation; the expected release comes from the effective cumulative amount and the refund is bound_token_base_units - expected_release_token_base_units under checked subtraction. A FROST quorum of 2 of 3 then authorizes the close under the channel.close action class, binding the exact state digest, sequence, versions, fence, and release. Finalization and release complete before closeSubmissionCutoffUnixSecs, which leaves the policy’s finality and broadcast margin before the escrow deadline.

Release runs through the ChioEscrow contract in the web3 module, which is off by default and sits beside the other payment rails outside the kernel. The close fixes the intended split; reconciliation records the realized split the chain reports, and the contract refunds deposited - released as it observed it. The contract’s releaseWithSignature path and its admin calls setPaused, setTokenAllowed, and transferAdmin sit outside the channel records, a trust assumption the operator holds. Treat a close allocation as completed payment once Reconciliation has closed it.

Channel records and validators

Transitions and validators

TransitionEntrypointsResult
Admit the funded relationship
verify_channel_open_consent, verify_admitted_channel_open
Verified bilateral consent and an admitted open tied to reserved funding.
Reserve before dispatch
verify_channel_reservation_proposal, verify_admitted_channel_reservation
One live reservation against the expected head and lifecycle fence.
Record dispatch
compose_channel_dispatch_transition
The authorized transition and its effect-slot identity, persisted before the service runs.
Commit the terminal outcome
build_channel_state_transition, verify_channel_state_transition
The receipt binding verified and the cumulative state advanced under the admitted reservation.
Close
build_channel_close_body, verify_channel_close
No live reservation; final state, escrow snapshot, signatures, and deadlines agree.

The release a close produces executes and reconciles separately. The host connects admission, durable state transitions, external dispatch, and reconciliation before it carries channel traffic.

Channel records

Every channel body is RFC 8785 canonical JSON with deny_unknown_fields, camelCase keys, and a versioned schema id, signed as a separate wrapper around the body. The files live under spec/schemas/chio-economy/; each registered kind in spec/schemas/registry.json uses the channel_ prefix and is introduced by micro-escrow-channels-v1.

  • chio.channel.funding-evidence.v1 One block-pinned read of the escrow: the immutable terms, the deposited, released, and refunded state, the creation event, an identity-registry observation, a token observation, the asset binding, and the finality pin. Every observation repeats the pin’s own block number and block hash.
  • chio.channel.open-intent.v1 The proposal both parties sign: identities and keys, refund and beneficiary addresses, currency, the immutable bound, the dispute tier copied from policy, the close-submission cutoff, the escrow reference, and the funding-evidence digest.
  • chio.channel.funding-acknowledgement.v1 The funding authority moves the escrow reference from unreserved to opening against the open-intent digest, incrementing the reservation version by exactly one.
  • chio.channel.open.v1 Binds the open-intent and acknowledgement digests, carries the derived channel id, and commits the sequence-zero state digest.
  • chio.channel.reservation.v1 The payer’s one-shot authorization for the exact next state, carrying the request, the service binding, a maximum charge, an expiry, and the channel-state version and lifecycle fence it expects.
  • chio.channel.state.v1 One cumulative state per admitted call: the running total, the appended receipt-id root, and every digest the transition proved.
  • chio.channel.terminal-outcome-commitment.v1 The signed outcome of one dispatched reservation: the operation, the reservation and its digest, the receipt and its digest, and the terminal result.
  • chio.channel.close.v1 The final allocation: close kind, final state digest and sequence, the expected release and post-release refund in token base units, the dispute deadline, and the versions and fence it was computed against.
  • chio.channel.dispute.v1 A contested-close challenge: the close it answers, the competing state and its sequence, the state-chain proof digest, and a reason.
  • chio.channel.release-authorization.v1 The publisher’s record of the broadcast: the FROST-bound authority, the publication root, and two distinct mutation bindings for the root publication and the release call.
  • chio.channel.transition-replay.v1 A replay descriptor pinning the authorities an offline verifier needs: open trust, funding authority, reservation authority, trusted kernel key, and the anchor.

Three schema constants govern records the registry leaves out, because they describe anchored state or a binding: chio.channel.lifecycle.v1 for the channel head, chio.channel.escrow-reservation.v1 for the escrow head, and chio.channel.asset-binding.v1, the currency-to-token binding every money field round-trips through.

Funding evidence and open

A channel opens on a rail that has already locked the full bound: a fully funded ChioEscrow deposit. A budget reservation alone opens nothing. Before either party signs the open intent, both verify the chio.channel.funding-evidence.v1 from a single block-pinned escrow read. It binds the complete immutable ChannelEscrowTermsV1, the deposited, released, and refunded state, the decoded EscrowCreated event, an identity-registry observation that the named operator is active with that exact key hash, a token observation that the token is allowed, and a trusted ChannelAssetBindingV1 carrying currency, both decimal scales, chain id, token address, token symbol, and the settlement-policy digest.

Every observation repeats the pin’s block number and block hash. The pin reports L1Finalized finality with observed confirmations equal to finalized_head_number - block_number and at least the configured minimum. An inactive operator, a disallowed token, an operator-key-hash mismatch, or a block timestamp later than the observation time rejects the evidence before any signature is checked.

The bound converts through the asset binding’s verify_round_trip, which recomputes token base units from the MonetaryAmount and back, and rejects with InexactAmount unless both directions agree, so terms.maxAmount == deposited == bound_token_base_units. Nothing may already be released or refunded; the escrow beneficiary equals the channel payee and the depositor equals the payer’s refund address; token, currency, both decimal scales, policy digest, chain id, and contract address match the configured funding authority. The dispute tier is selected once from the immutable bound and copied into the intent, and the close-submission cutoff equals the escrow deadline minus the policy’s fixed finality and broadcast margin. Closes and disputes reuse that tier, so a close submitter cannot shorten the dispute window by choosing a smaller amount.

Escrow reservation registry

The funding authority owns one durable escrow-reservation registry that serializes each escrow reference through its off-chain lifecycle. Two enums span it:

  • ChannelEscrowReservationStateV1 (unreserved, opening) is the prior and new state inside a signed funding acknowledgement. The body is valid only for the pair unreserved to opening with the version incremented by one, so one deposit funds one channel.
  • ChannelEscrowReservationStatusV1 (open, closing, released, refunded, incident) is the escrow head once the channel exists. Its status matches the channel head’s status under a fixed pairing, and both heads carry the same lifecycle fence and pending-close digest.

Channel id and initial state

The open record binds both prior digests and derives a non-cyclic channel id, so an offline verifier proves terms, a unique reservation acknowledgement, the initial state, and final consent in one direction. The derivation hashes a canonical three-tuple under an empty domain prefix, with the domain-separating literal inside the preimage:

crates/economy/chio-settle/src/channel/open.rsrust
pub fn derive_channel_id(
    open_intent_digest: &str,
    funding_acknowledgement_digest: &str,
) -> Result<String, ChannelError> {
    validate_digest("open_intent_digest", open_intent_digest)?;
    validate_digest(
        "funding_acknowledgement_digest",
        funding_acknowledgement_digest,
    )?;
    digest(
        b"",
        &(
            "chio.channel.id.v1",
            open_intent_digest,
            funding_acknowledgement_digest,
        ),
    )
}

The open record commits a sequence-zero state digest with zero cumulative amount and an empty receipt-id root. The consent check rebuilds that initial state from the channel id, currency, and asset-binding digest, and rejects unless the record’s initialStateDigest equals the digest of what it rebuilt.

Reservation wrapper and signatures

SignedChannelReservationV1 carries the reservation body and two ChannelSignatureV1 values, one from the payer and one from the channel authority. The body also binds the channel and open digests, the trusted receipt-authority digest, and the exact maximum in token base units. Every signature in the family has one shape: a signer id, a key epoch, a canonical public key, and a signature over a payload that prefixes the body with the domain chio.channel.signature.v1, the signer id, the epoch, and the key.

State body

crates/economy/chio-settle/src/channel/state.rsrust
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChannelStateBodyV1 {
    pub schema: String,
    pub channel_id: String,
    pub seq: u64,
    pub prev_state_digest: Option<String>,
    pub cumulative_owed: MonetaryAmount,
    pub receipt_id_root: String,
    pub receipt_count: u64,
    pub receipt_id: Option<String>,
    pub receipt_digest: Option<String>,
    pub receipt_authority_digest: Option<String>,
    pub obligation_atom_digest: Option<String>,
    pub reservation_digest: Option<String>,
    pub actual_charge: Option<MonetaryAmount>,
    pub cumulative_token_base_units: String,
    pub asset_binding_digest: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SignedChannelStateV1 {
    pub body: ChannelStateBodyV1,
    pub payee_signature: ChannelSignatureV1,
}

Each Option field carries skip_serializing_if = "Option::is_none", elided above, so an absent binding is an absent key. The fields separate the two shapes a state takes. At sequence zero every one is absent, the receipt count and cumulative amount are zero, the token total is the string "0", and the receipt-id root equals the empty root. At every later sequence all of them except obligationAtomDigest are present, the receipt count equals the sequence, and the obligation digest is present exactly when the actual charge is non-zero.

Lifecycle head

crates/economy/chio-settle/src/channel/lifecycle.rsrust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelLifecycleStatusV1 {
    Open,
    ClosePending,
    Closing,
    Released,
    Refunded,
    Incident,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ChannelLifecycleViewV1 {
    pub schema: String,
    pub channel_id: String,
    pub status: ChannelLifecycleStatusV1,
    pub latest_state_digest: String,
    pub latest_sequence: u64,
    pub state_version: u64,
    pub lifecycle_fence: u64,
    pub pending_close_body_digest: Option<String>,
    pub admitted_dispute_digest: Option<String>,
    pub live_reservation_id: Option<String>,
    pub operation_id: Option<String>,
}

The four Option fields carry the same elided skip_serializing_if attribute, and the head validates its own combinations. A live reservation id and an operation id are both present or both absent, and present only while the status is open. A pending close digest is required under close_pending and closing and forbidden elsewhere. An admitted dispute digest is allowed only under close_pending. A new reservation requires open with no live reservation, so moving the head to close_pending blocks every later reservation.

Close authorization

The registered ladder action class is channel.close: quorum 2 of 3 in scope treaty, co-sign mode n_of_m, and consistency model quorum-required anchored at frost-quorum. The signing domain is chio.frost.channel-close.v1 and the typed action preimage is chio.frost.action.channel-close.v1. channel_close_frost_action reads every preimage field from a verified effective close, takes only the publisher fence as an argument, and validates the preimage before returning it. Party signatures and independent endorsements cannot substitute for the group authorization.

Building a close requires the head to be open with no live reservation. Verification recomputes the release and refund, rejects a mismatch, and pins the channel-state version, the escrow-reservation version, and the lifecycle fence at exactly one past the values the head carried, so a close computed against a stale head is refused. A contested close’s dispute advance requires the head to carry the dispute digest and to be a validated successor of the head the close was taken against. Verification of a signed release authorization rejects a trusted time at or past the close-submission cutoff, and at or past the escrow deadline, so the runtime never attempts a release after expiry.

Escrow release and refund

Release reuses the escrow runtime. prepare_authorized_channel_merkle_release takes the verified release authorization, rebuilds the preparation facts from it, and refuses with channel release authority mismatch if they disagree. A zero release amount prepares no transaction and returns Ok(None). Otherwise the amount is EscrowExecutionAmount::Full when it equals the dispatch settlement amount and EscrowExecutionAmount::Partial below it, and prepare_merkle_release prepares partialReleaseWithProofDetailed for the escrow beneficiary. A prepared release whose scaled minor units differ from the authorization’s expectedReleaseTokenBaseUnits is refused.

The close body’s finalCumulativeOwed and expectedRefundAfterReleaseTokenBaseUnits are the intended split. ChioEscrow.refund can be called by anyone once block.timestamp passes the deadline, reverts with EscrowNotExpired before it and EscrowAlreadyRefunded after a first refund, and transfers deposited - released to the depositor before emitting EscrowRefunded. The observable events are EscrowReleased, EscrowPartialRelease, and EscrowRefunded.

The release and the post-deadline refund are two separate contract calls. Inside the release authorization, the two mutations it owns, the root publication and the release broadcast, must differ in operation id, effect slot id, idempotency key, and call digest, and both must name the FROST scope the authority binds. ChioEscrow understands deposits, releases, proofs, and the refund; the reservation-aware gate that guards release is the channel runtime’s off-chain control. A beneficiary, the named operator, or a settlement-key holder can move value through the signature and admin paths, so the pause, allowlist, and transferable-admin model is an explicit trust assumption.

Source

A channel is a bilateral overlay on one ChioEscrow deposit and adds no contract code. The records, validators, and close protocol are code in chio-settle under crates/economy/chio-settle/src/channel/, and release reuses the escrow runtime chio-settle owns. The close preconditions and allocation checks are in close.rs.

Next: reconcile what the close allocated