Skip to content

Commit

Permalink
Adds invariant AccountRootsDeletedClean:
Browse files Browse the repository at this point in the history
* Checks that a deleted account doesn't leave any directly
  accessible artifacts behind.
* Always tests, but only changes the transaction result if
  featureInvariantsV1_1 is enabled.
* Unit tests.

* Resolves #4638
  • Loading branch information
ximinez committed Oct 23, 2023
1 parent 495bc7a commit d9d403e
Show file tree
Hide file tree
Showing 5 changed files with 305 additions and 3 deletions.
1 change: 1 addition & 0 deletions Builds/CMake/RippledCore.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -1127,5 +1127,6 @@ if (tests)
# these two seem to produce conflicts in beast teardown template methods
src/test/rpc/ValidatorRPC_test.cpp
src/test/rpc/ShardArchiveHandler_test.cpp
src/test/ledger/Invariants_test.cpp
PROPERTIES SKIP_UNITY_BUILD_INCLUSION TRUE)
endif () #tests
95 changes: 95 additions & 0 deletions src/ripple/app/tx/impl/InvariantCheck.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,101 @@ AccountRootsNotDeleted::finalize(

//------------------------------------------------------------------------------

void
AccountRootsDeletedClean::visitEntry(
bool isDelete,
std::shared_ptr<SLE const> const& before,
std::shared_ptr<SLE const> const&)
{
if (isDelete && before && before->getType() == ltACCOUNT_ROOT)
accountsDeleted_.emplace_back(before);
}

bool
AccountRootsDeletedClean::finalize(
STTx const& tx,
TER const result,
XRPAmount const,
ReadView const& view,
beast::Journal const& j)
{
// This list should include all of the keylet functions that take a single
// AccountID parameter, except nftpage_max, because it is exercised below
// using the NFT page lookup logic. (nftpage_min is included because it
// should _never_ be present.)
using fType = std::function<Keylet(AccountID const& id)>;
static std::array<fType, 4> const keyletfuncs{
&keylet::account,
&keylet::ownerDir,
&keylet::signers,
&keylet::nftpage_min,
//&keylet::nftpage_max
};

// 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
bool const enforce = view.rules().enabled(featureInvariantsV1_1);

auto const objectExists = [&view, enforce, &j](auto const& keylet) {
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";
assert(enforce);
return true;
}
return false;
};

for (auto const& accountSLE : accountsDeleted_)
{
auto const accountID = accountSLE->getAccountID(sfAccount);
// Simple types
for (auto const& keyletfunc : keyletfuncs)
{
if (objectExists(std::invoke(keyletfunc, accountID)) && enforce)
return false;
}

// NFT pages
{
Keylet const first = keylet::nftpage_min(accountID);
Keylet const last = keylet::nftpage_max(accountID);

uint256 key =
view.succ(first.key, last.key.next()).value_or(last.key);

// current page
if (objectExists(Keylet{ltNFTOKEN_PAGE, key}) && enforce)
return false;
}

// Keys directly stored in the AccountRoot object
if (auto const ammKey = accountSLE->at(~sfAMMID))
{
if (objectExists(keylet::amm(*ammKey)) && enforce)
return false;
}
}

return true;
}

//------------------------------------------------------------------------------

void
LedgerEntryTypesMatch::visitEntry(
bool,
Expand Down
31 changes: 31 additions & 0 deletions src/ripple/app/tx/impl/InvariantCheck.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,36 @@ class AccountRootsNotDeleted
beast::Journal const&);
};

/**
* @brief Invariant: a deleted account must not have any objects left
*
* We iterate all deleted account roots, and ensure that there are no
* objects left that are directly accessible with that account's ID.
*
* There should only be one deleted account, but that's checked by
* AccountRootsNotDeleted. This invariant will handle multiple deleted account
* roots without a problem.
*/
class AccountRootsDeletedClean
{
std::vector<std::shared_ptr<SLE const>> accountsDeleted_;

public:
void
visitEntry(
bool,
std::shared_ptr<SLE const> const&,
std::shared_ptr<SLE const> const&);

bool
finalize(
STTx const&,
TER const,
XRPAmount const,
ReadView const&,
beast::Journal const&);
};

/**
* @brief Invariant: An account XRP balance must be in XRP and take a value
* between 0 and INITIAL_XRP drops, inclusive.
Expand Down Expand Up @@ -423,6 +453,7 @@ class ValidClawback
using InvariantChecks = std::tuple<
TransactionFeeCheck,
AccountRootsNotDeleted,
AccountRootsDeletedClean,
LedgerEntryTypesMatch,
XRPBalanceChecks,
XRPNotCreated,
Expand Down
1 change: 1 addition & 0 deletions src/ripple/app/tx/impl/details/NFTokenUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ getPageForToken(
: carr[0].getFieldH256(sfNFTokenID);

auto np = std::make_shared<SLE>(keylet::nftpage(base, tokenIDForNewPage));
assert(np->key() > base.key);
np->setFieldArray(sfNFTokens, narr);
np->setFieldH256(sfNextPageMin, cp->key());

Expand Down
180 changes: 177 additions & 3 deletions src/test/ledger/Invariants_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@
#include <ripple/protocol/STLedgerEntry.h>
#include <boost/algorithm/string/predicate.hpp>
#include <test/jtx.h>
#include <test/jtx/AMM.h>
#include <test/jtx/Env.h>

namespace ripple {

class Invariants_test : public beast::unit_test::suite
{
// The optional Preclose function is used to process additional transactions
// on the ledger after creating two accounts, but before closing it, and
// before the Precheck function. These should only be valid functions, and
// not direct manipulations. Preclose is not commonly used.
using Preclose = std::function<bool(
test::jtx::Account const& a,
test::jtx::Account const& b,
test::jtx::Env& env)>;

// this is common setup/method for running a failing invariant check. The
// precheck function is used to manipulate the ApplyContext with view
// changes that will cause the check to fail.
Expand All @@ -44,16 +54,18 @@ class Invariants_test : public beast::unit_test::suite
Precheck const& precheck,
XRPAmount fee = XRPAmount{},
STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}},
std::initializer_list<TER> ters = {
tecINVARIANT_FAILED,
tefINVARIANT_FAILED})
std::initializer_list<TER> ters =
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
Preclose const& preclose = {})
{
using namespace test::jtx;
Env env{*this};

Account A1{"A1"};
Account A2{"A2"};
env.fund(XRP(1000), A1, A2);
if (preclose)
BEAST_EXPECT(preclose(A1, A2, env));
env.close();

OpenView ov{*env.current()};
Expand Down Expand Up @@ -162,6 +174,167 @@ class Invariants_test : public beast::unit_test::suite
STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
}

void
testAccountRootsDeletedClean()
{
using namespace test::jtx;
testcase << "account root deletion left artifact";

// This list should include all of the keylet functions that take a
// single AccountID parameter
using fType = std::function<Keylet(AccountID const& id)>;
static std::map<std::string, fType> const keyletfuncs{
// Skip the account root, since that's what's being deleted.
//{"AccountRoot", &keylet::account},
{"DirectoryNode", &keylet::ownerDir},
{"SignerList", &keylet::signers},
// It's normally impossible to create an item at nftpage_min, but
// test it anyway, since the invariant checks for it.
{"NFTokenPage", &keylet::nftpage_min},
{"NFTokenPage", &keylet::nftpage_max}};

for (auto const& item : keyletfuncs)
{
auto const& type = item.first;
fType const& keyletfunc = item.second;
doInvariantCheck(
{{"account deletion left behind a " + type + " object"}},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
// Add an object to the ledger for account A1, then delete
// A1
auto const a1 = A1.id();
auto const sleA1 = ac.view().peek(keylet::account(a1));
if (!sleA1)
return false;

auto const key = std::invoke(keyletfunc, a1);
auto const newSLE = std::make_shared<SLE>(key);
ac.view().insert(newSLE);
ac.view().erase(sleA1);

return true;
},
XRPAmount{},
STTx{ttACCOUNT_DELETE, [](STObject& tx) {}});
};

// NFT special case
doInvariantCheck(
{{"account deletion left behind a NFTokenPage object"}},
[&](Account const& A1, Account const&, ApplyContext& ac) {
// remove an account from the view
auto const sle = ac.view().peek(keylet::account(A1.id()));
if (!sle)
return false;
ac.view().erase(sle);
return true;
},
XRPAmount{},
STTx{ttACCOUNT_DELETE, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
[&](Account const& A1, Account const&, Env& env) {
// Preclose callback to mint the NFT which will be deleted in
// the Precheck callback above.
env(token::mint(A1));

return true;
});

// AMM special cases
AccountID ammAcctID;
uint256 ammKey;
Issue ammIssue;
doInvariantCheck(
{{"account deletion left behind a DirectoryNode object"}},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
// Delete the AMM account without cleaning up the directory or
// deleting the AMM object
auto const sle = ac.view().peek(keylet::account(ammAcctID));
if (!sle)
return false;

BEAST_EXPECT(sle->at(~sfAMMID));
BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);

ac.view().erase(sle);

return true;
},
XRPAmount{},
STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
[&](Account const& A1, Account const& A2, Env& env) {
// Preclose callback to create the AMM which will be deleted in
// the Precheck callback above.
AMM amm(env, A1, XRP(100), A1["USD"](50));
ammAcctID = amm.ammAccount();
ammKey = amm.ammID();
ammIssue = amm.lptIssue();
return true;
});
doInvariantCheck(
{{"account deletion left behind a AMM object"}},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
// Delete the AMM account, cleaning up the directory, but
// without deleting the AMM object
auto const sle = ac.view().peek(keylet::account(ammAcctID));
if (!sle)
return false;

BEAST_EXPECT(sle->at(~sfAMMID));
BEAST_EXPECT(sle->at(~sfAMMID) == ammKey);

for (auto const trustKeylet :
{keylet::line(ammAcctID, A1["USD"]),
keylet::line(A1, ammIssue)})
{
if (auto const line = ac.view().peek(trustKeylet); !line)
{
return false;
}
else
{
STAmount lowLimit = line->at(sfLowLimit);
STAmount highLimit = line->at(sfHighLimit);
BEAST_EXPECT(
trustDelete(
ac.view(),
line,
lowLimit.getIssuer(),
highLimit.getIssuer(),
ac.journal) == tesSUCCESS);
}
}

auto const ammSle = ac.view().peek(keylet::amm(ammKey));
if (!BEAST_EXPECT(ammSle))
return false;
auto const ownerDirKeylet = keylet::ownerDir(ammAcctID);

BEAST_EXPECT(ac.view().dirRemove(
ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false));
BEAST_EXPECT(
!ac.view().exists(ownerDirKeylet) ||
ac.view().emptyDirDelete(ownerDirKeylet));

ac.view().erase(sle);

return true;
},
XRPAmount{},
STTx{ttAMM_WITHDRAW, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
[&](Account const& A1, Account const& A2, Env& env) {
// Delete the AMM account, cleaning up the directory, but
// without deleting the AMM object
AMM amm(env, A1, XRP(100), A1["USD"](50));
ammAcctID = amm.ammAccount();
ammKey = amm.ammID();
ammIssue = amm.lptIssue();
return true;
});
}

void
testTypesMatch()
{
Expand Down Expand Up @@ -467,6 +640,7 @@ class Invariants_test : public beast::unit_test::suite
{
testXRPNotCreated();
testAccountRootsNotRemoved();
testAccountRootsDeletedClean();
testTypesMatch();
testNoXRPTrustLine();
testXRPBalanceCheck();
Expand Down

0 comments on commit d9d403e

Please sign in to comment.