EconomyRails
Payment Channels
Fund one bounded escrow, reserve capacity for repeated calls, and settle the cumulative signed balance once at close.
A channel is a bilateral overlay on one ChioEscrow deposit, for two parties who settle many small calls with each other and would rather fund once, meter as the stream runs, and release a single cumulative amount. It adds no Solidity: the records, the validators, and the close protocol are pure code in chio-settle, under crates/economy/chio-settle/src/channel/, and the release reuses the escrow runtime that chio-settle already owns.
Why stream through a channel
Per-call settlement is fine for occasional calls. A high-frequency, low-value stream between two fixed parties pays a settlement cost per call that can dwarf the value of the call itself. A channel amortizes that: fund one bounded bilateral escrow up front, reserve capacity before each dispatch, accumulate one running cumulative amount across many calls, and release once at close.
The route is chosen before dispatch, never after. A post-persist observer is too late to pick a settlement path, because by then the tool has already delivered value. So a channelized receipt is committed to exactly one disposition: it becomes one immutable ObligationAtomV1 whose ObligationDispositionV1 is the Channelized variant carrying the channel and reservation ids, through the shared chio-credit obligation store.
Routing is exclusive at the point of observation. When a receipt carries valid ChannelReceiptMetadataV1, the kernel settlement observer never builds a per-call observation from it. It checks the terminal decision and the financial metadata, then returns a permanent failure with the reason channel settlement handler is not configured rather than falling through to the per-call path. Malformed channel metadata is its own permanent failure, channel metadata is malformed, so an unreadable binding cannot be treated as an absent one.
Channel records
Every channel body is RFC 8785 canonical JSON with deny_unknown_fields, camelCase keys, and a versioned schema id, and each one is signed as a separate wrapper around that body rather than by a signature field inside it. The family holds 11 schema files under spec/schemas/chio-economy/, of which spec/schemas/registry.json lists 11. Every registered kind uses the channel_ prefix and is introduced by micro-escrow-channels-v1.
| Registry id | What the body binds |
|---|---|
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 must repeat 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 that 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. |
Two further schema constants govern records that no registry row carries, because they describe anchored state rather than a signed artifact: chio.channel.lifecycle.v1 for the channel head and chio.channel.escrow-reservation.v1 for the escrow head. A third, chio.channel.asset-binding.v1, is the currency-to-token binding every money field round-trips through.
Open and fund
A budget reservation is not channel funding. Opening requires a rail that has already locked the full channel bound, and the funded rail is a fully-funded ChioEscrow deposit. Before either party signs the open intent, both verify the chio.channel.funding-evidence.v1 produced from a single block-pinned escrow read. That evidence binds the complete immutable ChannelEscrowTermsV1, the deposited, released, and refunded state, the decoded EscrowCreated event, an identity-registry observation proving the named operator is active with that exact key hash, a token observation proving the token is allowed, and a trusted ChannelAssetBindingV1 carrying currency, both decimal scales, chain id, token address, token symbol, and the settlement-policy digest.
The pin is what makes that read one read. Every observation in the body must repeat the block pin's own block number and block hash, and the pin itself must report 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 each reject the evidence before any signature is checked.
Open validation is exact. The bound converts through the asset binding's verify_round_trip, which recomputes the token base units from the MonetaryAmount and the amount back from the units and rejects with InexactAmount unless both directions agree, so that terms.maxAmount == deposited == bound_token_base_units. Nothing may already be released or refunded; the escrow beneficiary must equal the channel payee and the depositor must equal the payer's refund address; token, currency, both decimal scales, policy digest, chain id, and contract address must all 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 must equal the escrow deadline minus the policy's fixed finality and broadcast margin. No close or dispute recomputes the tier from the cumulative owed, which is what stops a close submitter from shortening the dispute window by choosing a smaller amount.
The funding authority owns one durable escrow-reservation registry that serializes each escrow reference through its off-chain lifecycle. Two enums span it, one inside the signed acknowledgement and one on the anchored head:
| Enum | Wire values | Where it appears |
|---|---|---|
ChannelEscrowReservationStateV1 | unreserved, opening | The prior and new state of one funding acknowledgement. The body is valid only for the exact pair unreserved to opening, with the version incremented by one, which is what makes it impossible to reuse a deposit for two channels. |
ChannelEscrowReservationStatusV1 | open, closing, released, refunded, incident | The escrow head after the channel exists. Its status must match the channel head's own status under a fixed pairing, and both heads must carry the same lifecycle fence and the same pending-close digest. |
The open record binds both prior digests and derives a non-cyclic channel id, so an offline verifier can prove 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:
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, and the consent check recomputes both rather than trusting the record: it rebuilds the 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.
Reserve before each call
Before any covered dispatch, the payer signs a chio.channel.reservation.v1: an authorization for the exact next state, durably persisted before the tool runs. The body binds the reservation id, the channel and open digests, the request and operation ids, the exact next sequence, the expected prior state digest, the service binding, the trusted receipt-authority digest, a maximum MonetaryAmount with its exact maximum token base units, an expiry, and the channel-state version and lifecycle fence it expects. The reservation id is itself derived from the channel id, open digest, request id, next sequence, and prior state digest, so a body that names a different sequence does not hash to the id it claims.
The signed wrapper is SignedChannelReservationV1, which carries the body plus two ChannelSignatureV1 values, one from the payer and one from the channel authority. Every signature in the family has that 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.
The quoted maximum is derived from bound.units - prior.cumulative_owed.units with checked arithmetic, and a maximum above that remainder rejects. The lifecycle head admits exactly one live reservation per channel: acceptance requires the channel status to be open with liveReservationId and operationId both absent, so there is no payer-selected post-service fork and no latest-integer-wins rule.
Consent moves before dispatch
ObligationAtomV1: the state transition requires an obligation digest exactly when the actual charge is non-zero, and the signed receipt still consumes the reservation and advances the chain with an unchanged cumulative owed.Cumulative state
Each admitted call appends one chio.channel.state.v1 body. Because the consumed pre-dispatch reservation is the payer's authority, the signed wrapper carries only the payee signature, never a second post-service payer one. The two money fields are typed differently on purpose. The protocol-unit total is a MonetaryAmount whose units are capped at the 53-bit I-JSON safe integer, so it can stay a JSON number. The token-base-unit total can exceed that range, so it is a String the validator parses to u128, at most 39 digits and with no leading zero:
#[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 above carries skip_serializing_if = "Option::is_none", elided here, so an absent binding is an absent key rather than a null. Those fields are not optional in practice; they are the difference between the two shapes a state can have. At sequence zero every one of them must be absent, the receipt count must be zero, the cumulative amount must be zero, the token total must be the string "0", and the receipt-id root must equal the empty root. At every later sequence all of them except obligationAtomDigest must be present, the receipt count must equal the sequence, and the obligation digest must be present exactly when the actual charge is non-zero.
Every later state must also satisfy all of:
seq == prior.seq + 1through checked arithmetic capped at the safe-integer bound, and the payer-signed reservation names that exact next sequence and prior digest;prevStateDigestequals the prior state's digest, which is the digest of the signed wrapper once a payee signature exists and of the bare body at sequence zero;- the receipt-id root is the prior root and the new receipt id hashed under the receipt-root domain, so the ordered receipt set appends exactly the reservation-bound trusted-kernel receipt;
cumulativeOwedequals the prior total plus the receipt'scost_chargedunder checked addition, with the actual charge inside the signed reservation's maximum and the cumulative total inside the immutable bound in both protocol units and token base units; and- the whole body equals the one
build_channel_state_transitionrebuilds from the prior state, the reservation, the receipt binding, and the open consent. The verifier compares structs, so a state that differs in any field is rejected rather than partially accepted.
The receipt binding is checked before any of that. The receipt must carry the trusted kernel key and a verifying signature, its channel metadata must match the reservation field for field, its settlement mode must be Channelized, and it must carry no payment reference. A zero-charge receipt is admitted only when it is allowed or denied with settlement status NotApplicable; a charging receipt only when it is allowed with status Pending.
A retry of the same dispatch lands on the same effect slot rather than a second one: derive_channel_service_dispatch_idempotency_key hashes the operation id, the reservation id, and the sequence under a dedicated domain, so those three fix the key. A competing state at the same sequence cannot replace the admitted one, because admission compares the candidate against the lifecycle head's latestStateDigest and latestSequence, and requires the state version and lifecycle fence to be exactly the reservation's expected values plus one. A head that has already advanced no longer matches.
One ChannelLifecycleViewV1 is that head, and it is the single concurrency fence for both service admission and close. Its status is a ChannelLifecycleStatusV1:
#[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 must both be present or both absent, and only while the status is open. A pending close digest is required under close_pending and closing and forbidden everywhere else. An admitted dispute digest is allowed only under close_pending. Because a new service reservation requires the open status with no live reservation, moving the head to close_pending is what blocks a later reservation.
Cooperative and contested close
The payee closes over the newest contiguous reservation-backed, trusted-receipt-derived state, and the payee signature is the one the close body always carries. Payer disappearance after dispatch cannot remove the closeable balance.
- Cooperative close. The payer signature is an
Optionon the wrapper, but a body whosecloseKindiscooperativeand whose payer signature is absent is rejected. What the payer signature cannot do is change the amount: it signs the same body, and the body's final cumulative owed is copied from the state the lifecycle head already points at. - Contested close. A
contestedbody opens the exact dispute duration committed in the open intent. Achio.channel.dispute.v1can replace the posted state only with a strictly higher sequence proved by a state chain rooted at the close's own final state digest, and the advance requires the head to carry the dispute digest and to be a validated successor of the head the close was taken against.
The close body is not a free-form claim. Building it requires the head to be open with no live reservation, and it computes the expected release from the effective state's cumulative owed and the expected post-release refund as bound_token_base_units - expected_release_token_base_units under checked subtraction. Verification recomputes both and rejects a mismatch. It also 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 cannot be admitted.
The close is FROST-authorized. The registered ladder action class is channel.close, its quorum is 2 of 3 in scope treaty, its co-sign mode is n_of_m, and its consistency model is 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 field of that preimage out of a verified effective close, takes only the publisher fence as an argument, and validates the preimage before returning it, so the authorization binds the exact state digest, sequence, versions, lifecycle fence, and token-base-unit release. Party signatures and independent endorsements cannot substitute for that group authorization.
Finalization and release must complete before the immutable closeSubmissionCutoffUnixSecs, which leaves the policy's fixed finality and broadcast margin before the escrow deadline for chain inclusion. Verification of a signed release authorization rejects a trusted time at or past that cutoff, and at or past the escrow deadline, so the runtime never attempts a release after expiry.
Settlement and reconciliation
Release reuses the existing 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 at all and returns Ok(None). Otherwise the amount is EscrowExecutionAmount::Full when it equals the dispatch settlement amount and EscrowExecutionAmount::Partial below it, and the prepared call goes through the existing prepare_merkle_release, which prepares partialReleaseWithProofDetailed for the escrow beneficiary. The prepared release is then checked back against the authorization: a scaled amount whose minor units differ from the authorization's expectedReleaseTokenBaseUnits is refused.
The intended allocation and the realized allocation are kept strictly apart, and the contract settles the difference on its own terms. The close body's finalCumulativeOwed and expectedRefundAfterReleaseTokenBaseUnits are the intended split, computed from the signed state. The realized split is whatever the chain reports: 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. So the refund is derived from what the contract observed as released, never from the close's intended figure. The observable events are EscrowReleased, EscrowPartialRelease, and EscrowRefunded.
The release and the post-deadline refund are two separate contract calls, and nothing makes the pair atomic. Inside the release authorization the two mutations it does own, the root publication and the release broadcast, are held apart by validation: the body is rejected unless their operation ids, effect slot ids, idempotency keys, and call digests all differ, and unless both name the FROST scope the authority binds.
What the escrow contract does not know
ChioEscrow understands deposits, releases, proofs, and a post-deadline refund. It does not understand a channel reservation, a close digest, or a FROST proof, so the reservation-aware gate that guards release is off-chain operational control rather than on-chain authority. The contract exposes a separate releaseWithSignature path, and its admin can call setPaused, setTokenAllowed, and transferAdmin. A beneficiary, the named operator, or a settlement-key holder can therefore move value through paths the channel records never see, and the pause, allowlist, and transferable-admin model stays an explicit trust assumption.See also
- On-Chain Settlement for the
ChioEscrowlifecycle a channel opens against, including the partial-release and refund paths a close reuses. - Settlement Rails for the full rail enum and the per-call and off-chain alternatives a channel is chosen against. A one-off or infrequent call settles more simply per call.
- Clearing Rounds for the multilateral netting path that channels sit beside as the bilateral streaming option.
- FROST Quorum for the roster, epoch checkpoint, and one-shot slot behind the
channel.closeauthorization. - Reconciliation for how release and refund observations close out against the exposure ledger.