xrpld
Loading...
Searching...
No Matches
GetAggregatePrice_test.cpp
1#include <test/jtx/Account.h>
2#include <test/jtx/Env.h>
3#include <test/jtx/Oracle.h>
4#include <test/jtx/amount.h>
5
6#include <xrpld/app/ledger/OpenLedger.h>
7
8#include <xrpl/basics/Number.h>
9#include <xrpl/basics/base_uint.h>
10#include <xrpl/basics/random.h>
11#include <xrpl/beast/unit_test/suite.h>
12#include <xrpl/beast/utility/Journal.h>
13#include <xrpl/core/ServiceRegistry.h>
14#include <xrpl/ledger/OpenView.h>
15#include <xrpl/protocol/Feature.h>
16#include <xrpl/protocol/Indexes.h>
17#include <xrpl/protocol/SField.h>
18#include <xrpl/protocol/jss.h>
19
20#include <cstdint>
21#include <memory>
22#include <optional>
23#include <string>
24#include <vector>
25
27
29{
30public:
31 void
33 {
34 testcase("Errors");
35 using namespace jtx;
36 Account const owner{"owner"};
37 Account const some{"some"};
38 static OraclesData kOracles = {{owner, 1}};
39
40 {
41 Env env(*this);
42 auto const baseFee = env.current()->fees().base;
43 // missing base_asset
44 auto ret = Oracle::aggregatePrice(env, std::nullopt, "USD", kOracles);
45 BEAST_EXPECT(ret[jss::error_message].asString() == "Missing field 'base_asset'.");
46
47 // missing quote_asset
48 ret = Oracle::aggregatePrice(env, "XRP", std::nullopt, kOracles);
49 BEAST_EXPECT(ret[jss::error_message].asString() == "Missing field 'quote_asset'.");
50
51 // invalid base_asset, quote_asset
52 std::vector<AnyValue> const invalidAsset = {
54 1,
55 -1,
56 1.2,
57 "",
58 "invalid",
59 "a",
60 "ab",
61 "A",
62 "AB",
63 "ABCD",
64 "010101",
65 "012345678901234567890123456789012345678",
66 "012345678901234567890123456789012345678G"};
67 for (auto const& v : invalidAsset)
68 {
69 ret = Oracle::aggregatePrice(env, "USD", v, kOracles);
70 BEAST_EXPECT(ret[jss::error].asString() == "invalidParams");
71 ret = Oracle::aggregatePrice(env, v, "USD", kOracles);
72 BEAST_EXPECT(ret[jss::error].asString() == "invalidParams");
73 ret = Oracle::aggregatePrice(env, v, v, kOracles);
74 BEAST_EXPECT(ret[jss::error].asString() == "invalidParams");
75 }
76
77 // missing oracles array
78 ret = Oracle::aggregatePrice(env, "XRP", "USD");
79 BEAST_EXPECT(ret[jss::error_message].asString() == "Missing field 'oracles'.");
80
81 // empty oracles array
82 ret = Oracle::aggregatePrice(env, "XRP", "USD", OraclesData{});
83 BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed");
84
85 // no token pairs found
86 ret = Oracle::aggregatePrice(env, "YAN", "USD", kOracles);
87 BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound");
88
89 // invalid oracle document id
90 // id doesn't exist
91 ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{owner, 2}}});
92 BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound");
93 // invalid values
94 std::vector<AnyValue> const invalidDocument = {kNoneTag, 1.2, -1, "", "none", "1.2"};
95 for (auto const& v : invalidDocument)
96 {
97 ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{owner, v}}});
98 json::Value jv;
99 toJson(jv, v);
100 BEAST_EXPECT(ret[jss::error].asString() == "invalidParams");
101 }
102 // missing document id
103 ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{owner, std::nullopt}}});
104 BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed");
105
106 // invalid owner
107 ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{some, 1}}});
108 BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound");
109 // missing account
110 ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{std::nullopt, 1}}});
111 BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed");
112
113 // oracles have wrong asset pair
114 env.fund(XRP(1'000), owner);
115 Oracle const oracle(
116 env,
117 {.owner = owner,
118 .series = {{"XRP", "EUR", 740, 1}},
119 .fee = static_cast<int>(baseFee.drops())});
120 ret = Oracle::aggregatePrice(env, "XRP", "USD", {{{owner, oracle.documentID()}}});
121 BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound");
122
123 // invalid trim value
124 std::vector<AnyValue> const invalidTrim = {kNoneTag, 0, 26, -1, 1.2, "", "none", "1.2"};
125 for (auto const& v : invalidTrim)
126 {
127 ret =
128 Oracle::aggregatePrice(env, "XRP", "USD", {{{owner, oracle.documentID()}}}, v);
129 BEAST_EXPECT(ret[jss::error].asString() == "invalidParams");
130 }
131
132 // invalid time threshold value
133 std::vector<AnyValue> const invalidTime = {kNoneTag, -1, 1.2, "", "none", "1.2"};
134 for (auto const& v : invalidTime)
135 {
137 env, "XRP", "USD", {{{owner, oracle.documentID()}}}, std::nullopt, v);
138 BEAST_EXPECT(ret[jss::error].asString() == "invalidParams");
139 }
140 }
141
142 // too many oracles
143 {
144 Env env(*this);
145 auto const baseFee = static_cast<int>(env.current()->fees().base.drops());
146
147 OraclesData oracles;
148 for (int i = 0; i < 201; ++i)
149 {
150 Account const owner(std::to_string(i));
151 env.fund(XRP(1'000), owner);
152 Oracle const oracle(env, {.owner = owner, .documentID = i, .fee = baseFee});
153 oracles.emplace_back(owner, oracle.documentID());
154 }
155 auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
156 BEAST_EXPECT(ret[jss::error].asString() == "oracleMalformed");
157 }
158 }
159
160 void
162 {
163 testcase("RPC");
164 using namespace jtx;
165
166 auto prep = [&](Env& env, auto& oracles) {
167 oracles.reserve(10);
168 for (int i = 0; i < 10; ++i)
169 {
170 auto const baseFee = static_cast<int>(env.current()->fees().base.drops());
171
172 Account const owner{std::to_string(i)};
173 env.fund(XRP(1'000), owner);
174 Oracle const oracle(
175 env,
176 {.owner = owner,
177 .documentID = randInt<std::uint32_t>(),
178 .series = {{"XRP", "USD", 740 + i, 1}, {"XRP", "EUR", 740, 1}},
179 .fee = baseFee});
180 oracles.emplace_back(owner, oracle.documentID());
181 }
182 };
183
184 // Aggregate data set includes all price oracle instances, no trimming
185 // or time threshold
186 {
187 auto const all = testableAmendments();
188 for (auto const& feats : {all - featureSingleAssetVault - featureLendingProtocol, all})
189 {
190 for (auto const mantissaSize : MantissaRange::getAllScales())
191 {
192 // Regardless of the features enabled, RPC is controlled by
193 // the global mantissa size. And since it's a thread-local,
194 // overriding it locally won't make a difference either.
195 // This will mean all RPC will use the default of "large".
196 NumberMantissaScaleGuard const mg(mantissaSize);
197
198 Env env(*this, feats);
199 OraclesData oracles;
200 prep(env, oracles);
201 // entire and trimmed stats
202 auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
203 BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45");
204 BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 10);
205 // Short: 0.3027650354097492
206 BEAST_EXPECTS(
207 ret[jss::entire_set][jss::standard_deviation] == "0.3027650354097491666",
208 ret[jss::entire_set][jss::standard_deviation].asString());
209 BEAST_EXPECT(ret[jss::median] == "74.45");
210 BEAST_EXPECT(ret[jss::time] == 946694900);
211 }
212 }
213 }
214
215 // Aggregate data set includes all price oracle instances
216 {
217 Env env(*this);
218 OraclesData oracles;
219 prep(env, oracles);
220 // entire and trimmed stats
221 auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, 100);
222 BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.45");
223 BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 10);
224 // Short: "0.3027650354097492",
225 BEAST_EXPECTS(
226 ret[jss::entire_set][jss::standard_deviation] == "0.3027650354097491666",
227 ret[jss::entire_set][jss::standard_deviation].asString());
228 BEAST_EXPECT(ret[jss::median] == "74.45");
229 BEAST_EXPECT(ret[jss::trimmed_set][jss::mean] == "74.45");
230 BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 6);
231 // Short: "0.187082869338697",
232 BEAST_EXPECTS(
233 ret[jss::trimmed_set][jss::standard_deviation] == "0.1870828693386970693",
234 ret[jss::trimmed_set][jss::standard_deviation].asString());
235 BEAST_EXPECT(ret[jss::time] == 946694900);
236 }
237
238 // A reduced dataset, as some price oracles have data beyond three
239 // updated ledgers
240 {
241 Env env(*this);
242 auto const baseFee = static_cast<int>(env.current()->fees().base.drops());
243
244 OraclesData oracles;
245 prep(env, oracles);
246 for (int i = 0; i < 3; ++i)
247 {
249 env,
250 {.owner = oracles[i].first,
251 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
252 .documentID = asUInt(*oracles[i].second),
253 .fee = baseFee},
254 false);
255 // push XRP/USD by more than three ledgers, so this price
256 // oracle is not included in the dataset
257 oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}, .fee = baseFee});
258 oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}, .fee = baseFee});
259 oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}, .fee = baseFee});
260 }
261 for (int i = 3; i < 6; ++i)
262 {
264 env,
265 {.owner = oracles[i].first,
266 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
267 .documentID = asUInt(*oracles[i].second),
268 .fee = baseFee},
269 false);
270 // push XRP/USD by two ledgers, so this price
271 // is included in the dataset
272 oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}, .fee = baseFee});
273 oracle.set(UpdateArg{.series = {{"XRP", "EUR", 740, 1}}, .fee = baseFee});
274 }
275
276 // entire and trimmed stats
277 auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles, 20, "200");
278 BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74.6");
279 BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 7);
280 // Short: 0.2160246899469287
281 BEAST_EXPECTS(
282 ret[jss::entire_set][jss::standard_deviation] == "0.2160246899469286744",
283 ret[jss::entire_set][jss::standard_deviation].asString());
284 BEAST_EXPECT(ret[jss::median] == "74.6");
285 BEAST_EXPECT(ret[jss::trimmed_set][jss::mean] == "74.6");
286 BEAST_EXPECT(ret[jss::trimmed_set][jss::size].asUInt() == 5);
287 // Short: 0.158113883008419
288 BEAST_EXPECTS(
289 ret[jss::trimmed_set][jss::standard_deviation] == "0.1581138830084189666",
290 ret[jss::trimmed_set][jss::standard_deviation].asString());
291 BEAST_EXPECT(ret[jss::time] == 946694900);
292 }
293
294 // Reduced data set because of the time threshold
295 {
296 Env env(*this);
297 auto const baseFee = static_cast<int>(env.current()->fees().base.drops());
298
299 OraclesData oracles;
300 prep(env, oracles);
301 for (auto& oracleData : oracles)
302 {
304 env,
305 {.owner = oracleData.first,
306 // NOLINTNEXTLINE(bugprone-unchecked-optional-access)
307 .documentID = asUInt(*oracleData.second),
308 .fee = baseFee},
309 false);
310 // push XRP/USD by two ledgers, so this price
311 // is included in the dataset
312 oracle.set(UpdateArg{.series = {{"XRP", "USD", 740, 1}}, .fee = baseFee});
313 }
314
315 // entire stats only, limit lastUpdateTime to {200, 125}
316 auto ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles, std::nullopt, 75);
317 BEAST_EXPECT(ret[jss::entire_set][jss::mean] == "74");
318 BEAST_EXPECT(ret[jss::entire_set][jss::size].asUInt() == 8);
319 BEAST_EXPECT(ret[jss::entire_set][jss::standard_deviation] == "0");
320 BEAST_EXPECT(ret[jss::median] == "74");
321 BEAST_EXPECT(ret[jss::time] == 946695000);
322 }
323
324 // Duplicate oracle entries should be deduplicated.
325 // Two separate oracles with different prices give size=2.
326 // Listing the first oracle twice in the query must not
327 // inflate the size to 3.
328 {
329 Env env(*this);
330 auto const baseFee = static_cast<int>(env.current()->fees().base.drops());
331
332 Account const owner1{"owner1"};
333 Account const owner2{"owner2"};
334 env.fund(XRP(1'000), owner1);
335 env.fund(XRP(1'000), owner2);
336 Oracle const oracle1(
337 env, {.owner = owner1, .series = {{"XRP", "USD", 740, 1}}, .fee = baseFee});
338 Oracle const oracle2(
339 env, {.owner = owner2, .series = {{"XRP", "USD", 840, 1}}, .fee = baseFee});
340
341 // Query with both oracles listed once
342 OraclesData const single = {
343 {owner1, oracle1.documentID()}, {owner2, oracle2.documentID()}};
344 auto const retSingle = Oracle::aggregatePrice(env, "XRP", "USD", single);
345
346 // Query with oracle1 listed twice
347 OraclesData const duplicated = {
348 {owner1, oracle1.documentID()},
349 {owner1, oracle1.documentID()},
350 {owner2, oracle2.documentID()}};
351 auto const retDup = Oracle::aggregatePrice(env, "XRP", "USD", duplicated);
352
353 // Results should be identical - duplicates must not be
354 // double-counted
355 BEAST_EXPECT(
356 retSingle[jss::entire_set][jss::size] == retDup[jss::entire_set][jss::size]);
357 BEAST_EXPECT(retDup[jss::entire_set][jss::size].asUInt() == 2);
358 BEAST_EXPECT(
359 retSingle[jss::entire_set][jss::mean] == retDup[jss::entire_set][jss::mean]);
360 BEAST_EXPECT(
361 retSingle[jss::entire_set][jss::standard_deviation] ==
362 retDup[jss::entire_set][jss::standard_deviation]);
363 BEAST_EXPECT(retSingle[jss::median] == retDup[jss::median]);
364 }
365 }
366
367 void
369 {
370 testcase("Null txRead metadata");
371 using namespace jtx;
372
373 // Verify that iteratePriceData handles a null txRead result
374 // gracefully (returns early) rather than crashing with a
375 // nullptr dereference. This simulates local data corruption
376 // where a transaction referenced by sfPreviousTxnID is missing
377 // from the ledger's transaction map.
378 Env env(*this);
379 auto const baseFee = static_cast<int>(env.current()->fees().base.drops());
380
381 Account const owner{"owner"};
382 env.fund(XRP(1'000), owner);
383
384 // Create oracle with XRP/USD and XRP/EUR
386 env,
387 {.owner = owner,
388 .series = {{"XRP", "USD", 740, 1}, {"XRP", "EUR", 840, 1}},
389 .fee = baseFee});
390
391 // Update oracle to only have XRP/EUR, pushing XRP/USD into
392 // history. iteratePriceData will need to read historical tx
393 // metadata to find the XRP/USD price.
394 oracle.set(UpdateArg{.series = {{"XRP", "EUR", 850, 1}}, .fee = baseFee});
395
396 OraclesData const oracles{{owner, oracle.documentID()}};
397
398 // Precondition: with an uncorrupted oracle, the historical
399 // traversal must succeed and produce a price for XRP/USD.
400 // This proves the test reaches iteratePriceData's history
401 // path; without it, a future change that breaks the setup
402 // could turn the post-corruption assertion into a vacuous
403 // pass (objectNotFound is reachable from many unrelated
404 // code paths).
405 {
406 auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
407 BEAST_EXPECT(!ret.isMember(jss::error));
408 BEAST_EXPECT(ret.isMember(jss::median));
409 }
410
411 // Simulate data corruption: modify the oracle SLE in the open
412 // ledger to have a bogus sfPreviousTxnID that doesn't exist in
413 // any ledger. sfPreviousTxnLgrSeq still points to a valid closed
414 // ledger, so getLedgerBySeq succeeds but txRead returns null.
415 auto const oracleKeylet = keylet::oracle(owner, oracle.documentID());
416 uint256 const bogusTxnID{0xABCABCAB};
417 bool const modified = env.app().getOpenLedger().modify(
418 [&oracleKeylet, &bogusTxnID](OpenView& view, beast::Journal) -> bool {
419 auto const sle = view.read(oracleKeylet);
420 if (!sle)
421 return false;
422 auto replacement = std::make_shared<SLE>(*sle, sle->key());
423 replacement->setFieldH256(sfPreviousTxnID, bogusTxnID);
424 view.rawReplace(replacement);
425 return true;
426 });
427
428 // Confirm the injection actually took effect: modify must
429 // report success, and re-reading the SLE must show the
430 // bogus hash. Otherwise the failure-mode assertion below
431 // would not be exercising the null-txRead path at all.
432 BEAST_EXPECT(modified);
433 if (auto const sle = env.current()->read(oracleKeylet); BEAST_EXPECT(sle))
434 BEAST_EXPECT(sle->getFieldH256(sfPreviousTxnID) == bogusTxnID);
435
436 // Query for XRP/USD using the "current" (open) ledger.
437 // The oracle SLE now has a bogus sfPreviousTxnID. The current
438 // oracle only has EUR, so iteratePriceData will try to read
439 // history. txRead returns null for the bogus hash, and the
440 // null check should cause a graceful early return instead of
441 // a nullptr dereference.
442 auto const ret = Oracle::aggregatePrice(env, "XRP", "USD", oracles);
443 BEAST_EXPECT(ret[jss::error].asString() == "objectNotFound");
444 }
445
446 void
447 run() override
448 {
449 testErrors();
450 testRpc();
452 }
453};
454
455BEAST_DEFINE_TESTSUITE(GetAggregatePrice, rpc, xrpl);
456
457} // namespace xrpl::test::jtx::oracle
A generic endpoint for log messages.
Definition Journal.h:44
A testsuite class.
Definition suite.h:52
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:155
Represents a JSON value.
Definition json_value.h:117
Sets the new scale and restores the old scale when it leaves scope.
Definition Number.h:963
bool modify(modify_type const &f)
Modify the open ledger.
Writable ledger view that accumulates state and tx changes.
Definition OpenView.h:59
SLE::const_pointer read(Keylet const &k) const override
Return the state item associated with a key.
Definition OpenView.cpp:167
void rawReplace(SLE::ref sle) override
Unconditionally replace a state item.
Definition OpenView.cpp:243
virtual OpenLedger & getOpenLedger()=0
Immutable cryptographic account descriptor.
Definition jtx/Account.h:21
A transaction testing environment.
Definition Env.h:161
Application & app()
Definition Env.h:300
void fund(bool setDefaultRipple, STAmount const &amount, Account const &account)
Definition Env.cpp:323
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
Definition Env.h:377
Oracle class facilitates unit-testing of the Price Oracle feature.
static json::Value aggregatePrice(Env &env, std::optional< AnyValue > const &baseAsset, std::optional< AnyValue > const &quoteAsset, std::optional< OraclesData > const &oracles=std::nullopt, std::optional< AnyValue > const &trim=std::nullopt, std::optional< AnyValue > const &timeThreshold=std::nullopt)
Definition Oracle.cpp:162
T emplace_back(T... args)
T make_shared(T... args)
Keylet oracle(AccountID const &account, std::uint32_t const documentID) noexcept
Definition Indexes.cpp:531
API version numbers used in later API versions.
Definition ApiVersion.h:36
std::uint32_t asUInt(AnyValue const &v)
Definition Oracle.cpp:399
std::vector< std::pair< std::optional< Account >, std::optional< AnyValue > > > OraclesData
constexpr char const * kNoneTag
void toJson(json::Value &jv, AnyValue const &v)
Definition Oracle.cpp:369
BEAST_DEFINE_TESTSUITE(Oracle, app, xrpl)
XrpT const XRP
Converts to XRP Issue or STAmount.
Definition amount.cpp:92
FeatureBitset testableAmendments()
Definition Env.h:92
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
BaseUInt< 256 > uint256
Definition base_uint.h:580
static std::set< MantissaScale > const & getAllScales()
Definition Number.h:178
T to_string(T... args)