validate.rs 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216
  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 referenced by the transfer.
  22. pub accounts: &'a HashMap<AccountId, Account>,
  23. /// Current balances keyed by (account, 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: a book that lists a
  303. // base account admits all of that account's subaccounts.
  304. let listed = policy.allowed_accounts.contains(&aid.base());
  305. let flag_match = !policy.allowed_flags.is_empty()
  306. && account.flags.intersects(policy.allowed_flags);
  307. if !(listed || flag_match) {
  308. return Err(ValidationError::BookAccountNotAllowed {
  309. book: book.id,
  310. account: *aid,
  311. });
  312. }
  313. }
  314. }
  315. }
  316. // 6. Per-asset conservation: Σ consumed == Σ created
  317. let mut consumed_by_asset: HashMap<AssetId, Cent> = HashMap::new();
  318. for pid in envelope.consumes() {
  319. let posting = consumed_by_id[pid];
  320. let entry = consumed_by_asset.entry(posting.asset).or_insert(Cent::ZERO);
  321. *entry = entry.checked_add(posting.value)?;
  322. }
  323. let mut created_by_asset: HashMap<AssetId, Cent> = HashMap::new();
  324. for np in envelope.creates() {
  325. let entry = created_by_asset.entry(np.asset).or_insert(Cent::ZERO);
  326. *entry = entry.checked_add(np.value)?;
  327. }
  328. // All assets must appear in both sides (or have sum 0 on the missing side)
  329. let mut all_assets: HashSet<AssetId> = HashSet::new();
  330. all_assets.extend(consumed_by_asset.keys());
  331. all_assets.extend(created_by_asset.keys());
  332. for asset in &all_assets {
  333. let consumed_sum = consumed_by_asset.get(asset).copied().unwrap_or(Cent::ZERO);
  334. let created_sum = created_by_asset.get(asset).copied().unwrap_or(Cent::ZERO);
  335. if consumed_sum != created_sum {
  336. return Err(ValidationError::ConservationViolation {
  337. asset: *asset,
  338. consumed_sum,
  339. created_sum,
  340. });
  341. }
  342. }
  343. // 7. Negative postings (offset positions) may target system, external, or
  344. // overdraft accounts. Overdraft floors are enforced separately in step 8.
  345. // Only NoOverdraft forbids holding a negative posting.
  346. for np in envelope.creates() {
  347. if np.value.is_negative() {
  348. let account = input
  349. .accounts
  350. .get(&np.owner)
  351. .ok_or(ValidationError::AccountNotFound(np.owner))?;
  352. match account.policy {
  353. AccountPolicy::SystemAccount
  354. | AccountPolicy::ExternalAccount
  355. | AccountPolicy::UncappedOverdraft
  356. | AccountPolicy::CappedOverdraft { .. } => {}
  357. AccountPolicy::NoOverdraft => {
  358. return Err(ValidationError::NegativePostingOnNonSystemAccount {
  359. account: np.owner,
  360. asset: np.asset,
  361. value: np.value,
  362. });
  363. }
  364. }
  365. }
  366. }
  367. // 8. Policy: projected balance satisfies account's floor
  368. let mut deltas: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
  369. for pid in envelope.consumes() {
  370. let posting = consumed_by_id[pid];
  371. let entry = deltas
  372. .entry((posting.owner, posting.asset))
  373. .or_insert(Cent::ZERO);
  374. *entry = entry.checked_sub(posting.value)?;
  375. }
  376. for np in envelope.creates() {
  377. let entry = deltas.entry((np.owner, np.asset)).or_insert(Cent::ZERO);
  378. *entry = entry.checked_add(np.value)?;
  379. }
  380. for ((account_id, asset_id), delta) in &deltas {
  381. let current_balance = input
  382. .balances
  383. .get(&(*account_id, *asset_id))
  384. .copied()
  385. .unwrap_or(Cent::ZERO);
  386. let projected = current_balance.checked_add(*delta)?;
  387. let account = &input.accounts[account_id];
  388. match &account.policy {
  389. AccountPolicy::NoOverdraft => {
  390. if projected.is_negative() {
  391. return Err(ValidationError::OverdraftExceeded {
  392. account: *account_id,
  393. asset: *asset_id,
  394. floor: Cent::ZERO,
  395. projected,
  396. });
  397. }
  398. }
  399. AccountPolicy::CappedOverdraft { floor } => {
  400. if projected < *floor {
  401. return Err(ValidationError::OverdraftExceeded {
  402. account: *account_id,
  403. asset: *asset_id,
  404. floor: *floor,
  405. projected,
  406. });
  407. }
  408. }
  409. AccountPolicy::UncappedOverdraft
  410. | AccountPolicy::SystemAccount
  411. | AccountPolicy::ExternalAccount => {
  412. // No floor check
  413. }
  414. }
  415. }
  416. // 8. Build the plan
  417. let tid = envelope_id(envelope);
  418. let postings_to_deactivate: Vec<PostingId> = envelope.consumes().to_vec();
  419. let postings_to_create: Vec<Posting> = envelope
  420. .creates
  421. .iter()
  422. .enumerate()
  423. .map(|(i, np)| {
  424. Posting::new(
  425. PostingId {
  426. transfer: tid,
  427. index: i as u16,
  428. },
  429. np.owner,
  430. np.asset,
  431. np.value,
  432. )
  433. })
  434. .collect();
  435. Ok(Plan {
  436. transfer_id: tid,
  437. postings_to_deactivate,
  438. postings_to_create,
  439. })
  440. }
  441. #[cfg(test)]
  442. mod tests {
  443. use super::*;
  444. use std::collections::BTreeMap;
  445. fn make_account(id: i64, policy: AccountPolicy) -> Account {
  446. Account {
  447. id: AccountId::new(id),
  448. version: 1,
  449. policy,
  450. flags: AccountFlags::empty(),
  451. book: BookId(0),
  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. account_snapshots: vec![],
  478. metadata: BTreeMap::new(),
  479. }
  480. }
  481. #[test]
  482. fn valid_deposit() {
  483. let envelope = deposit_envelope();
  484. let accounts = accounts_map(vec![
  485. make_account(1, AccountPolicy::NoOverdraft),
  486. make_account(99, AccountPolicy::ExternalAccount),
  487. ]);
  488. let balances = HashMap::new();
  489. let input = PlanInput {
  490. envelope: &envelope,
  491. consumed_postings: &[],
  492. accounts: &accounts,
  493. balances: &balances,
  494. book: None,
  495. };
  496. let plan = validate_and_plan(input).unwrap();
  497. assert_eq!(plan.postings_to_create.len(), 2);
  498. assert!(plan.postings_to_deactivate.is_empty());
  499. }
  500. #[test]
  501. fn empty_transfer_rejected() {
  502. let envelope = Envelope {
  503. consumes: vec![],
  504. creates: vec![],
  505. book: BookId(0),
  506. account_snapshots: vec![],
  507. metadata: BTreeMap::new(),
  508. };
  509. let accounts = HashMap::new();
  510. let balances = HashMap::new();
  511. let input = PlanInput {
  512. envelope: &envelope,
  513. consumed_postings: &[],
  514. accounts: &accounts,
  515. balances: &balances,
  516. book: None,
  517. };
  518. assert_eq!(
  519. validate_and_plan(input).unwrap_err(),
  520. ValidationError::EmptyTransfer
  521. );
  522. }
  523. #[test]
  524. fn conservation_violation() {
  525. let envelope = Envelope {
  526. consumes: vec![],
  527. creates: vec![NewPosting {
  528. owner: AccountId::new(1),
  529. asset: AssetId::new(1),
  530. value: Cent::from(100),
  531. payer: None,
  532. }],
  533. book: BookId(0),
  534. account_snapshots: vec![],
  535. metadata: BTreeMap::new(),
  536. };
  537. let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
  538. let balances = HashMap::new();
  539. let input = PlanInput {
  540. envelope: &envelope,
  541. consumed_postings: &[],
  542. accounts: &accounts,
  543. balances: &balances,
  544. book: None,
  545. };
  546. match validate_and_plan(input) {
  547. Err(ValidationError::ConservationViolation { .. }) => {}
  548. other => panic!("expected ConservationViolation, got {other:?}"),
  549. }
  550. }
  551. #[test]
  552. fn posting_not_found() {
  553. let missing_pid = PostingId {
  554. transfer: EnvelopeId([0; 32]),
  555. index: 0,
  556. };
  557. let envelope = Envelope {
  558. consumes: vec![missing_pid],
  559. creates: vec![],
  560. book: BookId(0),
  561. account_snapshots: vec![],
  562. metadata: BTreeMap::new(),
  563. };
  564. let accounts = HashMap::new();
  565. let balances = HashMap::new();
  566. let input = PlanInput {
  567. envelope: &envelope,
  568. consumed_postings: &[],
  569. accounts: &accounts,
  570. balances: &balances,
  571. book: None,
  572. };
  573. assert_eq!(
  574. validate_and_plan(input).unwrap_err(),
  575. ValidationError::PostingNotFound(missing_pid)
  576. );
  577. }
  578. #[test]
  579. fn double_spend_rejected() {
  580. let pid = PostingId {
  581. transfer: EnvelopeId([1; 32]),
  582. index: 0,
  583. };
  584. let posting = Posting {
  585. id: pid,
  586. owner: AccountId::new(1),
  587. asset: AssetId::new(1),
  588. value: Cent::from(100),
  589. status: PostingStatus::Inactive, // already consumed
  590. reservation: None,
  591. };
  592. let envelope = Envelope {
  593. consumes: vec![pid],
  594. creates: vec![NewPosting {
  595. owner: AccountId::new(2),
  596. asset: AssetId::new(1),
  597. value: Cent::from(100),
  598. payer: None,
  599. }],
  600. book: BookId(0),
  601. account_snapshots: vec![],
  602. metadata: BTreeMap::new(),
  603. };
  604. let accounts = accounts_map(vec![
  605. make_account(1, AccountPolicy::NoOverdraft),
  606. make_account(2, AccountPolicy::NoOverdraft),
  607. ]);
  608. let balances = HashMap::new();
  609. let input = PlanInput {
  610. envelope: &envelope,
  611. consumed_postings: &[posting],
  612. accounts: &accounts,
  613. balances: &balances,
  614. book: None,
  615. };
  616. assert_eq!(
  617. validate_and_plan(input).unwrap_err(),
  618. ValidationError::PostingAlreadyConsumed(pid)
  619. );
  620. }
  621. #[test]
  622. fn account_frozen_rejected() {
  623. let envelope = deposit_envelope();
  624. let mut acc = make_account(1, AccountPolicy::NoOverdraft);
  625. acc.flags = AccountFlags::FROZEN;
  626. let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
  627. let balances = HashMap::new();
  628. let input = PlanInput {
  629. envelope: &envelope,
  630. consumed_postings: &[],
  631. accounts: &accounts,
  632. balances: &balances,
  633. book: None,
  634. };
  635. assert_eq!(
  636. validate_and_plan(input).unwrap_err(),
  637. ValidationError::AccountFrozen(AccountId::new(1))
  638. );
  639. }
  640. #[test]
  641. fn account_closed_rejected() {
  642. let envelope = deposit_envelope();
  643. let mut acc = make_account(1, AccountPolicy::NoOverdraft);
  644. acc.flags = AccountFlags::CLOSED;
  645. let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
  646. let balances = HashMap::new();
  647. let input = PlanInput {
  648. envelope: &envelope,
  649. consumed_postings: &[],
  650. accounts: &accounts,
  651. balances: &balances,
  652. book: None,
  653. };
  654. assert_eq!(
  655. validate_and_plan(input).unwrap_err(),
  656. ValidationError::AccountClosed(AccountId::new(1))
  657. );
  658. }
  659. #[test]
  660. fn no_overdraft_exceeded() {
  661. let pid = PostingId {
  662. transfer: EnvelopeId([1; 32]),
  663. index: 0,
  664. };
  665. let posting = Posting {
  666. id: pid,
  667. owner: AccountId::new(1),
  668. asset: AssetId::new(1),
  669. value: Cent::from(50),
  670. status: PostingStatus::Active,
  671. reservation: None,
  672. };
  673. // Try to send 50 but create 100 for recipient (conservation will fail first,
  674. // but let's test overdraft with a valid conservation)
  675. let envelope = Envelope {
  676. consumes: vec![pid],
  677. creates: vec![NewPosting {
  678. owner: AccountId::new(2),
  679. asset: AssetId::new(1),
  680. value: Cent::from(50),
  681. payer: None,
  682. }],
  683. book: BookId(0),
  684. account_snapshots: vec![],
  685. metadata: BTreeMap::new(),
  686. };
  687. let accounts = accounts_map(vec![
  688. make_account(1, AccountPolicy::NoOverdraft),
  689. make_account(2, AccountPolicy::NoOverdraft),
  690. ]);
  691. // account1 has balance 50, consuming 50 leaves 0, that's fine.
  692. // Let's test when balance is insufficient: balance=30, consuming 50-value posting
  693. let mut balances = HashMap::new();
  694. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
  695. // projected = 30 - 50 = -20 < 0 → overdraft
  696. let input = PlanInput {
  697. envelope: &envelope,
  698. consumed_postings: &[posting],
  699. accounts: &accounts,
  700. balances: &balances,
  701. book: None,
  702. };
  703. match validate_and_plan(input) {
  704. Err(ValidationError::OverdraftExceeded { account, .. }) => {
  705. assert_eq!(account, AccountId::new(1));
  706. }
  707. other => panic!("expected OverdraftExceeded, got {other:?}"),
  708. }
  709. }
  710. #[test]
  711. fn capped_overdraft_within_limit() {
  712. let pid = PostingId {
  713. transfer: EnvelopeId([1; 32]),
  714. index: 0,
  715. };
  716. let posting = Posting {
  717. id: pid,
  718. owner: AccountId::new(1),
  719. asset: AssetId::new(1),
  720. value: Cent::from(100),
  721. status: PostingStatus::Active,
  722. reservation: None,
  723. };
  724. let envelope = Envelope {
  725. consumes: vec![pid],
  726. creates: vec![NewPosting {
  727. owner: AccountId::new(2),
  728. asset: AssetId::new(1),
  729. value: Cent::from(100),
  730. payer: None,
  731. }],
  732. book: BookId(0),
  733. account_snapshots: vec![],
  734. metadata: BTreeMap::new(),
  735. };
  736. let accounts = accounts_map(vec![
  737. make_account(
  738. 1,
  739. AccountPolicy::CappedOverdraft {
  740. floor: Cent::from(-50),
  741. },
  742. ),
  743. make_account(2, AccountPolicy::NoOverdraft),
  744. ]);
  745. // balance=80, consuming 100 → projected = 80 - 100 = -20 >= -50 → OK
  746. let mut balances = HashMap::new();
  747. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(80));
  748. let input = PlanInput {
  749. envelope: &envelope,
  750. consumed_postings: &[posting],
  751. accounts: &accounts,
  752. balances: &balances,
  753. book: None,
  754. };
  755. // A CappedOverdraft spend within the floor validates and produces a plan.
  756. let plan = validate_and_plan(input).unwrap();
  757. assert!(!plan.postings_to_create.is_empty());
  758. }
  759. #[test]
  760. fn capped_overdraft_exceeded() {
  761. let pid = PostingId {
  762. transfer: EnvelopeId([1; 32]),
  763. index: 0,
  764. };
  765. let posting = Posting {
  766. id: pid,
  767. owner: AccountId::new(1),
  768. asset: AssetId::new(1),
  769. value: Cent::from(100),
  770. status: PostingStatus::Active,
  771. reservation: None,
  772. };
  773. let envelope = Envelope {
  774. consumes: vec![pid],
  775. creates: vec![NewPosting {
  776. owner: AccountId::new(2),
  777. asset: AssetId::new(1),
  778. value: Cent::from(100),
  779. payer: None,
  780. }],
  781. book: BookId(0),
  782. account_snapshots: vec![],
  783. metadata: BTreeMap::new(),
  784. };
  785. let accounts = accounts_map(vec![
  786. make_account(
  787. 1,
  788. AccountPolicy::CappedOverdraft {
  789. floor: Cent::from(-50),
  790. },
  791. ),
  792. make_account(2, AccountPolicy::NoOverdraft),
  793. ]);
  794. // balance=30, consuming 100 → projected = 30 - 100 = -70 < -50 → FAIL
  795. let mut balances = HashMap::new();
  796. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
  797. let input = PlanInput {
  798. envelope: &envelope,
  799. consumed_postings: &[posting],
  800. accounts: &accounts,
  801. balances: &balances,
  802. book: None,
  803. };
  804. match validate_and_plan(input) {
  805. Err(ValidationError::OverdraftExceeded {
  806. floor, projected, ..
  807. }) => {
  808. assert_eq!(floor, Cent::from(-50));
  809. assert_eq!(projected, Cent::from(-70));
  810. }
  811. other => panic!("expected OverdraftExceeded, got {other:?}"),
  812. }
  813. }
  814. #[test]
  815. fn uncapped_overdraft_allows_negative() {
  816. let pid = PostingId {
  817. transfer: EnvelopeId([1; 32]),
  818. index: 0,
  819. };
  820. let posting = Posting {
  821. id: pid,
  822. owner: AccountId::new(1),
  823. asset: AssetId::new(1),
  824. value: Cent::from(100),
  825. status: PostingStatus::Active,
  826. reservation: None,
  827. };
  828. let envelope = Envelope {
  829. consumes: vec![pid],
  830. creates: vec![NewPosting {
  831. owner: AccountId::new(2),
  832. asset: AssetId::new(1),
  833. value: Cent::from(100),
  834. payer: None,
  835. }],
  836. book: BookId(0),
  837. account_snapshots: vec![],
  838. metadata: BTreeMap::new(),
  839. };
  840. let accounts = accounts_map(vec![
  841. make_account(1, AccountPolicy::UncappedOverdraft),
  842. make_account(2, AccountPolicy::NoOverdraft),
  843. ]);
  844. // balance=10, consuming 100 → projected = 10 - 100 = -90 → allowed
  845. let mut balances = HashMap::new();
  846. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(10));
  847. let input = PlanInput {
  848. envelope: &envelope,
  849. consumed_postings: &[posting],
  850. accounts: &accounts,
  851. balances: &balances,
  852. book: None,
  853. };
  854. // UncappedOverdraft permits the negative projection; the plan validates.
  855. let plan = validate_and_plan(input).unwrap();
  856. assert!(!plan.postings_to_create.is_empty());
  857. }
  858. #[test]
  859. fn duplicate_consumed_posting_rejected() {
  860. let pid = PostingId {
  861. transfer: EnvelopeId([1; 32]),
  862. index: 0,
  863. };
  864. let envelope = Envelope {
  865. consumes: vec![pid, pid], // duplicate
  866. creates: vec![],
  867. book: BookId(0),
  868. account_snapshots: vec![],
  869. metadata: BTreeMap::new(),
  870. };
  871. let accounts = HashMap::new();
  872. let balances = HashMap::new();
  873. let input = PlanInput {
  874. envelope: &envelope,
  875. consumed_postings: &[],
  876. accounts: &accounts,
  877. balances: &balances,
  878. book: None,
  879. };
  880. assert_eq!(
  881. validate_and_plan(input).unwrap_err(),
  882. ValidationError::DuplicateConsumedPosting(pid)
  883. );
  884. }
  885. #[test]
  886. fn internal_transfer_with_change() {
  887. // account1 has a 100 posting, sends 60 to account2, gets 40 change
  888. let pid = PostingId {
  889. transfer: EnvelopeId([1; 32]),
  890. index: 0,
  891. };
  892. let posting = Posting {
  893. id: pid,
  894. owner: AccountId::new(1),
  895. asset: AssetId::new(1),
  896. value: Cent::from(100),
  897. status: PostingStatus::Active,
  898. reservation: None,
  899. };
  900. let envelope = Envelope {
  901. consumes: vec![pid],
  902. creates: vec![
  903. NewPosting {
  904. owner: AccountId::new(2),
  905. asset: AssetId::new(1),
  906. value: Cent::from(60),
  907. payer: Some(AccountId::new(1)),
  908. },
  909. NewPosting {
  910. owner: AccountId::new(1),
  911. asset: AssetId::new(1),
  912. value: Cent::from(40),
  913. payer: None,
  914. },
  915. ],
  916. book: BookId(0),
  917. account_snapshots: vec![],
  918. metadata: BTreeMap::new(),
  919. };
  920. let accounts = accounts_map(vec![
  921. make_account(1, AccountPolicy::NoOverdraft),
  922. make_account(2, AccountPolicy::NoOverdraft),
  923. ]);
  924. let mut balances = HashMap::new();
  925. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
  926. let input = PlanInput {
  927. envelope: &envelope,
  928. consumed_postings: &[posting],
  929. accounts: &accounts,
  930. balances: &balances,
  931. book: None,
  932. };
  933. let plan = validate_and_plan(input).unwrap();
  934. assert_eq!(plan.postings_to_deactivate.len(), 1);
  935. assert_eq!(plan.postings_to_create.len(), 2);
  936. // account1 projected: 100 - 100 + 40 = 40 >= 0 ✓
  937. // account2 projected: 0 + 60 = 60 >= 0 ✓
  938. }
  939. fn make_subaccount(id: i64, sub: i64, policy: AccountPolicy) -> Account {
  940. Account {
  941. id: AccountId::with_sub(id, sub),
  942. version: 1,
  943. policy,
  944. flags: AccountFlags::empty(),
  945. book: BookId(0),
  946. metadata: BTreeMap::new(),
  947. }
  948. }
  949. #[test]
  950. fn subaccount_carries_own_policy() {
  951. // Base (1,0) is NoOverdraft but subaccount (1,7) is UncappedOverdraft.
  952. // A negative posting on the subaccount is allowed because the check keys
  953. // on the full owner and uses the subaccount's own policy.
  954. let sub = AccountId::with_sub(1, 7);
  955. let envelope = Envelope {
  956. consumes: vec![],
  957. creates: vec![
  958. NewPosting {
  959. owner: AccountId::new(2),
  960. asset: AssetId::new(1),
  961. value: Cent::from(100),
  962. payer: Some(sub),
  963. },
  964. NewPosting {
  965. owner: sub,
  966. asset: AssetId::new(1),
  967. value: Cent::from(-100),
  968. payer: None,
  969. },
  970. ],
  971. book: BookId(0),
  972. account_snapshots: vec![],
  973. metadata: BTreeMap::new(),
  974. };
  975. let accounts = accounts_map(vec![
  976. make_account(1, AccountPolicy::NoOverdraft),
  977. make_subaccount(1, 7, AccountPolicy::UncappedOverdraft),
  978. make_account(2, AccountPolicy::NoOverdraft),
  979. ]);
  980. let balances = HashMap::new();
  981. let input = PlanInput {
  982. envelope: &envelope,
  983. consumed_postings: &[],
  984. accounts: &accounts,
  985. balances: &balances,
  986. book: None,
  987. };
  988. let plan = validate_and_plan(input).unwrap();
  989. assert_eq!(plan.postings_to_create.len(), 2);
  990. }
  991. #[test]
  992. fn subaccount_floor_is_segregated_from_base() {
  993. // Base (1,0) holds 100, but NoOverdraft subaccount (1,7) holds nothing.
  994. // The base's balance must not rescue the subaccount: a negative posting
  995. // on the subaccount is rejected on its own policy.
  996. let sub = AccountId::with_sub(1, 7);
  997. let envelope = Envelope {
  998. consumes: vec![],
  999. creates: vec![
  1000. NewPosting {
  1001. owner: AccountId::new(2),
  1002. asset: AssetId::new(1),
  1003. value: Cent::from(100),
  1004. payer: Some(sub),
  1005. },
  1006. NewPosting {
  1007. owner: sub,
  1008. asset: AssetId::new(1),
  1009. value: Cent::from(-100),
  1010. payer: None,
  1011. },
  1012. ],
  1013. book: BookId(0),
  1014. account_snapshots: vec![],
  1015. metadata: BTreeMap::new(),
  1016. };
  1017. let accounts = accounts_map(vec![
  1018. make_account(1, AccountPolicy::NoOverdraft),
  1019. make_subaccount(1, 7, AccountPolicy::NoOverdraft),
  1020. make_account(2, AccountPolicy::NoOverdraft),
  1021. ]);
  1022. let mut balances = HashMap::new();
  1023. balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
  1024. let input = PlanInput {
  1025. envelope: &envelope,
  1026. consumed_postings: &[],
  1027. accounts: &accounts,
  1028. balances: &balances,
  1029. book: None,
  1030. };
  1031. assert_eq!(
  1032. validate_and_plan(input).unwrap_err(),
  1033. ValidationError::NegativePostingOnNonSystemAccount {
  1034. account: sub,
  1035. asset: AssetId::new(1),
  1036. value: Cent::from(-100),
  1037. },
  1038. );
  1039. }
  1040. #[test]
  1041. fn account_not_found() {
  1042. let envelope = Envelope {
  1043. consumes: vec![],
  1044. creates: vec![
  1045. NewPosting {
  1046. owner: AccountId::new(999),
  1047. asset: AssetId::new(1),
  1048. value: Cent::from(100),
  1049. payer: None,
  1050. },
  1051. NewPosting {
  1052. owner: AccountId::new(99),
  1053. asset: AssetId::new(1),
  1054. value: Cent::from(-100),
  1055. payer: None,
  1056. },
  1057. ],
  1058. book: BookId(0),
  1059. account_snapshots: vec![],
  1060. metadata: BTreeMap::new(),
  1061. };
  1062. // Only external account exists, account 999 doesn't
  1063. let accounts = accounts_map(vec![make_account(99, AccountPolicy::ExternalAccount)]);
  1064. let balances = HashMap::new();
  1065. let input = PlanInput {
  1066. envelope: &envelope,
  1067. consumed_postings: &[],
  1068. accounts: &accounts,
  1069. balances: &balances,
  1070. book: None,
  1071. };
  1072. assert_eq!(
  1073. validate_and_plan(input).unwrap_err(),
  1074. ValidationError::AccountNotFound(AccountId::new(999))
  1075. );
  1076. }
  1077. #[test]
  1078. fn negative_posting_rejected_on_regular_account() {
  1079. let envelope = Envelope {
  1080. consumes: vec![],
  1081. creates: vec![
  1082. NewPosting {
  1083. owner: AccountId::new(1),
  1084. asset: AssetId::new(1),
  1085. value: Cent::from(-100),
  1086. payer: None,
  1087. },
  1088. NewPosting {
  1089. owner: AccountId::new(1),
  1090. asset: AssetId::new(1),
  1091. value: Cent::from(100),
  1092. payer: None,
  1093. },
  1094. ],
  1095. book: BookId(0),
  1096. account_snapshots: vec![],
  1097. metadata: BTreeMap::new(),
  1098. };
  1099. let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
  1100. let balances = HashMap::new();
  1101. let input = PlanInput {
  1102. envelope: &envelope,
  1103. consumed_postings: &[],
  1104. accounts: &accounts,
  1105. balances: &balances,
  1106. book: None,
  1107. };
  1108. assert_eq!(
  1109. validate_and_plan(input).unwrap_err(),
  1110. ValidationError::NegativePostingOnNonSystemAccount {
  1111. account: AccountId::new(1),
  1112. asset: AssetId::new(1),
  1113. value: Cent::from(-100),
  1114. }
  1115. );
  1116. }
  1117. #[test]
  1118. fn negative_posting_allowed_on_system_account() {
  1119. let envelope = deposit_envelope();
  1120. let accounts = accounts_map(vec![
  1121. make_account(1, AccountPolicy::NoOverdraft),
  1122. make_account(99, AccountPolicy::SystemAccount),
  1123. ]);
  1124. let balances = HashMap::new();
  1125. let input = PlanInput {
  1126. envelope: &envelope,
  1127. consumed_postings: &[],
  1128. accounts: &accounts,
  1129. balances: &balances,
  1130. book: None,
  1131. };
  1132. let plan = validate_and_plan(input).unwrap();
  1133. assert_eq!(plan.postings_to_create.len(), 2);
  1134. }
  1135. }