xrpld
Loading...
Searching...
No Matches
MPTInvariant.cpp
1#include <xrpl/tx/invariants/MPTInvariant.h>
2
3#include <xrpl/basics/Log.h>
4#include <xrpl/basics/base_uint.h>
5#include <xrpl/beast/utility/Journal.h>
6#include <xrpl/beast/utility/Zero.h>
7#include <xrpl/beast/utility/instrumentation.h>
8#include <xrpl/ledger/ReadView.h>
9#include <xrpl/ledger/helpers/AccountRootHelpers.h>
10#include <xrpl/ledger/helpers/MPTokenHelpers.h>
11#include <xrpl/protocol/AccountID.h>
12#include <xrpl/protocol/Feature.h>
13#include <xrpl/protocol/Indexes.h>
14#include <xrpl/protocol/LedgerFormats.h>
15#include <xrpl/protocol/MPTIssue.h>
16#include <xrpl/protocol/Protocol.h>
17#include <xrpl/protocol/Rules.h>
18#include <xrpl/protocol/SField.h>
19#include <xrpl/protocol/STLedgerEntry.h>
20#include <xrpl/protocol/STTx.h>
21#include <xrpl/protocol/TER.h>
22#include <xrpl/protocol/TxFormats.h>
23#include <xrpl/protocol/UintTypes.h>
24#include <xrpl/protocol/XRPAmount.h>
25#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
26
27#include <algorithm>
28#include <array>
29#include <cstddef>
30#include <cstdint>
31#include <memory>
32
33namespace xrpl {
34
35namespace {
36constexpr auto kConfidentialMptTxTypes = std::to_array<TxType>({
37 ttCONFIDENTIAL_MPT_SEND,
38 ttCONFIDENTIAL_MPT_CONVERT,
39 ttCONFIDENTIAL_MPT_CONVERT_BACK,
40 ttCONFIDENTIAL_MPT_MERGE_INBOX,
41 ttCONFIDENTIAL_MPT_CLAWBACK,
42});
43
44// Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant
45// tests can inject INT64_MAX + 1, which would result in undefined behavior
46// under UBSan if converted directly.
47std::int64_t
48toSignedMPTAmount(std::uint64_t amount)
49{
50 return static_cast<std::int64_t>(std::min(amount, kMaxMpTokenAmount));
51}
52
53std::int64_t
54addMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
55{
56 return delta + toSignedMPTAmount(amount);
57}
58
59std::int64_t
60subtractMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
61{
62 return delta - toSignedMPTAmount(amount);
63}
64
65} // namespace
66
67void
69{
70 // The sfReferenceHolding tracking and the deleted-holding capture are
71 // only meaningful post-fixCleanup3_2_0 (the field is never set
72 // pre-amendment, and the holding-deletion rule does not apply).
73 // Skip both blocks when the amendment is off so we avoid wasted work
74 // on the hot path.
75 bool const fix320Enabled = isFeatureEnabled(fixCleanup3_2_0);
76
77 if (after && after->getType() == ltMPTOKEN_ISSUANCE)
78 {
79 if (isDelete)
80 {
82 }
83 else if (!before)
84 {
86 if (fix320Enabled && after->isFieldPresent(sfReferenceHolding))
88 }
89 else if (fix320Enabled)
90 {
91 // Modified issuance: detect any change to sfReferenceHolding.
92 bool const beforePresent = before->isFieldPresent(sfReferenceHolding);
93 bool const afterPresent = after->isFieldPresent(sfReferenceHolding);
94 if (beforePresent != afterPresent ||
95 (afterPresent &&
96 before->getFieldH256(sfReferenceHolding) !=
97 after->getFieldH256(sfReferenceHolding)))
98 {
100 }
101 }
102 }
103
104 if (after && after->getType() == ltMPTOKEN)
105 {
106 if (isDelete)
107 {
109 if (fix320Enabled)
110 deletedHoldings_.push_back(after);
111 }
112 else if (!before)
113 {
115 MPTIssue const mptIssue{after->at(sfMPTokenIssuanceID)};
116 if (mptIssue.getIssuer() == after->at(sfAccount))
117 mptCreatedByIssuer_ = true;
118 }
119 }
120
121 // Capture deleted RippleState SLEs so finalize() can verify none of
122 // them were owned by a vault pseudo-account outside VaultDelete.
123 if (fix320Enabled && isDelete && after && after->getType() == ltRIPPLE_STATE)
124 deletedHoldings_.push_back(after);
125}
126
127bool
129 STTx const& tx,
130 TER const result,
131 XRPAmount const fee,
132 ReadView const& view,
133 beast::Journal const& j) const
134{
135 auto const& rules = view.rules();
136 bool const mptV2Enabled = rules.enabled(featureMPTokensV2);
137
138 // Post-fixCleanup3_2_0:
139 // - sfReferenceHolding is set only by VaultCreate at share-issuance
140 // creation, and is immutable thereafter.
141 // - A vault pseudo-account's MPToken or RippleState may only be
142 // deleted by VaultDelete; the share's sfReferenceHolding pointer
143 // must not dangle outside that controlled lifecycle.
144 if (rules.enabled(fixCleanup3_2_0))
145 {
146 bool invariantPasses = true;
148 {
149 JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding was modified "
150 "on an existing MPTokenIssuance";
151 invariantPasses = false;
152 }
153 if (referenceHoldingSetOnCreate_ && tx.getTxnType() != ttVAULT_CREATE)
154 {
155 JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding set on a new "
156 "MPTokenIssuance by a non-VaultCreate transaction";
157 invariantPasses = false;
158 }
159 if (!deletedHoldings_.empty() && tx.getTxnType() != ttVAULT_DELETE)
160 {
161 auto const isVaultPseudo = [&](AccountID const& acct) {
162 auto const sle = view.read(keylet::account(acct));
163 return sle && sle->isFieldPresent(sfVaultID);
164 };
165 for (auto const& sleHolding : deletedHoldings_)
166 {
167 bool offending = false;
168 if (sleHolding->getType() == ltMPTOKEN)
169 {
170 offending = isVaultPseudo(sleHolding->at(sfAccount));
171 }
172 else // ltRIPPLE_STATE
173 {
174 auto const lowLimit = sleHolding->getFieldAmount(sfLowLimit);
175 auto const highLimit = sleHolding->getFieldAmount(sfHighLimit);
176 // Each limit's STAmount.issuer is the COUNTERPARTY of
177 // that side's owner: lowLimit's issuer is the high
178 // account, highLimit's issuer is the low account.
179 offending =
180 isVaultPseudo(lowLimit.getIssuer()) || isVaultPseudo(highLimit.getIssuer());
181 }
182 if (offending)
183 {
184 JLOG(j.fatal()) << "Invariant failed: vault pseudo-account holding "
185 "deleted by a non-VaultDelete transaction";
186 invariantPasses = false;
187 }
188 }
189 }
190 if (!invariantPasses)
191 return false;
192 }
193
194 if (isTesSuccess(result) || (mptV2Enabled && result == tecINCOMPLETE))
195 {
196 [[maybe_unused]]
197 bool const enforceCreatedByIssuer =
198 rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol);
200 {
201 JLOG(j.fatal()) << "Invariant failed: MPToken created for the MPT issuer";
202 // The comment above starting with "assert(enforce)" explains this
203 // assert.
204 XRPL_ASSERT_PARTS(
205 enforceCreatedByIssuer, "xrpl::ValidMPTIssuance::finalize", "no issuer MPToken");
206 if (enforceCreatedByIssuer)
207 return false;
208 }
209
210 auto const txnType = tx.getTxnType();
212 {
213 if (mptIssuancesCreated_ == 0)
214 {
215 JLOG(j.fatal()) << "Invariant failed: transaction "
216 "succeeded without creating a MPT issuance";
217 }
218 else if (mptIssuancesDeleted_ != 0)
219 {
220 JLOG(j.fatal()) << "Invariant failed: transaction "
221 "succeeded while removing MPT issuances";
222 }
223 else if (mptIssuancesCreated_ > 1)
224 {
225 JLOG(j.fatal()) << "Invariant failed: transaction "
226 "succeeded but created multiple issuances";
227 }
228
229 return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
230 }
231
233 {
234 if (mptIssuancesDeleted_ == 0)
235 {
236 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
237 "succeeded without removing a MPT issuance";
238 }
239 else if (mptIssuancesCreated_ > 0)
240 {
241 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
242 "succeeded while creating MPT issuances";
243 }
244 else if (mptIssuancesDeleted_ > 1)
245 {
246 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
247 "succeeded but deleted multiple issuances";
248 }
249
250 return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 1;
251 }
252
253 bool const lendingProtocolEnabled = rules.enabled(featureLendingProtocol);
254 // ttESCROW_FINISH may authorize an MPT, but it can't have the
255 // mayAuthorizeMPT privilege, because that may cause
256 // non-amendment-gated side effects.
257 bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) &&
258 (rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled);
259 if (hasPrivilege(tx, MustAuthorizeMpt | MayAuthorizeMpt) || enforceEscrowFinish)
260 {
261 bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
262
263 if (mptIssuancesCreated_ > 0)
264 {
265 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
266 "succeeded but created MPT issuances";
267 return false;
268 }
269 if (mptIssuancesDeleted_ > 0)
270 {
271 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
272 "succeeded but deleted issuances";
273 return false;
274 }
275 if (mptV2Enabled && hasPrivilege(tx, MayAuthorizeMpt) &&
276 (txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK))
277 {
278 if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0)
279 {
280 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
281 "submitted by issuer succeeded "
282 "but created bad number of mptokens";
283 return false;
284 }
285 // At most two MPToken may be created on withdraw/clawback since:
286 // - Liquidity Provider must have at least one token in order
287 // participate in AMM pool liquidity or have LPTokens only.
288 // - At most two MPTokens may be deleted if AMM pool, which has exactly
289 // two tokens, is empty after withdraw/clawback.
290 SOMETIMES(mptokensCreated_ == 2, "AMM withdraw/clawback recreated two MPTokens");
291 if (mptokensCreated_ > 2 || mptokensDeleted_ > 2)
292 {
293 JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
294 "but created/deleted bad number of mptokens";
295 return false;
296 }
297 }
298 else if (lendingProtocolEnabled && (mptokensCreated_ + mptokensDeleted_) > 1)
299 {
300 JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
301 "but created/deleted bad number mptokens";
302 return false;
303 }
304 else if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0))
305 {
306 JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
307 "succeeded but created/deleted mptokens";
308 return false;
309 }
310 else if (
311 !submittedByIssuer && hasPrivilege(tx, MustAuthorizeMpt) &&
313 {
314 // if the holder submitted this tx, then a mptoken must be
315 // either created or deleted.
316 JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder "
317 "succeeded but created/deleted bad number of mptokens";
318 return false;
319 }
320
321 return true;
322 }
323
324 if (hasPrivilege(tx, MayCreateMpt))
325 {
326 bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
327
328 if (mptIssuancesCreated_ > 0)
329 {
330 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
331 "succeeded but created MPT issuances";
332 return false;
333 }
334 if (mptIssuancesDeleted_ > 0)
335 {
336 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
337 "succeeded but deleted issuances";
338 return false;
339 }
340 if (mptokensDeleted_ > 0)
341 {
342 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
343 "succeeded but deleted MPTokens";
344 return false;
345 }
346 // AMMCreate may auto-create up to two MPT objects:
347 // - one per asset side in an MPT/MPT AMM, or one in an IOU/MPT AMM.
348 // CheckCash may auto-create at most one MPT object for the receiver.
349 if ((txnType == ttAMM_CREATE && mptokensCreated_ > 2) ||
350 (txnType == ttCHECK_CASH && mptokensCreated_ > 1))
351 {
352 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
353 "succeeded but created bad number of mptokens";
354 return false;
355 }
356 if (submittedByIssuer)
357 {
358 JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
359 "succeeded but created mptokens";
360 return false;
361 }
362
363 // Offer crossing or payment may consume multiple offers
364 // where takerPays is MPT amount. If the offer owner doesn't
365 // own MPT then MPT is created automatically.
366 return true;
367 }
368
369 if (txnType == ttESCROW_FINISH)
370 {
371 // ttESCROW_FINISH may authorize an MPT, but it can't have the
372 // mayAuthorizeMPT privilege, because that may cause
373 // non-amendment-gated side effects.
374 XRPL_ASSERT_PARTS(
375 !enforceEscrowFinish, "xrpl::ValidMPTIssuance::finalize", "not escrow finish tx");
376 return true;
377 }
378
379 if (hasPrivilege(tx, MayDeleteMpt) &&
380 ((txnType == ttAMM_DELETE && mptokensDeleted_ <= 2) || mptokensDeleted_ == 1) &&
382 return true;
383 }
384
385 if (mptIssuancesCreated_ != 0)
386 {
387 JLOG(j.fatal()) << "Invariant failed: a MPT issuance was created";
388 }
389 else if (mptIssuancesDeleted_ != 0)
390 {
391 JLOG(j.fatal()) << "Invariant failed: a MPT issuance was deleted";
392 }
393 else if (mptokensCreated_ != 0)
394 {
395 JLOG(j.fatal()) << "Invariant failed: a MPToken was created";
396 }
397 else if (mptokensDeleted_ != 0)
398 {
399 JLOG(j.fatal()) << "Invariant failed: a MPToken was deleted";
400 }
401
402 return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0 && mptokensCreated_ == 0 &&
403 mptokensDeleted_ == 0;
404}
405
406void
408{
409 if (overflow_)
410 return;
411
412 auto makeKey = [](SLE const& sle) {
413 if (sle.getType() == ltMPTOKEN_ISSUANCE)
414 return makeMptID(sle[sfSequence], sle[sfIssuer]);
415 return sle[sfMPTokenIssuanceID];
416 };
417
418 auto update = [&](SLE const& sle, Order order) -> bool {
419 auto const type = sle.getType();
420 if (type == ltMPTOKEN_ISSUANCE)
421 {
422 auto const outstanding = sle[sfOutstandingAmount];
423 if (outstanding > kMaxMpTokenAmount)
424 {
425 overflow_ = true;
426 return false;
427 }
428 data_[makeKey(sle)].outstanding[static_cast<std::size_t>(order)] = outstanding;
429 }
430 else if (type == ltMPTOKEN)
431 {
432 auto const mptAmt = sle[sfMPTAmount];
433 auto const lockedAmt = sle[~sfLockedAmount].value_or(0);
434 if (mptAmt > kMaxMpTokenAmount || lockedAmt > kMaxMpTokenAmount ||
435 lockedAmt > (kMaxMpTokenAmount - mptAmt))
436 {
437 overflow_ = true;
438 return false;
439 }
440 auto const res = static_cast<std::int64_t>(mptAmt + lockedAmt);
441 // subtract before from after
442 if (order == Order::Before)
443 {
444 data_[makeKey(sle)].mptAmount -= res;
445 }
446 else
447 {
448 data_[makeKey(sle)].mptAmount += res;
449 }
450 }
451 return true;
452 };
453
454 if (before && !update(*before, Order::Before))
455 return;
456
457 if (after)
458 {
459 if (after->getType() == ltMPTOKEN_ISSUANCE)
460 {
461 overflow_ = (*after)[sfOutstandingAmount] > maxMPTAmount(*after);
462 }
463 if (!update(*after, Order::After))
464 return;
465 }
466}
467
468bool
470 STTx const& tx,
471 TER const result,
472 XRPAmount const,
473 ReadView const& view,
474 beast::Journal const& j)
475{
476 if (isTesSuccess(result))
477 {
478 // Confidential transactions are validated by ValidConfidentialMPToken.
479 // They modify encrypted fields and sfConfidentialOutstandingAmount
480 // rather than sfMPTAmount/sfOutstandingAmount in the standard way,
481 // so ValidMPTPayment's accounting does not apply to them.
482 if (std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
483 kConfidentialMptTxTypes.end())
484 {
485 return true;
486 }
487
488 bool const invariantPasses = !view.rules().enabled(featureMPTokensV2);
489 if (overflow_)
490 {
491 JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow";
492 return invariantPasses;
493 }
494
495 auto const signedMax = static_cast<std::int64_t>(kMaxMpTokenAmount);
496 for (auto const& [id, data] : data_)
497 {
498 (void)id;
499 static constexpr auto kIBefore = static_cast<std::size_t>(Order::Before);
500 static constexpr auto kIAfter = static_cast<std::size_t>(Order::After);
501 bool const addOverflows =
502 (data.mptAmount > 0 && data.outstanding[kIBefore] > (signedMax - data.mptAmount)) ||
503 (data.mptAmount < 0 && data.outstanding[kIBefore] < (-signedMax - data.mptAmount));
504 if (addOverflows ||
505 data.outstanding[kIAfter] != (data.outstanding[kIBefore] + data.mptAmount))
506 {
507 JLOG(j.fatal()) << "Invariant failed: invalid OutstandingAmount balance "
508 << data.outstanding[kIBefore] << " " << data.outstanding[kIAfter]
509 << " " << data.mptAmount;
510 return invariantPasses;
511 }
512 }
513 }
514
515 return true;
516}
517
518void
520 bool isDelete,
521 std::shared_ptr<SLE const> const& before,
523{
524 // Helper to get MPToken Issuance ID safely
525 auto const getMptID = [](std::shared_ptr<SLE const> const& sle) -> uint192 {
526 if (!sle)
527 return beast::kZero;
528 if (sle->getType() == ltMPTOKEN)
529 return sle->getFieldH192(sfMPTokenIssuanceID);
530 if (sle->getType() == ltMPTOKEN_ISSUANCE)
531 return makeMptID(sle->getFieldU32(sfSequence), sle->getAccountID(sfIssuer));
532 return beast::kZero;
533 };
534
535 if (before && before->getType() == ltMPTOKEN)
536 {
537 uint192 const id = getMptID(before);
538 auto& change = changes_[id];
539 change.mptAmountDelta =
540 subtractMPTAmountDelta(change.mptAmountDelta, before->getFieldU64(sfMPTAmount));
541
542 // Cannot delete MPToken with non-zero confidential state or non-zero public amount
543 if (isDelete)
544 {
545 bool const hasPublicBalance = before->getFieldU64(sfMPTAmount) > 0;
546 bool const hasEncryptedFields = before->isFieldPresent(sfConfidentialBalanceSpending) ||
547 before->isFieldPresent(sfConfidentialBalanceInbox) ||
548 before->isFieldPresent(sfIssuerEncryptedBalance) ||
549 before->isFieldPresent(sfAuditorEncryptedBalance);
550
551 if (hasPublicBalance || hasEncryptedFields)
552 changes_[id].deletedWithEncrypted = true;
553 }
554 }
555
556 if (after && after->getType() == ltMPTOKEN)
557 {
558 uint192 const id = getMptID(after);
559 auto& change = changes_[id];
560 change.mptAmountDelta =
561 addMPTAmountDelta(change.mptAmountDelta, after->getFieldU64(sfMPTAmount));
562
563 // Encrypted field existence consistency
564 bool const hasIssuerBalance = after->isFieldPresent(sfIssuerEncryptedBalance);
565 bool const hasHolderInbox = after->isFieldPresent(sfConfidentialBalanceInbox);
566 bool const hasHolderSpending = after->isFieldPresent(sfConfidentialBalanceSpending);
567 bool const hasAuditorBalance = after->isFieldPresent(sfAuditorEncryptedBalance);
568
569 // The core encrypted balances must all exist or not exist at the same time. The auditor
570 // balance is optional, but cannot exist without the core fields.
571 if (hasHolderInbox != hasHolderSpending || hasHolderInbox != hasIssuerBalance ||
572 (hasAuditorBalance && !hasIssuerBalance))
573 changes_[id].badConsistency = true;
574
575 auto const confidentialBalanceFieldChanged = [&before, &after](auto const& field) {
576 auto const afterValue = (*after)[~field];
577 if (!afterValue)
578 return false;
579
580 if (!before || before->getType() != ltMPTOKEN)
581 return true; // LCOV_EXCL_LINE
582
583 return (*before)[~field] != afterValue;
584 };
585
586 if (confidentialBalanceFieldChanged(sfConfidentialBalanceInbox) ||
587 confidentialBalanceFieldChanged(sfConfidentialBalanceSpending) ||
588 confidentialBalanceFieldChanged(sfIssuerEncryptedBalance) ||
589 confidentialBalanceFieldChanged(sfAuditorEncryptedBalance))
590 {
591 changes_[id].changesConfidentialFields = true;
592 }
593 }
594
595 if (before && before->getType() == ltMPTOKEN_ISSUANCE)
596 {
597 uint192 const id = getMptID(before);
598 auto& change = changes_[id];
599 if (before->isFieldPresent(sfConfidentialOutstandingAmount))
600 {
601 change.coaDelta = subtractMPTAmountDelta(
602 change.coaDelta, before->getFieldU64(sfConfidentialOutstandingAmount));
603 }
604 change.outstandingDelta = subtractMPTAmountDelta(
605 change.outstandingDelta, before->getFieldU64(sfOutstandingAmount));
606 }
607
608 if (after && after->getType() == ltMPTOKEN_ISSUANCE)
609 {
610 uint192 const id = getMptID(after);
611 auto& change = changes_[id];
612
613 bool const hasCOA = after->isFieldPresent(sfConfidentialOutstandingAmount);
614 std::uint64_t const coa = (*after)[~sfConfidentialOutstandingAmount].value_or(0);
615 std::uint64_t const oa = after->getFieldU64(sfOutstandingAmount);
616
617 if (hasCOA)
618 change.coaDelta = addMPTAmountDelta(change.coaDelta, coa);
619
620 change.outstandingDelta = addMPTAmountDelta(change.outstandingDelta, oa);
621 change.issuance = after;
622
623 // COA <= OutstandingAmount
624 if (coa > oa)
625 change.badCOA = true;
626 }
627
628 if (before && after && before->getType() == ltMPTOKEN && after->getType() == ltMPTOKEN)
629 {
630 uint192 const id = getMptID(after);
631
632 // sfConfidentialBalanceVersion must change when spending changes
633 auto const spendingBefore = (*before)[~sfConfidentialBalanceSpending];
634 auto const spendingAfter = (*after)[~sfConfidentialBalanceSpending];
635 auto const versionBefore = (*before)[~sfConfidentialBalanceVersion];
636 auto const versionAfter = (*after)[~sfConfidentialBalanceVersion];
637
638 if (spendingBefore.has_value() && spendingBefore != spendingAfter)
639 {
640 if (versionBefore == versionAfter)
641 changes_[id].badVersion = true;
642 }
643 }
644}
645
646bool
648 STTx const& tx,
649 TER const result,
650 XRPAmount const,
651 ReadView const& view,
652 beast::Journal const& j)
653{
654 if (result != tesSUCCESS)
655 return true;
656
657 for (auto const& [id, checks] : changes_)
658 {
659 // Find the MPTokenIssuance
660 auto const issuance = [&]() -> std::shared_ptr<SLE const> {
661 if (checks.issuance)
662 return checks.issuance;
663 return view.read(keylet::mptokenIssuance(id));
664 }();
665
666 // Skip all invariance checks if issuance doesn't exist because that means the MPT has been
667 // deleted
668 if (!issuance)
669 continue;
670
671 // Cannot delete MPToken with non-zero confidential state
672 if (checks.deletedWithEncrypted)
673 {
674 if ((*issuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
675 {
676 JLOG(j.fatal())
677 << "Invariant failed: MPToken deleted with encrypted fields while COA > 0";
678 return false;
679 }
680 }
681
682 // Encrypted field existence consistency
683 if (checks.badConsistency)
684 {
685 JLOG(j.fatal()) << "Invariant failed: MPToken encrypted field "
686 "existence inconsistency";
687 return false;
688 }
689
690 // COA <= OutstandingAmount
691 if (checks.badCOA)
692 {
693 JLOG(j.fatal()) << "Invariant failed: Confidential outstanding amount "
694 "exceeds total outstanding amount";
695 return false;
696 }
697
698 // Confidential balance fields may remain on a holder MPToken after all
699 // confidential balances have returned to zero. Only creating or
700 // changing those fields requires the issuance privacy flag.
701 if (checks.changesConfidentialFields)
702 {
703 if (!issuance->isFlag(lsfMPTCanHoldConfidentialBalance))
704 {
705 JLOG(j.fatal()) << "Invariant failed: MPToken has encrypted "
706 "fields but Issuance does not have "
707 "lsfMPTCanHoldConfidentialBalance set";
708 return false;
709 }
710 }
711
712 // We only enforce this when Confidential Outstanding Amount changes (Convert, ConvertBack,
713 // ConfidentialClawback). This avoids falsely failing on Escrow or AMM operations that lock
714 // public tokens outside of ltMPTOKEN. Convert / ConvertBack:
715 // - COA and MPTAmount must have opposite deltas, which cancel each other out to zero.
716 // - OA remains unchanged.
717 // - Therefore, the net delta on both sides of the equation is zero.
718 //
719 // Clawback:
720 // - MPTAmount remains unchanged.
721 // - COA and OA must have identical deltas (mirrored on each side).
722 // - The equation remains balanced as both sides have equal offsets.
723 if (checks.coaDelta != 0)
724 {
725 if (checks.mptAmountDelta + checks.coaDelta != checks.outstandingDelta)
726 {
727 JLOG(j.fatal()) << "Invariant failed: Token conservation "
728 "violation for MPT "
729 << to_string(id);
730 return false;
731 }
732 }
733 else if (
734 std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
735 kConfidentialMptTxTypes.end())
736 {
737 // Confidential Txns should not modify public MPTAmount balance
738 // if Confidential Amount Delta is 0
739 if (checks.mptAmountDelta != 0)
740 {
741 JLOG(j.fatal()) << "Invariant failed: MPTAmount changed by confidential "
742 "transaction that should not modify this field."
743 << to_string(id);
744 return false;
745 }
746
747 // Among confidential MPT transactions, only ConfidentialMPTSend and
748 // ConfidentialMPTMergeInbox leave coaDelta unmodified. Therefore, if a confidential MPT
749 // transaction reaches here, it must be one of these two types, neither of which will
750 // modify sfOutstandingAmount
751 if (checks.outstandingDelta != 0)
752 {
753 JLOG(j.fatal()) << "Invariant failed: OutstandingAmount changed "
754 "by confidential transaction that should not "
755 "modify it for MPT "
756 << to_string(id);
757 return false;
758 }
759 }
760
761 if (checks.badVersion)
762 {
763 JLOG(j.fatal())
764 << "Invariant failed: MPToken sfConfidentialBalanceVersion not updated when "
765 "sfConfidentialBalanceSpending changed";
766 return false;
767 }
768 }
769
770 return true;
771}
772
773void
775 bool isDelete,
776 std::shared_ptr<SLE const> const& before,
778{
779 // Record the before/after MPTAmount for each (issuanceID, account) pair
780 // so finalize() can determine whether a transfer actually occurred.
781 auto update = [&](SLE const& sle, bool isBefore) {
782 if (sle.getType() == ltMPTOKEN)
783 {
784 auto const issuanceID = sle[sfMPTokenIssuanceID];
785 auto const account = sle[sfAccount];
786 auto const amount = sle[sfMPTAmount];
787 if (isBefore)
788 {
789 amount_[issuanceID][account].amtBefore = amount;
790 }
791 else
792 {
793 amount_[issuanceID][account].amtAfter = amount;
794 }
795 if (isDelete && isBefore)
796 {
797 deletedAuthorized_[sle.key()] = sle.isFlag(lsfMPTAuthorized);
798 }
799 }
800 };
801
802 if (before)
803 update(*before, true);
804
805 if (after)
806 update(*after, false);
807}
808
809bool
811 ReadView const& view,
812 MPTID const& mptid,
813 AccountID const& holder,
814 bool reqAuth) const
815{
816 // Pseudo-accounts (Vault, LoanBroker, AMM) hold assets on behalf of their
817 // participants and are implicitly authorized for any MPT they hold,
818 // including vault shares whose underlying asset would otherwise require
819 // auth. Exempt them here rather than relying on requireAuth: the recursive
820 // share -> underlying descent in requireAuth fails for a pseudo-account
821 // that holds the share but not the underlying.
822 if (isPseudoAccount(view, holder, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
823 return true;
824
825 auto const key = keylet::mptoken(mptid, holder);
826 auto const it = deletedAuthorized_.find(key.key);
827 if (it != deletedAuthorized_.end())
828 return !reqAuth || it->second;
829 return isTesSuccess(requireAuth(view, MPTIssue{mptid}, holder));
830}
831
832bool
834 STTx const& tx,
835 TER const,
836 XRPAmount const,
837 ReadView const& view,
838 beast::Journal const& j)
839{
841 return true;
842
843 // DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are
844 // subject to the MPTCanTrade flag in addition to the standard transfer rules.
845 // A payment is only DEX if it is a cross-currency payment.
846 auto const txnType = tx.getTxnType();
847 auto const isDEX = [&] {
848 if (txnType == ttPAYMENT)
849 {
850 // A payment is cross-currency (and thus DEX) only if SendMax is present
851 // and its asset differs from the destination asset.
852 auto const amount = tx[sfAmount];
853 return tx[~sfSendMax].value_or(amount).asset() != amount.asset();
854 }
855 return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE;
856 }();
857
858 // Only enforce once MPTokensV2 is enabled to preserve consensus with non-V2 nodes.
859 // Log invariant failure error even if MPTokensV2 is disabled.
860 auto const invariantPasses = !view.rules().enabled(featureMPTokensV2);
861
862 for (auto const& [mptID, values] : amount_)
863 {
864 std::uint16_t senders = 0;
865 std::uint16_t receivers = 0;
866 bool invalidTransfer = false;
867 auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID));
868 if (!sleIssuance)
869 {
870 continue;
871 }
872
873 // These transactions are recovery/settlement paths. They may move an
874 // existing MPT position even after the issuer clears CanTransfer, so
875 // holders are not trapped in AMM, vault, or loan protocol accounts.
876 auto const waivesCanTransfer = txnType == ttAMM_WITHDRAW ||
877 (view.rules().enabled(fixCleanup3_2_0) &&
878 (txnType == ttVAULT_WITHDRAW || txnType == ttLOAN_BROKER_COVER_WITHDRAW ||
879 txnType == ttLOAN_PAY));
880 auto const canTransfer = sleIssuance->isFlag(lsfMPTCanTransfer) || waivesCanTransfer;
881 auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade);
882 auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth);
883
884 for (auto const& [account, value] : values)
885 {
886 // Classify each account as a sender or receiver based on whether their MPTAmount
887 // decreased or increased. Count new MPToken holders (no amtBefore) as receivers.
888 // Skip deleted MPToken holders (amtAfter is nullopt); deletion requires zero balance.
889 if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
890 {
891 if (!value.amtBefore.has_value() || *value.amtAfter > *value.amtBefore)
892 {
893 ++receivers;
894 }
895 else
896 {
897 ++senders;
898 }
899
900 // Check once: if any involved account is frozen, the whole issuance transfer is
901 // considered frozen. Only need to check for frozen if there is a transfer of funds.
902 if (!invalidTransfer &&
903 (isFrozen(view, account, MPTIssue{mptID}) ||
904 !isAuthorized(view, mptID, account, reqAuth)))
905 {
906 invalidTransfer = true;
907 }
908 }
909 }
910 // A transfer between holders has occurred (senders > 0 && receivers > 0).
911 // Fail if the issuance is frozen, does not permit transfers, or — for
912 // DEX transactions — does not permit trading.
913 if ((invalidTransfer || !canTransfer || (isDEX && !canTrade)) && senders > 0 &&
914 receivers > 0)
915 {
916 JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders";
917 return invariantPasses;
918 }
919 }
920
921 return true;
922}
923
924} // namespace xrpl
A generic endpoint for log messages.
Definition Journal.h:44
Stream fatal() const
Definition Journal.h:368
AccountID const & getIssuer() const
Definition MPTIssue.cpp:29
A view into a ledger.
Definition ReadView.h:41
virtual Rules const & rules() const =0
Returns the tx processing rules.
virtual SLE::const_pointer read(Keylet const &k) const =0
Return the state item associated with a key.
bool enabled(uint256 const &feature) const
Returns true if a feature is enabled.
Definition Rules.cpp:180
uint256 const & key() const
Returns the 'key' (or 'index') of this item.
LedgerEntryType getType() const
std::shared_ptr< STLedgerEntry const > const & const_ref
bool isFlag(std::uint32_t) const
Definition STObject.cpp:511
bool isFieldPresent(SField const &field) const
Definition STObject.cpp:464
TxType getTxnType() const
Definition STTx.h:226
std::map< uint192, Changes > changes_
void visitEntry(bool isDelete, std::shared_ptr< SLE const > const &before, std::shared_ptr< SLE const > const &after)
Track confidential MPT balance, issuance, and version changes.
bool finalize(STTx const &tx, TER const result, XRPAmount const fee, ReadView const &view, beast::Journal const &j)
Verify confidential MPT accounting and encrypted-field invariants.
void visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
Track MPT amount and outstanding amount changes.
bool finalize(STTx const &tx, TER const result, XRPAmount const fee, ReadView const &view, beast::Journal const &j)
Verify public MPT payment accounting invariants.
hash_map< uint192, MPTData > data_
std::uint32_t mptokensCreated_
bool referenceHoldingSetOnCreate_
sfReferenceHolding is intended to be set exactly once at vault creation and immutable thereafter; tru...
std::uint32_t mptokensDeleted_
bool finalize(STTx const &tx, TER const result, XRPAmount const fee, ReadView const &view, beast::Journal const &j) const
Verify MPT issuance invariants after transaction application.
bool referenceHoldingMutated_
True when sfReferenceHolding was mutated on an existing MPTokenIssuance.
std::uint32_t mptIssuancesCreated_
void visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
Track MPT issuance and holding creations, deletions, and mutations.
std::vector< std::shared_ptr< SLE const > > deletedHoldings_
MPTokens and RippleStates deleted during apply.
std::uint32_t mptIssuancesDeleted_
hash_map< uint256, bool > deletedAuthorized_
bool isAuthorized(ReadView const &view, MPTID const &mptid, AccountID const &holder, bool requireAuth) const
Check whether a holder is authorized to send or receive an MPToken.
hash_map< uint192, hash_map< AccountID, Value > > amount_
void visitEntry(bool isDelete, std::shared_ptr< SLE const > const &before, std::shared_ptr< SLE const > const &after)
Track MPT balance changes and deleted authorization state.
bool finalize(STTx const &tx, TER const result, XRPAmount const fee, ReadView const &view, beast::Journal const &j)
Verify MPT transfer authorization invariants.
T find(T... args)
T min(T... args)
constexpr Zero kZero
Definition Zero.h:30
Keylet mptoken(MPTID const &issuanceID, AccountID const &holder) noexcept
Definition Indexes.cpp:543
Keylet account(AccountID const &id) noexcept
AccountID root.
Definition Indexes.cpp:198
Keylet mptokenIssuance(MPTID const &issuanceID) noexcept
Definition Indexes.cpp:537
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
BaseUInt< 192 > uint192
Definition base_uint.h:581
std::int64_t maxMPTAmount(SLE const &sleIssuance)
bool isFeatureEnabled(uint256 const &feature, bool resultIfNoRules)
Check whether a feature is enabled in the current ledger rules.
Definition Rules.cpp:197
TER canTransfer(ReadView const &view, MPTIssue const &mptIssue, AccountID const &from, AccountID const &to, WaiveMPTCanTransfer waive=WaiveMPTCanTransfer::No, std::uint8_t depth=0)
Check whether to may receive the given MPT from from.
std::string to_string(BaseUInt< Bits, Tag > const &a)
Definition base_uint.h:651
TER canTrade(ReadView const &view, Asset const &asset, std::uint8_t depth=0)
Check whether asset may be traded on the DEX.
STLedgerEntry SLE
bool hasPrivilege(STTx const &tx, Privilege priv)
BaseUInt< 192 > MPTID
MPTID is a 192-bit value representing MPT Issuance ID, which is a concatenation of a 32-bit sequence ...
Definition UintTypes.h:54
bool after(NetClock::time_point now, std::uint32_t mark)
Has the specified time passed?
Definition View.cpp:572
bool isFrozen(ReadView const &view, AccountID const &account, MPTIssue const &mptIssue, std::uint8_t depth=0)
MPTID makeMptID(std::uint32_t const sequence, AccountID const &account)
Definition Indexes.cpp:184
BaseUInt< 160, detail::AccountIDTag > AccountID
A 160-bit unsigned that uniquely identifies an account.
Definition AccountID.h:34
bool isTesSuccess(TER x) noexcept
Definition TER.h:676
TERSubset< CanCvtToTER > TER
Definition TER.h:647
TER requireAuth(ReadView const &view, MPTIssue const &mptIssue, AccountID const &account, AuthType authType=AuthType::Legacy, std::uint8_t depth=0)
Check if the account lacks required authorization for MPT.
@ tecINCOMPLETE
Definition TER.h:338
bool isPseudoAccount(SLE::const_pointer sleAcct, std::set< SField const * > const &pseudoFieldFilter={})
Returns true if and only if sleAcct is a pseudo-account or specific pseudo-accounts in pseudoFieldFil...
constexpr std::uint64_t kMaxMpTokenAmount
The maximum amount of MPTokenIssuance.
Definition Protocol.h:296
@ tesSUCCESS
Definition TER.h:245