inflight.rs 13 KB

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