From f839070f794fc5716db7088428ca66752905bf2c Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Wed, 25 Oct 2023 09:57:08 -0400 Subject: [PATCH] Price Oracle (XLS-47d): (#4789) Implement native support for Price Oracles. A Price Oracle is used to bring real-world data, such as market prices, onto the blockchain, enabling dApps to access and utilize information that resides outside the blockchain. Add Price Oracle functionality: - OracleSet: create or update the Oracle object - OracleDelete: delete the Oracle object To support this functionality add: - New RPC method, `get_aggregate_price`, to calculate aggregate price for a token pair of the specified oracles - `ltOracle` object The `ltOracle` object maintains: - Oracle Owner's account - Oracle's metadata - Up to ten token pairs with the scaled price - The last update time the token pairs were updated Add Oracle unit-tests --- Builds/CMake/RippledCore.cmake | 7 + src/ripple/app/tx/impl/DeleteAccount.cpp | 15 + src/ripple/app/tx/impl/DeleteOracle.cpp | 110 +++ src/ripple/app/tx/impl/DeleteOracle.h | 64 ++ src/ripple/app/tx/impl/InvariantCheck.cpp | 1 + src/ripple/app/tx/impl/SetOracle.cpp | 312 ++++++++ src/ripple/app/tx/impl/SetOracle.h | 57 ++ src/ripple/app/tx/impl/applySteps.cpp | 6 + src/ripple/protocol/ErrorCodes.h | 6 +- src/ripple/protocol/Feature.h | 3 +- src/ripple/protocol/Indexes.h | 3 + src/ripple/protocol/LedgerFormats.h | 5 + src/ripple/protocol/Protocol.h | 25 + src/ripple/protocol/SField.h | 15 + src/ripple/protocol/STCurrency.h | 138 ++++ src/ripple/protocol/STObject.h | 5 + src/ripple/protocol/TER.h | 9 +- src/ripple/protocol/TxFormats.h | 6 + src/ripple/protocol/impl/ErrorCodes.cpp | 3 +- src/ripple/protocol/impl/Feature.cpp | 1 + src/ripple/protocol/impl/Indexes.cpp | 7 + .../protocol/impl/InnerObjectFormats.cpp | 9 + src/ripple/protocol/impl/LedgerFormats.cpp | 16 + src/ripple/protocol/impl/SField.cpp | 14 +- src/ripple/protocol/impl/STCurrency.cpp | 114 +++ src/ripple/protocol/impl/STObject.cpp | 14 + src/ripple/protocol/impl/STParsedJSON.cpp | 13 + src/ripple/protocol/impl/STVar.cpp | 7 + src/ripple/protocol/impl/TER.cpp | 6 + src/ripple/protocol/impl/TxFormats.cpp | 19 + src/ripple/protocol/jss.h | 29 + src/ripple/rpc/handlers/GetAggregatePrice.cpp | 340 +++++++++ src/ripple/rpc/handlers/Handlers.h | 2 + src/ripple/rpc/handlers/LedgerEntry.cpp | 46 ++ src/ripple/rpc/impl/Handler.cpp | 4 + src/ripple/rpc/impl/RPCHelpers.cpp | 5 +- src/test/app/Oracle_test.cpp | 698 ++++++++++++++++++ src/test/jtx/Oracle.h | 186 +++++ src/test/jtx/impl/Oracle.cpp | 292 ++++++++ src/test/rpc/GetAggregatePrice_test.cpp | 260 +++++++ 40 files changed, 2865 insertions(+), 7 deletions(-) create mode 100644 src/ripple/app/tx/impl/DeleteOracle.cpp create mode 100644 src/ripple/app/tx/impl/DeleteOracle.h create mode 100644 src/ripple/app/tx/impl/SetOracle.cpp create mode 100644 src/ripple/app/tx/impl/SetOracle.h create mode 100644 src/ripple/protocol/STCurrency.h create mode 100644 src/ripple/protocol/impl/STCurrency.cpp create mode 100644 src/ripple/rpc/handlers/GetAggregatePrice.cpp create mode 100644 src/test/app/Oracle_test.cpp create mode 100644 src/test/jtx/Oracle.h create mode 100644 src/test/jtx/impl/Oracle.cpp create mode 100644 src/test/rpc/GetAggregatePrice_test.cpp diff --git a/Builds/CMake/RippledCore.cmake b/Builds/CMake/RippledCore.cmake index 9c0c7016379..1dabbe83bd6 100644 --- a/Builds/CMake/RippledCore.cmake +++ b/Builds/CMake/RippledCore.cmake @@ -98,6 +98,7 @@ target_sources (xrpl_core PRIVATE src/ripple/protocol/impl/STArray.cpp src/ripple/protocol/impl/STBase.cpp src/ripple/protocol/impl/STBlob.cpp + src/ripple/protocol/impl/STCurrency.cpp src/ripple/protocol/impl/STInteger.cpp src/ripple/protocol/impl/STLedgerEntry.cpp src/ripple/protocol/impl/STObject.cpp @@ -553,6 +554,7 @@ target_sources (rippled PRIVATE src/ripple/app/tx/impl/CreateOffer.cpp src/ripple/app/tx/impl/CreateTicket.cpp src/ripple/app/tx/impl/DeleteAccount.cpp + src/ripple/app/tx/impl/DeleteOracle.cpp src/ripple/app/tx/impl/DepositPreauth.cpp src/ripple/app/tx/impl/DID.cpp src/ripple/app/tx/impl/Escrow.cpp @@ -566,6 +568,7 @@ target_sources (rippled PRIVATE src/ripple/app/tx/impl/PayChan.cpp src/ripple/app/tx/impl/Payment.cpp src/ripple/app/tx/impl/SetAccount.cpp + src/ripple/app/tx/impl/SetOracle.cpp src/ripple/app/tx/impl/SetRegularKey.cpp src/ripple/app/tx/impl/SetSignerList.cpp src/ripple/app/tx/impl/SetTrust.cpp @@ -721,6 +724,7 @@ target_sources (rippled PRIVATE src/ripple/rpc/handlers/FetchInfo.cpp src/ripple/rpc/handlers/GatewayBalances.cpp src/ripple/rpc/handlers/GetCounts.cpp + src/ripple/rpc/handlers/GetAggregatePrice.cpp src/ripple/rpc/handlers/LedgerAccept.cpp src/ripple/rpc/handlers/LedgerCleanerHandler.cpp src/ripple/rpc/handlers/LedgerClosed.cpp @@ -840,6 +844,7 @@ if (tests) src/test/app/NFTokenDir_test.cpp src/test/app/OfferStream_test.cpp src/test/app/Offer_test.cpp + src/test/app/Oracle_test.cpp src/test/app/OversizeMeta_test.cpp src/test/app/Path_test.cpp src/test/app/PayChan_test.cpp @@ -964,6 +969,7 @@ if (tests) src/test/jtx/impl/AMMTest.cpp src/test/jtx/impl/Env.cpp src/test/jtx/impl/JSONRPCClient.cpp + src/test/jtx/impl/Oracle.cpp src/test/jtx/impl/TestHelpers.cpp src/test/jtx/impl/WSClient.cpp src/test/jtx/impl/acctdelete.cpp @@ -1089,6 +1095,7 @@ if (tests) src/test/rpc/DeliveredAmount_test.cpp src/test/rpc/Feature_test.cpp src/test/rpc/GatewayBalances_test.cpp + src/test/rpc/GetAggregatePrice_test.cpp src/test/rpc/GetCounts_test.cpp src/test/rpc/JSONRPC_test.cpp src/test/rpc/KeyGeneration_test.cpp diff --git a/src/ripple/app/tx/impl/DeleteAccount.cpp b/src/ripple/app/tx/impl/DeleteAccount.cpp index 49b645e31d9..efa38a4b74e 100644 --- a/src/ripple/app/tx/impl/DeleteAccount.cpp +++ b/src/ripple/app/tx/impl/DeleteAccount.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -146,6 +147,18 @@ removeDIDFromLedger( return DIDDelete::deleteSLE(view, sleDel, account, j); } +TER +removeOracleFromLedger( + Application&, + ApplyView& view, + AccountID const& account, + uint256 const&, + std::shared_ptr const& sleDel, + beast::Journal j) +{ + return DeleteOracle::deleteOracle(view, sleDel, account, j); +} + // Return nullptr if the LedgerEntryType represents an obligation that can't // be deleted. Otherwise return the pointer to the function that can delete // the non-obligation @@ -166,6 +179,8 @@ nonObligationDeleter(LedgerEntryType t) return removeNFTokenOfferFromLedger; case ltDID: return removeDIDFromLedger; + case ltORACLE: + return removeOracleFromLedger; default: return nullptr; } diff --git a/src/ripple/app/tx/impl/DeleteOracle.cpp b/src/ripple/app/tx/impl/DeleteOracle.cpp new file mode 100644 index 00000000000..dfaecc384d4 --- /dev/null +++ b/src/ripple/app/tx/impl/DeleteOracle.cpp @@ -0,0 +1,110 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include + +namespace ripple { + +NotTEC +DeleteOracle::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featurePriceOracle)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfUniversalMask) + { + JLOG(ctx.j.debug()) << "Oracle Delete: invalid flags."; + return temINVALID_FLAG; + } + + return preflight2(ctx); +} + +TER +DeleteOracle::preclaim(PreclaimContext const& ctx) +{ + if (!ctx.view.exists(keylet::account(ctx.tx.getAccountID(sfAccount)))) + return terNO_ACCOUNT; + + if (auto const sle = ctx.view.read(keylet::oracle( + ctx.tx.getAccountID(sfAccount), ctx.tx[sfOracleDocumentID])); + !sle) + { + JLOG(ctx.j.debug()) << "Oracle Delete: Oracle does not exist."; + return tecNO_ENTRY; + } + else if (ctx.tx.getAccountID(sfAccount) != sle->getAccountID(sfOwner)) + { + // this can't happen because of the above check + JLOG(ctx.j.debug()) << "Oracle Delete: invalid account."; + return tecINTERNAL; + } + return tesSUCCESS; +} + +TER +DeleteOracle::deleteOracle( + ApplyView& view, + std::shared_ptr const& sle, + AccountID const& account, + beast::Journal j) +{ + if (!sle) + return tesSUCCESS; + + if (!view.dirRemove( + keylet::ownerDir(account), (*sle)[sfOwnerNode], sle->key(), true)) + { + JLOG(j.fatal()) << "Unable to delete Oracle from owner."; + return tefBAD_LEDGER; + } + + auto const sleOwner = view.peek(keylet::account(account)); + if (!sleOwner) + return tecINTERNAL; + + auto const count = + sle->getFieldArray(sfPriceDataSeries).size() > 5 ? -2 : -1; + + adjustOwnerCount(view, sleOwner, count, j); + + view.erase(sle); + + return tesSUCCESS; +} + +TER +DeleteOracle::doApply() +{ + if (auto sle = ctx_.view().peek( + keylet::oracle(account_, ctx_.tx[sfOracleDocumentID]))) + return deleteOracle(ctx_.view(), sle, account_, j_); + + return tecINTERNAL; +} + +} // namespace ripple diff --git a/src/ripple/app/tx/impl/DeleteOracle.h b/src/ripple/app/tx/impl/DeleteOracle.h new file mode 100644 index 00000000000..e578adaaaf0 --- /dev/null +++ b/src/ripple/app/tx/impl/DeleteOracle.h @@ -0,0 +1,64 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_DELETEORACLE_H_INCLUDED +#define RIPPLE_TX_DELETEORACLE_H_INCLUDED + +#include + +namespace ripple { + +/** + Price Oracle is a system that acts as a bridge between + a blockchain network and the external world, providing off-chain price data + to decentralized applications (dApps) on the blockchain. This implementation + conforms to the requirements specified in the XLS-47d. + + The DeleteOracle transactor implements the deletion of Oracle objects. +*/ + +class DeleteOracle : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit DeleteOracle(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + static TER + deleteOracle( + ApplyView& view, + std::shared_ptr const& sle, + AccountID const& account, + beast::Journal j); +}; + +} // namespace ripple + +#endif // RIPPLE_TX_DELETEORACLE_H_INCLUDED diff --git a/src/ripple/app/tx/impl/InvariantCheck.cpp b/src/ripple/app/tx/impl/InvariantCheck.cpp index c717777f88f..c0ef5bbf0c5 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.cpp +++ b/src/ripple/app/tx/impl/InvariantCheck.cpp @@ -392,6 +392,7 @@ LedgerEntryTypesMatch::visitEntry( case ltXCHAIN_OWNED_CLAIM_ID: case ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID: case ltDID: + case ltORACLE: break; default: invalidTypeAdded_ = true; diff --git a/src/ripple/app/tx/impl/SetOracle.cpp b/src/ripple/app/tx/impl/SetOracle.cpp new file mode 100644 index 00000000000..37dc6fcd212 --- /dev/null +++ b/src/ripple/app/tx/impl/SetOracle.cpp @@ -0,0 +1,312 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ripple { + +static inline std::pair +tokenPairKey(STObject const& pair) +{ + return std::make_pair( + pair.getFieldCurrency(sfBaseAsset).currency(), + pair.getFieldCurrency(sfQuoteAsset).currency()); +} + +NotTEC +SetOracle::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featurePriceOracle)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfUniversalMask) + return temINVALID_FLAG; + + auto const& dataSeries = ctx.tx.getFieldArray(sfPriceDataSeries); + if (dataSeries.empty()) + return temARRAY_EMPTY; + if (dataSeries.size() > maxOracleDataSeries) + return temARRAY_TOO_LARGE; + + auto isInvalidLength = [&](auto const& sField, std::size_t length) { + return ctx.tx.isFieldPresent(sField) && + (ctx.tx[sField].length() == 0 || ctx.tx[sField].length() > length); + }; + + if (isInvalidLength(sfProvider, maxOracleProvider) || + isInvalidLength(sfURI, maxOracleURI) || + isInvalidLength(sfAssetClass, maxOracleSymbolClass)) + return temMALFORMED; + + return preflight2(ctx); +} + +TER +SetOracle::preclaim(PreclaimContext const& ctx) +{ + auto const sleSetter = + ctx.view.read(keylet::account(ctx.tx.getAccountID(sfAccount))); + if (!sleSetter) + return terNO_ACCOUNT; + + // lastUpdateTime must be within maxLastUpdateTimeDelta seconds + // of the last closed ledger + using namespace std::chrono; + std::size_t const closeTime = + duration_cast(ctx.view.info().closeTime.time_since_epoch()) + .count(); + std::size_t const lastUpdateTime = ctx.tx[sfLastUpdateTime]; + if (lastUpdateTime < epoch_offset.count()) + return tecINVALID_UPDATE_TIME; + std::size_t const lastUpdateTimeEpoch = + lastUpdateTime - epoch_offset.count(); + if (closeTime < maxLastUpdateTimeDelta) + Throw( + "Oracle: close time is less than maxLastUpdateTimeDelta"); + if (lastUpdateTimeEpoch < (closeTime - maxLastUpdateTimeDelta) || + lastUpdateTimeEpoch > (closeTime + maxLastUpdateTimeDelta)) + return tecINVALID_UPDATE_TIME; + + auto const sle = ctx.view.read(keylet::oracle( + ctx.tx.getAccountID(sfAccount), ctx.tx[sfOracleDocumentID])); + + // token pairs to add/update + hash_set> pairs; + // token pairs to delete. if a token pair doesn't include + // the price then this pair should be deleted from the object. + hash_set> pairsDel; + for (auto const& entry : ctx.tx.getFieldArray(sfPriceDataSeries)) + { + if (entry[sfBaseAsset] == entry[sfQuoteAsset]) + return temMALFORMED; + auto const key = tokenPairKey(entry); + if (pairs.contains(key) || pairsDel.contains(key)) + return temMALFORMED; + if (entry[~sfScale] > maxPriceScale) + return temMALFORMED; + if (entry.isFieldPresent(sfAssetPrice)) + pairs.emplace(key); + else if (sle) + pairsDel.emplace(key); + else + return temMALFORMED; + } + + // Lambda is used to check if the value of a field, passed + // in the transaction, is equal to the value of that field + // in the on-ledger object. + auto isConsistent = [&ctx, &sle](auto const& field) { + auto const v = ctx.tx[~field]; + return !v || *v == (*sle)[field]; + }; + + std::uint32_t adjustReserve = 0; + if (sle) + { + // update + // Account is the Owner since we can get sle + + // lastUpdateTime must be more recent than the previous one + if (ctx.tx[sfLastUpdateTime] <= (*sle)[sfLastUpdateTime]) + return tecINVALID_UPDATE_TIME; + + if (!isConsistent(sfProvider) || !isConsistent(sfAssetClass)) + return temMALFORMED; + + for (auto const& entry : sle->getFieldArray(sfPriceDataSeries)) + { + auto const key = tokenPairKey(entry); + if (!pairs.contains(key)) + { + if (pairsDel.contains(key)) + pairsDel.erase(key); + else + pairs.emplace(key); + } + } + if (!pairsDel.empty()) + return tecTOKEN_PAIR_NOT_FOUND; + + auto const oldCount = + sle->getFieldArray(sfPriceDataSeries).size() > 5 ? 2 : 1; + auto const newCount = pairs.size() > 5 ? 2 : 1; + adjustReserve = newCount - oldCount; + } + else + { + // create + + if (!ctx.tx.isFieldPresent(sfProvider) || + !ctx.tx.isFieldPresent(sfAssetClass)) + return temMALFORMED; + adjustReserve = pairs.size() > 5 ? 2 : 1; + } + + if (pairs.empty()) + return tecARRAY_EMPTY; + if (pairs.size() > maxOracleDataSeries) + return tecARRAY_TOO_LARGE; + + auto const reserve = ctx.view.fees().accountReserve( + sleSetter->getFieldU32(sfOwnerCount) + adjustReserve); + auto const& balance = sleSetter->getFieldAmount(sfBalance); + + if (balance < reserve) + return tecINSUFFICIENT_RESERVE; + + return tesSUCCESS; +} + +static bool +adjustOwnerCount(ApplyContext& ctx, int count) +{ + if (auto const sleAccount = + ctx.view().peek(keylet::account(ctx.tx[sfAccount]))) + { + adjustOwnerCount(ctx.view(), sleAccount, count, ctx.journal); + return true; + } + + return false; +} + +static void +setPriceDataInnerObjTemplate(STObject& obj) +{ + if (SOTemplate const* elements = + InnerObjectFormats::getInstance().findSOTemplateBySField( + sfPriceData)) + obj.set(*elements); +} + +TER +SetOracle::doApply() +{ + auto const oracleID = keylet::oracle(account_, ctx_.tx[sfOracleDocumentID]); + + if (auto sle = ctx_.view().peek(oracleID)) + { + // update + // the token pair that doesn't have their price updated will not + // include neither price nor scale in the updated PriceDataSeries + + hash_map, STObject> pairs; + // collect current token pairs + for (auto const& entry : sle->getFieldArray(sfPriceDataSeries)) + { + STObject priceData{sfPriceData}; + setPriceDataInnerObjTemplate(priceData); + priceData.setFieldCurrency( + sfBaseAsset, entry.getFieldCurrency(sfBaseAsset)); + priceData.setFieldCurrency( + sfQuoteAsset, entry.getFieldCurrency(sfQuoteAsset)); + pairs.emplace(tokenPairKey(entry), std::move(priceData)); + } + auto const oldCount = pairs.size() > 5 ? 2 : 1; + // update/add/delete pairs + for (auto const& entry : ctx_.tx.getFieldArray(sfPriceDataSeries)) + { + auto const key = tokenPairKey(entry); + if (!entry.isFieldPresent(sfAssetPrice)) + { + // delete token pair + pairs.erase(key); + } + else if (auto iter = pairs.find(key); iter != pairs.end()) + { + // update the price + iter->second.setFieldU64( + sfAssetPrice, entry.getFieldU64(sfAssetPrice)); + if (entry.isFieldPresent(sfScale)) + iter->second.setFieldU8(sfScale, entry.getFieldU8(sfScale)); + } + else + { + // add a token pair with the price + STObject priceData{sfPriceData}; + setPriceDataInnerObjTemplate(priceData); + priceData.setFieldCurrency( + sfBaseAsset, entry.getFieldCurrency(sfBaseAsset)); + priceData.setFieldCurrency( + sfQuoteAsset, entry.getFieldCurrency(sfQuoteAsset)); + priceData.setFieldU64( + sfAssetPrice, entry.getFieldU64(sfAssetPrice)); + if (entry.isFieldPresent(sfScale)) + priceData.setFieldU8(sfScale, entry.getFieldU8(sfScale)); + pairs.emplace(key, std::move(priceData)); + } + } + STArray updatedSeries; + for (auto const& iter : pairs) + updatedSeries.push_back(std::move(iter.second)); + sle->setFieldArray(sfPriceDataSeries, updatedSeries); + if (ctx_.tx.isFieldPresent(sfURI)) + sle->setFieldVL(sfURI, ctx_.tx[sfURI]); + sle->setFieldU32(sfLastUpdateTime, ctx_.tx[sfLastUpdateTime]); + + auto const newCount = pairs.size() > 5 ? 2 : 1; + auto const adjust = newCount - oldCount; + if (adjust != 0 && !adjustOwnerCount(ctx_, adjust)) + return tefINTERNAL; + + ctx_.view().update(sle); + } + else + { + // create + + sle = std::make_shared(oracleID); + sle->setAccountID(sfOwner, ctx_.tx.getAccountID(sfAccount)); + sle->setFieldVL(sfProvider, ctx_.tx[sfProvider]); + if (ctx_.tx.isFieldPresent(sfURI)) + sle->setFieldVL(sfURI, ctx_.tx[sfURI]); + auto const& series = ctx_.tx.getFieldArray(sfPriceDataSeries); + sle->setFieldArray(sfPriceDataSeries, series); + sle->setFieldVL(sfAssetClass, ctx_.tx[sfAssetClass]); + sle->setFieldU32(sfLastUpdateTime, ctx_.tx[sfLastUpdateTime]); + + auto page = ctx_.view().dirInsert( + keylet::ownerDir(account_), sle->key(), describeOwnerDir(account_)); + if (!page) + return tecDIR_FULL; + + (*sle)[sfOwnerNode] = *page; + + auto const count = series.size() > 5 ? 2 : 1; + if (!adjustOwnerCount(ctx_, count)) + return tefINTERNAL; + + ctx_.view().insert(sle); + } + + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/ripple/app/tx/impl/SetOracle.h b/src/ripple/app/tx/impl/SetOracle.h new file mode 100644 index 00000000000..0ab8e603aa5 --- /dev/null +++ b/src/ripple/app/tx/impl/SetOracle.h @@ -0,0 +1,57 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_SETORACLE_H_INCLUDED +#define RIPPLE_TX_SETORACLE_H_INCLUDED + +#include + +namespace ripple { + +/** + Price Oracle is a system that acts as a bridge between + a blockchain network and the external world, providing off-chain price data + to decentralized applications (dApps) on the blockchain. This implementation + conforms to the requirements specified in the XLS-47d. + + The SetOracle transactor implements creating or updating Oracle objects. +*/ + +class SetOracle : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit SetOracle(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif // RIPPLE_TX_SETORACLE_H_INCLUDED diff --git a/src/ripple/app/tx/impl/applySteps.cpp b/src/ripple/app/tx/impl/applySteps.cpp index 10e2b0c4524..1a1fc343e3c 100644 --- a/src/ripple/app/tx/impl/applySteps.cpp +++ b/src/ripple/app/tx/impl/applySteps.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -45,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -159,6 +161,10 @@ with_txn_type(TxType txnType, F&& f) return f.template operator()(); case ttDID_DELETE: return f.template operator()(); + case ttORACLE_SET: + return f.template operator()(); + case ttORACLE_DELETE: + return f.template operator()(); default: throw UnknownTxnType(txnType); } diff --git a/src/ripple/protocol/ErrorCodes.h b/src/ripple/protocol/ErrorCodes.h index 8319b69c8c2..ad849f2b4ef 100644 --- a/src/ripple/protocol/ErrorCodes.h +++ b/src/ripple/protocol/ErrorCodes.h @@ -145,7 +145,11 @@ enum error_code_i { // AMM rpcISSUE_MALFORMED = 93, - rpcLAST = rpcISSUE_MALFORMED // rpcLAST should always equal the last code.= + // Oracle + rpcORACLE_MALFORMED = 94, + + rpcLAST = + rpcORACLE_MALFORMED // rpcLAST should always equal the last code.= }; /** Codes returned in the `warnings` array of certain RPC commands. diff --git a/src/ripple/protocol/Feature.h b/src/ripple/protocol/Feature.h index 8e6483b1dbd..95a13d44f0e 100644 --- a/src/ripple/protocol/Feature.h +++ b/src/ripple/protocol/Feature.h @@ -74,7 +74,7 @@ namespace detail { // Feature.cpp. Because it's only used to reserve storage, and determine how // large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than // the actual number of amendments. A LogicError on startup will verify this. -static constexpr std::size_t numFeatures = 67; +static constexpr std::size_t numFeatures = 68; /** Amendments that this server supports and the default voting behavior. Whether they are enabled depends on the Rules defined in the validated @@ -354,6 +354,7 @@ extern uint256 const featureDID; extern uint256 const fixFillOrKill; extern uint256 const fixNFTokenReserve; extern uint256 const fixInnerObjTemplate; +extern uint256 const featurePriceOracle; } // namespace ripple diff --git a/src/ripple/protocol/Indexes.h b/src/ripple/protocol/Indexes.h index 9a330b6b4f0..d83599f892f 100644 --- a/src/ripple/protocol/Indexes.h +++ b/src/ripple/protocol/Indexes.h @@ -283,6 +283,9 @@ xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq); Keylet did(AccountID const& account) noexcept; +Keylet +oracle(AccountID const& account, std::uint32_t const& documentID) noexcept; + } // namespace keylet // Everything below is deprecated and should be removed in favor of keylets: diff --git a/src/ripple/protocol/LedgerFormats.h b/src/ripple/protocol/LedgerFormats.h index db64942790a..e0ea7bf6f46 100644 --- a/src/ripple/protocol/LedgerFormats.h +++ b/src/ripple/protocol/LedgerFormats.h @@ -192,6 +192,11 @@ enum LedgerEntryType : std::uint16_t */ ltDID = 0x0049, + /** A ledger object which tracks Oracle + \sa keylet::oracle + */ + ltORACLE = 0x0080, + //--------------------------------------------------------------------------- /** A special type, matching any ledger entry type. diff --git a/src/ripple/protocol/Protocol.h b/src/ripple/protocol/Protocol.h index 49642efc4cf..bd723627494 100644 --- a/src/ripple/protocol/Protocol.h +++ b/src/ripple/protocol/Protocol.h @@ -109,6 +109,31 @@ using TxID = uint256; */ std::uint16_t constexpr maxDeletableAMMTrustLines = 512; +/** The maximum length of a URI inside an Oracle */ +std::size_t constexpr maxOracleURI = 256; + +/** The maximum length of a Provider inside an Oracle */ +std::size_t constexpr maxOracleProvider = 256; + +/** The maximum size of a data series array inside an Oracle */ +std::size_t constexpr maxOracleDataSeries = 10; + +/** The maximum length of a SymbolClass inside an Oracle */ +std::size_t constexpr maxOracleSymbolClass = 16; + +/** The maximum allowed time difference between lastUpdateTime and the time + of the last closed ledger +*/ +std::size_t constexpr maxLastUpdateTimeDelta = 300; + +/** The maximum price scaling factor + */ +std::size_t constexpr maxPriceScale = 20; + +/** The maximum percentage of outliers to trim + */ +std::size_t constexpr maxTrim = 25; + } // namespace ripple #endif diff --git a/src/ripple/protocol/SField.h b/src/ripple/protocol/SField.h index 5d7acb12383..727d531ff40 100644 --- a/src/ripple/protocol/SField.h +++ b/src/ripple/protocol/SField.h @@ -51,6 +51,7 @@ template class STInteger; class STXChainBridge; class STVector256; +class STCurrency; #pragma push_macro("XMACRO") #undef XMACRO @@ -85,6 +86,7 @@ class STVector256; STYPE(STI_UINT512, 23) \ STYPE(STI_ISSUE, 24) \ STYPE(STI_XCHAIN_BRIDGE, 25) \ + STYPE(STI_CURRENCY, 26) \ \ /* high-level types */ \ /* cannot be serialized inside other types */ \ @@ -346,6 +348,7 @@ using SF_UINT512 = TypedField>; using SF_ACCOUNT = TypedField; using SF_AMOUNT = TypedField; using SF_ISSUE = TypedField; +using SF_CURRENCY = TypedField; using SF_VL = TypedField; using SF_VECTOR256 = TypedField; using SF_XCHAIN_BRIDGE = TypedField; @@ -364,6 +367,7 @@ extern SF_UINT8 const sfCloseResolution; extern SF_UINT8 const sfMethod; extern SF_UINT8 const sfTransactionResult; extern SF_UINT8 const sfWasLockingChainSend; +extern SF_UINT8 const sfScale; // 8-bit integers (uncommon) extern SF_UINT8 const sfTickSize; @@ -400,6 +404,7 @@ extern SF_UINT32 const sfTransferRate; extern SF_UINT32 const sfWalletSize; extern SF_UINT32 const sfOwnerCount; extern SF_UINT32 const sfDestinationTag; +extern SF_UINT32 const sfLastUpdateTime; // 32-bit integers (uncommon) extern SF_UINT32 const sfHighQualityIn; @@ -435,6 +440,7 @@ extern SF_UINT32 const sfHookStateCount; extern SF_UINT32 const sfEmitGeneration; extern SF_UINT32 const sfVoteWeight; extern SF_UINT32 const sfFirstNFTokenSequence; +extern SF_UINT32 const sfOracleDocumentID; // 64-bit integers (common) extern SF_UINT64 const sfIndexNext; @@ -459,6 +465,7 @@ extern SF_UINT64 const sfReferenceCount; extern SF_UINT64 const sfXChainClaimID; extern SF_UINT64 const sfXChainAccountCreateCount; extern SF_UINT64 const sfXChainAccountClaimCount; +extern SF_UINT64 const sfAssetPrice; // 128-bit extern SF_UINT128 const sfEmailHash; @@ -554,6 +561,8 @@ extern SF_VL const sfMemoData; extern SF_VL const sfMemoFormat; extern SF_VL const sfDIDDocument; extern SF_VL const sfData; +extern SF_VL const sfAssetClass; +extern SF_VL const sfProvider; // variable length (uncommon) extern SF_VL const sfFulfillment; @@ -590,6 +599,10 @@ extern SF_ACCOUNT const sfIssuingChainDoor; // path set extern SField const sfPaths; +// currency +extern SF_CURRENCY const sfBaseAsset; +extern SF_CURRENCY const sfQuoteAsset; + // issue extern SF_ISSUE const sfAsset; extern SF_ISSUE const sfAsset2; @@ -623,6 +636,7 @@ extern SField const sfHook; extern SField const sfVoteEntry; extern SField const sfAuctionSlot; extern SField const sfAuthAccount; +extern SField const sfPriceData; extern SField const sfSigner; extern SField const sfMajority; @@ -651,6 +665,7 @@ extern SField const sfNFTokens; extern SField const sfHooks; extern SField const sfVoteSlots; extern SField const sfAuthAccounts; +extern SField const sfPriceDataSeries; // array of objects (uncommon) extern SField const sfMajorities; diff --git a/src/ripple/protocol/STCurrency.h b/src/ripple/protocol/STCurrency.h new file mode 100644 index 00000000000..f855c24832e --- /dev/null +++ b/src/ripple/protocol/STCurrency.h @@ -0,0 +1,138 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_PROTOCOL_STCURRENCY_H_INCLUDED +#define RIPPLE_PROTOCOL_STCURRENCY_H_INCLUDED + +#include +#include +#include +#include +#include + +namespace ripple { + +class STCurrency final : public STBase +{ +private: + Currency currency_{}; + +public: + using value_type = Currency; + + STCurrency() = default; + + explicit STCurrency(SerialIter& sit, SField const& name); + + explicit STCurrency(SField const& name, Currency const& currency); + + explicit STCurrency(SField const& name); + + Currency const& + currency() const; + + Currency const& + value() const noexcept; + + void + setCurrency(Currency const& currency); + + SerializedTypeID + getSType() const override; + + std::string + getText() const override; + + Json::Value getJson(JsonOptions) const override; + + void + add(Serializer& s) const override; + + bool + isEquivalent(const STBase& t) const override; + + bool + isDefault() const override; + +private: + static std::unique_ptr + construct(SerialIter&, SField const& name); + + STBase* + copy(std::size_t n, void* buf) const override; + STBase* + move(std::size_t n, void* buf) override; + + friend class detail::STVar; +}; + +STCurrency +currencyFromJson(SField const& name, Json::Value const& v); + +inline Currency const& +STCurrency::currency() const +{ + return currency_; +} + +inline Currency const& +STCurrency::value() const noexcept +{ + return currency_; +} + +inline void +STCurrency::setCurrency(Currency const& currency) +{ + currency_ = currency; +} + +inline bool +operator==(STCurrency const& lhs, STCurrency const& rhs) +{ + return lhs.currency() == rhs.currency(); +} + +inline bool +operator!=(STCurrency const& lhs, STCurrency const& rhs) +{ + return !operator==(lhs, rhs); +} + +inline bool +operator<(STCurrency const& lhs, STCurrency const& rhs) +{ + return lhs.currency() < rhs.currency(); +} + +inline bool +operator==(STCurrency const& lhs, Currency const& rhs) +{ + return lhs.currency() == rhs; +} + +inline bool +operator<(STCurrency const& lhs, Currency const& rhs) +{ + return lhs.currency() < rhs; +} + +} // namespace ripple + +#endif diff --git a/src/ripple/protocol/STObject.h b/src/ripple/protocol/STObject.h index 5476cd01198..38678f67a55 100644 --- a/src/ripple/protocol/STObject.h +++ b/src/ripple/protocol/STObject.h @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -241,6 +242,8 @@ class STObject : public STBase, public CountedObject getFieldV256(SField const& field) const; const STArray& getFieldArray(SField const& field) const; + const STCurrency& + getFieldCurrency(SField const& field) const; /** Get the value of a field. @param A TypedField built from an SField value representing the desired @@ -370,6 +373,8 @@ class STObject : public STBase, public CountedObject void setFieldIssue(SField const& field, STIssue const&); void + setFieldCurrency(SField const& field, STCurrency const&); + void setFieldPathSet(SField const& field, STPathSet const&); void setFieldV256(SField const& field, STVector256 const& v); diff --git a/src/ripple/protocol/TER.h b/src/ripple/protocol/TER.h index 61028d60e9d..8cd5e824608 100644 --- a/src/ripple/protocol/TER.h +++ b/src/ripple/protocol/TER.h @@ -135,6 +135,9 @@ enum TEMcodes : TERUnderlyingType { temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT, temEMPTY_DID, + + temARRAY_EMPTY, + temARRAY_TOO_LARGE, }; //------------------------------------------------------------------------------ @@ -330,7 +333,11 @@ enum TECcodes : TERUnderlyingType { tecXCHAIN_SELF_COMMIT = 184, tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR = 185, tecXCHAIN_CREATE_ACCOUNT_DISABLED = 186, - tecEMPTY_DID = 187 + tecEMPTY_DID = 187, + tecINVALID_UPDATE_TIME = 188, + tecTOKEN_PAIR_NOT_FOUND = 189, + tecARRAY_EMPTY = 190, + tecARRAY_TOO_LARGE = 191 }; //------------------------------------------------------------------------------ diff --git a/src/ripple/protocol/TxFormats.h b/src/ripple/protocol/TxFormats.h index b12547b0a67..b5afa470f38 100644 --- a/src/ripple/protocol/TxFormats.h +++ b/src/ripple/protocol/TxFormats.h @@ -191,6 +191,12 @@ enum TxType : std::uint16_t ttDID_DELETE = 50, + /** This transaction type creates an Oracle instance */ + ttORACLE_SET = 51, + + /** This transaction type deletes an Oracle instance */ + ttORACLE_DELETE = 52, + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/src/ripple/protocol/impl/ErrorCodes.cpp b/src/ripple/protocol/impl/ErrorCodes.cpp index 319bd8e28c2..3af48891c78 100644 --- a/src/ripple/protocol/impl/ErrorCodes.cpp +++ b/src/ripple/protocol/impl/ErrorCodes.cpp @@ -109,7 +109,8 @@ constexpr static ErrorInfo unorderedErrorInfos[]{ {rpcSTREAM_MALFORMED, "malformedStream", "Stream malformed.", 400}, {rpcTOO_BUSY, "tooBusy", "The server is too busy to help you now.", 503}, {rpcTXN_NOT_FOUND, "txnNotFound", "Transaction not found.", 404}, - {rpcUNKNOWN_COMMAND, "unknownCmd", "Unknown method.", 405}}; + {rpcUNKNOWN_COMMAND, "unknownCmd", "Unknown method.", 405}, + {rpcORACLE_MALFORMED, "oracleMalformed", "Oracle request is malformed.", 400}}; // clang-format on // Sort and validate unorderedErrorInfos at compile time. Should be diff --git a/src/ripple/protocol/impl/Feature.cpp b/src/ripple/protocol/impl/Feature.cpp index ab36983edd7..526ef5982fc 100644 --- a/src/ripple/protocol/impl/Feature.cpp +++ b/src/ripple/protocol/impl/Feature.cpp @@ -461,6 +461,7 @@ REGISTER_FEATURE(DID, Supported::yes, VoteBehavior::De REGISTER_FIX(fixFillOrKill, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FIX (fixNFTokenReserve, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FIX(fixInnerObjTemplate, Supported::yes, VoteBehavior::DefaultNo); +REGISTER_FEATURE(PriceOracle, Supported::yes, VoteBehavior::DefaultNo); // The following amendments are obsolete, but must remain supported // because they could potentially get enabled. diff --git a/src/ripple/protocol/impl/Indexes.cpp b/src/ripple/protocol/impl/Indexes.cpp index 74f6b6492de..0ee52aab297 100644 --- a/src/ripple/protocol/impl/Indexes.cpp +++ b/src/ripple/protocol/impl/Indexes.cpp @@ -72,6 +72,7 @@ enum class LedgerNameSpace : std::uint16_t { XCHAIN_CLAIM_ID = 'Q', XCHAIN_CREATE_ACCOUNT_CLAIM_ID = 'K', DID = 'I', + ORACLE = 'R', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -444,6 +445,12 @@ did(AccountID const& account) noexcept return {ltDID, indexHash(LedgerNameSpace::DID, account)}; } +Keylet +oracle(AccountID const& account, std::uint32_t const& documentID) noexcept +{ + return {ltORACLE, indexHash(LedgerNameSpace::ORACLE, account, documentID)}; +} + } // namespace keylet } // namespace ripple diff --git a/src/ripple/protocol/impl/InnerObjectFormats.cpp b/src/ripple/protocol/impl/InnerObjectFormats.cpp index 4350ea180d2..edebc57477e 100644 --- a/src/ripple/protocol/impl/InnerObjectFormats.cpp +++ b/src/ripple/protocol/impl/InnerObjectFormats.cpp @@ -138,6 +138,15 @@ InnerObjectFormats::InnerObjectFormats() { {sfAccount, soeREQUIRED}, }); + + add(sfPriceData.jsonName.c_str(), + sfPriceData.getCode(), + { + {sfBaseAsset, soeREQUIRED}, + {sfQuoteAsset, soeREQUIRED}, + {sfAssetPrice, soeOPTIONAL}, + {sfScale, soeDEFAULT}, + }); } InnerObjectFormats const& diff --git a/src/ripple/protocol/impl/LedgerFormats.cpp b/src/ripple/protocol/impl/LedgerFormats.cpp index 729ddc1c7bc..26cd7ea69b0 100644 --- a/src/ripple/protocol/impl/LedgerFormats.cpp +++ b/src/ripple/protocol/impl/LedgerFormats.cpp @@ -339,6 +339,22 @@ LedgerFormats::LedgerFormats() {sfPreviousTxnLgrSeq, soeREQUIRED} }, commonFields); + + add(jss::Oracle, + ltORACLE, + { + {sfOwner, soeREQUIRED}, + {sfProvider, soeREQUIRED}, + {sfPriceDataSeries, soeREQUIRED}, + {sfAssetClass, soeREQUIRED}, + {sfLastUpdateTime, soeREQUIRED}, + {sfURI, soeOPTIONAL}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED} + }, + commonFields); + // clang-format on } diff --git a/src/ripple/protocol/impl/SField.cpp b/src/ripple/protocol/impl/SField.cpp index 027c8ffb9c5..6d034db75ef 100644 --- a/src/ripple/protocol/impl/SField.cpp +++ b/src/ripple/protocol/impl/SField.cpp @@ -91,6 +91,7 @@ CONSTRUCT_UNTYPED_SFIELD(sfMetadata, "Metadata", METADATA CONSTRUCT_TYPED_SFIELD(sfCloseResolution, "CloseResolution", UINT8, 1); CONSTRUCT_TYPED_SFIELD(sfMethod, "Method", UINT8, 2); CONSTRUCT_TYPED_SFIELD(sfTransactionResult, "TransactionResult", UINT8, 3); +CONSTRUCT_TYPED_SFIELD(sfScale, "Scale", UINT8, 4); // 8-bit integers (uncommon) CONSTRUCT_TYPED_SFIELD(sfTickSize, "TickSize", UINT8, 16); @@ -128,6 +129,7 @@ CONSTRUCT_TYPED_SFIELD(sfTransferRate, "TransferRate", UINT32, CONSTRUCT_TYPED_SFIELD(sfWalletSize, "WalletSize", UINT32, 12); CONSTRUCT_TYPED_SFIELD(sfOwnerCount, "OwnerCount", UINT32, 13); CONSTRUCT_TYPED_SFIELD(sfDestinationTag, "DestinationTag", UINT32, 14); +CONSTRUCT_TYPED_SFIELD(sfLastUpdateTime, "LastUpdateTime", UINT32, 15); // 32-bit integers (uncommon) CONSTRUCT_TYPED_SFIELD(sfHighQualityIn, "HighQualityIn", UINT32, 16); @@ -164,6 +166,7 @@ CONSTRUCT_TYPED_SFIELD(sfEmitGeneration, "EmitGeneration", UINT32, // 47 is reserved for LockCount(Hooks) CONSTRUCT_TYPED_SFIELD(sfVoteWeight, "VoteWeight", UINT32, 48); CONSTRUCT_TYPED_SFIELD(sfFirstNFTokenSequence, "FirstNFTokenSequence", UINT32, 50); +CONSTRUCT_TYPED_SFIELD(sfOracleDocumentID, "OracleDocumentID", UINT32, 51); // 64-bit integers (common) CONSTRUCT_TYPED_SFIELD(sfIndexNext, "IndexNext", UINT64, 1); @@ -188,6 +191,7 @@ CONSTRUCT_TYPED_SFIELD(sfReferenceCount, "ReferenceCount", U CONSTRUCT_TYPED_SFIELD(sfXChainClaimID, "XChainClaimID", UINT64, 20); CONSTRUCT_TYPED_SFIELD(sfXChainAccountCreateCount, "XChainAccountCreateCount", UINT64, 21); CONSTRUCT_TYPED_SFIELD(sfXChainAccountClaimCount, "XChainAccountClaimCount", UINT64, 22); +CONSTRUCT_TYPED_SFIELD(sfAssetPrice, "AssetPrice", UINT64, 23); // 128-bit CONSTRUCT_TYPED_SFIELD(sfEmailHash, "EmailHash", UINT128, 1); @@ -300,6 +304,8 @@ CONSTRUCT_TYPED_SFIELD(sfHookParameterName, "HookParameterName", VL, CONSTRUCT_TYPED_SFIELD(sfHookParameterValue, "HookParameterValue", VL, 25); CONSTRUCT_TYPED_SFIELD(sfDIDDocument, "DIDDocument", VL, 26); CONSTRUCT_TYPED_SFIELD(sfData, "Data", VL, 27); +CONSTRUCT_TYPED_SFIELD(sfAssetClass, "AssetClass", VL, 28); +CONSTRUCT_TYPED_SFIELD(sfProvider, "Provider", VL, 29); // account CONSTRUCT_TYPED_SFIELD(sfAccount, "Account", ACCOUNT, 1); @@ -331,6 +337,10 @@ CONSTRUCT_TYPED_SFIELD(sfNFTokenOffers, "NFTokenOffers", VECTOR25 // path set CONSTRUCT_UNTYPED_SFIELD(sfPaths, "Paths", PATHSET, 1); +// currency +CONSTRUCT_TYPED_SFIELD(sfBaseAsset, "BaseAsset", CURRENCY, 1); +CONSTRUCT_TYPED_SFIELD(sfQuoteAsset, "QuoteAsset", CURRENCY, 2); + // issue CONSTRUCT_TYPED_SFIELD(sfLockingChainIssue, "LockingChainIssue", ISSUE, 1); CONSTRUCT_TYPED_SFIELD(sfIssuingChainIssue, "IssuingChainIssue", ISSUE, 2); @@ -379,6 +389,7 @@ CONSTRUCT_UNTYPED_SFIELD(sfXChainClaimAttestationCollectionElement, CONSTRUCT_UNTYPED_SFIELD(sfXChainCreateAccountAttestationCollectionElement, "XChainCreateAccountAttestationCollectionElement", OBJECT, 31); +CONSTRUCT_UNTYPED_SFIELD(sfPriceData, "PriceData", OBJECT, 32); // array of objects // ARRAY/1 is reserved for end of array @@ -406,7 +417,8 @@ CONSTRUCT_UNTYPED_SFIELD(sfXChainClaimAttestations, CONSTRUCT_UNTYPED_SFIELD(sfXChainCreateAccountAttestations, "XChainCreateAccountAttestations", ARRAY, 22); -// 23 and 24 are unused and available for use +// 23 is unused and available for use +CONSTRUCT_UNTYPED_SFIELD(sfPriceDataSeries, "PriceDataSeries", ARRAY, 24); CONSTRUCT_UNTYPED_SFIELD(sfAuthAccounts, "AuthAccounts", ARRAY, 25); // clang-format on diff --git a/src/ripple/protocol/impl/STCurrency.cpp b/src/ripple/protocol/impl/STCurrency.cpp new file mode 100644 index 00000000000..d2bc1b3bea7 --- /dev/null +++ b/src/ripple/protocol/impl/STCurrency.cpp @@ -0,0 +1,114 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include + +#include + +namespace ripple { + +STCurrency::STCurrency(SField const& name) : STBase{name} +{ +} + +STCurrency::STCurrency(SerialIter& sit, SField const& name) : STBase{name} +{ + currency_ = sit.get160(); +} + +STCurrency::STCurrency(SField const& name, Currency const& currency) + : STBase{name}, currency_{currency} +{ +} + +SerializedTypeID +STCurrency::getSType() const +{ + return STI_CURRENCY; +} + +std::string +STCurrency::getText() const +{ + return to_string(currency_); +} + +Json::Value STCurrency::getJson(JsonOptions) const +{ + return to_string(currency_); +} + +void +STCurrency::add(Serializer& s) const +{ + s.addBitString(currency_); +} + +bool +STCurrency::isEquivalent(const STBase& t) const +{ + const STCurrency* v = dynamic_cast(&t); + return v && (*v == *this); +} + +bool +STCurrency::isDefault() const +{ + return isXRP(currency_); +} + +std::unique_ptr +STCurrency::construct(SerialIter& sit, SField const& name) +{ + return std::make_unique(sit, name); +} + +STBase* +STCurrency::copy(std::size_t n, void* buf) const +{ + return emplace(n, buf, *this); +} + +STBase* +STCurrency::move(std::size_t n, void* buf) +{ + return emplace(n, buf, std::move(*this)); +} + +STCurrency +currencyFromJson(SField const& name, Json::Value const& v) +{ + if (!v.isString()) + { + Throw( + "currencyFromJson currency must be a string Json value"); + } + + auto const currency = to_currency(v.asString()); + if (currency == badCurrency() || currency == noCurrency()) + { + Throw( + "currencyFromJson currency must be a valid currency"); + } + + return STCurrency{name, currency}; +} + +} // namespace ripple diff --git a/src/ripple/protocol/impl/STObject.cpp b/src/ripple/protocol/impl/STObject.cpp index 7c546a2568e..dbcb47e8794 100644 --- a/src/ripple/protocol/impl/STObject.cpp +++ b/src/ripple/protocol/impl/STObject.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include namespace ripple { @@ -642,6 +643,13 @@ STObject::getFieldArray(SField const& field) const return getFieldByConstRef(field, empty); } +STCurrency const& +STObject::getFieldCurrency(SField const& field) const +{ + static STCurrency const empty{}; + return getFieldByConstRef(field, empty); +} + void STObject::set(std::unique_ptr v) { @@ -730,6 +738,12 @@ STObject::setFieldAmount(SField const& field, STAmount const& v) setFieldUsingAssignment(field, v); } +void +STObject::setFieldCurrency(SField const& field, STCurrency const& v) +{ + setFieldUsingAssignment(field, v); +} + void STObject::setFieldIssue(SField const& field, STIssue const& v) { diff --git a/src/ripple/protocol/impl/STParsedJSON.cpp b/src/ripple/protocol/impl/STParsedJSON.cpp index fb960e6f11e..6727fe7388c 100644 --- a/src/ripple/protocol/impl/STParsedJSON.cpp +++ b/src/ripple/protocol/impl/STParsedJSON.cpp @@ -760,6 +760,19 @@ parseLeaf( } break; + case STI_CURRENCY: + try + { + ret = detail::make_stvar( + currencyFromJson(field, value)); + } + catch (std::exception const&) + { + error = invalid_data(json_name, fieldName); + return ret; + } + break; + default: error = bad_type(json_name, fieldName); return ret; diff --git a/src/ripple/protocol/impl/STVar.cpp b/src/ripple/protocol/impl/STVar.cpp index 2ec55ccaf03..adda165901f 100644 --- a/src/ripple/protocol/impl/STVar.cpp +++ b/src/ripple/protocol/impl/STVar.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -167,6 +168,9 @@ STVar::STVar(SerialIter& sit, SField const& name, int depth) case STI_XCHAIN_BRIDGE: construct(sit, name); return; + case STI_CURRENCY: + construct(sit, name); + return; default: Throw("Unknown object type"); } @@ -228,6 +232,9 @@ STVar::STVar(SerializedTypeID id, SField const& name) case STI_XCHAIN_BRIDGE: construct(name); return; + case STI_CURRENCY: + construct(name); + return; default: Throw("Unknown object type"); } diff --git a/src/ripple/protocol/impl/TER.cpp b/src/ripple/protocol/impl/TER.cpp index 5f608e806ab..caba034a1c4 100644 --- a/src/ripple/protocol/impl/TER.cpp +++ b/src/ripple/protocol/impl/TER.cpp @@ -111,6 +111,10 @@ transResults() MAKE_ERROR(tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR, "Bad public key account pair in an xchain transaction."), MAKE_ERROR(tecXCHAIN_CREATE_ACCOUNT_DISABLED, "This bridge does not support account creation."), MAKE_ERROR(tecEMPTY_DID, "The DID object did not have a URI or DIDDocument field."), + MAKE_ERROR(tecINVALID_UPDATE_TIME, "The Oracle object has invalid LastUpdateTime field."), + MAKE_ERROR(tecTOKEN_PAIR_NOT_FOUND, "Token pair is not found in Oracle object."), + MAKE_ERROR(tecARRAY_EMPTY, "Array is empty."), + MAKE_ERROR(tecARRAY_TOO_LARGE, "Array is too large."), MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."), MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."), @@ -197,6 +201,8 @@ transResults() MAKE_ERROR(temXCHAIN_BRIDGE_NONDOOR_OWNER, "Malformed: Bridge owner must be one of the door accounts."), MAKE_ERROR(temXCHAIN_BRIDGE_BAD_MIN_ACCOUNT_CREATE_AMOUNT, "Malformed: Bad min account create amount."), MAKE_ERROR(temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT, "Malformed: Bad reward amount."), + MAKE_ERROR(temARRAY_EMPTY, "Malformed: Array is empty."), + MAKE_ERROR(temARRAY_TOO_LARGE, "Malformed: Array is too large."), MAKE_ERROR(terRETRY, "Retry transaction."), MAKE_ERROR(terFUNDS_SPENT, "DEPRECATED."), diff --git a/src/ripple/protocol/impl/TxFormats.cpp b/src/ripple/protocol/impl/TxFormats.cpp index 7be8ca741e2..d2bdd4f8aa7 100644 --- a/src/ripple/protocol/impl/TxFormats.cpp +++ b/src/ripple/protocol/impl/TxFormats.cpp @@ -483,6 +483,25 @@ TxFormats::TxFormats() commonFields); add(jss::DIDDelete, ttDID_DELETE, {}, commonFields); + + add(jss::OracleSet, + ttORACLE_SET, + { + {sfOracleDocumentID, soeREQUIRED}, + {sfProvider, soeOPTIONAL}, + {sfURI, soeOPTIONAL}, + {sfAssetClass, soeOPTIONAL}, + {sfLastUpdateTime, soeREQUIRED}, + {sfPriceDataSeries, soeREQUIRED}, + }, + commonFields); + + add(jss::OracleDelete, + ttORACLE_DELETE, + { + {sfOracleDocumentID, soeREQUIRED}, + }, + commonFields); } TxFormats const& diff --git a/src/ripple/protocol/jss.h b/src/ripple/protocol/jss.h index fd1c94c67d2..2655e73ef57 100644 --- a/src/ripple/protocol/jss.h +++ b/src/ripple/protocol/jss.h @@ -60,8 +60,11 @@ JSS(Amount); // in: TransactionSign; field. JSS(Amount2); // in/out: AMM IOU/XRP pool, deposit, withdraw amount JSS(Asset); // in: AMM Asset1 JSS(Asset2); // in: AMM Asset2 +JSS(AssetClass); // in: Oracle +JSS(AssetPrice); // in: Oracle JSS(AuthAccount); // in: AMM Auction Slot JSS(AuthAccounts); // in: AMM Auction Slot +JSS(BaseAsset); // in: Oracle JSS(Bridge); // ledger type. JSS(Check); // ledger type. JSS(CheckCancel); // transaction type. @@ -89,6 +92,7 @@ JSS(Flags); // in/out: TransactionSign; field. JSS(incomplete_shards); // out: OverlayImpl, PeerImp JSS(Invalid); // JSS(LastLedgerSequence); // in: TransactionSign; field +JSS(LastUpdateTime); // field. JSS(LedgerHashes); // ledger type. JSS(LimitAmount); // field. JSS(BidMax); // in: AMM Bid @@ -108,16 +112,26 @@ JSS(Offer); // ledger type. JSS(OfferCancel); // transaction type. JSS(OfferCreate); // transaction type. JSS(OfferSequence); // field. +JSS(Oracle); // ledger type. +JSS(OracleDelete); // transaction type. +JSS(OracleDocumentID); // field +JSS(OracleSet); // transaction type. +JSS(Owner); // field JSS(Paths); // in/out: TransactionSign JSS(PayChannel); // ledger type. JSS(Payment); // transaction type. JSS(PaymentChannelClaim); // transaction type. JSS(PaymentChannelCreate); // transaction type. JSS(PaymentChannelFund); // transaction type. +JSS(PriceDataSeries); // field. +JSS(PriceData); // field. +JSS(Provider); // field. +JSS(QuoteAsset); // in: Oracle. JSS(RippleState); // ledger type. JSS(SLE_hit_rate); // out: GetCounts. JSS(SetFee); // transaction type. JSS(UNLModify); // transaction type. +JSS(Scale); // field. JSS(SettleDelay); // in: TransactionSign JSS(SendMax); // in: TransactionSign JSS(Sequence); // in/out: TransactionSign; field. @@ -135,6 +149,7 @@ JSS(TradingFee); // in/out: AMM trading fee JSS(TransactionType); // in: TransactionSign. JSS(TransferRate); // in: TransferRate. JSS(TrustSet); // transaction type. +JSS(URI); // field. JSS(VoteSlots); // out: AMM Vote JSS(XChainAddAccountCreateAttestation); // transaction type. JSS(XChainAddClaimAttestation); // transaction type. @@ -202,6 +217,7 @@ JSS(avg_bps_sent); // out: Peers JSS(balance); // out: AccountLines JSS(balances); // out: GatewayBalances JSS(base); // out: LogLevel +JSS(base_asset); // in: get_aggregate_price JSS(base_fee); // out: NetworkOPs JSS(base_fee_xrp); // out: NetworkOPs JSS(bids); // out: Subscribe @@ -299,6 +315,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 @@ -458,6 +475,8 @@ JSS(max_ledger); // in/out: LedgerCleaner JSS(max_queue_size); // out: TxQ JSS(max_spend_drops); // out: AccountInfo JSS(max_spend_drops_total); // out: AccountInfo +JSS(mean); // out: get_aggregate_price +JSS(median); // out: get_aggregate_price JSS(median_fee); // out: TxQ JSS(median_level); // out: TxQ JSS(message); // error. @@ -515,6 +534,9 @@ JSS(open); // out: handlers/Ledger JSS(open_ledger_cost); // out: SubmitTransaction JSS(open_ledger_fee); // out: TxQ JSS(open_ledger_level); // out: TxQ +JSS(oracle); // in: LedgerEntry +JSS(oracles); // in: get_aggregate_price +JSS(oracle_document_id); // in: get_aggregate_price JSS(owner); // in: LedgerEntry, out: NetworkOPs JSS(owner_funds); // in/out: Ledger, NetworkOPs, AcceptedLedgerTx JSS(page_index); @@ -561,6 +583,7 @@ JSS(queue); // in: AccountInfo JSS(queue_data); // out: AccountInfo JSS(queued); // out: SubmitTransaction JSS(queued_duration_us); +JSS(quote_asset); // in: get_aggregate_price JSS(random); // out: Random JSS(raw_meta); // out: AcceptedLedgerTx JSS(receive_currencies); // out: AccountCurrencies @@ -615,12 +638,14 @@ JSS(signing_keys); // out: ValidatorList JSS(signing_time); // out: NetworkOPs JSS(signer_list); // in: AccountObjects JSS(signer_lists); // in/out: AccountInfo +JSS(size); // out: get_aggregate_price JSS(snapshot); // in: Subscribe JSS(source_account); // in: PathRequest, RipplePathFind JSS(source_amount); // in: PathRequest, RipplePathFind JSS(source_currencies); // in: PathRequest, RipplePathFind JSS(source_tag); // out: AccountChannels JSS(stand_alone); // out: NetworkOPs +JSS(standard_deviation); // out: get_aggregate_price JSS(start); // in: TxHistory JSS(started); JSS(state); // out: Logic.h, ServerState, LedgerData @@ -636,6 +661,7 @@ JSS(sub_index); // in: LedgerEntry JSS(subcommand); // in: PathFind JSS(success); // rpc JSS(supported); // out: AmendmentTableImpl +JSS(sync_mode); // in: Submit JSS(system_time_offset); // out: NetworkOPs JSS(tag); // out: Peers JSS(taker); // in: Subscribe, BookOffers @@ -649,9 +675,12 @@ JSS(ticket_count); // out: AccountInfo JSS(ticket_seq); // in: LedgerEntry JSS(time); JSS(timeouts); // out: InboundLedger +JSS(time_threshold); // in/out: Oracle aggregate 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 new file mode 100644 index 00000000000..5490cc4fcff --- /dev/null +++ b/src/ripple/rpc/handlers/GetAggregatePrice.cpp @@ -0,0 +1,340 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ripple { + +using namespace boost::bimaps; +// sorted descending by lastUpdateTime, ascending by AssetPrice +using Prices = bimap< + multiset_of>, + multiset_of>; + +/** Calls callback "f" on the ledger-object sle and up to three previous + * metadata objects. Stops early if the callback returns true. + */ +static void +iteratePriceData( + RPC::JsonContext& context, + std::shared_ptr const& sle, + std::function&& f) +{ + using Meta = std::shared_ptr; + constexpr std::uint8_t maxHistory = 3; + bool isNew = false; + std::uint8_t history = 0; + + // `oracle` points to an object that has an `sfPriceDataSeries` field. + // When this function is called, that is a `PriceOracle` ledger object, + // but after one iteration of the loop below, it is an `sfNewFields` + // / `sfFinalFields` object in a `CreatedNode` / `ModifiedNode` object in + // a transaction's metadata. + + // `chain` points to an object that has `sfPreviousTxnID` and + // `sfPreviousTxnLgrSeq` fields. When this function is called, + // that is the `PriceOracle` ledger object pointed to by `oracle`, + // but after one iteration of the loop below, then it is a `ModifiedNode` + // / `CreatedNode` object in a transaction's metadata. + STObject const* oracle = sle.get(); + STObject const* chain = oracle; + // Use to test an unlikely scenario when CreatedNode / ModifiedNode + // for the Oracle is not found in the inner loop + STObject const* prevChain = nullptr; + + Meta meta = nullptr; + while (true) + { + if (prevChain == chain) + return; + + if (!oracle || f(*oracle) || isNew) + return; + + if (++history > maxHistory) + return; + + uint256 prevTx = chain->getFieldH256(sfPreviousTxnID); + std::uint32_t prevSeq = chain->getFieldU32(sfPreviousTxnLgrSeq); + + auto const ledger = context.ledgerMaster.getLedgerBySeq(prevSeq); + if (!ledger) + return; + + meta = ledger->txRead(prevTx).second; + + for (STObject const& node : meta->getFieldArray(sfAffectedNodes)) + { + if (node.getFieldU16(sfLedgerEntryType) != ltORACLE) + { + continue; + } + + prevChain = chain; + chain = &node; + isNew = node.isFieldPresent(sfNewFields); + // if a meta is for the new and this is the first + // look-up then it's the meta for the tx that + // created the current object; i.e. there is no + // historical data + if (isNew && history == 1) + return; + + oracle = isNew + ? &static_cast(node.peekAtField(sfNewFields)) + : &static_cast( + node.peekAtField(sfFinalFields)); + break; + } + } +} + +// 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 const 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 {account, oracle_document_id} + * base_asset: is the asset to be priced + * quote_asset: is the denomination in which the prices are expressed + * trim : percentage of outliers to trim [optional] + * 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) +{ + Json::Value result; + auto const& params(context.params); + + constexpr std::uint16_t maxOracles = 200; + if (!params.isMember(jss::oracles)) + return RPC::missing_field_error(jss::oracles); + if (!params[jss::oracles].isArray() || params[jss::oracles].size() == 0 || + params[jss::oracles].size() > maxOracles) + { + RPC::inject_error(rpcORACLE_MALFORMED, result); + return result; + } + + if (!params.isMember(jss::base_asset)) + return RPC::missing_field_error(jss::base_asset); + + if (!params.isMember(jss::quote_asset)) + return RPC::missing_field_error(jss::quote_asset); + + // Lambda to get `trim` and `time_threshold` fields. If the field + // is not included in the input then a default value is returned. + auto getField = [¶ms]( + 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; + } + if (params.isMember(jss::trim) && + (std::get(trim) == 0 || + std::get(trim) > maxTrim)) + { + RPC::inject_error(rpcINVALID_PARAMS, result); + return result; + } + + auto const timeThreshold = getField(jss::time_threshold, 0); + if (std::holds_alternative(timeThreshold)) + { + RPC::inject_error(std::get(timeThreshold), result); + return result; + } + + auto const& baseAsset = params[jss::base_asset]; + auto const& quoteAsset = params[jss::quote_asset]; + + // 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_document_id) || + !oracle.isMember(jss::account)) + { + RPC::inject_error(rpcORACLE_MALFORMED, result); + return result; + } + auto const documentID = oracle[jss::oracle_document_id].isConvertibleTo( + Json::ValueType::uintValue) + ? std::make_optional(oracle[jss::oracle_document_id].asUInt()) + : std::nullopt; + auto const account = + parseBase58(oracle[jss::account].asString()); + if (!account || account->isZero() || !documentID) + { + RPC::inject_error(rpcINVALID_PARAMS, result); + return result; + } + + std::shared_ptr ledger; + result = RPC::lookupLedger(ledger, context); + if (!ledger) + return result; + + auto const sle = ledger->read(keylet::oracle(*account, *documentID)); + iteratePriceData(context, sle, [&](STObject const& node) { + auto const& series = node.getFieldArray(sfPriceDataSeries); + // find the token pair entry with the price + if (auto iter = std::find_if( + series.begin(), + series.end(), + [&](STObject const& o) -> bool { + return o.getFieldCurrency(sfBaseAsset).getText() == + baseAsset && + o.getFieldCurrency(sfQuoteAsset).getText() == + quoteAsset && + o.isFieldPresent(sfAssetPrice); + }); + iter != series.end()) + { + auto const price = iter->getFieldU64(sfAssetPrice); + auto const scale = iter->isFieldPresent(sfScale) + ? -static_cast(iter->getFieldU8(sfScale)) + : 0; + prices.insert(Prices::value_type( + node.getFieldU32(sfLastUpdateTime), + STAmount{noIssue(), price, scale})); + return true; + } + return false; + }); + } + + if (prices.empty()) + { + RPC::inject_error(rpcOBJECT_NOT_FOUND, result); + return result; + } + + // erase outdated data + // sorted in descending, therefore begin is the latest, end is the oldest + auto const latestTime = prices.left.begin()->first; + if (auto const threshold = std::get(timeThreshold)) + { + // threshold defines an acceptable range {max,min} of lastUpdateTime as + // {latestTime, latestTime - threshold}, the prices with lastUpdateTime + // greater than (latestTime - threshold) are erased. + auto const oldestTime = prices.left.rbegin()->first; + 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()) + { + RPC::inject_error(rpcOBJECT_NOT_FOUND, result); + return result; + } + } + result[jss::time] = latestTime; + + // calculate stats + auto const [avg, sd, size] = + getStats(prices.right.begin(), prices.right.end()); + result[jss::entire_set][jss::mean] = avg.getText(); + result[jss::entire_set][jss::size] = size; + result[jss::entire_set][jss::standard_deviation] = to_string(sd); + + auto itAdvance = [&](auto it, int distance) { + std::advance(it, distance); + return it; + }; + + auto const median = [&prices, &itAdvance, &size_ = size]() { + auto const middle = size_ / 2; + if ((size_ % 2) == 0) + { + static STAmount two{noIssue(), 2, 0}; + auto it = itAdvance(prices.right.begin(), middle - 1); + auto const& a1 = it->first; + auto const& a2 = (++it)->first; + return divide(a1 + a2, two, noIssue()); + } + return itAdvance(prices.right.begin(), middle)->first; + }(); + result[jss::median] = median.getText(); + + if (std::get(trim) != 0) + { + auto const trimCount = + prices.size() * std::get(trim) / 100; + + auto const [avg, sd, size] = getStats( + itAdvance(prices.right.begin(), trimCount), + itAdvance(prices.right.end(), -trimCount)); + result[jss::trimmed_set][jss::mean] = avg.getText(); + result[jss::trimmed_set][jss::size] = size; + result[jss::trimmed_set][jss::standard_deviation] = to_string(sd); + } + + return result; +} + +} // namespace ripple diff --git a/src/ripple/rpc/handlers/Handlers.h b/src/ripple/rpc/handlers/Handlers.h index ba93be54513..6c74c5c7e5c 100644 --- a/src/ripple/rpc/handlers/Handlers.h +++ b/src/ripple/rpc/handlers/Handlers.h @@ -73,6 +73,8 @@ doGatewayBalances(RPC::JsonContext&); Json::Value doGetCounts(RPC::JsonContext&); Json::Value +doGetAggregatePrice(RPC::JsonContext&); +Json::Value doLedgerAccept(RPC::JsonContext&); Json::Value doLedgerCleaner(RPC::JsonContext&); diff --git a/src/ripple/rpc/handlers/LedgerEntry.cpp b/src/ripple/rpc/handlers/LedgerEntry.cpp index baff721cc1f..de106841ae6 100644 --- a/src/ripple/rpc/handlers/LedgerEntry.cpp +++ b/src/ripple/rpc/handlers/LedgerEntry.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -598,6 +599,51 @@ doLedgerEntry(RPC::JsonContext& context) else uNodeIndex = keylet::did(*account).key; } + else if (context.params.isMember(jss::oracle)) + { + expectedType = ltORACLE; + if (!context.params[jss::oracle].isObject()) + { + if (!uNodeIndex.parseHex( + context.params[jss::oracle].asString())) + { + uNodeIndex = beast::zero; + jvResult[jss::error] = "malformedRequest"; + } + } + else if ( + !context.params[jss::oracle].isMember( + jss::oracle_document_id) || + !context.params[jss::oracle].isMember(jss::account)) + { + jvResult[jss::error] = "malformedRequest"; + } + else + { + uNodeIndex = beast::zero; + auto const& oracle = context.params[jss::oracle]; + auto const documentID = [&]() -> std::optional { + auto const& id = oracle[jss::oracle_document_id]; + if (id.isConvertibleTo(Json::ValueType::uintValue)) + return std::make_optional(id.asUInt()); + else if (id.isString()) + { + std::uint32_t v; + if (beast::lexicalCastChecked(v, id.asString())) + return std::make_optional(v); + } + return std::nullopt; + }(); + auto const account = + parseBase58(oracle[jss::account].asString()); + if (!account || account->isZero()) + jvResult[jss::error] = "malformedAddress"; + else if (!documentID) + jvResult[jss::error] = "malformedDocumentID"; + else + uNodeIndex = keylet::oracle(*account, *documentID).key; + } + } else { if (context.params.isMember("params") && diff --git a/src/ripple/rpc/impl/Handler.cpp b/src/ripple/rpc/impl/Handler.cpp index d05c3279800..b3f11a28040 100644 --- a/src/ripple/rpc/impl/Handler.cpp +++ b/src/ripple/rpc/impl/Handler.cpp @@ -111,6 +111,10 @@ Handler const handlerArray[]{ {"gateway_balances", byRef(&doGatewayBalances), Role::USER, NO_CONDITION}, #endif {"get_counts", byRef(&doGetCounts), Role::ADMIN, NO_CONDITION}, + {"get_aggregate_price", + byRef(&doGetAggregatePrice), + Role::USER, + NO_CONDITION}, {"feature", byRef(&doFeature), Role::ADMIN, NO_CONDITION}, {"fee", byRef(&doFee), Role::USER, NEEDS_CURRENT_LEDGER}, {"fetch_info", byRef(&doFetchInfo), Role::ADMIN, NO_CONDITION}, diff --git a/src/ripple/rpc/impl/RPCHelpers.cpp b/src/ripple/rpc/impl/RPCHelpers.cpp index 5e7300adea8..d7fbaccbc40 100644 --- a/src/ripple/rpc/impl/RPCHelpers.cpp +++ b/src/ripple/rpc/impl/RPCHelpers.cpp @@ -934,7 +934,7 @@ chooseLedgerEntryType(Json::Value const& params) std::pair result{RPC::Status::OK, ltANY}; if (params.isMember(jss::type)) { - static constexpr std::array, 20> + static constexpr std::array, 21> types{ {{jss::account, ltACCOUNT_ROOT}, {jss::amendments, ltAMENDMENTS}, @@ -956,7 +956,8 @@ chooseLedgerEntryType(Json::Value const& params) {jss::xchain_owned_claim_id, ltXCHAIN_OWNED_CLAIM_ID}, {jss::xchain_owned_create_account_claim_id, ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID}, - {jss::did, ltDID}}}; + {jss::did, ltDID}, + {jss::oracle, ltORACLE}}}; auto const& p = params[jss::type]; if (!p.isString()) diff --git a/src/test/app/Oracle_test.cpp b/src/test/app/Oracle_test.cpp new file mode 100644 index 00000000000..f5488c793a1 --- /dev/null +++ b/src/test/app/Oracle_test.cpp @@ -0,0 +1,698 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include + +namespace ripple { +namespace test { +namespace jtx { +namespace oracle { + +struct Oracle_test : public beast::unit_test::suite +{ +private: + // Helper function that returns the owner count of an account root. + static std::uint32_t + ownerCount(jtx::Env const& env, jtx::Account const& acct) + { + std::uint32_t ret{0}; + if (auto const sleAcct = env.le(acct)) + ret = sleAcct->at(sfOwnerCount); + return ret; + } + + void + testInvalidSet() + { + testcase("Invalid Set"); + + using namespace jtx; + Account const owner("owner"); + + { + // Invalid account + Env env(*this); + Account const bad("bad"); + env.memoize(bad); + Oracle oracle( + env, {.owner = bad, .seq = seq(1), .err = ter(terNO_ACCOUNT)}); + } + + // Insufficient reserve + { + Env env(*this); + env.fund(env.current()->fees().accountReserve(0), owner); + Oracle oracle( + env, {.owner = owner, .err = ter(tecINSUFFICIENT_RESERVE)}); + } + // Insufficient reserve if the data series extends to greater than 5 + { + Env env(*this); + env.fund( + env.current()->fees().accountReserve(1) + + env.current()->fees().base * 2, + owner); + Oracle oracle(env, {.owner = owner}); + BEAST_EXPECT(oracle.exists()); + oracle.set(UpdateArg{ + .series = + { + {"XRP", "EUR", 740, 1}, + {"XRP", "GBP", 740, 1}, + {"XRP", "CNY", 740, 1}, + {"XRP", "CAD", 740, 1}, + {"XRP", "AUD", 740, 1}, + }, + .err = ter(tecINSUFFICIENT_RESERVE)}); + } + + { + Env env(*this); + env.fund(XRP(1'000), owner); + Oracle oracle(env, {.owner = owner}, false); + + // Invalid flag + oracle.set( + CreateArg{.flags = tfSellNFToken, .err = ter(temINVALID_FLAG)}); + + // Duplicate token pair + oracle.set(CreateArg{ + .series = {{"XRP", "USD", 740, 1}, {"XRP", "USD", 750, 1}}, + .err = ter(temMALFORMED)}); + + // Price is not included + oracle.set(CreateArg{ + .series = + {{"XRP", "USD", 740, 1}, {"XRP", "EUR", std::nullopt, 1}}, + .err = ter(temMALFORMED)}); + + // Token pair is in update and delete + oracle.set(CreateArg{ + .series = + {{"XRP", "USD", 740, 1}, {"XRP", "USD", std::nullopt, 1}}, + .err = ter(temMALFORMED)}); + // Token pair is in add and delete + oracle.set(CreateArg{ + .series = + {{"XRP", "EUR", 740, 1}, {"XRP", "EUR", std::nullopt, 1}}, + .err = ter(temMALFORMED)}); + + // Array of token pair is 0 or exceeds 10 + oracle.set(CreateArg{ + .series = + {{"XRP", "US1", 740, 1}, + {"XRP", "US2", 750, 1}, + {"XRP", "US3", 740, 1}, + {"XRP", "US4", 750, 1}, + {"XRP", "US5", 740, 1}, + {"XRP", "US6", 750, 1}, + {"XRP", "US7", 740, 1}, + {"XRP", "US8", 750, 1}, + {"XRP", "US9", 740, 1}, + {"XRP", "U10", 750, 1}, + {"XRP", "U11", 740, 1}}, + .err = ter(temARRAY_TOO_LARGE)}); + oracle.set(CreateArg{.series = {}, .err = ter(temARRAY_EMPTY)}); + } + + // Array of token pair exceeds 10 after update + { + Env env{*this}; + env.fund(XRP(1'000), owner); + + Oracle oracle( + env, + CreateArg{ + .owner = owner, .series = {{{"XRP", "USD", 740, 1}}}}); + oracle.set(UpdateArg{ + .series = + { + {"XRP", "US1", 740, 1}, + {"XRP", "US2", 750, 1}, + {"XRP", "US3", 740, 1}, + {"XRP", "US4", 750, 1}, + {"XRP", "US5", 740, 1}, + {"XRP", "US6", 750, 1}, + {"XRP", "US7", 740, 1}, + {"XRP", "US8", 750, 1}, + {"XRP", "US9", 740, 1}, + {"XRP", "U10", 750, 1}, + }, + .err = ter(tecARRAY_TOO_LARGE)}); + } + + { + Env env(*this); + env.fund(XRP(1'000), owner); + Oracle oracle(env, {.owner = owner}, false); + + // Symbol class or provider not included on create + oracle.set(CreateArg{ + .assetClass = std::nullopt, + .provider = "provider", + .err = ter(temMALFORMED)}); + oracle.set(CreateArg{ + .assetClass = "currency", + .provider = std::nullopt, + .uri = "URI", + .err = ter(temMALFORMED)}); + + // Symbol class or provider are included on update + // and don't match the current values + oracle.set(CreateArg{}); + BEAST_EXPECT(oracle.exists()); + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .provider = "provider1", + .err = ter(temMALFORMED)}); + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .assetClass = "currency1", + .err = ter(temMALFORMED)}); + } + + { + Env env(*this); + env.fund(XRP(1'000), owner); + Oracle oracle(env, {.owner = owner}, false); + + // Fields too long + // Symbol class + std::string assetClass(17, '0'); + oracle.set( + CreateArg{.assetClass = assetClass, .err = ter(temMALFORMED)}); + // provider + std::string const large(257, '0'); + oracle.set(CreateArg{.provider = large, .err = ter(temMALFORMED)}); + // URI + oracle.set(CreateArg{.uri = large, .err = ter(temMALFORMED)}); + } + + { + // Different owner creates a new object and fails because + // of missing fields currency/provider + Env env(*this); + Account const some("some"); + env.fund(XRP(1'000), owner); + env.fund(XRP(1'000), some); + Oracle oracle(env, {.owner = owner}); + BEAST_EXPECT(oracle.exists()); + oracle.set(UpdateArg{ + .owner = some, + .series = {{"XRP", "USD", 740, 1}}, + .err = ter(temMALFORMED)}); + } + + { + // Invalid update time + using namespace std::chrono; + Env env(*this); + env.fund(XRP(1'000), owner); + Oracle oracle(env, {.owner = owner}); + BEAST_EXPECT(oracle.exists()); + env.close(seconds(400)); + // Less than the last close time - 300s + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .lastUpdateTime = testStartTime.count() + 400 - 301, + .err = ter(tecINVALID_UPDATE_TIME)}); + // Greater than last close time + 300s + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .lastUpdateTime = testStartTime.count() + 400 + 301, + .err = ter(tecINVALID_UPDATE_TIME)}); + oracle.set(UpdateArg{.series = {{"XRP", "USD", 740, 1}}}); + BEAST_EXPECT( + oracle.expectLastUpdateTime(testStartTime.count() + 450)); + // Less than the previous lastUpdateTime + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .lastUpdateTime = testStartTime.count() + 449, + .err = ter(tecINVALID_UPDATE_TIME)}); + } + + { + // delete token pair that doesn't exist + Env env(*this); + env.fund(XRP(1'000), owner); + Oracle oracle(env, {.owner = owner}); + BEAST_EXPECT(oracle.exists()); + oracle.set(UpdateArg{ + .series = {{"XRP", "EUR", std::nullopt, std::nullopt}}, + .err = ter(tecTOKEN_PAIR_NOT_FOUND)}); + // delete all token pairs + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", std::nullopt, std::nullopt}}, + .err = ter(tecARRAY_EMPTY)}); + } + + { + // same BaseAsset and QuoteAsset + Env env(*this); + env.fund(XRP(1'000), owner); + Oracle oracle( + env, + {.owner = owner, + .series = {{"USD", "USD", 740, 1}}, + .err = ter(temMALFORMED)}); + } + + { + // Scale is greater than maxPriceScale + Env env(*this); + env.fund(XRP(1'000), owner); + Oracle oracle( + env, + {.owner = owner, + .series = {{"USD", "BTC", 740, maxPriceScale + 1}}, + .err = ter(temMALFORMED)}); + } + } + + void + testCreate() + { + testcase("Create"); + using namespace jtx; + Account const owner("owner"); + + auto test = [&](Env& env, DataSeries const& series, std::uint16_t adj) { + env.fund(XRP(1'000), owner); + auto const count = ownerCount(env, owner); + Oracle oracle(env, {.owner = owner, .series = series}); + BEAST_EXPECT(oracle.exists()); + BEAST_EXPECT(ownerCount(env, owner) == (count + adj)); + BEAST_EXPECT(oracle.expectLastUpdateTime(946694810)); + }; + + { + // owner count is adjusted by 1 + Env env(*this); + test(env, {{"XRP", "USD", 740, 1}}, 1); + } + + { + // owner count is adjusted by 2 + Env env(*this); + test( + env, + {{"XRP", "USD", 740, 1}, + {"BTC", "USD", 740, 1}, + {"ETH", "USD", 740, 1}, + {"CAN", "USD", 740, 1}, + {"YAN", "USD", 740, 1}, + {"GBP", "USD", 740, 1}}, + 2); + } + + { + // Different owner creates a new object + Env env(*this); + Account const some("some"); + env.fund(XRP(1'000), owner); + env.fund(XRP(1'000), some); + Oracle oracle(env, {.owner = owner}); + BEAST_EXPECT(oracle.exists()); + oracle.set(CreateArg{ + .owner = some, .series = {{"912810RR9", "USD", 740, 1}}}); + BEAST_EXPECT(Oracle::exists(env, some, oracle.documentID())); + } + } + + void + testInvalidDelete() + { + testcase("Invalid Delete"); + + using namespace jtx; + Env env(*this); + Account const owner("owner"); + env.fund(XRP(1'000), owner); + Oracle oracle(env, {.owner = owner}); + BEAST_EXPECT(oracle.exists()); + + { + // Invalid account + Account const bad("bad"); + env.memoize(bad); + oracle.remove( + {.owner = bad, .seq = seq(1), .err = ter(terNO_ACCOUNT)}); + } + + // Invalid Sequence + oracle.remove({.documentID = 2, .err = ter(tecNO_ENTRY)}); + + // Invalid owner + Account const invalid("invalid"); + env.fund(XRP(1'000), invalid); + oracle.remove({.owner = invalid, .err = ter(tecNO_ENTRY)}); + } + + void + testDelete() + { + testcase("Delete"); + using namespace jtx; + Account const owner("owner"); + + auto test = [&](Env& env, DataSeries const& series, std::uint16_t adj) { + env.fund(XRP(1'000), owner); + Oracle oracle(env, {.owner = owner, .series = series}); + auto const count = ownerCount(env, owner); + BEAST_EXPECT(oracle.exists()); + oracle.remove({}); + BEAST_EXPECT(!oracle.exists()); + BEAST_EXPECT(ownerCount(env, owner) == (count - adj)); + }; + + { + // owner count is adjusted by 1 + Env env(*this); + test(env, {{"XRP", "USD", 740, 1}}, 1); + } + + { + // owner count is adjusted by 2 + Env env(*this); + test( + env, + { + {"XRP", "USD", 740, 1}, + {"BTC", "USD", 740, 1}, + {"ETH", "USD", 740, 1}, + {"CAN", "USD", 740, 1}, + {"YAN", "USD", 740, 1}, + {"GBP", "USD", 740, 1}, + }, + 2); + } + + { + // deleting the account deletes the oracles + Env env(*this); + auto const alice = Account("alice"); + auto const acctDelFee{drops(env.current()->fees().increment)}; + env.fund(XRP(1'000), owner); + env.fund(XRP(1'000), alice); + Oracle oracle( + env, {.owner = owner, .series = {{"XRP", "USD", 740, 1}}}); + Oracle oracle1( + env, + {.owner = owner, + .documentID = 2, + .series = {{"XRP", "EUR", 740, 1}}}); + BEAST_EXPECT(ownerCount(env, owner) == 2); + BEAST_EXPECT(oracle.exists()); + BEAST_EXPECT(oracle1.exists()); + auto const index = env.closed()->seq(); + auto const hash = env.closed()->info().hash; + for (int i = 0; i < 256; ++i) + env.close(); + env(acctdelete(owner, alice), fee(acctDelFee)); + env.close(); + BEAST_EXPECT(!oracle.exists()); + BEAST_EXPECT(!oracle1.exists()); + + // can still get the oracles via the ledger index or hash + auto verifyLedgerData = [&](auto const& field, auto const& value) { + Json::Value jvParams; + jvParams[field] = value; + jvParams[jss::binary] = false; + jvParams[jss::type] = jss::oracle; + Json::Value jrr = env.rpc( + "json", + "ledger_data", + boost::lexical_cast(jvParams)); + BEAST_EXPECT(jrr[jss::result][jss::state].size() == 2); + }; + verifyLedgerData(jss::ledger_index, index); + verifyLedgerData(jss::ledger_hash, to_string(hash)); + } + } + + void + testUpdate() + { + testcase("Update"); + using namespace jtx; + Account const owner("owner"); + + { + Env env(*this); + env.fund(XRP(1'000), owner); + auto count = ownerCount(env, owner); + Oracle oracle(env, {.owner = owner}); + BEAST_EXPECT(oracle.exists()); + + // update existing pair + oracle.set(UpdateArg{.series = {{"XRP", "USD", 740, 2}}}); + BEAST_EXPECT(oracle.expectPrice({{"XRP", "USD", 740, 2}})); + // owner count is increased by 1 since the oracle object is added + // with one token pair + count += 1; + BEAST_EXPECT(ownerCount(env, owner) == count); + + // add new pairs, not-included pair is reset + oracle.set(UpdateArg{.series = {{"XRP", "EUR", 700, 2}}}); + BEAST_EXPECT(oracle.expectPrice( + {{"XRP", "USD", 0, 0}, {"XRP", "EUR", 700, 2}})); + // owner count is not changed since the number of pairs is 2 + BEAST_EXPECT(ownerCount(env, owner) == count); + + // update both pairs + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 741, 2}, {"XRP", "EUR", 710, 2}}}); + BEAST_EXPECT(oracle.expectPrice( + {{"XRP", "USD", 741, 2}, {"XRP", "EUR", 710, 2}})); + // owner count is not changed since the number of pairs is 2 + BEAST_EXPECT(ownerCount(env, owner) == count); + + // owner count is increased by 1 since the number of pairs is 6 + oracle.set(UpdateArg{ + .series = { + {"BTC", "USD", 741, 2}, + {"ETH", "EUR", 710, 2}, + {"YAN", "EUR", 710, 2}, + {"CAN", "EUR", 710, 2}, + }}); + count += 1; + BEAST_EXPECT(ownerCount(env, owner) == count); + + // update two pairs and delete four + oracle.set(UpdateArg{ + .series = {{"BTC", "USD", std::nullopt, std::nullopt}}}); + oracle.set(UpdateArg{ + .series = { + {"XRP", "USD", 742, 2}, + {"XRP", "EUR", 711, 2}, + {"ETH", "EUR", std::nullopt, std::nullopt}, + {"YAN", "EUR", std::nullopt, std::nullopt}, + {"CAN", "EUR", std::nullopt, std::nullopt}}}); + BEAST_EXPECT(oracle.expectPrice( + {{"XRP", "USD", 742, 2}, {"XRP", "EUR", 711, 2}})); + // owner count is decreased by 1 since the number of pairs is 2 + count -= 1; + BEAST_EXPECT(ownerCount(env, owner) == count); + } + + // Min reserve to create and update + { + Env env(*this); + env.fund( + env.current()->fees().accountReserve(1) + + env.current()->fees().base * 2, + owner); + Oracle oracle(env, {.owner = owner}); + oracle.set(UpdateArg{.series = {{"XRP", "USD", 742, 2}}}); + } + } + + void + testMultisig(FeatureBitset features) + { + testcase("Multisig"); + using namespace jtx; + Oracle::setFee(100'000); + + Env env(*this, features); + Account const alice{"alice", KeyType::secp256k1}; + Account const bogie{"bogie", KeyType::secp256k1}; + Account const ed{"ed", KeyType::secp256k1}; + Account const becky{"becky", KeyType::ed25519}; + Account const zelda{"zelda", KeyType::secp256k1}; + Account const bob{"bob", KeyType::secp256k1}; + env.fund(XRP(10'000), alice, becky, zelda, ed, bob); + + // alice uses a regular key with the master disabled. + Account const alie{"alie", KeyType::secp256k1}; + env(regkey(alice, alie)); + env(fset(alice, asfDisableMaster), sig(alice)); + + // Attach signers to alice. + env(signers(alice, 2, {{becky, 1}, {bogie, 1}, {ed, 2}}), sig(alie)); + env.close(); + // if multiSignReserve disabled then its 2 + 1 per signer + int const signerListOwners{features[featureMultiSignReserve] ? 1 : 5}; + env.require(owners(alice, signerListOwners)); + + // Create + // Force close (true) and time advancement because the close time + // is no longer 0. + Oracle oracle(env, CreateArg{.owner = alice, .close = true}, false); + oracle.set(CreateArg{.msig = msig(becky), .err = ter(tefBAD_QUORUM)}); + oracle.set( + CreateArg{.msig = msig(zelda), .err = ter(tefBAD_SIGNATURE)}); + oracle.set(CreateArg{.msig = msig(becky, bogie)}); + BEAST_EXPECT(oracle.exists()); + + // Update + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .msig = msig(becky), + .err = ter(tefBAD_QUORUM)}); + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .msig = msig(zelda), + .err = ter(tefBAD_SIGNATURE)}); + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 741, 1}}, .msig = msig(becky, bogie)}); + BEAST_EXPECT(oracle.expectPrice({{"XRP", "USD", 741, 1}})); + // remove the signer list + env(signers(alice, jtx::none), sig(alie)); + env.close(); + env.require(owners(alice, 1)); + // create new signer list + env(signers(alice, 2, {{zelda, 1}, {bob, 1}, {ed, 2}}), sig(alie)); + env.close(); + // old list fails + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 740, 1}}, + .msig = msig(becky, bogie), + .err = ter(tefBAD_SIGNATURE)}); + // updated list succeeds + oracle.set(UpdateArg{ + .series = {{"XRP", "USD", 7412, 2}}, .msig = msig(zelda, bob)}); + BEAST_EXPECT(oracle.expectPrice({{"XRP", "USD", 7412, 2}})); + oracle.set( + UpdateArg{.series = {{"XRP", "USD", 74245, 3}}, .msig = msig(ed)}); + BEAST_EXPECT(oracle.expectPrice({{"XRP", "USD", 74245, 3}})); + + // Remove + oracle.remove({.msig = msig(bob), .err = ter(tefBAD_QUORUM)}); + oracle.remove({.msig = msig(becky), .err = ter(tefBAD_SIGNATURE)}); + oracle.remove({.msig = msig(ed)}); + BEAST_EXPECT(!oracle.exists()); + } + + void + testAmendment() + { + testcase("Amendment"); + using namespace jtx; + + auto const features = supported_amendments() - featurePriceOracle; + Account const owner("owner"); + Env env(*this, features); + + env.fund(XRP(1'000), owner); + { + Oracle oracle(env, {.owner = owner, .err = ter(temDISABLED)}); + } + + { + Oracle oracle(env, {.owner = owner}, false); + oracle.remove({.err = ter(temDISABLED)}); + } + } + + void + testLedgerEntry() + { + testcase("Ledger Entry"); + using namespace jtx; + + Env env(*this); + std::vector accounts; + std::vector oracles; + for (int i = 0; i < 10; ++i) + { + Account const owner(std::string("owner") + std::to_string(i)); + env.fund(XRP(1'000), owner); + // different accounts can have the same asset pair + Oracle oracle(env, {.owner = owner, .documentID = i}); + accounts.push_back(owner.id()); + oracles.push_back(oracle.documentID()); + // same account can have different asset pair + Oracle oracle1(env, {.owner = owner, .documentID = i + 10}); + accounts.push_back(owner.id()); + oracles.push_back(oracle1.documentID()); + } + for (int i = 0; i < accounts.size(); ++i) + { + auto const jv = [&]() { + // document id is uint32 + if (i % 2) + return Oracle::ledgerEntry(env, accounts[i], oracles[i]); + // document id is string + return Oracle::ledgerEntry( + env, accounts[i], std::to_string(oracles[i])); + }(); + try + { + BEAST_EXPECT( + jv[jss::node][jss::Owner] == to_string(accounts[i])); + } + catch (...) + { + fail(); + } + } + } + +public: + void + run() override + { + using namespace jtx; + auto const all = supported_amendments(); + testInvalidSet(); + testInvalidDelete(); + testCreate(); + testDelete(); + testUpdate(); + testAmendment(); + for (auto const& features : + {all, + all - featureMultiSignReserve - featureExpandedSignerList, + all - featureExpandedSignerList}) + testMultisig(features); + testLedgerEntry(); + } +}; + +BEAST_DEFINE_TESTSUITE(Oracle, app, ripple); + +} // namespace oracle + +} // namespace jtx + +} // namespace test + +} // namespace ripple diff --git a/src/test/jtx/Oracle.h b/src/test/jtx/Oracle.h new file mode 100644 index 00000000000..f6fdbbff34a --- /dev/null +++ b/src/test/jtx/Oracle.h @@ -0,0 +1,186 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2012, 2013 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_TEST_JTX_ORACLE_H_INCLUDED +#define RIPPLE_TEST_JTX_ORACLE_H_INCLUDED + +#include +#include + +namespace ripple { +namespace test { +namespace jtx { +namespace oracle { + +// base asset, quote asset, price, scale +using DataSeries = std::vector, + std::optional>>; + +// Typical defaults for Create +struct CreateArg +{ + std::optional owner = std::nullopt; + std::optional documentID = 1; + DataSeries series = {{"XRP", "USD", 740, 1}}; + std::optional assetClass = "currency"; + std::optional provider = "provider"; + std::optional uri = "URI"; + std::optional lastUpdateTime = std::nullopt; + std::uint32_t flags = 0; + std::optional msig = std::nullopt; + std::optional seq = std::nullopt; + std::uint32_t fee = 10; + std::optional err = std::nullopt; + bool close = false; +}; + +// Typical defaults for Update +struct UpdateArg +{ + std::optional owner = std::nullopt; + std::optional documentID = std::nullopt; + DataSeries series = {}; + std::optional assetClass = std::nullopt; + std::optional provider = std::nullopt; + std::optional uri = "URI"; + std::optional lastUpdateTime = std::nullopt; + std::uint32_t flags = 0; + std::optional msig = std::nullopt; + std::optional seq = std::nullopt; + std::uint32_t fee = 10; + std::optional err = std::nullopt; +}; + +struct RemoveArg +{ + std::optional const& owner = std::nullopt; + std::optional const& documentID = std::nullopt; + std::optional const& msig = std::nullopt; + std::optional seq = std::nullopt; + std::uint32_t fee = 10; + std::optional const& err = std::nullopt; +}; + +// Simulate testStartTime as 10'000s from Ripple epoch time to make +// LastUpdateTime validation to work and to make unit-test consistent. +// The value doesn't matter much, it has to be greater +// than maxLastUpdateTimeDelta in order to pass LastUpdateTime +// validation {close-maxLastUpdateTimeDelta,close+maxLastUpdateTimeDelta}. +constexpr static std::chrono::seconds testStartTime = + epoch_offset + std::chrono::seconds(10'000); + +/** Oracle class facilitates unit-testing of the Price Oracle feature. + * It defines functions to create, update, and delete the Oracle object, + * to query for various states, and to call APIs. + */ +class Oracle +{ +private: + // Global fee if not 0 + static inline std::uint32_t fee = 0; + Env& env_; + AccountID owner_; + std::uint32_t documentID_; + +private: + void + submit( + Json::Value const& jv, + std::optional const& msig, + std::optional const& seq, + std::optional const& err); + +public: + Oracle(Env& env, CreateArg const& arg, bool submit = true); + + void + remove(RemoveArg const& arg); + + void + set(CreateArg const& arg); + void + set(UpdateArg const& arg); + + static Json::Value + aggregatePrice( + Env& env, + std::optional const& baseAsset, + std::optional const& quoteAsset, + std::optional>> const& + oracles = std::nullopt, + std::optional const& trim = std::nullopt, + std::optional const& timeTreshold = std::nullopt); + + std::uint32_t + documentID() const + { + return documentID_; + } + + [[nodiscard]] bool + exists() const + { + return exists(env_, owner_, documentID_); + } + + [[nodiscard]] static bool + exists(Env& env, AccountID const& account, std::uint32_t documentID); + + [[nodiscard]] bool + expectPrice(DataSeries const& pricess) const; + + [[nodiscard]] bool + expectLastUpdateTime(std::uint32_t lastUpdateTime) const; + + static Json::Value + ledgerEntry( + Env& env, + AccountID const& account, + std::variant const& documentID, + std::optional const& index = std::nullopt); + + Json::Value + ledgerEntry(std::optional const& index = std::nullopt) const + { + return Oracle::ledgerEntry(env_, owner_, documentID_, index); + } + + static void + setFee(std::uint32_t f) + { + fee = f; + } + + friend std::ostream& + operator<<(std::ostream& strm, Oracle const& oracle) + { + strm << oracle.ledgerEntry().toStyledString(); + return strm; + } +}; + +} // namespace oracle +} // namespace jtx +} // namespace test +} // namespace ripple + +#endif // RIPPLE_TEST_JTX_ORACLE_H_INCLUDED diff --git a/src/test/jtx/impl/Oracle.cpp b/src/test/jtx/impl/Oracle.cpp new file mode 100644 index 00000000000..95da59952a0 --- /dev/null +++ b/src/test/jtx/impl/Oracle.cpp @@ -0,0 +1,292 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2012, 2013 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include + +#include + +#include + +namespace ripple { +namespace test { +namespace jtx { +namespace oracle { + +Oracle::Oracle(Env& env, CreateArg const& arg, bool submit) + : env_(env), owner_{}, documentID_{} +{ + // LastUpdateTime is checked to be in range + // {close-maxLastUpdateTimeDelta, close+maxLastUpdateTimeDelta}. + // To make the validation work and to make the clock consistent + // for tests running at different time, simulate Unix time starting + // on testStartTime since Ripple epoch. + auto const now = env_.timeKeeper().now(); + if (now.time_since_epoch().count() == 0 || arg.close) + env_.close(now + testStartTime - epoch_offset); + if (arg.owner) + owner_ = *arg.owner; + if (arg.documentID) + documentID_ = *arg.documentID; + if (submit) + set(arg); +} + +void +Oracle::remove(RemoveArg const& arg) +{ + Json::Value jv; + jv[jss::TransactionType] = jss::OracleDelete; + jv[jss::Account] = to_string(arg.owner.value_or(owner_)); + jv[jss::OracleDocumentID] = arg.documentID.value_or(documentID_); + if (Oracle::fee != 0) + jv[jss::Fee] = std::to_string(Oracle::fee); + else if (arg.fee != 0) + jv[jss::Fee] = std::to_string(arg.fee); + else + jv[jss::Fee] = std::to_string(env_.current()->fees().increment.drops()); + submit(jv, arg.msig, arg.seq, arg.err); +} + +void +Oracle::submit( + Json::Value const& jv, + std::optional const& msig, + std::optional const& seq, + std::optional const& err) +{ + if (msig) + { + if (seq && err) + env_(jv, *msig, *seq, *err); + else if (seq) + env_(jv, *msig, *seq); + else if (err) + env_(jv, *msig, *err); + else + env_(jv, *msig); + } + else if (seq && err) + env_(jv, *seq, *err); + else if (seq) + env_(jv, *seq); + else if (err) + env_(jv, *err); + else + env_(jv); + env_.close(); +} + +bool +Oracle::exists(Env& env, AccountID const& account, std::uint32_t documentID) +{ + assert(account.isNonZero()); + return env.le(keylet::oracle(account, documentID)) != nullptr; +} + +bool +Oracle::expectPrice(DataSeries const& series) const +{ + if (auto const sle = env_.le(keylet::oracle(owner_, documentID_))) + { + auto const& leSeries = sle->getFieldArray(sfPriceDataSeries); + if (leSeries.size() == 0 || leSeries.size() != series.size()) + return false; + for (auto const& data : series) + { + if (std::find_if( + leSeries.begin(), + leSeries.end(), + [&](STObject const& o) -> bool { + auto const& baseAsset = o.getFieldCurrency(sfBaseAsset); + auto const& quoteAsset = + o.getFieldCurrency(sfQuoteAsset); + auto const& price = o.getFieldU64(sfAssetPrice); + auto const& scale = o.getFieldU8(sfScale); + return baseAsset.getText() == std::get<0>(data) && + quoteAsset.getText() == std::get<1>(data) && + price == std::get<2>(data) && + scale == std::get<3>(data); + }) == leSeries.end()) + return false; + } + return true; + } + return false; +} + +bool +Oracle::expectLastUpdateTime(std::uint32_t lastUpdateTime) const +{ + auto const sle = env_.le(keylet::oracle(owner_, documentID_)); + return sle && (*sle)[sfLastUpdateTime] == lastUpdateTime; +} + +Json::Value +Oracle::aggregatePrice( + Env& env, + std::optional const& baseAsset, + std::optional const& quoteAsset, + std::optional>> const& + oracles, + std::optional const& trim, + std::optional const& timeThreshold) +{ + Json::Value jv; + Json::Value jvOracles(Json::arrayValue); + if (oracles) + { + for (auto const& id : *oracles) + { + Json::Value oracle; + oracle[jss::account] = to_string(id.first.id()); + oracle[jss::oracle_document_id] = id.second; + jvOracles.append(oracle); + } + jv[jss::oracles] = jvOracles; + } + if (trim) + jv[jss::trim] = *trim; + if (baseAsset) + jv[jss::base_asset] = *baseAsset; + if (quoteAsset) + jv[jss::quote_asset] = *quoteAsset; + if (timeThreshold) + jv[jss::time_threshold] = *timeThreshold; + + auto jr = env.rpc("json", "get_aggregate_price", to_string(jv)); + + if (jr.isObject() && jr.isMember(jss::result) && + jr[jss::result].isMember(jss::status)) + return jr[jss::result]; + return Json::nullValue; +} + +void +Oracle::set(UpdateArg const& arg) +{ + using namespace std::chrono; + Json::Value jv; + if (arg.owner) + owner_ = *arg.owner; + if (arg.documentID) + documentID_ = *arg.documentID; + jv[jss::TransactionType] = jss::OracleSet; + jv[jss::Account] = to_string(owner_); + jv[jss::OracleDocumentID] = documentID_; + if (arg.assetClass) + jv[jss::AssetClass] = strHex(*arg.assetClass); + if (arg.provider) + jv[jss::Provider] = strHex(*arg.provider); + if (arg.uri) + jv[jss::URI] = strHex(*arg.uri); + if (arg.flags != 0) + jv[jss::Flags] = arg.flags; + if (Oracle::fee != 0) + jv[jss::Fee] = std::to_string(Oracle::fee); + else if (arg.fee != 0) + jv[jss::Fee] = std::to_string(arg.fee); + else + jv[jss::Fee] = std::to_string(env_.current()->fees().increment.drops()); + // lastUpdateTime if provided is offset from testStartTime + if (arg.lastUpdateTime) + jv[jss::LastUpdateTime] = + to_string(testStartTime.count() + *arg.lastUpdateTime); + else + jv[jss::LastUpdateTime] = to_string( + duration_cast( + env_.current()->info().closeTime.time_since_epoch()) + .count() + + epoch_offset.count()); + Json::Value dataSeries(Json::arrayValue); + auto assetToStr = [](std::string const& s) { + // assume standard currency + if (s.size() == 3) + return s; + assert(s.size() <= 20); + // anything else must be 160-bit hex string + std::string h = strHex(s); + return strHex(s).append(40 - s.size() * 2, '0'); + }; + for (auto const& data : arg.series) + { + Json::Value priceData; + Json::Value price; + price[jss::BaseAsset] = assetToStr(std::get<0>(data)); + price[jss::QuoteAsset] = assetToStr(std::get<1>(data)); + if (std::get<2>(data)) + price[jss::AssetPrice] = *std::get<2>(data); + if (std::get<3>(data)) + price[jss::Scale] = *std::get<3>(data); + priceData[jss::PriceData] = price; + dataSeries.append(priceData); + } + jv[jss::PriceDataSeries] = dataSeries; + submit(jv, arg.msig, arg.seq, arg.err); +} + +void +Oracle::set(CreateArg const& arg) +{ + set(UpdateArg{ + .owner = arg.owner, + .documentID = arg.documentID, + .series = arg.series, + .assetClass = arg.assetClass, + .provider = arg.provider, + .uri = arg.uri, + .lastUpdateTime = arg.lastUpdateTime, + .flags = arg.flags, + .msig = arg.msig, + .seq = arg.seq, + .fee = arg.fee, + .err = arg.err}); +} + +Json::Value +Oracle::ledgerEntry( + Env& env, + AccountID const& account, + std::variant const& documentID, + std::optional const& index) +{ + Json::Value jvParams; + jvParams[jss::oracle][jss::account] = to_string(account); + if (std::holds_alternative(documentID)) + jvParams[jss::oracle][jss::oracle_document_id] = + std::get(documentID); + else + jvParams[jss::oracle][jss::oracle_document_id] = + std::get(documentID); + 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 oracle +} // 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 new file mode 100644 index 00000000000..076a50443e8 --- /dev/null +++ b/src/test/rpc/GetAggregatePrice_test.cpp @@ -0,0 +1,260 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include + +namespace ripple { +namespace test { +namespace jtx { +namespace oracle { + +class GetAggregatePrice_test : public beast::unit_test::suite +{ +public: + void + testErrors() + { + testcase("Errors"); + using namespace jtx; + Account const owner{"owner"}; + Account const some{"some"}; + static std::vector> oracles = { + {owner, 1}}; + + { + Env env(*this); + // missing base_asset + auto ret = + Oracle::aggregatePrice(env, std::nullopt, "USD", oracles); + BEAST_EXPECT( + ret[jss::error_message].asString() == + "Missing field 'base_asset'."); + + // missing quote_asset + ret = Oracle::aggregatePrice(env, "XRP", std::nullopt, oracles); + BEAST_EXPECT( + ret[jss::error_message].asString() == + "Missing field 'quote_asset'."); + + // 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 document id + 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 = owner, .series = {{"XRP", "EUR", 740, 1}}}); + ret = Oracle::aggregatePrice( + env, "XRP", "USD", {{{owner, oracle.documentID()}}}); + BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound"); + + // invalid trim value + ret = Oracle::aggregatePrice( + env, "XRP", "USD", {{{owner, oracle.documentID()}}}, 0); + BEAST_EXPECT(ret[jss::error].asString() == "invalidParams"); + ret = Oracle::aggregatePrice( + env, "XRP", "USD", {{{owner, oracle.documentID()}}}, 26); + BEAST_EXPECT(ret[jss::error].asString() == "invalidParams"); + } + + // 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 = owner, .documentID = i}); + oracles.emplace_back(owner, oracle.documentID()); + } + auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles); + BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed"); + } + } + + void + testRpc() + { + testcase("RPC"); + using namespace jtx; + + auto prep = [&](Env& env, auto& oracles) { + oracles.reserve(10); + for (int i = 0; i < 10; ++i) + { + Account const owner{std::to_string(i)}; + env.fund(XRP(1'000), owner); + Oracle oracle( + env, + {.owner = owner, + .documentID = rand(), + .series = { + {"XRP", "USD", 740 + i, 1}, {"XRP", "EUR", 740, 1}}}); + oracles.emplace_back(owner, oracle.documentID()); + } + }; + + // Aggregate data set includes all price oracle instances, no trimming + // or time threshold + { + Env env(*this); + std::vector> oracles; + prep(env, oracles); + // entire and trimmed stats + auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles); + BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "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::time] == 946694900); + } + + // Aggregate data set includes 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::mean] == "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_set][jss::mean] == "74.45"); + BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 6); + BEAST_EXPECT( + ret[jss::trimmed_set][jss::standard_deviation] == + "0.187082869338697"); + BEAST_EXPECT(ret[jss::time] == 946694900); + } + + // A reduced dataset, as some price oracles have data beyond three + // updated ledgers + { + Env env(*this); + std::vector> oracles; + prep(env, oracles); + for (int i = 0; i < 3; ++i) + { + Oracle oracle( + env, + {.owner = oracles[i].first, + .documentID = oracles[i].second}, + false); + // push XRP/USD by more than three ledgers, so this price + // oracle is not included in the dataset + oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}}); + oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}}); + oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}}); + } + for (int i = 3; i < 6; ++i) + { + Oracle oracle( + env, + {.owner = oracles[i].first, + .documentID = oracles[i].second}, + false); + // push XRP/USD by two ledgers, so this price + // is included in the dataset + oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}}); + oracle.set(UpdateArg{.series = {{"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::mean] == "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::mean] == "74.6"); + BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 5); + BEAST_EXPECT( + ret[jss::trimmed_set][jss::standard_deviation] == + "0.158113883008419"); + BEAST_EXPECT(ret[jss::time] == 946694900); + } + + // 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, + {.owner = oracles[i].first, + .documentID = oracles[i].second}, + false); + // push XRP/USD by two ledgers, so this price + // is included in the dataset + oracle.set(UpdateArg{.series = {{"XRP", "USD", 740, 1}}}); + } + + // entire stats only, limit lastUpdateTime to {200, 125} + auto ret = Oracle::aggregatePrice( + env, "XRP", "USD", oracles, std::nullopt, 75); + BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "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(ret[jss::time] == 946695000); + } + } + + void + run() override + { + testErrors(); + testRpc(); + } +}; + +BEAST_DEFINE_TESTSUITE(GetAggregatePrice, app, ripple); + +} // namespace oracle +} // namespace jtx +} // namespace test +} // namespace ripple