Chio/Docs
LOGIN · JOIN

EconomyDebts

Clearing Rounds

A clearing round nets a complete, reserved set of single-currency obligation atoms into a smaller set of signed settlement intents.

Many agents owe many other agents for completed, receipt-backed work. Paying each debt separately moves more money than the group actually owes. A clearing round takes one closed set of those debts, computes each participant's net position, and emits the residual transfers. The netting function, the round records, and the round lifecycle are pure code in chio-credit, under crates/economy/chio-credit/src/clearing/. chio-store-sqlite implements the persistence and the compare-and-swap the lifecycle contract describes, in SqliteClearingLifecycleStore.

A round emits settlement intents. It holds no funds, moves no money, and establishes no settlement finality: a rail still has to execute each intent, and the rail's outcome comes back as a separate reconciliation record.


The clearing schema files

A round's records live in the flat spec/schemas/chio-economy/ directory, which also holds the channel, factor, and obligation families, so the clearing- base-name prefix is what marks a file as one of these. The registry lists an id for every one of the 17 files below, so there is no file here whose id a reader has to infer. The schema-id constants they mirror are declared in clearing/mod.rs, clearing/lifecycle.rs, and the lifecycle submodules.

FileTitleRegistry id
clearing-atom-transformation.v1.jsonChio Clearing Atom Transformation V1chio.clearing.atom-transformation.v1
clearing-input-manifest.v1.jsonChio Clearing Input Manifest V1chio.clearing.input-manifest.v1
clearing-netting-round-core.v1.jsonChio Clearing Netting Round Core V1chio.clearing.netting-round-core.v1
clearing-output-manifest.v1.jsonChio Clearing Output Manifest V1chio.clearing.output-manifest.v1
clearing-participant-acceptance.v1.jsonChio Clearing Participant Acceptance V1chio.clearing.participant-acceptance.v1
clearing-participant-snapshot-acknowledgement.v1.jsonChio Clearing Participant Snapshot Acknowledgement V1chio.clearing.participant-snapshot-acknowledgement.v1
clearing-participant-snapshot.v1.jsonChio Clearing Participant Snapshot V1chio.clearing.participant-snapshot.v1
clearing-participant-statement.v1.jsonChio Clearing Participant Statement V1chio.clearing.participant-statement.v1
clearing-round-abort.v1.jsonChio Clearing Round Abort V1chio.clearing.round-abort.v1
clearing-round-finalization.v1.jsonChio Clearing Round Finalization V1chio.clearing.round-finalization.v1
clearing-round-lifecycle.v1.jsonChio Clearing Round Lifecycle V1chio.clearing.round-lifecycle.v1
clearing-round-satisfaction.v1.jsonChio Clearing Round Satisfaction V1chio.clearing.round-satisfaction.v1
clearing-round-transition-proof.v1.jsonChio Clearing Round Transition Proof V1chio.clearing.round-transition-proof.v1
clearing-settlement-intent.v1.jsonChio Clearing Settlement Intent V1chio.clearing.settlement-intent.v1
clearing-settlement-reconciliation.v1.jsonChio Clearing Settlement Reconciliation V1chio.clearing.settlement-reconciliation.v1
clearing-zero-dispatch-proof.v1.jsonChio Clearing Zero Dispatch Proof V1chio.clearing.zero-dispatch-proof.v1
clearing-zero-intent-reconciliation.v1.jsonChio Clearing Zero Intent Reconciliation V1chio.clearing.zero-intent-reconciliation.v1

Every one of those bodies is RFC 8785 canonical JSON with deny_unknown_fields, camelCase keys, and a schema field the validator compares against its own constant. Digests are lowercase ^[0-9a-f]{64}$, computed as SHA-256 over a per-record domain prefix followed by the canonical bytes, so a statement digest and an intent digest over the same bytes never collide.


The input a round accepts

The clearinghouse never reads exposure-ledger rows or IOU envelopes directly, because the two can describe the same receipt and reading both double-counts one debt. Its 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. The obligation model those atoms come from is covered in Obligations.

Each atom arrives as a ClearingObligationInputV1 carrying a source sequence, the atom, and the atom's separate disposition record. ClearingInputManifestEntryV1::from_reserved reads the disposition and returns invalid clearing field `obligation_disposition` unless it is ClearingReserved { round_id }. That is the exclusivity: an atom that is still per_call, or already assigned or channelized, cannot enter a round at all, so a single debt is never settled through two paths at once. A repeated obligation_id, or a repeated atom digest, raises duplicate clearing obligation and rejects the whole request.

One signed participant snapshot

A round runs against exactly one currency, validated as three uppercase ASCII letters, and every atom whose currency differs raises clearing input currency does not match the round. Identities resolve through one signed chio.clearing.participant-snapshot.v1 that maps each identity string to one participant id and one settlement destination. There is no fallback mapping and no default:

  • The snapshot must verify under the pinned participant_authority_key, and the input manifest under the pinned obligation_authority_key. A body signed by any other key fails authority verification.
  • Participants must be strictly sorted by id, each participant's identity list strictly sorted, and no identity may appear under two participants. A repeat raises ParticipantIdentity naming the identity.
  • The snapshot must be live at the trusted clock, and the round's dispute window must close before the snapshot expires.
  • Every participant in the snapshot must have signed a chio.clearing.participant-snapshot-acknowledgement.v1 over the snapshot digest, under that participant's own acknowledgement key and key epoch. The acknowledgement count must equal the participant count.
  • An atom whose current creditor names a settlement destination other than the snapshot's destination for that participant is rejected, so the round cannot pay an address the snapshot did not authorize.

Completeness is proven, not assumed

A bounded exposure report at its item limit is not proof that the epoch input is complete, so chio.clearing.input-manifest.v1 carries a closed sequence range, start and end checkpoint digests, and the sorted entry list. Any of the following raises clearing input manifest is incomplete:

  • has_more is true, or a next_cursor is present;
  • the entry list is empty, or longer than MAX_CLEARING_INPUTS, which is MAX_ECONOMIC_TRANSITIONS - 2;
  • the range span does not equal the entry count, or an entry sits at a sequence other than range_start_sequence + offset;
  • the supplied obligations do not reproduce the manifest entries exactly, or one names a different round.

The signed round core then binds the manifest digest, the input count, and a reservation root, so no atom can be added or dropped after signing.


The netting algorithm

The entry point is compute_netting_round, and the only algorithm version it accepts is the literal balance_match.v1. It is deterministic: inputs sort by source sequence, every per-participant map is a BTreeMap keyed on the participant id, and reversing the input order produces a byte-identical output. All addition is checked u128, every emitted amount must convert back to a u64 at or below 2^53 - 1, and an overflow rejects the round rather than saturating.

  1. Verify. Check 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.
  2. Aggregate. For each atom, add its units to the debtor's gross debit and the creditor's gross credit, record the atom digest against both, add the units to a directed (debtor, creditor) total, and emit one chio.clearing.atom-transformation.v1 naming both the raw identity and the resolved participant id on each side.
  3. Measure bilateral cancellation. For each unordered pair, the offset amount is the smaller of the two directions, and it is added to both participants' canceled-debit and canceled-credit totals. A participant that owes itself has its self-flow counted the same way. This is a reported figure: the directed totals are never rewritten.
  4. Balance. Each participant's net balance is the difference between its gross debit and its gross credit, tagged debit, credit, or zero. A zero balance still gets a statement; it simply enters neither matching list.
  5. Conserve. The summed debit balances must equal the summed credit balances. They do not, and the round stops with clearing arithmetic overflow.
  6. Match. Walk the debtor and creditor lists, both already in participant-id order, matching the head of each for the smaller of the two remaining amounts and advancing whichever side reaches zero. A leftover on either side after the walk is the same error as a conservation failure.
  7. Emit. Produce one statement per participant, one settlement intent per match, one transformation per input atom, and one chio.clearing.output-manifest.v1 carrying a root and a count for all three lists.

Each pass of the match loop advances at least one of the two cursors, so a round with d debtors and c creditors emits at most d + c - 1 intents; the crate caps the stored progress rows at MAX_CLEARING_INPUTS * 2 - 1. The claim is determinism and balance conservation, not minimal rail fees under an arbitrary fee model.

A round the test suite runs

The chain case is the one worth walking, because it is where netting removes a hop rather than a cycle. Two reserved atoms: A owes B one hundred units, B owes C the same hundred. The test below is the crate's own vector for it.

A chain of 2 reserved obligations between 3 participants netting to one settlement intent from A to C for USD 100.reserved obligationsABCUSD 100USD 100settlement intentACUSD 100USD 101 here fails verification and refuses to sign
A two-hop chain reduced to one settlement intent. Every amount and participant is parsed out of the crate test this figure cites.
sourcecrates/economy/chio-credit/tests/clearing.rs:716-804at fe56570
crates/economy/chio-credit/tests/clearing.rs716-804rust
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(())
}

Working the four assertions back through the algorithm gives the whole round. B is the interesting row: it owes and is owed the same amount, so its balance is zero, it still receives a signed statement, and it never appears in an intent.

ParticipantGross debitGross creditBilateral canceledNet balance
A10000debit 100
B1001000zero 0
C01000credit 100

Nothing cancels bilaterally here, because neither direction of any pair has a matching reverse flow. The debtor list holds A, the creditor list holds C, and one pass of the match loop emits the single intent the test asserts: A pays C one hundred units. Two gross transfers became one. The rest of the test is what makes that number trustworthy rather than merely correct: it re-derives the output and compares, rejects a tampered amount, refuses to sign the tampered output, validates every signed body against its published JSON schema, rejects an added field, drops one snapshot acknowledgement and fails, then reverses the input order and asserts the output is unchanged.


Round records

Every record travels inside SignedClearingEnvelopeV1, a three-field wrapper of body, signerKey, and signature. A verifier compares the signer key against the pinned clearing authority key and re-runs compute_netting_round over the request, so a signed output is accepted only when it is the output the algorithm produces.

The round core is hashed before any output exists, and its roundCoreDigest is the only round digest that statements, intents, transformations, and the output manifest carry. Nothing points forward, which is what keeps the digest graph acyclic. A statement or an intent on its own is not a round proof: verification needs its inclusion in the signed output manifest and a finalization that covers every participant acceptance.

Two bodies are worth reading field by field, because both are easy to approximate wrongly. A statement reports cancellation on two fields, not one, and its net balance is a tagged object rather than a signed integer:

chio.clearing.participant-statement.v1Value
roundCoreDigestThe round core this statement belongs to.
participantIdThe canonical participant id from the snapshot.
grossDebitUnitsEverything this participant owed across the round.
grossCreditUnitsEverything it was owed.
bilateralDebitCancelledUnitsDebit removed by offsetting flows with a single counterparty.
bilateralCreditCancelledUnitsThe credit side of the same cancellation.
netBalance{ direction, units }, where direction is debit, credit, or zero. There is no currency field: the round core carries the one currency.
contributingAtomDigestsEvery atom this participant appeared in, on either side, sorted.

An intent carries an ordinal, and both of its identifiers are derived rather than chosen, so two verifiers computing the same round agree on them without coordinating:

chio.clearing.settlement-intent.v1Value
intentIdDomain-separated SHA-256 over the round core digest, the ordinal, both participant ids, and the amount.
roundCoreDigestThe round core this intent belongs to.
ordinalZero-based position in the emitted intent list.
debtorParticipantIdWho pays, as a canonical participant id.
creditorParticipantIdWho is paid.
creditorSettlementDestinationThe destination the snapshot bound to that participant, not a free-form address.
amount{ units, currency }.
contributingReservationRootThe round-wide reservation root, repeated on each intent.
dispatchIdempotencyKeyA second domain-separated digest, taken over the intent id.

An intent carries no mutable reconciliation field. What a rail did with it arrives later as a separate chio.clearing.settlement-reconciliation.v1, whose observedStatus is settled, permanent_no_effect, or unknown. A finalized round that produced no intents at all is closed by chio.clearing.zero-intent-reconciliation.v1 instead, whose outcome is the single value netted_without_rail.


One fenced round

A round is a single fenced state machine with ten states. Its contract is backend-neutral in chio-credit; every transition is an authority-authenticated local stage followed by an external EconomicStateAnchor compare-and-swap over the round and its obligations. The record below is the projected state, and its own validator rejects a row_version that differs from the fence:

crates/economy/chio-credit/src/clearing/lifecycle.rsrust
#[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,

The legal moves are a closed match on the pair of current state and proposed transition. Every pair not in the table below returns illegal clearing lifecycle transition:

FromTransitionTo
ReservedProposeProposed
ProposedBeginFinalizationFinalizing
FinalizingFinalizeFinalized
Reserved, ProposedBeginAbort with no burn checkpointAborting
FinalizingBeginAbort carrying a quorum burn checkpointAborting
AbortingAbortAborted
Finalized, Dispatching, ReconcilingBeginDispatchDispatching
Dispatching, ReconcilingBeginReconciliationReconciling
Dispatching, ReconcilingSatisfySatisfied
Dispatching, ReconcilingIncidentIncident
IncidentBeginDispatch, BeginReconciliationIncident
IncidentSatisfySatisfied

Three consequences fall straight out of that table. Abort is reachable only from Reserved, Proposed, and Finalizing; once a round is Finalized the only way out is forward. Aborting out of Finalizing requires the burn checkpoint for the quorum session, so a round cannot abandon a finalization it might still consume. And Incident keeps working: it accepts further dispatches and reconciliations and can still reach Satisfied, so ambiguity parks a round rather than killing it. Reconciliation is where the ambiguity is classified: BeginReconciliation refuses an observed status of unknown, which is what forces an effect-possible outcome down the Incident arm instead. Through all of it the reservations hold, and no atom returns to per_call without an abort digest and a zero-dispatch proof.


Quorum-gated finalization

Participant acceptances alone do not finalize a round. Before any acceptance is even read, the trusted clock must be at or past the round's dispute-window end, and a ClearingDisputeWindowResolver must return a status whose unresolvedDisputeCount is zero and whose observation window covers the closing time. One acceptance is then required per statement, in participant-id order, each signed under the acknowledgement key the snapshot bound to that participant, so a zero-balance participant still has to sign.

The seal on top of that is a FROST threshold signature. The finalization body is verified against 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. The authorization body binds the action digest, the roster digest, the key epoch, the resource version and fence, a scopeId that must equal the round's governance scope, and a resourceId that resolves to the round id. The action preimage itself binds the finalization body digest, the output manifest digest, the acceptance root, and the round id.

The finalization body carries no FROST field, so its digest is taken over a proof-free body and the proof binds to it rather than the other way round. Finalizing → Finalized is the single point at which the authorization is consumed, against a permanent completed authorization slot, so one proof can never finalize a round twice. Verifying that transition also requires a FROST artifact trust store: with none configured the replay returns clearing authority verification failed and the round stays in Finalizing. The group signature proves threshold group authorization, not the exact signer subset; the individual acceptances remain attributable evidence and are never a substitute for the quorum.

What a signed round is not

A signed round is netting evidence, not settlement truth. compute_netting_round emits chio.clearing.settlement-intent.v1 records and nothing else; a rail still has to move the money, and the outcome lives only in the separate chio.clearing.settlement-reconciliation.v1 evidence that rail returns. The clearinghouse takes no custody and claims no on-chain or bank finality. A reserved ObligationAtomV1 stays exclusively clearing_reserved until either a satisfaction record moves it to clearing_satisfied or an abort with a zero-dispatch proof releases it to per_call.

See also

  • Obligations for the atom and disposition model that produces the reserved ObligationAtomV1 a round reads.
  • Settlement Rails for the rail that executes one chio.clearing.settlement-intent.v1.
  • Reconciliation for how a dispatched settlement is closed against observed cost.
  • Cross-Org Swarms for the multi-party operator structures a round nets across.