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/View.h>
10#include <xrpl/ledger/helpers/AccountRootHelpers.h>
11#include <xrpl/ledger/helpers/MPTokenHelpers.h>
12#include <xrpl/protocol/AccountID.h>
13#include <xrpl/protocol/Feature.h>
14#include <xrpl/protocol/Indexes.h>
15#include <xrpl/protocol/LedgerFormats.h>
16#include <xrpl/protocol/MPTIssue.h>
17#include <xrpl/protocol/Protocol.h>
18#include <xrpl/protocol/Rules.h>
19#include <xrpl/protocol/SField.h>
20#include <xrpl/protocol/STLedgerEntry.h>
21#include <xrpl/protocol/STTx.h>
22#include <xrpl/protocol/TER.h>
23#include <xrpl/protocol/TxFormats.h>
24#include <xrpl/protocol/UintTypes.h>
25#include <xrpl/protocol/XRPAmount.h>
26#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
27
28#include <algorithm>
29#include <array>
30#include <cstddef>
31#include <cstdint>
32#include <memory>
33
34namespace xrpl {
35
36namespace {
37constexpr auto kConfidentialMptTxTypes = std::to_array<TxType>({
38 ttCONFIDENTIAL_MPT_SEND,
39 ttCONFIDENTIAL_MPT_CONVERT,
40 ttCONFIDENTIAL_MPT_CONVERT_BACK,
41 ttCONFIDENTIAL_MPT_MERGE_INBOX,
42 ttCONFIDENTIAL_MPT_CLAWBACK,
43});
44
45// Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant
46// tests can inject INT64_MAX + 1, which would result in undefined behavior
47// under UBSan if converted directly.
48std::int64_t
49toSignedMPTAmount(std::uint64_t amount)
50{
51 return static_cast<std::int64_t>(std::min(amount, kMaxMpTokenAmount));
52}
53
54std::int64_t
55addMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
56{
57 return delta + toSignedMPTAmount(amount);
58}
59
60std::int64_t
61subtractMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
62{
63 return delta - toSignedMPTAmount(amount);
64}
65
66} // namespace
67
68void
70{
71 // The sfReferenceHolding tracking and the deleted-holding capture are
72 // only meaningful post-fixCleanup3_2_0 (the field is never set
73 // pre-amendment, and the holding-deletion rule does not apply).
74 // Skip both blocks when the amendment is off so we avoid wasted work
75 // on the hot path.
76 bool const fix320Enabled = isFeatureEnabled(fixCleanup3_2_0);
77
78 if (after && after->getType() == ltMPTOKEN_ISSUANCE)
79 {
80 if (isDelete)
81 {
83 }
84 else if (!before)
85 {
87 if (fix320Enabled && after->isFieldPresent(sfReferenceHolding))
89 }
90 else if (fix320Enabled)
91 {
92 // Modified issuance: detect any change to sfReferenceHolding.
93 bool const beforePresent = before->isFieldPresent(sfReferenceHolding);
94 bool const afterPresent = after->isFieldPresent(sfReferenceHolding);
95 if (beforePresent != afterPresent ||
96 (afterPresent &&
97 before->getFieldH256(sfReferenceHolding) !=
98 after->getFieldH256(sfReferenceHolding)))
99 {
101 }
102 }
103 }
104
105 if (after && after->getType() == ltMPTOKEN)
106 {
107 if (isDelete)
108 {
110 if (fix320Enabled)
111 deletedHoldings_.push_back(after);
112 }
113 else if (!before)
114 {
116 MPTIssue const mptIssue{after->at(sfMPTokenIssuanceID)};
117 if (mptIssue.getIssuer() == after->at(sfAccount))
118 mptCreatedByIssuer_ = true;
119 }
120 }
121
122 // Capture deleted RippleState SLEs so finalize() can verify none of
123 // them were owned by a vault pseudo-account outside VaultDelete.
124 if (fix320Enabled && isDelete && after && after->getType() == ltRIPPLE_STATE)
125 deletedHoldings_.push_back(after);
126}
127
128bool
130 STTx const& tx,
131 TER const result,
132 XRPAmount const fee,
133 ReadView const& view,
134 beast::Journal const& j) const
135{
136 auto const& rules = view.rules();
137 bool const mptV2Enabled = rules.enabled(featureMPTokensV2);
138
139 // Post-fixCleanup3_2_0:
140 // - sfReferenceHolding is set only by VaultCreate at share-issuance
141 // creation, and is immutable thereafter.
142 // - A vault pseudo-account's MPToken or RippleState may only be
143 // deleted by VaultDelete; the share's sfReferenceHolding pointer
144 // must not dangle outside that controlled lifecycle.
145 if (rules.enabled(fixCleanup3_2_0))
146 {
147 bool invariantPasses = true;
149 {
150 JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding was modified "
151 "on an existing MPTokenIssuance";
152 invariantPasses = false;
153 }
154 if (referenceHoldingSetOnCreate_ && tx.getTxnType() != ttVAULT_CREATE)
155 {
156 JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding set on a new "
157 "MPTokenIssuance by a non-VaultCreate transaction";
158 invariantPasses = false;
159 }
160 if (!deletedHoldings_.empty() && tx.getTxnType() != ttVAULT_DELETE)
161 {
162 auto const isVaultPseudo = [&](AccountID const& acct) {
163 auto const sle = view.read(keylet::account(acct));
164 return sle && sle->isFieldPresent(sfVaultID);
165 };
166 for (auto const& sleHolding : deletedHoldings_)
167 {
168 bool offending = false;
169 if (sleHolding->getType() == ltMPTOKEN)
170 {
171 offending = isVaultPseudo(sleHolding->at(sfAccount));
172 }
173 else // ltRIPPLE_STATE
174 {
175 auto const lowLimit = sleHolding->getFieldAmount(sfLowLimit);
176 auto const highLimit = sleHolding->getFieldAmount(sfHighLimit);
177 // Each limit's STAmount.issuer is the COUNTERPARTY of
178 // that side's owner: lowLimit's issuer is the high
179 // account, highLimit's issuer is the low account.
180 offending =
181 isVaultPseudo(lowLimit.getIssuer()) || isVaultPseudo(highLimit.getIssuer());
182 }
183 if (offending)
184 {
185 JLOG(j.fatal()) << "Invariant failed: vault pseudo-account holding "
186 "deleted by a non-VaultDelete transaction";
187 invariantPasses = false;
188 }
189 }
190 }
191 if (!invariantPasses)
192 return false;
193 }
194
195 if (isTesSuccess(result) || (mptV2Enabled && result == tecINCOMPLETE))
196 {
197 [[maybe_unused]]
198 bool const enforceCreatedByIssuer =
199 rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol);
201 {
202 JLOG(j.fatal()) << "Invariant failed: MPToken created for the MPT issuer";
203 // The comment above starting with "assert(enforce)" explains this
204 // assert.
205 XRPL_ASSERT_PARTS(
206 enforceCreatedByIssuer, "xrpl::ValidMPTIssuance::finalize", "no issuer MPToken");
207 if (enforceCreatedByIssuer)
208 return false;
209 }
210
211 auto const txnType = tx.getTxnType();
213 {
214 if (mptIssuancesCreated_ == 0)
215 {
216 JLOG(j.fatal()) << "Invariant failed: transaction "
217 "succeeded without creating a MPT issuance";
218 }
219 else if (mptIssuancesDeleted_ != 0)
220 {
221 JLOG(j.fatal()) << "Invariant failed: transaction "
222 "succeeded while removing MPT issuances";
223 }
224 else if (mptIssuancesCreated_ > 1)
225 {
226 JLOG(j.fatal()) << "Invariant failed: transaction "
227 "succeeded but created multiple issuances";
228 }
229
230 return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
231 }
232
234 {
235 if (mptIssuancesDeleted_ == 0)
236 {
237 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
238 "succeeded without removing a MPT issuance";
239 }
240 else if (mptIssuancesCreated_ > 0)
241 {
242 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
243 "succeeded while creating MPT issuances";
244 }
245 else if (mptIssuancesDeleted_ > 1)
246 {
247 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
248 "succeeded but deleted multiple issuances";
249 }
250
251 return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 1;
252 }
253
254 bool const lendingProtocolEnabled = rules.enabled(featureLendingProtocol);
255 // ttESCROW_FINISH may authorize an MPT, but it can't have the
256 // mayAuthorizeMPT privilege, because that may cause
257 // non-amendment-gated side effects.
258 bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) &&
259 (rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled);
260 if (hasPrivilege(tx, MustAuthorizeMpt | MayAuthorizeMpt) || enforceEscrowFinish)
261 {
262 bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
263
264 if (mptIssuancesCreated_ > 0)
265 {
266 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
267 "succeeded but created MPT issuances";
268 return false;
269 }
270 if (mptIssuancesDeleted_ > 0)
271 {
272 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
273 "succeeded but deleted issuances";
274 return false;
275 }
276 if (mptV2Enabled && hasPrivilege(tx, MayAuthorizeMpt) &&
277 (txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK))
278 {
279 if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0)
280 {
281 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
282 "submitted by issuer succeeded "
283 "but created bad number of mptokens";
284 return false;
285 }
286 // At most one MPToken may be created on withdraw/clawback since:
287 // - Liquidity Provider must have at least one token in order
288 // participate in AMM pool liquidity.
289 // - At most two MPTokens may be deleted if AMM pool, which has exactly
290 // two tokens, is empty after withdraw/clawback.
291 if (mptokensCreated_ > 1 || 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{
840 auto const fix330Enabled = view.rules().enabled(fixCleanup3_3_0);
841
843 return true;
844
845 // DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are
846 // subject to the MPTCanTrade flag in addition to the standard transfer rules.
847 // A payment is only DEX if it is a cross-currency payment.
848 auto const txnType = tx.getTxnType();
849 auto const isDEX = [&] {
850 if (txnType == ttPAYMENT)
851 {
852 // A payment is cross-currency (and thus DEX) only if SendMax is present
853 // and its asset differs from the destination asset.
854 auto const amount = tx[sfAmount];
855 return tx[~sfSendMax].value_or(amount).asset() != amount.asset();
856 }
857 return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE;
858 }();
859
860 // Only enforce once MPTokensV2 is enabled to preserve consensus with non-V2 nodes.
861 // Log invariant failure error even if MPTokensV2 is disabled.
862 auto const invariantPasses = !view.rules().enabled(featureMPTokensV2);
863
864 for (auto const& [mptID, values] : amount_)
865 {
866 std::uint16_t senders = 0;
867 std::uint16_t receivers = 0;
868 bool invalidTransfer = false;
869 auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID));
870 if (!sleIssuance)
871 {
872 continue;
873 }
874
875 // These transactions are recovery/settlement paths. They may move an
876 // existing MPT position even after the issuer clears CanTransfer, so
877 // holders are not trapped in AMM, vault, or loan protocol accounts.
878 auto const waivesCanTransfer = txnType == ttAMM_WITHDRAW ||
879 (view.rules().enabled(fixCleanup3_2_0) &&
880 (txnType == ttVAULT_WITHDRAW || txnType == ttLOAN_BROKER_COVER_WITHDRAW ||
881 txnType == ttLOAN_PAY));
882 auto const canTransfer = sleIssuance->isFlag(lsfMPTCanTransfer) || waivesCanTransfer;
883 auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade);
884 auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth);
885
886 for (auto const& [account, value] : values)
887 {
888 // Classify each account as a sender or receiver based on whether their MPTAmount
889 // decreased or increased. Count new MPToken holders (no amtBefore) as receivers.
890 // Skip deleted MPToken holders (amtAfter is nullopt); deletion requires zero balance.
891 if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
892 {
893 if (!value.amtBefore.has_value() || *value.amtAfter > *value.amtBefore)
894 {
895 ++receivers;
896 }
897 else
898 {
899 ++senders;
900 }
901
902 // Check once: if any involved account is frozen, the whole issuance transfer is
903 // considered frozen. Only need to check for frozen if there is a transfer of funds.
904 //
905 // Post-fix330: full isFrozen() applies — vault-share transitive freeze is part of
906 // the freeze semantics for all changed holders.
907 //
908 // Pre-fix330: legacy AMM withdraw only checked individual freeze on the
909 // destination, not the transitive vault freeze. All other paths (and the AMM
910 // account itself as sender) did apply the full check.
911 MPTIssue const issue{mptID};
912 auto const legacyAccountFrozen = [&] {
913 if (isGlobalFrozen(view, issue) || isIndividualFrozen(view, account, issue))
914 return true;
915 bool const isReceiver =
916 !value.amtBefore.has_value() || *value.amtAfter > *value.amtBefore;
917 if (txnType == ttAMM_WITHDRAW && isReceiver)
918 return false;
919 return isVaultPseudoAccountFrozen(view, account, issue, 0);
920 };
921 bool const accountFrozen =
922 fix330Enabled ? isFrozen(view, account, issue) : legacyAccountFrozen();
923 if (!invalidTransfer &&
924 (accountFrozen || !isAuthorized(view, mptID, account, reqAuth)))
925 {
926 invalidTransfer = true;
927 }
928 }
929 }
930 // A transfer between holders has occurred (senders > 0 && receivers > 0).
931 // Fail if the issuance is frozen, does not permit transfers, or — for
932 // DEX transactions — does not permit trading.
933 if ((invalidTransfer || !canTransfer || (isDEX && !canTrade)) && senders > 0 &&
934 receivers > 0)
935 {
936 JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders";
937 return invariantPasses;
938 }
939 }
940
941 return true;
942}
943
944} // namespace xrpl
A generic endpoint for log messages.
Definition Journal.h:38
Stream fatal() const
Definition Journal.h:321
AccountID const & getIssuer() const
Definition MPTIssue.cpp:29
A view into a ledger.
Definition ReadView.h:31
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:171
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:501
bool isFieldPresent(SField const &field) const
Definition STObject.cpp:454
TxType getTxnType() const
Definition STTx.h:188
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.
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< uint192, MPTData > data_
bool finalize(STTx const &tx, TER const result, XRPAmount const fee, ReadView const &view, beast::Journal const &j)
Verify public MPT payment accounting invariants.
void visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
Track MPT amount and outstanding amount changes.
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)
Keylet mptokenIssuance(std::uint32_t seq, AccountID const &issuer) noexcept
Definition Indexes.cpp:521
Keylet mptoken(MPTID const &issuanceID, AccountID const &holder) noexcept
Definition Indexes.cpp:533
Keylet account(AccountID const &id) noexcept
AccountID root.
Definition Indexes.cpp:186
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:563
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:194
bool isIndividualFrozen(ReadView const &view, AccountID const &account, MPTIssue const &mptIssue)
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:633
TER canTrade(ReadView const &view, Asset const &asset, std::uint8_t depth=0)
Check whether asset may be traded on the DEX.
bool isVaultPseudoAccountFrozen(ReadView const &view, AccountID const &account, MPTIssue const &mptShare, std::uint8_t depth)
Definition View.cpp:56
STLedgerEntry SLE
bool isGlobalFrozen(ReadView const &view, AccountID const &issuer)
Check if the issuer has the global freeze flag set.
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:44
bool after(NetClock::time_point now, std::uint32_t mark)
Has the specified time passed?
Definition View.cpp:554
bool isFrozen(ReadView const &view, AccountID const &account, MPTIssue const &mptIssue, std::uint8_t depth=0)
BaseUInt< 160, detail::AccountIDTag > AccountID
A 160-bit unsigned that uniquely identifies an account.
Definition AccountID.h:28
bool isTesSuccess(TER x) noexcept
Definition TER.h:663
TERSubset< CanCvtToTER > TER
Definition TER.h:634
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:333
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:238
MPTID makeMptID(std::uint32_t sequence, AccountID const &account)
Definition Indexes.cpp:172
@ tesSUCCESS
Definition TER.h:240