inflight.rs 13 KB

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