validate.rs 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122
  1. //! Pure, sync validation — the auditable heart of the ledger.
  2. //!
  3. //! [`validate_and_plan`] enforces every invariant (conservation, double-spend,
  4. //! ownership, account policy) and produces a [`Plan`] describing the effects to
  5. //! apply. It takes no IO, no clock, and no randomness, so it is deterministic
  6. //! and testable with golden vectors. The caller provides pre-loaded state via
  7. //! [`PlanInput`]; this module never touches storage.
  8. use std::collections::{HashMap, HashSet};
  9. use crate::hash::{account_hash, envelope_id};
  10. use kuatia_types::*;
  11. // ---------------------------------------------------------------------------
  12. // Input / Output
  13. // ---------------------------------------------------------------------------
  14. /// Pre-loaded state the caller must supply. Borrowing avoids copies on the
  15. /// hot path and keeps this module allocation-free for the validation itself.
  16. pub struct PlanInput<'a> {
  17. /// The envelope to validate.
  18. pub envelope: &'a Envelope,
  19. /// Postings referenced by `transfer.consumes`.
  20. pub consumed_postings: &'a [Posting],
  21. /// All accounts (subaccounts) referenced by the transfer.
  22. pub accounts: &'a HashMap<AccountId, Account>,
  23. /// Current balances keyed by (account reference, asset).
  24. pub balances: &'a HashMap<(AccountId, AssetId), Cent>,
  25. /// The book gating this transfer, if one is loaded. `Some` enforces the
  26. /// book's [`BookPolicy`] (allowed assets/accounts/flags); `None` means the
  27. /// implicit unrestricted default book. The async layer is responsible for
  28. /// rejecting a *named* book id that has no row before reaching here.
  29. pub book: Option<&'a Book>,
  30. }
  31. /// The validated effects to apply atomically. Produced only when every
  32. /// invariant holds, so the store can apply it without re-checking.
  33. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
  34. pub struct Plan {
  35. /// Content-addressed id of the validated transfer.
  36. pub transfer_id: EnvelopeId,
  37. /// Postings to mark as inactive (consumed).
  38. pub postings_to_deactivate: Vec<PostingId>,
  39. /// New postings to persist.
  40. pub postings_to_create: Vec<Posting>,
  41. }
  42. // ---------------------------------------------------------------------------
  43. // Errors
  44. // ---------------------------------------------------------------------------
  45. /// An invariant violation detected during transfer validation.
  46. #[derive(Debug, Clone, PartialEq, Eq)]
  47. pub enum ValidationError {
  48. /// Transfer has no consumptions and no creations.
  49. EmptyTransfer,
  50. /// The same posting id appears more than once in `consumes`.
  51. DuplicateConsumedPosting(PostingId),
  52. /// A consumed posting id does not exist in the store.
  53. PostingNotFound(PostingId),
  54. /// A consumed posting has already been spent.
  55. PostingAlreadyConsumed(PostingId),
  56. /// A consumed posting is not owned by the expected account.
  57. OwnershipViolation {
  58. /// The posting that failed the ownership check.
  59. posting_id: PostingId,
  60. /// The account that should own the posting.
  61. expected: AccountId,
  62. /// The account that actually owns the posting.
  63. actual: AccountId,
  64. },
  65. /// A referenced account does not exist.
  66. AccountNotFound(AccountId),
  67. /// A referenced account is frozen.
  68. AccountFrozen(AccountId),
  69. /// A referenced account is closed.
  70. AccountClosed(AccountId),
  71. /// Per-asset conservation law violated: consumed sum != created sum.
  72. ConservationViolation {
  73. /// The asset whose sums differ.
  74. asset: AssetId,
  75. /// Total value of consumed postings for this asset.
  76. consumed_sum: Cent,
  77. /// Total value of created postings for this asset.
  78. created_sum: Cent,
  79. },
  80. /// Projected balance would fall below the account's floor.
  81. OverdraftExceeded {
  82. /// The account that would be overdrawn.
  83. account: AccountId,
  84. /// The asset involved.
  85. asset: AssetId,
  86. /// The minimum allowed balance.
  87. floor: Cent,
  88. /// The balance that would result from this transfer.
  89. projected: Cent,
  90. },
  91. /// Account snapshot hash does not match current state (stale read).
  92. AccountVersionMismatch {
  93. /// The account whose version was stale.
  94. account: AccountId,
  95. /// The snapshot hash the transfer expected.
  96. expected: [u8; 32],
  97. /// The actual current snapshot hash.
  98. actual: [u8; 32],
  99. },
  100. /// A negative posting targets an account whose policy forbids offset positions.
  101. NegativePostingOnNonSystemAccount {
  102. /// The account that would receive the negative posting.
  103. account: AccountId,
  104. /// The asset involved.
  105. asset: AssetId,
  106. /// The negative value.
  107. value: Cent,
  108. },
  109. /// An asset is not permitted by the transfer's book policy.
  110. BookAssetNotAllowed {
  111. /// The book whose policy rejected the asset.
  112. book: BookId,
  113. /// The disallowed asset.
  114. asset: AssetId,
  115. },
  116. /// An account is not permitted to participate by the transfer's book policy.
  117. BookAccountNotAllowed {
  118. /// The book whose policy rejected the account.
  119. book: BookId,
  120. /// The disallowed account.
  121. account: AccountId,
  122. },
  123. /// An arithmetic operation overflowed.
  124. Overflow,
  125. }
  126. impl std::fmt::Display for ValidationError {
  127. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  128. match self {
  129. Self::EmptyTransfer => write!(f, "transfer has no postings"),
  130. Self::DuplicateConsumedPosting(id) => write!(f, "duplicate consumed posting {id:?}"),
  131. Self::PostingNotFound(id) => write!(f, "posting not found: {id:?}"),
  132. Self::PostingAlreadyConsumed(id) => write!(f, "posting already consumed: {id:?}"),
  133. Self::OwnershipViolation {
  134. posting_id,
  135. expected,
  136. actual,
  137. } => {
  138. write!(
  139. f,
  140. "ownership violation on {posting_id:?}: expected {expected:?}, got {actual:?}"
  141. )
  142. }
  143. Self::AccountNotFound(id) => write!(f, "account not found: {id:?}"),
  144. Self::AccountFrozen(id) => write!(f, "account frozen: {id:?}"),
  145. Self::AccountClosed(id) => write!(f, "account closed: {id:?}"),
  146. Self::ConservationViolation {
  147. asset,
  148. consumed_sum,
  149. created_sum,
  150. } => {
  151. write!(
  152. f,
  153. "conservation violated for {asset:?}: consumed {consumed_sum}, created {created_sum}"
  154. )
  155. }
  156. Self::OverdraftExceeded {
  157. account,
  158. asset,
  159. floor,
  160. projected,
  161. } => {
  162. write!(
  163. f,
  164. "overdraft exceeded for {account:?}/{asset:?}: floor {floor}, projected {projected}"
  165. )
  166. }
  167. Self::AccountVersionMismatch {
  168. account,
  169. expected,
  170. actual,
  171. } => {
  172. write!(
  173. f,
  174. "account version mismatch for {account:?}: expected {expected:02x?}, got {actual:02x?}"
  175. )
  176. }
  177. Self::NegativePostingOnNonSystemAccount {
  178. account,
  179. asset,
  180. value,
  181. } => {
  182. write!(
  183. f,
  184. "negative posting ({value}) on account {account:?}/{asset:?} whose policy forbids offsets"
  185. )
  186. }
  187. Self::BookAssetNotAllowed { book, asset } => {
  188. write!(f, "asset {asset:?} not allowed by book {book:?}")
  189. }
  190. Self::BookAccountNotAllowed { book, account } => {
  191. write!(f, "account {account:?} not allowed by book {book:?}")
  192. }
  193. Self::Overflow => write!(f, "monetary amount overflow"),
  194. }
  195. }
  196. }
  197. impl std::error::Error for ValidationError {}
  198. impl From<OverflowError> for ValidationError {
  199. fn from(_: OverflowError) -> Self {
  200. Self::Overflow
  201. }
  202. }
  203. // ---------------------------------------------------------------------------
  204. // The pure decision function
  205. // ---------------------------------------------------------------------------
  206. /// The single entry point for all ledger invariant checks.
  207. ///
  208. /// Pure, sync, deterministic — no IO, no clock, no randomness — so the
  209. /// invariants are testable with golden vectors and replay deterministically.
  210. /// Returns a [`Plan`] only when every invariant holds; otherwise returns the
  211. /// specific [`ValidationError`] that was violated.
  212. pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError> {
  213. let envelope = input.envelope;
  214. // 1. Non-empty
  215. if envelope.consumes().is_empty() && envelope.creates().is_empty() {
  216. return Err(ValidationError::EmptyTransfer);
  217. }
  218. // 2. No duplicate consumed PostingIds
  219. {
  220. let mut seen = HashSet::with_capacity(envelope.consumes().len());
  221. for pid in envelope.consumes() {
  222. if !seen.insert(pid) {
  223. return Err(ValidationError::DuplicateConsumedPosting(*pid));
  224. }
  225. }
  226. }
  227. // Index consumed postings by id for lookup
  228. let consumed_by_id: HashMap<PostingId, &Posting> =
  229. input.consumed_postings.iter().map(|p| (p.id, p)).collect();
  230. // 3 & 4. Every consumed posting exists, is active, and we note ownership
  231. for pid in envelope.consumes() {
  232. let posting = consumed_by_id
  233. .get(pid)
  234. .ok_or(ValidationError::PostingNotFound(*pid))?;
  235. if posting.status != PostingStatus::Active
  236. && posting.status != PostingStatus::PendingInactive
  237. {
  238. return Err(ValidationError::PostingAlreadyConsumed(*pid));
  239. }
  240. }
  241. // 5. Every referenced account exists, not FROZEN, not CLOSED
  242. let mut all_account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  243. for pid in envelope.consumes() {
  244. let posting = consumed_by_id[pid];
  245. all_account_ids.push(posting.owner);
  246. }
  247. all_account_ids.sort();
  248. all_account_ids.dedup();
  249. for aid in &all_account_ids {
  250. let account = input
  251. .accounts
  252. .get(aid)
  253. .ok_or(ValidationError::AccountNotFound(*aid))?;
  254. if account.is_frozen() {
  255. return Err(ValidationError::AccountFrozen(*aid));
  256. }
  257. if account.is_closed() {
  258. return Err(ValidationError::AccountClosed(*aid));
  259. }
  260. }
  261. // 5b. Snapshot pinning: each account_snapshot must match current state.
  262. for snap in envelope.account_snapshots() {
  263. let account = input
  264. .accounts
  265. .get(&snap.account)
  266. .ok_or(ValidationError::AccountNotFound(snap.account))?;
  267. let actual = account_hash(account);
  268. if snap.snapshot_id != actual {
  269. return Err(ValidationError::AccountVersionMismatch {
  270. account: snap.account,
  271. expected: snap.snapshot_id,
  272. actual,
  273. });
  274. }
  275. }
  276. // 5c. Book policy: gate which assets and accounts may participate. Enforced
  277. // only when a book is loaded; an empty policy field means "no restriction".
  278. if let Some(book) = input.book {
  279. let policy = &book.policy;
  280. if !policy.allowed_assets.is_empty() {
  281. let mut referenced_assets: HashSet<AssetId> = HashSet::new();
  282. for pid in envelope.consumes() {
  283. referenced_assets.insert(consumed_by_id[pid].asset);
  284. }
  285. for np in envelope.creates() {
  286. referenced_assets.insert(np.asset);
  287. }
  288. for asset in &referenced_assets {
  289. if !policy.allowed_assets.contains(asset) {
  290. return Err(ValidationError::BookAssetNotAllowed {
  291. book: book.id,
  292. asset: *asset,
  293. });
  294. }
  295. }
  296. }
  297. let no_account_restriction =
  298. policy.allowed_accounts.is_empty() && policy.allowed_flags.is_empty();
  299. if !no_account_restriction {
  300. for aid in &all_account_ids {
  301. let account = &input.accounts[aid];
  302. // Book membership is scoped by base account, not subaccount.
  303. let listed = policy.allowed_accounts.contains(&aid.base());
  304. let flag_match = !policy.allowed_flags.is_empty()
  305. && account.flags.intersects(policy.allowed_flags);
  306. if !(listed || flag_match) {
  307. return Err(ValidationError::BookAccountNotAllowed {
  308. book: book.id,
  309. account: aid.base(),
  310. });
  311. }
  312. }
  313. }
  314. }
  315. // 6. Per-asset conservation: Σ consumed == Σ created
  316. let mut consumed_by_asset: HashMap<AssetId, Cent> = HashMap::new();
  317. for pid in envelope.consumes() {
  318. let posting = consumed_by_id[pid];
  319. let entry = consumed_by_asset.entry(posting.asset).or_insert(Cent::ZERO);
  320. *entry = entry.checked_add(posting.value)?;
  321. }
  322. let mut created_by_asset: HashMap<AssetId, Cent> = HashMap::new();
  323. for np in envelope.creates() {
  324. let entry = created_by_asset.entry(np.asset).or_insert(Cent::ZERO);
  325. *entry = entry.checked_add(np.value)?;
  326. }
  327. // All assets must appear in both sides (or have sum 0 on the missing side)
  328. let mut all_assets: HashSet<AssetId> = HashSet::new();
  329. all_assets.extend(consumed_by_asset.keys());
  330. all_assets.extend(created_by_asset.keys());
  331. for asset in &all_assets {
  332. let consumed_sum = consumed_by_asset.get(asset).copied().unwrap_or(Cent::ZERO);
  333. let created_sum = created_by_asset.get(asset).copied().unwrap_or(Cent::ZERO);
  334. if consumed_sum != created_sum {
  335. return Err(ValidationError::ConservationViolation {
  336. asset: *asset,
  337. consumed_sum,
  338. created_sum,
  339. });
  340. }
  341. }
  342. // 7. Negative postings (offset positions) may target system, external, or
  343. // overdraft accounts. Overdraft floors are enforced separately in step 8.
  344. // Only NoOverdraft forbids holding a negative posting.
  345. for np in envelope.creates() {
  346. if np.value.is_negative() {
  347. let account = input
  348. .accounts
  349. .get(&np.owner)
  350. .ok_or(ValidationError::AccountNotFound(np.owner))?;
  351. match account.policy {
  352. AccountPolicy::SystemAccount
  353. | AccountPolicy::ExternalAccount
  354. | AccountPolicy::UncappedOverdraft
  355. | AccountPolicy::CappedOverdraft { .. } => {}
  356. AccountPolicy::NoOverdraft => {
  357. return Err(ValidationError::NegativePostingOnNonSystemAccount {
  358. account: np.owner,
  359. asset: np.asset,
  360. value: np.value,
  361. });
  362. }
  363. }
  364. }
  365. }
  366. // 8. Policy: projected balance satisfies account's floor
  367. let mut deltas: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
  368. for pid in envelope.consumes() {
  369. let posting = consumed_by_id[pid];
  370. let entry = deltas
  371. .entry((posting.owner, posting.asset))
  372. .or_insert(Cent::ZERO);
  373. *entry = entry.checked_sub(posting.value)?;
  374. }
  375. for np in envelope.creates() {
  376. let entry = deltas.entry((np.owner, np.asset)).or_insert(Cent::ZERO);
  377. *entry = entry.checked_add(np.value)?;
  378. }
  379. for ((account_id, asset_id), delta) in &deltas {
  380. let current_balance = input
  381. .balances
  382. .get(&(*account_id, *asset_id))
  383. .copied()
  384. .unwrap_or(Cent::ZERO);
  385. let projected = current_balance.checked_add(*delta)?;
  386. let account = &input.accounts[account_id];
  387. match &account.policy {
  388. AccountPolicy::NoOverdraft => {
  389. if projected.is_negative() {
  390. return Err(ValidationError::OverdraftExceeded {
  391. account: *account_id,
  392. asset: *asset_id,
  393. floor: Cent::ZERO,
  394. projected,
  395. });
  396. }
  397. }
  398. AccountPolicy::CappedOverdraft { floor } => {
  399. if projected < *floor {
  400. return Err(ValidationError::OverdraftExceeded {
  401. account: *account_id,
  402. asset: *asset_id,
  403. floor: *floor,
  404. projected,
  405. });
  406. }
  407. }
  408. AccountPolicy::UncappedOverdraft
  409. | AccountPolicy::SystemAccount
  410. | AccountPolicy::ExternalAccount => {
  411. // No floor check
  412. }
  413. }
  414. }
  415. // 8. Build the plan
  416. let tid = envelope_id(envelope);
  417. let postings_to_deactivate: Vec<PostingId> = envelope.consumes().to_vec();
  418. let postings_to_create: Vec<Posting> = envelope
  419. .creates
  420. .iter()
  421. .enumerate()
  422. .map(|(i, np)| {
  423. Posting::new(
  424. PostingId {
  425. transfer: tid,
  426. index: i as u16,
  427. },
  428. np.owner,
  429. np.asset,
  430. np.value,
  431. )
  432. })
  433. .collect();
  434. Ok(Plan {
  435. transfer_id: tid,
  436. postings_to_deactivate,
  437. postings_to_create,
  438. })
  439. }
  440. #[cfg(test)]
  441. mod tests {
  442. use super::*;
  443. use std::collections::BTreeMap;
  444. fn make_account(id: i64, policy: AccountPolicy) -> Account {
  445. Account {
  446. id: AccountId::new(id),
  447. version: 1,
  448. policy,
  449. flags: AccountFlags::empty(),
  450. book: BookId(0),
  451. user_data: UserData::default(),
  452. metadata: BTreeMap::new(),
  453. }
  454. }
  455. fn accounts_map(accs: Vec<Account>) -> HashMap<AccountId, Account> {
  456. accs.into_iter().map(|a| (a.id, a)).collect()
  457. }
  458. // -- Deposit: external(-100) + account1(+100) --------------------------
  459. fn deposit_envelope() -> Envelope {
  460. Envelope {
  461. consumes: vec![],
  462. creates: vec![
  463. NewPosting {
  464. owner: AccountId::new(1),
  465. asset: AssetId::new(1),
  466. value: Cent::from(100),
  467. payer: None,
  468. },
  469. NewPosting {
  470. owner: AccountId::new(99),
  471. asset: AssetId::new(1),
  472. value: Cent::from(-100),
  473. payer: None,
  474. },
  475. ],
  476. book: BookId(0),
  477. user_data: UserData::default(),
  478. account_snapshots: vec![],
  479. metadata: BTreeMap::new(),
  480. }
  481. }
  482. #[test]
  483. fn valid_deposit() {
  484. let envelope = deposit_envelope();
  485. let accounts = accounts_map(vec![
  486. make_account(1, AccountPolicy::NoOverdraft),
  487. make_account(99, AccountPolicy::ExternalAccount),
  488. ]);
  489. let balances = HashMap::new();
  490. let input = PlanInput {
  491. envelope: &envelope,
  492. consumed_postings: &[],
  493. accounts: &accounts,
  494. balances: &balances,
  495. book: None,
  496. };
  497. let plan = validate_and_plan(input).unwrap();
  498. assert_eq!(plan.postings_to_create.len(), 2);
  499. assert!(plan.postings_to_deactivate.is_empty());
  500. }
  501. #[test]
  502. fn empty_transfer_rejected() {
  503. let envelope = Envelope {
  504. consumes: vec![],
  505. creates: vec![],
  506. book: BookId(0),
  507. user_data: UserData::default(),
  508. account_snapshots: vec![],
  509. metadata: BTreeMap::new(),
  510. };
  511. let accounts = HashMap::new();
  512. let balances = HashMap::new();
  513. let input = PlanInput {
  514. envelope: &envelope,
  515. consumed_postings: &[],
  516. accounts: &accounts,
  517. balances: &balances,
  518. book: None,
  519. };
  520. assert_eq!(
  521. validate_and_plan(input).unwrap_err(),
  522. ValidationError::EmptyTransfer
  523. );
  524. }
  525. #[test]
  526. fn conservation_violation() {
  527. let envelope = Envelope {
  528. consumes: vec![],
  529. creates: vec![NewPosting {
  530. owner: AccountId::new(1),
  531. asset: AssetId::new(1),
  532. value: Cent::from(100),
  533. payer: None,
  534. }],
  535. book: BookId(0),
  536. user_data: UserData::default(),
  537. account_snapshots: vec![],
  538. metadata: BTreeMap::new(),
  539. };
  540. let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
  541. let balances = HashMap::new();
  542. let input = PlanInput {
  543. envelope: &envelope,
  544. consumed_postings: &[],
  545. accounts: &accounts,
  546. balances: &balances,
  547. book: None,
  548. };
  549. match validate_and_plan(input) {
  550. Err(ValidationError::ConservationViolation { .. }) => {}
  551. other => panic!("expected ConservationViolation, got {other:?}"),
  552. }
  553. }
  554. #[test]
  555. fn posting_not_found() {
  556. let missing_pid = PostingId {
  557. transfer: EnvelopeId([0; 32]),
  558. index: 0,
  559. };
  560. let envelope = Envelope {
  561. consumes: vec![missing_pid],
  562. creates: vec![],
  563. book: BookId(0),
  564. user_data: UserData::default(),
  565. account_snapshots: vec![],
  566. metadata: BTreeMap::new(),
  567. };
  568. let accounts = HashMap::new();
  569. let balances = HashMap::new();
  570. let input = PlanInput {
  571. envelope: &envelope,
  572. consumed_postings: &[],
  573. accounts: &accounts,
  574. balances: &balances,
  575. book: None,
  576. };
  577. assert_eq!(
  578. validate_and_plan(input).unwrap_err(),
  579. ValidationError::PostingNotFound(missing_pid)
  580. );
  581. }
  582. #[test]
  583. fn double_spend_rejected() {
  584. let pid = PostingId {
  585. transfer: EnvelopeId([1; 32]),
  586. index: 0,
  587. };
  588. let posting = Posting {
  589. id: pid,
  590. owner: AccountId::new(1),
  591. asset: AssetId::new(1),
  592. value: Cent::from(100),
  593. status: PostingStatus::Inactive, // already consumed
  594. reservation: None,
  595. };
  596. let envelope = Envelope {
  597. consumes: vec![pid],
  598. creates: vec![NewPosting {
  599. owner: AccountId::new(2),
  600. asset: AssetId::new(1),
  601. value: Cent::from(100),
  602. payer: None,
  603. }],
  604. book: BookId(0),
  605. user_data: UserData::default(),
  606. account_snapshots: vec![],
  607. metadata: BTreeMap::new(),
  608. };
  609. let accounts = accounts_map(vec![
  610. make_account(1, AccountPolicy::NoOverdraft),
  611. make_account(2, AccountPolicy::NoOverdraft),
  612. ]);
  613. let balances = HashMap::new();
  614. let input = PlanInput {
  615. envelope: &envelope,
  616. consumed_postings: &[posting],
  617. accounts: &accounts,
  618. balances: &balances,
  619. book: None,
  620. };
  621. assert_eq!(
  622. validate_and_plan(input).unwrap_err(),
  623. ValidationError::PostingAlreadyConsumed(pid)
  624. );
  625. }
  626. #[test]
  627. fn account_frozen_rejected() {
  628. let envelope = deposit_envelope();
  629. let mut acc = make_account(1, AccountPolicy::NoOverdraft);
  630. acc.flags = AccountFlags::FROZEN;
  631. let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
  632. let balances = HashMap::new();
  633. let input = PlanInput {
  634. envelope: &envelope,
  635. consumed_postings: &[],
  636. accounts: &accounts,
  637. balances: &balances,
  638. book: None,
  639. };
  640. assert_eq!(
  641. validate_and_plan(input).unwrap_err(),
  642. ValidationError::AccountFrozen(AccountId::new(1))
  643. );
  644. }
  645. #[test]
  646. fn account_closed_rejected() {
  647. let envelope = deposit_envelope();
  648. let mut acc = make_account(1, AccountPolicy::NoOverdraft);
  649. acc.flags = AccountFlags::CLOSED;
  650. let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
  651. let balances = HashMap::new();
  652. let input = PlanInput {
  653. envelope: &envelope,
  654. consumed_postings: &[],
  655. accounts: &accounts,
  656. balances: &balances,
  657. book: None,
  658. };
  659. assert_eq!(
  660. validate_and_plan(input).unwrap_err(),
  661. ValidationError::AccountClosed(AccountId::new(1))
  662. );
  663. }
  664. #[test]
  665. fn no_overdraft_exceeded() {
  666. let pid = PostingId {
  667. transfer: EnvelopeId([1; 32]),
  668. index: 0,
  669. };
  670. let posting = Posting {
  671. id: pid,
  672. owner: AccountId::new(1),
  673. asset: AssetId::new(1),
  674. value: Cent::from(50),
  675. status: PostingStatus::Active,
  676. reservation: None,
  677. };
  678. // Try to send 50 but create 100 for recipient (conservation will fail first,
  679. // but let's test overdraft with a valid conservation)
  680. let envelope = Envelope {
  681. consumes: vec![pid],
  682. creates: vec![NewPosting {
  683. owner: AccountId::new(2),
  684. asset: AssetId::new(1),
  685. value: Cent::from(50),
  686. payer: None,
  687. }],
  688. book: BookId(0),
  689. user_data: UserData::default(),
  690. account_snapshots: vec![],
  691. metadata: BTreeMap::new(),
  692. };
  693. let accounts = accounts_map(vec![
  694. make_account(1, AccountPolicy::NoOverdraft),
  695. make_account(2, AccountPolicy::NoOverdraft),
  696. ]);
  697. // account1 has balance 50, consuming 50 leaves 0, that's fine.
  698. // Let's test when balance is insufficient: balance=30, consuming 50-value posting
  699. let mut balances = HashMap::new();
  700. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
  701. // projected = 30 - 50 = -20 < 0 → overdraft
  702. let input = PlanInput {
  703. envelope: &envelope,
  704. consumed_postings: &[posting],
  705. accounts: &accounts,
  706. balances: &balances,
  707. book: None,
  708. };
  709. match validate_and_plan(input) {
  710. Err(ValidationError::OverdraftExceeded { account, .. }) => {
  711. assert_eq!(account, AccountId::new(1));
  712. }
  713. other => panic!("expected OverdraftExceeded, got {other:?}"),
  714. }
  715. }
  716. #[test]
  717. fn capped_overdraft_within_limit() {
  718. let pid = PostingId {
  719. transfer: EnvelopeId([1; 32]),
  720. index: 0,
  721. };
  722. let posting = Posting {
  723. id: pid,
  724. owner: AccountId::new(1),
  725. asset: AssetId::new(1),
  726. value: Cent::from(100),
  727. status: PostingStatus::Active,
  728. reservation: None,
  729. };
  730. let envelope = Envelope {
  731. consumes: vec![pid],
  732. creates: vec![NewPosting {
  733. owner: AccountId::new(2),
  734. asset: AssetId::new(1),
  735. value: Cent::from(100),
  736. payer: None,
  737. }],
  738. book: BookId(0),
  739. user_data: UserData::default(),
  740. account_snapshots: vec![],
  741. metadata: BTreeMap::new(),
  742. };
  743. let accounts = accounts_map(vec![
  744. make_account(
  745. 1,
  746. AccountPolicy::CappedOverdraft {
  747. floor: Cent::from(-50),
  748. },
  749. ),
  750. make_account(2, AccountPolicy::NoOverdraft),
  751. ]);
  752. // balance=80, consuming 100 → projected = 80 - 100 = -20 >= -50 → OK
  753. let mut balances = HashMap::new();
  754. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(80));
  755. let input = PlanInput {
  756. envelope: &envelope,
  757. consumed_postings: &[posting],
  758. accounts: &accounts,
  759. balances: &balances,
  760. book: None,
  761. };
  762. // A CappedOverdraft spend within the floor validates and produces a plan.
  763. let plan = validate_and_plan(input).unwrap();
  764. assert!(!plan.postings_to_create.is_empty());
  765. }
  766. #[test]
  767. fn capped_overdraft_exceeded() {
  768. let pid = PostingId {
  769. transfer: EnvelopeId([1; 32]),
  770. index: 0,
  771. };
  772. let posting = Posting {
  773. id: pid,
  774. owner: AccountId::new(1),
  775. asset: AssetId::new(1),
  776. value: Cent::from(100),
  777. status: PostingStatus::Active,
  778. reservation: None,
  779. };
  780. let envelope = Envelope {
  781. consumes: vec![pid],
  782. creates: vec![NewPosting {
  783. owner: AccountId::new(2),
  784. asset: AssetId::new(1),
  785. value: Cent::from(100),
  786. payer: None,
  787. }],
  788. book: BookId(0),
  789. user_data: UserData::default(),
  790. account_snapshots: vec![],
  791. metadata: BTreeMap::new(),
  792. };
  793. let accounts = accounts_map(vec![
  794. make_account(
  795. 1,
  796. AccountPolicy::CappedOverdraft {
  797. floor: Cent::from(-50),
  798. },
  799. ),
  800. make_account(2, AccountPolicy::NoOverdraft),
  801. ]);
  802. // balance=30, consuming 100 → projected = 30 - 100 = -70 < -50 → FAIL
  803. let mut balances = HashMap::new();
  804. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
  805. let input = PlanInput {
  806. envelope: &envelope,
  807. consumed_postings: &[posting],
  808. accounts: &accounts,
  809. balances: &balances,
  810. book: None,
  811. };
  812. match validate_and_plan(input) {
  813. Err(ValidationError::OverdraftExceeded {
  814. floor, projected, ..
  815. }) => {
  816. assert_eq!(floor, Cent::from(-50));
  817. assert_eq!(projected, Cent::from(-70));
  818. }
  819. other => panic!("expected OverdraftExceeded, got {other:?}"),
  820. }
  821. }
  822. #[test]
  823. fn uncapped_overdraft_allows_negative() {
  824. let pid = PostingId {
  825. transfer: EnvelopeId([1; 32]),
  826. index: 0,
  827. };
  828. let posting = Posting {
  829. id: pid,
  830. owner: AccountId::new(1),
  831. asset: AssetId::new(1),
  832. value: Cent::from(100),
  833. status: PostingStatus::Active,
  834. reservation: None,
  835. };
  836. let envelope = Envelope {
  837. consumes: vec![pid],
  838. creates: vec![NewPosting {
  839. owner: AccountId::new(2),
  840. asset: AssetId::new(1),
  841. value: Cent::from(100),
  842. payer: None,
  843. }],
  844. book: BookId(0),
  845. user_data: UserData::default(),
  846. account_snapshots: vec![],
  847. metadata: BTreeMap::new(),
  848. };
  849. let accounts = accounts_map(vec![
  850. make_account(1, AccountPolicy::UncappedOverdraft),
  851. make_account(2, AccountPolicy::NoOverdraft),
  852. ]);
  853. // balance=10, consuming 100 → projected = 10 - 100 = -90 → allowed
  854. let mut balances = HashMap::new();
  855. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(10));
  856. let input = PlanInput {
  857. envelope: &envelope,
  858. consumed_postings: &[posting],
  859. accounts: &accounts,
  860. balances: &balances,
  861. book: None,
  862. };
  863. // UncappedOverdraft permits the negative projection; the plan validates.
  864. let plan = validate_and_plan(input).unwrap();
  865. assert!(!plan.postings_to_create.is_empty());
  866. }
  867. #[test]
  868. fn duplicate_consumed_posting_rejected() {
  869. let pid = PostingId {
  870. transfer: EnvelopeId([1; 32]),
  871. index: 0,
  872. };
  873. let envelope = Envelope {
  874. consumes: vec![pid, pid], // duplicate
  875. creates: vec![],
  876. book: BookId(0),
  877. user_data: UserData::default(),
  878. account_snapshots: vec![],
  879. metadata: BTreeMap::new(),
  880. };
  881. let accounts = HashMap::new();
  882. let balances = HashMap::new();
  883. let input = PlanInput {
  884. envelope: &envelope,
  885. consumed_postings: &[],
  886. accounts: &accounts,
  887. balances: &balances,
  888. book: None,
  889. };
  890. assert_eq!(
  891. validate_and_plan(input).unwrap_err(),
  892. ValidationError::DuplicateConsumedPosting(pid)
  893. );
  894. }
  895. #[test]
  896. fn internal_transfer_with_change() {
  897. // account1 has a 100 posting, sends 60 to account2, gets 40 change
  898. let pid = PostingId {
  899. transfer: EnvelopeId([1; 32]),
  900. index: 0,
  901. };
  902. let posting = Posting {
  903. id: pid,
  904. owner: AccountId::new(1),
  905. asset: AssetId::new(1),
  906. value: Cent::from(100),
  907. status: PostingStatus::Active,
  908. reservation: None,
  909. };
  910. let envelope = Envelope {
  911. consumes: vec![pid],
  912. creates: vec![
  913. NewPosting {
  914. owner: AccountId::new(2),
  915. asset: AssetId::new(1),
  916. value: Cent::from(60),
  917. payer: Some(AccountId::new(1)),
  918. },
  919. NewPosting {
  920. owner: AccountId::new(1),
  921. asset: AssetId::new(1),
  922. value: Cent::from(40),
  923. payer: None,
  924. },
  925. ],
  926. book: BookId(0),
  927. user_data: UserData::default(),
  928. account_snapshots: vec![],
  929. metadata: BTreeMap::new(),
  930. };
  931. let accounts = accounts_map(vec![
  932. make_account(1, AccountPolicy::NoOverdraft),
  933. make_account(2, AccountPolicy::NoOverdraft),
  934. ]);
  935. let mut balances = HashMap::new();
  936. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
  937. let input = PlanInput {
  938. envelope: &envelope,
  939. consumed_postings: &[posting],
  940. accounts: &accounts,
  941. balances: &balances,
  942. book: None,
  943. };
  944. let plan = validate_and_plan(input).unwrap();
  945. assert_eq!(plan.postings_to_deactivate.len(), 1);
  946. assert_eq!(plan.postings_to_create.len(), 2);
  947. // account1 projected: 100 - 100 + 40 = 40 >= 0 ✓
  948. // account2 projected: 0 + 60 = 60 >= 0 ✓
  949. }
  950. #[test]
  951. fn account_not_found() {
  952. let envelope = Envelope {
  953. consumes: vec![],
  954. creates: vec![
  955. NewPosting {
  956. owner: AccountId::new(999),
  957. asset: AssetId::new(1),
  958. value: Cent::from(100),
  959. payer: None,
  960. },
  961. NewPosting {
  962. owner: AccountId::new(99),
  963. asset: AssetId::new(1),
  964. value: Cent::from(-100),
  965. payer: None,
  966. },
  967. ],
  968. book: BookId(0),
  969. user_data: UserData::default(),
  970. account_snapshots: vec![],
  971. metadata: BTreeMap::new(),
  972. };
  973. // Only external account exists, account 999 doesn't
  974. let accounts = accounts_map(vec![make_account(99, AccountPolicy::ExternalAccount)]);
  975. let balances = HashMap::new();
  976. let input = PlanInput {
  977. envelope: &envelope,
  978. consumed_postings: &[],
  979. accounts: &accounts,
  980. balances: &balances,
  981. book: None,
  982. };
  983. assert_eq!(
  984. validate_and_plan(input).unwrap_err(),
  985. ValidationError::AccountNotFound(AccountId::new(999))
  986. );
  987. }
  988. #[test]
  989. fn negative_posting_rejected_on_regular_account() {
  990. let envelope = Envelope {
  991. consumes: vec![],
  992. creates: vec![
  993. NewPosting {
  994. owner: AccountId::new(1),
  995. asset: AssetId::new(1),
  996. value: Cent::from(-100),
  997. payer: None,
  998. },
  999. NewPosting {
  1000. owner: AccountId::new(1),
  1001. asset: AssetId::new(1),
  1002. value: Cent::from(100),
  1003. payer: None,
  1004. },
  1005. ],
  1006. book: BookId(0),
  1007. user_data: UserData::default(),
  1008. account_snapshots: vec![],
  1009. metadata: BTreeMap::new(),
  1010. };
  1011. let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
  1012. let balances = HashMap::new();
  1013. let input = PlanInput {
  1014. envelope: &envelope,
  1015. consumed_postings: &[],
  1016. accounts: &accounts,
  1017. balances: &balances,
  1018. book: None,
  1019. };
  1020. assert_eq!(
  1021. validate_and_plan(input).unwrap_err(),
  1022. ValidationError::NegativePostingOnNonSystemAccount {
  1023. account: AccountId::new(1),
  1024. asset: AssetId::new(1),
  1025. value: Cent::from(-100),
  1026. }
  1027. );
  1028. }
  1029. #[test]
  1030. fn negative_posting_allowed_on_system_account() {
  1031. let envelope = deposit_envelope();
  1032. let accounts = accounts_map(vec![
  1033. make_account(1, AccountPolicy::NoOverdraft),
  1034. make_account(99, AccountPolicy::SystemAccount),
  1035. ]);
  1036. let balances = HashMap::new();
  1037. let input = PlanInput {
  1038. envelope: &envelope,
  1039. consumed_postings: &[],
  1040. accounts: &accounts,
  1041. balances: &balances,
  1042. book: None,
  1043. };
  1044. let plan = validate_and_plan(input).unwrap();
  1045. assert_eq!(plan.postings_to_create.len(), 2);
  1046. }
  1047. }