#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace xrpl::test { struct FlowMPT_test : public beast::unit_test::Suite { using Accounts = std::vector; void testDirectStep(FeatureBitset features) { testcase("Direct Step"); using namespace jtx; auto const alice = Account("alice"); auto const bob = Account("bob"); auto const carol = Account("carol"); auto const gw = Account("gw"); { // Pay USD, trivial path Env env(*this, features); env.fund(XRP(10000), alice, bob, gw); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}}); env(pay(gw, alice, usd(100))); env(pay(alice, bob, usd(10)), Paths(usd)); env.require(Balance(bob, usd(10))); } { // Partial payments Env env(*this, features); env.fund(XRP(10000), alice, bob, gw); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}}); env(pay(gw, alice, usd(100))); env(pay(alice, bob, usd(110)), Paths(usd), Ter(tecPATH_PARTIAL)); env.require(Balance(bob, usd(0))); env(pay(alice, bob, usd(110)), Paths(usd), Txflags(tfPartialPayment)); env.require(Balance(bob, usd(100))); } { // Limit quality auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this, features); env.fund(XRP(10'000), gw, alice, bob, carol); env.close(); auto const usd = issue1({.env = env, .token = "USD", .issuer = gw, .holders = {alice, carol}}); auto const eur = issue2({.env = env, .token = "EUR", .issuer = gw, .holders = {bob}}); env(pay(gw, alice, usd(100))); env(pay(gw, bob, eur(100))); env(offer(alice, eur(4), usd(4))); env.close(); env(pay(bob, carol, usd(5)), Sendmax(eur(4)), Txflags(tfLimitQuality | tfPartialPayment), Ter(tecPATH_DRY)); env.require(Balance(carol, usd(0))); env(pay(bob, carol, usd(5)), Sendmax(eur(4)), Txflags(tfPartialPayment)); env.require(Balance(carol, usd(4))); }; testHelper2TokensMix(test); } } void testBookStep(FeatureBitset features) { testcase("Book Step"); using namespace jtx; auto const gw = Account("gateway"); Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); { // simple [MPT|IOU]/[IOU|MPT] offer auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob, carol}}); auto const btc = issue2( {.env = env, .token = "BTC", .issuer = gw, .holders = {alice, bob, carol}}); env(pay(gw, alice, btc(50))); env(pay(gw, bob, usd(50))); env(offer(bob, btc(50), usd(50))); env(pay(alice, carol, usd(50)), Path(~usd), Sendmax(btc(50))); env.require(Balance(alice, btc(0))); env.require(Balance(bob, btc(50))); env.require(Balance(bob, usd(0))); env.require(Balance(carol, usd(50))); BEAST_EXPECT(!isOffer(env, bob, btc(50), usd(50))); }; testHelper2TokensMix(test); } { // simple [MPT|IOU]/XRP XRP/[IOU|MPT] offer auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob, carol}}); auto const btc = issue2( {.env = env, .token = "BTC", .issuer = gw, .holders = {alice, bob, carol}}); env(pay(gw, alice, btc(50))); env(pay(gw, bob, usd(50))); env(offer(bob, btc(50), XRP(50))); env(offer(bob, XRP(50), usd(50))); env(pay(alice, carol, usd(50)), Path(~XRP, ~usd), Sendmax(btc(50))); env.require(Balance(alice, btc(0))); env.require(Balance(bob, btc(50))); env.require(Balance(bob, usd(0))); env.require(Balance(carol, usd(50))); BEAST_EXPECT(!isOffer(env, bob, XRP(50), usd(50))); BEAST_EXPECT(!isOffer(env, bob, btc(50), XRP(50))); }; testHelper2TokensMix(test); } { // simple XRP -> USD through offer and sendmax Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); env.close(); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}}); MPT const btc = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}}); env(pay(gw, bob, usd(50))); env(offer(bob, XRP(50), usd(50))); env(pay(alice, carol, usd(50)), Path(~usd), Sendmax(XRP(50))); // fee: MPTokenAuthorize * 2(EUR, USD) + pay env.require(Balance(alice, XRP(10'000 - 50) - txFee(env, 3))); // fee: MPTokenAuthorize * 2(EUR, USD) + offer env.require(Balance(bob, XRP(10'000 + 50) - txFee(env, 3))); env.require(Balance(bob, usd(0))); env.require(Balance(carol, usd(50))); BEAST_EXPECT(!isOffer(env, bob, XRP(50), usd(50))); } { // simple USD -> XRP through offer and sendmax Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); env.close(); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}}); MPT const btc = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}}); env(pay(gw, alice, usd(50))); env(offer(bob, usd(50), XRP(50))); env(pay(alice, carol, XRP(50)), Path(~XRP), Sendmax(usd(50))); env.require(Balance(alice, usd(0))); env.require(Balance(bob, XRP(10'000 - 50) - txFee(env, 3))); env.require(Balance(bob, usd(50))); env.require(Balance(carol, XRP(10'000 + 50) - txFee(env, 2))); BEAST_EXPECT(!isOffer(env, bob, usd(50), XRP(50))); } { // test unfunded offers are removed when payment succeeds auto test = [&](auto&& issue1, auto&& issue2, auto&& issue3) { Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob, carol}}); auto const btc = issue2( {.env = env, .token = "BTC", .issuer = gw, .holders = {alice, bob, carol}}); auto const eur = issue3( {.env = env, .token = "EUR", .issuer = gw, .holders = {alice, bob, carol}}); env(pay(gw, alice, btc(60))); env(pay(gw, bob, usd(50))); env(pay(gw, bob, eur(50))); env(offer(bob, btc(50), usd(50))); env(offer(bob, btc(40), eur(50))); env(offer(bob, eur(50), usd(50))); // unfund offer env(pay(bob, gw, eur(50))); env.require(Balance(bob, eur(0))); BEAST_EXPECT(isOffer(env, bob, btc(50), usd(50))); BEAST_EXPECT(isOffer(env, bob, btc(40), eur(50))); BEAST_EXPECT(isOffer(env, bob, eur(50), usd(50))); env(pay(alice, carol, usd(50)), Path(~usd), Path(~eur, ~usd), Sendmax(btc(60))); env.require(Balance(alice, btc(10))); env.require(Balance(bob, btc(50))); env.require(Balance(bob, usd(0))); env.require(Balance(bob, eur(0))); env.require(Balance(carol, usd(50))); // used in the payment BEAST_EXPECT(!isOffer(env, bob, btc(50), usd(50))); // found unfunded BEAST_EXPECT(!isOffer(env, bob, btc(40), eur(50))); // unfunded, but should not yet be found unfunded BEAST_EXPECT(isOffer(env, bob, eur(50), usd(50))); }; testHelper3TokensMix(test); } { // test unfunded offers are returned when the payment fails. // bob makes two offers: a funded 5000 USD for 50 BTC and an // unfunded 5000 EUR for 60 BTC. alice pays carol 6100 USD with 61 // BTC. alice only has 60 BTC, so the payment will fail. The payment // uses two paths: one through bob's funded offer and one through // his unfunded offer. When the payment fails `flow` should return // the unfunded offer. This test is intentionally similar to the one // that removes unfunded offers when the payment succeeds. auto test = [&](auto&& issue1, auto&& issue2, auto&& issue3) { Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob, carol}, .limit = 100'000}); auto const btc = issue2( {.env = env, .token = "BTC", .issuer = gw, .holders = {alice, bob, carol}, .limit = 100'000}); auto const eur = issue3( {.env = env, .token = "EUR", .issuer = gw, .holders = {alice, bob, carol}, .limit = 100'000}); env(pay(gw, alice, btc(60))); env(pay(gw, bob, usd(6'000))); env(pay(gw, bob, eur(5'000))); env(pay(gw, carol, eur(100))); env(offer(bob, btc(50), usd(5'000))); env(offer(bob, btc(60), eur(5'000))); env(offer(carol, btc(1'000), eur(100))); env(offer(bob, eur(5'000), usd(5'000))); // unfund offer env(pay(bob, gw, eur(5'000))); BEAST_EXPECT(isOffer(env, bob, btc(50), usd(5'000))); BEAST_EXPECT(isOffer(env, bob, btc(60), eur(5'000))); BEAST_EXPECT(isOffer(env, carol, btc(1'000), eur(100))); auto flowJournal = env.app().getLogs().journal("Flow"); auto const flowResult = [&] { STAmount const deliver(usd(5'100)); STAmount smax(btc(61)); PaymentSandbox sb(env.current().get(), TapNone); STPathSet paths; auto ipe = [](Asset const& asset) { return STPathElement( STPathElement::TypeAsset | STPathElement::TypeIssuer, xrpAccount(), asset, asset.getIssuer()); }; { // BTC -> USD STPath const p1({ipe(usd)}); paths.pushBack(p1); // BTC -> EUR -> USD STPath const p2({ipe(eur), ipe(usd)}); paths.pushBack(p2); } return flow( sb, deliver, alice, carol, paths, false, false, true, OfferCrossing::No, std::nullopt, smax, std::nullopt, flowJournal); }(); BEAST_EXPECT(flowResult.removableOffers.size() == 1); env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) { if (flowResult.removableOffers.empty()) return false; Sandbox sb(&view, TapNone); for (auto const& o : flowResult.removableOffers) { if (auto ok = sb.peek(keylet::offer(o))) offerDelete(sb, ok, flowJournal); } sb.apply(view); return true; }); // used in payment, but since payment failed should // be untouched BEAST_EXPECT(isOffer(env, bob, btc(50), usd(5'000))); BEAST_EXPECT(isOffer(env, carol, btc(1'000), eur(100))); // found unfunded BEAST_EXPECT(!isOffer(env, bob, btc(60), eur(5'000))); }; testHelper3TokensMix(test); } { // Do not produce more in the forward pass than the // reverse pass. This test uses a path whose reverse // pass will compute a 0.5 USD input required for a 1 // EUR output. It sets a sendmax of 0.4 USD, so the // payment engine will need to do a forward pass. // Without limits, the 0.4 USD would produce 1000 EUR in // the forward pass. This test checks that the payment // produces 1 EUR, as expected. auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob, carol}}); auto const eur = issue1( {.env = env, .token = "EUR", .issuer = gw, .holders = {alice, bob, carol}}); env(pay(gw, alice, usd(1'000))); env(pay(gw, bob, eur(1'000))); Keylet const bobUsdOffer = keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob))); env(offer(bob, usd(10), drops(2)), Txflags(tfPassive)); env(offer(bob, drops(1), eur(1'000)), Txflags(tfPassive)); bool const reducedOffersV2 = features[fixReducedOffersV2]; // With reducedOffersV2, it is not allowed to accept // less than USD(0.5) of bob's USD offer. If we // provide 1 drop for less than USD(0.5), then the // remaining fractional offer would block the order // book. TER const expectedTER = reducedOffersV2 ? TER(tecPATH_DRY) : TER(tesSUCCESS); env(pay(alice, carol, eur(1)), Path(~XRP, ~eur), Sendmax(usd(4)), Txflags(tfNoRippleDirect | tfPartialPayment), Ter(expectedTER)); if (!reducedOffersV2) { env.require(Balance(carol, eur(1))); env.require(Balance(bob, usd(4))); env.require(Balance(bob, eur(999))); // Show that bob's USD offer is now a blocker. SLE::const_pointer const usdOffer = env.le(bobUsdOffer); if (BEAST_EXPECT(usdOffer)) { std::uint64_t const bookRate = [&usdOffer]() { // Extract the least significant 64 // bits from the book page. That's // where the quality is stored. std::string bookDirStr = to_string(usdOffer->at(sfBookDirectory)); bookDirStr.erase(0, 48); return std::stoull(bookDirStr, nullptr, 16); }(); std::uint64_t const actualRate = getRate(usdOffer->at(sfTakerGets), usdOffer->at(sfTakerPays)); // We expect the actual rate of the offer to // be worse (larger) than the rate of the // book page holding the offer. This is a // defect which is corrected by // fixReducedOffersV2. BEAST_EXPECT(actualRate > bookRate); } } }; testHelper2TokensMix(test); } } void testTransferRate(FeatureBitset features) { testcase("Transfer Rate"); using namespace jtx; auto const gw = Account("gateway"); Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); { // Simple payment through a gateway with a // transfer rate Env env(*this, features); env.fund(XRP(10000), alice, bob, carol, gw); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, bob, carol}, .transferFee = 25'000, .maxAmt = 1'000}); env(pay(gw, alice, usd(50))); env.require(Balance(alice, usd(50))); env(pay(alice, bob, usd(40)), Sendmax(usd(50))); env.require(Balance(bob, usd(40)), Balance(alice, usd(0))); } { // transfer rate is not charged when issuer is src or // dst Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, bob, carol}, .transferFee = 25'000, .maxAmt = 1'000}); env(pay(gw, alice, usd(50))); env.require(Balance(alice, usd(50))); env(pay(alice, gw, usd(40)), Sendmax(usd(40))); env.require(Balance(alice, usd(10))); } { // transfer fee on an offer Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, bob, carol}, .transferFee = 25'000, .maxAmt = 10'000}); // scale by 1 env(pay(gw, bob, usd(650))); env(offer(bob, XRP(50), usd(500))); env(pay(alice, carol, usd(500)), Path(~usd), Sendmax(XRP(50)), Txflags(tfPartialPayment)); // bob pays 25% on 500USD -> 100USD; 400USD goes to carol env.require( Balance(alice, XRP(10'000 - 50) - txFee(env, 2)), Balance(bob, usd(150)), Balance(carol, usd(400))); } { // Transfer fee two consecutive offers auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this, features); env.fund(XRP(10'000), alice, bob, carol, gw); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob, carol}, .limit = 1'000, .transferFee = 25'000}); auto const eur = issue2( {.env = env, .token = "EUR", .issuer = gw, .holders = {alice, bob, carol}, .limit = 1'000, .transferFee = 25'000}); env(pay(gw, bob, usd(50))); env(pay(gw, bob, eur(50))); env(offer(bob, XRP(50), usd(50))); env(offer(bob, usd(50), eur(50))); env(pay(alice, carol, eur(40)), Path(~usd, ~eur), Sendmax(XRP(40)), Txflags(tfPartialPayment)); // +1 for fset in helperIssueIOU using tEUR = std::decay_t; auto const fee = txFee(env, 3); // bob pays 25% on 40USD (40 since sendmax is 40XRP) // 8USD goes to gw and 32USD goes back to bob -> // bob's USD balance is 42USD. USD/EUR offer is 32USD/32EUR. // bob pays 25% on 32EUR -> 7EUR if MPT, 6.4EUR if IOU, // therefore carl gets 25EUR if MPT, 25.6EUR if IOU. auto const carolEUR = [&]() { if constexpr (std::is_same_v) { return eur(25.6); } else { return eur(25); } }(); env.require( Balance(alice, XRP(10'000 - 40) - fee), Balance(bob, usd(42)), Balance(bob, eur(18)), Balance(carol, carolEUR)); }; testHelper2TokensMix(test); } { // Offer where the owner is also the issuer, sender pays // fee Env env(*this, features); env.fund(XRP(10'000), alice, bob, gw); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, bob}, .transferFee = 25'000, .maxAmt = 1'000}); env(offer(gw, XRP(100), usd(100))); env(pay(alice, bob, usd(100)), Sendmax(XRP(100)), Txflags(tfPartialPayment)); env.require(Balance(alice, XRP(10'000 - 100) - txFee(env, 2)), Balance(bob, usd(80))); } { // Offer where the owner is also the issuer, sender pays // fee Env env(*this, features); env.fund(XRP(10'000), alice, bob, gw); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, bob}, .transferFee = 25'000, .maxAmt = 1'000}); env(offer(gw, XRP(125), usd(125))); env(pay(alice, bob, usd(100)), Sendmax(XRP(200))); env.require(Balance(alice, XRP(10'000 - 125) - txFee(env, 2)), Balance(bob, usd(100))); } } void testMPTEndpointTransferRateOverflow(FeatureBitset features) { testcase("MPT Endpoint transfer rate overflow"); using namespace jtx; Account const iouGW("iou_gateway"); Account const mptGW("mpt_gateway"); Account const alice("alice"); Account const bob("bob"); { // Control: the same issuer-owned offer path works when the // transfer-fee-adjusted input amount remains representable. Env env(*this, features); std::int64_t constexpr deliverAmount = 1'000'000'000'000'000'000LL; std::int64_t constexpr offerAmount = deliverAmount + (deliverAmount / 2); env.fund(XRP(10'000), iouGW, mptGW, alice, bob); env.close(); auto const usd = iouGW["USD"]; env.trust(usd(offerAmount), alice); env.trust(usd(offerAmount), mptGW); env(pay(iouGW, alice, usd(offerAmount))); MPTTester const mpt( {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee}); env(offer(mptGW, usd(offerAmount), mpt(offerAmount))); env(pay(alice, bob, mpt(deliverAmount)), Path(~mpt), Sendmax(usd(offerAmount)), Txflags(tfNoRippleDirect | tfPartialPayment)); env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliverAmount))); BEAST_EXPECT(!isOffer(env, mptGW, usd(offerAmount), mpt(offerAmount))); } { // Regression: an extreme transfer-fee-adjusted MPT amount used to // throw from MPTAmount::mulRatio during the endpoint reverse pass. // The reverse pass now caps srcToDst at the largest amount whose // transfer-fee-adjusted input is representable, so the offer limits // the strand and a partial payment goes through. Env env(*this, features); std::int64_t constexpr overflowAmount = 7'000'000'000'000'000'000LL; // The offer caps the input at overflowAmount, which the maximum // transfer rate of 1.5 scales down to 7e18 * 2 / 3, rounded down std::int64_t constexpr deliveredAmount = 4'666'666'666'666'666'666LL; env.fund(XRP(10'000), iouGW, mptGW, alice, bob); env.close(); auto const usd = iouGW["USD"]; env.trust(usd(overflowAmount), alice); env.trust(usd(overflowAmount), mptGW); env(pay(iouGW, alice, usd(overflowAmount))); MPTTester const mpt( {.env = env, .issuer = mptGW, .holders = {bob}, .transferFee = kMaxTransferFee}); env(offer(mptGW, usd(overflowAmount), mpt(overflowAmount))); env(pay(alice, bob, mpt(overflowAmount)), Path(~mpt), Sendmax(usd(overflowAmount)), Txflags(tfNoRippleDirect | tfPartialPayment)); env.require(Balance(alice, usd(0)), Balance(bob, mpt(deliveredAmount))); BEAST_EXPECT(!isOffer(env, mptGW, usd(overflowAmount), mpt(overflowAmount))); } } void testMPTEndpointRipplingInputOverflow(FeatureBitset features) { // A payment between the holders of an MPT with a transfer fee ripples // through the issuer, and the issuing step has to charge the transfer // rate on the amount it receives. maxPaymentFlow() returns the issuance // maximum for that step, so srcToDst * transferRate is not necessarily // representable as an MPT amount. The reverse pass must cap the flow at // the largest representable input instead of declaring the strand dry, // otherwise a deliverable partial payment fails with tecPATH_DRY. // // Same defect as the case above, reached without an offer: holder -> // issuer -> holder, one case per branch of the pre-fix revImp. testcase("MPT Endpoint rippling input overflow"); using namespace jtx; Account const gw("gateway"); Account const alice("alice"); Account const bob("bob"); // The maximum transfer fee gives a transfer rate of 1.5, so an input of // kMaxMpTokenAmount covers at most kMaxMpTokenAmount * 2 / 3 of output. std::int64_t constexpr maxRepresentable = 6'148'914'691'236'517'204LL; std::int64_t constexpr aliceBalance = 1'000; // The forward pass rounds the delivered amount down: 1000 / 1.5 std::int64_t constexpr bobBalance = 666; auto const test = [&](std::uint64_t maxAmt, std::int64_t deliver, std::string const& label) { Env env(*this, features); env.fund(XRP(10'000), gw, alice, bob); env.close(); auto mpt = MPTTester( {.env = env, .issuer = gw, .holders = {alice, bob}, .transferFee = kMaxTransferFee, .maxAmt = maxAmt}); env(pay(gw, alice, mpt(aliceBalance))); env.close(); // alice asks to deliver more than the transfer rate can scale, // so the issuing step caps the flow and her balance limits it // further env(pay(alice, bob, mpt(deliver)), Sendmax(mpt(kMaxMpTokenAmount)), Txflags(tfPartialPayment)); BEAST_EXPECTS(env.ter() == tesSUCCESS, label); BEAST_EXPECTS(env.balance(alice, mpt) == mpt(0), label); BEAST_EXPECTS(env.balance(bob, mpt) == mpt(bobBalance), label); BEAST_EXPECTS(mpt.checkMPTokenOutstandingAmount(bobBalance), label); }; // The requested amount is below MaximumAmount, so the reverse pass // takes the non-limiting branch and overflows on the requested amount test(kMaxMpTokenAmount, maxRepresentable + 1, "non-limiting"); // MaximumAmount is below the requested amount but still large enough // that scaling it by the transfer rate is not representable, so the // reverse pass takes the limiting branch and overflows on the maximum test(maxRepresentable + 1, kMaxMpTokenAmount, "limiting"); } void testFalseDry(FeatureBitset features) { testcase("falseDryChanges"); using namespace jtx; auto const gw = Account("gateway"); Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this, features); env.fund(XRP(10'000), alice, carol, gw); env.fund(reserve(env, 5), bob); env.close(); auto const usd = issue1({.env = env, .token = "USD", .issuer = gw, .holders = {alice, carol, bob}}); auto const eur = issue2({.env = env, .token = "EUR", .issuer = gw, .holders = {alice, carol, bob}}); env(pay(gw, alice, eur(50))); env(pay(gw, bob, usd(50))); // Bob has _just_ slightly less than 50 xrp available // If his owner count changes, he will have more liquidity. // This is one error case to test (when Flow is used). // Computing the incoming xrp to the XRP/USD offer will // require two recursive calls to the EUR/XRP offer. The // second call will return tecPATH_DRY, but the entire path // should not be marked as dry. This is the second error // case to test (when flowV1 is used). env(offer(bob, eur(50), XRP(50))); env(offer(bob, XRP(50), usd(50))); env(pay(alice, carol, usd(1'000'000)), Path(~XRP, ~usd), Sendmax(eur(500)), Txflags(tfNoRippleDirect | tfPartialPayment)); auto const carolUSD = env.balance(carol, usd).value(); BEAST_EXPECT(carolUSD > usd(0) && carolUSD < usd(50)); }; testHelper2TokensMix(test); } void testLimitQuality() { // Single path with two offers and limit quality. The // quality limit is such that the first offer should be // taken but the second should not. The total amount // delivered should be the sum of the two offers and sendMax // should be more than the first offer. testcase("limitQuality"); using namespace jtx; auto const gw = Account("gateway"); Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); { Env env(*this); env.fund(XRP(10'000), alice, bob, carol, gw); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob, carol}}); env(pay(gw, bob, usd(100))); env(offer(bob, XRP(50), usd(50))); env(offer(bob, XRP(100), usd(50))); env(pay(alice, carol, usd(100)), Path(~usd), Sendmax(XRP(100)), Txflags(tfNoRippleDirect | tfPartialPayment | tfLimitQuality)); env.require(Balance(carol, usd(50))); } } // Helper function that returns the reserve on an account based on // the passed in number of owners. static XRPAmount reserve(jtx::Env& env, std::uint32_t count) { return env.current()->fees().accountReserve(count, 1); } // Helper function that returns the Offers on an account. static std::vector offersOnAccount(jtx::Env& env, jtx::Account account) { std::vector result; forEachItem(*env.current(), account, [&result](SLE::const_ref sle) { if (sle->getType() == ltOFFER) result.push_back(sle); }); return result; } void testOfferOwnerMPTCreation(FeatureBitset features) { using namespace jtx; Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); Account const gw("gw"); { testcase("Reserve-edge offer owner cannot create another object"); Env env(*this, features); auto const baseFee = env.current()->fees().base; auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); auto const xrpOffer = ownerIncrement - drops(1); auto const bobStart = reserve(env, 2) - drops(1) + baseFee; env.fund(XRP(10'000), alice, gw); env.fund(bobStart, bob); env.close(); MPTTester const usd({.env = env, .issuer = gw, .maxAmt = 10}); env(offer(bob, usd(1), xrpOffer)); env.close(); env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1)); // This mirrors the full-crossing setup below. Bob has enough XRP // for the resting offer, but not enough to pay a fee and add // another owner-count object while the offer remains on ledger. env(check::create(bob, alice, drops(1)), Ter(tecINSUFFICIENT_RESERVE)); env.close(); env.require(Owners(bob, 1)); BEAST_EXPECT(offersOnAccount(env, bob).size() == 1); } { testcase("Reserve-edge offer owner creates MPToken during consume"); Env env(*this, features); auto const baseFee = env.current()->fees().base; auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); auto const xrpOffer = ownerIncrement - drops(1); auto const bobStart = reserve(env, 2) - drops(1) + baseFee; env.fund(XRP(10'000), alice, carol, gw); env.fund(bobStart, bob); env.close(); MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); env(pay(gw, alice, usd(1))); env(offer(bob, usd(1), xrpOffer)); env.close(); env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1)); BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); auto const carolXRP = env.balance(carol); // Bob has enough XRP for the resting offer but is close to // reserve. The payment should not create Bob's USD MPToken until // the offer is actually consumed, otherwise the temporary owner // count increase can make the offer look underfunded during path // execution. env(pay(alice, carol, xrpOffer), Path(~XRP), Sendmax(usd(1)), Txflags(tfNoRippleDirect)); env.close(); env.require(Balance(carol, carolXRP + xrpOffer)); env.require(Balance(bob, usd(1))); env.require(Balance(bob, reserve(env, 1)), Owners(bob, 1)); BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); BEAST_EXPECT(offersOnAccount(env, bob).empty()); } { testcase("Partial offer owner creates MPToken during consume"); Env env(*this, features); auto const baseFee = env.current()->fees().base; auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); auto const bobStart = reserve(env, 3) + baseFee; env.fund(XRP(10'000), alice, carol, gw); env.fund(bobStart, bob); env.close(); MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); env(pay(gw, alice, usd(1))); env(offer(bob, usd(2), drops(2 * ownerIncrement))); env.close(); env.require(Balance(bob, reserve(env, 3)), Owners(bob, 1)); BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); auto const carolXRP = env.balance(carol); // Partial consumption leaves Bob's offer on the ledger, so he ends // up owning both the remaining offer and a newly created MPToken. // The MPToken is created regardless of reserve; this setup simply // funds Bob enough that he still meets reserve(2) afterward (the // under-reserved case is covered in OfferMPT_test's no-reserve-check // testcase). env(pay(alice, carol, drops(ownerIncrement)), Path(~XRP), Sendmax(usd(1)), Txflags(tfNoRippleDirect)); env.close(); env.require(Balance(carol, carolXRP + drops(ownerIncrement))); env.require(Balance(bob, usd(1))); env.require(Balance(bob, reserve(env, 2)), Owners(bob, 2)); BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); BEAST_EXPECT(offersOnAccount(env, bob).size() == 1); BEAST_EXPECT(isOffer(env, bob, usd(1), drops(ownerIncrement))); } { testcase("Issuer-owned offer does not create issuer MPToken"); Env env(*this, features); env.fund(XRP(10'000), alice, carol, gw); env.close(); MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); env(pay(gw, alice, usd(1))); env(offer(gw, usd(1), drops(1'000))); env.close(); BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id()))); auto const carolXRP = env.balance(carol); // The issuer can own an offer that receives its own MPT without an // MPToken. Consuming that offer should keep the issuer side // tokenless. env(pay(alice, carol, drops(1'000)), Path(~XRP), Sendmax(usd(1)), Txflags(tfNoRippleDirect)); env.close(); env.require(Balance(alice, usd(0))); env.require(Balance(carol, carolXRP + drops(1'000))); BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id()))); BEAST_EXPECT(offersOnAccount(env, gw).empty()); } } void testSelfPayment1(FeatureBitset features) { testcase("Self-payment 1"); // In this test case the new flow code mis-computes the // amount of money to move. Fortunately the new code's // re-execute check catches the problem and throws out the // transaction. // // The old payment code handles the payment correctly. using namespace jtx; auto test = [&](auto&& issue1, auto&& issue2) { auto const gw1 = Account("gw1"); auto const gw2 = Account("gw2"); auto const alice = Account("alice"); Env env(*this, features); env.fund(XRP(1'000'000), gw1, gw2); env.close(); // The fee that's charged for transactions. auto const f = env.current()->fees().base; env.fund(reserve(env, 3) + f * 4, alice); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw1, .holders = {alice}, .limit = 20'000}); auto const eur = issue2( {.env = env, .token = "EUR", .issuer = gw2, .holders = {alice}, .limit = 20'000}); env(pay(gw1, alice, usd(10))); env(pay(gw2, alice, eur(10'000))); env.close(); env(offer(alice, usd(5'000), eur(6'000))); env.close(); env.require(Owners(alice, 3)); env.require(Balance(alice, usd(10))); env.require(Balance(alice, eur(10'000))); auto aliceOffers = offersOnAccount(env, alice); BEAST_EXPECT(aliceOffers.size() == 1); for (auto const& offerPtr : aliceOffers) { auto const offer = *offerPtr; BEAST_EXPECT(offer[sfLedgerEntryType] == ltOFFER); BEAST_EXPECT(offer[sfTakerGets] == eur(6'000)); BEAST_EXPECT(offer[sfTakerPays] == usd(5'000)); } env(pay(alice, alice, eur(6'000)), Sendmax(usd(5'000)), Txflags(tfPartialPayment)); env.close(); env.require(Owners(alice, 3)); env.require(Balance(alice, usd(10))); env.require(Balance(alice, eur(10'000))); aliceOffers = offersOnAccount(env, alice); BEAST_EXPECT(aliceOffers.size() == 1); for (auto const& offerPtr : aliceOffers) { auto const offer = *offerPtr; BEAST_EXPECT(offer[sfLedgerEntryType] == ltOFFER); if constexpr (std::is_same_v, IOU>) { BEAST_EXPECT(offer[sfTakerGets] == eur(5'988)); } else { BEAST_EXPECT(offer[sfTakerGets] == eur(5'989)); } BEAST_EXPECT(offer[sfTakerPays] == usd(4'990)); } }; testHelper2TokensMix(test); } template struct TokenData { TGets gets; TPays pays; jtx::PrettyAmount remTakerGets; jtx::PrettyAmount remTakerPays; }; void testSelfPayment2(FeatureBitset features) { testcase("Self-payment 2"); using namespace jtx; // This test shows a difference between IOU and MPT // self-payment result depending on IOU trustline limit. auto const gw1 = Account("gw1"); auto const gw2 = Account("gw2"); auto const alice = Account("alice"); auto initMPT = [&](Env& env) { MPT const usd = MPTTester({.env = env, .issuer = gw1, .holders = {alice}, .maxAmt = 506}); MPT const eur = MPTTester({.env = env, .issuer = gw2, .holders = {alice}, .maxAmt = 606}); // Payment's engine last step overflows // OutstandingAmount since it doesn't know if the // BookStep redeems or not. The BookStep then has 600EUR // available. Consequently, the entire offer is crossed. // Note remaining takerGets is 541 rather than 540 due to integral // rounding. XRP has a similar result. return TokenData{ .gets = eur, .pays = usd, .remTakerGets = eur(541), .remTakerPays = usd(450)}; }; auto initXRP = [&](Env& env) { MPT const usd = MPTTester({.env = env, .issuer = gw1, .holders = {alice}, .maxAmt = 1'000}); // Payment's engine last step overflows // OutstandingAmount since it doesn't know if the // BookStep redeems or not. The BookStep then has 600EUR // available. Consequently, the entire offer is crossed. // Note remaining takerGets is 540.000001 rather than 540 due to // integral rounding. return TokenData{ .gets = XRP, .pays = usd, .remTakerGets = XRP(540.000001), .remTakerPays = usd(450)}; }; auto initIOU = [&](Env& env) { auto const usd = gw1["USD"]; auto const eur = gw2["EUR"]; env(trust(alice, usd(506))); env(trust(alice, eur(606))); env.close(); // Payment's engine last step is limited by alice's // trustline - 606. Therefore, only 6EUR is delivered // and the offer is partially crossed. return TokenData{ .gets = eur, .pays = usd, .remTakerGets = eur(594), .remTakerPays = usd(495)}; }; auto initIOU1 = [&](Env& env) { auto const usd = gw1["USD"]; auto const eur = gw2["EUR"]; env(trust(alice, usd(1'000))); env(trust(alice, eur(1'000))); env.close(); // Payment's engine last step is not limited by alice's // trustline. Therefore, the entire offer is crossed. // This the same result as with MPT. return TokenData{ .gets = eur, .pays = usd, .remTakerGets = eur(540), .remTakerPays = usd(450)}; }; auto test = [&](auto&& initToken) { Env env(*this, features); env.fund(XRP(2'000), gw1, gw2, alice); env.close(); auto const f = env.current()->fees().base; auto const tok = initToken(env); auto const& toK1 = tok.pays; auto const& toK2 = tok.gets; bool const isTakerGetsXRP = isXRP(Asset{toK2}); std::uint32_t const ownerCnt = isTakerGetsXRP ? 2 : 3; env(pay(gw1, alice, toK1(500))); if (!isTakerGetsXRP) env(pay(gw2, alice, toK2(600))); env.close(); env(offer(alice, toK1(500), toK2(600))); env.close(); env.require(Owners(alice, ownerCnt)); env.require(Balance(alice, toK1(500))); if (isTakerGetsXRP) { env.require(Balance(alice, toK2(2'000) - 2 * f)); } else { env.require(Balance(alice, toK2(600))); } auto aliceOffers = offersOnAccount(env, alice); BEAST_EXPECT(aliceOffers.size() == 1); for (auto const& offerPtr : aliceOffers) { auto const offer = *offerPtr; BEAST_EXPECT(offer[sfLedgerEntryType] == ltOFFER); BEAST_EXPECT(offer[sfTakerGets] == toK2(600)); BEAST_EXPECT(offer[sfTakerPays] == toK1(500)); } env(pay(alice, alice, toK2(60)), Sendmax(toK1(50)), Txflags(tfPartialPayment)); env.close(); env.require(Owners(alice, ownerCnt)); env.require(Balance(alice, toK1(500))); if (isTakerGetsXRP) { env.require(Balance(alice, toK2(2'000) - 3 * f)); } else { env.require(Balance(alice, toK2(600))); } aliceOffers = offersOnAccount(env, alice); BEAST_EXPECT(aliceOffers.size() == 1); for (auto const& offerPtr : aliceOffers) { auto const offer = *offerPtr; BEAST_EXPECT(offer[sfLedgerEntryType] == ltOFFER); BEAST_EXPECT(offer[sfTakerGets] == tok.remTakerGets); BEAST_EXPECT(offer[sfTakerPays] == tok.remTakerPays); } }; test(initXRP); test(initMPT); test(initIOU); test(initIOU1); } void testSelfFundedXRPEndpoint(bool consumeOffer, FeatureBitset features) { // Test that the deferred credit table is not bypassed for // XRPEndpointSteps. If the account in the first step is // sending XRP and that account also owns an offer that // receives XRP, it should not be possible for that step to // use the XRP received in the offer as part of the payment. testcase("Self funded XRPEndpoint"); using namespace jtx; Env env(*this, features); auto const alice = Account("alice"); auto const gw = Account("gw"); env.fund(XRP(10'000), alice, gw); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 20}); env(pay(gw, alice, usd(10))); env(offer(alice, XRP(50'000), usd(10))); // Consuming the offer changes the owner count, which could // also cause liquidity to decrease in the forward pass auto const toSend = consumeOffer ? usd(10) : usd(9); env(pay(alice, alice, toSend), Path(~usd), Sendmax(XRP(20'000)), Txflags(tfPartialPayment | tfNoRippleDirect)); } void testUnfundedOffer(FeatureBitset features) { testcase("Unfunded Offer"); using namespace jtx; { // Test reverse Env env(*this, features); auto const alice = Account("alice"); auto const bob = Account("bob"); auto const gw = Account("gw"); env.fund(XRP(100'000), alice, bob, gw); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = 20E+17}); // scale by 17 STAmount const tinyAmt1{usd, 9'000'000'000'000'000ll, 0, false, STAmount::Unchecked{}}; STAmount const tinyAmt3{usd, 9'000'000'000'000'003ll, 0, false, STAmount::Unchecked{}}; env(offer(gw, drops(9'000'000'000), tinyAmt3)); env(pay(alice, bob, tinyAmt1), Path(~usd), Sendmax(drops(9'000'000'000)), Txflags(tfNoRippleDirect)); BEAST_EXPECT(!isOffer(env, gw, XRP(0), usd(0))); } { // Test forward Env env(*this, features); auto const alice = Account("alice"); auto const bob = Account("bob"); auto const gw = Account("gw"); env.fund(XRP(100'000), alice, bob, gw); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = 20E+17}); // scale by 17 STAmount const tinyAmt1{usd, 9'000'000'000'000'000ll, 0, false, STAmount::Unchecked{}}; STAmount const tinyAmt3{usd, 9'000'000'000'000'003ll, 0, false, STAmount::Unchecked{}}; env(pay(gw, alice, tinyAmt1)); env(offer(gw, tinyAmt3, drops(9'000'000'000))); env(pay(alice, bob, drops(9'000'000'000)), Path(~XRP), Sendmax(usd(static_cast(1E+17))), Txflags(tfNoRippleDirect)); BEAST_EXPECT(!isOffer(env, gw, usd(0), XRP(0))); } } void testReExecuteDirectStep(FeatureBitset features) { testcase("ReexecuteDirectStep"); using namespace jtx; Env env(*this, features); auto const alice = Account("alice"); auto const bob = Account("bob"); auto const gw = Account("gw"); env.fund(XRP(10'000), alice, bob, gw); // scale by 16 MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = 100E+16}); env( pay(gw, alice, // 12.55.... STAmount{usd, std::uint64_t(1255555555555555ull), 2, false})); env(offer( gw, // 5.0... STAmount{usd, std::uint64_t(5000000000000000ull), 1, false}, XRP(1000))); env(offer( gw, // .555... STAmount{usd, std::uint64_t(5555555555555555ull), 0, false}, XRP(10))); env(offer( gw, // 4.44.... STAmount{usd, std::uint64_t(4444444444444444ull), 1, false}, XRP(.1))); env(offer( alice, // 17 STAmount{usd, std::uint64_t(1700000000000000ull), 0, false}, XRP(.001))); env(pay(alice, bob, XRP(10'000)), Path(~XRP), Sendmax(usd(static_cast(100E+16))), Txflags(tfPartialPayment | tfNoRippleDirect)); } void testSelfPayLowQualityOffer(FeatureBitset features) { // The new payment code used to assert if an offer was made // for more XRP than the offering account held. This unit // test reproduces that failing case. testcase("Self crossing low quality offer"); using namespace jtx; Env env(*this, features); auto const ann = Account("ann"); auto const gw = Account("gateway"); auto const fee = env.current()->fees().base; env.fund(reserve(env, 2) + drops(9999640) + fee, ann); env.fund(reserve(env, 2) + fee * 4, gw); // scale by 5 MPT const ctb = MPTTester( {.env = env, .issuer = gw, .holders = {ann}, .transferFee = 2'000, // 2% .maxAmt = 1'000'000}); env(pay(gw, ann, ctb(285'600))); env.close(); env(offer(ann, drops(365'611'702'030), ctb(571'300))); env.close(); // This payment caused assert. env(pay(ann, ann, ctb(68'700)), Sendmax(drops(20'000'000'000)), Txflags(tfPartialPayment)); } void testEmptyStrand(FeatureBitset features) { testcase("Empty Strand"); using namespace jtx; auto const alice = Account("alice"); Env env(*this, features); env.fund(XRP(10000), alice); MPT const usd; env(pay(alice, alice, usd(100)), Path(~usd), Ter(temBAD_PATH)); } void testXRPPathLoop() { testcase("Circular XRP"); using namespace jtx; auto const alice = Account("alice"); auto const bob = Account("bob"); auto const gw = Account("gw"); { // Payment path starting with XRP auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this); env.fund(XRP(10'000), alice, bob, gw); auto const usd = issue1({.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob}}); auto const eur = issue2({.env = env, .token = "EUR", .issuer = gw, .holders = {alice, bob}}); env(pay(gw, alice, usd(100))); env(pay(gw, alice, eur(100))); env.close(); env(offer(alice, XRP(100), usd(100)), Txflags(tfPassive)); env(offer(alice, usd(100), XRP(100)), Txflags(tfPassive)); env(offer(alice, XRP(100), eur(100)), Txflags(tfPassive)); env.close(); TER const expectedTer = TER{temBAD_PATH_LOOP}; env(pay(alice, bob, eur(1)), Path(~usd, ~XRP, ~eur), Sendmax(XRP(1)), Txflags(tfNoRippleDirect), Ter(expectedTer)); }; testHelper2TokensMix(test); } { // Payment path ending with XRP auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this); env.fund(XRP(10'000), alice, bob, gw); auto const usd = issue1({.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob}}); auto const eur = issue2({.env = env, .token = "EUR", .issuer = gw, .holders = {alice, bob}}); env(pay(gw, alice, usd(100))); env(pay(gw, alice, eur(100))); env.close(); env(offer(alice, XRP(100), usd(100)), Txflags(tfPassive)); env(offer(alice, eur(100), XRP(100)), Txflags(tfPassive)); env.close(); // EUR -> //XRP -> //USD ->XRP env(pay(alice, bob, XRP(1)), Path(~XRP, ~usd, ~XRP), Sendmax(eur(1)), Txflags(tfNoRippleDirect), Ter(temBAD_PATH_LOOP)); }; testHelper2TokensMix(test); } { // Payment where loop is formed in the middle of the // path, not on an endpoint auto test = [&](auto&& issue1, auto&& issue2, auto&& issue3) { Env env(*this); env.fund(XRP(10'000), alice, bob, gw); env.close(); auto const usd = issue1({.env = env, .token = "USD", .issuer = gw, .holders = {alice, bob}}); auto const eur = issue2({.env = env, .token = "EUR", .issuer = gw, .holders = {alice, bob}}); auto const jpy = issue3({.env = env, .token = "JPY", .issuer = gw, .holders = {alice, bob}}); env(pay(gw, alice, usd(100))); env(pay(gw, alice, eur(100))); env(pay(gw, alice, jpy(100))); env.close(); env(offer(alice, usd(100), XRP(100)), Txflags(tfPassive)); env(offer(alice, XRP(100), eur(100)), Txflags(tfPassive)); env(offer(alice, eur(100), XRP(100)), Txflags(tfPassive)); env(offer(alice, XRP(100), jpy(100)), Txflags(tfPassive)); env.close(); env(pay(alice, bob, jpy(1)), Path(~XRP, ~eur, ~XRP, ~jpy), Sendmax(usd(1)), Txflags(tfNoRippleDirect), Ter(temBAD_PATH_LOOP)); }; testHelper3TokensMix(test); } } void testMaxAndSelfPaymentEdgeCases(FeatureBitset features) { testcase("Max Flow/Self Payment Edge Cases"); using namespace jtx; Account const gw("gw"); Account const alice("alice"); Account const carol("carol"); Account const bob("bob"); // Direct payment between holders. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, carol}, .maxAmt = 100}); env(pay(gw, alice, usd(100))); env(pay(alice, carol, usd(100))); BEAST_EXPECT(env.balance(gw, usd) == usd(-100)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); } // Direct payment between holders. Partial payment limited // by holder funds. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, carol}, .maxAmt = 100}); env(pay(gw, alice, usd(80))); env(pay(alice, carol, usd(100)), Txflags(tfPartialPayment)); BEAST_EXPECT(env.balance(gw, usd) == usd(-80)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); BEAST_EXPECT(env.balance(carol, usd) == usd(80)); } // Direct payment between holders. Partial payment limited // by holder funds. OutstandingAmount is already at max // before the payment. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol, bob); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .maxAmt = 100}); env(pay(gw, bob, usd(20))); env(pay(gw, alice, usd(80))); env(pay(alice, carol, usd(100)), Txflags(tfPartialPayment)); BEAST_EXPECT(env.balance(gw, usd) == usd(-100)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); BEAST_EXPECT(env.balance(carol, usd) == usd(80)); } // Cross-currency payment holder to holder. Holder owns an // offer. OutstandingAmount is already at max before the // payment. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol, bob); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, carol}, .maxAmt = 100}); env(pay(gw, alice, usd(100))); env(offer(alice, XRP(100), usd(100))); env(pay(bob, carol, usd(100)), Sendmax(XRP(100)), Path(~usd)); BEAST_EXPECT(env.balance(gw, usd) == usd(-100)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); } // Cross-currency payment holder to holder. Issuer owns an // offer. OutstandingAmount is already at max before the // payment. Since an issuer owns the offer, it issues more // tokens to another holder, and the payment fails. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {carol}, .maxAmt = 100}); env(pay(gw, carol, usd(100))); env(offer(gw, XRP(100), usd(100))); env(pay(alice, carol, usd(100)), Sendmax(XRP(100)), Path(~usd), Txflags(tfPartialPayment), Ter(tecPATH_DRY)); BEAST_EXPECT(env.balance(gw, usd) == usd(-100)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); } // Cross-currency payment holder to holder. Issuer owns an // offer. OutstandingAmount is at 80USD before the payment. // Consequently, the issuer can issue 20USD more. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {carol}, .maxAmt = 100}); env(pay(gw, carol, usd(80))); env(offer(gw, XRP(100), usd(100))); env(pay(alice, carol, usd(100)), Sendmax(XRP(100)), Path(~usd), Txflags(tfPartialPayment)); BEAST_EXPECT(env.balance(gw, usd) == usd(-100)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); } // Cross-currency payment holder to holder. Holder owns an // offer. The offer buys more MPT's. The payment fails since // OutstandingAmount is already at max. { Env env(*this); env.fund(XRP(1'000), gw, alice); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 100}); env(pay(gw, alice, usd(100))); env(offer(alice, usd(100), XRP(100))); env(pay(gw, alice, XRP(100)), Sendmax(usd(100)), Path(~XRP), Ter(tecPATH_PARTIAL)); BEAST_EXPECT(env.balance(gw, usd) == usd(-100)); BEAST_EXPECT(env.balance(alice, usd) == usd(100)); } // Cross-currency payment issuer to holder. Holder owns an // offer. The offer buys EUR, OutstandingAmount goes to max, // no overflow. The offer redeems USD to the issuer. While // OutstandingAmount is already at max, the payment succeeds // since USD is redeemed. { auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, carol}, .limit = 100}); using tUSD = std::decay_t; auto const eur = issue2( {.env = env, .token = "EUR", .issuer = gw, .holders = {alice, carol}, .limit = 100}); env(pay(gw, alice, usd(100))); env(offer(alice, eur(100), usd(100))); env(pay(gw, carol, usd(100)), Sendmax(eur(100)), Path(~usd)); if constexpr (std::is_same_v) BEAST_EXPECT(env.balance(gw, usd) == usd(-100)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); BEAST_EXPECT(env.balance(alice, eur) == eur(100)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); }; testHelper2TokensMix(test); } // Cross-currency payment holder to holder. Offer is owned // by destination account. OutstandingAmount is not at max. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {carol}, .maxAmt = 120}); env(pay(gw, carol, usd(100))); env(offer(carol, XRP(100), usd(100))); env(pay(alice, carol, usd(100)), Path(~usd), Sendmax(XRP(100)), Txflags(tfPartialPayment)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); } // Cross-currency payment holder to holder. Offer is owned // by destination account. OutstandingAmount is already at // max. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {carol}, .maxAmt = 100}); env(pay(gw, carol, usd(100))); env(offer(carol, XRP(100), usd(100))); env(pay(alice, carol, usd(100)), Path(~usd), Sendmax(XRP(100)), Txflags(tfPartialPayment)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); } // Cross-currency payment holder to holder. Multiple offers // with different owners - some holders, some issuer. { auto test = [&](auto&& issue1, auto&& issue2) { Env env(*this); env.fund(XRP(1'000), gw, alice, carol, bob); env.close(); auto const usd = issue1( {.env = env, .token = "USD", .issuer = gw, .holders = {alice, carol, bob}, .limit = 1'000}); using tUSD = std::decay_t; auto const eur = issue2( {.env = env, .token = "EUR", .issuer = gw, .holders = {alice, carol, bob}, .limit = 1'000}); using tEUR = std::decay_t; env(pay(gw, alice, usd(600))); env(pay(gw, carol, eur(700))); env(offer(alice, eur(100), usd(105))); env(offer(gw, eur(100), usd(104))); env(offer(gw, eur(100), usd(103))); env(offer(gw, eur(100), usd(102))); env(offer(gw, eur(100), usd(101))); env(offer(gw, eur(100), usd(100))); env(pay(carol, bob, usd(2'000)), Sendmax(eur(2'000)), Path(~usd), Txflags(tfPartialPayment)); if constexpr (std::is_same_v) { BEAST_EXPECT(env.balance(gw, usd) == usd(-1'000)); BEAST_EXPECT(env.balance(alice, usd) == usd(495)); BEAST_EXPECT(env.balance(bob, usd) == usd(505)); } else { BEAST_EXPECT(env.balance(gw, usd) == usd(0)); BEAST_EXPECT(env.balance(alice, usd) == usd(495)); // all offers are consumed since the limit is different // for the holders BEAST_EXPECT(env.balance(bob, usd) == usd(615)); } if constexpr (std::is_same_v) { if constexpr (std::is_same_v) { BEAST_EXPECT(env.balance(carol, eur) == eur(210)); } else { // carol sells 600USD since all offers are consumed BEAST_EXPECT(env.balance(carol, eur) == eur(100)); } } else { BEAST_EXPECT( env.balance(carol, eur) == STAmount(eur, UINT64_C(209'9009900990099), -13)); } // 100/101 is partially crossed (90/91) and 100/100 is // unfunded when MPT. All offers are consumed if IOU. env.require(offers(gw, 0)); // alice's offer is consumed. env.require(offers(alice, 0)); }; testHelper2TokensMix(test); } // Cross-currency payment holder to holder. Multiple offers // with different owners - some holders, some issuer. Source // and destination account is the same. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, carol}, .maxAmt = 2'000}); env(pay(gw, carol, usd(1'000))); env(pay(gw, alice, usd(600))); env(offer(gw, XRP(5), usd(11))); env(offer(gw, XRP(6), usd(13))); env(offer(carol, XRP(7), usd(15))); env(offer(carol, XRP(17), usd(35))); env(offer(carol, XRP(23), usd(47))); env(offer(alice, XRP(10), usd(19))); env(offer(alice, XRP(15), usd(28))); env(offer(alice, XRP(25), usd(46))); env(pay(carol, carol, usd(200)), Sendmax(XRP(100)), Txflags(tfPartialPayment)); BEAST_EXPECT(env.balance(gw, usd) == usd(-1'624)); BEAST_EXPECT(env.balance(carol, usd) == usd(1'102)); env.require(offers(carol, 0)); env.require(offers(gw, 0)); // 100 XRP's = 5+6+7+17+23+10+15+17(25-8) BEAST_EXPECT(isOffer(env, alice, XRP(8), usd(15))); } // Cross-currency payment holder to holder. Multiple offers // with different owners - some holders, some issuer. { Env env(*this); env.fund(XRP(1'000), gw, alice, carol, bob); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice, carol, bob}, .maxAmt = 30}); env(pay(gw, alice, usd(12))); // 12, 15, 20 env(pay(gw, bob, usd(5))); // 5, 5, 10 env(offer(alice, XRP(10), usd(12))); env(offer(gw, XRP(10), usd(11))); env(offer(bob, XRP(10), usd(10))); env(pay(carol, bob, usd(30)), Sendmax(XRP(30)), Txflags(tfPartialPayment), Path(~usd)); BEAST_EXPECT(env.balance(gw, usd) == usd(-28)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); // 12+11+5 BEAST_EXPECT(env.balance(bob, usd) == usd(28)); } // Cross-currency payment two steps. Second book step // issues, first book step redeems. { Account const dan{"dan"}; Account const john{"john"}; Account const ed{"ed"}; Account const sam{"sam"}; Account const bill{"bill"}; struct TestData { int maxAmt; int sendMax; int dstTrustLimit; int dstExpectEUR; int outstandingUSD; int expEdBuyUSD; int expDanBuyUSD; int expBobSellUSD; int expGwXRP; // whole XRP excluding the fees std::uint8_t expOffersGw; bool lastGwBuyUSD; [[nodiscard]] std::uint8_t expOffersBob() const { return expBobSellUSD == 0 ? 1 : 0; } [[nodiscard]] std::uint8_t expOffersEd() const { // partially crossed if < 100 return expEdBuyUSD < 100 ? 1 : 0; } [[nodiscard]] std::uint8_t expOffersDan() const { return expDanBuyUSD == 0 ? 1 : 0; } }; auto test = [&](TestData const& d) { Env env(*this); env.fund(XRP(1'000), gw, alice, carol, bob, dan, john, ed, sam, bill); env.close(); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .maxAmt = d.maxAmt}); auto const eur = gw["EUR"]; env(pay(gw, alice, usd(100))); env(pay(gw, carol, usd(100))); env(pay(gw, bob, usd(100))); BEAST_EXPECT(env.balance(gw, usd) == usd(-300)); env(trust(john, eur(100))); env(trust(dan, eur(100))); env(trust(ed, eur(100))); env(trust(bill, eur(d.dstTrustLimit))); env(pay(gw, john, eur(100))); env(pay(gw, dan, eur(100))); env(pay(gw, ed, eur(100))); env.close(); // Sell USD env(offer(alice, XRP(100), usd(100))); env.close(); // close after each create to ensure // the order env(offer(carol, XRP(100), usd(100))); env.close(); if (!d.lastGwBuyUSD) { env(offer(gw, XRP(100), usd(100))); env.close(); } env(offer(bob, XRP(100), usd(100))); env.close(); if (d.lastGwBuyUSD) { env(offer(gw, XRP(100), usd(100))); env.close(); } BEAST_EXPECT(expectOffers(env, alice, 1)); BEAST_EXPECT(expectOffers(env, carol, 1)); BEAST_EXPECT(expectOffers(env, gw, 1)); BEAST_EXPECT(expectOffers(env, bob, 1)); // Buy USD env(offer(john, usd(100), eur(100))); env.close(); env(offer(gw, usd(100), eur(100))); env.close(); env(offer(dan, usd(100), eur(100))); env.close(); env(offer(ed, usd(100), eur(100))); env.close(); BEAST_EXPECT(expectOffers(env, john, 1)); BEAST_EXPECT(expectOffers(env, gw, 2)); BEAST_EXPECT(expectOffers(env, dan, 1)); BEAST_EXPECT(expectOffers(env, ed, 1)); env(pay(sam, bill, eur(400)), Sendmax(XRP(d.sendMax)), Path(~usd, ~eur), Txflags(tfPartialPayment | tfNoRippleDirect)); env.close(); auto const baseFee = env.current()->fees().base.drops(); BEAST_EXPECT(env.balance(bill, eur) == eur(d.dstExpectEUR)); BEAST_EXPECT(env.balance(john, usd) == usd(100)); BEAST_EXPECT(env.balance(dan, usd) == usd(d.expDanBuyUSD)); BEAST_EXPECT(env.balance(ed, usd) == usd(d.expEdBuyUSD)); BEAST_EXPECT(env.balance(gw, usd) == usd(-d.outstandingUSD)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); BEAST_EXPECT(env.balance(carol, usd) == usd(0)); BEAST_EXPECT(env.balance(bob, usd) == usd(100 - d.expBobSellUSD)); BEAST_EXPECT(env.balance(gw) == XRPAmount{d.expGwXRP * kDropsPerXrp - baseFee * 9}); BEAST_EXPECT(expectOffers(env, john, 0)); BEAST_EXPECT(expectOffers(env, gw, d.expOffersGw)); BEAST_EXPECT(expectOffers(env, dan, d.expOffersDan())); BEAST_EXPECT(expectOffers(env, ed, d.expOffersEd())); BEAST_EXPECT(expectOffers(env, alice, 0)); BEAST_EXPECT(expectOffers(env, carol, 0)); BEAST_EXPECT(expectOffers(env, bob, d.expOffersBob())); }; // clang-format off std::vector const tests = { // Sell USD: alice, carol, bob, gw are consumed. // Buy USD: john, gw, dan, ed are consumed. // gw's sell USD is consumed because there is sufficient available balance (100USD). // but OutstandingAmount is 300USD because gw's sell offer is balanced out by // gw's buy offer. //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw { .maxAmt=400, .sendMax=400, .dstTrustLimit=400, .dstExpectEUR=400, .outstandingUSD=300, .expEdBuyUSD=100, .expDanBuyUSD=100, .expBobSellUSD=100, .expGwXRP=1100, .expOffersGw=0, .lastGwBuyUSD=false}, // Sell USD: alice, carol, bob, gw are consumed. // Buy USD: john, gw, dan, ed (partially) are consumed. // gw's sell USD is partially consumed because there is available balance (50USD). // OutstandingAmount is 250USD because gw's sell offer is partially balanced by // gw's buy offer. ed's offer is on the books because it's partially crossed. // gw's offer is removed from the order book because it's partially consumed and // the remaining offer is unfunded. //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw { .maxAmt=350, .sendMax=400, .dstTrustLimit=400, .dstExpectEUR=350, .outstandingUSD=250, .expEdBuyUSD=50, .expDanBuyUSD=100, .expBobSellUSD=100, .expGwXRP=1050, .expOffersGw=0, .lastGwBuyUSD=false}, // Sell USD: alice, carol, bob are consumed; gw's is unfunded // since OutstandingAmount is initially at MaximumAmount. // Buy USD: john, gw, dan are consumed; ed's remains on the order // book since 300USD is the sell limit. //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=400, .dstTrustLimit=400, .dstExpectEUR=300, .outstandingUSD=200, .expEdBuyUSD=0, .expDanBuyUSD=100, .expBobSellUSD=100, .expGwXRP=1000, .expOffersGw=0, .lastGwBuyUSD=false}, // Same as above. bill's trustline limit sets the output to 300USD. //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=400, .dstTrustLimit=300, .dstExpectEUR=300, .outstandingUSD=200, .expEdBuyUSD=0, .expDanBuyUSD=100, .expBobSellUSD=100, .expGwXRP=1000, .expOffersGw=0, .lastGwBuyUSD=false}, // Sell USD: alice, carol, bob are consumed; gw's removed from // the order book since it's unfunded. // Buy USD: john, gw, dan are consumed; ed's remains on the order // book since 300USD is the limit. //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=400, .dstTrustLimit=300, .dstExpectEUR=300, .outstandingUSD=200, .expEdBuyUSD=0, .expDanBuyUSD=100, .expBobSellUSD=100, .expGwXRP=1000, .expOffersGw=0, .lastGwBuyUSD=true}, // Sell USD: alice, carol are consumed; gw's removed from // the order book in rev pass since it's unfunded; bob's // remains on the order book. // Buy USD: john, gw; ed's, dan's remains on the order // book since 300USD is the limit. //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=200, .dstTrustLimit=300, .dstExpectEUR=200, .outstandingUSD=200, .expEdBuyUSD=0, .expDanBuyUSD=0, .expBobSellUSD=0, .expGwXRP=1000, .expOffersGw=0, .lastGwBuyUSD=false}, // Same as three tests above since limited by buy 300USD (gw offer is unfunded) //*maxAmt sendMax limitEUR expectEUR outstandingUSD edBuy danBuy bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=380, .dstTrustLimit=400, .dstExpectEUR=300, .outstandingUSD=200, .expEdBuyUSD=0, .expDanBuyUSD=100, .expBobSellUSD=100, .expGwXRP=1000, .expOffersGw=0, .lastGwBuyUSD=false}, }; // clang-format on for (auto const& t : tests) test(t); } // Cross-currency payment. BookStep issues, the first step // redeems. { Account const ed{"ed"}; struct TestData { int maxAmt; int sendMax; int gwOffer; // quality == 1 int dstExpectXRP; int outstandingUSD; int expBobBuyUSD; int expGwXRP; // whole XRP excluding the fees std::uint8_t expOffersGw; bool lastGwBuyUSD; [[nodiscard]] std::uint8_t expOffersBob() const { // partially crossed if < 100 return expBobBuyUSD < 100 ? 1 : 0; } }; auto test = [&](TestData const& d) { Env env(*this); env.fund(XRP(1'000), gw, alice, carol, bob, ed); env.close(); MPT const usd = MPTTester({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = d.maxAmt}); env(pay(gw, alice, usd(300))); env.close(); env(offer(carol, usd(100), XRP(100))); env.close(); if (!d.lastGwBuyUSD) { env(offer(gw, usd(d.gwOffer), XRP(d.gwOffer))); env.close(); } env(offer(bob, usd(100), XRP(100))); env.close(); if (d.lastGwBuyUSD) { env(offer(gw, usd(d.gwOffer), XRP(d.gwOffer))); env.close(); } BEAST_EXPECT(expectOffers(env, carol, 1)); BEAST_EXPECT(expectOffers(env, bob, 1)); BEAST_EXPECT(expectOffers(env, gw, 1)); BEAST_EXPECT(env.balance(gw, usd) == usd(-300)); env(pay(alice, ed, XRP(300)), Sendmax(usd(d.sendMax)), Path(~XRP), Txflags(tfPartialPayment | tfNoRippleDirect)); env.close(); auto const baseFee = env.current()->fees().base.drops(); BEAST_EXPECT(env.balance(alice, usd) == usd(300 - d.sendMax)); BEAST_EXPECT(env.balance(carol, usd) == usd(100)); BEAST_EXPECT(env.balance(bob, usd) == usd(d.expBobBuyUSD)); BEAST_EXPECT(env.balance(ed) == XRP(d.dstExpectXRP)); BEAST_EXPECT(env.balance(gw, usd) == usd(-d.outstandingUSD)); BEAST_EXPECT(env.balance(gw) == XRPAmount{d.expGwXRP * kDropsPerXrp - baseFee * 3}); BEAST_EXPECT(expectOffers(env, carol, 0)); BEAST_EXPECT(expectOffers(env, bob, d.expOffersBob())); BEAST_EXPECT(expectOffers(env, gw, d.expOffersGw)); }; // clang-format off std::vector const tests = { // Buy USD: carol, gw, bob are consumed. // Gw gets 300USD from alice; carol and bob buy 200USD, // therefore OutstandingAmount is 200. //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .gwOffer=100, .dstExpectXRP=1300, .outstandingUSD=200, .expBobBuyUSD=100, .expGwXRP=900, .expOffersGw=0, .lastGwBuyUSD=false}, // Same as above. Gw offer location in the order book doesn't matter //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .gwOffer=100, .dstExpectXRP=1300, .outstandingUSD=200, .expBobBuyUSD=100, .expGwXRP=900, .expOffersGw=0, .lastGwBuyUSD=true}, // Buy USD: carol, gw are consumed. bob's offer remains on the order book. // Gw gets 300USD from alice; carol buys 100USD, // therefore OutstandingAmount is 100. //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .gwOffer=200, .dstExpectXRP=1300, .outstandingUSD=100, .expBobBuyUSD=0, .expGwXRP=800, .expOffersGw=0, .lastGwBuyUSD=false}, // Buy USD: carol, bob are consumed; gw's is partially consumed (100/100) since it's last. // Gw gets 300USD from alice; carol and bob buy 200USD, // therefore OutstandingAmount is 200. //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .gwOffer=200, .dstExpectXRP=1300, .outstandingUSD=200, .expBobBuyUSD=100, .expGwXRP=900, .expOffersGw=1, .lastGwBuyUSD=true}, // Buy USD: carol, bob are consumed; gw's is partially consumed (50/50) since it's last // and sendMax limits the output. // Gw gets 250USD from alice; carol and bob buy 200USD, alice has 50USD left, // therefore OutstandingAmount is 200. //*maxAmt sendMax gwOffer dstXRP outstandingUSD bobBuy gwXRP offersGw lastGw { .maxAmt=300, .sendMax=250, .gwOffer=200, .dstExpectXRP=1250, .outstandingUSD=250, .expBobBuyUSD=100, .expGwXRP=950, .expOffersGw=1, .lastGwBuyUSD=true}, }; // clang-format on for (auto const& t : tests) test(t); } // Cross-currency payment. BookStep redeems, the last step // issues. { Account const ed{"ed"}; struct TestData { int maxAmt; int sendMax; int initDst; int gwOffer; // quality == 1 int dstExpectUSD; int outstandingUSD; int expAliceXRP; // whole XRP excluding the fees int expBobSellUSD; int expGwXRP; std::uint8_t expOffersGw; bool lastGwBuyUSD; [[nodiscard]] std::uint8_t expOffersBob() const { return expBobSellUSD > 0 && expBobSellUSD < 100 ? 1 : 0; } }; auto test = [&](TestData const& d) { Env env(*this); env.fund(XRP(1'000), gw, alice, carol, bob, ed); env.close(); MPT const usd = MPTTester( {.env = env, .issuer = gw, .holders = {carol, bob, ed}, .maxAmt = d.maxAmt}); if (d.initDst != 0) env(pay(gw, ed, usd(d.initDst))); env(pay(gw, carol, usd(100))); env(pay(gw, bob, usd(100))); env.close(); env(offer(carol, XRP(100), usd(100))); env.close(); if (!d.lastGwBuyUSD) { env(offer(gw, XRP(d.gwOffer), usd(d.gwOffer))); env.close(); } env(offer(bob, XRP(100), usd(100))); env.close(); if (d.lastGwBuyUSD) { env(offer(gw, XRP(d.gwOffer), usd(d.gwOffer))); env.close(); } BEAST_EXPECT(expectOffers(env, carol, 1)); BEAST_EXPECT(expectOffers(env, bob, 1)); BEAST_EXPECT(expectOffers(env, gw, 1)); BEAST_EXPECT(env.balance(gw, usd) == usd(-200 - d.initDst)); env(pay(alice, ed, usd(300)), Sendmax(XRP(d.sendMax)), Path(~usd), Txflags(tfPartialPayment | tfNoRippleDirect)); env.close(); auto const baseFee = env.current()->fees().base.drops(); BEAST_EXPECT( env.balance(alice) == XRPAmount{d.expAliceXRP * kDropsPerXrp - baseFee}); BEAST_EXPECT(env.balance(carol, usd) == usd(0)); BEAST_EXPECT(env.balance(bob, usd) == usd(100 - d.expBobSellUSD)); BEAST_EXPECT(env.balance(ed, usd) == usd(d.dstExpectUSD)); BEAST_EXPECT(env.balance(gw, usd) == usd(-d.outstandingUSD)); BEAST_EXPECT( env.balance(gw) == XRPAmount{ d.expGwXRP * kDropsPerXrp - baseFee * (4 + (d.initDst != 0 ? 1 : 0))}); BEAST_EXPECT(expectOffers(env, carol, 0)); BEAST_EXPECT(expectOffers(env, bob, d.expOffersBob())); BEAST_EXPECT(expectOffers(env, gw, d.expOffersGw)); }; // clang-format off std::vector const tests = { // Sell USD: carol, gw, bob are consumed. // ed buys 300USD from carol, gw, bob therefore OutstandingAmount is 300. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .initDst=0, .gwOffer=100, .dstExpectUSD=300, .outstandingUSD=300, .expAliceXRP=700, .expBobSellUSD=100, .expGwXRP=1100, .expOffersGw=0, .lastGwBuyUSD=false}, // Same as above. Gw offer location in the order book doesn't matter //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .initDst=0, .gwOffer=100, .dstExpectUSD=300, .outstandingUSD=300, .expAliceXRP=700, .expBobSellUSD=100, .expGwXRP=1100, .expOffersGw=0, .lastGwBuyUSD=true}, // Sell USD: carol, bob are consumed, gw is partially consumed. // ed buys 200 from carol and bob and 50 from gw because gw can only issue 50 // (300(max) - 200(carol+bob) - 50(ed)). ed buys 250 from carol, gw, bob and has 50 initially, // therefore OutstandingAmount is 300. // gw's offer is removed from the order book because it's partially consumed and the remaining // offer is unfunded. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .initDst=50, .gwOffer=100, .dstExpectUSD=300, .outstandingUSD=300, .expAliceXRP=750, .expBobSellUSD=100, .expGwXRP=1050, .expOffersGw=0, .lastGwBuyUSD=false}, // Same as above. Gw offer location in the order book doesn't matter. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .initDst=50, .gwOffer=100, .dstExpectUSD=300, .outstandingUSD=300, .expAliceXRP=750, .expBobSellUSD=100, .expGwXRP=1050, .expOffersGw=0, .lastGwBuyUSD=true}, // Same as above. Gw offer size doesn't matter. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=300, .initDst=50, .gwOffer=200, .dstExpectUSD=300, .outstandingUSD=300, .expAliceXRP=750, .expBobSellUSD=100, .expGwXRP=1050, .expOffersGw=0, .lastGwBuyUSD=true}, // Sell USD: carol, gw are consumed, bob is partially consumed. // ed buys 200 from carol and gw and 50 form bob because of sendMax limit. bob keeps 50, // therefore OutstandingAmount is 300. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=250, .initDst=0, .gwOffer=100, .dstExpectUSD=250, .outstandingUSD=300, .expAliceXRP=750, .expBobSellUSD=50, .expGwXRP=1100, .expOffersGw=0, .lastGwBuyUSD=false}, // Sell USD: carol, bob are consumed, gw is partially consumed because of sendMax limit. // ed buys 200 from carol and bob and 50 from gw. Therefore, OutstandingAmount is 250. // gw's offer remains on the order book because it's partially consumed and has more funds. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=250, .initDst=0, .gwOffer=100, .dstExpectUSD=250, .outstandingUSD=250, .expAliceXRP=750, .expBobSellUSD=100, .expGwXRP=1050, .expOffersGw=1, .lastGwBuyUSD=true}, // Sell USD: carol, bob are consumed, gw is partially consumed because of sendMax limit, also // there is only 50 available to issue. ed buys 200 from carol and bob and 50 from gw, plus // he has initially 50, therefore OutstandingAmount is 300. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=250, .initDst=50, .gwOffer=100, .dstExpectUSD=300, .outstandingUSD=300, .expAliceXRP=750, .expBobSellUSD=100, .expGwXRP=1050, .expOffersGw=0, .lastGwBuyUSD=true}, // Sell USD: carol, bob are consumed, gw is not consumed because there is not available funds // to issue. ed buys 200 from carol and bob and, plus he has initially 100, // therefore OutstandingAmount is 300. gw offer is removed because it's unfunded. //*maxAmt sendMax initDst gwOffer dstUSD outstandingUSD aliceXRP bobSell gwXRP offersGw lastGw { .maxAmt=300, .sendMax=250, .initDst=100, .gwOffer=100, .dstExpectUSD=300, .outstandingUSD=300, .expAliceXRP=800, .expBobSellUSD=100, .expGwXRP=1000, .expOffersGw=0, .lastGwBuyUSD=true}, }; // clang-format on for (auto const& t : tests) test(t); } // Cross-currency payment with BookStep as the first step. // BookStep limits the buy amount. { auto test = [&](int sendMax, std::uint16_t dstXRP, std::uint8_t expGwOffers) { Env env(*this); env.fund(XRP(1'000), gw, alice, carol); MPT const usd = MPTTester({.env = env, .issuer = gw, .maxAmt = 300}); env(offer(carol, usd(400), XRP(400))); env(offer(gw, usd(100), XRP(100))); BEAST_EXPECT(expectOffers(env, carol, 1)); BEAST_EXPECT(expectOffers(env, gw, 1)); env(pay(gw, alice, XRP(500)), Sendmax(usd(sendMax)), Path(~XRP), Txflags(tfPartialPayment | tfNoRippleDirect)); BEAST_EXPECT(env.balance(alice) == XRP(dstXRP)); BEAST_EXPECT(env.balance(gw, usd) == usd(-300)); BEAST_EXPECT(env.balance(carol, usd) == usd(300)); BEAST_EXPECT(expectOffers(env, carol, 0)); BEAST_EXPECT(expectOffers(env, gw, expGwOffers)); }; // carol's offer is partially consumed - 300USD/300XRP // because available amount to issue is 300USD. gw's // offer is fully consumed because it doesn't change // OutstandingAmount. Both offers are removed from the // order book - carol's offer is unfunded and gw's offer // is fully consumed. test(500, 1'400, 0); // carol's offer is partially consumed - 300USD/300XRP // because available amount to issue is 300USD. gw's // offer is partially consumed because of sendMax limit. // carol's offer is removed from the order book because // it's unfunded. gw's offer remains on the order book // because it's partially consumed and gw has more // funds. test(350, 1'350, 1); } } void testLockedMidPathHolder(FeatureBitset features) { // Regression: a cross-currency strand whose second book step // consumes the offer of a holder that is locked on the step's // in-asset (an MPT). The strand is XRP -> [book1: XRP/USD] -> // USD -> [book2: USD/EUR] -> EUR, so book2 has book_.in == USD // (an MPT) and its previous step is another BookStep. That is // exactly the checkMPTDEX() branch that trusts the preceding // BookStep and no longer re-checks isFrozen(owner, book_.in). // // The bypass the branch might appear to open does not exist: // for MPT, isDeepFrozen() == isFrozen() (frozen MPTs can neither // send nor receive), and OfferStream gates every offer through // isDeepFrozen(owner, assetIn) before it can reach checkMPTDEX(). // So a locked mid-path holder's offer is removed by the liquidity // source and the strand simply finds no liquidity at book2. testcase("Locked mid-path holder behind a BookStep"); using namespace jtx; Account const gw("gw"); Account const alice("alice"); // book1 (XRP/USD) offer owner Account const mid("mid"); // book2 (USD/EUR) offer owner Account const sam("sam"); // source Account const bill("bill"); // destination auto const test = [&](bool lock) { Env env(*this, features); env.fund(XRP(1'000), gw, alice, mid, sam, bill); env.close(); auto usd = MPTTester( {.env = env, .issuer = gw, .holders = {alice, mid}, .flags = kMptDexFlags | tfMPTCanLock, .maxAmt = 1'000}); auto const eur = gw["EUR"]; // alice funds book1 (sells USD for XRP); mid funds book2 // (sells EUR for USD, i.e. receives the mid-path USD). env(pay(gw, alice, usd(100))); env(trust(mid, eur(100))); env(pay(gw, mid, eur(100))); env(trust(bill, eur(100))); env.close(); env(offer(alice, XRP(100), usd(100))); // XRP/USD, sells USD env.close(); env(offer(mid, usd(100), eur(100))); // USD/EUR, sells EUR env.close(); BEAST_EXPECT(expectOffers(env, alice, 1)); BEAST_EXPECT(expectOffers(env, mid, 1)); // Lock mid on USD *after* its offer is already on the book: // the reviewer's "frozen holder's offer sits behind a // BookStep" scenario. if (lock) { usd.set({.holder = mid, .flags = tfMPTLock}); env.close(); } env(pay(sam, bill, eur(100)), Sendmax(XRP(100)), Path(~usd, ~eur), Txflags(tfNoRippleDirect), // book1 (XRP/USD) still has liquidity, so the strand is // not fully dry; it just cannot cross book2 once mid's // offer is removed, hence PARTIAL rather than DRY. Ter(lock ? TER(tecPATH_PARTIAL) : TER(tesSUCCESS))); env.close(); if (lock) { // No liquidity reached book2: mid neither received USD // nor delivered EUR, so bill received nothing. BEAST_EXPECT(env.balance(bill, eur) == eur(0)); BEAST_EXPECT(env.balance(mid, usd) == usd(0)); } else { // The strand crosses both books: mid receives the // mid-path USD and bill receives EUR. BEAST_EXPECT(env.balance(bill, eur) == eur(100)); BEAST_EXPECT(env.balance(mid, usd) == usd(100)); BEAST_EXPECT(env.balance(alice, usd) == usd(0)); BEAST_EXPECT(expectOffers(env, alice, 0)); BEAST_EXPECT(expectOffers(env, mid, 0)); } }; test(false); // baseline: unlocked strand succeeds test(true); // locked mid-path holder: strand finds no liquidity } void testWithFeats(FeatureBitset features) { using namespace jtx; testMaxAndSelfPaymentEdgeCases(features); testFalseDry(features); testDirectStep(features); testBookStep(features); testOfferOwnerMPTCreation(features); testTransferRate(features); testMPTEndpointTransferRateOverflow(features); testMPTEndpointRipplingInputOverflow(features); testSelfPayment1(features); testSelfPayment2(features); testSelfFundedXRPEndpoint(false, features); testSelfFundedXRPEndpoint(true, features); testUnfundedOffer(features); testReExecuteDirectStep(features); testSelfPayLowQualityOffer(features); testLockedMidPathHolder(features); } void run() override { using namespace jtx; auto const sa = testableAmendments(); testLimitQuality(); testXRPPathLoop(); testWithFeats(sa); testEmptyStrand(sa); } }; BEAST_DEFINE_TESTSUITE_PRIO(FlowMPT, app, xrpl, 2); } // namespace xrpl::test