inflight.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. //! Integration tests for inflight holds (authorize / confirm / void).
  2. //!
  3. //! The running example is the ADR's confirmed trade between A and B with a fee
  4. //! account, spanning two assets:
  5. //!
  6. //! ```text
  7. //! A -> B -> 100 EUR
  8. //! B -> A -> 10 BTC
  9. //! A -> fee -> 1 BTC
  10. //! B -> fee -> 1 EUR
  11. //! ```
  12. //!
  13. //! Authorized, the funds park in per-destination holding accounts; `fee`'s hold
  14. //! collects EUR from B and BTC from A.
  15. use std::collections::BTreeMap;
  16. use std::sync::Arc;
  17. use kuatia::prelude::*;
  18. fn eur() -> AssetId {
  19. AssetId::new(1)
  20. }
  21. fn btc() -> AssetId {
  22. AssetId::new(2)
  23. }
  24. fn a() -> AccountId {
  25. AccountId::new(1)
  26. }
  27. fn b() -> AccountId {
  28. AccountId::new(2)
  29. }
  30. fn fee() -> AccountId {
  31. AccountId::new(3)
  32. }
  33. fn ext() -> AccountId {
  34. AccountId::new(99)
  35. }
  36. fn make_account(id: i64, policy: AccountPolicy) -> Account {
  37. Account {
  38. id: AccountId::new(id),
  39. version: 1,
  40. policy,
  41. flags: AccountFlags::empty(),
  42. book: BookId(0),
  43. metadata: BTreeMap::new(),
  44. }
  45. }
  46. async fn deposit(ledger: &Arc<Ledger>, to: AccountId, asset: AssetId, amount: i64) {
  47. let t = TransferBuilder::new()
  48. .deposit(to, asset, Cent::from(amount), ext())
  49. .unwrap()
  50. .build();
  51. ledger.commit(t).await.unwrap();
  52. }
  53. /// A ledger with accounts A, B, fee, external; A holds 100 EUR + 1 BTC, B holds
  54. /// 10 BTC + 1 EUR.
  55. async fn setup() -> Arc<Ledger> {
  56. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  57. for id in [1, 2, 3] {
  58. ledger
  59. .store()
  60. .create_account(make_account(id, AccountPolicy::NoOverdraft))
  61. .await
  62. .unwrap();
  63. }
  64. ledger
  65. .store()
  66. .create_account(make_account(99, AccountPolicy::ExternalAccount))
  67. .await
  68. .unwrap();
  69. deposit(&ledger, a(), eur(), 100).await;
  70. deposit(&ledger, a(), btc(), 1).await;
  71. deposit(&ledger, b(), btc(), 10).await;
  72. deposit(&ledger, b(), eur(), 1).await;
  73. ledger
  74. }
  75. fn trade() -> Transfer {
  76. TransferBuilder::new()
  77. .pay(a(), b(), eur(), Cent::from(100))
  78. .pay(b(), a(), btc(), Cent::from(10))
  79. .pay(a(), fee(), btc(), Cent::from(1))
  80. .pay(b(), fee(), eur(), Cent::from(1))
  81. .build()
  82. }
  83. async fn bal(ledger: &Arc<Ledger>, account: AccountId, asset: AssetId) -> Cent {
  84. ledger.balance(&account, &asset).await.unwrap()
  85. }
  86. /// A one-movement confirm set, built with the same `.pay()` interface as a
  87. /// transfer: `from` is the leg's funder, `to` its destination.
  88. fn confirm_one(from: AccountId, to: AccountId, asset: AssetId, amount: i64) -> Transfer {
  89. TransferBuilder::new()
  90. .pay(from, to, asset, Cent::from(amount))
  91. .build()
  92. }
  93. /// After authorize, funds leave the payers and sit in the holds; the payers'
  94. /// balances drop to zero and nothing has reached the destinations yet.
  95. #[tokio::test]
  96. async fn authorize_parks_funds_in_holds() {
  97. let ledger = setup().await;
  98. let auth = ledger.authorize(trade()).await.unwrap();
  99. // Payers emptied.
  100. assert_eq!(bal(&ledger, a(), eur()).await, Cent::ZERO);
  101. assert_eq!(bal(&ledger, a(), btc()).await, Cent::ZERO);
  102. assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
  103. assert_eq!(bal(&ledger, b(), btc()).await, Cent::ZERO);
  104. // Destinations untouched.
  105. assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
  106. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
  107. // Three holds are open, and status reports everything Held.
  108. assert_eq!(ledger.list_open_inflights().await.unwrap().len(), 3);
  109. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  110. assert_eq!(status.state, InflightState::Held);
  111. let total_held: Cent = Cent::checked_sum(status.legs.iter().map(|l| l.held)).unwrap();
  112. let total_auth: Cent = Cent::checked_sum(status.legs.iter().map(|l| l.authorized)).unwrap();
  113. assert_eq!(total_held, total_auth);
  114. }
  115. /// Confirming the whole transaction settles every leg to its destination and
  116. /// closes the holds. The net result equals the original trade.
  117. #[tokio::test]
  118. async fn confirm_all_settles_to_destinations() {
  119. let ledger = setup().await;
  120. let auth = ledger.authorize(trade()).await.unwrap();
  121. ledger.confirm_all(&auth.inflight).await.unwrap();
  122. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
  123. assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(10));
  124. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::from(1));
  125. assert_eq!(bal(&ledger, fee(), btc()).await, Cent::from(1));
  126. // Holds drained and closed.
  127. assert!(ledger.list_open_inflights().await.unwrap().is_empty());
  128. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  129. assert_eq!(status.state, InflightState::Confirmed);
  130. }
  131. /// Voiding returns every held posting to the funder recorded in the leg table,
  132. /// including the multi-asset fee hold funded by two different accounts.
  133. #[tokio::test]
  134. async fn void_returns_funds_to_funders() {
  135. let ledger = setup().await;
  136. let auth = ledger.authorize(trade()).await.unwrap();
  137. ledger.void(&auth.inflight).await.unwrap();
  138. // Everyone is back where they started.
  139. assert_eq!(bal(&ledger, a(), eur()).await, Cent::from(100));
  140. assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(1));
  141. assert_eq!(bal(&ledger, b(), btc()).await, Cent::from(10));
  142. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(1));
  143. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
  144. assert_eq!(bal(&ledger, fee(), btc()).await, Cent::ZERO);
  145. assert!(ledger.list_open_inflights().await.unwrap().is_empty());
  146. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  147. assert_eq!(status.state, InflightState::Voided);
  148. }
  149. /// A partial confirm delivers a slice and leaves the remainder held. Confirming
  150. /// the rest drains and closes the hold.
  151. #[tokio::test]
  152. async fn partial_confirm_then_confirm_remainder() {
  153. let ledger = setup().await;
  154. let auth = ledger.authorize(trade()).await.unwrap();
  155. ledger
  156. .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 40))
  157. .await
  158. .unwrap();
  159. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(40));
  160. // The B/EUR leg is partially confirmed.
  161. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  162. let leg = status
  163. .legs
  164. .iter()
  165. .find(|l| l.destination.id == b().id && l.asset == eur())
  166. .unwrap();
  167. assert_eq!(leg.authorized, Cent::from(100));
  168. assert_eq!(leg.confirmed, Cent::from(40));
  169. assert_eq!(leg.held, Cent::from(60));
  170. assert_eq!(status.state, InflightState::PartiallyConfirmed);
  171. // Confirm the rest.
  172. ledger
  173. .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 60))
  174. .await
  175. .unwrap();
  176. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
  177. // The B hold is now closed (its only asset drained).
  178. assert!(
  179. !ledger
  180. .list_open_inflights()
  181. .await
  182. .unwrap()
  183. .contains(&leg.hold)
  184. );
  185. }
  186. /// A partial confirm followed by a void: the slice reaches the destination and
  187. /// the remainder returns to the funder.
  188. #[tokio::test]
  189. async fn partial_confirm_then_void_remainder() {
  190. let ledger = setup().await;
  191. let auth = ledger.authorize(trade()).await.unwrap();
  192. ledger
  193. .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 40))
  194. .await
  195. .unwrap();
  196. ledger.void(&auth.inflight).await.unwrap();
  197. // B kept the confirmed 40 EUR from its own hold, and got its 1 EUR fee
  198. // contribution back from the (now voided) fee hold: 41 total. A got the
  199. // remaining 60 EUR of B's hold back.
  200. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(41));
  201. assert_eq!(bal(&ledger, a(), eur()).await, Cent::from(60));
  202. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  203. let leg = status
  204. .legs
  205. .iter()
  206. .find(|l| l.destination.id == b().id && l.asset == eur())
  207. .unwrap();
  208. assert_eq!(leg.confirmed, Cent::from(40));
  209. assert_eq!(leg.voided, Cent::from(60));
  210. assert_eq!(leg.held, Cent::ZERO);
  211. assert_eq!(status.state, InflightState::Mixed);
  212. }
  213. /// Confirming more than is held is rejected. The `NoOverdraft` hold makes
  214. /// over-confirmation impossible.
  215. #[tokio::test]
  216. async fn over_confirm_is_rejected() {
  217. let ledger = setup().await;
  218. let auth = ledger.authorize(trade()).await.unwrap();
  219. let err = ledger
  220. .confirm(&auth.inflight, confirm_one(a(), b(), eur(), 101))
  221. .await
  222. .unwrap_err();
  223. assert!(matches!(err, LedgerError::Selection(_)));
  224. // Nothing moved.
  225. assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
  226. }
  227. /// A single confirm call settles several legs at once, built with the same
  228. /// `.pay()` interface as a transfer.
  229. #[tokio::test]
  230. async fn batch_confirm_multiple_legs() {
  231. let ledger = setup().await;
  232. let auth = ledger.authorize(trade()).await.unwrap();
  233. // Confirm B's EUR leg and A's BTC leg in one call.
  234. let confirms = TransferBuilder::new()
  235. .pay(a(), b(), eur(), Cent::from(100))
  236. .pay(b(), a(), btc(), Cent::from(10))
  237. .build();
  238. let receipts = ledger.confirm(&auth.inflight, confirms).await.unwrap();
  239. assert_eq!(receipts.len(), 2);
  240. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
  241. assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(10));
  242. // The fee hold is untouched, so it is still open.
  243. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
  244. assert_eq!(bal(&ledger, fee(), btc()).await, Cent::ZERO);
  245. assert_eq!(ledger.list_open_inflights().await.unwrap().len(), 1);
  246. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  247. assert_eq!(status.state, InflightState::PartiallyConfirmed);
  248. }
  249. /// Confirming a movement whose `(from, to, asset)` matches no leg is rejected.
  250. #[tokio::test]
  251. async fn confirm_unknown_leg_is_rejected() {
  252. let ledger = setup().await;
  253. let auth = ledger.authorize(trade()).await.unwrap();
  254. // fee never funded a BTC leg to B.
  255. let err = ledger
  256. .confirm(&auth.inflight, confirm_one(fee(), b(), btc(), 1))
  257. .await
  258. .unwrap_err();
  259. assert!(matches!(err, LedgerError::InflightLegNotFound { .. }));
  260. }
  261. /// A destination can hold several concurrent inflights (one per distinct trade,
  262. /// each under its own subaccount), but the *same* trade cannot be authorized
  263. /// twice while open (its holds already exist).
  264. #[tokio::test]
  265. async fn concurrent_inflights_per_account() {
  266. let ledger = setup().await;
  267. let auth = ledger.authorize(trade()).await.unwrap();
  268. // Re-authorizing the identical trade collides on the derived hold subaccount.
  269. let err = ledger.authorize(trade()).await.unwrap_err();
  270. assert!(matches!(err, LedgerError::InflightAlreadyOpen(_)));
  271. // A different trade to the same destination B opens a second, independent
  272. // inflight under a different subaccount.
  273. deposit(&ledger, a(), eur(), 10).await;
  274. let other = TransferBuilder::new()
  275. .pay(a(), b(), eur(), Cent::from(10))
  276. .build();
  277. let auth2 = ledger.authorize(other).await.unwrap();
  278. assert_ne!(auth.inflight, auth2.inflight);
  279. // Both are open at once: B has two inflight holds under distinct subaccounts.
  280. let b_holds = ledger
  281. .list_subaccounts(&b())
  282. .await
  283. .unwrap()
  284. .into_iter()
  285. .filter(|r| r.sub != 0)
  286. .count();
  287. assert_eq!(b_holds, 2);
  288. }
  289. /// After a full confirm closes the holds, a fresh inflight to the same
  290. /// destinations is allowed again.
  291. #[tokio::test]
  292. async fn reauthorize_after_settlement() {
  293. let ledger = setup().await;
  294. let auth = ledger.authorize(trade()).await.unwrap();
  295. ledger.confirm_all(&auth.inflight).await.unwrap();
  296. // B now holds 100 EUR; authorize a new hold of 30 of it to fee.
  297. let again = TransferBuilder::new()
  298. .pay(b(), fee(), eur(), Cent::from(30))
  299. .build();
  300. let auth2 = ledger.authorize(again).await.unwrap();
  301. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(70));
  302. ledger.confirm_all(&auth2.inflight).await.unwrap();
  303. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::from(31));
  304. }
  305. /// Operating on a non-inflight or unknown transfer id is a clean error.
  306. #[tokio::test]
  307. async fn unknown_inflight_is_an_error() {
  308. let ledger = setup().await;
  309. let bogus = EnvelopeId([7u8; 32]);
  310. assert!(matches!(
  311. ledger.confirm_all(&bogus).await.unwrap_err(),
  312. LedgerError::InflightNotFound(_)
  313. ));
  314. }
  315. /// Balances are always segregated by subaccount: the account query lists the
  316. /// main subaccount and each open hold separately, never summed.
  317. #[tokio::test]
  318. async fn balances_are_segregated_by_subaccount() {
  319. let ledger = setup().await;
  320. let _auth = ledger.authorize(trade()).await.unwrap();
  321. // B's EUR across subaccounts: the main (0, now empty) and its inflight hold.
  322. let all = ledger.balances(&b(), &eur(), None).await.unwrap();
  323. let main = all.iter().find(|e| e.account.sub == 0).unwrap();
  324. assert_eq!(main.value, Cent::ZERO); // B's own 1 EUR went into the fee hold
  325. let hold = all.iter().find(|e| e.account.sub != 0).unwrap();
  326. assert_eq!(hold.value, Cent::from(100)); // A's 100 EUR parked for B
  327. assert_eq!(all.len(), 2);
  328. // Filtering to the main subaccount returns only it (still segregated form).
  329. let only_main = ledger.balances(&b(), &eur(), Some(0)).await.unwrap();
  330. assert_eq!(only_main.len(), 1);
  331. assert_eq!(only_main[0].account.sub, 0);
  332. }