#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace xrpl { /* assert(enforce) There are several asserts (or XRPL_ASSERTs) in this file that check a variable named `enforce` when an invariant fails. At first glance, those asserts may look incorrect, but they are not. Those asserts take advantage of two facts: 1. `asserts` are not (normally) executed in release builds. 2. Invariants should *never* fail, except in tests that specifically modify the open ledger to break them. This makes `assert(enforce)` sort of a second-layer of invariant enforcement aimed at _developers_. It's designed to fire if a developer writes code that violates an invariant, and runs it in unit tests or a develop build that _does not have the relevant amendments enabled_. It's intentionally a pain in the neck so that bad code gets caught and fixed as early as possible. */ enum Privilege { noPriv = 0x0000, // The transaction can not do any of the enumerated operations createAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. createPseudoAcct = 0x0002, // The transaction can create a pseudo account, // which implies createAcct mustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object mayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT // object, but does not have to overrideFreeze = 0x0010, // The transaction can override some freeze rules changeNFTCounts = 0x0020, // The transaction can mint or burn an NFT createMPTIssuance = 0x0040, // The transaction can create a new MPT issuance destroyMPTIssuance = 0x0080, // The transaction can destroy an MPT issuance mustAuthorizeMPT = 0x0100, // The transaction MUST create or delete an MPT // object (except by issuer) mayAuthorizeMPT = 0x0200, // The transaction MAY create or delete an MPT // object (except by issuer) mayDeleteMPT = 0x0400, // The transaction MAY delete an MPT object. May not create. mustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault mayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault }; constexpr Privilege operator|(Privilege lhs, Privilege rhs) { return safe_cast( safe_cast>(lhs) | safe_cast>(rhs)); } #pragma push_macro("TRANSACTION") #undef TRANSACTION #define TRANSACTION(tag, value, name, delegable, amendment, privileges, ...) \ case tag: { \ return (privileges) & priv; \ } bool hasPrivilege(STTx const& tx, Privilege priv) { switch (tx.getTxnType()) { #include // Deprecated types default: return false; } }; #undef TRANSACTION #pragma pop_macro("TRANSACTION") void TransactionFeeCheck::visitEntry( bool, std::shared_ptr const&, std::shared_ptr const&) { // nothing to do } bool TransactionFeeCheck::finalize( STTx const& tx, TER const, XRPAmount const fee, ReadView const&, beast::Journal const& j) { // We should never charge a negative fee if (fee.drops() < 0) { JLOG(j.fatal()) << "Invariant failed: fee paid was negative: " << fee.drops(); return false; } // We should never charge a fee that's greater than or equal to the // entire XRP supply. if (fee >= INITIAL_XRP) { JLOG(j.fatal()) << "Invariant failed: fee paid exceeds system limit: " << fee.drops(); return false; } // We should never charge more for a transaction than the transaction // authorizes. It's possible to charge less in some circumstances. if (fee > tx.getFieldAmount(sfFee).xrp()) { JLOG(j.fatal()) << "Invariant failed: fee paid is " << fee.drops() << " exceeds fee specified in transaction."; return false; } return true; } //------------------------------------------------------------------------------ void XRPNotCreated::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { /* We go through all modified ledger entries, looking only at account roots, * escrow payments, and payment channels. We remove from the total any * previous XRP values and add to the total any new XRP values. The net * balance of a payment channel is computed from two fields (amount and * balance) and deletions are ignored for paychan and escrow because the * amount fields have not been adjusted for those in the case of deletion. */ if (before) { switch (before->getType()) { case ltACCOUNT_ROOT: drops_ -= (*before)[sfBalance].xrp().drops(); break; case ltPAYCHAN: drops_ -= ((*before)[sfAmount] - (*before)[sfBalance]).xrp().drops(); break; case ltESCROW: if (isXRP((*before)[sfAmount])) drops_ -= (*before)[sfAmount].xrp().drops(); break; default: break; } } if (after) { switch (after->getType()) { case ltACCOUNT_ROOT: drops_ += (*after)[sfBalance].xrp().drops(); break; case ltPAYCHAN: if (!isDelete) drops_ += ((*after)[sfAmount] - (*after)[sfBalance]).xrp().drops(); break; case ltESCROW: if (!isDelete && isXRP((*after)[sfAmount])) drops_ += (*after)[sfAmount].xrp().drops(); break; default: break; } } } bool XRPNotCreated::finalize( STTx const& tx, TER const, XRPAmount const fee, ReadView const&, beast::Journal const& j) { // The net change should never be positive, as this would mean that the // transaction created XRP out of thin air. That's not possible. if (drops_ > 0) { JLOG(j.fatal()) << "Invariant failed: XRP net change was positive: " << drops_; return false; } // The negative of the net change should be equal to actual fee charged. if (-drops_ != fee.drops()) { JLOG(j.fatal()) << "Invariant failed: XRP net change of " << drops_ << " doesn't match fee " << fee.drops(); return false; } return true; } //------------------------------------------------------------------------------ void XRPBalanceChecks::visitEntry( bool, std::shared_ptr const& before, std::shared_ptr const& after) { auto isBad = [](STAmount const& balance) { if (!balance.native()) return true; auto const drops = balance.xrp(); // Can't have more than the number of drops instantiated // in the genesis ledger. if (drops > INITIAL_XRP) return true; // Can't have a negative balance (0 is OK) if (drops < XRPAmount{0}) return true; return false; }; if (before && before->getType() == ltACCOUNT_ROOT) bad_ |= isBad((*before)[sfBalance]); if (after && after->getType() == ltACCOUNT_ROOT) bad_ |= isBad((*after)[sfBalance]); } bool XRPBalanceChecks::finalize( STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const& j) { if (bad_) { JLOG(j.fatal()) << "Invariant failed: incorrect account XRP balance"; return false; } return true; } //------------------------------------------------------------------------------ void NoBadOffers::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { auto isBad = [](STAmount const& pays, STAmount const& gets) { // An offer should never be negative if (pays < beast::zero) return true; if (gets < beast::zero) return true; // Can't have an XRP to XRP offer: return pays.native() && gets.native(); }; if (before && before->getType() == ltOFFER) bad_ |= isBad((*before)[sfTakerPays], (*before)[sfTakerGets]); if (after && after->getType() == ltOFFER) bad_ |= isBad((*after)[sfTakerPays], (*after)[sfTakerGets]); } bool NoBadOffers::finalize( STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const& j) { if (bad_) { JLOG(j.fatal()) << "Invariant failed: offer with a bad amount"; return false; } return true; } //------------------------------------------------------------------------------ void NoZeroEscrow::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { auto isBad = [](STAmount const& amount) { // XRP case if (amount.native()) { if (amount.xrp() <= XRPAmount{0}) return true; if (amount.xrp() >= INITIAL_XRP) return true; } else { // IOU case if (amount.holds()) { if (amount <= beast::zero) return true; if (badCurrency() == amount.getCurrency()) return true; } // MPT case if (amount.holds()) { if (amount <= beast::zero) return true; if (amount.mpt() > MPTAmount{maxMPTokenAmount}) return true; // LCOV_EXCL_LINE } } return false; }; if (before && before->getType() == ltESCROW) bad_ |= isBad((*before)[sfAmount]); if (after && after->getType() == ltESCROW) bad_ |= isBad((*after)[sfAmount]); auto checkAmount = [this](std::int64_t amount) { if (amount > maxMPTokenAmount || amount < 0) bad_ = true; }; if (after && after->getType() == ltMPTOKEN_ISSUANCE) { auto const outstanding = (*after)[sfOutstandingAmount]; checkAmount(outstanding); if (auto const locked = (*after)[~sfLockedAmount]) { checkAmount(*locked); bad_ = outstanding < *locked; } } if (after && after->getType() == ltMPTOKEN) { auto const mptAmount = (*after)[sfMPTAmount]; checkAmount(mptAmount); if (auto const locked = (*after)[~sfLockedAmount]) { checkAmount(*locked); } } } bool NoZeroEscrow::finalize( STTx const& txn, TER const, XRPAmount const, ReadView const& rv, beast::Journal const& j) { if (bad_) { JLOG(j.fatal()) << "Invariant failed: escrow specifies invalid amount"; return false; } return true; } //------------------------------------------------------------------------------ void AccountRootsNotDeleted::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const&) { if (isDelete && before && before->getType() == ltACCOUNT_ROOT) accountsDeleted_++; } bool AccountRootsNotDeleted::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const&, beast::Journal const& j) { // AMM account root can be deleted as the result of AMM withdraw/delete // transaction when the total AMM LP Tokens balance goes to 0. // A successful AccountDelete or AMMDelete MUST delete exactly // one account root. if (hasPrivilege(tx, mustDeleteAcct) && result == tesSUCCESS) { if (accountsDeleted_ == 1) return true; if (accountsDeleted_ == 0) JLOG(j.fatal()) << "Invariant failed: account deletion " "succeeded without deleting an account"; else JLOG(j.fatal()) << "Invariant failed: account deletion " "succeeded but deleted multiple accounts!"; return false; } // A successful AMMWithdraw/AMMClawback MAY delete one account root // when the total AMM LP Tokens balance goes to 0. Not every AMM withdraw // deletes the AMM account, accountsDeleted_ is set if it is deleted. if (hasPrivilege(tx, mayDeleteAcct) && result == tesSUCCESS && accountsDeleted_ == 1) return true; if (accountsDeleted_ == 0) return true; JLOG(j.fatal()) << "Invariant failed: an account root was deleted"; return false; } //------------------------------------------------------------------------------ void AccountRootsDeletedClean::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { if (isDelete && before && before->getType() == ltACCOUNT_ROOT) accountsDeleted_.emplace_back(before, after); } bool AccountRootsDeletedClean::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { // Always check for objects in the ledger, but to prevent differing // transaction processing results, however unlikely, only fail if the // feature is enabled. Enabled, or not, though, a fatal-level message will // be logged [[maybe_unused]] bool const enforce = view.rules().enabled(featureInvariantsV1_1) || view.rules().enabled(featureSingleAssetVault) || view.rules().enabled(featureLendingProtocol); auto const objectExists = [&view, enforce, &j](auto const& keylet) { (void)enforce; if (auto const sle = view.read(keylet)) { // Finding the object is bad auto const typeName = [&sle]() { auto item = LedgerFormats::getInstance().findByType(sle->getType()); if (item != nullptr) return item->getName(); return std::to_string(sle->getType()); }(); JLOG(j.fatal()) << "Invariant failed: account deletion left behind a " << typeName << " object"; // The comment above starting with "assert(enforce)" explains this // assert. XRPL_ASSERT( enforce, "xrpl::AccountRootsDeletedClean::finalize::objectExists : " "account deletion left no objects behind"); return true; } return false; }; for (auto const& [before, after] : accountsDeleted_) { auto const accountID = before->getAccountID(sfAccount); // An account should not be deleted with a balance if (after->at(sfBalance) != beast::zero) { JLOG(j.fatal()) << "Invariant failed: account deletion left " "behind a non-zero balance"; XRPL_ASSERT( enforce, "xrpl::AccountRootsDeletedClean::finalize : " "deleted account has zero balance"); if (enforce) return false; } // An account should not be deleted with a non-zero owner count if (after->at(sfOwnerCount) != 0) { JLOG(j.fatal()) << "Invariant failed: account deletion left " "behind a non-zero owner count"; XRPL_ASSERT( enforce, "xrpl::AccountRootsDeletedClean::finalize : " "deleted account has zero owner count"); if (enforce) return false; } // Simple types for (auto const& [keyletfunc, _, __] : directAccountKeylets) { if (objectExists(std::invoke(keyletfunc, accountID)) && enforce) return false; } { // NFT pages. nftpage_min and nftpage_max were already explicitly // checked above as entries in directAccountKeylets. This uses // view.succ() to check for any NFT pages in between the two // endpoints. Keylet const first = keylet::nftpage_min(accountID); Keylet const last = keylet::nftpage_max(accountID); std::optional key = view.succ(first.key, last.key.next()); // current page if (key && objectExists(Keylet{ltNFTOKEN_PAGE, *key}) && enforce) return false; } // If the account is a pseudo account, then the linked object must // also be deleted. e.g. AMM, Vault, etc. for (auto const& field : getPseudoAccountFields()) { if (before->isFieldPresent(*field)) { auto const key = before->getFieldH256(*field); if (objectExists(keylet::unchecked(key)) && enforce) return false; } } } return true; } //------------------------------------------------------------------------------ void LedgerEntryTypesMatch::visitEntry( bool, std::shared_ptr const& before, std::shared_ptr const& after) { if (before && after && before->getType() != after->getType()) typeMismatch_ = true; if (after) { #pragma push_macro("LEDGER_ENTRY") #undef LEDGER_ENTRY #define LEDGER_ENTRY(tag, ...) case tag: switch (after->getType()) { #include break; default: invalidTypeAdded_ = true; break; } #undef LEDGER_ENTRY #pragma pop_macro("LEDGER_ENTRY") } } bool LedgerEntryTypesMatch::finalize( STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const& j) { if ((!typeMismatch_) && (!invalidTypeAdded_)) return true; if (typeMismatch_) { JLOG(j.fatal()) << "Invariant failed: ledger entry type mismatch"; } if (invalidTypeAdded_) { JLOG(j.fatal()) << "Invariant failed: invalid ledger entry type added"; } return false; } //------------------------------------------------------------------------------ void NoXRPTrustLines::visitEntry( bool, std::shared_ptr const&, std::shared_ptr const& after) { if (after && after->getType() == ltRIPPLE_STATE) { // checking the issue directly here instead of // relying on .native() just in case native somehow // were systematically incorrect xrpTrustLine_ = after->getFieldAmount(sfLowLimit).issue() == xrpIssue() || after->getFieldAmount(sfHighLimit).issue() == xrpIssue(); } } bool NoXRPTrustLines::finalize( STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const& j) { if (!xrpTrustLine_) return true; JLOG(j.fatal()) << "Invariant failed: an XRP trust line was created"; return false; } //------------------------------------------------------------------------------ void NoDeepFreezeTrustLinesWithoutFreeze::visitEntry( bool, std::shared_ptr const&, std::shared_ptr const& after) { if (after && after->getType() == ltRIPPLE_STATE) { std::uint32_t const uFlags = after->getFieldU32(sfFlags); bool const lowFreeze = uFlags & lsfLowFreeze; bool const lowDeepFreeze = uFlags & lsfLowDeepFreeze; bool const highFreeze = uFlags & lsfHighFreeze; bool const highDeepFreeze = uFlags & lsfHighDeepFreeze; deepFreezeWithoutFreeze_ = (lowDeepFreeze && !lowFreeze) || (highDeepFreeze && !highFreeze); } } bool NoDeepFreezeTrustLinesWithoutFreeze::finalize( STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const& j) { if (!deepFreezeWithoutFreeze_) return true; JLOG(j.fatal()) << "Invariant failed: a trust line with deep freeze flag " "without normal freeze was created"; return false; } //------------------------------------------------------------------------------ void TransfersNotFrozen::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { /* * A trust line freeze state alone doesn't determine if a transfer is * frozen. The transfer must be examined "end-to-end" because both sides of * the transfer may have different freeze states and freeze impact depends * on the transfer direction. This is why first we need to track the * transfers using IssuerChanges senders/receivers. * * Only in validateIssuerChanges, after we collected all changes can we * determine if the transfer is valid. */ if (!isValidEntry(before, after)) { return; } auto const balanceChange = calculateBalanceChange(before, after, isDelete); if (balanceChange.signum() == 0) { return; } recordBalanceChanges(after, balanceChange); } bool TransfersNotFrozen::finalize( STTx const& tx, TER const ter, XRPAmount const fee, ReadView const& view, beast::Journal const& j) { /* * We check this invariant regardless of deep freeze amendment status, * allowing for detection and logging of potential issues even when the * amendment is disabled. * * If an exploit that allows moving frozen assets is discovered, * we can alert operators who monitor fatal messages and trigger assert in * debug builds for an early warning. * * In an unlikely event that an exploit is found, this early detection * enables encouraging the UNL to expedite deep freeze amendment activation * or deploy hotfixes via new amendments. In case of a new amendment, we'd * only have to change this line setting 'enforce' variable. * enforce = view.rules().enabled(featureDeepFreeze) || * view.rules().enabled(fixFreezeExploit); */ [[maybe_unused]] bool const enforce = view.rules().enabled(featureDeepFreeze); for (auto const& [issue, changes] : balanceChanges_) { auto const issuerSle = findIssuer(issue.account, view); // It should be impossible for the issuer to not be found, but check // just in case so rippled doesn't crash in release. if (!issuerSle) { // The comment above starting with "assert(enforce)" explains this // assert. XRPL_ASSERT( enforce, "xrpl::TransfersNotFrozen::finalize : enforce " "invariant."); if (enforce) { return false; } continue; } if (!validateIssuerChanges(issuerSle, changes, tx, j, enforce)) { return false; } } return true; } bool TransfersNotFrozen::isValidEntry( std::shared_ptr const& before, std::shared_ptr const& after) { // `after` can never be null, even if the trust line is deleted. XRPL_ASSERT(after, "xrpl::TransfersNotFrozen::isValidEntry : valid after."); if (!after) { return false; } if (after->getType() == ltACCOUNT_ROOT) { possibleIssuers_.emplace(after->at(sfAccount), after); return false; } /* While LedgerEntryTypesMatch invariant also checks types, all invariants * are processed regardless of previous failures. * * This type check is still necessary here because it prevents potential * issues in subsequent processing. */ return after->getType() == ltRIPPLE_STATE && (!before || before->getType() == ltRIPPLE_STATE); } STAmount TransfersNotFrozen::calculateBalanceChange( std::shared_ptr const& before, std::shared_ptr const& after, bool isDelete) { auto const getBalance = [](auto const& line, auto const& other, bool zero) { STAmount amt = line ? line->at(sfBalance) : other->at(sfBalance).zeroed(); return zero ? amt.zeroed() : amt; }; /* Trust lines can be created dynamically by other transactions such as * Payment and OfferCreate that cross offers. Such trust line won't be * created frozen, but the sender might be, so the starting balance must be * treated as zero. */ auto const balanceBefore = getBalance(before, after, false); /* Same as above, trust lines can be dynamically deleted, and for frozen * trust lines, payments not involving the issuer must be blocked. This is * achieved by treating the final balance as zero when isDelete=true to * ensure frozen line restrictions are enforced even during deletion. */ auto const balanceAfter = getBalance(after, before, isDelete); return balanceAfter - balanceBefore; } void TransfersNotFrozen::recordBalance(Issue const& issue, BalanceChange change) { XRPL_ASSERT( change.balanceChangeSign, "xrpl::TransfersNotFrozen::recordBalance : valid trustline " "balance sign."); auto& changes = balanceChanges_[issue]; if (change.balanceChangeSign < 0) changes.senders.emplace_back(std::move(change)); else changes.receivers.emplace_back(std::move(change)); } void TransfersNotFrozen::recordBalanceChanges( std::shared_ptr const& after, STAmount const& balanceChange) { auto const balanceChangeSign = balanceChange.signum(); auto const currency = after->at(sfBalance).getCurrency(); // Change from low account's perspective, which is trust line default recordBalance({currency, after->at(sfHighLimit).getIssuer()}, {after, balanceChangeSign}); // Change from high account's perspective, which reverses the sign. recordBalance({currency, after->at(sfLowLimit).getIssuer()}, {after, -balanceChangeSign}); } std::shared_ptr TransfersNotFrozen::findIssuer(AccountID const& issuerID, ReadView const& view) { if (auto it = possibleIssuers_.find(issuerID); it != possibleIssuers_.end()) { return it->second; } return view.read(keylet::account(issuerID)); } bool TransfersNotFrozen::validateIssuerChanges( std::shared_ptr const& issuer, IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, bool enforce) { if (!issuer) { return false; } bool const globalFreeze = issuer->isFlag(lsfGlobalFreeze); if (changes.receivers.empty() || changes.senders.empty()) { /* If there are no receivers, then the holder(s) are returning * their tokens to the issuer. Likewise, if there are no * senders, then the issuer is issuing tokens to the holder(s). * This is allowed regardless of the issuer's freeze flags. (The * holder may have contradicting freeze flags, but that will be * checked when the holder is treated as issuer.) */ return true; } for (auto const& actors : {changes.senders, changes.receivers}) { for (auto const& change : actors) { bool const high = change.line->at(sfLowLimit).getIssuer() == issuer->at(sfAccount); if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze)) { return false; } } } return true; } bool TransfersNotFrozen::validateFrozenState( BalanceChange const& change, bool high, STTx const& tx, beast::Journal const& j, bool enforce, bool globalFreeze) { bool const freeze = change.balanceChangeSign < 0 && change.line->isFlag(high ? lsfLowFreeze : lsfHighFreeze); bool const deepFreeze = change.line->isFlag(high ? lsfLowDeepFreeze : lsfHighDeepFreeze); bool const frozen = globalFreeze || deepFreeze || freeze; bool const isAMMLine = change.line->isFlag(lsfAMMNode); if (!frozen) { return true; } // AMMClawbacks are allowed to override some freeze rules if ((!isAMMLine || globalFreeze) && hasPrivilege(tx, overrideFreeze)) { JLOG(j.debug()) << "Invariant check allowing funds to be moved " << (change.balanceChangeSign > 0 ? "to" : "from") << " a frozen trustline for AMMClawback " << tx.getTransactionID(); return true; } JLOG(j.fatal()) << "Invariant failed: Attempting to move frozen funds for " << tx.getTransactionID(); // The comment above starting with "assert(enforce)" explains this assert. XRPL_ASSERT( enforce, "xrpl::TransfersNotFrozen::validateFrozenState : enforce " "invariant."); if (enforce) { return false; } return true; } //------------------------------------------------------------------------------ void ValidNewAccountRoot::visitEntry( bool, std::shared_ptr const& before, std::shared_ptr const& after) { if (!before && after->getType() == ltACCOUNT_ROOT) { accountsCreated_++; accountSeq_ = (*after)[sfSequence]; pseudoAccount_ = isPseudoAccount(after); flags_ = after->getFlags(); } } bool ValidNewAccountRoot::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { if (accountsCreated_ == 0) return true; if (accountsCreated_ > 1) { JLOG(j.fatal()) << "Invariant failed: multiple accounts " "created in a single transaction"; return false; } // From this point on we know exactly one account was created. if (hasPrivilege(tx, createAcct | createPseudoAcct) && result == tesSUCCESS) { bool const pseudoAccount = (pseudoAccount_ && (view.rules().enabled(featureSingleAssetVault) || view.rules().enabled(featureLendingProtocol))); if (pseudoAccount && !hasPrivilege(tx, createPseudoAcct)) { JLOG(j.fatal()) << "Invariant failed: pseudo-account created by a " "wrong transaction type"; return false; } std::uint32_t const startingSeq = pseudoAccount ? 0 : view.seq(); if (accountSeq_ != startingSeq) { JLOG(j.fatal()) << "Invariant failed: account created with " "wrong starting sequence number"; return false; } if (pseudoAccount) { std::uint32_t const expected = (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); if (flags_ != expected) { JLOG(j.fatal()) << "Invariant failed: pseudo-account created with " "wrong flags"; return false; } } return true; } JLOG(j.fatal()) << "Invariant failed: account root created illegally"; return false; } // namespace xrpl //------------------------------------------------------------------------------ void ValidNFTokenPage::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { static constexpr uint256 const& pageBits = nft::pageMask; static constexpr uint256 const accountBits = ~pageBits; if ((before && before->getType() != ltNFTOKEN_PAGE) || (after && after->getType() != ltNFTOKEN_PAGE)) return; auto check = [this, isDelete](std::shared_ptr const& sle) { uint256 const account = sle->key() & accountBits; uint256 const hiLimit = sle->key() & pageBits; std::optional const prev = (*sle)[~sfPreviousPageMin]; // Make sure that any page links... // 1. Are properly associated with the owning account and // 2. The page is correctly ordered between links. if (prev) { if (account != (*prev & accountBits)) badLink_ = true; if (hiLimit <= (*prev & pageBits)) badLink_ = true; } if (auto const next = (*sle)[~sfNextPageMin]) { if (account != (*next & accountBits)) badLink_ = true; if (hiLimit >= (*next & pageBits)) badLink_ = true; } { auto const& nftokens = sle->getFieldArray(sfNFTokens); // An NFTokenPage should never contain too many tokens or be empty. if (std::size_t const nftokenCount = nftokens.size(); (!isDelete && nftokenCount == 0) || nftokenCount > dirMaxTokensPerPage) invalidSize_ = true; // If prev is valid, use it to establish a lower bound for // page entries. If prev is not valid the lower bound is zero. uint256 const loLimit = prev ? *prev & pageBits : uint256(beast::zero); // Also verify that all NFTokenIDs in the page are sorted. uint256 loCmp = loLimit; for (auto const& obj : nftokens) { uint256 const tokenID = obj[sfNFTokenID]; if (!nft::compareTokens(loCmp, tokenID)) badSort_ = true; loCmp = tokenID; // None of the NFTs on this page should belong on lower or // higher pages. if (uint256 const tokenPageBits = tokenID & pageBits; tokenPageBits < loLimit || tokenPageBits >= hiLimit) badEntry_ = true; if (auto uri = obj[~sfURI]; uri && uri->empty()) badURI_ = true; } } }; if (before) { check(before); // While an account's NFToken directory contains any NFTokens, the last // NFTokenPage (with 96 bits of 1 in the low part of the index) should // never be deleted. if (isDelete && (before->key() & nft::pageMask) == nft::pageMask && before->isFieldPresent(sfPreviousPageMin)) { deletedFinalPage_ = true; } } if (after) check(after); if (!isDelete && before && after) { // If the NFTokenPage // 1. Has a NextMinPage field in before, but loses it in after, and // 2. This is not the last page in the directory // Then we have identified a corruption in the links between the // NFToken pages in the NFToken directory. if ((before->key() & nft::pageMask) != nft::pageMask && before->isFieldPresent(sfNextPageMin) && !after->isFieldPresent(sfNextPageMin)) { deletedLink_ = true; } } } bool ValidNFTokenPage::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { if (badLink_) { JLOG(j.fatal()) << "Invariant failed: NFT page is improperly linked."; return false; } if (badEntry_) { JLOG(j.fatal()) << "Invariant failed: NFT found in incorrect page."; return false; } if (badSort_) { JLOG(j.fatal()) << "Invariant failed: NFTs on page are not sorted."; return false; } if (badURI_) { JLOG(j.fatal()) << "Invariant failed: NFT contains empty URI."; return false; } if (invalidSize_) { JLOG(j.fatal()) << "Invariant failed: NFT page has invalid size."; return false; } if (view.rules().enabled(fixNFTokenPageLinks)) { if (deletedFinalPage_) { JLOG(j.fatal()) << "Invariant failed: Last NFT page deleted with " "non-empty directory."; return false; } if (deletedLink_) { JLOG(j.fatal()) << "Invariant failed: Lost NextMinPage link."; return false; } } return true; } //------------------------------------------------------------------------------ void NFTokenCountTracking::visitEntry( bool, std::shared_ptr const& before, std::shared_ptr const& after) { if (before && before->getType() == ltACCOUNT_ROOT) { beforeMintedTotal += (*before)[~sfMintedNFTokens].value_or(0); beforeBurnedTotal += (*before)[~sfBurnedNFTokens].value_or(0); } if (after && after->getType() == ltACCOUNT_ROOT) { afterMintedTotal += (*after)[~sfMintedNFTokens].value_or(0); afterBurnedTotal += (*after)[~sfBurnedNFTokens].value_or(0); } } bool NFTokenCountTracking::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { if (!hasPrivilege(tx, changeNFTCounts)) { if (beforeMintedTotal != afterMintedTotal) { JLOG(j.fatal()) << "Invariant failed: the number of minted tokens " "changed without a mint transaction!"; return false; } if (beforeBurnedTotal != afterBurnedTotal) { JLOG(j.fatal()) << "Invariant failed: the number of burned tokens " "changed without a burn transaction!"; return false; } return true; } if (tx.getTxnType() == ttNFTOKEN_MINT) { if (result == tesSUCCESS && beforeMintedTotal >= afterMintedTotal) { JLOG(j.fatal()) << "Invariant failed: successful minting didn't increase " "the number of minted tokens."; return false; } if (result != tesSUCCESS && beforeMintedTotal != afterMintedTotal) { JLOG(j.fatal()) << "Invariant failed: failed minting changed the " "number of minted tokens."; return false; } if (beforeBurnedTotal != afterBurnedTotal) { JLOG(j.fatal()) << "Invariant failed: minting changed the number of " "burned tokens."; return false; } } if (tx.getTxnType() == ttNFTOKEN_BURN) { if (result == tesSUCCESS) { if (beforeBurnedTotal >= afterBurnedTotal) { JLOG(j.fatal()) << "Invariant failed: successful burning didn't increase " "the number of burned tokens."; return false; } } if (result != tesSUCCESS && beforeBurnedTotal != afterBurnedTotal) { JLOG(j.fatal()) << "Invariant failed: failed burning changed the " "number of burned tokens."; return false; } if (beforeMintedTotal != afterMintedTotal) { JLOG(j.fatal()) << "Invariant failed: burning changed the number of " "minted tokens."; return false; } } return true; } //------------------------------------------------------------------------------ void ValidClawback::visitEntry( bool, std::shared_ptr const& before, std::shared_ptr const&) { if (before && before->getType() == ltRIPPLE_STATE) trustlinesChanged++; if (before && before->getType() == ltMPTOKEN) mptokensChanged++; } bool ValidClawback::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { if (tx.getTxnType() != ttCLAWBACK) return true; if (result == tesSUCCESS) { if (trustlinesChanged > 1) { JLOG(j.fatal()) << "Invariant failed: more than one trustline changed."; return false; } if (mptokensChanged > 1) { JLOG(j.fatal()) << "Invariant failed: more than one mptokens changed."; return false; } if (trustlinesChanged == 1) { AccountID const issuer = tx.getAccountID(sfAccount); STAmount const& amount = tx.getFieldAmount(sfAmount); AccountID const& holder = amount.getIssuer(); STAmount const holderBalance = accountHolds(view, holder, amount.getCurrency(), issuer, fhIGNORE_FREEZE, j); if (holderBalance.signum() < 0) { JLOG(j.fatal()) << "Invariant failed: trustline balance is negative"; return false; } } } else { if (trustlinesChanged != 0) { JLOG(j.fatal()) << "Invariant failed: some trustlines were changed " "despite failure of the transaction."; return false; } if (mptokensChanged != 0) { JLOG(j.fatal()) << "Invariant failed: some mptokens were changed " "despite failure of the transaction."; return false; } } return true; } //------------------------------------------------------------------------------ void ValidMPTIssuance::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { if (after && after->getType() == ltMPTOKEN_ISSUANCE) { if (isDelete) mptIssuancesDeleted_++; else if (!before) mptIssuancesCreated_++; } if (after && after->getType() == ltMPTOKEN) { if (isDelete) mptokensDeleted_++; else if (!before) { mptokensCreated_++; MPTIssue const mptIssue{after->at(sfMPTokenIssuanceID)}; if (mptIssue.getIssuer() == after->at(sfAccount)) mptCreatedByIssuer_ = true; } } } bool ValidMPTIssuance::finalize( STTx const& tx, TER const result, XRPAmount const _fee, ReadView const& view, beast::Journal const& j) { if (result == tesSUCCESS) { auto const& rules = view.rules(); [[maybe_unused]] bool enforceCreatedByIssuer = rules.enabled(featureSingleAssetVault) || rules.enabled(featureLendingProtocol); if (mptCreatedByIssuer_) { JLOG(j.fatal()) << "Invariant failed: MPToken created for the MPT issuer"; // The comment above starting with "assert(enforce)" explains this // assert. XRPL_ASSERT_PARTS( enforceCreatedByIssuer, "xrpl::ValidMPTIssuance::finalize", "no issuer MPToken"); if (enforceCreatedByIssuer) return false; } auto const txnType = tx.getTxnType(); if (hasPrivilege(tx, createMPTIssuance)) { if (mptIssuancesCreated_ == 0) { JLOG(j.fatal()) << "Invariant failed: transaction " "succeeded without creating a MPT issuance"; } else if (mptIssuancesDeleted_ != 0) { JLOG(j.fatal()) << "Invariant failed: transaction " "succeeded while removing MPT issuances"; } else if (mptIssuancesCreated_ > 1) { JLOG(j.fatal()) << "Invariant failed: transaction " "succeeded but created multiple issuances"; } return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0; } if (hasPrivilege(tx, destroyMPTIssuance)) { if (mptIssuancesDeleted_ == 0) { JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion " "succeeded without removing a MPT issuance"; } else if (mptIssuancesCreated_ > 0) { JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion " "succeeded while creating MPT issuances"; } else if (mptIssuancesDeleted_ > 1) { JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion " "succeeded but deleted multiple issuances"; } return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 1; } bool const lendingProtocolEnabled = view.rules().enabled(featureLendingProtocol); // ttESCROW_FINISH may authorize an MPT, but it can't have the // mayAuthorizeMPT privilege, because that may cause // non-amendment-gated side effects. bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) && (view.rules().enabled(featureSingleAssetVault) || lendingProtocolEnabled); if (hasPrivilege(tx, mustAuthorizeMPT | mayAuthorizeMPT) || enforceEscrowFinish) { bool const submittedByIssuer = tx.isFieldPresent(sfHolder); if (mptIssuancesCreated_ > 0) { JLOG(j.fatal()) << "Invariant failed: MPT authorize " "succeeded but created MPT issuances"; return false; } else if (mptIssuancesDeleted_ > 0) { JLOG(j.fatal()) << "Invariant failed: MPT authorize " "succeeded but deleted issuances"; return false; } else if (lendingProtocolEnabled && mptokensCreated_ + mptokensDeleted_ > 1) { JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " "but created/deleted bad number mptokens"; return false; } else if (submittedByIssuer && (mptokensCreated_ > 0 || mptokensDeleted_ > 0)) { JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by issuer " "succeeded but created/deleted mptokens"; return false; } else if ( !submittedByIssuer && hasPrivilege(tx, mustAuthorizeMPT) && (mptokensCreated_ + mptokensDeleted_ != 1)) { // if the holder submitted this tx, then a mptoken must be // either created or deleted. JLOG(j.fatal()) << "Invariant failed: MPT authorize submitted by holder " "succeeded but created/deleted bad number of mptokens"; return false; } return true; } if (txnType == ttESCROW_FINISH) { // ttESCROW_FINISH may authorize an MPT, but it can't have the // mayAuthorizeMPT privilege, because that may cause // non-amendment-gated side effects. XRPL_ASSERT_PARTS( !enforceEscrowFinish, "xrpl::ValidMPTIssuance::finalize", "not escrow finish tx"); return true; } if (hasPrivilege(tx, mayDeleteMPT) && mptokensDeleted_ == 1 && mptokensCreated_ == 0 && mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0) return true; } if (mptIssuancesCreated_ != 0) { JLOG(j.fatal()) << "Invariant failed: a MPT issuance was created"; } else if (mptIssuancesDeleted_ != 0) { JLOG(j.fatal()) << "Invariant failed: a MPT issuance was deleted"; } else if (mptokensCreated_ != 0) { JLOG(j.fatal()) << "Invariant failed: a MPToken was created"; } else if (mptokensDeleted_ != 0) { JLOG(j.fatal()) << "Invariant failed: a MPToken was deleted"; } return mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0 && mptokensCreated_ == 0 && mptokensDeleted_ == 0; } //------------------------------------------------------------------------------ void ValidPermissionedDomain::visitEntry( bool isDel, std::shared_ptr const& before, std::shared_ptr const& after) { if (before && before->getType() != ltPERMISSIONED_DOMAIN) return; if (after && after->getType() != ltPERMISSIONED_DOMAIN) return; auto check = [isDel](std::vector& sleStatus, std::shared_ptr const& sle) { auto const& credentials = sle->getFieldArray(sfAcceptedCredentials); auto const sorted = credentials::makeSorted(credentials); SleStatus ss{credentials.size(), false, !sorted.empty(), isDel}; // If array have duplicates then all the other checks are invalid if (ss.isUnique_) { unsigned i = 0; for (auto const& cred : sorted) { auto const& credTx = credentials[i++]; ss.isSorted_ = (cred.first == credTx[sfIssuer]) && (cred.second == credTx[sfCredentialType]); if (!ss.isSorted_) break; } } sleStatus.emplace_back(std::move(ss)); }; if (after) check(sleStatus_, after); } bool ValidPermissionedDomain::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { auto check = [](SleStatus const& sleStatus, beast::Journal const& j) { if (!sleStatus.credentialsSize_) { JLOG(j.fatal()) << "Invariant failed: permissioned domain with " "no rules."; return false; } if (sleStatus.credentialsSize_ > maxPermissionedDomainCredentialsArraySize) { JLOG(j.fatal()) << "Invariant failed: permissioned domain bad " "credentials size " << sleStatus.credentialsSize_; return false; } if (!sleStatus.isUnique_) { JLOG(j.fatal()) << "Invariant failed: permissioned domain credentials " "aren't unique"; return false; } if (!sleStatus.isSorted_) { JLOG(j.fatal()) << "Invariant failed: permissioned domain credentials " "aren't sorted"; return false; } return true; }; if (view.rules().enabled(fixPermissionedDomainInvariant)) { // No permissioned domains should be affected if the transaction failed if (result != tesSUCCESS) // If nothing changed, all is good. If there were changes, that's // bad. return sleStatus_.empty(); if (sleStatus_.size() > 1) { JLOG(j.fatal()) << "Invariant failed: transaction affected more " "than 1 permissioned domain entry."; return false; } switch (tx.getTxnType()) { case ttPERMISSIONED_DOMAIN_SET: { if (sleStatus_.empty()) { JLOG(j.fatal()) << "Invariant failed: no domain objects affected by " "PermissionedDomainSet"; return false; } auto const& sleStatus = sleStatus_[0]; if (sleStatus.isDelete_) { JLOG(j.fatal()) << "Invariant failed: domain object " "deleted by PermissionedDomainSet"; return false; } return check(sleStatus, j); } case ttPERMISSIONED_DOMAIN_DELETE: { if (sleStatus_.empty()) { JLOG(j.fatal()) << "Invariant failed: no domain objects affected by " "PermissionedDomainDelete"; return false; } if (!sleStatus_[0].isDelete_) { JLOG(j.fatal()) << "Invariant failed: domain object " "modified, but not deleted by " "PermissionedDomainDelete"; return false; } return true; } default: { if (!sleStatus_.empty()) { JLOG(j.fatal()) << "Invariant failed: " << sleStatus_.size() << " domain object(s) affected by an " "unauthorized transaction. " << tx.getTxnType(); return false; } return true; } } } else { if (tx.getTxnType() != ttPERMISSIONED_DOMAIN_SET || result != tesSUCCESS || sleStatus_.empty()) return true; return check(sleStatus_[0], j); } } //------------------------------------------------------------------------------ void ValidPseudoAccounts::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { if (isDelete) // Deletion is ignored return; if (after && after->getType() == ltACCOUNT_ROOT) { bool const isPseudo = [&]() { // isPseudoAccount checks that any of the pseudo-account fields are // set. if (isPseudoAccount(after)) return true; // Not all pseudo-accounts have a zero sequence, but all accounts // with a zero sequence had better be pseudo-accounts. if (after->at(sfSequence) == 0) return true; return false; }(); if (isPseudo) { // Pseudo accounts must have the following properties: // 1. Exactly one of the pseudo-account fields is set. // 2. The sequence number is not changed. // 3. The lsfDisableMaster, lsfDefaultRipple, and lsfDepositAuth // flags are set. // 4. The RegularKey is not set. { std::vector const& fields = getPseudoAccountFields(); auto const numFields = std::count_if(fields.begin(), fields.end(), [&after](SField const* sf) -> bool { return after->isFieldPresent(*sf); }); if (numFields != 1) { std::stringstream error; error << "pseudo-account has " << numFields << " pseudo-account fields set"; errors_.emplace_back(error.str()); } } if (before && before->at(sfSequence) != after->at(sfSequence)) { errors_.emplace_back("pseudo-account sequence changed"); } if (!after->isFlag(lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth)) { errors_.emplace_back("pseudo-account flags are not set"); } if (after->isFieldPresent(sfRegularKey)) { errors_.emplace_back("pseudo-account has a regular key"); } } } } bool ValidPseudoAccounts::finalize( STTx const& tx, TER const, XRPAmount const, ReadView const& view, beast::Journal const& j) { bool const enforce = view.rules().enabled(featureSingleAssetVault); XRPL_ASSERT( errors_.empty() || enforce, "xrpl::ValidPseudoAccounts::finalize : no bad " "changes or enforce invariant"); if (!errors_.empty()) { for (auto const& error : errors_) { JLOG(j.fatal()) << "Invariant failed: " << error; } if (enforce) return false; } return true; } //------------------------------------------------------------------------------ void ValidPermissionedDEX::visitEntry( bool, std::shared_ptr const& before, std::shared_ptr const& after) { if (after && after->getType() == ltDIR_NODE) { if (after->isFieldPresent(sfDomainID)) domains_.insert(after->getFieldH256(sfDomainID)); } if (after && after->getType() == ltOFFER) { if (after->isFieldPresent(sfDomainID)) domains_.insert(after->getFieldH256(sfDomainID)); else regularOffers_ = true; // if a hybrid offer is missing domain or additional book, there's // something wrong if (after->isFlag(lsfHybrid) && (!after->isFieldPresent(sfDomainID) || !after->isFieldPresent(sfAdditionalBooks) || after->getFieldArray(sfAdditionalBooks).size() > 1)) badHybrids_ = true; } } bool ValidPermissionedDEX::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { auto const txType = tx.getTxnType(); if ((txType != ttPAYMENT && txType != ttOFFER_CREATE) || result != tesSUCCESS) return true; // For each offercreate transaction, check if // permissioned offers are valid if (txType == ttOFFER_CREATE && badHybrids_) { JLOG(j.fatal()) << "Invariant failed: hybrid offer is malformed"; return false; } if (!tx.isFieldPresent(sfDomainID)) return true; auto const domain = tx.getFieldH256(sfDomainID); if (!view.exists(keylet::permissionedDomain(domain))) { JLOG(j.fatal()) << "Invariant failed: domain doesn't exist"; return false; } // for both payment and offercreate, there shouldn't be another domain // that's different from the domain specified for (auto const& d : domains_) { if (d != domain) { JLOG(j.fatal()) << "Invariant failed: transaction" " consumed wrong domains"; return false; } } if (regularOffers_) { JLOG(j.fatal()) << "Invariant failed: domain transaction" " affected regular offers"; return false; } return true; } void ValidAMM::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { if (isDelete) return; if (after) { auto const type = after->getType(); // AMM object changed if (type == ltAMM) { ammAccount_ = after->getAccountID(sfAccount); lptAMMBalanceAfter_ = after->getFieldAmount(sfLPTokenBalance); } // AMM pool changed else if ( (type == ltRIPPLE_STATE && after->getFlags() & lsfAMMNode) || (type == ltACCOUNT_ROOT && after->isFieldPresent(sfAMMID))) { ammPoolChanged_ = true; } } if (before) { // AMM object changed if (before->getType() == ltAMM) { lptAMMBalanceBefore_ = before->getFieldAmount(sfLPTokenBalance); } } } static bool validBalances( STAmount const& amount, STAmount const& amount2, STAmount const& lptAMMBalance, ValidAMM::ZeroAllowed zeroAllowed) { bool const positive = amount > beast::zero && amount2 > beast::zero && lptAMMBalance > beast::zero; if (zeroAllowed == ValidAMM::ZeroAllowed::Yes) return positive || (amount == beast::zero && amount2 == beast::zero && lptAMMBalance == beast::zero); return positive; } bool ValidAMM::finalizeVote(bool enforce, beast::Journal const& j) const { if (lptAMMBalanceAfter_ != lptAMMBalanceBefore_ || ammPoolChanged_) { // LPTokens and the pool can not change on vote // LCOV_EXCL_START JLOG(j.error()) << "AMMVote invariant failed: " << lptAMMBalanceBefore_.value_or(STAmount{}) << " " << lptAMMBalanceAfter_.value_or(STAmount{}) << " " << ammPoolChanged_; if (enforce) return false; // LCOV_EXCL_STOP } return true; } bool ValidAMM::finalizeBid(bool enforce, beast::Journal const& j) const { if (ammPoolChanged_) { // The pool can not change on bid // LCOV_EXCL_START JLOG(j.error()) << "AMMBid invariant failed: pool changed"; if (enforce) return false; // LCOV_EXCL_STOP } // LPTokens are burnt, therefore there should be fewer LPTokens else if ( lptAMMBalanceBefore_ && lptAMMBalanceAfter_ && (*lptAMMBalanceAfter_ > *lptAMMBalanceBefore_ || *lptAMMBalanceAfter_ <= beast::zero)) { // LCOV_EXCL_START JLOG(j.error()) << "AMMBid invariant failed: " << *lptAMMBalanceBefore_ << " " << *lptAMMBalanceAfter_; if (enforce) return false; // LCOV_EXCL_STOP } return true; } bool ValidAMM::finalizeCreate( STTx const& tx, ReadView const& view, bool enforce, beast::Journal const& j) const { if (!ammAccount_) { // LCOV_EXCL_START JLOG(j.error()) << "AMMCreate invariant failed: AMM object is not created"; if (enforce) return false; // LCOV_EXCL_STOP } else { auto const [amount, amount2] = ammPoolHolds( view, *ammAccount_, tx[sfAmount].get(), tx[sfAmount2].get(), fhIGNORE_FREEZE, j); // Create invariant: // sqrt(amount * amount2) == LPTokens // all balances are greater than zero if (!validBalances(amount, amount2, *lptAMMBalanceAfter_, ZeroAllowed::No) || ammLPTokens(amount, amount2, lptAMMBalanceAfter_->issue()) != *lptAMMBalanceAfter_) { JLOG(j.error()) << "AMMCreate invariant failed: " << amount << " " << amount2 << " " << *lptAMMBalanceAfter_; if (enforce) return false; } } return true; } bool ValidAMM::finalizeDelete(bool enforce, TER res, beast::Journal const& j) const { if (ammAccount_) { // LCOV_EXCL_START std::string const msg = (res == tesSUCCESS) ? "AMM object is not deleted on tesSUCCESS" : "AMM object is changed on tecINCOMPLETE"; JLOG(j.error()) << "AMMDelete invariant failed: " << msg; if (enforce) return false; // LCOV_EXCL_STOP } return true; } bool ValidAMM::finalizeDEX(bool enforce, beast::Journal const& j) const { if (ammAccount_) { // LCOV_EXCL_START JLOG(j.error()) << "AMM swap invariant failed: AMM object changed"; if (enforce) return false; // LCOV_EXCL_STOP } return true; } bool ValidAMM::generalInvariant( xrpl::STTx const& tx, xrpl::ReadView const& view, ZeroAllowed zeroAllowed, beast::Journal const& j) const { auto const [amount, amount2] = ammPoolHolds( view, *ammAccount_, tx[sfAsset].get(), tx[sfAsset2].get(), fhIGNORE_FREEZE, j); // Deposit and Withdrawal invariant: // sqrt(amount * amount2) >= LPTokens // all balances are greater than zero // unless on last withdrawal auto const poolProductMean = root2(amount * amount2); bool const nonNegativeBalances = validBalances(amount, amount2, *lptAMMBalanceAfter_, zeroAllowed); bool const strongInvariantCheck = poolProductMean >= *lptAMMBalanceAfter_; // Allow for a small relative error if strongInvariantCheck fails auto weakInvariantCheck = [&]() { return *lptAMMBalanceAfter_ != beast::zero && withinRelativeDistance(poolProductMean, Number{*lptAMMBalanceAfter_}, Number{1, -11}); }; if (!nonNegativeBalances || (!strongInvariantCheck && !weakInvariantCheck())) { JLOG(j.error()) << "AMM " << tx.getTxnType() << " invariant failed: " << tx.getHash(HashPrefix::transactionID) << " " << ammPoolChanged_ << " " << amount << " " << amount2 << " " << poolProductMean << " " << lptAMMBalanceAfter_->getText() << " " << ((*lptAMMBalanceAfter_ == beast::zero) ? Number{1} : ((*lptAMMBalanceAfter_ - poolProductMean) / poolProductMean)); return false; } return true; } bool ValidAMM::finalizeDeposit( xrpl::STTx const& tx, xrpl::ReadView const& view, bool enforce, beast::Journal const& j) const { if (!ammAccount_) { // LCOV_EXCL_START JLOG(j.error()) << "AMMDeposit invariant failed: AMM object is deleted"; if (enforce) return false; // LCOV_EXCL_STOP } else if (!generalInvariant(tx, view, ZeroAllowed::No, j) && enforce) return false; return true; } bool ValidAMM::finalizeWithdraw( xrpl::STTx const& tx, xrpl::ReadView const& view, bool enforce, beast::Journal const& j) const { if (!ammAccount_) { // Last Withdraw or Clawback deleted AMM } else if (!generalInvariant(tx, view, ZeroAllowed::Yes, j)) { if (enforce) return false; } return true; } bool ValidAMM::finalize( STTx const& tx, TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) { // Delete may return tecINCOMPLETE if there are too many // trustlines to delete. if (result != tesSUCCESS && result != tecINCOMPLETE) return true; bool const enforce = view.rules().enabled(fixAMMv1_3); switch (tx.getTxnType()) { case ttAMM_CREATE: return finalizeCreate(tx, view, enforce, j); case ttAMM_DEPOSIT: return finalizeDeposit(tx, view, enforce, j); case ttAMM_CLAWBACK: case ttAMM_WITHDRAW: return finalizeWithdraw(tx, view, enforce, j); case ttAMM_BID: return finalizeBid(enforce, j); case ttAMM_VOTE: return finalizeVote(enforce, j); case ttAMM_DELETE: return finalizeDelete(enforce, result, j); case ttCHECK_CASH: case ttOFFER_CREATE: case ttPAYMENT: return finalizeDEX(enforce, j); default: break; } return true; } //------------------------------------------------------------------------------ void NoModifiedUnmodifiableFields::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { if (isDelete || !before) // Creation and deletion are ignored return; changedEntries_.emplace(before, after); } bool NoModifiedUnmodifiableFields::finalize( STTx const& tx, TER const, XRPAmount const, ReadView const& view, beast::Journal const& j) { static auto const fieldChanged = [](auto const& before, auto const& after, auto const& field) { bool const beforeField = before->isFieldPresent(field); bool const afterField = after->isFieldPresent(field); return beforeField != afterField || (afterField && before->at(field) != after->at(field)); }; for (auto const& slePair : changedEntries_) { auto const& before = slePair.first; auto const& after = slePair.second; auto const type = after->getType(); bool bad = false; [[maybe_unused]] bool enforce = false; switch (type) { case ltLOAN_BROKER: /* * We check this invariant regardless of lending protocol * amendment status, allowing for detection and logging of * potential issues even when the amendment is disabled. */ enforce = view.rules().enabled(featureLendingProtocol); bad = fieldChanged(before, after, sfLedgerEntryType) || fieldChanged(before, after, sfLedgerIndex) || fieldChanged(before, after, sfSequence) || fieldChanged(before, after, sfOwnerNode) || fieldChanged(before, after, sfVaultNode) || fieldChanged(before, after, sfVaultID) || fieldChanged(before, after, sfAccount) || fieldChanged(before, after, sfOwner) || fieldChanged(before, after, sfManagementFeeRate) || fieldChanged(before, after, sfCoverRateMinimum) || fieldChanged(before, after, sfCoverRateLiquidation); break; case ltLOAN: /* * We check this invariant regardless of lending protocol * amendment status, allowing for detection and logging of * potential issues even when the amendment is disabled. */ enforce = view.rules().enabled(featureLendingProtocol); bad = fieldChanged(before, after, sfLedgerEntryType) || fieldChanged(before, after, sfLedgerIndex) || fieldChanged(before, after, sfSequence) || fieldChanged(before, after, sfOwnerNode) || fieldChanged(before, after, sfLoanBrokerNode) || fieldChanged(before, after, sfLoanBrokerID) || fieldChanged(before, after, sfBorrower) || fieldChanged(before, after, sfLoanOriginationFee) || fieldChanged(before, after, sfLoanServiceFee) || fieldChanged(before, after, sfLatePaymentFee) || fieldChanged(before, after, sfClosePaymentFee) || fieldChanged(before, after, sfOverpaymentFee) || fieldChanged(before, after, sfInterestRate) || fieldChanged(before, after, sfLateInterestRate) || fieldChanged(before, after, sfCloseInterestRate) || fieldChanged(before, after, sfOverpaymentInterestRate) || fieldChanged(before, after, sfStartDate) || fieldChanged(before, after, sfPaymentInterval) || fieldChanged(before, after, sfGracePeriod) || fieldChanged(before, after, sfLoanScale); break; default: /* * We check this invariant regardless of lending protocol * amendment status, allowing for detection and logging of * potential issues even when the amendment is disabled. * * We use the lending protocol as a gate, even though * all transactions are affected because that's when it * was added. */ enforce = view.rules().enabled(featureLendingProtocol); bad = fieldChanged(before, after, sfLedgerEntryType) || fieldChanged(before, after, sfLedgerIndex); } XRPL_ASSERT( !bad || enforce, "xrpl::NoModifiedUnmodifiableFields::finalize : no bad " "changes or enforce invariant"); if (bad) { JLOG(j.fatal()) << "Invariant failed: changed an unchangeable field for " << tx.getTransactionID(); if (enforce) return false; } } return true; } //------------------------------------------------------------------------------ void ValidLoanBroker::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { if (after) { if (after->getType() == ltLOAN_BROKER) { auto& broker = brokers_[after->key()]; broker.brokerBefore = before; broker.brokerAfter = after; } else if (after->getType() == ltACCOUNT_ROOT && after->isFieldPresent(sfLoanBrokerID)) { auto const& loanBrokerID = after->at(sfLoanBrokerID); // create an entry if one doesn't already exist brokers_.emplace(loanBrokerID, BrokerInfo{}); } else if (after->getType() == ltRIPPLE_STATE) { lines_.emplace_back(after); } else if (after->getType() == ltMPTOKEN) { mpts_.emplace_back(after); } } } bool ValidLoanBroker::goodZeroDirectory( ReadView const& view, SLE::const_ref dir, beast::Journal const& j) const { auto const next = dir->at(~sfIndexNext); auto const prev = dir->at(~sfIndexPrevious); if ((prev && *prev) || (next && *next)) { JLOG(j.fatal()) << "Invariant failed: Loan Broker with zero " "OwnerCount has multiple directory pages"; return false; } auto indexes = dir->getFieldV256(sfIndexes); if (indexes.size() > 1) { JLOG(j.fatal()) << "Invariant failed: Loan Broker with zero " "OwnerCount has multiple indexes in the Directory root"; return false; } if (indexes.size() == 1) { auto const index = indexes.value().front(); auto const sle = view.read(keylet::unchecked(index)); if (!sle) { JLOG(j.fatal()) << "Invariant failed: Loan Broker directory corrupt"; return false; } if (sle->getType() != ltRIPPLE_STATE && sle->getType() != ltMPTOKEN) { JLOG(j.fatal()) << "Invariant failed: Loan Broker with zero " "OwnerCount has an unexpected entry in the directory"; return false; } } return true; } bool ValidLoanBroker::finalize( STTx const& tx, TER const, XRPAmount const, ReadView const& view, beast::Journal const& j) { // Loan Brokers will not exist on ledger if the Lending Protocol amendment // is not enabled, so there's no need to check it. for (auto const& line : lines_) { for (auto const& field : {&sfLowLimit, &sfHighLimit}) { auto const account = view.read(keylet::account(line->at(*field).getIssuer())); // This Invariant doesn't know about the rules for Trust Lines, so // if the account is missing, don't treat it as an error. This // loop is only concerned with finding Broker pseudo-accounts if (account && account->isFieldPresent(sfLoanBrokerID)) { auto const& loanBrokerID = account->at(sfLoanBrokerID); // create an entry if one doesn't already exist brokers_.emplace(loanBrokerID, BrokerInfo{}); } } } for (auto const& mpt : mpts_) { auto const account = view.read(keylet::account(mpt->at(sfAccount))); // This Invariant doesn't know about the rules for MPTokens, so // if the account is missing, don't treat is as an error. This // loop is only concerned with finding Broker pseudo-accounts if (account && account->isFieldPresent(sfLoanBrokerID)) { auto const& loanBrokerID = account->at(sfLoanBrokerID); // create an entry if one doesn't already exist brokers_.emplace(loanBrokerID, BrokerInfo{}); } } for (auto const& [brokerID, broker] : brokers_) { auto const& after = broker.brokerAfter ? broker.brokerAfter : view.read(keylet::loanbroker(brokerID)); if (!after) { JLOG(j.fatal()) << "Invariant failed: Loan Broker missing"; return false; } auto const& before = broker.brokerBefore; // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants // If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most // one node (the root), which will only hold entries for `RippleState` // or `MPToken` objects. if (after->at(sfOwnerCount) == 0) { auto const dir = view.read(keylet::ownerDir(after->at(sfAccount))); if (dir) { if (!goodZeroDirectory(view, dir, j)) { return false; } } } if (before && before->at(sfLoanSequence) > after->at(sfLoanSequence)) { JLOG(j.fatal()) << "Invariant failed: Loan Broker sequence number " "decreased"; return false; } if (after->at(sfDebtTotal) < 0) { JLOG(j.fatal()) << "Invariant failed: Loan Broker debt total is negative"; return false; } if (after->at(sfCoverAvailable) < 0) { JLOG(j.fatal()) << "Invariant failed: Loan Broker cover available is negative"; return false; } auto const vault = view.read(keylet::vault(after->at(sfVaultID))); if (!vault) { JLOG(j.fatal()) << "Invariant failed: Loan Broker vault ID is invalid"; return false; } auto const& vaultAsset = vault->at(sfAsset); if (after->at(sfCoverAvailable) < accountHolds( view, after->at(sfAccount), vaultAsset, FreezeHandling::fhIGNORE_FREEZE, AuthHandling::ahIGNORE_AUTH, j)) { JLOG(j.fatal()) << "Invariant failed: Loan Broker cover available " "is less than pseudo-account asset balance"; return false; } } return true; } //------------------------------------------------------------------------------ void ValidLoan::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { if (after && after->getType() == ltLOAN) { loans_.emplace_back(before, after); } } bool ValidLoan::finalize( STTx const& tx, TER const, XRPAmount const, ReadView const& view, beast::Journal const& j) { // Loans will not exist on ledger if the Lending Protocol amendment // is not enabled, so there's no need to check it. for (auto const& [before, after] : loans_) { // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off if (after->at(sfPaymentRemaining) == 0 && (after->at(sfTotalValueOutstanding) != beast::zero || after->at(sfPrincipalOutstanding) != beast::zero || after->at(sfManagementFeeOutstanding) != beast::zero)) { JLOG(j.fatal()) << "Invariant failed: Loan with zero payments " "remaining has not been paid off"; return false; } // If `Loan.PaymentRemaining != 0` then the loan MUST NOT be fully paid // off if (after->at(sfPaymentRemaining) != 0 && after->at(sfTotalValueOutstanding) == beast::zero && after->at(sfPrincipalOutstanding) == beast::zero && after->at(sfManagementFeeOutstanding) == beast::zero) { JLOG(j.fatal()) << "Invariant failed: Loan with zero payments " "remaining has not been paid off"; return false; } if (before && (before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment))) { JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed"; return false; } // Must not be negative - STNumber for (auto const field : {&sfLoanServiceFee, &sfLatePaymentFee, &sfClosePaymentFee, &sfPrincipalOutstanding, &sfTotalValueOutstanding, &sfManagementFeeOutstanding}) { if (after->at(*field) < 0) { JLOG(j.fatal()) << "Invariant failed: " << field->getName() << " is negative "; return false; } } // Must be positive - STNumber for (auto const field : { &sfPeriodicPayment, }) { if (after->at(*field) <= 0) { JLOG(j.fatal()) << "Invariant failed: " << field->getName() << " is zero or negative "; return false; } } } return true; } ValidVault::Vault ValidVault::Vault::make(SLE const& from) { XRPL_ASSERT(from.getType() == ltVAULT, "ValidVault::Vault::make : from Vault object"); ValidVault::Vault self; self.key = from.key(); self.asset = from.at(sfAsset); self.pseudoId = from.getAccountID(sfAccount); self.owner = from.at(sfOwner); self.shareMPTID = from.getFieldH192(sfShareMPTID); self.assetsTotal = from.at(sfAssetsTotal); self.assetsAvailable = from.at(sfAssetsAvailable); self.assetsMaximum = from.at(sfAssetsMaximum); self.lossUnrealized = from.at(sfLossUnrealized); return self; } ValidVault::Shares ValidVault::Shares::make(SLE const& from) { XRPL_ASSERT( from.getType() == ltMPTOKEN_ISSUANCE, "ValidVault::Shares::make : from MPTokenIssuance object"); ValidVault::Shares self; self.share = MPTIssue(makeMptID(from.getFieldU32(sfSequence), from.getAccountID(sfIssuer))); self.sharesTotal = from.at(sfOutstandingAmount); self.sharesMaximum = from[~sfMaximumAmount].value_or(maxMPTokenAmount); return self; } void ValidVault::visitEntry( bool isDelete, std::shared_ptr const& before, std::shared_ptr const& after) { // If `before` is empty, this means an object is being created, in which // case `isDelete` must be false. Otherwise `before` and `after` are set and // `isDelete` indicates whether an object is being deleted or modified. XRPL_ASSERT( after != nullptr && (before != nullptr || !isDelete), "xrpl::ValidVault::visitEntry : some object is available"); // Number balanceDelta will capture the difference (delta) between "before" // state (zero if created) and "after" state (zero if destroyed), so the // invariants can validate that the change in account balances matches the // change in vault balances, stored to deltas_ at the end of this function. Number balanceDelta{}; std::int8_t sign = 0; if (before) { switch (before->getType()) { case ltVAULT: beforeVault_.push_back(Vault::make(*before)); break; case ltMPTOKEN_ISSUANCE: // At this moment we have no way of telling if this object holds // vault shares or something else. Save it for finalize. beforeMPTs_.push_back(Shares::make(*before)); balanceDelta = static_cast(before->getFieldU64(sfOutstandingAmount)); sign = 1; break; case ltMPTOKEN: balanceDelta = static_cast(before->getFieldU64(sfMPTAmount)); sign = -1; break; case ltACCOUNT_ROOT: case ltRIPPLE_STATE: balanceDelta = before->getFieldAmount(sfBalance); sign = -1; break; default:; } } if (!isDelete && after) { switch (after->getType()) { case ltVAULT: afterVault_.push_back(Vault::make(*after)); break; case ltMPTOKEN_ISSUANCE: // At this moment we have no way of telling if this object holds // vault shares or something else. Save it for finalize. afterMPTs_.push_back(Shares::make(*after)); balanceDelta -= Number(static_cast(after->getFieldU64(sfOutstandingAmount))); sign = 1; break; case ltMPTOKEN: balanceDelta -= Number(static_cast(after->getFieldU64(sfMPTAmount))); sign = -1; break; case ltACCOUNT_ROOT: case ltRIPPLE_STATE: balanceDelta -= Number(after->getFieldAmount(sfBalance)); sign = -1; break; default:; } } uint256 const key = (before ? before->key() : after->key()); // Append to deltas if sign is non-zero, i.e. an object of an interesting // type has been updated. A transaction may update an object even when // its balance has not changed, e.g. transaction fee equals the amount // transferred to the account. We intentionally do not compare balanceDelta // against zero, to avoid missing such updates. if (sign != 0) deltas_[key] = balanceDelta * sign; } bool ValidVault::finalize( STTx const& tx, TER const ret, XRPAmount const fee, ReadView const& view, beast::Journal const& j) { bool const enforce = view.rules().enabled(featureSingleAssetVault); if (!isTesSuccess(ret)) return true; // Do not perform checks if (afterVault_.empty() && beforeVault_.empty()) { if (hasPrivilege(tx, mustModifyVault)) { JLOG(j.fatal()) << // "Invariant failed: vault operation succeeded without modifying " "a vault"; XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : vault noop invariant"); return !enforce; } return true; // Not a vault operation } else if (!(hasPrivilege(tx, mustModifyVault) || hasPrivilege(tx, mayModifyVault))) { JLOG(j.fatal()) << // "Invariant failed: vault updated by a wrong transaction type"; XRPL_ASSERT( enforce, "xrpl::ValidVault::finalize : illegal vault transaction " "invariant"); return !enforce; // Also not a vault operation } if (beforeVault_.size() > 1 || afterVault_.size() > 1) { JLOG(j.fatal()) << // "Invariant failed: vault operation updated more than single vault"; XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : single vault invariant"); return !enforce; // That's all we can do here } auto const txnType = tx.getTxnType(); // We do special handling for ttVAULT_DELETE first, because it's the only // vault-modifying transaction without an "after" state of the vault if (afterVault_.empty()) { if (txnType != ttVAULT_DELETE) { JLOG(j.fatal()) << // "Invariant failed: vault deleted by a wrong transaction type"; XRPL_ASSERT( enforce, "xrpl::ValidVault::finalize : illegal vault deletion " "invariant"); return !enforce; // That's all we can do here } // Note, if afterVault_ is empty then we know that beforeVault_ is not // empty, as enforced at the top of this function auto const& beforeVault = beforeVault_[0]; // At this moment we only know a vault is being deleted and there // might be some MPTokenIssuance objects which are deleted in the // same transaction. Find the one matching this vault. auto const deletedShares = [&]() -> std::optional { for (auto const& e : beforeMPTs_) { if (e.share.getMptID() == beforeVault.shareMPTID) return std::move(e); } return std::nullopt; }(); if (!deletedShares) { JLOG(j.fatal()) << "Invariant failed: deleted vault must also " "delete shares"; XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : shares deletion invariant"); return !enforce; // That's all we can do here } bool result = true; if (deletedShares->sharesTotal != 0) { JLOG(j.fatal()) << "Invariant failed: deleted vault must have no " "shares outstanding"; result = false; } if (beforeVault.assetsTotal != zero) { JLOG(j.fatal()) << "Invariant failed: deleted vault must have no " "assets outstanding"; result = false; } if (beforeVault.assetsAvailable != zero) { JLOG(j.fatal()) << "Invariant failed: deleted vault must have no " "assets available"; result = false; } return result; } else if (txnType == ttVAULT_DELETE) { JLOG(j.fatal()) << "Invariant failed: vault deletion succeeded without " "deleting a vault"; XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : vault deletion invariant"); return !enforce; // That's all we can do here } // Note, `afterVault_.empty()` is handled above auto const& afterVault = afterVault_[0]; XRPL_ASSERT( beforeVault_.empty() || beforeVault_[0].key == afterVault.key, "xrpl::ValidVault::finalize : single vault operation"); auto const updatedShares = [&]() -> std::optional { // At this moment we only know that a vault is being updated and there // might be some MPTokenIssuance objects which are also updated in the // same transaction. Find the one matching the shares to this vault. // Note, we expect updatedMPTs collection to be extremely small. For // such collections linear search is faster than lookup. for (auto const& e : afterMPTs_) { if (e.share.getMptID() == afterVault.shareMPTID) return e; } auto const sleShares = view.read(keylet::mptIssuance(afterVault.shareMPTID)); return sleShares ? std::optional(Shares::make(*sleShares)) : std::nullopt; }(); bool result = true; // Universal transaction checks if (!beforeVault_.empty()) { auto const& beforeVault = beforeVault_[0]; if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId || afterVault.shareMPTID != beforeVault.shareMPTID) { JLOG(j.fatal()) << "Invariant failed: violation of vault immutable data"; result = false; } } if (!updatedShares) { JLOG(j.fatal()) << "Invariant failed: updated vault must have shares"; XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : vault has shares invariant"); return !enforce; // That's all we can do here } if (updatedShares->sharesTotal == 0) { if (afterVault.assetsTotal != zero) { JLOG(j.fatal()) << "Invariant failed: updated zero sized " "vault must have no assets outstanding"; result = false; } if (afterVault.assetsAvailable != zero) { JLOG(j.fatal()) << "Invariant failed: updated zero sized " "vault must have no assets available"; result = false; } } else if (updatedShares->sharesTotal > updatedShares->sharesMaximum) { JLOG(j.fatal()) // << "Invariant failed: updated shares must not exceed maximum " << updatedShares->sharesMaximum; result = false; } if (afterVault.assetsAvailable < zero) { JLOG(j.fatal()) << "Invariant failed: assets available must be positive"; result = false; } if (afterVault.assetsAvailable > afterVault.assetsTotal) { JLOG(j.fatal()) << "Invariant failed: assets available must " "not be greater than assets outstanding"; result = false; } else if (afterVault.lossUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable) { JLOG(j.fatal()) // << "Invariant failed: loss unrealized must not exceed " "the difference between assets outstanding and available"; result = false; } if (afterVault.assetsTotal < zero) { JLOG(j.fatal()) << "Invariant failed: assets outstanding must be positive"; result = false; } if (afterVault.assetsMaximum < zero) { JLOG(j.fatal()) << "Invariant failed: assets maximum must be positive"; result = false; } // Thanks to this check we can simply do `assert(!beforeVault_.empty()` when // enforcing invariants on transaction types other than ttVAULT_CREATE if (beforeVault_.empty() && txnType != ttVAULT_CREATE) { JLOG(j.fatal()) << // "Invariant failed: vault created by a wrong transaction type"; XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : vault creation invariant"); return !enforce; // That's all we can do here } if (!beforeVault_.empty() && afterVault.lossUnrealized != beforeVault_[0].lossUnrealized && txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY) { JLOG(j.fatal()) << // "Invariant failed: vault transaction must not change loss " "unrealized"; result = false; } auto const beforeShares = [&]() -> std::optional { if (beforeVault_.empty()) return std::nullopt; auto const& beforeVault = beforeVault_[0]; for (auto const& e : beforeMPTs_) { if (e.share.getMptID() == beforeVault.shareMPTID) return std::move(e); } return std::nullopt; }(); if (!beforeShares && (tx.getTxnType() == ttVAULT_DEPOSIT || // tx.getTxnType() == ttVAULT_WITHDRAW || // tx.getTxnType() == ttVAULT_CLAWBACK)) { JLOG(j.fatal()) << "Invariant failed: vault operation succeeded " "without updating shares"; XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : shares noop invariant"); return !enforce; // That's all we can do here } auto const& vaultAsset = afterVault.asset; auto const deltaAssets = [&](AccountID const& id) -> std::optional { auto const get = // [&](auto const& it, std::int8_t sign = 1) -> std::optional { if (it == deltas_.end()) return std::nullopt; return it->second * sign; }; return std::visit( [&](TIss const& issue) { if constexpr (std::is_same_v) { if (isXRP(issue)) return get(deltas_.find(keylet::account(id).key)); return get( deltas_.find(keylet::line(id, issue).key), id > issue.getIssuer() ? -1 : 1); } else if constexpr (std::is_same_v) { return get(deltas_.find(keylet::mptoken(issue.getMptID(), id).key)); } }, vaultAsset.value()); }; auto const deltaAssetsTxAccount = [&]() -> std::optional { auto ret = deltaAssets(tx[sfAccount]); // Nothing returned or not XRP transaction if (!ret.has_value() || !vaultAsset.native()) return ret; // Delegated transaction; no need to compensate for fees if (auto const delegate = tx[~sfDelegate]; delegate.has_value() && *delegate != tx[sfAccount]) return ret; *ret += fee.drops(); if (*ret == zero) return std::nullopt; return ret; }; auto const deltaShares = [&](AccountID const& id) -> std::optional { auto const it = [&]() { if (id == afterVault.pseudoId) return deltas_.find(keylet::mptIssuance(afterVault.shareMPTID).key); return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key); }(); return it != deltas_.end() ? std::optional(it->second) : std::nullopt; }; auto const vaultHoldsNoAssets = [&](Vault const& vault) { return vault.assetsAvailable == 0 && vault.assetsTotal == 0; }; // Technically this does not need to be a lambda, but it's more // convenient thanks to early "return false"; the not-so-nice // alternatives are several layers of nested if/else or more complex // (i.e. brittle) if statements. result &= [&]() { switch (txnType) { case ttVAULT_CREATE: { bool result = true; if (!beforeVault_.empty()) { JLOG(j.fatal()) // << "Invariant failed: create operation must not have " "updated a vault"; result = false; } if (afterVault.assetsAvailable != zero || afterVault.assetsTotal != zero || afterVault.lossUnrealized != zero || updatedShares->sharesTotal != 0) { JLOG(j.fatal()) // << "Invariant failed: created vault must be empty"; result = false; } if (afterVault.pseudoId != updatedShares->share.getIssuer()) { JLOG(j.fatal()) // << "Invariant failed: shares issuer and vault " "pseudo-account must be the same"; result = false; } auto const sleSharesIssuer = view.read(keylet::account(updatedShares->share.getIssuer())); if (!sleSharesIssuer) { JLOG(j.fatal()) // << "Invariant failed: shares issuer must exist"; return false; } if (!isPseudoAccount(sleSharesIssuer)) { JLOG(j.fatal()) // << "Invariant failed: shares issuer must be a " "pseudo-account"; result = false; } if (auto const vaultId = (*sleSharesIssuer)[~sfVaultID]; !vaultId || *vaultId != afterVault.key) { JLOG(j.fatal()) // << "Invariant failed: shares issuer pseudo-account " "must point back to the vault"; result = false; } return result; } case ttVAULT_SET: { bool result = true; XRPL_ASSERT( !beforeVault_.empty(), "xrpl::ValidVault::finalize : set updated a vault"); auto const& beforeVault = beforeVault_[0]; auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (vaultDeltaAssets) { JLOG(j.fatal()) << // "Invariant failed: set must not change vault balance"; result = false; } if (beforeVault.assetsTotal != afterVault.assetsTotal) { JLOG(j.fatal()) << // "Invariant failed: set must not change assets " "outstanding"; result = false; } if (afterVault.assetsMaximum > zero && afterVault.assetsTotal > afterVault.assetsMaximum) { JLOG(j.fatal()) << // "Invariant failed: set assets outstanding must not " "exceed assets maximum"; result = false; } if (beforeVault.assetsAvailable != afterVault.assetsAvailable) { JLOG(j.fatal()) << // "Invariant failed: set must not change assets " "available"; result = false; } if (beforeShares && updatedShares && beforeShares->sharesTotal != updatedShares->sharesTotal) { JLOG(j.fatal()) << // "Invariant failed: set must not change shares " "outstanding"; result = false; } return result; } case ttVAULT_DEPOSIT: { bool result = true; XRPL_ASSERT( !beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault"); auto const& beforeVault = beforeVault_[0]; auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!vaultDeltaAssets) { JLOG(j.fatal()) << // "Invariant failed: deposit must change vault balance"; return false; // That's all we can do } if (*vaultDeltaAssets > tx[sfAmount]) { JLOG(j.fatal()) << // "Invariant failed: deposit must not change vault " "balance by more than deposited amount"; result = false; } if (*vaultDeltaAssets <= zero) { JLOG(j.fatal()) << // "Invariant failed: deposit must increase vault balance"; result = false; } // Any payments (including deposits) made by the issuer // do not change their balance, but create funds instead. bool const issuerDeposit = [&]() -> bool { if (vaultAsset.native()) return false; return tx[sfAccount] == vaultAsset.getIssuer(); }(); if (!issuerDeposit) { auto const accountDeltaAssets = deltaAssetsTxAccount(); if (!accountDeltaAssets) { JLOG(j.fatal()) << // "Invariant failed: deposit must change depositor " "balance"; return false; } if (*accountDeltaAssets >= zero) { JLOG(j.fatal()) << // "Invariant failed: deposit must decrease depositor " "balance"; result = false; } if (*accountDeltaAssets * -1 != *vaultDeltaAssets) { JLOG(j.fatal()) << // "Invariant failed: deposit must change vault and " "depositor balance by equal amount"; result = false; } } if (afterVault.assetsMaximum > zero && afterVault.assetsTotal > afterVault.assetsMaximum) { JLOG(j.fatal()) << // "Invariant failed: deposit assets outstanding must not " "exceed assets maximum"; result = false; } auto const accountDeltaShares = deltaShares(tx[sfAccount]); if (!accountDeltaShares) { JLOG(j.fatal()) << // "Invariant failed: deposit must change depositor " "shares"; return false; // That's all we can do } if (*accountDeltaShares <= zero) { JLOG(j.fatal()) << // "Invariant failed: deposit must increase depositor " "shares"; result = false; } auto const vaultDeltaShares = deltaShares(afterVault.pseudoId); if (!vaultDeltaShares || *vaultDeltaShares == zero) { JLOG(j.fatal()) << // "Invariant failed: deposit must change vault shares"; return false; // That's all we can do } if (*vaultDeltaShares * -1 != *accountDeltaShares) { JLOG(j.fatal()) << // "Invariant failed: deposit must change depositor and " "vault shares by equal amount"; result = false; } if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal) { JLOG(j.fatal()) << "Invariant failed: deposit and assets " "outstanding must add up"; result = false; } if (beforeVault.assetsAvailable + *vaultDeltaAssets != afterVault.assetsAvailable) { JLOG(j.fatal()) << "Invariant failed: deposit and assets " "available must add up"; result = false; } return result; } case ttVAULT_WITHDRAW: { bool result = true; XRPL_ASSERT( !beforeVault_.empty(), "xrpl::ValidVault::finalize : withdrawal updated a " "vault"); auto const& beforeVault = beforeVault_[0]; auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!vaultDeltaAssets) { JLOG(j.fatal()) << "Invariant failed: withdrawal must " "change vault balance"; return false; // That's all we can do } if (*vaultDeltaAssets >= zero) { JLOG(j.fatal()) << "Invariant failed: withdrawal must " "decrease vault balance"; result = false; } // Any payments (including withdrawal) going to the issuer // do not change their balance, but destroy funds instead. bool const issuerWithdrawal = [&]() -> bool { if (vaultAsset.native()) return false; auto const destination = tx[~sfDestination].value_or(tx[sfAccount]); return destination == vaultAsset.getIssuer(); }(); if (!issuerWithdrawal) { auto const accountDeltaAssets = deltaAssetsTxAccount(); auto const otherAccountDelta = [&]() -> std::optional { if (auto const destination = tx[~sfDestination]; destination && *destination != tx[sfAccount]) return deltaAssets(*destination); return std::nullopt; }(); if (accountDeltaAssets.has_value() == otherAccountDelta.has_value()) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must change one " "destination balance"; return false; } auto const destinationDelta = // accountDeltaAssets ? *accountDeltaAssets : *otherAccountDelta; if (destinationDelta <= zero) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must increase " "destination balance"; result = false; } if (*vaultDeltaAssets * -1 != destinationDelta) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must change vault " "and destination balance by equal amount"; result = false; } } auto const accountDeltaShares = deltaShares(tx[sfAccount]); if (!accountDeltaShares) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must change depositor " "shares"; return false; } if (*accountDeltaShares >= zero) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must decrease depositor " "shares"; result = false; } auto const vaultDeltaShares = deltaShares(afterVault.pseudoId); if (!vaultDeltaShares || *vaultDeltaShares == zero) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must change vault shares"; return false; // That's all we can do } if (*vaultDeltaShares * -1 != *accountDeltaShares) { JLOG(j.fatal()) << // "Invariant failed: withdrawal must change depositor " "and vault shares by equal amount"; result = false; } // Note, vaultBalance is negative (see check above) if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal) { JLOG(j.fatal()) << "Invariant failed: withdrawal and " "assets outstanding must add up"; result = false; } if (beforeVault.assetsAvailable + *vaultDeltaAssets != afterVault.assetsAvailable) { JLOG(j.fatal()) << "Invariant failed: withdrawal and " "assets available must add up"; result = false; } return result; } case ttVAULT_CLAWBACK: { bool result = true; XRPL_ASSERT( !beforeVault_.empty(), "xrpl::ValidVault::finalize : clawback updated a vault"); auto const& beforeVault = beforeVault_[0]; if (vaultAsset.native() || vaultAsset.getIssuer() != tx[sfAccount]) { // The owner can use clawback to force-burn shares when the // vault is empty but there are outstanding shares if (!(beforeShares && beforeShares->sharesTotal > 0 && vaultHoldsNoAssets(beforeVault) && beforeVault.owner == tx[sfAccount])) { JLOG(j.fatal()) << // "Invariant failed: clawback may only be performed " "by the asset issuer, or by the vault owner of an " "empty vault"; return false; // That's all we can do } } auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (vaultDeltaAssets) { if (*vaultDeltaAssets >= zero) { JLOG(j.fatal()) << // "Invariant failed: clawback must decrease vault " "balance"; result = false; } if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal) { JLOG(j.fatal()) << // "Invariant failed: clawback and assets outstanding " "must add up"; result = false; } if (beforeVault.assetsAvailable + *vaultDeltaAssets != afterVault.assetsAvailable) { JLOG(j.fatal()) << // "Invariant failed: clawback and assets available " "must add up"; result = false; } } else if (!vaultHoldsNoAssets(beforeVault)) { JLOG(j.fatal()) << // "Invariant failed: clawback must change vault balance"; return false; // That's all we can do } auto const accountDeltaShares = deltaShares(tx[sfHolder]); if (!accountDeltaShares) { JLOG(j.fatal()) << // "Invariant failed: clawback must change holder shares"; return false; // That's all we can do } if (*accountDeltaShares >= zero) { JLOG(j.fatal()) << // "Invariant failed: clawback must decrease holder " "shares"; result = false; } auto const vaultDeltaShares = deltaShares(afterVault.pseudoId); if (!vaultDeltaShares || *vaultDeltaShares == zero) { JLOG(j.fatal()) << // "Invariant failed: clawback must change vault shares"; return false; // That's all we can do } if (*vaultDeltaShares * -1 != *accountDeltaShares) { JLOG(j.fatal()) << // "Invariant failed: clawback must change holder and " "vault shares by equal amount"; result = false; } return result; } case ttLOAN_SET: case ttLOAN_MANAGE: case ttLOAN_PAY: { // TBD return true; } default: // LCOV_EXCL_START UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type"); return false; // LCOV_EXCL_STOP } }(); if (!result) { // The comment at the top of this file starting with "assert(enforce)" // explains this assert. XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : vault invariants"); return !enforce; } return true; } } // namespace xrpl