|
@@ -0,0 +1,598 @@
|
|
|
|
|
+//! Inflight holds: authorize funds now, confirm (fully or partially) or void
|
|
|
|
|
+//! later.
|
|
|
|
|
+//!
|
|
|
|
|
+//! An inflight transaction is an ordinary trade whose every destination is
|
|
|
|
|
+//! rewritten to a fresh per-destination holding account (`NoOverdraft`, flagged
|
|
|
|
|
+//! [`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.
|
|
|
|
|
+//!
|
|
|
|
|
+//! See `doc/adr/0004-inflight-holds-via-holding-accounts.md`.
|
|
|
|
|
+
|
|
|
|
|
+use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
|
+use std::sync::Arc;
|
|
|
|
|
+
|
|
|
|
|
+use kuatia_core::{
|
|
|
|
|
+ Account, AccountFlags, AccountId, AccountPolicy, AssetId, BookId, Cent, EnvelopeId, Metadata,
|
|
|
|
|
+ Receipt, SelectionError, Transfer, TransferBuilder,
|
|
|
|
|
+};
|
|
|
|
|
+use kuatia_storage::store::EnvelopeRecord;
|
|
|
|
|
+use kuatia_types::PostingStatus;
|
|
|
|
|
+
|
|
|
|
|
+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
|
|
|
|
|
+
|
|
|
|
|
+/// 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)]
|
|
|
|
|
+pub struct InflightLeg {
|
|
|
|
|
+ /// Account the funds settle to on confirm.
|
|
|
|
|
+ pub destination: AccountId,
|
|
|
|
|
+ /// Per-destination holding account parking the funds.
|
|
|
|
|
+ pub hold: AccountId,
|
|
|
|
|
+ /// Account that funded this leg (the funds return here on void).
|
|
|
|
|
+ pub funder: AccountId,
|
|
|
|
|
+ /// Asset being held.
|
|
|
|
|
+ pub asset: AssetId,
|
|
|
|
|
+ /// Amount authorized for this leg.
|
|
|
|
|
+ pub amount: Cent,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Result of [`Ledger::authorize`].
|
|
|
|
|
+#[derive(Debug, Clone)]
|
|
|
|
|
+pub struct Authorization {
|
|
|
|
|
+ /// Handle for the inflight transaction: the authorize transfer's id.
|
|
|
|
|
+ pub inflight: EnvelopeId,
|
|
|
|
|
+ /// Receipt of the authorize commit.
|
|
|
|
|
+ pub receipt: Receipt,
|
|
|
|
|
+ /// The legs, one per original movement.
|
|
|
|
|
+ pub legs: Vec<InflightLeg>,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Lifecycle state of an inflight transaction, derived from balances and the
|
|
|
|
|
+/// settling transfers.
|
|
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
+pub enum InflightState {
|
|
|
|
|
+ /// Nothing settled yet; the full authorized amount is still held.
|
|
|
|
|
+ Held,
|
|
|
|
|
+ /// Some funds settled, some still held.
|
|
|
|
|
+ PartiallyConfirmed,
|
|
|
|
|
+ /// Fully settled to destinations.
|
|
|
|
|
+ Confirmed,
|
|
|
|
|
+ /// Fully returned to funders.
|
|
|
|
|
+ Voided,
|
|
|
|
|
+ /// Fully settled, but a mix of confirmed and voided legs.
|
|
|
|
|
+ Mixed,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Per-(destination, asset) status line.
|
|
|
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
+pub struct InflightLegStatus {
|
|
|
|
|
+ /// Destination account.
|
|
|
|
|
+ pub destination: AccountId,
|
|
|
|
|
+ /// Holding account.
|
|
|
|
|
+ pub hold: AccountId,
|
|
|
|
|
+ /// Asset.
|
|
|
|
|
+ pub asset: AssetId,
|
|
|
|
|
+ /// Amount originally authorized.
|
|
|
|
|
+ pub authorized: Cent,
|
|
|
|
|
+ /// Amount confirmed to the destination so far.
|
|
|
|
|
+ pub confirmed: Cent,
|
|
|
|
|
+ /// Amount returned to funders so far.
|
|
|
|
|
+ pub voided: Cent,
|
|
|
|
|
+ /// Amount still held (`= authorized - confirmed - voided`).
|
|
|
|
|
+ pub held: Cent,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Derived status of an inflight transaction.
|
|
|
|
|
+#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
+pub struct InflightStatus {
|
|
|
|
|
+ /// The inflight handle.
|
|
|
|
|
+ pub inflight: EnvelopeId,
|
|
|
|
|
+ /// One entry per (destination, asset).
|
|
|
|
|
+ pub legs: Vec<InflightLegStatus>,
|
|
|
|
|
+ /// Overall state.
|
|
|
|
|
+ pub state: InflightState,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// Metadata encoding
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+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))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 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
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn settle_metadata(role: u8, tx: EnvelopeId, destination: AccountId) -> Metadata {
|
|
|
|
|
+ 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
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn role_of(meta: &Metadata) -> Option<u8> {
|
|
|
|
|
+ meta.get(K_ROLE).and_then(|v| v.first().copied())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl Ledger {
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+ // Authorize
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /// Authorize a trade without settling it. Each movement's destination is
|
|
|
|
|
+ /// rewritten to a fresh per-destination holding account, and the rewritten
|
|
|
|
|
+ /// transfer is committed, parking the funds. Returns a handle used by
|
|
|
|
|
+ /// [`confirm_all`](Self::confirm_all), [`confirm`](Self::confirm), and
|
|
|
|
|
+ /// [`void`](Self::void).
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Every movement must move between two distinct accounts. A destination
|
|
|
|
|
+ /// that already has an open inflight hold is rejected.
|
|
|
|
|
+ pub async fn authorize(
|
|
|
|
|
+ self: &Arc<Self>,
|
|
|
|
|
+ transfer: Transfer,
|
|
|
|
|
+ ) -> Result<Authorization, LedgerError> {
|
|
|
|
|
+ // One holding account per distinct destination.
|
|
|
|
|
+ let mut dest_to_hold: BTreeMap<AccountId, AccountId> = BTreeMap::new();
|
|
|
|
|
+ for m in &transfer.movements {
|
|
|
|
|
+ if m.from == m.to {
|
|
|
|
|
+ return Err(LedgerError::InflightSelfMovement(m.from));
|
|
|
|
|
+ }
|
|
|
|
|
+ dest_to_hold.entry(m.to).or_default();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Enforce one open inflight per destination, then create the holds.
|
|
|
|
|
+ for (dest, hold) in &dest_to_hold {
|
|
|
|
|
+ if self.open_inflight_hold_for(dest).await?.is_some() {
|
|
|
|
|
+ return Err(LedgerError::InflightAlreadyOpen(*dest));
|
|
|
|
|
+ }
|
|
|
|
|
+ let mut acct = Account::new(*hold, AccountPolicy::NoOverdraft);
|
|
|
|
|
+ acct.flags = AccountFlags::INFLIGHT;
|
|
|
|
|
+ acct.book = transfer.book;
|
|
|
|
|
+ acct.metadata = hold_metadata(*dest);
|
|
|
|
|
+ self.create_account(acct).await?;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Rewrite each movement to its hold and record the leg table.
|
|
|
|
|
+ let mut legs = Vec::with_capacity(transfer.movements.len());
|
|
|
|
|
+ let mut builder = TransferBuilder::new()
|
|
|
|
|
+ .book(transfer.book)
|
|
|
|
|
+ .user_data(transfer.user_data.clone());
|
|
|
|
|
+ for m in &transfer.movements {
|
|
|
|
|
+ let hold = dest_to_hold[&m.to];
|
|
|
|
|
+ legs.push(InflightLeg {
|
|
|
|
|
+ destination: m.to,
|
|
|
|
|
+ hold,
|
|
|
|
|
+ funder: m.from,
|
|
|
|
|
+ asset: m.asset,
|
|
|
|
|
+ amount: m.amount,
|
|
|
|
|
+ });
|
|
|
|
|
+ builder = builder.movement(m.from, hold, m.asset, m.amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ let rewritten = builder
|
|
|
|
|
+ .metadata(authorize_metadata(&transfer.metadata, &legs))
|
|
|
|
|
+ .build();
|
|
|
|
|
+
|
|
|
|
|
+ let receipt = self.commit(rewritten).await?;
|
|
|
|
|
+ Ok(Authorization {
|
|
|
|
|
+ inflight: receipt.transfer_id,
|
|
|
|
|
+ receipt,
|
|
|
|
|
+ legs,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+ // Confirm
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /// Confirm the entire inflight transaction: sweep every hold's remaining
|
|
|
|
|
+ /// balance to its destination and close the drained holds.
|
|
|
|
|
+ pub async fn confirm_all(
|
|
|
|
|
+ self: &Arc<Self>,
|
|
|
|
|
+ inflight: &EnvelopeId,
|
|
|
|
|
+ ) -> Result<Vec<Receipt>, LedgerError> {
|
|
|
|
|
+ let (record, legs) = self.load_inflight(inflight).await?;
|
|
|
|
|
+ let book = record.envelope.book();
|
|
|
|
|
+ let mut receipts = Vec::new();
|
|
|
|
|
+ for hold in holds_of(&legs) {
|
|
|
|
|
+ let dest = destination_of(&legs, hold, *inflight)?;
|
|
|
|
|
+ for asset in assets_of(&legs, hold) {
|
|
|
|
|
+ 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.close_if_drained(&hold).await?;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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.
|
|
|
|
|
+ pub async fn confirm(
|
|
|
|
|
+ self: &Arc<Self>,
|
|
|
|
|
+ inflight: &EnvelopeId,
|
|
|
|
|
+ destination: &AccountId,
|
|
|
|
|
+ asset: &AssetId,
|
|
|
|
|
+ amount: Cent,
|
|
|
|
|
+ ) -> Result<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 receipt = self
|
|
|
|
|
+ .settle(
|
|
|
|
|
+ record.envelope.book(),
|
|
|
|
|
+ *inflight,
|
|
|
|
|
+ hold,
|
|
|
|
|
+ *destination,
|
|
|
|
|
+ *destination,
|
|
|
|
|
+ *asset,
|
|
|
|
|
+ amount,
|
|
|
|
|
+ ROLE_CONFIRM,
|
|
|
|
|
+ )
|
|
|
|
|
+ .await?;
|
|
|
|
|
+ self.close_if_drained(&hold).await?;
|
|
|
|
|
+ Ok(receipt)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+ // Void
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /// Void the entire inflight transaction: return every hold's remaining
|
|
|
|
|
+ /// balance to the funders recorded in the leg table and close the holds.
|
|
|
|
|
+ pub async fn void(
|
|
|
|
|
+ self: &Arc<Self>,
|
|
|
|
|
+ inflight: &EnvelopeId,
|
|
|
|
|
+ ) -> Result<Vec<Receipt>, LedgerError> {
|
|
|
|
|
+ let (record, legs) = self.load_inflight(inflight).await?;
|
|
|
|
|
+ let book = record.envelope.book();
|
|
|
|
|
+ let mut receipts = Vec::new();
|
|
|
|
|
+ for hold in holds_of(&legs) {
|
|
|
|
|
+ let dest = destination_of(&legs, hold, *inflight)?;
|
|
|
|
|
+ for asset in assets_of(&legs, hold) {
|
|
|
|
|
+ let mut remaining = self.balance(&hold, &asset).await?;
|
|
|
|
|
+ // Return to funders in leg order, each up to what it funded. For
|
|
|
|
|
+ // the common single-funder-per-(hold, asset) case this returns the
|
|
|
|
|
+ // whole remaining balance to that funder.
|
|
|
|
|
+ let mut funders: Vec<(AccountId, Cent)> = legs
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .filter(|l| l.hold == hold && l.asset == asset)
|
|
|
|
|
+ .map(|l| (l.funder, l.amount))
|
|
|
|
|
+ .collect();
|
|
|
|
|
+ // Ensure any co-funding rounding leftover lands on the last funder.
|
|
|
|
|
+ if let Some(last) = funders.last_mut() {
|
|
|
|
|
+ last.1 = Cent::from(i64::MAX);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (funder, cap) in funders {
|
|
|
|
|
+ if !remaining.is_positive() {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ let give = if cap < remaining { cap } else { remaining };
|
|
|
|
|
+ if give.is_positive() {
|
|
|
|
|
+ receipts.push(
|
|
|
|
|
+ self.settle(
|
|
|
|
|
+ book, *inflight, hold, funder, dest, asset, give, ROLE_VOID,
|
|
|
|
|
+ )
|
|
|
|
|
+ .await?,
|
|
|
|
|
+ );
|
|
|
|
|
+ remaining = remaining.checked_sub(give)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ self.close_if_drained(&hold).await?;
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(receipts)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+ // Status / queries
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /// Derived status of an inflight transaction: per-leg authorized, confirmed,
|
|
|
|
|
+ /// voided, and still-held amounts, plus an overall state. All figures come
|
|
|
|
|
+ /// from balances and the metadata-tagged settling transfers.
|
|
|
|
|
+ pub async fn inflight_status(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ inflight: &EnvelopeId,
|
|
|
|
|
+ ) -> Result<InflightStatus, LedgerError> {
|
|
|
|
|
+ let (_record, legs) = self.load_inflight(inflight).await?;
|
|
|
|
|
+
|
|
|
|
|
+ // Authorized per (hold, asset).
|
|
|
|
|
+ let mut authorized: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
|
|
|
|
|
+ for l in &legs {
|
|
|
|
|
+ let e = authorized.entry((l.hold, l.asset)).or_insert(Cent::ZERO);
|
|
|
|
|
+ *e = e.checked_add(l.amount)?;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Confirmed / voided per (hold, asset), summed from settle transfers.
|
|
|
|
|
+ let mut confirmed: BTreeMap<(AccountId, AssetId), Cent> = BTreeMap::new();
|
|
|
|
|
+ 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,
|
|
|
|
|
+ _ => 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)?;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let mut lines = Vec::new();
|
|
|
|
|
+ for ((hold, asset), auth) in &authorized {
|
|
|
|
|
+ let held = self.balance(hold, asset).await?;
|
|
|
|
|
+ let dest = destination_of(&legs, *hold, *inflight)?;
|
|
|
|
|
+ lines.push(InflightLegStatus {
|
|
|
|
|
+ destination: dest,
|
|
|
|
|
+ hold: *hold,
|
|
|
|
|
+ asset: *asset,
|
|
|
|
|
+ authorized: *auth,
|
|
|
|
|
+ confirmed: confirmed
|
|
|
|
|
+ .get(&(*hold, *asset))
|
|
|
|
|
+ .copied()
|
|
|
|
|
+ .unwrap_or(Cent::ZERO),
|
|
|
|
|
+ voided: voided.get(&(*hold, *asset)).copied().unwrap_or(Cent::ZERO),
|
|
|
|
|
+ held,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let state = overall_state(&lines);
|
|
|
|
|
+ Ok(InflightStatus {
|
|
|
|
|
+ inflight: *inflight,
|
|
|
|
|
+ legs: lines,
|
|
|
|
|
+ state,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// List the holding accounts of every currently open inflight (an
|
|
|
|
|
+ /// `INFLIGHT`-flagged account that is not closed).
|
|
|
|
|
+ pub async fn list_open_inflights(&self) -> Result<Vec<AccountId>, LedgerError> {
|
|
|
|
|
+ Ok(self
|
|
|
|
|
+ .list_accounts()
|
|
|
|
|
+ .await?
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .filter(|a| a.flags.contains(AccountFlags::INFLIGHT) && !a.is_closed())
|
|
|
|
|
+ .map(|a| a.id)
|
|
|
|
|
+ .collect())
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+ // Internal helpers
|
|
|
|
|
+ // -----------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ /// Load the authorize transfer and decode its leg table.
|
|
|
|
|
+ async fn load_inflight(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ inflight: &EnvelopeId,
|
|
|
|
|
+ ) -> Result<(EnvelopeRecord, Vec<InflightLeg>), LedgerError> {
|
|
|
|
|
+ let record = self
|
|
|
|
|
+ .store()
|
|
|
|
|
+ .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)?;
|
|
|
|
|
+ Ok((record, legs))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Find an open (not-closed) inflight holding account for `destination`.
|
|
|
|
|
+ async fn open_inflight_hold_for(
|
|
|
|
|
+ &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())
|
|
|
|
|
+ {
|
|
|
|
|
+ return Ok(Some(a.id));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(None)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Commit a `hold -> target` settling transfer tagged with the inflight role.
|
|
|
|
|
+ #[allow(clippy::too_many_arguments)]
|
|
|
|
|
+ async fn settle(
|
|
|
|
|
+ self: &Arc<Self>,
|
|
|
|
|
+ book: BookId,
|
|
|
|
|
+ inflight: EnvelopeId,
|
|
|
|
|
+ hold: AccountId,
|
|
|
|
|
+ target: AccountId,
|
|
|
|
|
+ destination: AccountId,
|
|
|
|
|
+ asset: AssetId,
|
|
|
|
|
+ amount: Cent,
|
|
|
|
|
+ role: u8,
|
|
|
|
|
+ ) -> Result<Receipt, LedgerError> {
|
|
|
|
|
+ let tx = TransferBuilder::new()
|
|
|
|
|
+ .book(book)
|
|
|
|
|
+ .pay(hold, target, asset, amount)
|
|
|
|
|
+ .metadata(settle_metadata(role, inflight, destination))
|
|
|
|
|
+ .build();
|
|
|
|
|
+ self.commit(tx).await
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Close a holding account once it has no live (Active/PendingInactive)
|
|
|
|
|
+ /// postings left. No-op if already closed or still holding funds.
|
|
|
|
|
+ async fn close_if_drained(&self, hold: &AccountId) -> Result<(), LedgerError> {
|
|
|
|
|
+ let live = self
|
|
|
|
|
+ .store()
|
|
|
|
|
+ .get_postings_by_account(hold, None, None)
|
|
|
|
|
+ .await?
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .any(|p| p.status != PostingStatus::Inactive);
|
|
|
|
|
+ if live {
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ }
|
|
|
|
|
+ if !self.get_account(hold).await?.is_closed() {
|
|
|
|
|
+ self.close(hold).await?;
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn holds_of(legs: &[InflightLeg]) -> BTreeSet<AccountId> {
|
|
|
|
|
+ legs.iter().map(|l| l.hold).collect()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn assets_of(legs: &[InflightLeg], hold: AccountId) -> BTreeSet<AssetId> {
|
|
|
|
|
+ legs.iter()
|
|
|
|
|
+ .filter(|l| l.hold == hold)
|
|
|
|
|
+ .map(|l| l.asset)
|
|
|
|
|
+ .collect()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn destination_of(
|
|
|
|
|
+ legs: &[InflightLeg],
|
|
|
|
|
+ hold: AccountId,
|
|
|
|
|
+ inflight: EnvelopeId,
|
|
|
|
|
+) -> Result<AccountId, LedgerError> {
|
|
|
|
|
+ legs.iter()
|
|
|
|
|
+ .find(|l| l.hold == hold)
|
|
|
|
|
+ .map(|l| l.destination)
|
|
|
|
|
+ .ok_or_else(|| malformed(inflight))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn overall_state(lines: &[InflightLegStatus]) -> InflightState {
|
|
|
|
|
+ let mut any_held = false;
|
|
|
|
|
+ let mut any_confirmed = false;
|
|
|
|
|
+ let mut any_voided = false;
|
|
|
|
|
+ for l in lines {
|
|
|
|
|
+ if l.held.is_positive() {
|
|
|
|
|
+ any_held = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if l.confirmed.is_positive() {
|
|
|
|
|
+ any_confirmed = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if l.voided.is_positive() {
|
|
|
|
|
+ any_voided = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ match (any_held, any_confirmed, any_voided) {
|
|
|
|
|
+ (true, false, false) => InflightState::Held,
|
|
|
|
|
+ (true, _, _) => InflightState::PartiallyConfirmed,
|
|
|
|
|
+ (false, true, true) => InflightState::Mixed,
|
|
|
|
|
+ (false, false, true) => InflightState::Voided,
|
|
|
|
|
+ // Fully settled to destinations, or an empty/zero authorization.
|
|
|
|
|
+ (false, _, false) => InflightState::Confirmed,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|