#include #include #include #include #include namespace ripple { namespace test { class ReducedOffer_test : public beast::unit_test::suite { static auto ledgerEntryOffer( jtx::Env& env, jtx::Account const& acct, std::uint32_t offer_seq) { Json::Value jvParams; jvParams[jss::offer][jss::account] = acct.human(); jvParams[jss::offer][jss::seq] = offer_seq; return env.rpc( "json", "ledger_entry", to_string(jvParams))[jss::result]; } static bool offerInLedger( jtx::Env& env, jtx::Account const& acct, std::uint32_t offerSeq) { Json::Value ledgerOffer = ledgerEntryOffer(env, acct, offerSeq); return !( ledgerOffer.isMember(jss::error) && ledgerOffer[jss::error].asString() == "entryNotFound"); } // Common code to clean up unneeded offers. static void cleanupOldOffers( jtx::Env& env, std::initializer_list> list) { for (auto [acct, offerSeq] : list) env(offer_cancel(acct, offerSeq)); env.close(); } public: void testPartialCrossNewXrpIouQChange() { testcase("exercise partial cross new XRP/IOU offer Q change"); using namespace jtx; auto const gw = Account{"gateway"}; auto const alice = Account{"alice"}; auto const bob = Account{"bob"}; auto const USD = gw["USD"]; { Env env{*this, testable_amendments()}; // Make sure none of the offers we generate are under funded. env.fund(XRP(10'000'000), gw, alice, bob); env.close(); env(trust(alice, USD(10'000'000))); env(trust(bob, USD(10'000'000))); env.close(); env(pay(gw, bob, USD(10'000'000))); env.close(); // Lambda that: // 1. Exercises one offer pair, // 2. Collects the results, and // 3. Cleans up for the next offer pair. // Returns 1 if the crossed offer has a bad rate for the book. auto exerciseOfferPair = [this, &env, &alice, &bob]( Amounts const& inLedger, Amounts const& newOffer) -> unsigned int { // Put inLedger offer in the ledger so newOffer can cross it. std::uint32_t const aliceOfferSeq = env.seq(alice); env(offer(alice, inLedger.in, inLedger.out)); env.close(); // Now alice's offer will partially cross bob's offer. STAmount const initialRate = Quality(newOffer).rate(); std::uint32_t const bobOfferSeq = env.seq(bob); STAmount const bobInitialBalance = env.balance(bob); STAmount const bobsFee = env.current()->fees().base; env(offer(bob, newOffer.in, newOffer.out, tfSell), fee(bobsFee)); env.close(); STAmount const bobFinalBalance = env.balance(bob); // alice's offer should be fully crossed and so gone from // the ledger. if (!BEAST_EXPECT(!offerInLedger(env, alice, aliceOfferSeq))) // If the in-ledger offer was not consumed then further // results are meaningless. return 1; // bob's offer should be in the ledger, but reduced in size. unsigned int badRate = 1; { Json::Value bobOffer = ledgerEntryOffer(env, bob, bobOfferSeq); STAmount const reducedTakerGets = amountFromJson( sfTakerGets, bobOffer[jss::node][sfTakerGets.jsonName]); STAmount const reducedTakerPays = amountFromJson( sfTakerPays, bobOffer[jss::node][sfTakerPays.jsonName]); STAmount const bobGot = env.balance(bob) + bobsFee - bobInitialBalance; BEAST_EXPECT(reducedTakerPays < newOffer.in); BEAST_EXPECT(reducedTakerGets < newOffer.out); STAmount const inLedgerRate = Quality(Amounts{reducedTakerPays, reducedTakerGets}) .rate(); badRate = inLedgerRate > initialRate ? 1 : 0; // If the inLedgerRate is less than initial rate, then // incrementing the mantissa of the reduced taker pays // should result in a rate higher than initial. Check // this to verify that the largest allowable TakerPays // was computed. if (badRate == 0) { STAmount const tweakedTakerPays = reducedTakerPays + drops(1); STAmount const tweakedRate = Quality(Amounts{tweakedTakerPays, reducedTakerGets}) .rate(); BEAST_EXPECT(tweakedRate > initialRate); } #if 0 std::cout << "Placed rate: " << initialRate << "; in-ledger rate: " << inLedgerRate << "; TakerPays: " << reducedTakerPays << "; TakerGets: " << reducedTakerGets << "; bob already got: " << bobGot << std::endl; // #else std::string_view filler = inLedgerRate > initialRate ? "**" : " "; std::cout << "| `" << reducedTakerGets << "` | `" << reducedTakerPays << "` | `" << initialRate << "` | " << filler << "`" << inLedgerRate << "`" << filler << " |`" << std::endl; #endif } // In preparation for the next iteration make sure the two // offers are gone from the ledger. cleanupOldOffers( env, {{alice, aliceOfferSeq}, {bob, bobOfferSeq}}); return badRate; }; // bob's offer (the new offer) is the same every time: Amounts const bobsOffer{ STAmount(XRP(1)), STAmount(USD.issue(), 1, 0)}; // alice's offer has a slightly smaller TakerPays with each // iteration. This should mean that the size of the offer bob // places in the ledger should increase with each iteration. unsigned int blockedCount = 0; for (std::uint64_t mantissaReduce = 1'000'000'000ull; mantissaReduce <= 5'000'000'000ull; mantissaReduce += 20'000'000ull) { STAmount aliceUSD{ bobsOffer.out.issue(), bobsOffer.out.mantissa() - mantissaReduce, bobsOffer.out.exponent()}; STAmount aliceXRP{ bobsOffer.in.issue(), bobsOffer.in.mantissa() - 1}; Amounts alicesOffer{aliceUSD, aliceXRP}; blockedCount += exerciseOfferPair(alicesOffer, bobsOffer); } // None of the test cases should produce a potentially blocking // rate. BEAST_EXPECT(blockedCount == 0); } } void testPartialCrossOldXrpIouQChange() { testcase("exercise partial cross old XRP/IOU offer Q change"); using namespace jtx; auto const gw = Account{"gateway"}; auto const alice = Account{"alice"}; auto const bob = Account{"bob"}; auto const USD = gw["USD"]; { // Make sure none of the offers we generate are under funded. Env env{*this, testable_amendments()}; env.fund(XRP(10'000'000), gw, alice, bob); env.close(); env(trust(alice, USD(10'000'000))); env(trust(bob, USD(10'000'000))); env.close(); env(pay(gw, alice, USD(10'000'000))); env.close(); // Lambda that: // 1. Exercises one offer pair, // 2. Collects the results, and // 3. Cleans up for the next offer pair. auto exerciseOfferPair = [this, &env, &alice, &bob]( Amounts const& inLedger, Amounts const& newOffer) -> unsigned int { // Get the inLedger offer into the ledger so newOffer can cross // it. STAmount const initialRate = Quality(inLedger).rate(); std::uint32_t const aliceOfferSeq = env.seq(alice); env(offer(alice, inLedger.in, inLedger.out)); env.close(); // Now bob's offer will partially cross alice's offer. std::uint32_t const bobOfferSeq = env.seq(bob); STAmount const aliceInitialBalance = env.balance(alice); env(offer(bob, newOffer.in, newOffer.out)); env.close(); STAmount const aliceFinalBalance = env.balance(alice); // bob's offer should not have made it into the ledger. if (!BEAST_EXPECT(!offerInLedger(env, bob, bobOfferSeq))) { // If the in-ledger offer was not consumed then further // results are meaningless. cleanupOldOffers( env, {{alice, aliceOfferSeq}, {bob, bobOfferSeq}}); return 1; } // alice's offer should still be in the ledger, but reduced in // size. unsigned int badRate = 1; { Json::Value aliceOffer = ledgerEntryOffer(env, alice, aliceOfferSeq); STAmount const reducedTakerGets = amountFromJson( sfTakerGets, aliceOffer[jss::node][sfTakerGets.jsonName]); STAmount const reducedTakerPays = amountFromJson( sfTakerPays, aliceOffer[jss::node][sfTakerPays.jsonName]); STAmount const aliceGot = env.balance(alice) - aliceInitialBalance; BEAST_EXPECT(reducedTakerPays < inLedger.in); BEAST_EXPECT(reducedTakerGets < inLedger.out); STAmount const inLedgerRate = Quality(Amounts{reducedTakerPays, reducedTakerGets}) .rate(); badRate = inLedgerRate > initialRate ? 1 : 0; // If the inLedgerRate is less than initial rate, then // incrementing the mantissa of the reduced taker pays // should result in a rate higher than initial. Check // this to verify that the largest allowable TakerPays // was computed. if (badRate == 0) { STAmount const tweakedTakerPays = reducedTakerPays + drops(1); STAmount const tweakedRate = Quality(Amounts{tweakedTakerPays, reducedTakerGets}) .rate(); BEAST_EXPECT(tweakedRate > initialRate); } #if 0 std::cout << "Placed rate: " << initialRate << "; in-ledger rate: " << inLedgerRate << "; TakerPays: " << reducedTakerPays << "; TakerGets: " << reducedTakerGets << "; alice already got: " << aliceGot << std::endl; // #else std::string_view filler = badRate ? "**" : " "; std::cout << "| `" << reducedTakerGets << "` | `" << reducedTakerPays << "` | `" << initialRate << "` | " << filler << "`" << inLedgerRate << "`" << filler << " | `" << aliceGot << "` |" << std::endl; #endif } // In preparation for the next iteration make sure the two // offers are gone from the ledger. cleanupOldOffers( env, {{alice, aliceOfferSeq}, {bob, bobOfferSeq}}); return badRate; }; // alice's offer (the old offer) is the same every time: Amounts const aliceOffer{ STAmount(XRP(1)), STAmount(USD.issue(), 1, 0)}; // bob's offer has a slightly smaller TakerPays with each iteration. // This should mean that the size of the offer alice leaves in the // ledger should increase with each iteration. unsigned int blockedCount = 0; for (std::uint64_t mantissaReduce = 1'000'000'000ull; mantissaReduce <= 4'000'000'000ull; mantissaReduce += 20'000'000ull) { STAmount bobUSD{ aliceOffer.out.issue(), aliceOffer.out.mantissa() - mantissaReduce, aliceOffer.out.exponent()}; STAmount bobXRP{ aliceOffer.in.issue(), aliceOffer.in.mantissa() - 1}; Amounts bobsOffer{bobUSD, bobXRP}; blockedCount += exerciseOfferPair(aliceOffer, bobsOffer); } // None of the test cases should produce a potentially blocking // rate. BEAST_EXPECT(blockedCount == 0); } } void testUnderFundedXrpIouQChange() { testcase("exercise underfunded XRP/IOU offer Q change"); // Bob places an offer that is not fully funded. using namespace jtx; auto const alice = Account{"alice"}; auto const bob = Account{"bob"}; auto const gw = Account{"gw"}; auto const USD = gw["USD"]; { Env env{*this, testable_amendments()}; env.fund(XRP(10000), alice, bob, gw); env.close(); env.trust(USD(1000), alice, bob); int blockedOrderBookCount = 0; for (STAmount initialBobUSD = USD(0.45); initialBobUSD <= USD(1); initialBobUSD += USD(0.025)) { // underfund bob's offer env(pay(gw, bob, initialBobUSD)); env.close(); std::uint32_t const bobOfferSeq = env.seq(bob); env(offer(bob, drops(2), USD(1))); env.close(); // alice places an offer that would cross bob's if bob's were // well funded. std::uint32_t const aliceOfferSeq = env.seq(alice); env(offer(alice, USD(1), drops(2))); env.close(); // We want to detect order book blocking. If: // 1. bob's offer is still in the ledger and // 2. alice received no USD // then we use that as evidence that bob's offer blocked the // order book. { bool const bobsOfferGone = !offerInLedger(env, bob, bobOfferSeq); STAmount const aliceBalanceUSD = env.balance(alice, USD); // Sanity check the ledger if alice got USD. if (aliceBalanceUSD.signum() > 0) { BEAST_EXPECT(aliceBalanceUSD == initialBobUSD); BEAST_EXPECT(env.balance(bob, USD) == USD(0)); BEAST_EXPECT(bobsOfferGone); } // Track occurrences of order book blocking. if (!bobsOfferGone && aliceBalanceUSD.signum() == 0) { ++blockedOrderBookCount; } // In preparation for the next iteration clean up any // leftover offers. cleanupOldOffers( env, {{alice, aliceOfferSeq}, {bob, bobOfferSeq}}); // Zero out alice's and bob's USD balances. if (STAmount const aliceBalance = env.balance(alice, USD); aliceBalance.signum() > 0) env(pay(alice, gw, aliceBalance)); if (STAmount const bobBalance = env.balance(bob, USD); bobBalance.signum() > 0) env(pay(bob, gw, bobBalance)); env.close(); } } // None of the test cases should produce a potentially blocking // rate. BEAST_EXPECT(blockedOrderBookCount == 0); } } void testUnderFundedIouIouQChange() { testcase("exercise underfunded IOU/IOU offer Q change"); // Bob places an IOU/IOU offer that is not fully funded. using namespace jtx; using namespace std::chrono_literals; auto const alice = Account{"alice"}; auto const bob = Account{"bob"}; auto const gw = Account{"gw"}; auto const USD = gw["USD"]; auto const EUR = gw["EUR"]; STAmount const tinyUSD(USD.issue(), /*mantissa*/ 1, /*exponent*/ -81); { Env env{*this, testable_amendments()}; env.fund(XRP(10000), alice, bob, gw); env.close(); env.trust(USD(1000), alice, bob); env.trust(EUR(1000), alice, bob); STAmount const eurOffer( EUR.issue(), /*mantissa*/ 2957, /*exponent*/ -76); STAmount const usdOffer( USD.issue(), /*mantissa*/ 7109, /*exponent*/ -76); STAmount const endLoop( USD.issue(), /*mantissa*/ 50, /*exponent*/ -81); int blockedOrderBookCount = 0; for (STAmount initialBobUSD = tinyUSD; initialBobUSD <= endLoop; initialBobUSD += tinyUSD) { // underfund bob's offer env(pay(gw, bob, initialBobUSD)); env(pay(gw, alice, EUR(100))); env.close(); // This offer is underfunded std::uint32_t bobOfferSeq = env.seq(bob); env(offer(bob, eurOffer, usdOffer)); env.close(); env.require(offers(bob, 1)); // alice places an offer that crosses bob's. std::uint32_t aliceOfferSeq = env.seq(alice); env(offer(alice, usdOffer, eurOffer)); env.close(); // Examine the aftermath of alice's offer. { bool const bobsOfferGone = !offerInLedger(env, bob, bobOfferSeq); STAmount aliceBalanceUSD = env.balance(alice, USD); #if 0 std::cout << "bobs initial: " << initialBobUSD << "; alice final: " << aliceBalanceUSD << "; bobs offer: " << bobsOfferJson.toStyledString() << std::endl; #endif // Sanity check the ledger if alice got USD. if (aliceBalanceUSD.signum() > 0) { BEAST_EXPECT(aliceBalanceUSD == initialBobUSD); BEAST_EXPECT(env.balance(bob, USD) == USD(0)); BEAST_EXPECT(bobsOfferGone); } // Track occurrences of order book blocking. if (!bobsOfferGone && aliceBalanceUSD.signum() == 0) { ++blockedOrderBookCount; } } // In preparation for the next iteration clean up any // leftover offers. cleanupOldOffers( env, {{alice, aliceOfferSeq}, {bob, bobOfferSeq}}); // Zero out alice's and bob's IOU balances. auto zeroBalance = [&env, &gw]( Account const& acct, IOU const& iou) { if (STAmount const balance = env.balance(acct, iou); balance.signum() > 0) env(pay(acct, gw, balance)); }; zeroBalance(alice, EUR); zeroBalance(alice, USD); zeroBalance(bob, EUR); zeroBalance(bob, USD); env.close(); } // None of the test cases should produce a potentially blocking // rate. BEAST_EXPECT(blockedOrderBookCount == 0); } } Amounts jsonOfferToAmounts(Json::Value const& json) { STAmount const in = amountFromJson(sfTakerPays, json[sfTakerPays.jsonName]); STAmount const out = amountFromJson(sfTakerGets, json[sfTakerGets.jsonName]); return {in, out}; } void testSellPartialCrossOldXrpIouQChange() { // This test case was motivated by Issue #4937. It recreates // the specific failure identified in that issue and samples some other // cases in the same vicinity to make sure that the new behavior makes // sense. testcase("exercise tfSell partial cross old XRP/IOU offer Q change"); using namespace jtx; Account const gw("gateway"); Account const alice("alice"); Account const bob("bob"); Account const carol("carol"); auto const USD = gw["USD"]; // Make one test run without fixReducedOffersV2 and one with. for (FeatureBitset features : {testable_amendments() - fixReducedOffersV2, testable_amendments() | fixReducedOffersV2}) { // Make sure none of the offers we generate are under funded. Env env{*this, features}; env.fund(XRP(10'000'000), gw, alice, bob, carol); env.close(); env(trust(alice, USD(10'000'000))); env(trust(bob, USD(10'000'000))); env(trust(carol, USD(10'000'000))); env.close(); env(pay(gw, alice, USD(10'000'000))); env(pay(gw, bob, USD(10'000'000))); env(pay(gw, carol, USD(10'000'000))); env.close(); // Lambda that: // 1. Exercises one offer trio, // 2. Collects the results, and // 3. Cleans up for the next offer trio. auto exerciseOfferTrio = [this, &env, &alice, &bob, &carol, &USD]( Amounts const& carolOffer) -> unsigned int { // alice submits an offer that may become a blocker. std::uint32_t const aliceOfferSeq = env.seq(alice); static Amounts const aliceInitialOffer(USD(2), drops(3382562)); env(offer(alice, aliceInitialOffer.in, aliceInitialOffer.out)); env.close(); STAmount const initialRate = Quality(jsonOfferToAmounts(ledgerEntryOffer( env, alice, aliceOfferSeq)[jss::node])) .rate(); // bob submits an offer that is more desirable than alice's std::uint32_t const bobOfferSeq = env.seq(bob); env(offer(bob, USD(0.97086565812384), drops(1642020))); env.close(); // Now carol's offer consumes bob's and partially crosses // alice's. The tfSell flag is important. std::uint32_t const carolOfferSeq = env.seq(carol); env(offer(carol, carolOffer.in, carolOffer.out), txflags(tfSell)); env.close(); // carol's offer should not have made it into the ledger and // bob's offer should be fully consumed. if (!BEAST_EXPECT( !offerInLedger(env, carol, carolOfferSeq) && !offerInLedger(env, bob, bobOfferSeq))) { // If carol's or bob's offers are still in the ledger then // further results are meaningless. cleanupOldOffers( env, {{alice, aliceOfferSeq}, {bob, bobOfferSeq}, {carol, carolOfferSeq}}); return 1; } // alice's offer should still be in the ledger, but reduced in // size. unsigned int badRate = 1; { Json::Value aliceOffer = ledgerEntryOffer(env, alice, aliceOfferSeq); Amounts aliceReducedOffer = jsonOfferToAmounts(aliceOffer[jss::node]); BEAST_EXPECT(aliceReducedOffer.in < aliceInitialOffer.in); BEAST_EXPECT(aliceReducedOffer.out < aliceInitialOffer.out); STAmount const inLedgerRate = Quality(aliceReducedOffer).rate(); badRate = inLedgerRate > initialRate ? 1 : 0; // If the inLedgerRate is less than initial rate, then // incrementing the mantissa of the reduced TakerGets // should result in a rate higher than initial. Check // this to verify that the largest allowable TakerGets // was computed. if (badRate == 0) { STAmount const tweakedTakerGets( aliceReducedOffer.in.issue(), aliceReducedOffer.in.mantissa() + 1, aliceReducedOffer.in.exponent(), aliceReducedOffer.in.negative()); STAmount const tweakedRate = Quality( Amounts{aliceReducedOffer.in, tweakedTakerGets}) .rate(); BEAST_EXPECT(tweakedRate > initialRate); } #if 0 std::cout << "Placed rate: " << initialRate << "; in-ledger rate: " << inLedgerRate << "; TakerPays: " << aliceReducedOffer.in << "; TakerGets: " << aliceReducedOffer.out << std::endl; // #else std::string_view filler = badRate ? "**" : " "; std::cout << "| " << aliceReducedOffer.in << "` | `" << aliceReducedOffer.out << "` | `" << initialRate << "` | " << filler << "`" << inLedgerRate << "`" << filler << std::endl; #endif } // In preparation for the next iteration make sure all three // offers are gone from the ledger. cleanupOldOffers( env, {{alice, aliceOfferSeq}, {bob, bobOfferSeq}, {carol, carolOfferSeq}}); return badRate; }; constexpr int loopCount = 100; unsigned int blockedCount = 0; { STAmount increaseGets = USD(0); STAmount const step(increaseGets.issue(), 1, -8); for (unsigned int i = 0; i < loopCount; ++i) { blockedCount += exerciseOfferTrio( Amounts(drops(1642020), USD(1) + increaseGets)); increaseGets += step; } } // If fixReducedOffersV2 is enabled, then none of the test cases // should produce a potentially blocking rate. // // Also verify that if fixReducedOffersV2 is not enabled then // some of the test cases produced a potentially blocking rate. if (features[fixReducedOffersV2]) { BEAST_EXPECT(blockedCount == 0); } else { BEAST_EXPECT(blockedCount > 80); } } } void run() override { testPartialCrossNewXrpIouQChange(); testPartialCrossOldXrpIouQChange(); testUnderFundedXrpIouQChange(); testUnderFundedIouIouQChange(); testSellPartialCrossOldXrpIouQChange(); } }; BEAST_DEFINE_TESTSUITE_PRIO(ReducedOffer, app, ripple, 2); } // namespace test } // namespace ripple