ledger.rs 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294
  1. //! The async ledger resource -- the primary entry point for callers.
  2. use std::collections::HashMap;
  3. use std::sync::Arc;
  4. use legend::{ExecutionResult, legend};
  5. use tracing::instrument;
  6. use kuatia_core::{
  7. AccountId, AccountPolicy, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
  8. EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingId, PostingStatus, Receipt,
  9. SelectionError, Transfer, account_snapshot_id, envelope_id, select_postings, validate_and_plan,
  10. };
  11. use crate::error::LedgerError;
  12. /// Return the current time as Unix milliseconds.
  13. pub(crate) fn now_millis() -> Result<i64, LedgerError> {
  14. Ok(std::time::SystemTime::now()
  15. .duration_since(std::time::UNIX_EPOCH)
  16. .map_err(|_| LedgerError::Overflow)?
  17. .as_millis() as i64)
  18. }
  19. use crate::saga::{
  20. FinalizeInput, FinalizeTransferStep, LedgerCtx, ReserveInput, ReservePostingsStep, SagaError,
  21. };
  22. use kuatia_storage::error::StoreError;
  23. use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
  24. use kuatia_storage::store::{EnvelopeRecord, Store};
  25. #[allow(missing_docs)]
  26. mod envelope_saga {
  27. use super::*;
  28. legend! {
  29. EnvelopeSaga<LedgerCtx, SagaError> {
  30. reserve: ReservePostingsStep,
  31. finalize: FinalizeTransferStep,
  32. }
  33. }
  34. }
  35. use envelope_saga::*;
  36. /// Phase of an in-flight commit, persisted with the write-ahead record so
  37. /// recovery knows whether validation has completed.
  38. #[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
  39. enum SagaPhase {
  40. /// Saved before reserve. Validation has not necessarily run, so recovery must
  41. /// re-reserve and re-validate before it can commit.
  42. Reserving,
  43. /// Saved at the start of finalize — after validation passed and just before
  44. /// the consumed postings begin turning `Inactive` (the point of no return).
  45. /// Recovery rolls forward without re-validating.
  46. Finalizing,
  47. }
  48. /// Write-ahead record for an in-flight commit, persisted via `SagaStore` before
  49. /// the saga mutates anything and removed once it reaches a terminal state. On
  50. /// startup [`Ledger::recover`] completes any that survive a crash.
  51. #[derive(serde::Serialize, serde::Deserialize)]
  52. struct PendingSaga {
  53. envelope: Envelope,
  54. reservation: kuatia_core::ReservationId,
  55. phase: SagaPhase,
  56. }
  57. /// A single subaccount's balance for one asset. Balances are always reported
  58. /// per subaccount and never summed across them (ADR-0012).
  59. #[derive(Clone, Copy, PartialEq, Eq, Debug)]
  60. pub struct SubAccountBalance {
  61. /// The subaccount this balance belongs to.
  62. pub account: AccountId,
  63. /// The balance of `account` for the queried asset.
  64. pub value: Cent,
  65. }
  66. /// Async ledger resource composing the commit pipeline.
  67. pub struct Ledger {
  68. store: Arc<dyn Store>,
  69. }
  70. impl Ledger {
  71. /// Create a new ledger backed by the given store.
  72. pub fn new(store: impl Store + 'static) -> Self {
  73. Self {
  74. store: Arc::new(store),
  75. }
  76. }
  77. /// Returns a reference to the underlying store.
  78. pub fn store(&self) -> &dyn Store {
  79. self.store.as_ref()
  80. }
  81. // -----------------------------------------------------------------------
  82. // Three-piece API: load -> plan -> apply
  83. // -----------------------------------------------------------------------
  84. /// Phase 1: load all state needed for validation.
  85. #[instrument(skip(self, envelope), name = "ledger.load")]
  86. pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
  87. let consumed_postings = if envelope.consumes().is_empty() {
  88. vec![]
  89. } else {
  90. self.store.get_postings(envelope.consumes()).await?
  91. };
  92. let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  93. for p in &consumed_postings {
  94. account_ids.push(p.owner);
  95. }
  96. account_ids.sort();
  97. account_ids.dedup();
  98. let account_list = self.store.get_accounts(&account_ids).await?;
  99. let accounts: HashMap<AccountId, _> = account_list.into_iter().map(|a| (a.id, a)).collect();
  100. let mut balance_keys: Vec<(AccountId, AssetId)> = Vec::new();
  101. for p in &consumed_postings {
  102. balance_keys.push((p.owner, p.asset));
  103. }
  104. for np in envelope.creates() {
  105. balance_keys.push((np.owner, np.asset));
  106. }
  107. balance_keys.sort();
  108. balance_keys.dedup();
  109. let mut balances = HashMap::new();
  110. for (account_id, asset_id) in &balance_keys {
  111. let bal = self.compute_balance(account_id, asset_id).await?;
  112. balances.insert((*account_id, *asset_id), bal);
  113. }
  114. // Load the gating book. A missing named (non-default) book is an error;
  115. // a missing default book means "unrestricted" (no policy to enforce).
  116. let book_id = envelope.book();
  117. let book = match self.store.get_book(&book_id).await {
  118. Ok(b) => Some(b),
  119. Err(StoreError::NotFound(_)) if book_id == DEFAULT_BOOK => None,
  120. Err(StoreError::NotFound(_)) => return Err(LedgerError::BookNotFound(book_id)),
  121. Err(e) => return Err(e.into()),
  122. };
  123. Ok(LoadedState {
  124. consumed_postings,
  125. accounts,
  126. balances,
  127. book,
  128. })
  129. }
  130. /// Phase 2: run pure validation and produce a plan.
  131. pub fn plan(
  132. &self,
  133. envelope: &Envelope,
  134. loaded: &LoadedState,
  135. ) -> Result<kuatia_core::Plan, LedgerError> {
  136. let input = PlanInput {
  137. envelope,
  138. consumed_postings: &loaded.consumed_postings,
  139. accounts: &loaded.accounts,
  140. balances: &loaded.balances,
  141. book: loaded.book.as_ref(),
  142. };
  143. Ok(validate_and_plan(input)?)
  144. }
  145. // -----------------------------------------------------------------------
  146. // Resolve: Transfer (intent) -> Envelope (concrete postings)
  147. // -----------------------------------------------------------------------
  148. /// Convert a [`Transfer`] intent into a concrete [`Envelope`] by selecting
  149. /// postings for each movement and computing change.
  150. ///
  151. /// Pass 1: create output postings and aggregate net debits per (account, asset).
  152. /// Pass 2: for each pair with a positive net debit, select postings and compute change.
  153. #[instrument(skip(self, transfer), name = "ledger.resolve")]
  154. pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
  155. let mut consumes: Vec<PostingId> = Vec::new();
  156. let mut creates: Vec<NewPosting> = Vec::new();
  157. let mut net_debits: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
  158. // Pass 1: output postings + debit aggregation
  159. for m in &transfer.movements {
  160. let payer = if m.from != m.to { Some(m.from) } else { None };
  161. creates.push(NewPosting {
  162. owner: m.to,
  163. asset: m.asset,
  164. value: m.amount,
  165. payer,
  166. });
  167. let entry = net_debits.entry((m.from, m.asset)).or_insert(Cent::ZERO);
  168. *entry = entry.checked_add(m.amount)?;
  169. }
  170. // Pass 2: posting selection for accounts with positive net debit
  171. for ((account, asset), net_debit) in &net_debits {
  172. if !net_debit.is_positive() {
  173. continue;
  174. }
  175. let available = self
  176. .store
  177. .get_postings_by_account(
  178. account.id,
  179. Some(account.sub),
  180. Some(asset),
  181. Some(PostingStatus::Active),
  182. )
  183. .await?;
  184. let total_positive = Cent::checked_sum(
  185. available
  186. .iter()
  187. .filter(|p| p.value.is_positive())
  188. .map(|p| p.value),
  189. )?;
  190. if total_positive >= *net_debit {
  191. // Enough positive postings: select a subset and compute change.
  192. let selected = select_postings(&available, *asset, *net_debit)?;
  193. let consumed_sum = Cent::checked_sum(
  194. available
  195. .iter()
  196. .filter(|p| selected.contains(&p.id))
  197. .map(|p| p.value),
  198. )?;
  199. let change = consumed_sum.checked_sub(*net_debit)?;
  200. consumes.extend_from_slice(&selected);
  201. if change.is_positive() {
  202. creates.push(NewPosting {
  203. owner: *account,
  204. asset: *asset,
  205. value: change,
  206. payer: None,
  207. });
  208. }
  209. } else {
  210. // Not enough positive postings. Overdraft accounts cover the
  211. // shortfall with a negative posting (an offset position); the
  212. // floor is enforced later in validation. Any other policy fails.
  213. let policy = self.store.get_account(account).await?.policy;
  214. match policy {
  215. AccountPolicy::CappedOverdraft { .. } | AccountPolicy::UncappedOverdraft => {
  216. let positives: Vec<PostingId> = available
  217. .iter()
  218. .filter(|p| p.value.is_positive())
  219. .map(|p| p.id)
  220. .collect();
  221. consumes.extend_from_slice(&positives);
  222. let shortfall = net_debit.checked_sub(total_positive)?;
  223. creates.push(NewPosting {
  224. owner: *account,
  225. asset: *asset,
  226. value: shortfall.checked_neg()?,
  227. payer: None,
  228. });
  229. }
  230. _ => {
  231. return Err(LedgerError::Selection(SelectionError::InsufficientFunds {
  232. available: total_positive,
  233. requested: *net_debit,
  234. }));
  235. }
  236. }
  237. }
  238. }
  239. let mut envelope = EnvelopeBuilder::new()
  240. .consumes(consumes)
  241. .creates(creates)
  242. .book(transfer.book)
  243. .metadata(transfer.metadata.clone())
  244. .build();
  245. // Resolve account snapshots for optimistic concurrency
  246. let ids = envelope.referenced_accounts();
  247. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  248. Ok(envelope)
  249. }
  250. // -----------------------------------------------------------------------
  251. // Commit: every commit is the envelope saga (reserve -> finalize; finalize re-validates)
  252. // -----------------------------------------------------------------------
  253. /// Commit a [`Transfer`] intent. Resolves it into a concrete envelope, then
  254. /// drives the envelope saga. Resolution is read-only, so a crash before the
  255. /// saga's write-ahead record leaves no partial state.
  256. #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
  257. pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
  258. let envelope = self.resolve(&transfer).await?;
  259. self.commit_envelope(envelope).await
  260. }
  261. /// Commit a pre-resolved [`Envelope`] through the saga pipeline (reserve ->
  262. /// validate -> finalize). This is the single commit path; `commit()` and
  263. /// `reverse()` both funnel through it.
  264. ///
  265. /// Before running, the saga (envelope + reservation) is persisted as a
  266. /// pending record so a crash mid-commit is completed by [`recover`](Self::recover). The
  267. /// record is deleted once the saga reaches a terminal state. The commit is
  268. /// idempotent on the content-addressed transfer id.
  269. #[instrument(skip(self, envelope), name = "ledger.commit_envelope")]
  270. pub async fn commit_envelope(
  271. self: &Arc<Self>,
  272. mut envelope: Envelope,
  273. ) -> Result<Receipt, LedgerError> {
  274. if envelope.account_snapshots().is_empty() {
  275. let mut ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  276. ids.sort();
  277. ids.dedup();
  278. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  279. }
  280. // Idempotency: an already-committed transfer returns its receipt.
  281. let tid = envelope_id(&envelope);
  282. if let Some(record) = self.store.get_transfer(&tid).await? {
  283. return Ok(record.receipt);
  284. }
  285. // Write-ahead: persist {envelope, reservation, phase=Reserving} before any
  286. // mutation. The finalize step bumps the phase to Finalizing.
  287. let reservation = kuatia_core::ReservationId::default();
  288. let saga_id = reservation.0;
  289. self.save_pending(&envelope, reservation, SagaPhase::Reserving)
  290. .await?;
  291. let result = self.drive_envelope_saga(envelope, reservation).await;
  292. // Delete the pending record only when it is safe: on success, or on a
  293. // failure that never reached finalize (phase still Reserving → the saga's
  294. // compensation released our reservation, nothing of ours was applied). If
  295. // finalize started (Finalizing) and failed, keep it so `recover()` rolls
  296. // the half-applied commit forward.
  297. let safe_to_delete = match &result {
  298. Ok(_) => true,
  299. Err(_) => self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing),
  300. };
  301. if safe_to_delete {
  302. self.store.delete_saga(&saga_id).await?;
  303. }
  304. result
  305. }
  306. /// Build and run the envelope saga (reserve → finalize) to a terminal
  307. /// outcome, returning the resulting receipt.
  308. async fn drive_envelope_saga(
  309. self: &Arc<Self>,
  310. envelope: Envelope,
  311. reservation: kuatia_core::ReservationId,
  312. ) -> Result<Receipt, LedgerError> {
  313. let saga = EnvelopeSaga::new(EnvelopeSagaInputs {
  314. reserve: ReserveInput,
  315. finalize: FinalizeInput,
  316. });
  317. let ctx = LedgerCtx::for_envelope(Arc::clone(self), envelope, reservation);
  318. let execution = saga.build(ctx);
  319. match execution.start().await {
  320. ExecutionResult::Completed(e) => {
  321. let ctx = e.into_context();
  322. ctx.receipts.last().cloned().ok_or_else(|| {
  323. LedgerError::Store(StoreError::Internal("saga completed but no receipt".into()))
  324. })
  325. }
  326. ExecutionResult::Failed(_, err) => {
  327. Err(LedgerError::Store(StoreError::Internal(err.message)))
  328. }
  329. ExecutionResult::CompensationFailed {
  330. original_error,
  331. compensation_error,
  332. ..
  333. } => Err(LedgerError::CompensationFailed {
  334. original: Box::new(LedgerError::Store(StoreError::Internal(
  335. original_error.message,
  336. ))),
  337. compensation: Box::new(LedgerError::Store(StoreError::Internal(
  338. compensation_error.message,
  339. ))),
  340. }),
  341. ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
  342. "saga paused unexpectedly".into(),
  343. ))),
  344. }
  345. }
  346. /// Complete every pending saga left by a crash. Call on startup; returns how
  347. /// many were processed.
  348. ///
  349. /// Recovery branches on the persisted phase. A `Reserving` saga had not
  350. /// necessarily validated, so it is re-run through the real saga (which
  351. /// re-reserves and **re-validates** — aborting cleanly if the postings were
  352. /// taken or an account was frozen meanwhile). A `Finalizing` saga had already
  353. /// validated and owns its postings, so it is rolled forward through the
  354. /// verified `finalize_envelope`. Either way the record is removed only once
  355. /// the work is committed or safely abandoned.
  356. #[instrument(skip(self), name = "ledger.recover")]
  357. pub async fn recover(self: &Arc<Self>) -> Result<usize, LedgerError> {
  358. let pending = self.store.list_pending_sagas().await?;
  359. let count = pending.len();
  360. for (saga_id, blob) in pending {
  361. let PendingSaga {
  362. envelope,
  363. reservation,
  364. phase,
  365. } = serde_json::from_slice(&blob)
  366. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  367. // The transfer record is durable, but a full commit is more than the
  368. // transfer row: it also includes the committed event, appended *after*
  369. // store_transfer. A crash in that window leaves the record present yet
  370. // the event missing, so repair the whole end-state (idempotent) before
  371. // clearing the pending record.
  372. let tid = envelope_id(&envelope);
  373. if self.store.get_transfer(&tid).await?.is_some() {
  374. self.append_committed_event(tid).await?;
  375. self.store.delete_saga(&saga_id).await?;
  376. continue;
  377. }
  378. match phase {
  379. SagaPhase::Finalizing => {
  380. // Validation passed and the postings are ours; roll forward.
  381. // Keep the record if completion fails so a later run retries.
  382. if self.finalize_envelope(&envelope, reservation).await.is_ok() {
  383. self.store.delete_saga(&saga_id).await?;
  384. }
  385. }
  386. SagaPhase::Reserving => {
  387. // Re-run the validating saga. On failure, delete only if it did
  388. // not reach finalize (clean abort); otherwise keep for next run.
  389. let result = self.drive_envelope_saga(envelope, reservation).await;
  390. let safe_to_delete = result.is_ok()
  391. || self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing);
  392. if safe_to_delete {
  393. self.store.delete_saga(&saga_id).await?;
  394. }
  395. }
  396. }
  397. }
  398. Ok(count)
  399. }
  400. /// Idempotently finalize `envelope` to its committed state, **verifying every
  401. /// step's end-state**. Used by the saga's finalize step and by recovery.
  402. ///
  403. /// When the consumed postings are still pre-deactivation it re-validates
  404. /// against current state (the last-step floor / freeze-close guard) and then
  405. /// marks the saga `Finalizing` (the point of no return). Once any consumed
  406. /// posting is already `Inactive` — a prior attempt or recovery passed that
  407. /// point — it rolls forward without re-validating (validation rejects
  408. /// `Inactive`). It never creates or stores anything unless **all** consumed
  409. /// postings are confirmed `Inactive`, which is the double-spend guard.
  410. pub(crate) async fn finalize_envelope(
  411. &self,
  412. envelope: &Envelope,
  413. reservation: kuatia_core::ReservationId,
  414. ) -> Result<Receipt, LedgerError> {
  415. let tid = envelope_id(envelope);
  416. if let Some(record) = self.store.get_transfer(&tid).await? {
  417. // The transfer record is durable, but a crash (or a retried finalize)
  418. // can land between store_transfer and the event append below. The
  419. // committed end-state includes the event, so ensure it before
  420. // returning — `append_committed_event` is idempotent.
  421. self.append_committed_event(tid).await?;
  422. return Ok(record.receipt); // already committed
  423. }
  424. let consumes = envelope.consumes();
  425. // Read consumed postings (also captures their owners for indexing).
  426. let consumed = if consumes.is_empty() {
  427. Vec::new()
  428. } else {
  429. self.store.get_postings(consumes).await?
  430. };
  431. let past_no_return = consumed.iter().any(|p| p.status == PostingStatus::Inactive);
  432. // Last-step boundary re-check: re-validate floor + freeze/close + snapshots
  433. // against current state, but only while it is still safe (validation
  434. // rejects already-`Inactive` consumed postings).
  435. if !past_no_return {
  436. let loaded = self.load(envelope).await?;
  437. self.plan(envelope, &loaded)?;
  438. }
  439. // Point of no return: record Finalizing before any posting turns Inactive.
  440. self.save_pending(envelope, reservation, SagaPhase::Finalizing)
  441. .await?;
  442. // Deactivate consumed postings (PendingInactive owned by us → Inactive),
  443. // then assert ALL consumed postings are Inactive. This is the double-spend
  444. // guard: do not create/store unless the inputs were really consumed by us.
  445. self.store
  446. .deactivate_postings(consumes, Some(reservation))
  447. .await?;
  448. if !consumes.is_empty() {
  449. let after = self.store.get_postings(consumes).await?;
  450. if after.len() != consumes.len()
  451. || after.iter().any(|p| p.status != PostingStatus::Inactive)
  452. {
  453. return Err(LedgerError::Store(StoreError::Internal(
  454. "finalize: consumed postings not all inactive (contended or not reserved by this saga)".into(),
  455. )));
  456. }
  457. }
  458. // Created postings, derived deterministically from the envelope.
  459. let created: Vec<Posting> = envelope
  460. .creates()
  461. .iter()
  462. .enumerate()
  463. .map(|(i, np)| {
  464. Posting::new(
  465. PostingId {
  466. transfer: tid,
  467. index: i as u16,
  468. },
  469. np.owner,
  470. np.asset,
  471. np.value,
  472. )
  473. })
  474. .collect();
  475. self.store.insert_postings(&created).await?;
  476. if !created.is_empty() {
  477. let ids: Vec<PostingId> = created.iter().map(|p| p.id).collect();
  478. if self.store.get_postings(&ids).await?.len() != created.len() {
  479. return Err(LedgerError::Store(StoreError::Internal(
  480. "finalize: created postings missing after insert".into(),
  481. )));
  482. }
  483. }
  484. // Index both created and consumed owners.
  485. let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
  486. involved.extend(consumed.iter().map(|p| p.owner));
  487. involved.sort();
  488. involved.dedup();
  489. let receipt = Receipt { transfer_id: tid };
  490. self.store
  491. .store_transfer(
  492. EnvelopeRecord {
  493. envelope: envelope.clone(),
  494. receipt: receipt.clone(),
  495. created_at: now_millis()?,
  496. },
  497. &involved,
  498. )
  499. .await?;
  500. if self.store.get_transfer(&tid).await?.is_none() {
  501. return Err(LedgerError::Store(StoreError::Internal(
  502. "finalize: transfer record missing after store".into(),
  503. )));
  504. }
  505. self.append_committed_event(tid).await?;
  506. Ok(receipt)
  507. }
  508. /// Idempotently append the `TransferCommitted` event for `tid`.
  509. ///
  510. /// The event append is the final finalize step, *after* `store_transfer`, so a
  511. /// crash in that window leaves a stored transfer with no event. Recovery and a
  512. /// retried finalize both call this to repair the committed end-state.
  513. /// `append_event` dedups on the transfer id, so calling it more than once for
  514. /// the same transfer is a no-op.
  515. async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
  516. self.store
  517. .append_event(&LedgerEvent {
  518. seq: 0,
  519. timestamp: now_millis()?,
  520. kind: LedgerEventKind::TransferCommitted { transfer_id: tid },
  521. })
  522. .await?;
  523. Ok(())
  524. }
  525. /// Persist the write-ahead pending-saga record (upsert on the reservation id).
  526. async fn save_pending(
  527. &self,
  528. envelope: &Envelope,
  529. reservation: kuatia_core::ReservationId,
  530. phase: SagaPhase,
  531. ) -> Result<(), LedgerError> {
  532. let blob = serde_json::to_vec(&PendingSaga {
  533. envelope: envelope.clone(),
  534. reservation,
  535. phase,
  536. })
  537. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  538. self.store.save_saga(&reservation.0, blob).await?;
  539. Ok(())
  540. }
  541. /// Read the persisted phase of a pending saga, if it still exists.
  542. async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
  543. for (id, blob) in self.store.list_pending_sagas().await? {
  544. if id == saga_id {
  545. let pending: PendingSaga = serde_json::from_slice(&blob)
  546. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  547. return Ok(Some(pending.phase));
  548. }
  549. }
  550. Ok(None)
  551. }
  552. // -----------------------------------------------------------------------
  553. // Reverse
  554. // -----------------------------------------------------------------------
  555. /// Create and commit a reversal envelope for the given envelope id.
  556. #[instrument(skip(self), name = "ledger.reverse")]
  557. pub async fn reverse(self: &Arc<Self>, id: &EnvelopeId) -> Result<Receipt, LedgerError> {
  558. let record = self
  559. .store
  560. .get_transfer(id)
  561. .await?
  562. .ok_or(LedgerError::TransferNotFound(*id))?;
  563. let original = &record.envelope;
  564. let created_posting_ids: Vec<PostingId> = original
  565. .creates()
  566. .iter()
  567. .enumerate()
  568. .map(|(i, _)| PostingId {
  569. transfer: record.receipt.transfer_id,
  570. index: i as u16,
  571. })
  572. .collect();
  573. let original_consumed = if original.consumes().is_empty() {
  574. vec![]
  575. } else {
  576. self.store.get_postings(original.consumes()).await?
  577. };
  578. let new_postings: Vec<NewPosting> = original_consumed
  579. .iter()
  580. .map(|p| NewPosting {
  581. owner: p.owner,
  582. asset: p.asset,
  583. value: p.value,
  584. payer: None,
  585. })
  586. .collect();
  587. let reverse_envelope = EnvelopeBuilder::new()
  588. .consumes(created_posting_ids)
  589. .creates(new_postings)
  590. .book(original.book())
  591. .metadata(original.metadata().clone())
  592. .build();
  593. self.commit_envelope(reverse_envelope).await
  594. }
  595. // -----------------------------------------------------------------------
  596. // Internal: resolve account snapshots
  597. // -----------------------------------------------------------------------
  598. /// Compute balance from non-Inactive postings for an account/asset pair.
  599. async fn compute_balance(
  600. &self,
  601. account: &AccountId,
  602. asset: &AssetId,
  603. ) -> Result<Cent, LedgerError> {
  604. let postings = self
  605. .store
  606. .get_postings_by_account(account.id, Some(account.sub), Some(asset), None)
  607. .await?;
  608. Ok(Cent::checked_sum(
  609. postings
  610. .iter()
  611. .filter(|p| p.status != PostingStatus::Inactive)
  612. .map(|p| p.value),
  613. )?)
  614. }
  615. async fn resolve_snapshots(
  616. &self,
  617. ids: &[AccountId],
  618. ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
  619. let accounts = self.store.get_accounts(ids).await?;
  620. Ok(accounts.iter().map(account_snapshot_id).collect())
  621. }
  622. // -----------------------------------------------------------------------
  623. // Account lifecycle
  624. // -----------------------------------------------------------------------
  625. /// Freeze an account, preventing all transfers.
  626. #[instrument(skip(self), name = "ledger.freeze")]
  627. pub async fn freeze(&self, id: &AccountId) -> Result<(), LedgerError> {
  628. let current = self
  629. .store
  630. .get_account(id)
  631. .await
  632. .map_err(|_| LedgerError::AccountNotFound(*id))?;
  633. if current.is_closed() {
  634. return Err(LedgerError::AccountAlreadyClosed(*id));
  635. }
  636. let mut next = current.clone();
  637. next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
  638. next.flags |= kuatia_core::AccountFlags::FROZEN;
  639. self.store.append_account_version(next).await?;
  640. self.store
  641. .append_event(&LedgerEvent {
  642. seq: 0,
  643. timestamp: now_millis()?,
  644. kind: LedgerEventKind::AccountFrozen { account_id: *id },
  645. })
  646. .await?;
  647. Ok(())
  648. }
  649. /// Unfreeze a previously frozen account.
  650. #[instrument(skip(self), name = "ledger.unfreeze")]
  651. pub async fn unfreeze(&self, id: &AccountId) -> Result<(), LedgerError> {
  652. let current = self
  653. .store
  654. .get_account(id)
  655. .await
  656. .map_err(|_| LedgerError::AccountNotFound(*id))?;
  657. if current.is_closed() {
  658. return Err(LedgerError::AccountAlreadyClosed(*id));
  659. }
  660. let mut next = current.clone();
  661. next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
  662. next.flags.remove(kuatia_core::AccountFlags::FROZEN);
  663. self.store.append_account_version(next).await?;
  664. self.store
  665. .append_event(&LedgerEvent {
  666. seq: 0,
  667. timestamp: now_millis()?,
  668. kind: LedgerEventKind::AccountUnfrozen { account_id: *id },
  669. })
  670. .await?;
  671. Ok(())
  672. }
  673. /// Close an account. Must have no active postings.
  674. #[instrument(skip(self), name = "ledger.close")]
  675. pub async fn close(&self, id: &AccountId) -> Result<(), LedgerError> {
  676. let current = self
  677. .store
  678. .get_account(id)
  679. .await
  680. .map_err(|_| LedgerError::AccountNotFound(*id))?;
  681. if current.is_closed() {
  682. return Err(LedgerError::AccountAlreadyClosed(*id));
  683. }
  684. // Reject if any posting is still live — Active or PendingInactive
  685. // (reserved, i.e. a transfer in flight). Only fully Inactive postings
  686. // (or none) permit a close.
  687. let blocking = self
  688. .store
  689. .get_postings_by_account(id.id, Some(id.sub), None, None)
  690. .await?
  691. .into_iter()
  692. .any(|p| p.status != PostingStatus::Inactive);
  693. if blocking {
  694. return Err(LedgerError::AccountNotEmpty(*id));
  695. }
  696. let mut next = current.clone();
  697. next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
  698. next.flags |= kuatia_core::AccountFlags::CLOSED;
  699. next.flags.remove(kuatia_core::AccountFlags::FROZEN);
  700. self.store.append_account_version(next).await?;
  701. self.store
  702. .append_event(&LedgerEvent {
  703. seq: 0,
  704. timestamp: now_millis()?,
  705. kind: LedgerEventKind::AccountClosed { account_id: *id },
  706. })
  707. .await?;
  708. Ok(())
  709. }
  710. /// Query the current balance of one subaccount for a given asset. This reads
  711. /// exactly the `account` passed (base id and subaccount) and never rolls up
  712. /// other subaccounts.
  713. #[instrument(skip(self), name = "ledger.balance")]
  714. pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
  715. self.compute_balance(account, asset).await
  716. }
  717. /// Report the per-subaccount balances of a base account for one asset.
  718. ///
  719. /// One entry per non-closed subaccount. `sub == None` spans every
  720. /// subaccount of `account`'s base id; `Some(s)` restricts to that one.
  721. /// Balances are never summed across subaccounts (ADR-0012).
  722. #[instrument(skip(self), name = "ledger.balances")]
  723. pub async fn balances(
  724. &self,
  725. account: &AccountId,
  726. asset: &AssetId,
  727. sub: Option<i64>,
  728. ) -> Result<Vec<SubAccountBalance>, LedgerError> {
  729. let mut result = Vec::new();
  730. for subaccount in self.list_subaccounts(account).await? {
  731. if let Some(s) = sub
  732. && subaccount.sub != s
  733. {
  734. continue;
  735. }
  736. let value = self.compute_balance(&subaccount, asset).await?;
  737. result.push(SubAccountBalance {
  738. account: subaccount,
  739. value,
  740. });
  741. }
  742. Ok(result)
  743. }
  744. /// List the non-closed subaccounts of a base account.
  745. ///
  746. /// This scans every account row and filters in memory, so it pays for
  747. /// subaccounts that were created and later closed (ADR-0012).
  748. #[instrument(skip(self), name = "ledger.list_subaccounts")]
  749. pub async fn list_subaccounts(
  750. &self,
  751. account: &AccountId,
  752. ) -> Result<Vec<AccountId>, LedgerError> {
  753. let base = account.id;
  754. let mut subs: Vec<AccountId> = self
  755. .store
  756. .list_accounts()
  757. .await?
  758. .into_iter()
  759. .filter(|a| a.id.id == base && !a.is_closed())
  760. .map(|a| a.id)
  761. .collect();
  762. subs.sort();
  763. Ok(subs)
  764. }
  765. // -----------------------------------------------------------------------
  766. // Query layer
  767. // -----------------------------------------------------------------------
  768. /// List all accounts (latest version of each).
  769. pub async fn list_accounts(&self) -> Result<Vec<kuatia_core::Account>, LedgerError> {
  770. Ok(self.store.list_accounts().await?)
  771. }
  772. /// Fetch a single account by id.
  773. pub async fn get_account(&self, id: &AccountId) -> Result<kuatia_core::Account, LedgerError> {
  774. self.store
  775. .get_account(id)
  776. .await
  777. .map_err(|_| LedgerError::AccountNotFound(*id))
  778. }
  779. /// Return all transfers involving the given account (exact subaccount).
  780. pub async fn history(
  781. &self,
  782. account: &AccountId,
  783. ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
  784. Ok(self
  785. .store
  786. .get_transfers_for_account(account.id, Some(account.sub))
  787. .await?)
  788. }
  789. /// Query transfers with filtering and pagination.
  790. pub async fn query_transfers(
  791. &self,
  792. query: &crate::store::TransferQuery,
  793. ) -> Result<crate::store::Page<crate::store::EnvelopeRecord>, LedgerError> {
  794. Ok(self.store.query_transfers(query).await?)
  795. }
  796. /// Return all postings (any status) for the given account.
  797. pub async fn postings(
  798. &self,
  799. account: &AccountId,
  800. ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
  801. Ok(self
  802. .store
  803. .get_postings_by_account(account.id, Some(account.sub), None, None)
  804. .await?)
  805. }
  806. /// Query postings with filtering and pagination.
  807. pub async fn query_postings(
  808. &self,
  809. query: &crate::store::PostingQuery,
  810. ) -> Result<crate::store::Page<kuatia_core::Posting>, LedgerError> {
  811. Ok(self.store.query_postings(query).await?)
  812. }
  813. /// Return the full version history for an account.
  814. pub async fn account_history(
  815. &self,
  816. id: &AccountId,
  817. ) -> Result<Vec<kuatia_core::Account>, LedgerError> {
  818. Ok(self.store.get_account_history(id).await?)
  819. }
  820. /// Create a new account and emit an AccountCreated event.
  821. pub async fn create_account(&self, account: kuatia_core::Account) -> Result<(), LedgerError> {
  822. let id = account.id;
  823. self.store.create_account(account).await?;
  824. self.store
  825. .append_event(&LedgerEvent {
  826. seq: 0,
  827. timestamp: now_millis()?,
  828. kind: LedgerEventKind::AccountCreated { account_id: id },
  829. })
  830. .await?;
  831. Ok(())
  832. }
  833. /// Create a new book.
  834. pub async fn create_book(&self, book: kuatia_core::Book) -> Result<(), LedgerError> {
  835. Ok(self.store.create_book(book).await?)
  836. }
  837. /// Fetch a book by id.
  838. pub async fn get_book(
  839. &self,
  840. id: &kuatia_core::BookId,
  841. ) -> Result<kuatia_core::Book, LedgerError> {
  842. Ok(self.store.get_book(id).await?)
  843. }
  844. /// List all books.
  845. pub async fn list_books(&self) -> Result<Vec<kuatia_core::Book>, LedgerError> {
  846. Ok(self.store.list_books().await?)
  847. }
  848. /// Query ledger events after a given sequence number.
  849. pub async fn get_events_since(
  850. &self,
  851. after_seq: u64,
  852. limit: u32,
  853. ) -> Result<Vec<LedgerEvent>, LedgerError> {
  854. Ok(self.store.get_events_since(after_seq, limit).await?)
  855. }
  856. }
  857. /// State loaded in phase 1, passed to the pure validation in phase 2.
  858. pub struct LoadedState {
  859. /// Postings being consumed by the envelope.
  860. pub consumed_postings: Vec<Posting>,
  861. /// Accounts referenced by the envelope.
  862. pub accounts: HashMap<AccountId, kuatia_core::Account>,
  863. /// Current balances for all referenced (account, asset) pairs.
  864. pub balances: HashMap<(AccountId, AssetId), Cent>,
  865. /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
  866. pub book: Option<Book>,
  867. }
  868. #[cfg(test)]
  869. mod recovery_tests {
  870. use super::*;
  871. use kuatia_core::{Account, AccountFlags, ReservationId, TransferBuilder};
  872. use kuatia_storage::mem_store::InMemoryStore;
  873. use std::collections::BTreeMap;
  874. fn acct(id: i64, policy: AccountPolicy) -> Account {
  875. Account {
  876. id: AccountId::new(id),
  877. version: 1,
  878. policy,
  879. flags: AccountFlags::empty(),
  880. book: kuatia_core::BookId(0),
  881. metadata: BTreeMap::new(),
  882. }
  883. }
  884. async fn funded_ledger() -> Arc<Ledger> {
  885. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  886. for (id, p) in [
  887. (1, AccountPolicy::NoOverdraft),
  888. (2, AccountPolicy::NoOverdraft),
  889. (3, AccountPolicy::NoOverdraft),
  890. (99, AccountPolicy::ExternalAccount),
  891. ] {
  892. ledger.store().create_account(acct(id, p)).await.unwrap();
  893. }
  894. let deposit = TransferBuilder::new()
  895. .deposit(
  896. AccountId::new(1),
  897. AssetId::new(1),
  898. Cent::from(100),
  899. AccountId::new(99),
  900. )
  901. .unwrap()
  902. .build();
  903. ledger.commit(deposit).await.unwrap();
  904. ledger
  905. }
  906. fn pay_transfer() -> Transfer {
  907. TransferBuilder::new()
  908. .pay(
  909. AccountId::new(1),
  910. AccountId::new(2),
  911. AssetId::new(1),
  912. Cent::from(40),
  913. )
  914. .build()
  915. }
  916. async fn save_pending(
  917. ledger: &Arc<Ledger>,
  918. envelope: &Envelope,
  919. rid: ReservationId,
  920. phase: SagaPhase,
  921. ) {
  922. let blob = serde_json::to_vec(&PendingSaga {
  923. envelope: envelope.clone(),
  924. reservation: rid,
  925. phase,
  926. })
  927. .unwrap();
  928. ledger.store().save_saga(&rid.0, blob).await.unwrap();
  929. }
  930. /// A commit interrupted right after its write-ahead record (phase Reserving,
  931. /// before any step) is re-run and completed by `recover()`.
  932. #[tokio::test]
  933. async fn recover_redrives_reserving_saga() {
  934. let ledger = funded_ledger().await;
  935. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  936. let rid = ReservationId::default();
  937. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  938. assert_eq!(ledger.recover().await.unwrap(), 1);
  939. assert_eq!(
  940. ledger
  941. .balance(&AccountId::new(2), &AssetId::new(1))
  942. .await
  943. .unwrap(),
  944. Cent::from(40)
  945. );
  946. assert_eq!(
  947. ledger
  948. .balance(&AccountId::new(1), &AssetId::new(1))
  949. .await
  950. .unwrap(),
  951. Cent::from(60)
  952. );
  953. assert!(
  954. ledger
  955. .store()
  956. .list_pending_sagas()
  957. .await
  958. .unwrap()
  959. .is_empty()
  960. );
  961. }
  962. /// A commit that crashed mid-finalize (phase Finalizing; the consumed posting
  963. /// is already Inactive) is rolled forward by `recover()`.
  964. #[tokio::test]
  965. async fn recover_completes_partial_finalize() {
  966. let ledger = funded_ledger().await;
  967. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  968. let rid = ReservationId::default();
  969. // Run the commit halfway: reserve + deactivate the consumed posting.
  970. let consumes = envelope.consumes().to_vec();
  971. ledger
  972. .store()
  973. .reserve_postings(&consumes, rid)
  974. .await
  975. .unwrap();
  976. assert_eq!(
  977. ledger
  978. .store()
  979. .deactivate_postings(&consumes, Some(rid))
  980. .await
  981. .unwrap(),
  982. 1
  983. );
  984. save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
  985. assert_eq!(ledger.recover().await.unwrap(), 1);
  986. assert_eq!(
  987. ledger
  988. .balance(&AccountId::new(2), &AssetId::new(1))
  989. .await
  990. .unwrap(),
  991. Cent::from(40)
  992. );
  993. assert_eq!(
  994. ledger
  995. .balance(&AccountId::new(1), &AssetId::new(1))
  996. .await
  997. .unwrap(),
  998. Cent::from(60)
  999. );
  1000. assert!(
  1001. ledger
  1002. .store()
  1003. .list_pending_sagas()
  1004. .await
  1005. .unwrap()
  1006. .is_empty()
  1007. );
  1008. }
  1009. /// A commit that crashed *after* `store_transfer` but *before* the committed
  1010. /// event was appended (phase Finalizing, transfer row present, event missing)
  1011. /// is repaired by `recover()`: the full end-state includes the event, so
  1012. /// recovery appends it (idempotently) instead of treating the transfer row as
  1013. /// proof of a complete commit.
  1014. #[tokio::test]
  1015. async fn recover_appends_missing_committed_event() {
  1016. let ledger = funded_ledger().await;
  1017. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  1018. let tid = envelope_id(&envelope);
  1019. let rid = ReservationId::default();
  1020. // Replay finalize by hand up to and including store_transfer, stopping
  1021. // short of the event append — exactly the crash window.
  1022. let consumes = envelope.consumes().to_vec();
  1023. ledger
  1024. .store()
  1025. .reserve_postings(&consumes, rid)
  1026. .await
  1027. .unwrap();
  1028. ledger
  1029. .store()
  1030. .deactivate_postings(&consumes, Some(rid))
  1031. .await
  1032. .unwrap();
  1033. let created: Vec<Posting> = envelope
  1034. .creates()
  1035. .iter()
  1036. .enumerate()
  1037. .map(|(i, np)| {
  1038. Posting::new(
  1039. PostingId {
  1040. transfer: tid,
  1041. index: i as u16,
  1042. },
  1043. np.owner,
  1044. np.asset,
  1045. np.value,
  1046. )
  1047. })
  1048. .collect();
  1049. ledger.store().insert_postings(&created).await.unwrap();
  1050. let consumed = ledger.store().get_postings(&consumes).await.unwrap();
  1051. let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
  1052. involved.extend(consumed.iter().map(|p| p.owner));
  1053. involved.sort();
  1054. involved.dedup();
  1055. ledger
  1056. .store()
  1057. .store_transfer(
  1058. EnvelopeRecord {
  1059. envelope: envelope.clone(),
  1060. receipt: Receipt { transfer_id: tid },
  1061. created_at: 0,
  1062. },
  1063. &involved,
  1064. )
  1065. .await
  1066. .unwrap();
  1067. save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
  1068. // Precondition: the transfer is stored, but no committed event exists yet.
  1069. let committed = |evs: &[LedgerEvent]| {
  1070. evs.iter().any(|e| {
  1071. matches!(
  1072. e.kind,
  1073. LedgerEventKind::TransferCommitted { transfer_id } if transfer_id == tid
  1074. )
  1075. })
  1076. };
  1077. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_some());
  1078. assert!(!committed(&ledger.get_events_since(0, 1000).await.unwrap()));
  1079. assert_eq!(ledger.recover().await.unwrap(), 1);
  1080. // The missing event is repaired and the pending record cleared.
  1081. assert!(committed(&ledger.get_events_since(0, 1000).await.unwrap()));
  1082. assert!(
  1083. ledger
  1084. .store()
  1085. .list_pending_sagas()
  1086. .await
  1087. .unwrap()
  1088. .is_empty()
  1089. );
  1090. }
  1091. /// Recovery of a `Reserving` saga re-validates against current state: if an
  1092. /// account was frozen after the write-ahead record, the commit is abandoned —
  1093. /// no postings move, the reservation is released, and the record is cleared.
  1094. #[tokio::test]
  1095. async fn recover_revalidates_and_aborts_when_account_frozen() {
  1096. let ledger = funded_ledger().await;
  1097. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  1098. let tid = envelope_id(&envelope);
  1099. let rid = ReservationId::default();
  1100. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  1101. // A freeze lands before recovery runs.
  1102. ledger.freeze(&AccountId::new(1)).await.unwrap();
  1103. assert_eq!(ledger.recover().await.unwrap(), 1);
  1104. // Nothing committed; balances unchanged; reservation released.
  1105. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
  1106. assert_eq!(
  1107. ledger
  1108. .balance(&AccountId::new(1), &AssetId::new(1))
  1109. .await
  1110. .unwrap(),
  1111. Cent::from(100)
  1112. );
  1113. assert_eq!(
  1114. ledger
  1115. .balance(&AccountId::new(2), &AssetId::new(1))
  1116. .await
  1117. .unwrap(),
  1118. Cent::ZERO
  1119. );
  1120. let active = ledger
  1121. .store()
  1122. .get_postings_by_account(1, None, Some(&AssetId::new(1)), Some(PostingStatus::Active))
  1123. .await
  1124. .unwrap();
  1125. assert_eq!(active.len(), 1); // back to Active
  1126. assert!(
  1127. ledger
  1128. .store()
  1129. .list_pending_sagas()
  1130. .await
  1131. .unwrap()
  1132. .is_empty()
  1133. );
  1134. }
  1135. /// Recovery cannot double-spend: if the consumed posting was taken by another
  1136. /// transfer while the saga was pending, recovery aborts without creating or
  1137. /// storing anything.
  1138. #[tokio::test]
  1139. async fn recover_does_not_double_spend_a_taken_posting() {
  1140. let ledger = funded_ledger().await;
  1141. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  1142. let tid = envelope_id(&envelope);
  1143. let rid = ReservationId::default();
  1144. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  1145. // Another transfer consumes account 1's posting and commits.
  1146. let steal = TransferBuilder::new()
  1147. .pay(
  1148. AccountId::new(1),
  1149. AccountId::new(3),
  1150. AssetId::new(1),
  1151. Cent::from(50),
  1152. )
  1153. .build();
  1154. ledger.commit(steal).await.unwrap();
  1155. assert_eq!(ledger.recover().await.unwrap(), 1);
  1156. // Our envelope never committed; only the stealing transfer applied.
  1157. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
  1158. assert_eq!(
  1159. ledger
  1160. .balance(&AccountId::new(1), &AssetId::new(1))
  1161. .await
  1162. .unwrap(),
  1163. Cent::from(50)
  1164. );
  1165. assert_eq!(
  1166. ledger
  1167. .balance(&AccountId::new(3), &AssetId::new(1))
  1168. .await
  1169. .unwrap(),
  1170. Cent::from(50)
  1171. );
  1172. assert_eq!(
  1173. ledger
  1174. .balance(&AccountId::new(2), &AssetId::new(1))
  1175. .await
  1176. .unwrap(),
  1177. Cent::ZERO
  1178. );
  1179. assert!(
  1180. ledger
  1181. .store()
  1182. .list_pending_sagas()
  1183. .await
  1184. .unwrap()
  1185. .is_empty()
  1186. );
  1187. }
  1188. }