|
|
@@ -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
|
|
|
}
|