|
@@ -1,13 +1,13 @@
|
|
|
-//! Posting selection for the intent layer.
|
|
|
|
|
|
|
+//! The error raised when an intent cannot be covered by the available postings.
|
|
|
//!
|
|
//!
|
|
|
//! When a caller uses `pay` or `withdraw`, they specify an amount — not which
|
|
//! When a caller uses `pay` or `withdraw`, they specify an amount — not which
|
|
|
-//! postings to consume. This module picks the smallest set of postings that
|
|
|
|
|
-//! covers the requested amount, so the intent layer can build the transfer
|
|
|
|
|
-//! automatically without exposing UTXO mechanics to the caller.
|
|
|
|
|
|
|
+//! postings to consume. Resolution ([`crate::posting_resolution::resolve_envelope`])
|
|
|
|
|
+//! picks the postings; when a non-overdraft account cannot cover the requested
|
|
|
|
|
+//! amount it fails with [`SelectionError::InsufficientFunds`].
|
|
|
|
|
|
|
|
-use kuatia_types::{AssetId, Cent, Posting, PostingId};
|
|
|
|
|
|
|
+use kuatia_types::Cent;
|
|
|
|
|
|
|
|
-/// Error returned when posting selection fails.
|
|
|
|
|
|
|
+/// Error returned when an amount cannot be covered by the available postings.
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
pub enum SelectionError {
|
|
pub enum SelectionError {
|
|
|
/// Available postings do not cover the requested amount.
|
|
/// Available postings do not cover the requested amount.
|
|
@@ -17,8 +17,6 @@ pub enum SelectionError {
|
|
|
/// Amount the caller asked for.
|
|
/// Amount the caller asked for.
|
|
|
requested: Cent,
|
|
requested: Cent,
|
|
|
},
|
|
},
|
|
|
- /// Summing posting values would overflow `Cent`.
|
|
|
|
|
- Overflow,
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl std::fmt::Display for SelectionError {
|
|
impl std::fmt::Display for SelectionError {
|
|
@@ -33,141 +31,8 @@ impl std::fmt::Display for SelectionError {
|
|
|
"insufficient funds: available {available}, requested {requested}"
|
|
"insufficient funds: available {available}, requested {requested}"
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
- Self::Overflow => write!(f, "monetary amount overflow"),
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl std::error::Error for SelectionError {}
|
|
impl std::error::Error for SelectionError {}
|
|
|
-
|
|
|
|
|
-/// Picks postings to cover `target`, using largest-first greedy to minimise
|
|
|
|
|
-/// the number of postings consumed (and therefore the number of change postings
|
|
|
|
|
-/// created). Only positive postings of the right asset are considered; the
|
|
|
|
|
-/// caller supplies an already-active `available` set.
|
|
|
|
|
-pub fn select_postings(
|
|
|
|
|
- available: &[Posting],
|
|
|
|
|
- asset: AssetId,
|
|
|
|
|
- target: Cent,
|
|
|
|
|
-) -> Result<Vec<PostingId>, SelectionError> {
|
|
|
|
|
- assert!(target.is_positive(), "target must be positive");
|
|
|
|
|
-
|
|
|
|
|
- let mut candidates: Vec<&Posting> = available
|
|
|
|
|
- .iter()
|
|
|
|
|
- .filter(|p| p.asset == asset && p.value.is_positive())
|
|
|
|
|
- .collect();
|
|
|
|
|
-
|
|
|
|
|
- // Largest first
|
|
|
|
|
- candidates.sort_by_key(|p| std::cmp::Reverse(p.value));
|
|
|
|
|
-
|
|
|
|
|
- let mut total_available = Cent::ZERO;
|
|
|
|
|
- for p in &candidates {
|
|
|
|
|
- total_available = total_available
|
|
|
|
|
- .checked_add(p.value)
|
|
|
|
|
- .map_err(|_| SelectionError::Overflow)?;
|
|
|
|
|
- }
|
|
|
|
|
- if total_available < target {
|
|
|
|
|
- return Err(SelectionError::InsufficientFunds {
|
|
|
|
|
- available: total_available,
|
|
|
|
|
- requested: target,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let mut selected = Vec::new();
|
|
|
|
|
- let mut sum = Cent::ZERO;
|
|
|
|
|
- for posting in candidates {
|
|
|
|
|
- selected.push(posting.id);
|
|
|
|
|
- sum = sum
|
|
|
|
|
- .checked_add(posting.value)
|
|
|
|
|
- .map_err(|_| SelectionError::Overflow)?;
|
|
|
|
|
- if sum >= target {
|
|
|
|
|
- break;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- Ok(selected)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-#[cfg(test)]
|
|
|
|
|
-mod tests {
|
|
|
|
|
- use super::*;
|
|
|
|
|
- use kuatia_types::*;
|
|
|
|
|
-
|
|
|
|
|
- fn make_posting(index: u16, value: i64) -> Posting {
|
|
|
|
|
- Posting::new(
|
|
|
|
|
- PostingId {
|
|
|
|
|
- transfer: EnvelopeId([1; 32]),
|
|
|
|
|
- index,
|
|
|
|
|
- },
|
|
|
|
|
- AccountId::new(1),
|
|
|
|
|
- AssetId::new(1),
|
|
|
|
|
- Cent::from(value),
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn exact_match() {
|
|
|
|
|
- let postings = vec![make_posting(0, 50), make_posting(1, 50)];
|
|
|
|
|
- let result = select_postings(&postings, AssetId::new(1), Cent::from(100)).unwrap();
|
|
|
|
|
- assert_eq!(result.len(), 2);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn largest_first() {
|
|
|
|
|
- let postings = vec![
|
|
|
|
|
- make_posting(0, 10),
|
|
|
|
|
- make_posting(1, 90),
|
|
|
|
|
- make_posting(2, 50),
|
|
|
|
|
- ];
|
|
|
|
|
- let result = select_postings(&postings, AssetId::new(1), Cent::from(80)).unwrap();
|
|
|
|
|
- // Should pick 90 first (enough on its own)
|
|
|
|
|
- assert_eq!(result.len(), 1);
|
|
|
|
|
- assert_eq!(result[0].index, 1);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn insufficient_funds() {
|
|
|
|
|
- let postings = vec![make_posting(0, 30), make_posting(1, 20)];
|
|
|
|
|
- let err = select_postings(&postings, AssetId::new(1), Cent::from(100)).unwrap_err();
|
|
|
|
|
- assert_eq!(
|
|
|
|
|
- err,
|
|
|
|
|
- SelectionError::InsufficientFunds {
|
|
|
|
|
- available: Cent::from(50),
|
|
|
|
|
- requested: Cent::from(100)
|
|
|
|
|
- }
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn ignores_wrong_asset() {
|
|
|
|
|
- // Selection receives an already-active set (state lives in the store's
|
|
|
|
|
- // index tables, not on the posting), so it only has to skip the wrong
|
|
|
|
|
- // asset and negative values.
|
|
|
|
|
- let mut wrong_asset = make_posting(1, 1000);
|
|
|
|
|
- wrong_asset.asset = AssetId::new(2);
|
|
|
|
|
-
|
|
|
|
|
- let good = make_posting(2, 50);
|
|
|
|
|
-
|
|
|
|
|
- let postings = vec![wrong_asset, good];
|
|
|
|
|
- let result = select_postings(&postings, AssetId::new(1), Cent::from(50)).unwrap();
|
|
|
|
|
- assert_eq!(result.len(), 1);
|
|
|
|
|
- assert_eq!(result[0].index, 2);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn ignores_negative_postings() {
|
|
|
|
|
- let negative = Posting::new(
|
|
|
|
|
- PostingId {
|
|
|
|
|
- transfer: EnvelopeId([1; 32]),
|
|
|
|
|
- index: 0,
|
|
|
|
|
- },
|
|
|
|
|
- AccountId::new(1),
|
|
|
|
|
- AssetId::new(1),
|
|
|
|
|
- Cent::from(-100),
|
|
|
|
|
- );
|
|
|
|
|
- let good = make_posting(1, 50);
|
|
|
|
|
- let postings = vec![negative, good];
|
|
|
|
|
- let result = select_postings(&postings, AssetId::new(1), Cent::from(50)).unwrap();
|
|
|
|
|
- assert_eq!(result.len(), 1);
|
|
|
|
|
- assert_eq!(result[0].index, 1);
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|