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/LendingHelpers.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 ttCONFIDENTIAL_MPT_MIRROR_UPDATE,
44 ttCONFIDENTIAL_MPT_HOLDER_KEY_UPDATE,
45});
46
47// Clamp to the cap (== INT64_MAX) before the signed conversion. Invariant
48// tests can inject INT64_MAX + 1, which would result in undefined behavior
49// under UBSan if converted directly.
50std::int64_t
51toSignedMPTAmount(std::uint64_t amount)
52{
53 return static_cast<std::int64_t>(std::min(amount, kMaxMpTokenAmount));
54}
55
56std::int64_t
57addMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
58{
59 return delta + toSignedMPTAmount(amount);
60}
61
62std::int64_t
63subtractMPTAmountDelta(std::int64_t delta, std::uint64_t amount)
64{
65 return delta - toSignedMPTAmount(amount);
66}
67
68} // namespace
69
70void
72{
73 // The sfReferenceHolding tracking and the deleted-holding capture are
74 // only meaningful post-fixCleanup3_2_0 (the field is never set
75 // pre-amendment, and the holding-deletion rule does not apply).
76 // Skip both blocks when the amendment is off so we avoid wasted work
77 // on the hot path, except where noted for fixCleanup3_5_0 below.
78 bool const fix320Enabled = isFeatureEnabled(fixCleanup3_2_0);
79
80 if (after && after->getType() == ltMPTOKEN_ISSUANCE)
81 {
82 if (isDelete)
83 {
85 }
86 else if (!before)
87 {
89 if (fix320Enabled && after->isFieldPresent(sfReferenceHolding))
91 }
92 else
93 {
94 // lsfMPTLocked is the only issuance flag with a legal clear path
95 // (tfMPTUnlock); the rest are fixed at creation or set-once.
96 issuanceFlagsCleared_ |= before->getFlags() & ~after->getFlags() & ~lsfMPTLocked;
97
98 if (fix320Enabled)
99 {
100 // Modified issuance: detect any change to sfReferenceHolding.
101 bool const beforePresent = before->isFieldPresent(sfReferenceHolding);
102 bool const afterPresent = after->isFieldPresent(sfReferenceHolding);
103 if (beforePresent != afterPresent ||
104 (afterPresent &&
105 before->getFieldH256(sfReferenceHolding) !=
106 after->getFieldH256(sfReferenceHolding)))
107 {
109 }
110 }
111 }
112 }
113
114 if (after && after->getType() == ltMPTOKEN)
115 {
116 if (isDelete)
117 {
119 // deletedHoldings_ also feeds finalize()'s erase-time public
120 // balance check, gated on fixCleanup3_5_0 independently of
121 // fixCleanup3_2_0.
122 if (fix320Enabled || isFeatureEnabled(fixCleanup3_5_0))
123 deletedHoldings_.push_back(after);
124 }
125 else if (!before)
126 {
128 MPTIssue const mptIssue{after->at(sfMPTokenIssuanceID)};
129 if (mptIssue.getIssuer() == after->at(sfAccount))
130 mptCreatedByIssuer_ = true;
131 }
132 }
133
134 // Capture deleted RippleState SLEs so finalize() can verify none of
135 // them were owned by a vault pseudo-account outside VaultDelete.
136 if (fix320Enabled && isDelete && after && after->getType() == ltRIPPLE_STATE)
137 deletedHoldings_.push_back(after);
138}
139
140bool
142 STTx const& tx,
143 TER const result,
144 XRPAmount const fee,
145 ReadView const& view,
146 beast::Journal const& j) const
147{
148 auto const& rules = view.rules();
149 bool const mptV2Enabled = rules.enabled(featureMPTokensV2);
150
151 // Post-fixCleanup3_2_0:
152 // - sfReferenceHolding is set only by VaultCreate at share-issuance
153 // creation, and is immutable thereafter.
154 // - A vault pseudo-account's MPToken or RippleState may only be
155 // deleted by VaultDelete; the share's sfReferenceHolding pointer
156 // must not dangle outside that controlled lifecycle.
157 if (rules.enabled(fixCleanup3_2_0))
158 {
159 // Not an amendment gate like the same-named flags below, just an
160 // accumulator, so that every violation gets logged before returning.
161 bool invariantPasses = true;
163 {
164 JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding was modified "
165 "on an existing MPTokenIssuance";
166 invariantPasses = false;
167 }
168 if (referenceHoldingSetOnCreate_ && tx.getTxnType() != ttVAULT_CREATE)
169 {
170 JLOG(j.fatal()) << "Invariant failed: sfReferenceHolding set on a new "
171 "MPTokenIssuance by a non-VaultCreate transaction";
172 invariantPasses = false;
173 }
174 if (!deletedHoldings_.empty() && tx.getTxnType() != ttVAULT_DELETE)
175 {
176 auto const isVaultPseudo = [&](AccountID const& acct) {
177 auto const sle = view.read(keylet::account(acct));
178 return sle && sle->isFieldPresent(sfVaultID);
179 };
180 for (auto const& sleHolding : deletedHoldings_)
181 {
182 bool offending = false;
183 if (sleHolding->getType() == ltMPTOKEN)
184 {
185 offending = isVaultPseudo(sleHolding->at(sfAccount));
186 }
187 else // ltRIPPLE_STATE
188 {
189 auto const lowLimit = sleHolding->getFieldAmount(sfLowLimit);
190 auto const highLimit = sleHolding->getFieldAmount(sfHighLimit);
191 // Each limit's STAmount.issuer is the COUNTERPARTY of
192 // that side's owner: lowLimit's issuer is the high
193 // account, highLimit's issuer is the low account.
194 offending =
195 isVaultPseudo(lowLimit.getIssuer()) || isVaultPseudo(highLimit.getIssuer());
196 }
197 if (offending)
198 {
199 JLOG(j.fatal()) << "Invariant failed: vault pseudo-account holding "
200 "deleted by a non-VaultDelete transaction";
201 invariantPasses = false;
202 }
203 }
204 }
205 if (!invariantPasses)
206 return false;
207 }
208
209 // Post-fixCleanup3_5_0: no transaction may clear an issuance flag other
210 // than lsfMPTLocked, so downstream code can trust set-once flags such as
211 // lsfMPTCanTransfer.
212 if (rules.enabled(fixCleanup3_5_0) && issuanceFlagsCleared_ != 0)
213 {
214 JLOG(j.fatal()) << "Invariant failed: immutable MPTokenIssuance flag cleared: "
216 return false;
217 }
218
219 // Deleting an MPToken with a non-zero MPTAmount is rejected.
220 if (rules.enabled(fixCleanup3_5_0))
221 {
222 for (auto const& sleHolding : deletedHoldings_)
223 {
224 if (sleHolding->getType() == ltMPTOKEN && sleHolding->getFieldU64(sfMPTAmount) > 0)
225 {
226 JLOG(j.fatal()) << "Invariant failed: MPToken deleted with non-zero balance";
227 return false;
228 }
229 }
230 }
231
232 if (isTesSuccess(result) || (mptV2Enabled && result == tecINCOMPLETE))
233 {
234 [[maybe_unused]]
235 bool const enforceCreatedByIssuer =
236 rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol);
238 {
239 JLOG(j.fatal()) << "Invariant failed: MPToken created for the MPT issuer";
240 // The comment above starting with "assert(enforce)" explains this
241 // assert.
242 XRPL_ASSERT_PARTS(
243 enforceCreatedByIssuer, "xrpl::ValidMPTIssuance::finalize", "no issuer MPToken");
244 if (enforceCreatedByIssuer)
245 return false;
246 }
247
248 auto const txnType = tx.getTxnType();
250 {
251 if (mptIssuancesCreated_ == 0)
252 {
253 JLOG(j.fatal()) << "Invariant failed: transaction "
254 "succeeded without creating a MPT issuance";
255 }
256 else if (mptIssuancesDeleted_ != 0)
257 {
258 JLOG(j.fatal()) << "Invariant failed: transaction "
259 "succeeded while removing MPT issuances";
260 }
261 else if (mptIssuancesCreated_ > 1)
262 {
263 JLOG(j.fatal()) << "Invariant failed: transaction "
264 "succeeded but created multiple issuances";
265 }
266
267 return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0;
268 }
269
271 {
272 if (mptIssuancesDeleted_ == 0)
273 {
274 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
275 "succeeded without removing a MPT issuance";
276 }
277 else if (mptIssuancesCreated_ > 0)
278 {
279 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
280 "succeeded while creating MPT issuances";
281 }
282 else if (mptIssuancesDeleted_ > 1)
283 {
284 JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion "
285 "succeeded but deleted multiple issuances";
286 }
287
288 return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 1;
289 }
290
291 bool const lendingProtocolEnabled = rules.enabled(featureLendingProtocol);
292 // ttESCROW_FINISH may authorize an MPT, but it can't have the
293 // mayAuthorizeMPT privilege, because that may cause
294 // non-amendment-gated side effects.
295 bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) &&
296 (rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled);
298 enforceEscrowFinish)
299 {
300 bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
301
302 if (mptIssuancesCreated_ > 0)
303 {
304 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
305 "succeeded but created MPT issuances";
306 return false;
307 }
308 if (mptIssuancesDeleted_ > 0)
309 {
310 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
311 "succeeded but deleted issuances";
312 return false;
313 }
314 if (mptV2Enabled && hasPrivilege(tx, Privilege::MayAuthorizeMpt) &&
315 (txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK))
316 {
317 if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0)
318 {
319 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
320 "submitted by issuer succeeded "
321 "but created bad number of mptokens";
322 return false;
323 }
324 // At most two MPToken may be created on withdraw/clawback since:
325 // - Liquidity Provider must have at least one token in order
326 // participate in AMM pool liquidity or have LPTokens only.
327 // - At most two MPTokens may be deleted if AMM pool, which has exactly
328 // two tokens, is empty after withdraw/clawback.
329 SOMETIMES(mptokensCreated_ == 2, "AMM withdraw/clawback recreated two MPTokens");
330 if (mptokensCreated_ > 2 || mptokensDeleted_ > 2)
331 {
332 JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
333 "but created/deleted bad number of mptokens";
334 return false;
335 }
336 }
337 else
338 {
339 // Cap on MPToken creates and deletes while featureLendingProtocol is enabled.
340 // - LoanSet: at most two creates and no deletes.
341 // - VaultWithdraw: at most one create and one delete.
342 // - Other MayAuthorizeMpt types: created + deleted <= 1.
343 // - MustAuthorizeMpt still requires exactly one create or delete below.
344 auto const mptokensExceedAuthorizeCap = [&] {
345 if (!lendingProtocolEnabled)
346 return false;
347 if (rules.enabled(fixCleanup3_4_0))
348 {
349 if (txnType == ttLOAN_SET)
350 return mptokensDeleted_ != 0 || mptokensCreated_ > 2;
351 if (txnType == ttVAULT_WITHDRAW)
352 return mptokensCreated_ > 1 || mptokensDeleted_ > 1;
353 }
354 return (mptokensCreated_ + mptokensDeleted_) > 1;
355 };
356 if (mptokensExceedAuthorizeCap())
357 {
358 JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded "
359 "but created/deleted bad number mptokens";
360 return false;
361 }
362 if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0))
363 {
364 JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
365 "succeeded but created/deleted mptokens";
366 return false;
367 }
368 if (!submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) &&
370 {
371 // if the holder submitted this tx, then a mptoken must be
372 // either created or deleted.
373 JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder "
374 "succeeded but created/deleted bad number of mptokens";
375 return false;
376 }
377 }
378
379 return true;
380 }
381
383 {
384 bool const submittedByIssuer = tx.isFieldPresent(sfHolder);
385
386 if (mptIssuancesCreated_ > 0)
387 {
388 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
389 "succeeded but created MPT issuances";
390 return false;
391 }
392 if (mptIssuancesDeleted_ > 0)
393 {
394 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
395 "succeeded but deleted issuances";
396 return false;
397 }
398 if (mptokensDeleted_ > 0)
399 {
400 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
401 "succeeded but deleted MPTokens";
402 return false;
403 }
404 // AMMCreate may auto-create up to two MPT objects:
405 // - one per asset side in an MPT/MPT AMM, or one in an IOU/MPT AMM.
406 // CheckCash may auto-create at most one MPT object for the receiver.
407 if ((txnType == ttAMM_CREATE && mptokensCreated_ > 2) ||
408 (txnType == ttCHECK_CASH && mptokensCreated_ > 1))
409 {
410 JLOG(j.fatal()) << "Invariant failed: MPT authorize "
411 "succeeded but created bad number of mptokens";
412 return false;
413 }
414 if (submittedByIssuer)
415 {
416 JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer "
417 "succeeded but created mptokens";
418 return false;
419 }
420
421 // Offer crossing or payment may consume multiple offers
422 // where takerPays is MPT amount. If the offer owner doesn't
423 // own MPT then MPT is created automatically.
424 return true;
425 }
426
427 if (txnType == ttESCROW_FINISH)
428 {
429 // ttESCROW_FINISH may authorize an MPT, but it can't have the
430 // mayAuthorizeMPT privilege, because that may cause
431 // non-amendment-gated side effects.
432 XRPL_ASSERT_PARTS(
433 !enforceEscrowFinish, "xrpl::ValidMPTIssuance::finalize", "not escrow finish tx");
434 return true;
435 }
436
438 ((txnType == ttAMM_DELETE && mptokensDeleted_ <= 2) || mptokensDeleted_ == 1) &&
440 return true;
441 }
442
443 if (mptIssuancesCreated_ != 0)
444 {
445 JLOG(j.fatal()) << "Invariant failed: a MPT issuance was created";
446 }
447 else if (mptIssuancesDeleted_ != 0)
448 {
449 JLOG(j.fatal()) << "Invariant failed: a MPT issuance was deleted";
450 }
451 else if (mptokensCreated_ != 0)
452 {
453 JLOG(j.fatal()) << "Invariant failed: a MPToken was created";
454 }
455 else if (mptokensDeleted_ != 0)
456 {
457 JLOG(j.fatal()) << "Invariant failed: a MPToken was deleted";
458 }
459
460 return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0 && mptokensCreated_ == 0 &&
461 mptokensDeleted_ == 0;
462}
463
464void
466{
467 if (overflow_)
468 return;
469
470 auto makeKey = [](SLE const& sle) {
471 if (sle.getType() == ltMPTOKEN_ISSUANCE)
472 return makeMptID(sle[sfSequence], sle[sfIssuer]);
473 return sle[sfMPTokenIssuanceID];
474 };
475
476 auto update = [&](SLE const& sle, Order order) -> bool {
477 auto const type = sle.getType();
478 if (type == ltMPTOKEN_ISSUANCE)
479 {
480 auto const outstanding = sle[sfOutstandingAmount];
481 if (outstanding > kMaxMpTokenAmount)
482 {
483 overflow_ = true;
484 return false;
485 }
486 data_[makeKey(sle)].outstanding[static_cast<std::size_t>(order)] = outstanding;
487 }
488 else if (type == ltMPTOKEN)
489 {
490 auto const mptAmt = sle[sfMPTAmount];
491 auto const lockedAmt = sle[~sfLockedAmount].value_or(0);
492 if (mptAmt > kMaxMpTokenAmount || lockedAmt > kMaxMpTokenAmount ||
493 lockedAmt > (kMaxMpTokenAmount - mptAmt))
494 {
495 overflow_ = true;
496 return false;
497 }
498 auto const res = static_cast<std::int64_t>(mptAmt + lockedAmt);
499 // subtract before from after
500 if (order == Order::Before)
501 {
502 data_[makeKey(sle)].mptAmount -= res;
503 }
504 else
505 {
506 data_[makeKey(sle)].mptAmount += res;
507 }
508 }
509 return true;
510 };
511
512 if (before && !update(*before, Order::Before))
513 return;
514
515 if (after)
516 {
517 if (after->getType() == ltMPTOKEN_ISSUANCE)
518 {
519 overflow_ = (*after)[sfOutstandingAmount] > maxMPTAmount(*after);
520 }
521 if (!update(*after, Order::After))
522 return;
523 }
524}
525
526bool
528 STTx const& tx,
529 TER const result,
530 XRPAmount const,
531 ReadView const& view,
532 beast::Journal const& j)
533{
534 auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
535
536 if (isTesSuccess(result) || fix340Enabled)
537 {
538 // Confidential transactions are validated by ValidConfidentialMPToken.
539 // They modify encrypted fields and sfConfidentialOutstandingAmount
540 // rather than sfMPTAmount/sfOutstandingAmount in the standard way,
541 // so ValidMPTPayment's accounting does not apply to them.
542 if (std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
543 kConfidentialMptTxTypes.end())
544 {
545 return true;
546 }
547
548 // Returned when a violation is found below, so this is the log-only
549 // condition. Either amendment makes the checks enforcing.
550 auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
551 if (overflow_)
552 {
553 JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow";
554 return invariantPasses;
555 }
556
557 auto const signedMax = static_cast<std::int64_t>(kMaxMpTokenAmount);
558 for (auto const& [id, data] : data_)
559 {
560 (void)id;
561 static constexpr auto kIBefore = static_cast<std::size_t>(Order::Before);
562 static constexpr auto kIAfter = static_cast<std::size_t>(Order::After);
563 bool const addOverflows =
564 (data.mptAmount > 0 && data.outstanding[kIBefore] > (signedMax - data.mptAmount)) ||
565 (data.mptAmount < 0 && data.outstanding[kIBefore] < (-signedMax - data.mptAmount));
566 if (addOverflows ||
567 data.outstanding[kIAfter] != (data.outstanding[kIBefore] + data.mptAmount))
568 {
569 JLOG(j.fatal()) << "Invariant failed: invalid OutstandingAmount balance "
570 << data.outstanding[kIBefore] << " " << data.outstanding[kIAfter]
571 << " " << data.mptAmount;
572 return invariantPasses;
573 }
574
575 // A failed transaction must not have moved MPT value; the check
576 // above ties mptAmount to the OutstandingAmount delta. No result
577 // code is exempt: on any tec the transactor discards the view and
578 // re-applies only offer, trust line, NFT offer and credential
579 // deletions (Transactor::typesForResult), none of which touch MPTs.
580 if (!isTesSuccess(result) && data.mptAmount != 0)
581 {
582 JLOG(j.fatal()) << "Invariant failed: OutstandingAmount balance changed on failure "
583 << tx.getTxnType() << " " << result;
584 return invariantPasses;
585 }
586 }
587 }
588
589 return true;
590}
591
592void
594 bool isDelete,
595 std::shared_ptr<SLE const> const& before,
597{
598 // Helper to get MPToken Issuance ID safely
599 auto const getMptID = [](std::shared_ptr<SLE const> const& sle) -> UInt192 {
600 if (!sle)
601 return beast::kZero;
602 if (sle->getType() == ltMPTOKEN)
603 return sle->getFieldH192(sfMPTokenIssuanceID);
604 if (sle->getType() == ltMPTOKEN_ISSUANCE)
605 return makeMptID(sle->getFieldU32(sfSequence), sle->getAccountID(sfIssuer));
606 return beast::kZero;
607 };
608
609 if (before && before->getType() == ltMPTOKEN)
610 {
611 UInt192 const id = getMptID(before);
612 auto& change = changes_[id];
613 change.mptAmountDelta =
614 subtractMPTAmountDelta(change.mptAmountDelta, before->getFieldU64(sfMPTAmount));
615
616 // Cannot delete MPToken with non-zero confidential state.
617 if (isDelete)
618 {
619 // changes_ is keyed by issuance, so sibling holders erased by the
620 // same transaction share this entry. Only ever set these flags,
621 // never clear them, or an empty sibling visited later would mask
622 // a funded MPToken.
623
624 // Retired by fixCleanup3_5_0, which moved the public balance
625 // check to ValidMPTIssuance::finalize. Kept pre-amendment for
626 // consensus safety: a non-zero public balance used to feed the
627 // confidential gate below, rejecting the erase whenever the
628 // issuance's COA was non-zero, and already-validated ledgers
629 // depend on that.
630 if (!isFeatureEnabled(fixCleanup3_5_0) && before->getFieldU64(sfMPTAmount) > 0)
631 changes_[id].deletedWithBalanceBefore = true;
632
633 if (before->isFieldPresent(sfConfidentialBalanceSpending) ||
634 before->isFieldPresent(sfConfidentialBalanceInbox) ||
635 before->isFieldPresent(sfIssuerEncryptedBalance) ||
636 before->isFieldPresent(sfAuditorEncryptedBalance))
637 changes_[id].deletedWithEncrypted = true;
638 }
639 }
640
641 if (after && after->getType() == ltMPTOKEN)
642 {
643 UInt192 const id = getMptID(after);
644 auto& change = changes_[id];
645 change.mptAmountDelta =
646 addMPTAmountDelta(change.mptAmountDelta, after->getFieldU64(sfMPTAmount));
647
648 // Encrypted field existence consistency
649 bool const hasIssuerBalance = after->isFieldPresent(sfIssuerEncryptedBalance);
650 bool const hasHolderInbox = after->isFieldPresent(sfConfidentialBalanceInbox);
651 bool const hasHolderSpending = after->isFieldPresent(sfConfidentialBalanceSpending);
652 bool const hasAuditorBalance = after->isFieldPresent(sfAuditorEncryptedBalance);
653
654 // The core encrypted balances must all exist or not exist at the same time. The auditor
655 // balance is optional, but cannot exist without the core fields.
656 if (hasHolderInbox != hasHolderSpending || hasHolderInbox != hasIssuerBalance ||
657 (hasAuditorBalance && !hasIssuerBalance))
658 changes_[id].badConsistency = true;
659
660 auto const confidentialBalanceFieldChanged = [&before, &after](auto const& field) {
661 auto const afterValue = (*after)[~field];
662 if (!afterValue)
663 return false;
664
665 if (!before || before->getType() != ltMPTOKEN)
666 return true; // LCOV_EXCL_LINE
667
668 return (*before)[~field] != afterValue;
669 };
670
671 if (confidentialBalanceFieldChanged(sfConfidentialBalanceInbox) ||
672 confidentialBalanceFieldChanged(sfConfidentialBalanceSpending) ||
673 confidentialBalanceFieldChanged(sfIssuerEncryptedBalance) ||
674 confidentialBalanceFieldChanged(sfAuditorEncryptedBalance))
675 {
676 changes_[id].changesConfidentialFields = true;
677 }
678 }
679
680 if (before && before->getType() == ltMPTOKEN_ISSUANCE)
681 {
682 UInt192 const id = getMptID(before);
683 auto& change = changes_[id];
684 if (before->isFieldPresent(sfConfidentialOutstandingAmount))
685 {
686 change.coaDelta = subtractMPTAmountDelta(
687 change.coaDelta, before->getFieldU64(sfConfidentialOutstandingAmount));
688 }
689 change.outstandingDelta = subtractMPTAmountDelta(
690 change.outstandingDelta, before->getFieldU64(sfOutstandingAmount));
691 }
692
693 if (after && after->getType() == ltMPTOKEN_ISSUANCE)
694 {
695 UInt192 const id = getMptID(after);
696 auto& change = changes_[id];
697
698 bool const hasCOA = after->isFieldPresent(sfConfidentialOutstandingAmount);
699 std::uint64_t const coa = (*after)[~sfConfidentialOutstandingAmount].value_or(0);
700 std::uint64_t const oa = after->getFieldU64(sfOutstandingAmount);
701
702 if (hasCOA)
703 change.coaDelta = addMPTAmountDelta(change.coaDelta, coa);
704
705 change.outstandingDelta = addMPTAmountDelta(change.outstandingDelta, oa);
706 change.issuance = after;
707
708 // COA <= OutstandingAmount
709 if (coa > oa)
710 change.badCOA = true;
711 }
712
713 if (before && after && before->getType() == ltMPTOKEN && after->getType() == ltMPTOKEN)
714 {
715 UInt192 const id = getMptID(after);
716
717 // sfConfidentialBalanceVersion must change when spending changes
718 auto const spendingBefore = (*before)[~sfConfidentialBalanceSpending];
719 auto const spendingAfter = (*after)[~sfConfidentialBalanceSpending];
720 auto const versionBefore = (*before)[~sfConfidentialBalanceVersion];
721 auto const versionAfter = (*after)[~sfConfidentialBalanceVersion];
722
723 if (spendingBefore.has_value() && spendingBefore != spendingAfter)
724 {
725 if (versionBefore == versionAfter)
726 changes_[id].badVersion = true;
727 }
728 }
729}
730
731bool
733 STTx const& tx,
734 TER const result,
735 XRPAmount const,
736 ReadView const& view,
737 beast::Journal const& j)
738{
739 if (result != tesSUCCESS)
740 return true;
741
742 bool const fix350Enabled = view.rules().enabled(fixCleanup3_5_0);
743
744 for (auto const& [id, checks] : changes_)
745 {
746 // Find the MPTokenIssuance
747 auto const issuance = [&]() -> std::shared_ptr<SLE const> {
748 if (checks.issuance)
749 return checks.issuance;
750 return view.read(keylet::mptokenIssuance(id));
751 }();
752
753 // Skip all invariance checks if issuance doesn't exist because that means the MPT has been
754 // deleted
755 if (!issuance)
756 continue;
757
758 // Cannot delete MPToken with non-zero confidential state.
759 //
760 // Before fixCleanup3_5_0 this gate also absorbed the pre-transaction
761 // public balance, so any drain-then-erase of an MPToken -- an
762 // AMMWithdraw of the whole pool, a LoanBrokerDelete returning cover --
763 // was rejected whenever some unrelated holder of the same issuance
764 // held a confidential balance. The COA gate itself is correct for
765 // ciphertext and mirrors MPTokenAuthorize::preclaim; only the public
766 // balance leg was misplaced.
767 bool const deletedWithEncrypted = fix350Enabled
768 ? checks.deletedWithEncrypted
769 : (checks.deletedWithEncrypted || checks.deletedWithBalanceBefore);
770
771 if (deletedWithEncrypted)
772 {
773 if ((*issuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0)
774 {
775 JLOG(j.fatal())
776 << "Invariant failed: MPToken deleted with encrypted fields while COA > 0";
777 return false;
778 }
779 }
780
781 // Encrypted field existence consistency
782 if (checks.badConsistency)
783 {
784 JLOG(j.fatal()) << "Invariant failed: MPToken encrypted field "
785 "existence inconsistency";
786 return false;
787 }
788
789 // COA <= OutstandingAmount
790 if (checks.badCOA)
791 {
792 JLOG(j.fatal()) << "Invariant failed: Confidential outstanding amount "
793 "exceeds total outstanding amount";
794 return false;
795 }
796
797 // Confidential balance fields may remain on a holder MPToken after all
798 // confidential balances have returned to zero. Only creating or
799 // changing those fields requires the issuance privacy flag.
800 if (checks.changesConfidentialFields)
801 {
802 if (!issuance->isFlag(lsfMPTCanHoldConfidentialBalance))
803 {
804 JLOG(j.fatal()) << "Invariant failed: MPToken has encrypted "
805 "fields but Issuance does not have "
806 "lsfMPTCanHoldConfidentialBalance set";
807 return false;
808 }
809 }
810
811 // We only enforce this when Confidential Outstanding Amount changes (Convert, ConvertBack,
812 // ConfidentialClawback). This avoids falsely failing on Escrow or AMM operations that lock
813 // public tokens outside of ltMPTOKEN. Convert / ConvertBack:
814 // - COA and MPTAmount must have opposite deltas, which cancel each other out to zero.
815 // - OA remains unchanged.
816 // - Therefore, the net delta on both sides of the equation is zero.
817 //
818 // Clawback:
819 // - MPTAmount remains unchanged.
820 // - COA and OA must have identical deltas (mirrored on each side).
821 // - The equation remains balanced as both sides have equal offsets.
822 if (checks.coaDelta != 0)
823 {
824 if (checks.mptAmountDelta + checks.coaDelta != checks.outstandingDelta)
825 {
826 JLOG(j.fatal()) << "Invariant failed: Token conservation "
827 "violation for MPT "
828 << to_string(id);
829 return false;
830 }
831 }
832 else if (
833 std::ranges::find(kConfidentialMptTxTypes, tx.getTxnType()) !=
834 kConfidentialMptTxTypes.end())
835 {
836 // Confidential Txns should not modify public MPTAmount balance
837 // if Confidential Amount Delta is 0
838 if (checks.mptAmountDelta != 0)
839 {
840 JLOG(j.fatal()) << "Invariant failed: MPTAmount changed by confidential "
841 "transaction that should not modify this field."
842 << to_string(id);
843 return false;
844 }
845
846 // Reaching here means this confidential MPT transaction left coaDelta
847 // unmodified (e.g. ConfidentialMPTSend, ConfidentialMPTMergeInbox, or
848 // ConfidentialMPTHolderKeyUpdate/ConfidentialMPTMirrorUpdate, none of which touch
849 // sfConfidentialOutstandingAmount), so it must not modify sfOutstandingAmount either.
850 if (checks.outstandingDelta != 0)
851 {
852 JLOG(j.fatal()) << "Invariant failed: OutstandingAmount changed "
853 "by confidential transaction that should not "
854 "modify it for MPT "
855 << to_string(id);
856 return false;
857 }
858 }
859
860 if (checks.badVersion)
861 {
862 JLOG(j.fatal())
863 << "Invariant failed: MPToken sfConfidentialBalanceVersion not updated when "
864 "sfConfidentialBalanceSpending changed";
865 return false;
866 }
867 }
868
869 return true;
870}
871
872void
874 bool isDelete,
875 std::shared_ptr<SLE const> const& before,
877{
878 // Record the before/after MPTAmount for each (issuanceID, account) pair
879 // so finalize() can determine whether a transfer actually occurred.
880 auto update = [&](SLE const& sle, bool isBefore) {
881 if (sle.getType() == ltMPTOKEN)
882 {
883 auto const issuanceID = sle[sfMPTokenIssuanceID];
884 auto const account = sle[sfAccount];
885 auto const amount = sle[sfMPTAmount];
886 if (isBefore)
887 {
888 amount_[issuanceID][account].amtBefore = amount;
889 }
890 else
891 {
892 amount_[issuanceID][account].amtAfter = amount;
893 }
894 if (isDelete && isBefore)
895 {
896 deletedAuthorized_[sle.key()] = sle.isFlag(lsfMPTAuthorized);
897 }
898 }
899 };
900
901 if (before)
902 update(*before, true);
903
904 if (after)
905 update(*after, false);
906
907 // Record whether every touched AccountRoot was a pseudo-account BEFORE
908 // the transaction applied (true and false). A transaction that erases a
909 // pseudo-account (and moves MPT out of it) in the same transaction leaves
910 // no trace of its pseudo-account status in the post-transaction view
911 // isAuthorized() sees at finalize() time.
912 if (before && before->getType() == ltACCOUNT_ROOT)
913 pseudoAccountsBefore_[before->at(sfAccount)] = isPseudoAccount(before);
914}
915
916bool
918 ReadView const& view,
919 MPTID const& mptid,
920 AccountID const& holder,
921 bool reqAuth) const
922{
923 // Pseudo-accounts (Vault, LoanBroker, AMM) hold assets on behalf of their
924 // participants and are implicitly authorized for any MPT they hold,
925 // including vault shares whose underlying asset would otherwise require
926 // auth. Exempt them here rather than relying on requireAuth: the recursive
927 // share -> underlying descent in requireAuth fails for a pseudo-account
928 // that holds the share but not the underlying.
929 //
930 // Use the pre-transaction classification for any account this
931 // transaction touched (pseudoAccountsBefore_): the post-transaction view
932 // is wrong for an account this same transaction erased. Untouched
933 // accounts aren't in the map, so fall back to the current view, which is
934 // still accurate for them since nothing changed.
935 auto const pseudoIt = pseudoAccountsBefore_.find(holder);
936 bool const isPseudo =
937 pseudoIt != pseudoAccountsBefore_.end() ? pseudoIt->second : isPseudoAccount(view, holder);
938 if (isPseudo)
939 return true;
940
941 auto const key = keylet::mptoken(mptid, holder);
942 auto const it = deletedAuthorized_.find(key.key);
943 if (it != deletedAuthorized_.end())
944 return !reqAuth || it->second;
945 return isTesSuccess(requireAuth(view, MPTIssue{mptid}, holder));
946}
947
948bool
950 STTx const& tx,
951 TER const result,
952 XRPAmount const,
953 ReadView const& view,
954 beast::Journal const& j)
955{
957 return true;
958
959 // XLS-0066: a broker must be able to default an already-late loan
960 // regardless of the vault asset's lock state. Gated behind
961 // fixCleanup3_4_0, and scoped below to exactly the broker/vault
962 // pseudo-accounts and the vault's own MPT issuance -- see
963 // FreezeInvariant.cpp's TransfersNotFrozen::finalize for the IOU-side
964 // equivalent and rationale.
965 auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx);
966
967 // DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are
968 // subject to the MPTCanTrade flag in addition to the standard transfer rules.
969 // A payment is only DEX if it is a cross-currency payment.
970 auto const txnType = tx.getTxnType();
971 auto const isDEX = [&] {
972 if (txnType == ttPAYMENT)
973 {
974 // A payment is cross-currency (and thus DEX) only if SendMax is present
975 // and its asset differs from the destination asset.
976 auto const amount = tx[sfAmount];
977 return tx[~sfSendMax].value_or(amount).asset() != amount.asset();
978 }
979 return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE;
980 }();
981
982 auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0);
983 // Returned when a violation is found below, so this is the log-only
984 // condition. Either amendment makes the checks enforcing.
985 auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled);
986
987 // A failed transaction must not persist an MPToken deletion. Pre-loop
988 // because deletedAuthorized_ is not issuance-scoped and orphans continue.
989 if (fix340Enabled && !isTesSuccess(result) && !deletedAuthorized_.empty())
990 {
991 JLOG(j.fatal()) << "Invariant failed: MPToken deleted on failure " << txnType << " "
992 << result;
993 return invariantPasses;
994 }
995
996 for (auto const& [mptID, values] : amount_)
997 {
998 std::uint16_t senders = 0;
999 std::uint16_t receivers = 0;
1000 bool invalidTransfer = false;
1001 auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID));
1002 if (!sleIssuance)
1003 {
1004 // MPTokenIssuanceDestroy only requires a zero OutstandingAmount, so
1005 // an orphaned MPToken can outlive its issuance and be cleaned up
1006 // later by a transaction of any type. There are no transfer rules
1007 // left to check, but its balance is zero and nothing can raise it,
1008 // so any change other than deletion is a bug.
1009 for (auto const& [account, value] : values)
1010 {
1011 if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
1012 {
1013 JLOG(j.fatal()) << "Invariant failed: orphaned MPToken balance changed "
1014 << txnType << " " << result;
1015 return invariantPasses;
1016 }
1017 }
1018 continue;
1019 }
1020
1021 // These transactions are recovery/settlement paths. They may move an
1022 // existing MPT position even after the issuer clears CanTransfer, so
1023 // holders are not trapped in AMM, vault, or loan protocol accounts.
1024 auto const waivesCanTransfer = txnType == ttAMM_WITHDRAW ||
1025 (view.rules().enabled(fixCleanup3_2_0) &&
1026 (txnType == ttVAULT_WITHDRAW || txnType == ttLOAN_BROKER_COVER_WITHDRAW ||
1027 txnType == ttLOAN_PAY));
1028 auto const canTransfer = sleIssuance->isFlag(lsfMPTCanTransfer) || waivesCanTransfer;
1029 auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade);
1030 auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth);
1031
1032 // This issuance is the LoanManage default's own vault asset, so the
1033 // broker/vault freeze exemption applies to it -- an unrelated MPT
1034 // issuance the same accounts happen to hold is still caught.
1035 bool const isLoanDefaultAsset = loanDefaultAccounts &&
1036 loanDefaultAccounts->asset.holds<MPTIssue>() &&
1037 loanDefaultAccounts->asset.get<MPTIssue>().getMptID() == mptID;
1038
1039 for (auto const& [account, value] : values)
1040 {
1041 // Classify each account as a sender or receiver based on whether their MPTAmount
1042 // decreased or increased. Count new MPToken holders (no amtBefore) as receivers.
1043 // Skip deleted MPToken holders (amtAfter is nullopt); deletion requires zero balance.
1044 if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter)
1045 {
1046 if (!value.amtBefore.has_value() || *value.amtAfter > *value.amtBefore)
1047 {
1048 ++receivers;
1049 }
1050 else
1051 {
1052 ++senders;
1053 }
1054
1055 // Check once: if any involved account is frozen, the whole issuance transfer is
1056 // considered frozen. Only need to check for frozen if there is a transfer of funds.
1057 //
1058 // The LoanManage default exemption only waives the frozen check, and only for
1059 // the specific broker/vault pseudo-accounts identified above -- authorization is
1060 // still enforced for them, and both checks still apply to every other account.
1061 bool const exemptFromFreeze = isLoanDefaultAsset && loanDefaultAccounts &&
1062 (account == loanDefaultAccounts->broker ||
1063 account == loanDefaultAccounts->vault);
1064 if (!invalidTransfer &&
1065 ((!exemptFromFreeze && isFrozen(view, account, *sleIssuance)) ||
1066 !isAuthorized(view, mptID, account, reqAuth)))
1067 {
1068 invalidTransfer = true;
1069 }
1070 }
1071 }
1072 // A transfer between holders has occurred (senders > 0 && receivers > 0).
1073 // Fail if the issuance is frozen, does not permit transfers, or — for
1074 // DEX transactions — does not permit trading.
1075 if ((invalidTransfer || !canTransfer || (isDEX && !canTrade)) && senders > 0 &&
1076 receivers > 0)
1077 {
1078 JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders";
1079 return invariantPasses;
1080 }
1081
1082 // A failed transaction must not have changed a holder's balance. One
1083 // side is enough, unlike the transfer check above, so this also catches
1084 // a lock/unlock moving value between sfMPTAmount and sfLockedAmount.
1085 if (fix340Enabled && !isTesSuccess(result) && (senders > 0 || receivers > 0))
1086 {
1087 JLOG(j.fatal()) << "Invariant failed: MPToken balance changed on failure " << txnType
1088 << " " << result;
1089 return invariantPasses;
1090 }
1091 }
1092
1093 return true;
1094}
1095
1096} // namespace xrpl
A generic endpoint for log messages.
Definition Journal.h:44
Stream fatal() const
Definition Journal.h:368
constexpr MPTID const & getMptID() const
Definition MPTIssue.h:43
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:182
UInt256 const & key() const
Returns the 'key' (or 'index') of this item.
LedgerEntryType getType() const
std::shared_ptr< STLedgerEntry const > const & ConstRef
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:250
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::map< UInt192, Changes > changes_
void visitEntry(bool isDelete, SLE::ConstRef before, SLE::ConstRef after)
Track MPT amount and outstanding amount changes.
HashMap< 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.
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.
void visitEntry(bool isDelete, SLE::ConstRef before, SLE::ConstRef after)
Track MPT issuance and holding creations, deletions, and mutations.
bool referenceHoldingMutated_
True when sfReferenceHolding was mutated on an existing MPTokenIssuance.
std::uint32_t mptIssuancesCreated_
std::vector< std::shared_ptr< SLE const > > deletedHoldings_
MPTokens and RippleStates deleted during apply.
std::uint32_t issuanceFlagsCleared_
Flags cleared on an existing MPTokenIssuance, except lsfMPTLocked, which tfMPTUnlock clears legitimat...
std::uint32_t mptIssuancesDeleted_
HashMap< AccountID, bool > pseudoAccountsBefore_
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.
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.
HashMap< UInt192, HashMap< AccountID, Value > > amount_
HashMap< UInt256, bool > deletedAuthorized_
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:573
Keylet account(AccountID const &id) noexcept
AccountID root.
Definition Indexes.cpp:220
Keylet mptokenIssuance(MPTID const &issuanceID) noexcept
Definition Indexes.cpp:567
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
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:199
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.
BaseUInt< 192 > UInt192
Definition base_uint.h:581
std::string to_string(BaseUInt< Bits, Tag > const &a)
Definition base_uint.h:657
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)
std::optional< LoanDefaultFreezeExemptAccounts > getLoanDefaultFreezeExemptAccounts(ReadView const &view, STTx const &tx)
Resolves the accounts and asset a LoanManage default transaction is exempt from freeze/lock for.
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:644
bool isFrozen(ReadView const &view, AccountID const &account, MPTIssue const &mptIssue, std::uint8_t depth=0)
Returns true if account cannot send or receive tokens of mptIssue because a freeze applies.
MPTID makeMptID(std::uint32_t const sequence, AccountID const &account)
Definition Indexes.cpp:206
bool isPseudoAccount(SLE::const_pointer sleAcct)
Returns true if and only if sleAcct is a pseudo-account of any kind (i.e.
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:683
TERSubset< CanCvtToTER > TER
Definition TER.h:654
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:343
constexpr std::uint64_t kMaxMpTokenAmount
The maximum amount of MPTokenIssuance.
Definition Protocol.h:297
@ tesSUCCESS
Definition TER.h:250