xrpld
Loading...
Searching...
No Matches
PermissionedDEX_test.cpp
1#include <test/jtx/AMM.h>
2#include <test/jtx/AMMTest.h>
3#include <test/jtx/Account.h>
4#include <test/jtx/Env.h>
5#include <test/jtx/TestHelpers.h>
6#include <test/jtx/amount.h>
7#include <test/jtx/balance.h>
8#include <test/jtx/credentials.h>
9#include <test/jtx/domain.h>
10#include <test/jtx/fee.h>
11#include <test/jtx/jtx_json.h>
12#include <test/jtx/ledgerStateFix.h>
13#include <test/jtx/offer.h>
14#include <test/jtx/owners.h> // IWYU pragma: keep
15#include <test/jtx/paths.h>
16#include <test/jtx/pay.h>
17#include <test/jtx/permissioned_dex.h>
18#include <test/jtx/permissioned_domains.h>
19#include <test/jtx/sendmax.h>
20#include <test/jtx/ter.h>
21#include <test/jtx/trust.h>
22#include <test/jtx/txflags.h>
23
24#include <xrpl/basics/base_uint.h>
25#include <xrpl/beast/unit_test/suite.h>
26#include <xrpl/beast/utility/Journal.h>
27#include <xrpl/ledger/OpenView.h>
28#include <xrpl/protocol/Book.h>
29#include <xrpl/protocol/Feature.h>
30#include <xrpl/protocol/Indexes.h>
31#include <xrpl/protocol/Issue.h>
32#include <xrpl/protocol/Keylet.h>
33#include <xrpl/protocol/LedgerFormats.h>
34#include <xrpl/protocol/SField.h>
35#include <xrpl/protocol/STAmount.h>
36#include <xrpl/protocol/STArray.h>
37#include <xrpl/protocol/STLedgerEntry.h>
38#include <xrpl/protocol/TER.h>
39#include <xrpl/protocol/TxFlags.h>
40#include <xrpl/protocol/jss.h>
41
42#include <chrono>
43#include <cstddef>
44#include <cstdint>
45#include <map>
46#include <memory>
47#include <optional>
48#include <string>
49#include <utility>
50#include <vector>
51
52namespace xrpl::test {
53
54using namespace jtx;
55
57{
58 [[nodiscard]] static bool
59 offerExists(Env const& env, Account const& account, std::uint32_t offerSeq)
60 {
61 return static_cast<bool>(env.le(keylet::offer(account.id(), offerSeq)));
62 }
63
64 [[nodiscard]] static bool
66 Env const& env,
67 Account const& account,
68 std::uint32_t offerSeq,
69 STAmount const& takerPays,
70 STAmount const& takerGets,
71 uint32_t const flags = 0,
72 bool const domainOffer = false)
73 {
74 auto offerInDir = [&](uint256 const& directory,
75 uint64_t const pageIndex,
76 std::optional<uint256> domain = std::nullopt) -> bool {
77 auto const page = env.le(keylet::page(directory, pageIndex));
78 if (!page)
79 return false;
80
81 if (domain != (*page)[~sfDomainID])
82 return false;
83
84 auto const& indexes = page->getFieldV256(sfIndexes);
85 for (auto const& index : indexes)
86 {
87 if (index == keylet::offer(account, offerSeq).key)
88 return true;
89 }
90
91 return false;
92 };
93
94 auto const sle = env.le(keylet::offer(account.id(), offerSeq));
95 if (!sle)
96 return false;
97 if (sle->getFieldAmount(sfTakerGets) != takerGets)
98 return false;
99 if (sle->getFieldAmount(sfTakerPays) != takerPays)
100 return false;
101 if (sle->getFlags() != flags)
102 return false;
103 if (domainOffer && !sle->isFieldPresent(sfDomainID))
104 return false;
105 if (!domainOffer && sle->isFieldPresent(sfDomainID))
106 return false;
107 if (!offerInDir(
108 sle->getFieldH256(sfBookDirectory),
109 sle->getFieldU64(sfBookNode),
110 (*sle)[~sfDomainID]))
111 return false;
112
113 if (sle->isFlag(lsfHybrid))
114 {
115 if (!sle->isFieldPresent(sfDomainID))
116 return false;
117 if (!sle->isFieldPresent(sfAdditionalBooks))
118 return false;
119 if (sle->getFieldArray(sfAdditionalBooks).size() != 1)
120 return false;
121
122 auto const& additionalBookDirs = sle->getFieldArray(sfAdditionalBooks);
123
124 for (auto const& bookDir : additionalBookDirs)
125 {
126 auto const& dirIndex = bookDir.getFieldH256(sfBookDirectory);
127 auto const& dirNode = bookDir.getFieldU64(sfBookNode);
128
129 // the directory is for the open order book, so the dir
130 // doesn't have domainID
131 if (!offerInDir(dirIndex, dirNode, std::nullopt))
132 return false;
133 }
134 }
135 else
136 {
137 if (sle->isFieldPresent(sfAdditionalBooks))
138 return false;
139 }
140
141 return true;
142 }
143
144 static uint256
145 getBookDirKey(Book const& book, STAmount const& takerPays, STAmount const& takerGets)
146 {
147 return keylet::quality(keylet::book(book), getRate(takerGets, takerPays)).key;
148 }
149
151 getDefaultOfferDirKey(Env const& env, Account const& account, std::uint32_t offerSeq)
152 {
153 if (auto const sle = env.le(keylet::offer(account.id(), offerSeq)))
154 return Keylet(ltDIR_NODE, (*sle)[sfBookDirectory]).key;
155
156 return {};
157 }
158
159 [[nodiscard]] static bool
161 {
162 std::optional<std::uint64_t> pageIndex{0};
163 std::uint32_t dirCnt = 0;
164
165 do
166 {
167 auto const page = env.le(
168 keylet::page(directory, *pageIndex)); // NOLINT(bugprone-unchecked-optional-access)
169 if (!page)
170 break;
171
172 pageIndex = (*page)[~sfIndexNext];
173 dirCnt += (*page)[sfIndexes].size();
174
175 } while (pageIndex.value_or(0) != 0u);
176
177 return dirCnt == dirSize;
178 }
179
180 void
182 {
183 testcase("OfferCreate");
184
185 // test preflight
186 {
187 Env env(*this, features - featurePermissionedDEX);
188 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
189 PermissionedDEX(env);
190
191 env(offer(bob, XRP(10), USD(10)), Domain(domainID), Ter(temDISABLED));
192 env.close();
193
194 env.enableFeature(featurePermissionedDEX);
195 env.close();
196 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
197 env.close();
198 }
199
200 // test preflight - malformed DomainID being zero
201 // Only test this with fixCleanup3_2_0 enabled. Without the fix,
202 // an assert-enabled build can crash when Ledger::read() receives
203 // a zero-key PermissionedDomain keylet.
204 if (features[fixCleanup3_2_0])
205 {
206 Env env(*this, features);
207 auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
208 PermissionedDEX(env);
209
210 env(offer(bob_, XRP(10), USD(10)), Domain(uint256{}), Ter(temMALFORMED));
211 env.close();
212 }
213
214 // preclaim - someone outside of the domain cannot create domain offer
215 {
216 Env env(*this, features);
217 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
218 PermissionedDEX(env);
219
220 // create devin account who is not part of the domain
221 Account const devin("devin");
222 env.fund(XRP(1000), devin);
223 env.close();
224 env.trust(USD(1000), devin);
225 env.close();
226 env(pay(gw, devin, USD(100)));
227 env.close();
228
229 env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(tecNO_PERMISSION));
230 env.close();
231
232 // domain owner also issues a credential for devin
233 env(credentials::create(devin, domainOwner, credType));
234 env.close();
235
236 // devin still cannot create offer since he didn't accept credential
237 env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(tecNO_PERMISSION));
238 env.close();
239
240 env(credentials::accept(devin, domainOwner, credType));
241 env.close();
242
243 env(offer(devin, XRP(10), USD(10)), Domain(domainID));
244 env.close();
245 }
246
247 // preclaim - someone with expired cred cannot create domain offer
248 {
249 Env env(*this, features);
250 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
251 PermissionedDEX(env);
252
253 // create devin account who is not part of the domain
254 Account const devin("devin");
255 env.fund(XRP(1000), devin);
256 env.close();
257 env.trust(USD(1000), devin);
258 env.close();
259 env(pay(gw, devin, USD(100)));
260 env.close();
261
262 auto jv = credentials::create(devin, domainOwner, credType);
263 uint32_t const t = env.current()->header().parentCloseTime.time_since_epoch().count();
264 jv[sfExpiration.jsonName] = t + 20;
265 env(jv);
266
267 env(credentials::accept(devin, domainOwner, credType));
268 env.close();
269
270 // devin can still create offer while his cred is not expired
271 env(offer(devin, XRP(10), USD(10)), Domain(domainID));
272 env.close();
273
274 // time advance
276
277 // devin cannot create offer with expired cred
278 env(offer(devin, XRP(10), USD(10)), Domain(domainID), Ter(tecNO_PERMISSION));
279 env.close();
280 }
281
282 // preclaim - cannot create an offer in a non existent domain
283 {
284 Env env(*this, features);
285 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
286 PermissionedDEX(env);
287 uint256 const badDomain{
288 "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134"
289 "E5"};
290
291 env(offer(bob, XRP(10), USD(10)), Domain(badDomain), Ter(tecNO_PERMISSION));
292 env.close();
293 }
294
295 // apply - offer can be created even if takergets issuer is not in
296 // domain
297 {
298 Env env(*this, features);
299 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
300 PermissionedDEX(env);
301
302 env(credentials::deleteCred(domainOwner, gw, domainOwner, credType));
303 env.close();
304
305 auto const bobOfferSeq{env.seq(bob)};
306 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
307 env.close();
308
309 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
310 }
311
312 // apply - offer can be created even if takerpays issuer is not in
313 // domain
314 {
315 Env env(*this, features);
316 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
317 PermissionedDEX(env);
318
319 env(credentials::deleteCred(domainOwner, gw, domainOwner, credType));
320 env.close();
321
322 auto const bobOfferSeq{env.seq(bob)};
323 env(offer(bob, USD(10), XRP(10)), Domain(domainID));
324 env.close();
325
326 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, USD(10), XRP(10), 0, true));
327 }
328
329 // apply - two domain offers cross with each other
330 {
331 Env env(*this, features);
332 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
333 PermissionedDEX(env);
334
335 auto const bobOfferSeq{env.seq(bob)};
336 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
337 env.close();
338
339 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
340 BEAST_EXPECT(ownerCount(env, bob) == 3);
341
342 // a non domain offer cannot cross with domain offer
343 env(offer(carol, USD(10), XRP(10)));
344 env.close();
345
346 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
347
348 auto const aliceOfferSeq{env.seq(alice)};
349 env(offer(alice, USD(10), XRP(10)), Domain(domainID));
350 env.close();
351
352 BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
353 BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
354 BEAST_EXPECT(ownerCount(env, alice) == 2);
355 }
356
357 // apply - create lots of domain offers
358 {
359 Env env(*this, features);
360 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
361 PermissionedDEX(env);
362
364 offerSeqs.reserve(100);
365
366 for (size_t i = 0; i <= 100; i++)
367 {
368 auto const bobOfferSeq{env.seq(bob)};
369 offerSeqs.emplace_back(bobOfferSeq);
370
371 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
372 env.close();
373 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
374 }
375
376 for (auto const offerSeq : offerSeqs)
377 {
378 env(offerCancel(bob, offerSeq));
379 env.close();
380 BEAST_EXPECT(!offerExists(env, bob, offerSeq));
381 }
382 }
383 }
384
385 void
387 {
388 testcase("Payment");
389
390 // test preflight - without enabling featurePermissionedDEX amendment
391 {
392 Env env(*this, features - featurePermissionedDEX);
393 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
394 PermissionedDEX(env);
395
396 env(pay(bob, alice, USD(10)),
397 Path(~USD),
398 Sendmax(XRP(10)),
399 Domain(domainID),
401 env.close();
402
403 env.enableFeature(featurePermissionedDEX);
404 env.close();
405
406 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
407 env.close();
408
409 env(pay(bob, alice, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
410 env.close();
411 }
412
413 // test preflight - malformed DomainID being zero
414 // Only test this with fixCleanup3_2_0 enabled. Without the fix,
415 // an assert-enabled build can crash when Ledger::read() receives
416 // a zero-key PermissionedDomain keylet.
417 if (features[fixCleanup3_2_0])
418 {
419 Env env(*this, features);
420 auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
421 PermissionedDEX(env);
422
423 env(pay(bob_, alice_, USD(10)),
424 Path(~USD),
425 Sendmax(XRP(10)),
426 Domain(uint256{}),
428 env.close();
429 }
430
431 // preclaim - cannot send payment with non existent domain
432 {
433 Env env(*this, features);
434 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
435 PermissionedDEX(env);
436 uint256 const badDomain{
437 "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134"
438 "E5"};
439
440 env(pay(bob, alice, USD(10)),
441 Path(~USD),
442 Sendmax(XRP(10)),
443 Domain(badDomain),
445 env.close();
446 }
447
448 // preclaim - payment with non-domain destination fails
449 {
450 Env env(*this, features);
451 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
452 PermissionedDEX(env);
453
454 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
455 env.close();
456
457 // create devin account who is not part of the domain
458 Account const devin("devin");
459 env.fund(XRP(1000), devin);
460 env.close();
461 env.trust(USD(1000), devin);
462 env.close();
463 env(pay(gw, devin, USD(100)));
464 env.close();
465
466 // devin is not part of domain
467 env(pay(alice, devin, USD(10)),
468 Path(~USD),
469 Sendmax(XRP(10)),
470 Domain(domainID),
472 env.close();
473
474 // domain owner also issues a credential for devin
475 env(credentials::create(devin, domainOwner, credType));
476 env.close();
477
478 // devin has not yet accepted cred
479 env(pay(alice, devin, USD(10)),
480 Path(~USD),
481 Sendmax(XRP(10)),
482 Domain(domainID),
484 env.close();
485
486 env(credentials::accept(devin, domainOwner, credType));
487 env.close();
488
489 // devin can now receive payment after he is in domain
490 env(pay(alice, devin, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
491 env.close();
492 }
493
494 // preclaim - non-domain sender cannot send payment
495 {
496 Env env(*this, features);
497 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
498 PermissionedDEX(env);
499
500 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
501 env.close();
502
503 // create devin account who is not part of the domain
504 Account const devin("devin");
505 env.fund(XRP(1000), devin);
506 env.close();
507 env.trust(USD(1000), devin);
508 env.close();
509 env(pay(gw, devin, USD(100)));
510 env.close();
511
512 // devin tries to send domain payment
513 env(pay(devin, alice, USD(10)),
514 Path(~USD),
515 Sendmax(XRP(10)),
516 Domain(domainID),
518 env.close();
519
520 // domain owner also issues a credential for devin
521 env(credentials::create(devin, domainOwner, credType));
522 env.close();
523
524 // devin has not yet accepted cred
525 env(pay(devin, alice, USD(10)),
526 Path(~USD),
527 Sendmax(XRP(10)),
528 Domain(domainID),
530 env.close();
531
532 env(credentials::accept(devin, domainOwner, credType));
533 env.close();
534
535 // devin can now send payment after he is in domain
536 env(pay(devin, alice, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
537 env.close();
538 }
539
540 // apply - domain owner can always send and receive domain payment
541 {
542 Env env(*this, features);
543 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
544 PermissionedDEX(env);
545
546 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
547 env.close();
548
549 // domain owner can always be destination
550 env(pay(alice, domainOwner, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
551 env.close();
552
553 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
554 env.close();
555
556 // domain owner can send
557 env(pay(domainOwner, alice, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
558 env.close();
559 }
560 }
561
562 void
564 {
565 testcase("Book step");
566
567 // test domain cross currency payment consuming one offer
568 {
569 Env env(*this, features);
570 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
571 PermissionedDEX(env);
572
573 // create a regular offer without domain
574 auto const regularOfferSeq{env.seq(bob)};
575 env(offer(bob, XRP(10), USD(10)));
576 env.close();
577 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
578
579 auto const regularDirKey = getDefaultOfferDirKey(env, bob, regularOfferSeq);
580 BEAST_EXPECT(regularDirKey);
581 BEAST_EXPECT(checkDirectorySize(
582 env, *regularDirKey, 1)); // NOLINT(bugprone-unchecked-optional-access)
583
584 // a domain payment cannot consume regular offers
585 env(pay(alice, carol, USD(10)),
586 Path(~USD),
587 Sendmax(XRP(10)),
588 Domain(domainID),
590 env.close();
591
592 // create a domain offer
593 auto const domainOfferSeq{env.seq(bob)};
594 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
595 env.close();
596
597 BEAST_EXPECT(checkOffer(env, bob, domainOfferSeq, XRP(10), USD(10), 0, true));
598
599 auto const domainDirKey = getDefaultOfferDirKey(env, bob, domainOfferSeq);
600 BEAST_EXPECT(domainDirKey);
601 BEAST_EXPECT(checkDirectorySize(
602 env, *domainDirKey, 1)); // NOLINT(bugprone-unchecked-optional-access)
603
604 // cross-currency permissioned payment consumed
605 // domain offer instead of regular offer
606 env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
607 env.close();
608 BEAST_EXPECT(!offerExists(env, bob, domainOfferSeq));
609 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
610
611 // domain directory is empty
612 BEAST_EXPECT(checkDirectorySize(
613 env, *domainDirKey, 0)); // NOLINT(bugprone-unchecked-optional-access)
614 BEAST_EXPECT(checkDirectorySize(
615 env, *regularDirKey, 1)); // NOLINT(bugprone-unchecked-optional-access)
616 }
617
618 // test domain payment consuming two offers in the path
619 {
620 Env env(*this, features);
621 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
622 PermissionedDEX(env);
623
624 auto const eur = gw["EUR"];
625 env.trust(eur(1000), alice);
626 env.close();
627 env.trust(eur(1000), bob);
628 env.close();
629 env.trust(eur(1000), carol);
630 env.close();
631 env(pay(gw, bob, eur(100)));
632 env.close();
633
634 // create XRP/USD domain offer
635 auto const usdOfferSeq{env.seq(bob)};
636 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
637 env.close();
638
639 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
640
641 // payment fail because there isn't eur offer
642 env(pay(alice, carol, eur(10)),
643 Path(~USD, ~eur),
644 Sendmax(XRP(10)),
645 Domain(domainID),
647 env.close();
648 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
649
650 // bob creates a regular USD/EUR offer
651 auto const regularOfferSeq{env.seq(bob)};
652 env(offer(bob, USD(10), eur(10)));
653 env.close();
654 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, USD(10), eur(10)));
655
656 // alice tries to pay again, but still fails because the regular
657 // offer cannot be consumed
658 env(pay(alice, carol, eur(10)),
659 Path(~USD, ~eur),
660 Sendmax(XRP(10)),
661 Domain(domainID),
663 env.close();
664
665 // bob creates a domain USD/EUR offer
666 auto const eurOfferSeq{env.seq(bob)};
667 env(offer(bob, USD(10), eur(10)), Domain(domainID));
668 env.close();
669 BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(10), eur(10), 0, true));
670
671 // alice successfully consume two domain offers: xrp/usd and usd/eur
672 env(pay(alice, carol, eur(5)), Sendmax(XRP(5)), Domain(domainID), Path(~USD, ~eur));
673 env.close();
674
675 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, true));
676 BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(5), eur(5), 0, true));
677
678 // alice successfully consume two domain offers and deletes them
679 // we compute path this time using `paths`
680 env(pay(alice, carol, eur(5)), Sendmax(XRP(5)), Domain(domainID), Paths(XRP));
681 env.close();
682
683 BEAST_EXPECT(!offerExists(env, bob, usdOfferSeq));
684 BEAST_EXPECT(!offerExists(env, bob, eurOfferSeq));
685
686 // regular offer is not consumed
687 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, USD(10), eur(10)));
688 }
689
690 // domain payment cannot consume offer from another domain
691 {
692 Env env(*this, features);
693 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
694 PermissionedDEX(env);
695
696 // Fund devin and create USD trustline
697 Account const badDomainOwner("badDomainOwner");
698 Account const devin("devin");
699 env.fund(XRP(1000), badDomainOwner, devin);
700 env.close();
701 env.trust(USD(1000), devin);
702 env.close();
703 env(pay(gw, devin, USD(100)));
704 env.close();
705
706 auto const badCredType = "badCred";
708 {.issuer = badDomainOwner, .credType = badCredType}};
709 env(pdomain::setTx(badDomainOwner, credentials));
710
711 auto objects = pdomain::getObjects(badDomainOwner, env);
712 auto const badDomainID = objects.begin()->first;
713
714 env(credentials::create(devin, badDomainOwner, badCredType));
715 env.close();
716 env(credentials::accept(devin, badDomainOwner, badCredType));
717
718 // devin creates a domain offer in another domain
719 env(offer(devin, XRP(10), USD(10)), Domain(badDomainID));
720 env.close();
721
722 // domain payment can't consume an offer from another domain
723 env(pay(alice, carol, USD(10)),
724 Path(~USD),
725 Sendmax(XRP(10)),
726 Domain(domainID),
728 env.close();
729
730 // bob creates an offer under the right domain
731 auto const bobOfferSeq{env.seq(bob)};
732 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
733 env.close();
734 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
735
736 // domain payment now consumes from the right domain
737 env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
738 env.close();
739
740 BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
741 }
742
743 // sanity check: devin, who is part of the domain but doesn't have a
744 // trustline with USD issuer, can successfully make a payment using
745 // offer
746 {
747 Env env(*this, features);
748 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
749 PermissionedDEX(env);
750
751 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
752 env.close();
753
754 // fund devin but don't create a USD trustline with gateway
755 Account const devin("devin");
756 env.fund(XRP(1000), devin);
757 env.close();
758
759 // domain owner also issues a credential for devin
760 env(credentials::create(devin, domainOwner, credType));
761 env.close();
762
763 env(credentials::accept(devin, domainOwner, credType));
764 env.close();
765
766 // successful payment because offer is consumed
767 env(pay(devin, alice, USD(10)), Sendmax(XRP(10)), Domain(domainID));
768 env.close();
769 }
770
771 // offer becomes unfunded when offer owner's cred expires
772 {
773 Env env(*this, features);
774 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
775 PermissionedDEX(env);
776
777 // create devin account who is not part of the domain
778 Account const devin("devin");
779 env.fund(XRP(1000), devin);
780 env.close();
781 env.trust(USD(1000), devin);
782 env.close();
783 env(pay(gw, devin, USD(100)));
784 env.close();
785
786 auto jv = credentials::create(devin, domainOwner, credType);
787 uint32_t const t = env.current()->header().parentCloseTime.time_since_epoch().count();
788 jv[sfExpiration.jsonName] = t + 20;
789 env(jv);
790
791 env(credentials::accept(devin, domainOwner, credType));
792 env.close();
793
794 // devin can still create offer while his cred is not expired
795 auto const offerSeq{env.seq(devin)};
796 env(offer(devin, XRP(10), USD(10)), Domain(domainID));
797 env.close();
798
799 // devin's offer can still be consumed while his cred isn't expired
800 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
801 env.close();
802 BEAST_EXPECT(checkOffer(env, devin, offerSeq, XRP(5), USD(5), 0, true));
803
804 // advance time
806
807 // devin's offer is unfunded now due to expired cred
808 env(pay(alice, carol, USD(5)),
809 Path(~USD),
810 Sendmax(XRP(5)),
811 Domain(domainID),
813 env.close();
814 BEAST_EXPECT(checkOffer(env, devin, offerSeq, XRP(5), USD(5), 0, true));
815 }
816
817 // offer becomes unfunded when offer owner's cred is removed
818 {
819 Env env(*this, features);
820 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
821 PermissionedDEX(env);
822
823 auto const offerSeq{env.seq(bob)};
824 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
825 env.close();
826
827 // bob's offer can still be consumed while his cred exists
828 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
829 env.close();
830 BEAST_EXPECT(checkOffer(env, bob, offerSeq, XRP(5), USD(5), 0, true));
831
832 // remove bob's cred
833 env(credentials::deleteCred(domainOwner, bob, domainOwner, credType));
834 env.close();
835
836 // bob's offer is unfunded now due to expired cred
837 env(pay(alice, carol, USD(5)),
838 Path(~USD),
839 Sendmax(XRP(5)),
840 Domain(domainID),
842 env.close();
843 BEAST_EXPECT(checkOffer(env, bob, offerSeq, XRP(5), USD(5), 0, true));
844 }
845 }
846
847 void
849 {
850 testcase("Rippling");
851
852 // test a non-domain account can still be part of rippling in a domain
853 // payment. If the domain wishes to control who is allowed to ripple
854 // through, they should set the rippling individually
855 Env env(*this, features);
856 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
857 PermissionedDEX(env);
858
859 auto const eura = alice["EUR"];
860 auto const eurb = bob["EUR"];
861
862 env.trust(eura(100), bob);
863 env.trust(eurb(100), carol);
864 env.close();
865
866 // remove bob from domain
867 env(credentials::deleteCred(domainOwner, bob, domainOwner, credType));
868 env.close();
869
870 // alice can still ripple through bob even though he's not part
871 // of the domain, this is intentional
872 env(pay(alice, carol, eurb(10)), Paths(eura), Domain(domainID));
873 env.close();
874 env.require(Balance(bob, eura(10)), Balance(carol, eurb(10)));
875
876 // carol sets no ripple on bob
877 env(trust(carol, bob["EUR"](0), bob, tfSetNoRipple));
878 env.close();
879
880 // payment no longer works because carol has no ripple on bob
881 env(pay(alice, carol, eurb(5)), Paths(eura), Domain(domainID), Ter(tecPATH_DRY));
882 env.close();
883 env.require(Balance(bob, eura(10)), Balance(carol, eurb(10)));
884 }
885
886 void
888 {
889 testcase("Offer token issuer in domain");
890
891 // whether the issuer is in the domain should NOT affect whether an
892 // offer can be consumed in domain payment
893 Env env(*this, features);
894 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
895 PermissionedDEX(env);
896
897 // create an xrp/usd offer with usd as takergets
898 auto const bobOffer1Seq{env.seq(bob)};
899 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
900 env.close();
901
902 // create an usd/xrp offer with usd as takerpays
903 auto const bobOffer2Seq{env.seq(bob)};
904 env(offer(bob, USD(10), XRP(10)), Domain(domainID), Txflags(tfPassive));
905 env.close();
906
907 BEAST_EXPECT(checkOffer(env, bob, bobOffer1Seq, XRP(10), USD(10), 0, true));
908 BEAST_EXPECT(checkOffer(env, bob, bobOffer2Seq, USD(10), XRP(10), lsfPassive, true));
909
910 // remove gateway from domain
911 env(credentials::deleteCred(domainOwner, gw, domainOwner, credType));
912 env.close();
913
914 // payment succeeds even if issuer is not in domain
915 // xrp/usd offer is consumed
916 env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
917 env.close();
918 BEAST_EXPECT(!offerExists(env, bob, bobOffer1Seq));
919
920 // payment succeeds even if issuer is not in domain
921 // usd/xrp offer is consumed
922 env(pay(alice, carol, XRP(10)), Path(~XRP), Sendmax(USD(10)), Domain(domainID));
923 env.close();
924 BEAST_EXPECT(!offerExists(env, bob, bobOffer2Seq));
925 }
926
927 void
929 {
930 testcase("Remove unfunded offer");
931
932 // checking that an unfunded offer will be implicitly removed by a
933 // successful payment tx
934 Env env(*this, features);
935 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
936 PermissionedDEX(env);
937
938 auto const aliceOfferSeq{env.seq(alice)};
939 env(offer(alice, XRP(100), USD(100)), Domain(domainID));
940 env.close();
941
942 auto const bobOfferSeq{env.seq(bob)};
943 env(offer(bob, XRP(20), USD(20)), Domain(domainID));
944 env.close();
945
946 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(20), USD(20), 0, true));
947 BEAST_EXPECT(checkOffer(env, alice, aliceOfferSeq, XRP(100), USD(100), 0, true));
948
949 auto const domainDirKey = getDefaultOfferDirKey(env, bob, bobOfferSeq);
950 BEAST_EXPECT(domainDirKey);
951 BEAST_EXPECT(checkDirectorySize(
952 env, *domainDirKey, 2)); // NOLINT(bugprone-unchecked-optional-access)
953
954 // remove alice from domain and thus alice's offer becomes unfunded
955 env(credentials::deleteCred(domainOwner, alice, domainOwner, credType));
956 env.close();
957
958 env(pay(gw, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
959 env.close();
960
961 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
962
963 // alice's unfunded offer is removed implicitly
964 BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
965 BEAST_EXPECT(checkDirectorySize(
966 env, *domainDirKey, 1)); // NOLINT(bugprone-unchecked-optional-access)
967 }
968
969 void
971 {
972 testcase("AMM not used");
973
974 Env env(*this, features);
975 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
976 PermissionedDEX(env);
977 AMM const amm(env, alice, XRP(10), USD(50));
978
979 // a domain payment isn't able to consume AMM
980 env(pay(bob, carol, USD(5)),
981 Path(~USD),
982 Sendmax(XRP(5)),
983 Domain(domainID),
985 env.close();
986
987 // a non domain payment can use AMM
988 env(pay(bob, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
989 env.close();
990
991 // USD amount in AMM is changed
992 auto [xrp, usd, lpt] = amm.balances(XRP, USD);
993 BEAST_EXPECT(usd == USD(45));
994 }
995
996 void
998 {
999 bool const excludesAmmFromDomainQuality = features[fixCleanup3_3_0];
1000
1001 testcase << "AMM quality not leaked into domain BookStep"
1002 << (excludesAmmFromDomainQuality ? " (Cleanup3_3_0 enabled)"
1003 : " (Cleanup3_3_0 disabled)");
1004
1005 Env env(*this, features);
1006 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1007 PermissionedDEX(env);
1008 auto const eur = gw["EUR"];
1009
1010 env.trust(eur(1000), bob, domainOwner);
1011 env.close();
1012 env(pay(gw, bob, eur(100)));
1013 env.close();
1014
1015 env(pay(gw, alice, USD(500)));
1016 env.close();
1017
1018 // The AMM makes the direct XRP->USD book look much better than it
1019 // really is for domain payments. The domain LOB direct path is 1:1,
1020 // while the competing XRP->EUR->USD path is 2:1.
1021 AMM const amm(env, alice, XRP(10), USD(500));
1022
1023 auto const directOfferSeq{env.seq(bob)};
1024 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
1025 env.close();
1026
1027 auto const xrpEurOfferSeq{env.seq(bob)};
1028 env(offer(bob, XRP(10), eur(20)), Domain(domainID));
1029 env.close();
1030
1031 auto const eurUsdOfferSeq{env.seq(domainOwner)};
1032 env(offer(domainOwner, eur(20), USD(20)), Domain(domainID));
1033 env.close();
1034
1035 auto const carolBalBefore = env.balance(carol, USD);
1036
1037 // Both paths compete for the same XRP(10) sendmax. If AMM quality leaks
1038 // into the direct domain book, the engine ranks direct XRP->USD first
1039 // but crossing can only consume the 1:1 LOB offer. With the fix, the
1040 // direct book is ranked by its domain LOB quality, so the 2:1
1041 // XRP->EUR->USD path executes first.
1042 env(pay(alice, carol, USD(100)),
1043 Path(~USD),
1044 Path(~eur, ~USD),
1045 Sendmax(XRP(10)),
1046 Txflags(tfPartialPayment | tfNoRippleDirect),
1047 Domain(domainID));
1048 env.close();
1049
1050 auto const delivered = env.balance(carol, USD) - carolBalBefore;
1051 if (excludesAmmFromDomainQuality)
1052 {
1053 BEAST_EXPECT(delivered == USD(20));
1054
1055 BEAST_EXPECT(checkOffer(env, bob, directOfferSeq, XRP(10), USD(10), 0, true));
1056 BEAST_EXPECT(!offerExists(env, bob, xrpEurOfferSeq));
1057 BEAST_EXPECT(!offerExists(env, domainOwner, eurUsdOfferSeq));
1058 }
1059 else
1060 {
1061 BEAST_EXPECT(delivered == USD(10));
1062
1063 BEAST_EXPECT(!offerExists(env, bob, directOfferSeq));
1064 BEAST_EXPECT(checkOffer(env, bob, xrpEurOfferSeq, XRP(10), eur(20), 0, true));
1065 BEAST_EXPECT(checkOffer(env, domainOwner, eurUsdOfferSeq, eur(20), USD(20), 0, true));
1066 }
1067
1068 auto [xrp, usd, lpt] = amm.balances(XRP, USD);
1069 BEAST_EXPECT(xrp == XRP(10));
1070 BEAST_EXPECT(usd == USD(500));
1071 }
1072
1073 void
1075 {
1076 testcase("Hybrid offer create");
1077
1078 // test preflight - invalid hybrid flag
1079 {
1080 Env env(*this, features - featurePermissionedDEX);
1081 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1082 PermissionedDEX(env);
1083
1084 env(offer(bob, XRP(10), USD(10)),
1085 Domain(domainID),
1086 Txflags(tfHybrid),
1087 Ter(temDISABLED));
1088 env.close();
1089
1090 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Ter(temINVALID_FLAG));
1091 env.close();
1092
1093 env.enableFeature(featurePermissionedDEX);
1094 env.close();
1095
1096 // hybrid offer must have domainID
1097 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Ter(temINVALID_FLAG));
1098 env.close();
1099
1100 // hybrid offer must have domainID
1101 auto const offerSeq{env.seq(bob)};
1102 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1103 env.close();
1104 BEAST_EXPECT(checkOffer(env, bob, offerSeq, XRP(10), USD(10), lsfHybrid, true));
1105 }
1106
1107 // apply - domain offer can cross with hybrid
1108 {
1109 Env env(*this, features);
1110 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1111 PermissionedDEX(env);
1112
1113 auto const bobOfferSeq{env.seq(bob)};
1114 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1115 env.close();
1116
1117 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
1118 BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
1119 BEAST_EXPECT(ownerCount(env, bob) == 3);
1120
1121 auto const aliceOfferSeq{env.seq(alice)};
1122 env(offer(alice, USD(10), XRP(10)), Domain(domainID));
1123 env.close();
1124
1125 BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
1126 BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
1127 BEAST_EXPECT(ownerCount(env, alice) == 2);
1128 }
1129
1130 // apply - open offer can cross with hybrid
1131 {
1132 Env env(*this, features);
1133 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1134 PermissionedDEX(env);
1135
1136 auto const bobOfferSeq{env.seq(bob)};
1137 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1138 env.close();
1139
1140 BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
1141 BEAST_EXPECT(ownerCount(env, bob) == 3);
1142 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
1143
1144 auto const aliceOfferSeq{env.seq(alice)};
1145 env(offer(alice, USD(10), XRP(10)));
1146 env.close();
1147
1148 BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
1149 BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
1150 BEAST_EXPECT(ownerCount(env, alice) == 2);
1151 }
1152
1153 // apply - by default, hybrid offer tries to cross with offers in the
1154 // domain book
1155 {
1156 Env env(*this, features);
1157 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1158 PermissionedDEX(env);
1159
1160 auto const bobOfferSeq{env.seq(bob)};
1161 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
1162 env.close();
1163
1164 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
1165 BEAST_EXPECT(ownerCount(env, bob) == 3);
1166
1167 // hybrid offer auto crosses with domain offer
1168 auto const aliceOfferSeq{env.seq(alice)};
1169 env(offer(alice, USD(10), XRP(10)), Domain(domainID), Txflags(tfHybrid));
1170 env.close();
1171
1172 BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq));
1173 BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
1174 BEAST_EXPECT(ownerCount(env, alice) == 2);
1175 }
1176
1177 // apply - hybrid offer does not automatically cross with open offers
1178 // because by default, it only tries to cross domain offers
1179 {
1180 Env env(*this, features);
1181 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1182 PermissionedDEX(env);
1183
1184 auto const bobOfferSeq{env.seq(bob)};
1185 env(offer(bob, XRP(10), USD(10)));
1186 env.close();
1187
1188 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, false));
1189 BEAST_EXPECT(ownerCount(env, bob) == 3);
1190
1191 // hybrid offer auto crosses with domain offer
1192 auto const aliceOfferSeq{env.seq(alice)};
1193 env(offer(alice, USD(10), XRP(10)), Domain(domainID), Txflags(tfHybrid));
1194 env.close();
1195
1196 BEAST_EXPECT(offerExists(env, alice, aliceOfferSeq));
1197 BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
1198 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, false));
1199 BEAST_EXPECT(checkOffer(env, alice, aliceOfferSeq, USD(10), XRP(10), lsfHybrid, true));
1200 BEAST_EXPECT(ownerCount(env, alice) == 3);
1201 }
1202 }
1203
1204 void
1206 {
1207 testcase("Hybrid invalid offer");
1208
1209 // bob has a hybrid offer and then he is removed from the domain.
1210 // Domain payments must not consume the offer; regular open-book
1211 // payments follow the fixCleanup3_3_0 behavior checked below.
1212 Env env(*this, features);
1213 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1214 PermissionedDEX(env);
1215
1216 auto const hybridOfferSeq{env.seq(bob)};
1217 env(offer(bob, XRP(50), USD(50)), Txflags(tfHybrid), Domain(domainID));
1218 env.close();
1219
1220 // remove bob from domain
1221 env(credentials::deleteCred(domainOwner, bob, domainOwner, credType));
1222 env.close();
1223
1224 // bob's hybrid offer is unfunded and can not be consumed in a domain
1225 // payment
1226 env(pay(alice, carol, USD(5)),
1227 Path(~USD),
1228 Sendmax(XRP(5)),
1229 Domain(domainID),
1231 env.close();
1232 BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true));
1233
1234 if (features[fixCleanup3_3_0])
1235 {
1236 // Post-fixCleanup3_3_0: hybrid offer can still be consumed via a regular
1237 // open-book payment even though the domain credential was revoked.
1238 auto const carolBalBefore = env.balance(carol, USD);
1239 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
1240 env.close();
1241 BEAST_EXPECT(env.balance(carol, USD) - carolBalBefore == USD(5));
1242 BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(45), USD(45), lsfHybrid, true));
1243
1244 // create a regular offer alongside the hybrid one
1245 auto const regularOfferSeq{env.seq(bob)};
1246 env(offer(bob, XRP(10), USD(10)));
1247 env.close();
1248 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
1249
1250 auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq));
1251 if (!BEAST_EXPECT(sleHybridOffer))
1252 return;
1253 auto const openDir =
1254 sleHybridOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
1255 // both offers are in the open book directory
1256 BEAST_EXPECT(checkDirectorySize(env, openDir, 2));
1257
1258 // A regular payment crosses the hybrid offer first (FIFO, older
1259 // offer), then stops; the regular offer is untouched.
1260 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
1261 env.close();
1262
1263 BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(40), USD(40), lsfHybrid, true));
1264 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
1265 BEAST_EXPECT(checkDirectorySize(env, openDir, 2));
1266 }
1267 else
1268 {
1269 // Pre-fixCleanup3_3_0: the open-book traversal
1270 // also runs the offerInDomain eviction check, so the hybrid offer
1271 // is treated as unfunded and the regular payment fails.
1272 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Ter(tecPATH_PARTIAL));
1273 env.close();
1274 BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true));
1275
1276 // create a regular offer
1277 auto const regularOfferSeq{env.seq(bob)};
1278 env(offer(bob, XRP(10), USD(10)));
1279 env.close();
1280 BEAST_EXPECT(offerExists(env, bob, regularOfferSeq));
1281 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10)));
1282
1283 auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq));
1284 if (!BEAST_EXPECT(sleHybridOffer))
1285 return;
1286 auto const openDir =
1287 sleHybridOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
1288 BEAST_EXPECT(checkDirectorySize(env, openDir, 2));
1289
1290 // This payment crosses the regular offer and permanently evicts the
1291 // hybrid offer from the open book (since the payment succeeds, the
1292 // sandbox, including the hybrid eviction, is committed).
1293 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
1294 env.close();
1295
1296 BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq));
1297 BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(5), USD(5)));
1298 BEAST_EXPECT(checkDirectorySize(env, openDir, 1));
1299 }
1300 }
1301
1302 void
1304 {
1305 testcase("Hybrid book step");
1306
1307 // both non domain and domain payments can consume hybrid offer
1308 {
1309 Env env(*this, features);
1310 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1311 PermissionedDEX(env);
1312
1313 auto const hybridOfferSeq{env.seq(bob)};
1314 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1315 env.close();
1316
1317 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
1318 env.close();
1319 BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
1320
1321 // hybrid offer can't be consumed since bob is not in domain anymore
1322 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
1323 env.close();
1324
1325 BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq));
1326 }
1327
1328 // someone from another domain can't cross hybrid if they specified
1329 // wrong domainID
1330 {
1331 Env env(*this, features);
1332 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1333 PermissionedDEX(env);
1334
1335 // Fund accounts
1336 Account const badDomainOwner("badDomainOwner");
1337 Account const devin("devin");
1338 env.fund(XRP(1000), badDomainOwner, devin);
1339 env.close();
1340
1341 auto const badCredType = "badCred";
1343 {.issuer = badDomainOwner, .credType = badCredType}};
1344 env(pdomain::setTx(badDomainOwner, credentials));
1345
1346 auto objects = pdomain::getObjects(badDomainOwner, env);
1347 auto const badDomainID = objects.begin()->first;
1348
1349 env(credentials::create(devin, badDomainOwner, badCredType));
1350 env.close();
1351 env(credentials::accept(devin, badDomainOwner, badCredType));
1352 env.close();
1353
1354 auto const hybridOfferSeq{env.seq(bob)};
1355 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1356 env.close();
1357
1358 // other domains can't consume the offer
1359 env(pay(devin, badDomainOwner, USD(5)),
1360 Path(~USD),
1361 Sendmax(XRP(5)),
1362 Domain(badDomainID),
1363 Ter(tecPATH_DRY));
1364 env.close();
1365 BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(10), USD(10), lsfHybrid, true));
1366
1367 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)), Domain(domainID));
1368 env.close();
1369 BEAST_EXPECT(checkOffer(env, bob, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
1370
1371 // hybrid offer can't be consumed since bob is not in domain anymore
1372 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
1373 env.close();
1374
1375 BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq));
1376 }
1377
1378 // test domain payment consuming two offers w/ hybrid offer
1379 {
1380 Env env(*this, features);
1381 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1382 PermissionedDEX(env);
1383
1384 auto const eur = gw["EUR"];
1385 env.trust(eur(1000), alice);
1386 env.close();
1387 env.trust(eur(1000), bob);
1388 env.close();
1389 env.trust(eur(1000), carol);
1390 env.close();
1391 env(pay(gw, bob, eur(100)));
1392 env.close();
1393
1394 auto const usdOfferSeq{env.seq(bob)};
1395 env(offer(bob, XRP(10), USD(10)), Domain(domainID));
1396 env.close();
1397
1398 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
1399
1400 // payment fail because there isn't eur offer
1401 env(pay(alice, carol, eur(5)),
1402 Path(~USD, ~eur),
1403 Sendmax(XRP(5)),
1404 Domain(domainID),
1406 env.close();
1407 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true));
1408
1409 // bob creates a hybrid eur offer
1410 auto const eurOfferSeq{env.seq(bob)};
1411 env(offer(bob, USD(10), eur(10)), Domain(domainID), Txflags(tfHybrid));
1412 env.close();
1413 BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(10), eur(10), lsfHybrid, true));
1414
1415 // alice successfully consume two domain offers: xrp/usd and usd/eur
1416 env(pay(alice, carol, eur(5)), Path(~USD, ~eur), Sendmax(XRP(5)), Domain(domainID));
1417 env.close();
1418
1419 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, true));
1420 BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(5), eur(5), lsfHybrid, true));
1421 }
1422
1423 // test regular payment using a regular offer and a hybrid offer
1424 {
1425 Env env(*this, features);
1426 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1427 PermissionedDEX(env);
1428
1429 auto const eur = gw["EUR"];
1430 env.trust(eur(1000), alice);
1431 env.close();
1432 env.trust(eur(1000), bob);
1433 env.close();
1434 env.trust(eur(1000), carol);
1435 env.close();
1436 env(pay(gw, bob, eur(100)));
1437 env.close();
1438
1439 // bob creates a regular usd offer
1440 auto const usdOfferSeq{env.seq(bob)};
1441 env(offer(bob, XRP(10), USD(10)));
1442 env.close();
1443
1444 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, false));
1445
1446 // bob creates a hybrid eur offer
1447 auto const eurOfferSeq{env.seq(bob)};
1448 env(offer(bob, USD(10), eur(10)), Domain(domainID), Txflags(tfHybrid));
1449 env.close();
1450 BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(10), eur(10), lsfHybrid, true));
1451
1452 // alice successfully consume two offers: xrp/usd and usd/eur
1453 env(pay(alice, carol, eur(5)), Path(~USD, ~eur), Sendmax(XRP(5)));
1454 env.close();
1455
1456 BEAST_EXPECT(checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, false));
1457 BEAST_EXPECT(checkOffer(env, bob, eurOfferSeq, USD(5), eur(5), lsfHybrid, true));
1458 }
1459 }
1460
1461 // Test that a hybrid offer remains crossable in the open book after the
1462 // owner's domain credential expires. A domain payment after expiry should
1463 // fail (domain book evicts the offer in its sandbox), but the open book
1464 // remains usable.
1465 void
1467 {
1468 testcase("Hybrid open book after credential expiry");
1469
1470 Env env(*this, features);
1471 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1472 PermissionedDEX(env);
1473
1474 Account const devin("devin");
1475 env.fund(XRP(100000), devin);
1476 env.close();
1477 env.trust(USD(1000), devin);
1478 env.close();
1479 env(pay(gw, devin, USD(100)));
1480 env.close();
1481
1482 // Give devin a credential that expires far enough in the future to
1483 // survive the setup env.close() calls.
1484 auto jv = credentials::create(devin, domainOwner, credType);
1485 uint32_t const t = env.current()->header().parentCloseTime.time_since_epoch().count();
1486 jv[sfExpiration.jsonName] = t + 100;
1487 env(jv);
1488 env.close();
1489 env(credentials::accept(devin, domainOwner, credType));
1490 env.close();
1491
1492 // Devin creates a hybrid offer: sell USD(10) for XRP(10).
1493 // The offer is placed in both the domain book and the open book.
1494 auto const hybridOfferSeq{env.seq(devin)};
1495 env(offer(devin, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1496 env.close();
1497
1498 BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(10), USD(10), lsfHybrid, true));
1499
1500 // A non-domain open-book payment partially crosses the offer while
1501 // devin's credential is still valid.
1502 auto carolBalance = env.balance(carol, USD);
1503 env(pay(alice, carol, USD(5)), Path(~USD), Sendmax(XRP(5)));
1504 env.close();
1505 BEAST_EXPECT(env.balance(carol, USD) - carolBalance == USD(5));
1506 BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true));
1507
1508 // Advance time so that devin's credential expires.
1509 env.close(std::chrono::seconds(100));
1510
1511 // Confirm devin can no longer create domain offers.
1512 env(offer(devin, XRP(1), USD(1)), Domain(domainID), Ter(tecNO_PERMISSION));
1513 env.close();
1514
1515 // The hybrid offer must still exist in the open book after expiry.
1516 BEAST_EXPECT(offerExists(env, devin, hybridOfferSeq));
1517
1518 // A non-domain open-book payment must cross (not evict) the
1519 // remaining portion of devin's hybrid offer.
1520 carolBalance = env.balance(carol, USD);
1521 env(pay(alice, carol, USD(2)), Path(~USD), Sendmax(XRP(2)));
1522 env.close();
1523
1524 // Carol received USD; the offer was crossed, not evicted.
1525 BEAST_EXPECT(env.balance(carol, USD) - carolBalance == USD(2));
1526 // Offer still exists with 3 USD / 3 XRP remaining.
1527 BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(3), USD(3), lsfHybrid, true));
1528
1529 // A domain payment now fails because the domain book evicts devin's
1530 // offer (his credential has expired). The eviction is rolled back with
1531 // the failed sandbox, so the offer is NOT permanently removed.
1532 env(pay(alice, carol, USD(1)),
1533 Path(~USD),
1534 Sendmax(XRP(1)),
1535 Domain(domainID),
1537 env.close();
1538
1539 // Offer still intact in the open book; domain payment did not
1540 // permanently delete it.
1541 BEAST_EXPECT(checkOffer(env, devin, hybridOfferSeq, XRP(3), USD(3), lsfHybrid, true));
1542
1543 // The open book can still fully consume the remaining portion.
1544 carolBalance = env.balance(carol, USD);
1545 env(pay(alice, carol, USD(3)), Path(~USD), Sendmax(XRP(3)));
1546 env.close();
1547 BEAST_EXPECT(env.balance(carol, USD) - carolBalance == USD(3));
1548 BEAST_EXPECT(!offerExists(env, devin, hybridOfferSeq));
1549 }
1550
1551 void
1553 {
1554 Env env(*this, features);
1555 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1556 PermissionedDEX(env);
1557
1559 offerSeqs.reserve(100);
1560
1561 Book const domainBook{Issue(XRP), Issue(USD), domainID};
1562 Book const openBook{Issue(XRP), Issue(USD), std::nullopt};
1563
1564 auto const domainDir = getBookDirKey(domainBook, XRP(10), USD(10));
1565 auto const openDir = getBookDirKey(openBook, XRP(10), USD(10));
1566
1567 size_t dirCnt = 100;
1568
1569 for (size_t i = 1; i <= dirCnt; i++)
1570 {
1571 auto const bobOfferSeq{env.seq(bob)};
1572 offerSeqs.emplace_back(bobOfferSeq);
1573 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1574 env.close();
1575
1576 auto const sleOffer = env.le(keylet::offer(bob.id(), bobOfferSeq));
1577 BEAST_EXPECT(sleOffer);
1578 BEAST_EXPECT(sleOffer->getFieldH256(sfBookDirectory) == domainDir);
1579 BEAST_EXPECT(sleOffer->getFieldArray(sfAdditionalBooks).size() == 1);
1580 BEAST_EXPECT(
1581 sleOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory) ==
1582 openDir);
1583
1584 BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true));
1585 BEAST_EXPECT(checkDirectorySize(env, domainDir, i));
1586 BEAST_EXPECT(checkDirectorySize(env, openDir, i));
1587 }
1588
1589 for (auto const offerSeq : offerSeqs)
1590 {
1591 env(offerCancel(bob, offerSeq));
1592 env.close();
1593 dirCnt--;
1594 BEAST_EXPECT(!offerExists(env, bob, offerSeq));
1595 BEAST_EXPECT(checkDirectorySize(env, domainDir, dirCnt));
1596 BEAST_EXPECT(checkDirectorySize(env, openDir, dirCnt));
1597 }
1598 }
1599
1600 void
1602 {
1603 testcase("Auto bridge");
1604
1605 Env env(*this, features);
1606 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1607 PermissionedDEX(env);
1608 auto const eur = gw["EUR"];
1609
1610 for (auto const& account : {alice, bob, carol})
1611 {
1612 env(trust(account, eur(10000)));
1613 env.close();
1614 }
1615
1616 env(pay(gw, carol, eur(1)));
1617 env.close();
1618
1619 auto const aliceOfferSeq{env.seq(alice)};
1620 auto const bobOfferSeq{env.seq(bob)};
1621 env(offer(alice, XRP(100), USD(1)), Domain(domainID));
1622 env(offer(bob, eur(1), XRP(100)), Domain(domainID));
1623 env.close();
1624
1625 // carol's offer should cross bob and alice's offers due to auto
1626 // bridging
1627 auto const carolOfferSeq{env.seq(carol)};
1628 env(offer(carol, USD(1), eur(1)), Domain(domainID));
1629 env.close();
1630
1631 BEAST_EXPECT(!offerExists(env, bob, aliceOfferSeq));
1632 BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
1633 BEAST_EXPECT(!offerExists(env, bob, carolOfferSeq));
1634 }
1635
1636 void
1638 {
1639 bool const fixEnabled = features[fixCleanup3_1_3];
1640
1641 testcase << "Hybrid offer with empty AdditionalBooks"
1642 << (fixEnabled ? " (fixCleanup3_1_3 enabled)" : " (fixCleanup3_1_3 disabled)");
1643
1644 // offerInDomain has two code paths gated by fixCleanup3_1_3:
1645 //
1646 // pre-fix: only rejects a hybrid offer when sfAdditionalBooks is
1647 // entirely absent — an empty array (size 0) passes through.
1648 // post-fix: also rejects a hybrid offer whose sfAdditionalBooks array
1649 // has size != 1 (i.e. 0 or >1 entries).
1650 //
1651 // We create a valid hybrid offer, then directly manipulate its SLE to
1652 // produce the size==0 case that cannot occur via normal transactions,
1653 // and verify that the two code paths produce the expected outcomes.
1654 //
1655 // Note: the PermissionedDEX invariant checker (ValidPermissionedDEX)
1656 // does not flag this malformation for ttPAYMENT — only for
1657 // ttOFFER_CREATE — so the without-fix payment completes as tesSUCCESS.
1658
1659 Env env(*this, features);
1660 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1661 PermissionedDEX(env);
1662
1663 // Create a valid hybrid offer (sfAdditionalBooks has exactly 1 entry)
1664 auto const bobOfferSeq{env.seq(bob)};
1665 env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID));
1666 env.close();
1667 BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
1668
1669 // Directly manipulate the offer SLE in the open ledger so that
1670 // sfAdditionalBooks is present but empty (size 0). This is the
1671 // malformed state that fixCleanup3_1_3 is designed to catch.
1672 auto const offerKey = keylet::offer(bob.id(), bobOfferSeq);
1673 env.app().getOpenLedger().modify([&offerKey](OpenView& view, beast::Journal) {
1674 auto const sle = view.read(offerKey);
1675 if (!sle)
1676 return false;
1677 auto replacement = std::make_shared<SLE>(*sle, sle->key());
1678 replacement->setFieldArray(sfAdditionalBooks, STArray{});
1679 view.rawReplace(replacement);
1680 return true;
1681 });
1682
1683 if (fixEnabled)
1684 {
1685 // post-fixCleanup3_1_3: offerInDomain rejects the malformed
1686 // offer (size == 0), so no valid domain offer is found.
1687 env(pay(alice, carol, USD(10)),
1688 Path(~USD),
1689 Sendmax(XRP(10)),
1690 Domain(domainID),
1692 }
1693 else
1694 {
1695 // pre-fixCleanup3_1_3: offerInDomain only checks for a missing
1696 // sfAdditionalBooks field; size == 0 passes through, so the
1697 // malformed offer is crossed and the payment succeeds.
1698 env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
1699 }
1700 }
1701
1702 void
1704 {
1705 bool const fixEnabled = features[fixCleanup3_2_0];
1706 testcase << "Hybrid offer crossing quality"
1707 << (fixEnabled ? " (fixCleanup3_2_0)" : " (pre-fix)");
1708
1709 // Partially-crossed hybrid offer should have consistent quality
1710 // across both book directories.
1711 //
1712 // Steps:
1713 // - Bob places a hybrid offer.
1714 // - Alice places an opposing hybrid offer that partially crosses.
1715 //
1716 // Verify:
1717 // - Domain-book key quality == its sfExchangeRate.
1718 // - Post-fix: open-book key quality == domain-book key quality.
1719 // - Pre-fix: open-book key quality != domain-book key quality
1720 // (key used post-crossing rate, sfExchangeRate used pre-crossing).
1721
1722 Env env(*this, features);
1723 auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
1724 PermissionedDEX(env);
1725
1726 // Bob places a hybrid offer: TakerPays = XRP(100), TakerGets = USD(40)
1727 auto const bobOfferSeq{env.seq(bob_)};
1728 env(offer(bob_, XRP(100), USD(40)), Txflags(tfHybrid), Domain(domainID));
1729 env.close();
1730 BEAST_EXPECT(offerExists(env, bob_, bobOfferSeq));
1731
1732 // Alice places a hybrid offer in the opposite direction that
1733 // partially crosses Bob's offer.
1734 // Alice: TakerPays = USD(100), TakerGets = XRP(300) (rate = 3 XRP/USD)
1735 // Bob's offer is at a better rate (2.5 XRP/USD) so crossing occurs.
1736 auto const aliceOfferSeq{env.seq(alice_)};
1737 env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID));
1738 env.close();
1739
1740 // After crossing, Alice's remaining offer should be placed.
1741 auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq));
1742 BEAST_EXPECT(sle);
1743 BEAST_EXPECT(sle->isFieldPresent(sfAdditionalBooks));
1744 BEAST_EXPECT(sle->getFieldArray(sfAdditionalBooks).size() == 1);
1745
1746 auto const domainDirKey = sle->getFieldH256(sfBookDirectory);
1747 auto const openDirKey =
1748 sle->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
1749
1750 auto const domainQuality = getQuality(domainDirKey);
1751 auto const openQuality = getQuality(openDirKey);
1752
1753 // Read the directory SLEs and check sfExchangeRate vs key quality.
1754 auto const domainDirSle = env.le(Keylet(ltDIR_NODE, domainDirKey));
1755 auto const openDirSle = env.le(Keylet(ltDIR_NODE, openDirKey));
1756 BEAST_EXPECT(domainDirSle);
1757 BEAST_EXPECT(openDirSle);
1758
1759 auto const domainExRate = domainDirSle->getFieldU64(sfExchangeRate);
1760 auto const openExRate = openDirSle->getFieldU64(sfExchangeRate);
1761 auto const preCrossingQuality = std::uint64_t{5623825668291712342ULL};
1762 auto const postCrossingQuality = std::uint64_t{5623825668291712341ULL};
1763
1764 // Domain directory: sfExchangeRate should always match key quality
1765 // (both use the pre-crossing rate). Correct behavior.
1766 BEAST_EXPECT(domainQuality == preCrossingQuality);
1767 BEAST_EXPECT(domainExRate == preCrossingQuality);
1768 BEAST_EXPECT(domainExRate == domainQuality);
1769
1770 if (fixEnabled)
1771 {
1772 // Correct behavior: both directory keys use the pre-crossing rate.
1773 BEAST_EXPECT(openQuality == preCrossingQuality);
1774 BEAST_EXPECT(domainQuality == openQuality);
1775
1776 // sfExchangeRate matches key quality on both directories.
1777 BEAST_EXPECT(openExRate == preCrossingQuality);
1778 BEAST_EXPECT(openExRate == openQuality);
1779 }
1780 else
1781 {
1782 // Wrong legacy behavior: the open-book directory key uses the
1783 // post-crossing rate instead of the domain-book rate.
1784 BEAST_EXPECT(openQuality == postCrossingQuality);
1785 BEAST_EXPECT(domainQuality != openQuality);
1786
1787 // The open-book sfExchangeRate still uses the pre-crossing rate,
1788 // so it no longer matches the actual quality encoded in the
1789 // open-book directory key.
1790 BEAST_EXPECT(openExRate == preCrossingQuality);
1791 BEAST_EXPECT(openExRate != openQuality);
1792 BEAST_EXPECT(openExRate == domainQuality);
1793 }
1794 }
1795
1796 void
1798 {
1799 testcase("LedgerStateFix BookExchangeRate");
1800
1801 // Use the pre-fix path to create a hybrid offer with a mismatched
1802 // sfExchangeRate, then apply LedgerStateFix to correct it.
1803 //
1804 // Steps:
1805 // - Create a partially-crossed hybrid offer (pre-fixCleanup3_2_0)
1806 // so the open-book directory has wrong sfExchangeRate.
1807 // - Re-enable fixCleanup3_2_0 and submit a LedgerStateFix to
1808 // repair the open-book directory's sfExchangeRate.
1809 //
1810 // Verify:
1811 // - Before fix: sfExchangeRate != getQuality(key).
1812 // - After fix: sfExchangeRate == getQuality(key).
1813
1814 {
1815 // Amendment gate: BookExchangeRate fixes require fixCleanup3_2_0.
1816 Env env(*this, features - fixCleanup3_2_0);
1817 Account const carol{"carol"};
1818
1819 env.fund(XRP(1000), carol);
1820 env.close();
1821
1823 }
1824
1825 {
1826 // Preflight check: BookExchangeRate fixes only accept their
1827 // required fix-specific field.
1828 Env env(*this, features);
1829 Account const carol{"carol"};
1830
1831 env.fund(XRP(1000), carol);
1832 env.close();
1833
1834 // BookExchangeRate fixes require sfBookDirectory.
1835 auto missingBookDirectory = ledgerStateFix::bookExchangeRate(carol, uint256{1});
1836 missingBookDirectory.removeMember(sfBookDirectory.jsonName);
1837 env(missingBookDirectory, Ter(temINVALID));
1838
1839 // BookExchangeRate fixes reject fields that belong to other
1840 // LedgerStateFix types.
1841 auto extraOwner = ledgerStateFix::bookExchangeRate(carol, uint256{1});
1842 extraOwner[sfOwner.jsonName] = carol.human();
1843 env(extraOwner, Ter(temINVALID));
1844 }
1845
1846 {
1847 Env env(*this, features);
1848 auto const setup = PermissionedDEX(env);
1849 auto const fixFee = drops(env.current()->fees().increment);
1850
1851 {
1852 // Preclaim check: the target directory must exist.
1853 env(ledgerStateFix::bookExchangeRate(setup.carol, uint256{1}),
1854 Fee(fixFee),
1856 }
1857
1858 {
1859 // Preclaim check: the target directory must be a book root
1860 // page. Owner directories are ltDIR_NODE entries, but they do
1861 // not carry sfExchangeRate.
1862 auto const ownerDir = keylet::ownerDir(setup.bob.id());
1863 auto const ownerDirSle = env.le(ownerDir);
1864 BEAST_EXPECT(ownerDirSle);
1865 BEAST_EXPECT(!ownerDirSle->isFieldPresent(sfExchangeRate));
1866
1867 env(ledgerStateFix::bookExchangeRate(setup.carol, ownerDir.key),
1868 Fee(fixFee),
1870 }
1871
1872 {
1873 // Preclaim check: a correct sfExchangeRate leaves nothing to
1874 // repair.
1875 auto const bobOfferSeq{env.seq(setup.bob)};
1876 env(offer(setup.bob, XRP(100), setup.usd(40)));
1877 env.close();
1878
1879 auto const sle = env.le(keylet::offer(setup.bob.id(), bobOfferSeq));
1880 BEAST_EXPECT(sle);
1881
1882 auto const dirKey = sle->getFieldH256(sfBookDirectory);
1883 {
1884 auto const dirSle = env.le(Keylet(ltDIR_NODE, dirKey));
1885 BEAST_EXPECT(dirSle);
1886 auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate);
1887 auto const quality = getQuality(dirKey);
1888 BEAST_EXPECT(exchangeRate == quality);
1889 }
1890
1891 env(ledgerStateFix::bookExchangeRate(setup.carol, dirKey),
1892 Fee(fixFee),
1894 }
1895 }
1896
1897 {
1898 // Repair path: start without fixCleanup3_2_0 to produce the
1899 // mismatch, then enable the amendment and fix it.
1900 Env env(*this, features - fixCleanup3_2_0);
1901 auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] =
1902 PermissionedDEX(env);
1903
1904 // Bob places a hybrid offer.
1905 env(offer(bob_, XRP(100), USD(40)), Txflags(tfHybrid), Domain(domainID));
1906 env.close();
1907
1908 // Alice partially crosses Bob.
1909 auto const aliceOfferSeq{env.seq(alice_)};
1910 env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID));
1911 env.close();
1912
1913 auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq));
1914 BEAST_EXPECT(sle);
1915
1916 auto const openDirKey =
1917 sle->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory);
1918
1919 auto const preCrossingQuality = std::uint64_t{5623825668291712342ULL};
1920 auto const postCrossingQuality = std::uint64_t{5623825668291712341ULL};
1921
1922 // Confirm mismatch exists.
1923 {
1924 auto const dirSle = env.le(Keylet(ltDIR_NODE, openDirKey));
1925 BEAST_EXPECT(dirSle);
1926 auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate);
1927 auto const quality = getQuality(openDirKey);
1928 BEAST_EXPECT(exchangeRate == preCrossingQuality);
1929 BEAST_EXPECT(quality == postCrossingQuality);
1930 BEAST_EXPECT(exchangeRate != quality);
1931 }
1932
1933 // Enable fixCleanup3_2_0 and apply the LedgerStateFix.
1934 env.enableFeature(fixCleanup3_2_0);
1935 env.close();
1936
1937 auto const fixFee = drops(env.current()->fees().increment);
1938 env(ledgerStateFix::bookExchangeRate(carol_, openDirKey), Fee(fixFee));
1939 env.close();
1940
1941 // Confirm sfExchangeRate now matches the key quality.
1942 {
1943 auto const dirSle = env.le(Keylet(ltDIR_NODE, openDirKey));
1944 BEAST_EXPECT(dirSle);
1945 auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate);
1946 auto const quality = getQuality(openDirKey);
1947 BEAST_EXPECT(exchangeRate == postCrossingQuality);
1948 BEAST_EXPECT(quality == postCrossingQuality);
1949 BEAST_EXPECT(exchangeRate == quality);
1950 }
1951
1952 // Submitting again should fail — nothing to fix.
1953 env(ledgerStateFix::bookExchangeRate(carol_, openDirKey),
1954 Fee(fixFee),
1956 }
1957 }
1958
1959 void
1961 {
1962 bool const fixEnabled = features[fixCleanup3_2_0];
1963
1964 testcase << "Cancel regular offer via domain OfferCreate"
1965 << (fixEnabled ? " (fixCleanup3_2_0 enabled)" : " (fixCleanup3_2_0 disabled)");
1966
1967 // An OfferCreate with sfDomainID and sfOfferSequence pointing to
1968 // the user's own non-domain offer should atomically cancel the
1969 // regular offer and place the new domain offer.
1970 //
1971 // Pre-fixCleanup3_2_0: ValidPermissionedDEX flagged the deleted
1972 // regular offer, so the transaction failed with tecINVARIANT_FAILED.
1973 // Post-fixCleanup3_2_0: the invariant ignores deletions and the
1974 // transaction succeeds.
1975
1976 Env env(*this, features);
1977 auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
1978 PermissionedDEX(env);
1979
1980 auto const regularSeq = env.seq(bob);
1981 env(offer(bob, XRP(10), USD(10)));
1982 env.close();
1983 BEAST_EXPECT(checkOffer(env, bob, regularSeq, XRP(10), USD(10), 0, false));
1984
1985 auto const domainSeq = env.seq(bob);
1986 if (fixEnabled)
1987 {
1988 env(offer(bob, XRP(20), USD(20)),
1989 Domain(domainID),
1990 Json(jss::OfferSequence, regularSeq));
1991 env.close();
1992 BEAST_EXPECT(!offerExists(env, bob, regularSeq));
1993 BEAST_EXPECT(checkOffer(env, bob, domainSeq, XRP(20), USD(20), 0, true));
1994 }
1995 else
1996 {
1997 env(offer(bob, XRP(20), USD(20)),
1998 Domain(domainID),
1999 Json(jss::OfferSequence, regularSeq),
2001 env.close();
2002 BEAST_EXPECT(offerExists(env, bob, regularSeq));
2003 BEAST_EXPECT(!offerExists(env, bob, domainSeq));
2004 }
2005 }
2006
2007public:
2008 void
2009 run() override
2010 {
2012
2013 // Test domain offer (w/o hybrid)
2014 testOfferCreate(all);
2015 testOfferCreate(all - fixCleanup3_2_0);
2016 testPayment(all);
2017 testPayment(all - fixCleanup3_2_0);
2018 testBookStep(all);
2019 testRippling(all);
2022 testAmmNotUsed(all);
2024 testAmmQualityNotLeaked(all - fixCleanup3_3_0);
2025 testAutoBridge(all);
2026
2027 // Test hybrid offers
2029 testHybridBookStep(all);
2030 testHybridInvalidOffer(all - fixCleanup3_3_0);
2035 testHybridMalformedOffer(all - fixCleanup3_1_3);
2037 testHybridOfferCrossingQuality(all - fixCleanup3_2_0);
2039
2040 // Cancelling a regular offer in a domain OfferCreate is allowed
2041 // only after fixCleanup3_2_0.
2043 testCancelRegularOfferWithDomainCreate(all - fixCleanup3_2_0);
2044 }
2045};
2046
2048
2049} // namespace xrpl::test
A generic endpoint for log messages.
Definition Journal.h:38
A testsuite class.
Definition suite.h:50
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:149
Value removeMember(char const *key)
Remove and return the named member.
Specifies an order book.
Definition Book.h:16
A currency issued by an account.
Definition Issue.h:13
Writable ledger view that accumulates state and tx changes.
Definition OpenView.h:45
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
void testRippling(FeatureBitset features)
void testOfferCreate(FeatureBitset features)
void testHybridOfferDirectories(FeatureBitset features)
static std::optional< uint256 > getDefaultOfferDirKey(Env const &env, Account const &account, std::uint32_t offerSeq)
static uint256 getBookDirKey(Book const &book, STAmount const &takerPays, STAmount const &takerGets)
void testHybridOfferCreate(FeatureBitset features)
void testCancelRegularOfferWithDomainCreate(FeatureBitset features)
void testHybridOpenBookAfterCredentialExpiry(FeatureBitset features)
void testHybridOfferCrossingQuality(FeatureBitset features)
void testAutoBridge(FeatureBitset features)
void testOfferTokenIssuerInDomain(FeatureBitset features)
void run() override
Runs the suite.
void testPayment(FeatureBitset features)
static bool checkDirectorySize(Env const &env, uint256 directory, std::uint32_t dirSize)
void testHybridBookStep(FeatureBitset features)
void testAmmNotUsed(FeatureBitset features)
void testBookStep(FeatureBitset features)
static bool offerExists(Env const &env, Account const &account, std::uint32_t offerSeq)
void testAmmQualityNotLeaked(FeatureBitset features)
static bool checkOffer(Env const &env, Account const &account, std::uint32_t offerSeq, STAmount const &takerPays, STAmount const &takerGets, uint32_t const flags=0, bool const domainOffer=false)
void testBookExchangeRateFix(FeatureBitset features)
void testHybridMalformedOffer(FeatureBitset features)
void testHybridInvalidOffer(FeatureBitset features)
void testRemoveUnfundedOffer(FeatureBitset features)
Convenience class to test AMM functionality.
Immutable cryptographic account descriptor.
Definition jtx/Account.h:17
std::string const & human() const
Returns the human readable public key.
Definition jtx/Account.h:92
A transaction testing environment.
Definition Env.h:143
bool close(NetClock::time_point closeTime, std::optional< std::chrono::milliseconds > consensusDelay=std::nullopt)
Close and advance the ledger.
Definition Env.cpp:133
SLE::const_pointer le(Account const &account) const
Return an account root.
Definition Env.cpp:284
void fund(bool setDefaultRipple, STAmount const &amount, Account const &account)
Definition Env.cpp:296
void enableFeature(uint256 const feature)
Definition Env.cpp:682
std::uint32_t seq(Account const &account) const
Returns the next sequence number on account.
Definition Env.cpp:275
PrettyAmount balance(Account const &account) const
Returns the XRP balance on an account.
Definition Env.cpp:201
void trust(STAmount const &amount, Account const &account)
Establish trust lines.
Definition Env.cpp:327
void require(Args const &... args)
Check a set of requirements.
Definition Env.h:605
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
Definition Env.h:353
Set the fee on a JTx.
Definition fee.h:15
Inject raw JSON.
Definition jtx_json.h:11
Add a path.
Definition paths.h:39
Set Paths, SendMax on a JTx.
Definition paths.h:16
Sets the SendMax on a JTx.
Definition sendmax.h:13
Set the expected result code for a JTx The test will fail if the code doesn't match.
Definition ter.h:13
Set the flags on a JTx.
Definition txflags.h:9
T emplace_back(T... args)
T make_shared(T... args)
Keylet quality(Keylet const &k, std::uint64_t q) noexcept
The initial directory page for a specific quality.
Definition Indexes.cpp:270
Keylet book(Book const &b)
The beginning of an order book.
Definition Indexes.cpp:235
Keylet ownerDir(AccountID const &id) noexcept
The root page of an account's directory.
Definition Indexes.cpp:357
Keylet offer(AccountID const &id, std::uint32_t seq) noexcept
An offer from an account.
Definition Indexes.cpp:264
Keylet page(uint256 const &root, std::uint64_t index=0) noexcept
A page in a directory.
Definition Indexes.cpp:363
json::Value deleteCred(jtx::Account const &acc, jtx::Account const &subject, jtx::Account const &issuer, std::string_view credType)
Definition creds.cpp:40
json::Value accept(jtx::Account const &subject, jtx::Account const &issuer, std::string_view credType)
Definition creds.cpp:29
json::Value create(jtx::Account const &subject, jtx::Account const &issuer, std::string_view credType)
Definition creds.cpp:16
Directory operations.
Definition directory.h:13
json::Value bookExchangeRate(jtx::Account const &acct, uint256 const &bookDir)
Repair sfExchangeRate on a book directory's first page.
std::vector< Credential > Credentials
std::map< uint256, json::Value > getObjects(Account const &account, Env &env, bool withType)
json::Value setTx(AccountID const &account, Credentials const &credentials, std::optional< uint256 > domain)
json::Value pay(AccountID const &account, AccountID const &to, AnyAmount amount)
Create a payment.
Definition pay.cpp:14
json::Value offerCancel(Account const &account, std::uint32_t offerSeq)
Cancel an offer.
Definition offer.cpp:31
XrpT const XRP
Converts to XRP Issue or STAmount.
Definition amount.cpp:92
std::uint32_t ownerCount(Env const &env, Account const &account)
FeatureBitset testableAmendments()
Definition Env.h:76
json::Value offer(Account const &account, STAmount const &takerPays, STAmount const &takerGets, std::uint32_t flags)
Create an offer.
Definition offer.cpp:14
json::Value trust(Account const &account, STAmount const &amount, std::uint32_t flags)
Modify a trust line.
Definition trust.cpp:18
PrettyAmount drops(Integer i)
Returns an XRP PrettyAmount, which is trivially convertible to STAmount.
BEAST_DEFINE_TESTSUITE(AMMClawback, app, xrpl)
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
BaseUInt< 256 > Domain
Domain is a 256-bit hash representing a specific domain.
Definition UintTypes.h:47
std::uint64_t getQuality(uint256 const &uBase)
Definition Indexes.cpp:152
std::uint64_t getRate(STAmount const &offerOut, STAmount const &offerIn)
Definition STAmount.cpp:422
@ temINVALID
Definition TER.h:96
@ temINVALID_FLAG
Definition TER.h:97
@ temMALFORMED
Definition TER.h:73
@ temDISABLED
Definition TER.h:100
@ tecPATH_PARTIAL
Definition TER.h:280
@ tecPATH_DRY
Definition TER.h:292
@ tecOBJECT_NOT_FOUND
Definition TER.h:324
@ tecINVARIANT_FAILED
Definition TER.h:311
@ tecNO_PERMISSION
Definition TER.h:303
BaseUInt< 256 > uint256
Definition base_uint.h:562
T reserve(T... args)
A pair of SHAMap key and LedgerEntryType.
Definition Keylet.h:19
uint256 key
Definition Keylet.h:20
T value_or(T... args)