From 25a42195c9c38f9402a283e7b35d307a4cd8e513 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Mon, 23 Oct 2023 15:03:24 -0400 Subject: [PATCH] [FOLD] Update get_aggregate_price with historical data, and other refactoring --- src/ripple/app/tx/impl/SetOracle.cpp | 28 +- .../protocol/impl/InnerObjectFormats.cpp | 4 +- src/ripple/protocol/jss.h | 4 +- src/ripple/rpc/handlers/GetAggregatePrice.cpp | 330 ++++++++++++++---- src/test/jtx/Oracle.h | 7 +- src/test/jtx/impl/Oracle.cpp | 41 ++- src/test/rpc/GetAggregatePrice_test.cpp | 202 ++++++++--- 7 files changed, 464 insertions(+), 152 deletions(-) diff --git a/src/ripple/app/tx/impl/SetOracle.cpp b/src/ripple/app/tx/impl/SetOracle.cpp index 12c804d77d7..81456136675 100644 --- a/src/ripple/app/tx/impl/SetOracle.cpp +++ b/src/ripple/app/tx/impl/SetOracle.cpp @@ -43,11 +43,17 @@ SetOracle::preflight(PreflightContext const& ctx) return temINVALID_FLAG; } - if (ctx.tx.getFieldArray(sfPriceDataSeries).size() > maxOracleDataSeries) + auto const dataSeries = ctx.tx.getFieldArray(sfPriceDataSeries); + if (dataSeries.size() == 0 || dataSeries.size() > maxOracleDataSeries) { - JLOG(ctx.j.debug()) << "Oracle Set: price data series too large"; + JLOG(ctx.j.debug()) << "Oracle Set: invalid price data series size"; return temARRAY_SIZE; } + for (auto const& entry : dataSeries) + { + if (!entry.isFieldPresent(sfSymbolPrice)) + return temMALFORMED; + } if (ctx.tx.getFieldVL(sfProvider).size() > maxOracleProvider) { @@ -84,6 +90,7 @@ SetOracle::preclaim(PreclaimContext const& ctx) pairs.emplace(hash); } + // update if (auto const sle = ctx.view.read(keylet::oracle( ctx.tx.getAccountID(sfAccount), ctx.tx[sfOracleSequence]))) { @@ -103,6 +110,7 @@ SetOracle::preclaim(PreclaimContext const& ctx) pairs.emplace(hash); } } + // create else { if (!ctx.tx.isFieldPresent(sfProvider) || @@ -110,7 +118,7 @@ SetOracle::preclaim(PreclaimContext const& ctx) return temMALFORMED; } - if (pairs.size() == 0 || pairs.size() > 10) + if (pairs.size() > maxOracleDataSeries) return temARRAY_SIZE; auto const sleSetter = @@ -140,10 +148,9 @@ applySet( { auto const oracleID = keylet::oracle(account_, ctx_.tx[sfOracleSequence]); + // update if (auto sle = sb.peek(oracleID)) { - // update Oracle - hash_map pairs; // collect current pairs for (auto const& entry : sle->getFieldArray(sfPriceDataSeries)) @@ -153,8 +160,6 @@ applySet( sfSymbol, entry.getFieldCurrency(sfSymbol)); priceData.setFieldCurrency( sfPriceUnit, entry.getFieldCurrency(sfPriceUnit)); - priceData.setFieldU64(sfSymbolPrice, 0); - priceData.setFieldU8(sfScale, 0); pairs.emplace( sha512Half( entry.getFieldCurrency(sfSymbol).currency(), @@ -171,7 +176,8 @@ applySet( { iter->second.setFieldU64( sfSymbolPrice, entry.getFieldU64(sfSymbolPrice)); - iter->second.setFieldU8(sfScale, entry.getFieldU8(sfScale)); + if (entry.isFieldPresent(sfScale)) + iter->second.setFieldU8(sfScale, entry.getFieldU8(sfScale)); } else { @@ -182,7 +188,8 @@ applySet( sfPriceUnit, entry.getFieldCurrency(sfPriceUnit)); priceData.setFieldU64( sfSymbolPrice, entry.getFieldU64(sfSymbolPrice)); - priceData.setFieldU8(sfScale, entry.getFieldU8(sfScale)); + if (entry.isFieldPresent(sfScale)) + priceData.setFieldU8(sfScale, entry.getFieldU8(sfScale)); pairs.emplace( sha512Half( entry.getFieldCurrency(sfSymbol).currency(), @@ -200,10 +207,9 @@ applySet( sb.update(sle); } + // create else { - // create new Oracle - sle = std::make_shared(oracleID); sle->setAccountID(sfOwner, ctx_.tx.getAccountID(sfAccount)); sle->setFieldVL(sfProvider, ctx_.tx[sfProvider]); diff --git a/src/ripple/protocol/impl/InnerObjectFormats.cpp b/src/ripple/protocol/impl/InnerObjectFormats.cpp index 1e25dde729a..93748c28c65 100644 --- a/src/ripple/protocol/impl/InnerObjectFormats.cpp +++ b/src/ripple/protocol/impl/InnerObjectFormats.cpp @@ -141,8 +141,8 @@ InnerObjectFormats::InnerObjectFormats() { {sfSymbol, soeREQUIRED}, {sfPriceUnit, soeREQUIRED}, - {sfSymbolPrice, soeREQUIRED}, - {sfScale, soeREQUIRED}, + {sfSymbolPrice, soeOPTIONAL}, + {sfScale, soeDEFAULT}, }); } diff --git a/src/ripple/protocol/jss.h b/src/ripple/protocol/jss.h index f7153d3ad09..57e07f88476 100644 --- a/src/ripple/protocol/jss.h +++ b/src/ripple/protocol/jss.h @@ -208,6 +208,7 @@ JSS(auth_accounts); // out: amm_info JSS(auth_change); // out: AccountInfo JSS(auth_change_queued); // out: AccountInfo JSS(available); // out: ValidatorList +JSS(average); // out: get_aggregate_price JSS(avg_bps_recv); // out: Peers JSS(avg_bps_sent); // out: Peers JSS(balance); // out: AccountLines @@ -307,6 +308,7 @@ JSS(enabled); // out: AmendmentTable JSS(engine_result); // out: NetworkOPs, TransactionSign, Submit JSS(engine_result_code); // out: NetworkOPs, TransactionSign, Submit JSS(engine_result_message); // out: NetworkOPs, TransactionSign, Submit +JSS(entire_set); // out: get_aggregate_price JSS(ephemeral_key); // out: ValidatorInfo // in/out: Manifest JSS(error); // out: error @@ -616,7 +618,6 @@ JSS(signing_keys); // out: ValidatorList JSS(signing_time); // out: NetworkOPs JSS(signer_list); // in: AccountObjects JSS(signer_lists); // in/out: AccountInfo -JSS(simple_average); // out: get_aggregate_price JSS(size); // out: get_aggregate_price JSS(snapshot); // in: Subscribe JSS(source_account); // in: PathRequest, RipplePathFind @@ -660,6 +661,7 @@ JSS(time_interval); // out: AMM Auction Slot JSS(track); // out: PeerImp JSS(traffic); // out: Overlay JSS(trim); // in: get_aggregate_price +JSS(trimmed_set); // out: get_aggregate_price JSS(total); // out: counters JSS(total_bytes_recv); // out: Peers JSS(total_bytes_sent); // out: Peers diff --git a/src/ripple/rpc/handlers/GetAggregatePrice.cpp b/src/ripple/rpc/handlers/GetAggregatePrice.cpp index c47046c2509..5c0a8f32d0e 100644 --- a/src/ripple/rpc/handlers/GetAggregatePrice.cpp +++ b/src/ripple/rpc/handlers/GetAggregatePrice.cpp @@ -25,17 +25,183 @@ #include #include +#include +#include + namespace ripple { +namespace { +struct CompareDescending +{ + bool + operator()(std::uint32_t const& a, std::uint32_t const& b) const + { + return a > b; // Sort lastUpdateTime in descending order + } +}; + +using namespace boost::bimaps; +using Prices = + bimap, multiset_of>; +using PriceData = std::pair; + +enum class Status { tokenPairNotFound, missingPrice }; +} // namespace + +/** find updateTime/price in PriceDataSeries for the given token pair. + * Return updateTime/price if found, error otherwise. + */ +static std::variant +findPriceData( + STObject const& node, + std::string const& symbol, + std::string const& priceUnit) +{ + auto const& series = node.getFieldArray(sfPriceDataSeries); + // find the token pair entry + if (auto iter = std::find_if( + series.begin(), + series.end(), + [&](STObject const& o) -> bool { + return o.getFieldCurrency(sfSymbol).getText() == symbol && + o.getFieldCurrency(sfPriceUnit).getText() == priceUnit; + }); + iter != series.end()) + { + if (iter->isFieldPresent(sfSymbolPrice)) + { + auto const price = iter->getFieldU64(sfSymbolPrice); + auto const scale = iter->isFieldPresent(sfScale) + ? -static_cast(iter->getFieldU8(sfScale)) + : 0; + // found the price + return std::make_pair( + node.getFieldU32(sfLastUpdateTime), + STAmount{noIssue(), price, scale}); + } + return Status::missingPrice; + } + return Status::tokenPairNotFound; +} + +/** Get historical price by traversing at most three previous ledgers with + * the oracle update transaction. Return updateTime/price if found, nullopt + * otherwise. + */ +static std::optional +getHistoricalPriceData( + RPC::JsonContext& context, + STObject const& node, + std::string const& symbol, + std::string const& priceUnit) +{ + constexpr std::uint8_t maxHistory = 3; + std::uint8_t cnt = 0; + uint256 prevTx = node.getFieldH256(sfPreviousTxnID); + std::optional prevSeq = + node.getFieldU32(sfPreviousTxnLgrSeq); + + auto getMeta = [&]() -> std::shared_ptr { + if (prevSeq && ++cnt <= maxHistory) + { + if (auto const ledger = + context.ledgerMaster.getLedgerBySeq(*prevSeq)) + { + prevSeq = std::nullopt; + return ledger->txRead(prevTx).second; + } + } + return nullptr; + }; + + while (auto const& meta = getMeta()) + { + for (STObject const& node : meta->getFieldArray(sfAffectedNodes)) + { + if (node.getFieldU16(sfLedgerEntryType) == ltORACLE) + { + bool const isUpdate = node.isFieldPresent(sfFinalFields); + auto const& fields = isUpdate + ? static_cast( + node.peekAtField(sfFinalFields)) + : static_cast( + node.peekAtField(sfNewFields)); + auto const& v = findPriceData(fields, symbol, priceUnit); + // found the price + if (std::holds_alternative(v)) + return std::get(v); + + // if missing price then try again + if (isUpdate && std::get(v) == Status::missingPrice) + { + prevTx = node.getFieldH256(sfPreviousTxnID); + prevSeq = node.getFieldU32(sfPreviousTxnLgrSeq); + } + // found the previous price oracle, but not necessarily the + // price + break; + } + } + } + return std::nullopt; +} + +/** Get lastUpdateTime/price from the price oracle identified + * by account/sequence. Go back up to three previous tx ledgers if the price + * is not included in the price oracle. Missing price indicates that the price + * for the token pair is not updated in the price oracle object. + */ +static std::optional> +getPriceData( + RPC::JsonContext& context, + STObject const& oracleSle, + std::string const& symbol, + std::string const& priceUnit) +{ + auto const v = findPriceData(oracleSle, symbol, priceUnit); + // found the price + if (std::holds_alternative(v)) + return std::get(v); + + // failed to find, try historical data + if (std::get(v) == Status::missingPrice) + return getHistoricalPriceData(context, oracleSle, symbol, priceUnit); + // the token pair is missing + return std::nullopt; +} + +// Return avg, sd, data set size +static std::tuple +getStats( + Prices::right_const_iterator const& begin, + Prices::right_const_iterator const& end) +{ + STAmount avg{noIssue(), 0, 0}; + Number sd{0}; + std::uint16_t size = std::distance(begin, end); + avg = std::accumulate( + begin, end, avg, [&](STAmount const& acc, auto const& it) { + return acc + it.first; + }); + avg = divide(avg, STAmount{noIssue(), size, 0}, noIssue()); + if (size > 1) + { + sd = std::accumulate( + begin, end, sd, [&](Number const& acc, auto const& it) { + return acc + (it.first - avg) * (it.first - avg); + }); + sd = root2(sd / (size - 1)); + } + return {avg, sd, size}; +}; + /** - * oracles: array of OracleID + * oracles: array of {account, oracle_sequence} * symbol: is the symbol to be priced * priceUnit: is the denomination in which the prices are expressed * trim : percentage of outliers to trim [optional] - * flags : specify aggregation type. at least one flag must be included - * tfSimpleAverage : 0x01 - * tfMedian : 0x02 - * tfTrimmedMedian : 0x04 + * time_threshold : defines a range of prices to include based on the timestamp + * range - {most recent, most recent - time_threshold} [optional] */ Json::Value doGetAggregatePrice(RPC::JsonContext& context) @@ -46,9 +212,11 @@ doGetAggregatePrice(RPC::JsonContext& context) if (!ledger) return result; + constexpr std::uint16_t maxOracles = 200; if (!params.isMember(jss::oracles)) return RPC::missing_field_error(jss::oracles); - if (!params[jss::oracles].isArray()) + if (!params[jss::oracles].isArray() || params[jss::oracles].size() == 0 || + params[jss::oracles].size() > maxOracles) { RPC::inject_error(rpcORACLE_MALFORMED, result); return result; @@ -60,17 +228,40 @@ doGetAggregatePrice(RPC::JsonContext& context) if (!params.isMember(jss::price_unit)) return RPC::missing_field_error(jss::price_unit); - std::optional trim = params.isMember(jss::trim) - ? std::make_optional(params[jss::trim].asUInt()) - : std::nullopt; + auto getField = [&](Json::StaticString const& field, + unsigned int def = + 0) -> std::variant { + if (params.isMember(field)) + { + if (!params[field].isConvertibleTo(Json::ValueType::uintValue)) + return rpcORACLE_MALFORMED; + return params[field].asUInt(); + } + return def; + }; + + auto const trim = getField(jss::trim); + if (std::holds_alternative(trim)) + { + RPC::inject_error(std::get(trim), result); + return result; + } + + constexpr std::uint32_t defaultTimeThreshold = 4; + auto const timeThreshold = + getField(jss::time_threshold, defaultTimeThreshold); + if (std::holds_alternative(timeThreshold)) + { + RPC::inject_error(std::get(timeThreshold), result); + return result; + } auto const symbol = params[jss::symbol]; auto const priceUnit = params[jss::price_unit]; - // prices sorted low to high. use STAmount since Number is int64 only - std::vector prices; - Issue const someIssue = {to_currency("SOM"), AccountID{1}}; - STAmount avg{someIssue, 0, 0}; + // Collect the dataset into bimap keyed by lastUpdateTime and + // STAmount (Number is int64 and price is uint64) + Prices prices; for (auto const& oracle : params[jss::oracles]) { if (!oracle.isMember(jss::oracle_sequence) || @@ -92,91 +283,76 @@ doGetAggregatePrice(RPC::JsonContext& context) } if (auto const sle = ledger->read(keylet::oracle(*account, *sequence))) { - auto const series = sle->getFieldArray(sfPriceDataSeries); - if (auto iter = std::find_if( - series.begin(), - series.end(), - [&](STObject const& o) -> bool { - return o.getFieldCurrency(sfSymbol).getText() == - symbol && - o.getFieldCurrency(sfPriceUnit).getText() == - priceUnit; - }); - iter == series.end()) + if (auto const data = getPriceData( + context, *sle, symbol.asString(), priceUnit.asString())) { - RPC::inject_error(rpcOBJECT_NOT_FOUND, result); - return result; + prices.insert(Prices::value_type(data->first, data->second)); } - else - { - auto const price = iter->getFieldU64(sfSymbolPrice); - auto const scale = -static_cast(iter->getFieldU8(sfScale)); - prices.push_back(STAmount{someIssue, price, scale}); - } - avg += prices.back(); - } - else - { - RPC::inject_error(rpcOBJECT_NOT_FOUND, result); - return result; } } + if (prices.empty()) { - RPC::inject_error(rpcORACLE_MALFORMED, result); + RPC::inject_error(rpcOBJECT_NOT_FOUND, result); return result; } - avg = divide( - avg, - STAmount{someIssue, static_cast(prices.size()), 0}, - someIssue); - result[jss::simple_average] = avg.getText(); - result[jss::size] = static_cast(prices.size()); - auto sd = STAmount{someIssue, 0}; - result[jss::standard_deviation] = "0"; - if (prices.size() > 1) + + // erase outdated data + // sorted in descending, therefore begin is the latest, end is the oldest + auto const latestTime = prices.left.begin()->first; + auto const oldestTime = prices.left.rbegin()->first; + auto const threshold = std::get(timeThreshold); + auto const upperBound = + latestTime > threshold ? (latestTime - threshold) : oldestTime; + if (upperBound > oldestTime) + prices.left.erase( + prices.left.upper_bound(upperBound), prices.left.end()); + + if (prices.empty()) { - for (auto const& price : prices) - sd += multiply(price - avg, price - avg, someIssue); - result[jss::standard_deviation] = to_string(root2(sd)); + RPC::inject_error(rpcOBJECT_NOT_FOUND, result); + return result; } - std::stable_sort(prices.begin(), prices.end()); + // calculate stats + auto const [avg, sd, size] = + getStats(prices.right.begin(), prices.right.end()); + result[jss::entire_set][jss::average] = avg.getText(); + result[jss::entire_set][jss::size] = size; + result[jss::entire_set][jss::standard_deviation] = to_string(sd); + + auto advRight = [&](auto begin, std::size_t by) { + auto it = begin; + std::advance(it, by); + return it; + }; + auto const median = [&]() { - if (prices.size() % 2 == 0) - { - // Even number of elements - size_t middle = prices.size() / 2; - return divide( - prices[middle - 1] + prices[middle], - STAmount{someIssue, 2, 0}, - someIssue); - } - else + size_t const middle = size / 2; + if ((size % 2) == 0) { - // Odd number of elements - return divide( - prices[prices.size()], STAmount{someIssue, 2, 0}, someIssue); + static STAmount two{noIssue(), 2, 0}; + auto const sum = advRight(prices.right.begin(), middle - 1)->first + + advRight(prices.right.begin(), middle)->first; + return divide(sum, two, noIssue()); } + return advRight(prices.right.begin(), middle)->first; }(); result[jss::median] = median.getText(); - if (trim) + if (std::get(trim) != 0) { - auto const trimCount = prices.size() * *trim / 100; + auto const trimCount = + prices.size() * std::get(trim) / 100; size_t start = trimCount; size_t end = prices.size() - trimCount; - avg = std::accumulate( - prices.begin() + trimCount, - prices.begin() + end, - STAmount{someIssue, 0, 0}); - - avg = divide( - avg, - STAmount{someIssue, static_cast(end - start), 0}, - someIssue); - result[jss::trimmed_mean] = avg.getText(); + auto const [avg, sd, size] = getStats( + advRight(prices.right.begin(), start), + advRight(prices.right.begin(), end)); + result[jss::trimmed_set][jss::average] = avg.getText(); + result[jss::trimmed_set][jss::size] = size; + result[jss::trimmed_set][jss::standard_deviation] = to_string(sd); } return result; diff --git a/src/test/jtx/Oracle.h b/src/test/jtx/Oracle.h index f463bb5f4dd..62a7548634f 100644 --- a/src/test/jtx/Oracle.h +++ b/src/test/jtx/Oracle.h @@ -75,6 +75,8 @@ class Oracle DataSeries const& series, std::optional const& ter); + Oracle(Env& env, Account const& owner, std::uint32_t sequence); + void create( AccountID const& owner, @@ -114,7 +116,7 @@ class Oracle Env& env, std::optional const& symbol, std::optional const& priceUnit, - std::optional>> const& + std::optional>> const& oracles = std::nullopt, std::optional const& trim = std::nullopt, std::optional const& timeTreshold = std::nullopt); @@ -150,6 +152,9 @@ class Oracle std::uint32_t fee = 0, std::optional const& ter = std::nullopt); + Json::Value + ledgerEntry(std::optional const& index = std::nullopt) const; + private: }; diff --git a/src/test/jtx/impl/Oracle.cpp b/src/test/jtx/impl/Oracle.cpp index 7a9b67a385f..5e313f99366 100644 --- a/src/test/jtx/impl/Oracle.cpp +++ b/src/test/jtx/impl/Oracle.cpp @@ -22,6 +22,8 @@ #include #include +#include + #include #include @@ -94,6 +96,15 @@ Oracle::Oracle( { } +Oracle::Oracle(Env& env, Account const& owner, std::uint32_t sequence) + : env_(env) + , owner_(owner) + , oracleSequence_(sequence) + , msig_(std::nullopt) + , fee_(0) +{ +} + void Oracle::create( AccountID const& owner, @@ -239,7 +250,7 @@ Oracle::aggregatePrice( Env& env, std::optional const& symbol, std::optional const& priceUnit, - std::optional>> const& + std::optional>> const& oracles, std::optional const& trim, std::optional const& timeThreshold) @@ -251,7 +262,7 @@ Oracle::aggregatePrice( for (auto const& id : *oracles) { Json::Value oracle; - oracle[jss::account] = to_string(id.first); + oracle[jss::account] = to_string(id.first.id()); oracle[jss::oracle_sequence] = id.second; jvOracles.append(oracle); } @@ -264,7 +275,7 @@ Oracle::aggregatePrice( if (priceUnit) jv[jss::price_unit] = *priceUnit; if (timeThreshold) - jv[jss::time_interval] = *timeThreshold; + jv[jss::time_threshold] = *timeThreshold; auto jr = env.rpc("json", "get_aggregate_price", to_string(jv)); @@ -309,9 +320,10 @@ Oracle::set( if (lastUpdateTime) jv[jss::LastUpdateTime] = *lastUpdateTime; else - jv[jss::LastUpdateTime] = to_string( - duration_cast(env_.timeKeeper().now().time_since_epoch()) - .count()); + jv[jss::LastUpdateTime] = + to_string(duration_cast( + env_.current()->info().closeTime.time_since_epoch()) + .count()); Json::Value dataSeries(Json::arrayValue); for (auto const& data : series) { @@ -330,6 +342,23 @@ Oracle::set( submit(jv, msig, std::nullopt, ter); } +Json::Value +Oracle::ledgerEntry(std::optional const& index) const +{ + Json::Value jvParams; + jvParams[jss::oracle][jss::account] = to_string(owner_); + jvParams[jss::oracle][jss::oracle_sequence] = oracleSequence_; + if (index) + { + std::uint32_t i; + if (boost::conversion::try_lexical_convert(*index, i)) + jvParams[jss::oracle][jss::ledger_index] = i; + else + jvParams[jss::oracle][jss::ledger_index] = *index; + } + return env_.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; +} + } // namespace jtx } // namespace test } // namespace ripple \ No newline at end of file diff --git a/src/test/rpc/GetAggregatePrice_test.cpp b/src/test/rpc/GetAggregatePrice_test.cpp index d96d8b6b1e0..e3b5f0c27f9 100644 --- a/src/test/rpc/GetAggregatePrice_test.cpp +++ b/src/test/rpc/GetAggregatePrice_test.cpp @@ -17,6 +17,7 @@ */ //============================================================================== +#include #include #include #include @@ -33,52 +34,68 @@ class GetAggregatePrice_test : public beast::unit_test::suite { testcase("Errors"); using namespace jtx; - Env env(*this); Account const owner{"owner"}; Account const some{"some"}; - static std::vector> oracles = { + static std::vector> oracles = { {owner, 1}}; - // missing symbol - auto ret = Oracle::aggregatePrice(env, std::nullopt, "USD", oracles); - BEAST_EXPECT( - ret[jss::error_message].asString() == "Missing field 'symbol'."); - - // missing price_unit - ret = Oracle::aggregatePrice(env, "XRP", std::nullopt, oracles); - BEAST_EXPECT( - ret[jss::error_message].asString() == - "Missing field 'price_unit'."); - - // missing oracles array - ret = Oracle::aggregatePrice(env, "XRP", "USD"); - BEAST_EXPECT( - ret[jss::error_message].asString() == "Missing field 'oracles'."); - - // empty oracles array - ret = Oracle::aggregatePrice(env, "XRP", "USD", {{}}); - BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed"); - - // invalid oracle sequence - ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{owner, 2}}}); - BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound"); - - // invalid owner - ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{some, 1}}}); - BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound"); - - // oracles have wrong asset pair - env.fund(XRP(1'000), owner); - Oracle oracle(env, owner, 1, {{"XRP", "EUR", 740, 1}}, ter(tesSUCCESS)); - Oracle oracle1( - env, owner, 2, {{"XRP", "USD", 740, 1}}, ter(tesSUCCESS)); - ret = Oracle::aggregatePrice( - env, - "XRP", - "USD", - {{{owner, oracle.oracleSequence()}, - {owner, oracle1.oracleSequence()}}}); - BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound"); + { + Env env(*this); + // missing symbol + auto ret = + Oracle::aggregatePrice(env, std::nullopt, "USD", oracles); + BEAST_EXPECT( + ret[jss::error_message].asString() == + "Missing field 'symbol'."); + + // missing price_unit + ret = Oracle::aggregatePrice(env, "XRP", std::nullopt, oracles); + BEAST_EXPECT( + ret[jss::error_message].asString() == + "Missing field 'price_unit'."); + + // missing oracles array + ret = Oracle::aggregatePrice(env, "XRP", "USD"); + BEAST_EXPECT( + ret[jss::error_message].asString() == + "Missing field 'oracles'."); + + // empty oracles array + ret = Oracle::aggregatePrice(env, "XRP", "USD", {{}}); + BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed"); + + // invalid oracle sequence + ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{owner, 2}}}); + BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound"); + + // invalid owner + ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{some, 1}}}); + BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound"); + + // oracles have wrong asset pair + env.fund(XRP(1'000), owner); + Oracle oracle( + env, owner, 1, {{"XRP", "EUR", 740, 1}}, ter(tesSUCCESS)); + ret = Oracle::aggregatePrice( + env, "XRP", "USD", {{{owner, oracle.oracleSequence()}}}); + BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound"); + } + + // too many oracles + { + Env env(*this); + std::vector> oracles; + for (int i = 0; i < 201; ++i) + { + Account const owner(std::to_string(i)); + env.fund(XRP(1'000), owner); + Oracle oracle( + env, owner, i, {{"XRP", "USD", 740, 1}}, ter(tesSUCCESS)); + oracles.emplace_back(owner, oracle.oracleSequence()); + } + auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles); + BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed"); + } } void @@ -87,9 +104,7 @@ class GetAggregatePrice_test : public beast::unit_test::suite testcase("RPC"); using namespace jtx; - { - Env env(*this); - std::vector> oracles; + auto prep = [&](Env& env, auto& oracles) { oracles.reserve(10); for (int i = 0; i < 10; ++i) { @@ -99,19 +114,98 @@ class GetAggregatePrice_test : public beast::unit_test::suite env, owner, rand(), - {{"XRP", "USD", 740 + i, 1}}, + {{"XRP", "USD", 740 + i, 1}, {"XRP", "EUR", 740, 1}}, ter(tesSUCCESS)); - oracles.emplace_back(owner.id(), oracle.oracleSequence()); + oracles.emplace_back(owner, oracle.oracleSequence()); } - // simple average - auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20); - BEAST_EXPECT(ret[jss::simple_average] == "74.45"); + }; + + // can find price data for all price oracle instances + { + Env env(*this); + std::vector> oracles; + prep(env, oracles); + // entire and trimmed stats + auto ret = + Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, 100); + BEAST_EXPECT(ret[jss::entire_set][jss::average] == "74.45"); + BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 10); + BEAST_EXPECT( + ret[jss::entire_set][jss::standard_deviation] == + "0.3027650354097492"); BEAST_EXPECT(ret[jss::median] == "74.45"); - BEAST_EXPECT(ret[jss::trimmed_mean] == "74.45"); - BEAST_EXPECT(ret[jss::size].asUInt() == 10); - BEAST_EXPECT(ret[jss::standard_deviation] == "0.9082951062292475"); + BEAST_EXPECT(ret[jss::trimmed_set][jss::average] == "74.45"); + BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 6); + BEAST_EXPECT( + ret[jss::trimmed_set][jss::standard_deviation] == + "0.187082869338697"); + } + + // reduced data set as some price oracles have the price data + // beyond three updated ledgers + { + Env env(*this); + std::vector> oracles; + prep(env, oracles); + for (int i = 0; i < 3; ++i) + { + Oracle oracle(env, oracles[i].first, oracles[i].second); + // push XRP/USD farther than three ledger, so this price + // oracle is not included in the dataset + oracle.update(oracles[i].first, {{"XRP", "EUR", 740, 1}}); + oracle.update(oracles[i].first, {{"XRP", "EUR", 740, 1}}); + oracle.update(oracles[i].first, {{"XRP", "EUR", 740, 1}}); + } + for (int i = 3; i < 6; ++i) + { + Oracle oracle(env, oracles[i].first, oracles[i].second); + // push XRP/USD by two ledgers, so this price + // is included in the dataset + oracle.update(oracles[i].first, {{"XRP", "EUR", 740, 1}}); + oracle.update(oracles[i].first, {{"XRP", "EUR", 740, 1}}); + } + + // entire and trimmed stats + auto ret = + Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, 200); + BEAST_EXPECT(ret[jss::entire_set][jss::average] == "74.6"); + BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 7); + BEAST_EXPECT( + ret[jss::entire_set][jss::standard_deviation] == + "0.2160246899469287"); + BEAST_EXPECT(ret[jss::median] == "74.6"); + BEAST_EXPECT(ret[jss::trimmed_set][jss::average] == "74.6"); + BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 5); + BEAST_EXPECT( + ret[jss::trimmed_set][jss::standard_deviation] == + "0.158113883008419"); + } + + // reduced data set because of the time threshold + { + Env env(*this); + std::vector> oracles; + prep(env, oracles); + for (int i = 0; i < oracles.size(); ++i) + { + Oracle oracle(env, oracles[i].first, oracles[i].second); + // push XRP/USD by two ledgers, so this price + // is included in the dataset + oracle.update( + oracles[i].first, + {{"XRP", "USD", 740, 1}}, + "URI", + 1'000 + i); + } + + // entire stats only, limit lastUpdateTime to 1,009-1,002 + auto ret = Oracle::aggregatePrice( + env, "XRP", "USD", oracles, std::nullopt, 7); + BEAST_EXPECT(ret[jss::entire_set][jss::average] == "74"); + BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 8); + BEAST_EXPECT(ret[jss::entire_set][jss::standard_deviation] == "0"); + BEAST_EXPECT(ret[jss::median] == "74"); } - BEAST_EXPECT(true); } void