From 2403670da94eafe8d073111f6fc15f4a796115cf Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Tue, 14 Jul 2026 10:31:06 -0400 Subject: [PATCH] fix: Strengthen Clawback invariant checks for MPT balances (#7285) --- include/xrpl/tx/invariants/InvariantCheck.h | 17 +- include/xrpl/tx/invariants/MPTInvariant.h | 2 +- src/libxrpl/tx/invariants/InvariantCheck.cpp | 149 +++++- src/libxrpl/tx/invariants/MPTInvariant.cpp | 4 +- src/test/app/Invariants_test.cpp | 519 +++++++++++++++++++ 5 files changed, 666 insertions(+), 25 deletions(-) diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index bb51105de2..150c0ed510 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -316,17 +316,26 @@ public: }; /** - * @brief Invariant: Token holder's trustline balance cannot be negative after - * Clawback. + * @brief Invariant: Token holder's trustline/MPT balance cannot be invalid + * after Clawback. * * We iterate all the trust lines affected by this transaction and ensure * that no more than one trustline is modified, and also holder's balance is - * non-negative. + * non-negative. When featureMPTokensV2 is enabled, also verify the holder's + * raw trustline/MPToken balance decreased by the clawed amount. */ class ValidClawback { + struct EntryChange + { + SLE::const_pointer before; + SLE::const_pointer after; + }; + std::uint32_t trustlinesChanged_ = 0; std::uint32_t mptokensChanged_ = 0; + EntryChange iou_; + EntryChange mpt_; public: void @@ -440,7 +449,7 @@ using InvariantChecks = std::tuple< ValidLoan, ValidVault, ValidConfidentialMPToken, - ValidMPTPayment, + ValidMPTBalanceChanges, ValidAmounts, ValidMPTTransfer, ObjectHasPseudoAccount, diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index 9a546fa400..5740cd5be2 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -87,7 +87,7 @@ public: * OutstandingAmount after application equals OutstandingAmount before * application plus the net holder balance delta. */ -class ValidMPTPayment +class ValidMPTBalanceChanges { enum class Order { Before = 0, After = 1 }; struct MPTData diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 220a7a8e41..1032644132 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -799,14 +799,49 @@ ValidNewAccountRoot::finalize( //------------------------------------------------------------------------------ +static std::optional +clawbackTrustLineBalanceInHolderTerms( + SLE::const_pointer const& sle, + AccountID const& holder, + AccountID const& issuer, + Currency const& currency) +{ + if (!sle) + return STAmount{Issue{currency, issuer}}; + + if (sle->getType() != ltRIPPLE_STATE || + sle->key() != keylet::trustLine(holder, issuer, currency).key) + { + return std::nullopt; + } + + STAmount balance = sle->getFieldAmount(sfBalance); + if (holder > issuer) + balance.negate(); + balance.get().account = issuer; + return balance; +} + void -ValidClawback::visitEntry(bool, SLE::const_ref before, SLE::const_ref) +ValidClawback::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { if (before && before->getType() == ltRIPPLE_STATE) + { trustlinesChanged_++; + iou_.before = before; + } + + if (!isDelete && after && after->getType() == ltRIPPLE_STATE) + iou_.after = after; if (before && before->getType() == ltMPTOKEN) + { mptokensChanged_++; + mpt_.before = before; + } + + if (!isDelete && after && after->getType() == ltMPTOKEN) + mpt_.after = after; } bool @@ -835,31 +870,109 @@ ValidClawback::finalize( } bool const mptV2Enabled = view.rules().enabled(featureMPTokensV2); + if (trustlinesChanged_ != 0 && mptokensChanged_ != 0) + { + JLOG(j.fatal()) << "Invariant failed: trustline and MPToken both changed."; + if (mptV2Enabled) + return false; + } + if (trustlinesChanged_ == 1 || (mptV2Enabled && mptokensChanged_ == 1)) { - AccountID const issuer = tx.getAccountID(sfAccount); STAmount const& amount = tx.getFieldAmount(sfAmount); - AccountID const& holder = amount.getIssuer(); - STAmount const holderBalance = amount.asset().visit( + + return amount.asset().visit( [&](Issue const& issue) { - return accountHolds( + AccountID const issuer = tx.getAccountID(sfAccount); + AccountID const& holder = amount.getIssuer(); + STAmount const holderBalance = accountHolds( view, holder, issue.currency, issuer, FreezeHandling::IgnoreFreeze, j); + + if (holderBalance.signum() < 0) + { + JLOG(j.fatal()) << "Invariant failed: trustline or MPT balance is negative"; + return false; + } + + if (!iou_.before) + { + JLOG(j.fatal()) + << "Invariant failed: trustline clawback changed the wrong line"; + return !mptV2Enabled; + } + + auto const beforeBalance = clawbackTrustLineBalanceInHolderTerms( + iou_.before, holder, issuer, issue.currency); + auto const afterBalance = clawbackTrustLineBalanceInHolderTerms( + iou_.after, holder, issuer, issue.currency); + if (!beforeBalance || !afterBalance) + { + JLOG(j.fatal()) + << "Invariant failed: trustline clawback changed the wrong line"; + return !mptV2Enabled; + } + + STAmount clawAmount = amount; + clawAmount.get().account = issuer; + if (clawAmount <= beast::kZero) + { + JLOG(j.fatal()) << "Invariant failed: trustline clawback amount is invalid"; + return !mptV2Enabled; + } + + if (*afterBalance > *beforeBalance || + (*beforeBalance - *afterBalance) != std::min(*beforeBalance, clawAmount)) + { + JLOG(j.fatal()) + << "Invariant failed: trustline clawback balance change is invalid"; + return !mptV2Enabled; + } + + return true; }, [&](MPTIssue const& issue) { - return accountHolds( - view, - holder, - issue, - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j); - }); + auto const holder = tx[~sfHolder]; + if (!holder) + { + JLOG(j.fatal()) << "Invariant failed: MPT clawback missing holder"; + return !mptV2Enabled; + } - if (holderBalance.signum() < 0) - { - JLOG(j.fatal()) << "Invariant failed: trustline or MPT balance is negative"; - return false; - } + if (!mpt_.before || !mpt_.after) + { + JLOG(j.fatal()) << "Invariant failed: MPT clawback token is missing"; + return !mptV2Enabled; + } + + if (mpt_.before->getAccountID(sfAccount) != *holder || + mpt_.after->getAccountID(sfAccount) != *holder || + (*mpt_.before)[sfMPTokenIssuanceID] != issue.getMptID() || + (*mpt_.after)[sfMPTokenIssuanceID] != issue.getMptID()) + { + JLOG(j.fatal()) << "Invariant failed: MPT clawback changed the wrong token"; + return !mptV2Enabled; + } + + auto const before = mpt_.before->getFieldU64(sfMPTAmount); + auto const after = mpt_.after->getFieldU64(sfMPTAmount); + if (amount.negative() || amount.mantissa() == 0) + { + JLOG(j.fatal()) << "Invariant failed: MPT clawback amount is invalid"; + return !mptV2Enabled; + } + auto const clawAmount = amount.mantissa(); + + // MPT balances are unsigned, so validate the raw holder + // debit instead of routing through accountHolds(). + if (after > before || (before - after) != std::min(before, clawAmount)) + { + JLOG(j.fatal()) + << "Invariant failed: MPT clawback balance change is invalid"; + return !mptV2Enabled; + } + + return true; + }); } } else diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index d8f7fcc27d..77c5ad781e 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -403,7 +403,7 @@ ValidMPTIssuance::finalize( } void -ValidMPTPayment::visitEntry(bool, SLE::const_ref before, SLE::const_ref after) +ValidMPTBalanceChanges::visitEntry(bool, SLE::const_ref before, SLE::const_ref after) { if (overflow_) return; @@ -465,7 +465,7 @@ ValidMPTPayment::visitEntry(bool, SLE::const_ref before, SLE::const_ref after) } bool -ValidMPTPayment::finalize( +ValidMPTBalanceChanges::finalize( STTx const& tx, TER const result, XRPAmount const, diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 569eecb27b..9c90ade72f 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4441,6 +4441,525 @@ class Invariants_test : public beast::unit_test::Suite return true; }); + // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback balance change is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto const sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100}; + }}, + {tesSUCCESS, tesSUCCESS}); + } + + // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing. + { + Env env(*this, defaultAmendments() - featureMPTokensV2); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback balance change is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tesSUCCESS, tesSUCCESS}); + } + + // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback balance change is invalid"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + + sleToken->setFieldU64(sfMPTAmount, 80); + sleIssuance->setFieldU64(sfOutstandingAmount, 80); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline and MPToken both changed"}}, + [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleLine = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id())); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleLine || !sleToken || !sleIssuance) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sleLine->setFieldAmount(sfBalance, balance); + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleLine); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Clawback that modifies a trustline other than the one implied by the + // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for + // the mismatched line. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + auto const eur = issuer["EUR"]; + env.trust(eur(100), holder); + env(pay(issuer, holder, eur(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback changed the wrong line"}}, + [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency)); + if (!sle) + return false; + STAmount balance{Issue{eur.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Clawback leaving the holder's balance negative. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline or MPT balance is negative"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + // Make the holder's balance negative from their perspective. + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() < issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // IOU-amount clawback while only an MPToken changed: no trustline was + // recorded, so iou_.before is empty. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback changed the wrong line"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Valid trustline change but a zero clawback amount. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback amount is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + STAmount balance{Issue{usd.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback tx missing the Holder field. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback missing holder"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback where the holder's MPToken was deleted (after is empty). + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback token is missing"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + // Keep the issuance consistent after removing the token. + sleIssuance->setFieldU64(sfOutstandingAmount, 0); + ac.view().update(sleIssuance); + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback that changed a different holder's MPToken. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, + .issuer = issuer, + .holders = {holder, other}, + .pay = 100, + .maxAmt = 200}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback changed the wrong token"}}, + [id](Account const&, Account const& other, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, other)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 190); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Valid MPToken change but a zero MPT clawback amount. + { + Env env(*this, defaultAmendments()); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback amount is invalid"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 0}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + // More MPTokens created than expected std::array, 4> const tests = { std::make_pair(ttAMM_WITHDRAW, 2),