ledger.rs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  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,
  21. ResolveInput, ResolveStep, SagaError, ValidateInput, ValidateTransferStep,
  22. };
  23. use kuatia_storage::error::StoreError;
  24. use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
  25. use kuatia_storage::store::{CommitRequest, EnvelopeRecord, Store};
  26. #[allow(missing_docs)]
  27. mod transfer_saga {
  28. use super::*;
  29. legend! {
  30. TransferSaga<LedgerCtx, SagaError> {
  31. resolve: ResolveStep,
  32. reserve: ReservePostingsStep,
  33. validate: ValidateTransferStep,
  34. finalize: FinalizeTransferStep,
  35. }
  36. }
  37. }
  38. use transfer_saga::*;
  39. /// Async ledger resource composing the four-phase commit pipeline.
  40. pub struct Ledger {
  41. store: Arc<dyn Store>,
  42. }
  43. impl Ledger {
  44. /// Create a new ledger backed by the given store.
  45. pub fn new(store: impl Store + 'static) -> Self {
  46. Self {
  47. store: Arc::new(store),
  48. }
  49. }
  50. /// Returns a reference to the underlying store.
  51. pub fn store(&self) -> &dyn Store {
  52. self.store.as_ref()
  53. }
  54. // -----------------------------------------------------------------------
  55. // Three-piece API: load -> plan -> apply
  56. // -----------------------------------------------------------------------
  57. /// Phase 1: load all state needed for validation.
  58. #[instrument(skip(self, envelope), name = "ledger.load")]
  59. pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
  60. let consumed_postings = if envelope.consumes().is_empty() {
  61. vec![]
  62. } else {
  63. self.store.get_postings(envelope.consumes()).await?
  64. };
  65. let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  66. for p in &consumed_postings {
  67. account_ids.push(p.owner);
  68. }
  69. account_ids.sort();
  70. account_ids.dedup();
  71. let account_list = self.store.get_accounts(&account_ids).await?;
  72. let accounts: HashMap<AccountId, _> = account_list.into_iter().map(|a| (a.id, a)).collect();
  73. let mut balance_keys: Vec<(AccountId, AssetId)> = Vec::new();
  74. for p in &consumed_postings {
  75. balance_keys.push((p.owner, p.asset));
  76. }
  77. for np in envelope.creates() {
  78. balance_keys.push((np.owner, np.asset));
  79. }
  80. balance_keys.sort();
  81. balance_keys.dedup();
  82. let mut balances = HashMap::new();
  83. for (account_id, asset_id) in &balance_keys {
  84. let bal = self.compute_balance(account_id, asset_id).await?;
  85. balances.insert((*account_id, *asset_id), bal);
  86. }
  87. // Load the gating book. A missing named (non-default) book is an error;
  88. // a missing default book means "unrestricted" (no policy to enforce).
  89. let book_id = envelope.book();
  90. let book = match self.store.get_book(&book_id).await {
  91. Ok(b) => Some(b),
  92. Err(StoreError::NotFound(_)) if book_id == DEFAULT_BOOK => None,
  93. Err(StoreError::NotFound(_)) => return Err(LedgerError::BookNotFound(book_id)),
  94. Err(e) => return Err(e.into()),
  95. };
  96. Ok(LoadedState {
  97. consumed_postings,
  98. accounts,
  99. balances,
  100. book,
  101. })
  102. }
  103. /// Phase 2: run pure validation and produce a plan.
  104. pub fn plan(
  105. &self,
  106. envelope: &Envelope,
  107. loaded: &LoadedState,
  108. ) -> Result<kuatia_core::Plan, LedgerError> {
  109. let input = PlanInput {
  110. envelope,
  111. consumed_postings: &loaded.consumed_postings,
  112. accounts: &loaded.accounts,
  113. balances: &loaded.balances,
  114. book: loaded.book.as_ref(),
  115. };
  116. Ok(validate_and_plan(input)?)
  117. }
  118. /// Phase 3: apply the plan to storage and return a receipt.
  119. #[instrument(skip(self, envelope, plan), name = "ledger.apply")]
  120. pub async fn apply(
  121. &self,
  122. envelope: &Envelope,
  123. plan: &kuatia_core::Plan,
  124. ) -> Result<Receipt, LedgerError> {
  125. let receipt = Receipt {
  126. transfer_id: plan.transfer_id,
  127. };
  128. // Raw path: consumed postings are Active (never reserved), so pass
  129. // `reservation: None`. Postings, transfer record, account index, and
  130. // event commit atomically.
  131. let events = [LedgerEvent {
  132. seq: 0,
  133. timestamp: now_millis()?,
  134. kind: LedgerEventKind::TransferCommitted {
  135. transfer_id: receipt.transfer_id,
  136. },
  137. }];
  138. self.store
  139. .commit_transfer(CommitRequest {
  140. deactivate: &plan.postings_to_deactivate,
  141. create: &plan.postings_to_create,
  142. cas_guards: &plan.cas_guards,
  143. reservation: None,
  144. record: EnvelopeRecord {
  145. envelope: envelope.clone(),
  146. receipt: receipt.clone(),
  147. created_at: now_millis()?,
  148. },
  149. events: &events,
  150. })
  151. .await?;
  152. Ok(receipt)
  153. }
  154. // -----------------------------------------------------------------------
  155. // Resolve: Transfer (intent) -> Envelope (concrete postings)
  156. // -----------------------------------------------------------------------
  157. /// Convert a [`Transfer`] intent into a concrete [`Envelope`] by selecting
  158. /// postings for each movement and computing change.
  159. ///
  160. /// Pass 1: create output postings and aggregate net debits per (account, asset).
  161. /// Pass 2: for each pair with a positive net debit, select postings and compute change.
  162. #[instrument(skip(self, transfer), name = "ledger.resolve")]
  163. pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
  164. let mut consumes: Vec<PostingId> = Vec::new();
  165. let mut creates: Vec<NewPosting> = Vec::new();
  166. let mut net_debits: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
  167. // Pass 1: output postings + debit aggregation
  168. for m in &transfer.movements {
  169. let payer = if m.from != m.to { Some(m.from) } else { None };
  170. creates.push(NewPosting {
  171. owner: m.to,
  172. asset: m.asset,
  173. value: m.amount,
  174. payer,
  175. });
  176. let entry = net_debits.entry((m.from, m.asset)).or_insert(Cent::ZERO);
  177. *entry = entry.checked_add(m.amount)?;
  178. }
  179. // Pass 2: posting selection for accounts with positive net debit
  180. for ((account, asset), net_debit) in &net_debits {
  181. if !net_debit.is_positive() {
  182. continue;
  183. }
  184. let available = self
  185. .store
  186. .get_postings_by_account(account, Some(asset), Some(PostingStatus::Active))
  187. .await?;
  188. let total_positive = Cent::checked_sum(
  189. available.iter().filter(|p| p.value.is_positive()).map(|p| p.value),
  190. )?;
  191. if total_positive >= *net_debit {
  192. // Enough positive postings: select a subset and compute change.
  193. let selected = select_postings(&available, *asset, *net_debit)?;
  194. let consumed_sum = Cent::checked_sum(
  195. available
  196. .iter()
  197. .filter(|p| selected.contains(&p.id))
  198. .map(|p| p.value),
  199. )?;
  200. let change = consumed_sum.checked_sub(*net_debit)?;
  201. consumes.extend_from_slice(&selected);
  202. if change.is_positive() {
  203. creates.push(NewPosting {
  204. owner: *account,
  205. asset: *asset,
  206. value: change,
  207. payer: None,
  208. });
  209. }
  210. } else {
  211. // Not enough positive postings. Overdraft accounts cover the
  212. // shortfall with a negative posting (an offset position); the
  213. // floor is enforced later in validation. Any other policy fails.
  214. let policy = self.store.get_account(account).await?.policy;
  215. match policy {
  216. AccountPolicy::CappedOverdraft { .. } | AccountPolicy::UncappedOverdraft => {
  217. let positives: Vec<PostingId> = available
  218. .iter()
  219. .filter(|p| p.value.is_positive())
  220. .map(|p| p.id)
  221. .collect();
  222. consumes.extend_from_slice(&positives);
  223. let shortfall = net_debit.checked_sub(total_positive)?;
  224. creates.push(NewPosting {
  225. owner: *account,
  226. asset: *asset,
  227. value: shortfall.checked_neg()?,
  228. payer: None,
  229. });
  230. }
  231. _ => {
  232. return Err(LedgerError::Selection(SelectionError::InsufficientFunds {
  233. available: total_positive,
  234. requested: *net_debit,
  235. }));
  236. }
  237. }
  238. }
  239. }
  240. let mut envelope = EnvelopeBuilder::new()
  241. .consumes(consumes)
  242. .creates(creates)
  243. .book(transfer.book)
  244. .user_data(transfer.user_data.clone())
  245. .metadata(transfer.metadata.clone())
  246. .build();
  247. // Resolve account snapshots for optimistic concurrency
  248. let ids = envelope.referenced_accounts();
  249. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  250. Ok(envelope)
  251. }
  252. // -----------------------------------------------------------------------
  253. // Atomic commit (raw path -- used by reverse() and internal callers)
  254. // -----------------------------------------------------------------------
  255. /// Load, validate, and apply an envelope in one shot (no saga).
  256. #[instrument(skip(self, envelope), name = "ledger.commit_atomic")]
  257. pub async fn commit_atomic(&self, mut envelope: Envelope) -> Result<Receipt, LedgerError> {
  258. if envelope.account_snapshots().is_empty() {
  259. let mut ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  260. ids.sort();
  261. ids.dedup();
  262. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  263. }
  264. let tid = envelope_id(&envelope);
  265. if let Some(record) = self.store.get_transfer(&tid).await? {
  266. return Ok(record.receipt);
  267. }
  268. let loaded = self.load(&envelope).await?;
  269. let plan = self.plan(&envelope, &loaded)?;
  270. self.apply(&envelope, &plan).await
  271. }
  272. // -----------------------------------------------------------------------
  273. // Saga commit: resolve -> reserve -> validate -> finalize (via legend)
  274. // -----------------------------------------------------------------------
  275. /// Commit a transfer intent using the saga pipeline driven by legend.
  276. ///
  277. /// Steps: resolve movements into envelope -> reserve consumed postings ->
  278. /// validate -> finalize.
  279. /// On failure, legend compensates completed steps in reverse order.
  280. #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
  281. pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
  282. let saga = TransferSaga::new(TransferSagaInputs {
  283. resolve: ResolveInput {
  284. transfer: transfer.clone(),
  285. },
  286. reserve: ReserveInput,
  287. validate: ValidateInput,
  288. finalize: FinalizeInput,
  289. });
  290. let ctx = LedgerCtx::new(Arc::clone(self));
  291. let execution = saga.build(ctx);
  292. match execution.start().await {
  293. ExecutionResult::Completed(e) => {
  294. let ctx = e.into_context();
  295. ctx.receipts.last().cloned().ok_or_else(|| {
  296. LedgerError::Store(kuatia_storage::error::StoreError::Internal(
  297. "saga completed but no receipt".into(),
  298. ))
  299. })
  300. }
  301. ExecutionResult::Failed(_, err) => Err(LedgerError::Store(
  302. kuatia_storage::error::StoreError::Internal(err.message),
  303. )),
  304. ExecutionResult::CompensationFailed {
  305. original_error,
  306. compensation_error,
  307. ..
  308. } => Err(LedgerError::CompensationFailed {
  309. original: Box::new(LedgerError::Store(
  310. kuatia_storage::error::StoreError::Internal(original_error.message),
  311. )),
  312. compensation: Box::new(LedgerError::Store(
  313. kuatia_storage::error::StoreError::Internal(compensation_error.message),
  314. )),
  315. }),
  316. ExecutionResult::Paused(_) => Err(LedgerError::Store(
  317. kuatia_storage::error::StoreError::Internal("saga paused unexpectedly".into()),
  318. )),
  319. }
  320. }
  321. // -----------------------------------------------------------------------
  322. // Reverse
  323. // -----------------------------------------------------------------------
  324. /// Create and commit a reversal envelope for the given envelope id.
  325. #[instrument(skip(self), name = "ledger.reverse")]
  326. pub async fn reverse(&self, id: &EnvelopeId) -> Result<Receipt, LedgerError> {
  327. let record = self
  328. .store
  329. .get_transfer(id)
  330. .await?
  331. .ok_or(LedgerError::TransferNotFound(*id))?;
  332. let original = &record.envelope;
  333. let created_posting_ids: Vec<PostingId> = original
  334. .creates()
  335. .iter()
  336. .enumerate()
  337. .map(|(i, _)| PostingId {
  338. transfer: record.receipt.transfer_id,
  339. index: i as u16,
  340. })
  341. .collect();
  342. let original_consumed = if original.consumes().is_empty() {
  343. vec![]
  344. } else {
  345. self.store.get_postings(original.consumes()).await?
  346. };
  347. let new_postings: Vec<NewPosting> = original_consumed
  348. .iter()
  349. .map(|p| NewPosting {
  350. owner: p.owner,
  351. asset: p.asset,
  352. value: p.value,
  353. payer: None,
  354. })
  355. .collect();
  356. let reverse_envelope = EnvelopeBuilder::new()
  357. .consumes(created_posting_ids)
  358. .creates(new_postings)
  359. .book(original.book())
  360. .metadata(original.metadata().clone())
  361. .build();
  362. self.commit_atomic(reverse_envelope).await
  363. }
  364. // -----------------------------------------------------------------------
  365. // Internal: resolve account snapshots
  366. // -----------------------------------------------------------------------
  367. /// Compute balance from non-Inactive postings for an account/asset pair.
  368. async fn compute_balance(
  369. &self,
  370. account: &AccountId,
  371. asset: &AssetId,
  372. ) -> Result<Cent, LedgerError> {
  373. let postings = self
  374. .store
  375. .get_postings_by_account(account, Some(asset), None)
  376. .await?;
  377. Ok(Cent::checked_sum(
  378. postings
  379. .iter()
  380. .filter(|p| p.status != PostingStatus::Inactive)
  381. .map(|p| p.value),
  382. )?)
  383. }
  384. async fn resolve_snapshots(
  385. &self,
  386. ids: &[AccountId],
  387. ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
  388. let accounts = self.store.get_accounts(ids).await?;
  389. Ok(accounts.iter().map(account_snapshot_id).collect())
  390. }
  391. // -----------------------------------------------------------------------
  392. // Account lifecycle
  393. // -----------------------------------------------------------------------
  394. /// Freeze an account, preventing all transfers.
  395. #[instrument(skip(self), name = "ledger.freeze")]
  396. pub async fn freeze(&self, id: &AccountId) -> Result<(), LedgerError> {
  397. let current = self
  398. .store
  399. .get_account(id)
  400. .await
  401. .map_err(|_| LedgerError::AccountNotFound(*id))?;
  402. if current.is_closed() {
  403. return Err(LedgerError::AccountAlreadyClosed(*id));
  404. }
  405. let mut next = current.clone();
  406. next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
  407. next.flags |= kuatia_core::AccountFlags::FROZEN;
  408. self.store.append_account_version(next).await?;
  409. let _ = self
  410. .store
  411. .append_event(&LedgerEvent {
  412. seq: 0,
  413. timestamp: now_millis()?,
  414. kind: LedgerEventKind::AccountFrozen { account_id: *id },
  415. })
  416. .await;
  417. Ok(())
  418. }
  419. /// Unfreeze a previously frozen account.
  420. #[instrument(skip(self), name = "ledger.unfreeze")]
  421. pub async fn unfreeze(&self, id: &AccountId) -> Result<(), LedgerError> {
  422. let current = self
  423. .store
  424. .get_account(id)
  425. .await
  426. .map_err(|_| LedgerError::AccountNotFound(*id))?;
  427. if current.is_closed() {
  428. return Err(LedgerError::AccountAlreadyClosed(*id));
  429. }
  430. let mut next = current.clone();
  431. next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
  432. next.flags.remove(kuatia_core::AccountFlags::FROZEN);
  433. self.store.append_account_version(next).await?;
  434. let _ = self
  435. .store
  436. .append_event(&LedgerEvent {
  437. seq: 0,
  438. timestamp: now_millis()?,
  439. kind: LedgerEventKind::AccountUnfrozen { account_id: *id },
  440. })
  441. .await;
  442. Ok(())
  443. }
  444. /// Close an account. Must have no active postings.
  445. #[instrument(skip(self), name = "ledger.close")]
  446. pub async fn close(&self, id: &AccountId) -> Result<(), LedgerError> {
  447. let current = self
  448. .store
  449. .get_account(id)
  450. .await
  451. .map_err(|_| LedgerError::AccountNotFound(*id))?;
  452. if current.is_closed() {
  453. return Err(LedgerError::AccountAlreadyClosed(*id));
  454. }
  455. let has_active = !self
  456. .store
  457. .get_postings_by_account(id, None, Some(PostingStatus::Active))
  458. .await?
  459. .is_empty();
  460. if has_active {
  461. return Err(LedgerError::AccountNotEmpty(*id));
  462. }
  463. let mut next = current.clone();
  464. next.version = next.version.checked_add(1).ok_or(LedgerError::Overflow)?;
  465. next.flags |= kuatia_core::AccountFlags::CLOSED;
  466. next.flags.remove(kuatia_core::AccountFlags::FROZEN);
  467. self.store.append_account_version(next).await?;
  468. let _ = self
  469. .store
  470. .append_event(&LedgerEvent {
  471. seq: 0,
  472. timestamp: now_millis()?,
  473. kind: LedgerEventKind::AccountClosed { account_id: *id },
  474. })
  475. .await;
  476. Ok(())
  477. }
  478. /// Query the current balance of an account for a given asset.
  479. #[instrument(skip(self), name = "ledger.balance")]
  480. pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
  481. self.compute_balance(account, asset).await
  482. }
  483. // -----------------------------------------------------------------------
  484. // Query layer
  485. // -----------------------------------------------------------------------
  486. /// List all accounts (latest version of each).
  487. pub async fn list_accounts(&self) -> Result<Vec<kuatia_core::Account>, LedgerError> {
  488. Ok(self.store.list_accounts().await?)
  489. }
  490. /// Fetch a single account by id.
  491. pub async fn get_account(&self, id: &AccountId) -> Result<kuatia_core::Account, LedgerError> {
  492. self.store
  493. .get_account(id)
  494. .await
  495. .map_err(|_| LedgerError::AccountNotFound(*id))
  496. }
  497. /// Return all transfers involving the given account.
  498. pub async fn history(
  499. &self,
  500. account: &AccountId,
  501. ) -> Result<Vec<crate::store::EnvelopeRecord>, LedgerError> {
  502. Ok(self.store.get_transfers_for_account(account).await?)
  503. }
  504. /// Query transfers with filtering and pagination.
  505. pub async fn query_transfers(
  506. &self,
  507. query: &crate::store::TransferQuery,
  508. ) -> Result<crate::store::Page<crate::store::EnvelopeRecord>, LedgerError> {
  509. Ok(self.store.query_transfers(query).await?)
  510. }
  511. /// Return all postings (any status) for the given account.
  512. pub async fn postings(
  513. &self,
  514. account: &AccountId,
  515. ) -> Result<Vec<kuatia_core::Posting>, LedgerError> {
  516. Ok(self
  517. .store
  518. .get_postings_by_account(account, None, None)
  519. .await?)
  520. }
  521. /// Query postings with filtering and pagination.
  522. pub async fn query_postings(
  523. &self,
  524. query: &crate::store::PostingQuery,
  525. ) -> Result<crate::store::Page<kuatia_core::Posting>, LedgerError> {
  526. Ok(self.store.query_postings(query).await?)
  527. }
  528. /// Return the full version history for an account.
  529. pub async fn account_history(
  530. &self,
  531. id: &AccountId,
  532. ) -> Result<Vec<kuatia_core::Account>, LedgerError> {
  533. Ok(self.store.get_account_history(id).await?)
  534. }
  535. /// Create a new account and emit an AccountCreated event.
  536. pub async fn create_account(&self, account: kuatia_core::Account) -> Result<(), LedgerError> {
  537. let id = account.id;
  538. self.store.create_account(account).await?;
  539. let _ = self
  540. .store
  541. .append_event(&LedgerEvent {
  542. seq: 0,
  543. timestamp: now_millis()?,
  544. kind: LedgerEventKind::AccountCreated { account_id: id },
  545. })
  546. .await;
  547. Ok(())
  548. }
  549. /// Create a new book.
  550. pub async fn create_book(
  551. &self,
  552. book: kuatia_core::Book,
  553. ) -> Result<(), LedgerError> {
  554. Ok(self.store.create_book(book).await?)
  555. }
  556. /// Fetch a book by id.
  557. pub async fn get_book(
  558. &self,
  559. id: &kuatia_core::BookId,
  560. ) -> Result<kuatia_core::Book, LedgerError> {
  561. Ok(self.store.get_book(id).await?)
  562. }
  563. /// List all books.
  564. pub async fn list_books(&self) -> Result<Vec<kuatia_core::Book>, LedgerError> {
  565. Ok(self.store.list_books().await?)
  566. }
  567. /// Query ledger events after a given sequence number.
  568. pub async fn get_events_since(
  569. &self,
  570. after_seq: u64,
  571. limit: u32,
  572. ) -> Result<Vec<LedgerEvent>, LedgerError> {
  573. Ok(self.store.get_events_since(after_seq, limit).await?)
  574. }
  575. }
  576. /// State loaded in phase 1, passed to the pure validation in phase 2.
  577. pub struct LoadedState {
  578. /// Postings being consumed by the envelope.
  579. pub consumed_postings: Vec<Posting>,
  580. /// Accounts referenced by the envelope.
  581. pub accounts: HashMap<AccountId, kuatia_core::Account>,
  582. /// Current balances for all referenced (account, asset) pairs.
  583. pub balances: HashMap<(AccountId, AssetId), Cent>,
  584. /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
  585. pub book: Option<Book>,
  586. }