Clearing Rounds
Net receipt-backed debts into participant balances and signed settlement intents while keeping each obligation reserved to one round.
On this page
Many agents can settle what they owe each other with a few transfers. A clearing round takes one closed set of receipt-backed debts, computes each participant’s net position, and emits signed settlement intents for the residual transfers, with every input debt traceable to the transfers it produced.
Each debt is reserved to exactly one round. The round emits intents, takes no custody, and claims no finality: a payment rail executes each intent, and the rail’s outcome returns as a separate reconciliation record that the round observes. Clearing is a module ofchio-credit, which is part of every build.
Netting turns a chain of debts into one transfer
In the crate’s own test vector, A owes B 100 and B owes C 100. Netting keeps each participant’s net position: A pays 100, B nets to zero, and C receives 100, so two gross transfers become one intent.
Two reserved obligations, three participants
A owes B 100 and B owes C 100, each a receipt-backed obligation reserved to this round. Paying each one moves 200 in two transfers. Netting algorithm.
balance_match.v1 to one settlement intent. Every amount and participant comes from the test. Participant snapshots, acknowledgements, and the rail that executes the intent are left out.| Participant | Gross debit | Gross credit | Bilateral canceled | Net balance |
|---|---|---|---|---|
A | 100 | 0 | 0 | debit 100 |
B | 100 | 100 | 0 | zero 0 |
C | 0 | 100 | 0 | credit 100 |
Nothing cancels bilaterally, because no pair has a reverse flow. The debtor list holds A, the creditor list holds C, and one pass of the match loop emits the intent: A pays C 100. B still receives a signed statement and appears in no intent. The test then re-derives the output and compares, rejects a tampered amount of 101 and refuses to sign it, validates every signed body against its published JSON schema, rejects an added field, fails when one snapshot acknowledgement is dropped, and asserts the output is unchanged when the input order is reversed.
The crate test for this round
fn acyclic_chain_reduces_to_one_direct_intent() -> TestResult {
let participant_authority = Keypair::from_seed(&[1; 32]);
let obligation_authority = Keypair::from_seed(&[2; 32]);
let obligations = vec![
reserved_obligation(1, "A", "B", "USD", 100)?,
reserved_obligation(2, "B", "C", "USD", 100)?,
];
let request = signed_request(
obligations.clone(),
&participant_authority,
&obligation_authority,
)?;
let output = compute_netting_round(
&request,
&trust(&participant_authority, &obligation_authority),
)?;
assert_eq!(output.intents.len(), 1);
assert_eq!(output.intents[0].debtor_participant_id, "A");
assert_eq!(output.intents[0].creditor_participant_id, "C");
assert_eq!(output.intents[0].amount.units, 100);
verify_netting_round(
&request,
&trust(&participant_authority, &obligation_authority),
&output,
)?;
let mut tampered = output.clone();
tampered.intents[0].amount.units = 101;
assert!(verify_netting_round(
&request,
&trust(&participant_authority, &obligation_authority),
&tampered,
)
.is_err());
let authority_trust = trust(&participant_authority, &obligation_authority);
assert!(sign_netting_round(
&request,
&tampered,
&authority_trust,
&participant_authority,
)
.is_err());
let signed = sign_netting_round(&request, &output, &authority_trust, &participant_authority)?;
validate_schema(
"clearing-participant-snapshot.v1.json",
&request.participant_snapshot,
)?;
for acknowledgement in &request.participant_acknowledgements {
validate_schema(
"clearing-participant-snapshot-acknowledgement.v1.json",
acknowledgement,
)?;
}
validate_schema("clearing-input-manifest.v1.json", &request.input_manifest)?;
validate_schema("clearing-netting-round-core.v1.json", &signed.core)?;
for statement in &signed.participant_statements {
validate_schema("clearing-participant-statement.v1.json", statement)?;
}
for intent in &signed.intents {
validate_schema("clearing-settlement-intent.v1.json", intent)?;
}
for transformation in &signed.transformations {
validate_schema("clearing-atom-transformation.v1.json", transformation)?;
}
validate_schema("clearing-output-manifest.v1.json", &signed.output_manifest)?;
assert_eq!(
verify_signed_netting_round(&request, &authority_trust, &signed)?,
output
);
let mut tampered_signed = signed;
tampered_signed.intents[0].body.amount.units = 101;
assert!(verify_signed_netting_round(&request, &authority_trust, &tampered_signed).is_err());
let mut unknown_field = serde_json::to_value(&tampered_signed.core)?;
unknown_field["unexpected"] = serde_json::Value::Bool(true);
assert!(serde_json::from_value::<SignedNettingRoundCoreV1>(unknown_field).is_err());
let mut missing_acknowledgement = request.clone();
missing_acknowledgement.participant_acknowledgements.pop();
assert!(compute_netting_round(&missing_acknowledgement, &authority_trust).is_err());
let mut shuffled = request;
shuffled.obligations.reverse();
let shuffled_output = compute_netting_round(
&shuffled,
&trust(&participant_authority, &obligation_authority),
)?;
assert_eq!(output, shuffled_output);
Ok(())
}A debt enters one round, and only with proof the set is complete
The round’s only input is the canonical ObligationAtomV1: one immutable atom per receipt-backed debt, binding the debtor, the payee-bound original creditor, the amount, and the currency. Exposure-ledger rows and IOU envelopes can describe the same receipt, so the clearinghouse reads atoms alone and counts each debt once.
Each atom arrives as a ClearingObligationInputV1 carrying a source sequence and the atom’s own disposition record, and enters only when that disposition is ClearingReserved { round_id }. An atom that is per_call, assigned, or channelized stays out, so one debt never settles through two paths at once. A repeated obligation_id or atom digest raises duplicate clearing obligation and rejects the whole request.
- One currency. A round runs in one currency, three uppercase ASCII letters; a mismatched atom raises
clearing input currency does not match the round. - One signed participant snapshot.
chio.clearing.participant-snapshot.v1maps each identity to one participant id and one settlement destination, and every participant acknowledges it. The round pays only destinations the snapshot names. - A complete manifest.
chio.clearing.input-manifest.v1carries a closed sequence range, start and end checkpoint digests, and the sorted entries; a bounded report that stopped at its item limit fails asclearing input manifest is incomplete.
The signed round core binds the manifest digest, the input count, and a reservation root, so no atom can join or leave after signing.
Read more in Obligations, which produces the reserved atoms a round reads, and in the snapshot and completeness rules.
Netting is deterministic and conserves every balance
compute_netting_round accepts one algorithm version, the literal balance_match.v1. Inputs sort by source sequence, every per-participant map is a BTreeMap keyed on the participant id, and a reversed input yields byte-identical output. Addition is checked u128, every emitted amount converts back to a u64 at or below 2^53 - 1, and an overflow rejects the round.
- Verify. The trust configuration, the participant snapshot and its acknowledgements, the input manifest, the reserved disposition on every atom, the exact currency, and the manifest binding.
- Aggregate. Each atom adds its units to the debtor’s gross debit, the creditor’s gross credit, and a directed
(debtor, creditor)total, records its digest against both, and emits onechio.clearing.atom-transformation.v1naming the raw identity and the resolved participant id on each side. - Measure bilateral cancellation. For each unordered pair, the smaller direction is added to both participants’ canceled-debit and canceled-credit totals, self-flow included. The figure is reported; the directed totals stay as recorded.
- Balance. Each net balance is gross debit minus gross credit, tagged
debit,credit, orzero. A zero balance gets a statement and enters neither matching list. - Conserve. Summed debit balances equal summed credit balances, or the round stops with
clearing arithmetic overflow. - Match. Walk the debtor and creditor lists in participant-id order, match the two heads for the smaller remaining amount, and advance whichever side reaches zero. A leftover after the walk is the same error as a conservation failure.
- Emit. One statement per participant, one settlement intent per match, one transformation per input atom, and one
chio.clearing.output-manifest.v1carrying a root and a count for all three lists.
Each pass of the match loop advances at least one cursor, so a round with d debtors and c creditors emits at most d + c - 1 intents; the crate caps stored progress rows at MAX_CLEARING_INPUTS * 2 - 1. The guarantee is determinism and balance conservation; minimizing rail fees under a fee model is outside it.
A round moves through ten fenced states
Every transition is an authority-authenticated local stage followed by an EconomicStateAnchor compare-and-swap over the round and its obligations. Any pair of state and transition outside the closed match returns illegal clearing lifecycle transition.
A quorum seal finalizes the round
The round moves forward through Propose, BeginFinalization, and Finalize. Finalization needs a closed dispute window, one acceptance per statement, and a two-of-three FROST authorization, consumed once at the move from Finalizing to Finalized. Lifecycle transitions.
ClearingRoundLifecycleStateV1 and every legal move between them; doubled circles are terminal. Each move is an authority-authenticated local stage followed by an EconomicStateAnchor compare-and-swap; fence values and per-intent progress rows are left out.Abort is reachable from Reserved, Proposed, and Finalizing, and leaving Finalizing needs the burn checkpoint for its quorum session, so a round never abandons a finalization it might still consume. BeginReconciliation refuses an observed status of unknown, which sends an effect-possible outcome to Incident; that state keeps accepting dispatches and reconciliations and can reach Satisfied. Through all of it the reservations hold.
- An input is missing from a claimed complete set. Refuse the round and rebuild from authenticated reserved inputs, with completeness evidence that covers the intended set.
- A required acceptance is missing. Keep the round unfinalized; obtain a valid acceptance bound to this round and manifest, or take the admitted abort path.
- The finalization response is lost. Read the durable lifecycle head and recover the original operation before proposing another transition; a lost response leaves the round’s state to the recorded head.
- Abort is requested after a possible dispatch. Establish the zero-dispatch proof with verified abort authority, or resolve the execution outcome. A transport error is no proof of zero dispatch.
- An intent’s payment is still pending. Retain the intent and its operation identity and keep reconciling until a verified settlement outcome arrives.
Read more in the abort verifier, which checks the zero-dispatch proof.
A two-of-three quorum seals each round once
Finalization starts only once the trusted clock is at or past the round’s dispute-window end and a ClearingDisputeWindowResolver reports unresolvedDisputeCount of zero over a window that covers the closing time. One acceptance is then required per statement, in participant-id order, under the acknowledgement key the snapshot bound to that participant, so a zero-balance participant signs too.
The seal is a FROST threshold signature: a detached chio.frost.authorization.v1 in the chio.frost.clearing-round-finalize.v1 domain, registered with ladder action class clearing.round_finalize at a two-of-three treaty-scoped quorum. It binds the action digest, the roster digest, the key epoch, the resource version and fence, a scopeId equal to the round’s governance scope, and a resourceId that resolves to the round id. The action preimage binds the finalization body digest, the output manifest digest, the acceptance root, and the round id.
The finalization body carries no FROST field, so the proof binds to a proof-free body digest. The move from Finalizing to Finalized consumes the authorization against a permanent completed slot, so one proof finalizes one round once. Replaying that move needs a FROST artifact trust store; without one it returns clearing authority verification failed and the round stays in Finalizing. The group signature proves threshold authorization by the group without naming the signer subset; the individual acceptances are the attributable evidence of who signed.
Round records, transitions, and schemas
Try netBalance, dispatchIdempotencyKey, Incident, or participant-snapshot.
Entry points by stage
| Stage | Entry point or record | What the host retains |
|---|---|---|
Reserve a complete input set | Reserved obligations, dispositions, participant snapshot, completeness evidence | The input manifest and expected heads |
Compute and verify | compute_, verify_ | The deterministic round core and output manifest |
Finalize | prepare_, verify_ | Acceptances and authority proof bound to the round and output manifest |
Admit dispatch | compose_ | The fenced transition and effect-slot identity for the selected intent |
Execute and reconcile | compose_ | Observed settlement evidence and the resulting transition |
The netting function, the round records, and the lifecycle contract are pure code in chio-credit. SqliteClearingLifecycleStore in chio-store-sqlite implements the persistence and the compare-and-swap the lifecycle contract describes. An omitted eligible input is a refusal, and computation alone moves no money.
Participant snapshot rules
Identities resolve through one signed chio.clearing.participant-snapshot.v1, with no fallback mapping and no default.
- The snapshot verifies under the pinned
participant_authority_key, and the input manifest under the pinnedobligation_authority_key. A body signed by any other key fails authority verification. - Participants are strictly sorted by id, each identity list is strictly sorted, and no identity appears under two participants. A repeat raises
ParticipantIdentitynaming the identity. - The snapshot is live at the trusted clock, and the round’s dispute window closes before the snapshot expires.
- Every participant signs a
chio.clearing.participant-snapshot-acknowledgement.v1over the snapshot digest, under its own acknowledgement key and key epoch. The acknowledgement count equals the participant count. - An atom whose current creditor names a settlement destination other than the snapshot’s destination for that participant is rejected.
Input manifest completeness
ClearingInputManifestEntryV1::from_reserved returns invalid clearing field `obligation_disposition` for any disposition other than ClearingReserved { round_id }. Any of these raises clearing input manifest is incomplete:
has_moreis true, or anext_cursoris present.- The entry list is empty, or longer than
MAX_CLEARING_INPUTS, which isMAX_ECONOMIC_TRANSITIONS - 2. - The range span differs from the entry count, or an entry sits at a sequence other than
range_start_sequence + offset. - The supplied obligations differ from the manifest entries, or one names a different round.
Signed envelope and digests
Every record travels inside SignedClearingEnvelopeV1, a three-field wrapper of body, signerKey, and signature. A verifier compares the signer key with the pinned clearing authority key and re-runs compute_netting_round over the request, so a signed output is accepted only when the algorithm produces it.
The round core is hashed before any output exists, and its roundCoreDigest is the only round digest statements, intents, transformations, and the output manifest carry. Nothing points forward, which keeps the digest graph acyclic. A statement or an intent proves a round only with its inclusion in the signed output manifest and a finalization that covers every participant acceptance.
Every body is RFC 8785 canonical JSON with deny_unknown_fields, camelCase keys, and a schema field the validator compares with its own constant. Digests are lowercase ^[0-9a-f]{64}$, SHA-256 over a per-record domain prefix and the canonical bytes, so a statement digest and an intent digest over the same bytes never collide.
Participant statement
chio.clearing.participant-statement.v1 reports cancellation on two fields, and its net balance is a tagged object with no currency field; the round core carries the one currency.
roundCoreDigest: the round core the statement belongs to.participantId: the canonical participant id from the snapshot.grossDebitUnits: everything the participant owed across the round.grossCreditUnits: everything it was owed.bilateralDebitCancelledUnits: debit removed by offsetting flows with a single counterparty.bilateralCreditCancelledUnits: the credit side of the same cancellation.netBalance:{ direction, units }, directiondebit,credit, orzero.contributingAtomDigests: every atom the participant appeared in, on either side, sorted.
Settlement intent
chio.clearing.settlement-intent.v1 carries an ordinal, and both of its identifiers are derived, so two verifiers computing the same round agree on them without coordinating.
intentId: domain-separated SHA-256 over the round core digest, the ordinal, both participant ids, and the amount.roundCoreDigest: the round core the intent belongs to.ordinal: zero-based position in the emitted intent list.debtorParticipantId: who pays, as a canonical participant id.creditorParticipantId: who is paid.creditorSettlementDestination: the destination the snapshot bound to that participant.amount:{ units, currency }.contributingReservationRoot: the round-wide reservation root, repeated on each intent.dispatchIdempotencyKey: a second domain-separated digest, taken over the intent id.
Reconciliation records
An intent carries no mutable reconciliation field. What a rail did with it arrives as a separate chio.clearing.settlement-reconciliation.v1, whose observedStatus is settled, permanent_no_effect, or unknown. A finalized round with no intents closes through chio.clearing.zero-intent-reconciliation.v1, whose single outcome is netted_without_rail. A reserved ObligationAtomV1 stays clearing_reserved until a satisfaction record moves it to clearing_satisfied or an abort with a zero-dispatch proof releases it to per_call.
Lifecycle record
The contract is backend-neutral in chio-credit. The record is the projected state, and its validator rejects a row_version that differs from the fence.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClearingRoundLifecycleRecordV1 {
schema: String,
round_id: String,
governance_scope_id: String,
round_core_digest: String,
input_manifest_digest: String,
reservation_root: String,
reservation_count: u64,
state: ClearingRoundLifecycleStateV1,
row_version: u64,
fence: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
output_manifest_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
participant_acceptance_root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
participant_acceptance_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
finalization_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
abort_digest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
first_dispatch_operation_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
intent_progress: Vec<ClearingIntentProgressV1>,
last_transition_digest: String,Lifecycle transitions
The legal moves are a closed match on the current state and the proposed transition. Every pair outside this grid returns illegal clearing lifecycle transition.
| From | Transition | To |
|---|---|---|
Reserved | Propose | Proposed |
Proposed | Begin | Finalizing |
Finalizing | Finalize | Finalized |
Reserved, Proposed | BeginAbort with no burn checkpoint | Aborting |
Finalizing | BeginAbort carrying a quorum burn checkpoint | Aborting |
Aborting | Abort | Aborted |
Finalized, Dispatching, Reconciling | BeginDispatch | Dispatching |
Dispatching, Reconciling | Begin | Reconciling |
Dispatching, Reconciling | Satisfy | Satisfied |
Dispatching, Reconciling | Incident | Incident |
Incident | BeginDispatch, Begin | Incident |
Incident | Satisfy | Satisfied |
Clearing schema files
A round’s records live in the flat spec/schemas/chio-economy/ directory beside the channel, factor, and obligation families, so the clearing- base-name prefix marks a file as one of these. The registry lists an id for every one of the 17 files, so every file’s id is stated. The schema-id constants they mirror are declared in clearing/mod.rs, clearing/lifecycle.rs, and the lifecycle submodules.
| File | Title | Registry id |
|---|---|---|
clearing- | Chio Clearing Atom Transformation V1 | chio. |
clearing- | Chio Clearing Input Manifest V1 | chio. |
clearing- | Chio Clearing Netting Round Core V1 | chio. |
clearing- | Chio Clearing Output Manifest V1 | chio. |
clearing- | Chio Clearing Participant Acceptance V1 | chio. |
clearing- | Chio Clearing Participant Snapshot Acknowledgement V1 | chio. |
clearing- | Chio Clearing Participant Snapshot V1 | chio. |
clearing- | Chio Clearing Participant Statement V1 | chio. |
clearing- | Chio Clearing Round Abort V1 | chio. |
clearing- | Chio Clearing Round Finalization V1 | chio. |
clearing- | Chio Clearing Round Lifecycle V1 | chio. |
clearing- | Chio Clearing Round Satisfaction V1 | chio. |
clearing- | Chio Clearing Round Transition Proof V1 | chio. |
clearing- | Chio Clearing Settlement Intent V1 | chio. |
clearing- | Chio Clearing Settlement Reconciliation V1 | chio. |
clearing- | Chio Clearing Zero Dispatch Proof V1 | chio. |
clearing- | Chio Clearing Zero Intent Reconciliation V1 | chio. |