inflight.md 4.8 KB

Inflight holds

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/0004-inflight-holds-via-holding-accounts.md. This page is the usage guide.

Model

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.

Lifecycle

stateDiagram-v2
    [*] --> Held: authorize
    Held --> Held: confirm (partial)
    Held --> Confirmed: confirm_all / drained
    Held --> Voided: void
    Confirmed --> [*]
    Voided --> [*]

Every operation is an ordinary commit, so idempotency, conservation, and crash recovery are inherited unchanged. A hold closes automatically once drained.

API

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, receipt, legs }. The inflight field (an EnvelopeId) is the handle passed to every other call.

Guarantees

  • Over-confirmation is impossible. A hold is NoOverdraft, so confirming more than it holds fails validation. The sum of confirmations can never exceed the authorized amount.
  • No double-spend under concurrency. Concurrent confirmations serialize on the shared holding posting via the reservation protocol. On contention, one wins and the caller retries the other against the new remaining balance.
  • State is derived. The amount still held on a leg is balance(hold, asset). Confirmed and voided amounts are summed from the metadata-tagged settling transfers. Nothing mutable is stored.

Constraints and limitations

  • One open inflight per account. A destination with an open hold cannot be the destination of a second authorize until the first is confirmed or voided.
  • Distinct movements. Every movement in an authorize must move between two different accounts.
  • Void needs an open payer. Voiding returns funds to the original funder, so that account must still be open.
  • Single funder per (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.
  • Books. A hold is created in the authorize transfer's book. If that book restricts participation by flag or account, it must admit the holds (for example by allowing the INFLIGHT flag).

Where it lives

  • crates/kuatia/src/inflight.rs — the API and metadata schema.
  • crates/kuatia/tests/inflight.rs — authorize, confirm, partial confirm, void, over-confirm rejection, one-open-per-account, and status tests.
  • AccountFlags::INFLIGHTcrates/kuatia-types/src/lib.rs.