Просмотр исходного кода

Use CBOR for inflight metadata and make confirm batch

The inflight metadata was hand-packed with big-endian byte fields across
four namespaced keys, which is fragile and awkward to extend. And confirm
settled a single leg per call, so confirming several legs meant several
calls.

Encode the inflight payload as one CBOR-tagged enum (via ciborium) stored
under a single metadata key, replacing the manual encode/decode helpers and
the role byte. Metadata is hashed as opaque bytes into the transfer id and
the domain types serialize deterministically, so content addressing and
tamper evidence are preserved.

Replace the single-leg confirm with a batch confirm that takes a Transfer
built through the existing TransferBuilder.pay(from, to, asset, amount).
Each movement is matched to its leg and settled; funder is from, destination
is to. Movements settle in order as ordinary commits, consistent with
confirm_all.

Bring ADR 0004 in line with the shipped design: the inflight handle is the
authorize transfer's content-addressed EnvelopeId rather than a separately
minted id stamped on every artifact, holding accounts record only their
destination, and confirm is documented as accepting a batch of legs.

Drop the unnecessary allow(missing_docs) from the inflight test; its items
are private, so the workspace deny-missing-docs lint never applied.
Cesar Rodas 10 часов назад
Родитель
Сommit
fc96f6c73b

+ 45 - 0
Cargo.lock

@@ -268,6 +268,33 @@ dependencies = [
 ]
 
 [[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
+[[package]]
 name = "clap"
 version = "4.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -393,6 +420,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
 
 [[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
 name = "crypto-common"
 version = "0.1.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -655,6 +688,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
+[[package]]
 name = "hashbrown"
 version = "0.15.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -989,6 +1033,7 @@ name = "kuatia"
 version = "0.2.0"
 dependencies = [
  "async-trait",
+ "ciborium",
  "kuatia-core",
  "kuatia-storage",
  "kuatia-storage-sql",

+ 1 - 0
Cargo.toml

@@ -23,6 +23,7 @@ kuatia-storage-sql = { path = "crates/kuatia-storage-sql", version = "0.2.0" }
 # External crates
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+ciborium = "0.2"
 sha2 = { version = "0.10", default-features = false }
 bitflags = { version = "2", features = ["serde"] }
 async-trait = "0.1"

+ 1 - 0
crates/kuatia/Cargo.toml

@@ -27,6 +27,7 @@ legend.workspace = true
 tokio = { workspace = true, features = ["sync", "rt", "macros"] }
 serde.workspace = true
 serde_json.workspace = true
+ciborium.workspace = true
 async-trait.workspace = true
 tracing.workspace = true
 

+ 145 - 149
crates/kuatia/src/inflight.rs

@@ -6,8 +6,8 @@
 //! [`AccountFlags::INFLIGHT`]). Committing that rewritten transfer parks the
 //! funds. Confirm and void are ordinary commits that move a hold's balance to
 //! its destination or back to its funder. Nothing new is stored: the authorize
-//! transfer's metadata carries the leg table, and every artifact is tagged in an
-//! `inflight.` metadata namespace so the lifecycle is read, not inferred.
+//! transfer's metadata carries the leg table, and every artifact is tagged with
+//! a CBOR-encoded `InflightMeta` entry so the lifecycle is read, not inferred.
 //!
 //! See `doc/adr/0004-inflight-holds-via-holding-accounts.md`.
 
@@ -18,29 +18,20 @@ use kuatia_core::{
     Account, AccountFlags, AccountId, AccountPolicy, AssetId, BookId, Cent, EnvelopeId, Metadata,
     Receipt, SelectionError, Transfer, TransferBuilder,
 };
+use kuatia_storage::error::StoreError;
 use kuatia_storage::store::EnvelopeRecord;
 use kuatia_types::PostingStatus;
+use serde::{Deserialize, Serialize};
 
 use crate::error::LedgerError;
 use crate::ledger::Ledger;
 
-// Metadata keys (all under the `inflight.` namespace).
-const K_ROLE: &str = "inflight.role";
-const K_TX: &str = "inflight.tx";
-const K_DEST: &str = "inflight.destination";
-const K_LEGS: &str = "inflight.legs";
-
-// `inflight.role` values.
-const ROLE_AUTHORIZE: u8 = 0;
-const ROLE_HOLD: u8 = 1;
-const ROLE_CONFIRM: u8 = 2;
-const ROLE_VOID: u8 = 3;
-
-const LEG_BYTES: usize = 8 + 8 + 8 + 4 + 16; // destination, hold, funder, asset, amount
+/// Single metadata key holding the CBOR-encoded [`InflightMeta`] payload.
+const K_INFLIGHT: &str = "inflight";
 
 /// One leg of an inflight transaction: an amount of an asset funded by `funder`,
 /// parked in `hold`, destined for `destination`.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 pub struct InflightLeg {
     /// Account the funds settle to on confirm.
     pub destination: AccountId,
@@ -112,90 +103,60 @@ pub struct InflightStatus {
 }
 
 // ---------------------------------------------------------------------------
-// Metadata encoding
+// Metadata: one CBOR-encoded tagged payload under the `inflight` key
 // ---------------------------------------------------------------------------
 
-fn encode_legs(legs: &[InflightLeg]) -> Vec<u8> {
-    let mut buf = Vec::with_capacity(4 + legs.len() * LEG_BYTES);
-    buf.extend_from_slice(&(legs.len() as u32).to_be_bytes());
-    for l in legs {
-        buf.extend_from_slice(&l.destination.0.to_be_bytes());
-        buf.extend_from_slice(&l.hold.0.to_be_bytes());
-        buf.extend_from_slice(&l.funder.0.to_be_bytes());
-        buf.extend_from_slice(&l.asset.0.to_be_bytes());
-        buf.extend_from_slice(&l.amount.to_canonical_bytes());
-    }
-    buf
-}
-
-fn malformed(tid: EnvelopeId) -> LedgerError {
-    LedgerError::NotInflightTransaction(tid)
-}
-
-fn read_i64(bytes: &[u8], off: usize, tid: EnvelopeId) -> Result<i64, LedgerError> {
-    let slice: [u8; 8] = bytes
-        .get(off..off + 8)
-        .and_then(|s| s.try_into().ok())
-        .ok_or_else(|| malformed(tid))?;
-    Ok(i64::from_be_bytes(slice))
+/// The inflight payload carried in a transfer's or holding account's metadata.
+/// Serialized to CBOR (via `ciborium`) and stored under [`K_INFLIGHT`], so the
+/// whole lifecycle is self-describing and read back, not inferred.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+enum InflightMeta {
+    /// Tags the authorize transfer and carries its leg table.
+    Authorize { legs: Vec<InflightLeg> },
+    /// Tags a per-destination holding account.
+    Hold { destination: AccountId },
+    /// Tags a settling transfer that delivers to a destination.
+    Confirm {
+        tx: EnvelopeId,
+        destination: AccountId,
+    },
+    /// Tags a settling transfer that returns to a funder.
+    Void {
+        tx: EnvelopeId,
+        destination: AccountId,
+    },
 }
 
-fn decode_legs(bytes: &[u8], tid: EnvelopeId) -> Result<Vec<InflightLeg>, LedgerError> {
-    let count_slice: [u8; 4] = bytes
-        .get(0..4)
-        .and_then(|s| s.try_into().ok())
-        .ok_or_else(|| malformed(tid))?;
-    let n = u32::from_be_bytes(count_slice) as usize;
-    let mut out = Vec::with_capacity(n);
-    let mut off = 4;
-    for _ in 0..n {
-        let destination = AccountId::new(read_i64(bytes, off, tid)?);
-        let hold = AccountId::new(read_i64(bytes, off + 8, tid)?);
-        let funder = AccountId::new(read_i64(bytes, off + 16, tid)?);
-        let asset_slice: [u8; 4] = bytes
-            .get(off + 24..off + 28)
-            .and_then(|s| s.try_into().ok())
-            .ok_or_else(|| malformed(tid))?;
-        let amount_slice: [u8; 16] = bytes
-            .get(off + 28..off + 44)
-            .and_then(|s| s.try_into().ok())
-            .ok_or_else(|| malformed(tid))?;
-        out.push(InflightLeg {
-            destination,
-            hold,
-            funder,
-            asset: AssetId::new(u32::from_be_bytes(asset_slice)),
-            amount: Cent::from_canonical_bytes(&amount_slice)?,
-        });
-        off += LEG_BYTES;
-    }
-    Ok(out)
+/// Whether a settle delivers to the destination or returns to a funder.
+#[derive(Clone, Copy)]
+enum SettleRole {
+    Confirm,
+    Void,
 }
 
-fn hold_metadata(destination: AccountId) -> Metadata {
-    let mut m = Metadata::new();
-    m.insert(K_ROLE.to_string(), vec![ROLE_HOLD]);
-    m.insert(K_DEST.to_string(), destination.0.to_be_bytes().to_vec());
-    m
+fn malformed(tid: EnvelopeId) -> LedgerError {
+    LedgerError::NotInflightTransaction(tid)
 }
 
-fn authorize_metadata(base: &Metadata, legs: &[InflightLeg]) -> Metadata {
-    let mut m = base.clone();
-    m.insert(K_ROLE.to_string(), vec![ROLE_AUTHORIZE]);
-    m.insert(K_LEGS.to_string(), encode_legs(legs));
-    m
+/// Encode an [`InflightMeta`] to CBOR bytes.
+fn encode_meta(meta: &InflightMeta) -> Result<Vec<u8>, LedgerError> {
+    let mut buf = Vec::new();
+    ciborium::into_writer(meta, &mut buf)
+        .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
+    Ok(buf)
 }
 
-fn settle_metadata(role: u8, tx: EnvelopeId, destination: AccountId) -> Metadata {
+/// Wrap a single [`InflightMeta`] into a fresh [`Metadata`] map.
+fn meta_map(meta: &InflightMeta) -> Result<Metadata, LedgerError> {
     let mut m = Metadata::new();
-    m.insert(K_ROLE.to_string(), vec![role]);
-    m.insert(K_TX.to_string(), tx.0.to_vec());
-    m.insert(K_DEST.to_string(), destination.0.to_be_bytes().to_vec());
-    m
+    m.insert(K_INFLIGHT.to_string(), encode_meta(meta)?);
+    Ok(m)
 }
 
-fn role_of(meta: &Metadata) -> Option<u8> {
-    meta.get(K_ROLE).and_then(|v| v.first().copied())
+/// Decode the [`InflightMeta`] carried by a metadata map, if any.
+fn read_meta(meta: &Metadata) -> Option<InflightMeta> {
+    let bytes = meta.get(K_INFLIGHT)?;
+    ciborium::from_reader(bytes.as_slice()).ok()
 }
 
 impl Ledger {
@@ -232,7 +193,7 @@ impl Ledger {
             let mut acct = Account::new(*hold, AccountPolicy::NoOverdraft);
             acct.flags = AccountFlags::INFLIGHT;
             acct.book = transfer.book;
-            acct.metadata = hold_metadata(*dest);
+            acct.metadata = meta_map(&InflightMeta::Hold { destination: *dest })?;
             self.create_account(acct).await?;
         }
 
@@ -252,9 +213,12 @@ impl Ledger {
             });
             builder = builder.movement(m.from, hold, m.asset, m.amount);
         }
-        let rewritten = builder
-            .metadata(authorize_metadata(&transfer.metadata, &legs))
-            .build();
+        let mut md = transfer.metadata.clone();
+        md.insert(
+            K_INFLIGHT.to_string(),
+            encode_meta(&InflightMeta::Authorize { legs: legs.clone() })?,
+        );
+        let rewritten = builder.metadata(md).build();
 
         let receipt = self.commit(rewritten).await?;
         Ok(Authorization {
@@ -283,8 +247,17 @@ impl Ledger {
                 let bal = self.balance(&hold, &asset).await?;
                 if bal.is_positive() {
                     receipts.push(
-                        self.settle(book, *inflight, hold, dest, dest, asset, bal, ROLE_CONFIRM)
-                            .await?,
+                        self.settle(
+                            book,
+                            *inflight,
+                            hold,
+                            dest,
+                            dest,
+                            asset,
+                            bal,
+                            SettleRole::Confirm,
+                        )
+                        .await?,
                     );
                 }
             }
@@ -293,46 +266,62 @@ impl Ledger {
         Ok(receipts)
     }
 
-    /// Confirm a slice of one leg: move `amount` of `asset` from the destination's
-    /// hold to the destination. `amount` must not exceed the amount still held;
-    /// the `NoOverdraft` hold makes over-confirmation impossible regardless.
+    /// Confirm one or more legs in a single call. Each movement is expressed with
+    /// the same `(from, to, asset, amount)` shape as [`TransferBuilder::pay`]:
+    /// `from` is the leg's funder, `to` its destination. Build the set with
+    /// `TransferBuilder` and pass the resulting [`Transfer`]; its book, user data,
+    /// and metadata are ignored.
+    ///
+    /// Each movement delivers `amount` of `asset` from the matching leg's hold to
+    /// its destination. `amount` must not exceed the amount still held; the
+    /// `NoOverdraft` hold makes over-confirmation impossible regardless. A hold is
+    /// closed once fully drained.
+    ///
+    /// Movements settle in order, each its own commit, so the batch is not atomic:
+    /// a later movement failing leaves earlier confirmations applied.
     pub async fn confirm(
         self: &Arc<Self>,
         inflight: &EnvelopeId,
-        destination: &AccountId,
-        asset: &AssetId,
-        amount: Cent,
-    ) -> Result<Receipt, LedgerError> {
+        confirms: Transfer,
+    ) -> Result<Vec<Receipt>, LedgerError> {
         let (record, legs) = self.load_inflight(inflight).await?;
-        let leg = legs
-            .iter()
-            .find(|l| &l.destination == destination && &l.asset == asset)
-            .ok_or(LedgerError::InflightLegNotFound {
-                destination: *destination,
-                asset: *asset,
-            })?;
-        let hold = leg.hold;
-        let held = self.balance(&hold, asset).await?;
-        if amount > held {
-            return Err(LedgerError::Selection(SelectionError::InsufficientFunds {
-                available: held,
-                requested: amount,
-            }));
+        let book = record.envelope.book();
+        let mut receipts = Vec::new();
+        let mut touched: BTreeSet<AccountId> = BTreeSet::new();
+        for m in &confirms.movements {
+            let leg = legs
+                .iter()
+                .find(|l| l.funder == m.from && l.destination == m.to && l.asset == m.asset)
+                .ok_or(LedgerError::InflightLegNotFound {
+                    destination: m.to,
+                    asset: m.asset,
+                })?;
+            let held = self.balance(&leg.hold, &m.asset).await?;
+            if m.amount > held {
+                return Err(LedgerError::Selection(SelectionError::InsufficientFunds {
+                    available: held,
+                    requested: m.amount,
+                }));
+            }
+            receipts.push(
+                self.settle(
+                    book,
+                    *inflight,
+                    leg.hold,
+                    m.to,
+                    m.to,
+                    m.asset,
+                    m.amount,
+                    SettleRole::Confirm,
+                )
+                .await?,
+            );
+            touched.insert(leg.hold);
         }
-        let receipt = self
-            .settle(
-                record.envelope.book(),
-                *inflight,
-                hold,
-                *destination,
-                *destination,
-                *asset,
-                amount,
-                ROLE_CONFIRM,
-            )
-            .await?;
-        self.close_if_drained(&hold).await?;
-        Ok(receipt)
+        for hold in touched {
+            self.close_if_drained(&hold).await?;
+        }
+        Ok(receipts)
     }
 
     // -----------------------------------------------------------------------
@@ -372,7 +361,14 @@ impl Ledger {
                     if give.is_positive() {
                         receipts.push(
                             self.settle(
-                                book, *inflight, hold, funder, dest, asset, give, ROLE_VOID,
+                                book,
+                                *inflight,
+                                hold,
+                                funder,
+                                dest,
+                                asset,
+                                give,
+                                SettleRole::Void,
                             )
                             .await?,
                         );
@@ -410,19 +406,15 @@ impl Ledger {
         let mut voided: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
         for hold in holds_of(&legs) {
             for record in self.history(&hold).await? {
-                let role = match role_of(record.envelope.metadata()) {
-                    Some(r @ (ROLE_CONFIRM | ROLE_VOID)) => r,
+                let bucket = match read_meta(record.envelope.metadata()) {
+                    Some(InflightMeta::Confirm { .. }) => &mut confirmed,
+                    Some(InflightMeta::Void { .. }) => &mut voided,
                     _ => continue,
                 };
                 for np in record.envelope.creates() {
                     if np.owner == hold {
                         continue; // change returned to the hold, not settled out
                     }
-                    let bucket = if role == ROLE_CONFIRM {
-                        &mut confirmed
-                    } else {
-                        &mut voided
-                    };
                     let e = bucket.entry((hold, np.asset)).or_insert(Cent::ZERO);
                     *e = e.checked_add(np.value)?;
                 }
@@ -481,15 +473,10 @@ impl Ledger {
             .get_transfer(inflight)
             .await?
             .ok_or(LedgerError::InflightNotFound(*inflight))?;
-        if role_of(record.envelope.metadata()) != Some(ROLE_AUTHORIZE) {
-            return Err(LedgerError::NotInflightTransaction(*inflight));
-        }
-        let bytes = record
-            .envelope
-            .metadata()
-            .get(K_LEGS)
-            .ok_or_else(|| malformed(*inflight))?;
-        let legs = decode_legs(bytes, *inflight)?;
+        let legs = match read_meta(record.envelope.metadata()) {
+            Some(InflightMeta::Authorize { legs }) => legs,
+            _ => return Err(LedgerError::NotInflightTransaction(*inflight)),
+        };
         Ok((record, legs))
     }
 
@@ -498,11 +485,10 @@ impl Ledger {
         &self,
         destination: &AccountId,
     ) -> Result<Option<AccountId>, LedgerError> {
-        let want = destination.0.to_be_bytes();
         for a in self.list_accounts().await? {
             if a.flags.contains(AccountFlags::INFLIGHT)
                 && !a.is_closed()
-                && a.metadata.get(K_DEST).map(|v| v.as_slice()) == Some(want.as_slice())
+                && matches!(read_meta(&a.metadata), Some(InflightMeta::Hold { destination: d }) if d == *destination)
             {
                 return Ok(Some(a.id));
             }
@@ -521,12 +507,22 @@ impl Ledger {
         destination: AccountId,
         asset: AssetId,
         amount: Cent,
-        role: u8,
+        role: SettleRole,
     ) -> Result<Receipt, LedgerError> {
+        let meta = match role {
+            SettleRole::Confirm => InflightMeta::Confirm {
+                tx: inflight,
+                destination,
+            },
+            SettleRole::Void => InflightMeta::Void {
+                tx: inflight,
+                destination,
+            },
+        };
         let tx = TransferBuilder::new()
             .book(book)
             .pay(hold, target, asset, amount)
-            .metadata(settle_metadata(role, inflight, destination))
+            .metadata(meta_map(&meta)?)
             .build();
         self.commit(tx).await
     }

+ 52 - 6
crates/kuatia/tests/inflight.rs

@@ -13,8 +13,6 @@
 //! Authorized, the funds park in per-destination holding accounts; `fee`'s hold
 //! collects EUR from B and BTC from A.
 
-#![allow(missing_docs)]
-
 use std::collections::BTreeMap;
 use std::sync::Arc;
 
@@ -95,6 +93,14 @@ async fn bal(ledger: &Arc<Ledger>, account: AccountId, asset: AssetId) -> Cent {
     ledger.balance(&account, &asset).await.unwrap()
 }
 
+/// A one-movement confirm set, built with the same `.pay()` interface as a
+/// transfer: `from` is the leg's funder, `to` its destination.
+fn confirm_one(from: AccountId, to: AccountId, asset: AssetId, amount: i64) -> Transfer {
+    TransferBuilder::new()
+        .pay(from, to, asset, Cent::from(amount))
+        .build()
+}
+
 /// After authorize, funds leave the payers and sit in the holds; the payers'
 /// balances drop to zero and nothing has reached the destinations yet.
 #[tokio::test]
@@ -171,7 +177,7 @@ async fn partial_confirm_then_confirm_remainder() {
     let auth = ledger.authorize(trade()).await.unwrap();
 
     ledger
-        .confirm(&auth.inflight, &b(), &eur(), Cent::from(40))
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 40))
         .await
         .unwrap();
     assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(40));
@@ -190,7 +196,7 @@ async fn partial_confirm_then_confirm_remainder() {
 
     // Confirm the rest.
     ledger
-        .confirm(&auth.inflight, &b(), &eur(), Cent::from(60))
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 60))
         .await
         .unwrap();
     assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
@@ -212,7 +218,7 @@ async fn partial_confirm_then_void_remainder() {
     let auth = ledger.authorize(trade()).await.unwrap();
 
     ledger
-        .confirm(&auth.inflight, &b(), &eur(), Cent::from(40))
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 40))
         .await
         .unwrap();
     ledger.void(&auth.inflight).await.unwrap();
@@ -243,7 +249,7 @@ async fn over_confirm_is_rejected() {
     let auth = ledger.authorize(trade()).await.unwrap();
 
     let err = ledger
-        .confirm(&auth.inflight, &b(), &eur(), Cent::from(101))
+        .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 101))
         .await
         .unwrap_err();
     assert!(matches!(err, LedgerError::Selection(_)));
@@ -251,6 +257,46 @@ async fn over_confirm_is_rejected() {
     assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
 }
 
+/// A single confirm call settles several legs at once, built with the same
+/// `.pay()` interface as a transfer.
+#[tokio::test]
+async fn batch_confirm_multiple_legs() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    // Confirm B's EUR leg and A's BTC leg in one call.
+    let confirms = TransferBuilder::new()
+        .pay(a(), b(), eur(), Cent::from(100))
+        .pay(b(), a(), btc(), Cent::from(10))
+        .build();
+    let receipts = ledger.confirm(&auth.inflight, confirms).await.unwrap();
+    assert_eq!(receipts.len(), 2);
+
+    assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
+    assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(10));
+    // The fee hold is untouched, so it is still open.
+    assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
+    assert_eq!(bal(&ledger, fee(), btc()).await, Cent::ZERO);
+    assert_eq!(ledger.list_open_inflights().await.unwrap().len(), 1);
+
+    let status = ledger.inflight_status(&auth.inflight).await.unwrap();
+    assert_eq!(status.state, InflightState::PartiallyConfirmed);
+}
+
+/// Confirming a movement whose `(from, to, asset)` matches no leg is rejected.
+#[tokio::test]
+async fn confirm_unknown_leg_is_rejected() {
+    let ledger = setup().await;
+    let auth = ledger.authorize(trade()).await.unwrap();
+
+    // fee never funded a BTC leg to B.
+    let err = ledger
+        .confirm(&auth.inflight, confirm_one(fee(), b(), btc(), 1))
+        .await
+        .unwrap_err();
+    assert!(matches!(err, LedgerError::InflightLegNotFound { .. }));
+}
+
 /// Only one open inflight is allowed per destination account at a time.
 #[tokio::test]
 async fn one_open_inflight_per_account() {

+ 42 - 39
doc/adr/0004-inflight-holds-via-holding-accounts.md

@@ -108,7 +108,7 @@ posting on partial confirmation.
 
 #### Option 3: Rewrite each destination to a per-destination holding account (chosen)
 
-Model an inflight transaction `T` as the ordinary trade with every destination
+Model an inflight transaction as the ordinary trade with every destination
 `to` rewritten to a fresh holding account created for that destination:
 
 ```
@@ -120,14 +120,15 @@ B -> fee.inflight -> 1 EUR
 
 Committing that rewritten transfer is the authorize step: one atomic,
 conservation-preserving commit moves the funds out of A and B into the holding
-accounts. That commit is stored in the transactions table like any other. An
-inflight transaction id `T` is minted up front and carried in metadata across
-every artifact: the authorize transfer's metadata declares its role and the full
-leg table `[(destination, hold, funder, asset, amount)]`; each holding account's
-metadata records its role, `T`, and its destination; each later confirm or void
-transfer's metadata records its role, `T`, and the leg it settles. The metadata
-is therefore the record of what is held and for whom, and it is content-addressed
-into each transfer's id, so it is tamper-evident. A hold is keyed by destination,
+accounts. That commit is stored in the transactions table like any other, and its
+content-addressed `EnvelopeId` is the inflight handle. The metadata carries the
+record across every artifact: the authorize transfer's metadata declares its role
+and the full leg table `[(destination, hold, funder, asset, amount)]`; each
+holding account's metadata records its role and its destination; each later
+confirm or void transfer's metadata records its role, the inflight handle, and the
+leg it settles. The metadata is therefore the record of what is held and for whom,
+and it is content-addressed into each transfer's id, so it is tamper-evident. A
+hold is keyed by destination,
 so `fee.inflight` legitimately holds two assets funded by two different accounts.
 
 The lifecycle operations are ordinary commits, each driven from the leg table in
@@ -197,9 +198,9 @@ ledger already enforces. Concretely:
 
 * **Authorize rewrites destinations.** For an inflight transaction, each movement
   `from -> to` becomes `from -> hold(to)`, where `hold(to)` is a fresh
-  `NoOverdraft` account flagged `INFLIGHT` whose metadata records the inflight id
-  `T` and its destination. The rewritten transfer is committed normally with the
-  leg table and `T` in its metadata.
+  `NoOverdraft` account flagged `INFLIGHT` whose metadata records its destination.
+  The rewritten transfer is committed normally with the leg table in its metadata,
+  and its content-addressed `EnvelopeId` becomes the inflight handle.
 * **Metadata is the record.** Confirm and void load the authorize transfer with
   `get_transfer` / `get_transfers_for_account` and read the leg table and funders
   straight from its metadata, rather than reconstructing them from movement
@@ -210,8 +211,12 @@ ledger already enforces. Concretely:
   commits `hold -> destination` for each leg's balance; partial confirm commits
   `hold -> destination` for a slice; void commits `hold -> funder` per leg, with
   the funder taken from the leg table. Each settling transfer carries its role
-  (`confirm` or `void`), `T`, and the leg it settles in metadata. All go through
-  `commit`, so all are idempotent and crash-safe.
+  (`confirm` or `void`), the inflight handle, and the leg it settles in metadata.
+  All go through `commit`, so all are idempotent and crash-safe. Confirm accepts a
+  batch of legs to settle in one call, expressed with the same
+  `(from, to, asset, amount)` shape as `TransferBuilder::pay` (`from` the funder,
+  `to` the destination); the movements settle in order, each its own commit, so a
+  batch is not atomic.
 * **State is derived.** The amount held on a leg is `balance(hold, asset)`. The
   authorized amount is the leg's amount in the metadata leg table. Confirmed is
   authorized minus held. Whether a leg was confirmed or voided is read from the
@@ -226,31 +231,29 @@ current `payer: None`).
 
 ### Inflight metadata schema
 
-All keys live under an `inflight.` namespace in the existing `Metadata` map
-(`BTreeMap<String, Vec<u8>>`). Values use the same canonical big-endian encoding
-(`ToBytes`) as the rest of the ledger.
-
-* **Holding account** (`Account.metadata`):
-  * `inflight.role` = `hold`
-  * `inflight.tx` = `T`
-  * `inflight.destination` = the real destination `AccountId`
-* **Authorize transfer** (`Envelope.metadata`):
-  * `inflight.role` = `authorize`
-  * `inflight.tx` = `T`
-  * `inflight.legs` = encoded list of `{ destination, hold, funder, asset,
-    amount }`
-* **Confirm / void transfer** (`Envelope.metadata`):
-  * `inflight.role` = `confirm` | `void`
-  * `inflight.tx` = `T`
-  * `inflight.destination` = the leg being settled
-
-`T` is minted up front (an `AutoId`, or supplied by the caller) so it is known
-before any account or transfer is built and can be stamped identically
-everywhere. It is distinct from the authorize transfer's content-addressed
-`EnvelopeId`. Because metadata is hashed into each transfer id, these tags are
-tamper-evident. The `involved` index and account `INFLIGHT` flag remain the only
-things used for discovery (metadata is carried, not queried); everything semantic
-is read from the tags above.
+The payload is a single CBOR-encoded tagged enum stored under one `inflight` key
+in the existing `Metadata` map (`BTreeMap<String, Vec<u8>>`), via `ciborium`.
+This supersedes the earlier per-key big-endian byte layout: one typed value
+instead of hand-packed fields.
+
+```rust
+enum InflightMeta {
+    Authorize { legs: Vec<InflightLeg> }, // on the authorize transfer
+    Hold { destination: AccountId },      // on each holding account
+    Confirm { tx: EnvelopeId, destination: AccountId }, // on a confirm transfer
+    Void { tx: EnvelopeId, destination: AccountId },    // on a void transfer
+}
+```
+
+Each `InflightLeg` is `{ destination, hold, funder, asset, amount }`. The
+inflight handle is the authorize transfer's content-addressed `EnvelopeId`; the
+`tx` field on a settling transfer back-references it.
+
+Open holds are discovered by scanning `INFLIGHT`-flagged, not-closed accounts and
+reading their `Hold` metadata; the flag is the marker (metadata is carried, not
+queried). Everything semantic (leg table, funders, per-transfer role) is read
+from the enum. Because metadata is hashed as opaque bytes into each transfer id,
+the payload is tamper-evident; `ciborium` is deterministic for a fixed value.
 
 ### Positive Consequences
 

+ 9 - 4
doc/inflight.md

@@ -29,8 +29,9 @@ 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 in an `inflight.`
-metadata namespace, so the lifecycle is read from recorded fields, not inferred.
+(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
 
@@ -63,8 +64,12 @@ let trade = TransferBuilder::new()
     .build();
 let auth = ledger.authorize(trade).await?;
 
-// Confirm one leg partially: deliver 40 EUR of B's hold to B now.
-ledger.confirm(&auth.inflight, &b, &eur, Cent::from(40)).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?;