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.
| File | Title | Registry id |
|---|---|---|
clearing-atom-transformation.v1.json | Chio Clearing Atom Transformation V1 | chio.clearing.atom-transformation.v1 |
clearing-input-manifest.v1.json | Chio Clearing Input Manifest V1 | chio.clearing.input-manifest.v1 |
clearing-netting-round-core.v1.json | Chio Clearing Netting Round Core V1 | chio.clearing.netting-round-core.v1 |
clearing-output-manifest.v1.json | Chio Clearing Output Manifest V1 | chio.clearing.output-manifest.v1 |
clearing-participant-acceptance.v1.json | Chio Clearing Participant Acceptance V1 | chio.clearing.participant-acceptance.v1 |
clearing-participant-snapshot-acknowledgement.v1.json | Chio Clearing Participant Snapshot Acknowledgement V1 | chio.clearing.participant-snapshot-acknowledgement.v1 |
clearing-participant-snapshot.v1.json | Chio Clearing Participant Snapshot V1 | chio.clearing.participant-snapshot.v1 |
clearing-participant-statement.v1.json | Chio Clearing Participant Statement V1 | chio.clearing.participant-statement.v1 |
clearing-round-abort.v1.json | Chio Clearing Round Abort V1 | chio.clearing.round-abort.v1 |
clearing-round-finalization.v1.json | Chio Clearing Round Finalization V1 | chio.clearing.round-finalization.v1 |
clearing-round-lifecycle.v1.json | Chio Clearing Round Lifecycle V1 | chio.clearing.round-lifecycle.v1 |
clearing-round-satisfaction.v1.json | Chio Clearing Round Satisfaction V1 | chio.clearing.round-satisfaction.v1 |
clearing-round-transition-proof.v1.json | Chio Clearing Round Transition Proof V1 | chio.clearing.round-transition-proof.v1 |
clearing-settlement-intent.v1.json | Chio Clearing Settlement Intent V1 | chio.clearing.settlement-intent.v1 |
clearing-settlement-reconciliation.v1.json | Chio Clearing Settlement Reconciliation V1 | chio.clearing.settlement-reconciliation.v1 |
clearing-zero-dispatch-proof.v1.json | Chio Clearing Zero Dispatch Proof V1 | chio.clearing.zero-dispatch-proof.v1 |
clearing-zero-intent-reconciliation.v1.json | Chio Clearing Zero Intent Reconciliation V1 | chio.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 pinnedobligation_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
ParticipantIdentitynaming 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.v1over 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_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 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.
- 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.
- 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 onechio.clearing.atom-transformation.v1naming both the raw identity and the resolved participant id on each side. - 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.
- Balance. Each participant's net balance is the difference between its gross debit and its gross credit, tagged
debit,credit, orzero. A zero balance still gets a statement; it simply enters neither matching list. - Conserve. The summed debit balances must equal the summed credit balances. They do not, and the round stops with
clearing arithmetic overflow. - 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.
- Emit. Produce 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 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.
crates/economy/chio-credit/tests/clearing.rs:716-804at fe56570fn 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.
| 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 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.v1 | Value |
|---|---|
roundCoreDigest | The round core this statement belongs to. |
participantId | The canonical participant id from the snapshot. |
grossDebitUnits | Everything this 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 }, where direction is debit, credit, or zero. There is no currency field: the round core carries the one currency. |
contributingAtomDigests | Every 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.v1 | Value |
|---|---|
intentId | Domain-separated SHA-256 over the round core digest, the ordinal, both participant ids, and the amount. |
roundCoreDigest | The round core this 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, not a free-form address. |
amount | { units, currency }. |
contributingReservationRoot | The round-wide reservation root, repeated on each intent. |
dispatchIdempotencyKey | A 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:
#[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:
| From | Transition | To |
|---|---|---|
Reserved | Propose | Proposed |
Proposed | BeginFinalization | 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 | BeginReconciliation | Reconciling |
Dispatching, Reconciling | Satisfy | Satisfied |
Dispatching, Reconciling | Incident | Incident |
Incident | BeginDispatch, BeginReconciliation | Incident |
Incident | Satisfy | Satisfied |
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
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
ObligationAtomV1a 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.