Inflight holds let you reserve funds now and settle later: authorize a trade, then confirm it (in full or in parts) or void it. This is the authorization/capture pattern, applied to a multi-leg trade.
The design and its rationale are in adr/0014-inflight-holds-via-holding-accounts.md. This page is the usage guide.
An inflight transaction is an ordinary trade whose every destination is
rewritten to a fresh per-destination holding account (NoOverdraft, flagged
INFLIGHT). Committing that rewritten transfer parks the funds:
Confirmed trade Inflight form
----------------- --------------------------------
A -> B -> 100 EUR A -> hold(B) -> 100 EUR
B -> A -> 10 BTC B -> hold(A) -> 10 BTC
A -> fee -> 1 BTC A -> hold(fee) -> 1 BTC
B -> fee -> 1 EUR B -> hold(fee) -> 1 EUR
A hold is keyed by destination, so hold(fee) collects EUR from B and BTC from
A. Each holding posting's funder is recorded in the authorize transfer's leg
table, so a void returns each posting to the account that paid it.
Nothing new is stored. The authorize transfer is the record: its EnvelopeId is
the inflight handle, and its metadata carries the leg table. Every artifact
(holding accounts, authorize, confirm, void) is tagged with a CBOR-encoded
payload under a single inflight metadata key, so the lifecycle is read from
recorded fields, not inferred.
stateDiagram-v2
[*] --> Held: authorize
Held --> Held: confirm (partial)
Held --> Confirmed: confirm_all / drained
Held --> Voided: void
Held --> Expired: deadline passes
Expired --> Voided: reaper voids
Confirmed --> [*]
Voided --> [*]
Every operation is an ordinary commit, so idempotency, conservation, and crash
recovery are inherited unchanged. A hold closes automatically once drained.
Expired is a derived, transient state: a hold whose deadline has passed but
whose funds are still held. The reaper resolves it to Voided by returning the
funds to their funders. See Expiry below.
All methods hang off Ledger.
use kuatia::prelude::*;
// Authorize the trade. Funds leave A and B and park in the holds.
let trade = TransferBuilder::new()
.pay(a, b, eur, Cent::from(100))
.pay(b, a, btc, Cent::from(10))
.pay(a, fee, btc, Cent::from(1))
.pay(b, fee, eur, Cent::from(1))
.build();
let auth = ledger.authorize(trade).await?;
// Confirm one or more legs, built with the same .pay() interface as a transfer
// (from = funder, to = destination). Deliver 40 EUR of B's hold to B now:
let some = TransferBuilder::new()
.pay(a, b, eur, Cent::from(40))
.build();
ledger.confirm(&auth.inflight, some).await?;
// Confirm everything else and close the holds.
ledger.confirm_all(&auth.inflight).await?;
// ...or return everything to the funders instead.
ledger.void(&auth.inflight).await?;
// Derived status: per-leg authorized / confirmed / voided / held, plus state.
let status = ledger.inflight_status(&auth.inflight).await?;
// The holding accounts of every open inflight.
let open = ledger.list_open_inflights().await?;
authorize returns an Authorization { inflight, legs, expires_at }. The
inflight field (an EnvelopeId) is the handle passed to every other call.
A hold can carry a deadline. Funds still held past it are returned to their funders automatically, so an abandoned authorization does not park a payer's balance forever.
// Authorize with an auto-void deadline (Unix milliseconds).
let deadline = now_millis + 30 * 60 * 1000; // 30 minutes from now
let auth = ledger.authorize_with_expiry(trade, deadline).await?;
// Run once per process: a background task that voids holds as they expire.
// Keep the handle alive for as long as the ledger should auto-void; dropping it
// stops the task.
let reaper = ledger.spawn_expiry_reaper();
// Or drive expiry manually (e.g. from your own scheduler): void everything due
// at or before `now`.
let reaped = ledger.expire_due(now_millis).await;
Plain authorize records no deadline: the hold never expires and must be
settled explicitly.
How it works (see adr/0016-hold-expiry-and-reaper.md):
BTreeMap<deadline, {handle}> that drives the
reaper: it sleeps until the earliest deadline rather than polling. The map is a
cache, rebuilt from the durable metadata by Ledger::recover() on startup, so
a restart loses no deadline.void, so it is crash-safe, idempotent, and appears
in the audit trail as a normal void (the funds went back to the funder either
way). inflight_status reports InflightState::Expired for a past-deadline
hold that the reaper has not yet reached.Run at most one reaper per store. Two racing reapers are safe (the loser voids
nothing) but redundant. While the ledger is down, deadlines do not fire; they are
swept on the next startup after recover() rebuilds the index.
NoOverdraft, so confirming
more than it holds fails validation. The sum of confirmations can never exceed
the authorized amount.balance(hold, asset).
Confirmed and voided amounts are summed from the metadata-tagged settling
transfers. Nothing mutable is stored.A hold is a subaccount of its destination: (destination, sub), where sub
is derived from a hash of the submitted trade (see
adr/0012-subaccounts.md). All holds
of one inflight share that sub. Because a different trade derives a different
sub, a destination can host many concurrent inflights at once, each isolated
in its own subaccount and listed by ledger.list_subaccounts(&destination).
Re-authorizing the identical trade collides on the existing hold and is
rejected. Balances are always segregated per subaccount: ledger.balances(&base,
&asset, None) lists the main account and every open hold separately, never
summed.
(hold, asset). When two accounts fund the same asset
into the same destination hold, a partially-confirmed remainder cannot be
split back to each funder exactly; void returns it in leg order.INFLIGHT flag).crates/kuatia/src/inflight.rs — the API and metadata schema.crates/kuatia/src/expiry.rs — the deadline index and the reaper task.crates/kuatia/tests/inflight.rs — authorize, confirm, partial confirm, void,
over-confirm rejection, concurrent inflights per account, segregated balances,
and status tests.crates/kuatia/tests/expiry.rs — deadline surfacing, the Expired state,
expire_due, the reaper, and index rebuild.AccountFlags::INFLIGHT — crates/kuatia-types/src/lib.rs.