ledger.rs 48 KB

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