inflight.rs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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. #![allow(missing_docs)]
  16. use std::collections::BTreeMap;
  17. use std::sync::Arc;
  18. use kuatia::prelude::*;
  19. fn eur() -> AssetId {
  20. AssetId::new(1)
  21. }
  22. fn btc() -> AssetId {
  23. AssetId::new(2)
  24. }
  25. fn a() -> AccountId {
  26. AccountId::new(1)
  27. }
  28. fn b() -> AccountId {
  29. AccountId::new(2)
  30. }
  31. fn fee() -> AccountId {
  32. AccountId::new(3)
  33. }
  34. fn ext() -> AccountId {
  35. AccountId::new(99)
  36. }
  37. fn make_account(id: i64, policy: AccountPolicy) -> Account {
  38. Account {
  39. id: AccountId::new(id),
  40. version: 1,
  41. policy,
  42. flags: AccountFlags::empty(),
  43. book: BookId(0),
  44. user_data: UserData::default(),
  45. metadata: BTreeMap::new(),
  46. }
  47. }
  48. async fn deposit(ledger: &Arc<Ledger>, to: AccountId, asset: AssetId, amount: i64) {
  49. let t = TransferBuilder::new()
  50. .deposit(to, asset, Cent::from(amount), ext())
  51. .unwrap()
  52. .build();
  53. ledger.commit(t).await.unwrap();
  54. }
  55. /// A ledger with accounts A, B, fee, external; A holds 100 EUR + 1 BTC, B holds
  56. /// 10 BTC + 1 EUR.
  57. async fn setup() -> Arc<Ledger> {
  58. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  59. for id in [1, 2, 3] {
  60. ledger
  61. .store()
  62. .create_account(make_account(id, AccountPolicy::NoOverdraft))
  63. .await
  64. .unwrap();
  65. }
  66. ledger
  67. .store()
  68. .create_account(make_account(99, AccountPolicy::ExternalAccount))
  69. .await
  70. .unwrap();
  71. deposit(&ledger, a(), eur(), 100).await;
  72. deposit(&ledger, a(), btc(), 1).await;
  73. deposit(&ledger, b(), btc(), 10).await;
  74. deposit(&ledger, b(), eur(), 1).await;
  75. ledger
  76. }
  77. fn trade() -> Transfer {
  78. TransferBuilder::new()
  79. .pay(a(), b(), eur(), Cent::from(100))
  80. .pay(b(), a(), btc(), Cent::from(10))
  81. .pay(a(), fee(), btc(), Cent::from(1))
  82. .pay(b(), fee(), eur(), Cent::from(1))
  83. .build()
  84. }
  85. async fn bal(ledger: &Arc<Ledger>, account: AccountId, asset: AssetId) -> Cent {
  86. ledger.balance(&account, &asset).await.unwrap()
  87. }
  88. /// After authorize, funds leave the payers and sit in the holds; the payers'
  89. /// balances drop to zero and nothing has reached the destinations yet.
  90. #[tokio::test]
  91. async fn authorize_parks_funds_in_holds() {
  92. let ledger = setup().await;
  93. let auth = ledger.authorize(trade()).await.unwrap();
  94. // Payers emptied.
  95. assert_eq!(bal(&ledger, a(), eur()).await, Cent::ZERO);
  96. assert_eq!(bal(&ledger, a(), btc()).await, Cent::ZERO);
  97. assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
  98. assert_eq!(bal(&ledger, b(), btc()).await, Cent::ZERO);
  99. // Destinations untouched.
  100. assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
  101. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
  102. // Three holds are open, and status reports everything Held.
  103. assert_eq!(ledger.list_open_inflights().await.unwrap().len(), 3);
  104. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  105. assert_eq!(status.state, InflightState::Held);
  106. let total_held: Cent = Cent::checked_sum(status.legs.iter().map(|l| l.held)).unwrap();
  107. let total_auth: Cent = Cent::checked_sum(status.legs.iter().map(|l| l.authorized)).unwrap();
  108. assert_eq!(total_held, total_auth);
  109. }
  110. /// Confirming the whole transaction settles every leg to its destination and
  111. /// closes the holds. The net result equals the original trade.
  112. #[tokio::test]
  113. async fn confirm_all_settles_to_destinations() {
  114. let ledger = setup().await;
  115. let auth = ledger.authorize(trade()).await.unwrap();
  116. ledger.confirm_all(&auth.inflight).await.unwrap();
  117. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
  118. assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(10));
  119. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::from(1));
  120. assert_eq!(bal(&ledger, fee(), btc()).await, Cent::from(1));
  121. // Holds drained and closed.
  122. assert!(ledger.list_open_inflights().await.unwrap().is_empty());
  123. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  124. assert_eq!(status.state, InflightState::Confirmed);
  125. }
  126. /// Voiding returns every held posting to the funder recorded in the leg table,
  127. /// including the multi-asset fee hold funded by two different accounts.
  128. #[tokio::test]
  129. async fn void_returns_funds_to_funders() {
  130. let ledger = setup().await;
  131. let auth = ledger.authorize(trade()).await.unwrap();
  132. ledger.void(&auth.inflight).await.unwrap();
  133. // Everyone is back where they started.
  134. assert_eq!(bal(&ledger, a(), eur()).await, Cent::from(100));
  135. assert_eq!(bal(&ledger, a(), btc()).await, Cent::from(1));
  136. assert_eq!(bal(&ledger, b(), btc()).await, Cent::from(10));
  137. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(1));
  138. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::ZERO);
  139. assert_eq!(bal(&ledger, fee(), btc()).await, Cent::ZERO);
  140. assert!(ledger.list_open_inflights().await.unwrap().is_empty());
  141. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  142. assert_eq!(status.state, InflightState::Voided);
  143. }
  144. /// A partial confirm delivers a slice and leaves the remainder held. Confirming
  145. /// the rest drains and closes the hold.
  146. #[tokio::test]
  147. async fn partial_confirm_then_confirm_remainder() {
  148. let ledger = setup().await;
  149. let auth = ledger.authorize(trade()).await.unwrap();
  150. ledger
  151. .confirm(&auth.inflight, &b(), &eur(), Cent::from(40))
  152. .await
  153. .unwrap();
  154. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(40));
  155. // The B/EUR leg is partially confirmed.
  156. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  157. let leg = status
  158. .legs
  159. .iter()
  160. .find(|l| l.destination == b() && l.asset == eur())
  161. .unwrap();
  162. assert_eq!(leg.authorized, Cent::from(100));
  163. assert_eq!(leg.confirmed, Cent::from(40));
  164. assert_eq!(leg.held, Cent::from(60));
  165. assert_eq!(status.state, InflightState::PartiallyConfirmed);
  166. // Confirm the rest.
  167. ledger
  168. .confirm(&auth.inflight, &b(), &eur(), Cent::from(60))
  169. .await
  170. .unwrap();
  171. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(100));
  172. // The B hold is now closed (its only asset drained).
  173. assert!(
  174. !ledger
  175. .list_open_inflights()
  176. .await
  177. .unwrap()
  178. .contains(&leg.hold)
  179. );
  180. }
  181. /// A partial confirm followed by a void: the slice reaches the destination and
  182. /// the remainder returns to the funder.
  183. #[tokio::test]
  184. async fn partial_confirm_then_void_remainder() {
  185. let ledger = setup().await;
  186. let auth = ledger.authorize(trade()).await.unwrap();
  187. ledger
  188. .confirm(&auth.inflight, &b(), &eur(), Cent::from(40))
  189. .await
  190. .unwrap();
  191. ledger.void(&auth.inflight).await.unwrap();
  192. // B kept the confirmed 40 EUR from its own hold, and got its 1 EUR fee
  193. // contribution back from the (now voided) fee hold: 41 total. A got the
  194. // remaining 60 EUR of B's hold back.
  195. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(41));
  196. assert_eq!(bal(&ledger, a(), eur()).await, Cent::from(60));
  197. let status = ledger.inflight_status(&auth.inflight).await.unwrap();
  198. let leg = status
  199. .legs
  200. .iter()
  201. .find(|l| l.destination == b() && l.asset == eur())
  202. .unwrap();
  203. assert_eq!(leg.confirmed, Cent::from(40));
  204. assert_eq!(leg.voided, Cent::from(60));
  205. assert_eq!(leg.held, Cent::ZERO);
  206. assert_eq!(status.state, InflightState::Mixed);
  207. }
  208. /// Confirming more than is held is rejected. The `NoOverdraft` hold makes
  209. /// over-confirmation impossible.
  210. #[tokio::test]
  211. async fn over_confirm_is_rejected() {
  212. let ledger = setup().await;
  213. let auth = ledger.authorize(trade()).await.unwrap();
  214. let err = ledger
  215. .confirm(&auth.inflight, &b(), &eur(), Cent::from(101))
  216. .await
  217. .unwrap_err();
  218. assert!(matches!(err, LedgerError::Selection(_)));
  219. // Nothing moved.
  220. assert_eq!(bal(&ledger, b(), eur()).await, Cent::ZERO);
  221. }
  222. /// Only one open inflight is allowed per destination account at a time.
  223. #[tokio::test]
  224. async fn one_open_inflight_per_account() {
  225. let ledger = setup().await;
  226. let _auth = ledger.authorize(trade()).await.unwrap();
  227. // A second authorize touching B (an open destination) is rejected.
  228. deposit(&ledger, a(), eur(), 10).await;
  229. let again = TransferBuilder::new()
  230. .pay(a(), b(), eur(), Cent::from(10))
  231. .build();
  232. let err = ledger.authorize(again).await.unwrap_err();
  233. assert!(matches!(err, LedgerError::InflightAlreadyOpen(id) if id == b()));
  234. }
  235. /// After a full confirm closes the holds, a fresh inflight to the same
  236. /// destinations is allowed again.
  237. #[tokio::test]
  238. async fn reauthorize_after_settlement() {
  239. let ledger = setup().await;
  240. let auth = ledger.authorize(trade()).await.unwrap();
  241. ledger.confirm_all(&auth.inflight).await.unwrap();
  242. // B now holds 100 EUR; authorize a new hold of 30 of it to fee.
  243. let again = TransferBuilder::new()
  244. .pay(b(), fee(), eur(), Cent::from(30))
  245. .build();
  246. let auth2 = ledger.authorize(again).await.unwrap();
  247. assert_eq!(bal(&ledger, b(), eur()).await, Cent::from(70));
  248. ledger.confirm_all(&auth2.inflight).await.unwrap();
  249. assert_eq!(bal(&ledger, fee(), eur()).await, Cent::from(31));
  250. }
  251. /// Operating on a non-inflight or unknown transfer id is a clean error.
  252. #[tokio::test]
  253. async fn unknown_inflight_is_an_error() {
  254. let ledger = setup().await;
  255. let bogus = EnvelopeId([7u8; 32]);
  256. assert!(matches!(
  257. ledger.confirm_all(&bogus).await.unwrap_err(),
  258. LedgerError::InflightNotFound(_)
  259. ));
  260. }