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