Merge remote-tracking branch 'Transia-RnD-rippled/dangell7/beneficiary' into develop

# Conflicts:
#	include/xrpl/protocol/detail/ledger_entries.macro
#	include/xrpl/tx/invariants/InvariantCheck.h
#	src/libxrpl/protocol/Indexes.cpp
#	src/libxrpl/tx/Transactor.cpp
This commit is contained in:
Denis Angell
2026-09-13 19:41:00 -04:00
16 changed files with 1197 additions and 1 deletions

View File

@@ -211,6 +211,12 @@ signerList(AccountID const& account) noexcept;
Keylet
sponsorship(AccountID const& sponsor, AccountID const& sponsee) noexcept;
/**
* An account's beneficiary designation. One per account.
*/
Keylet
beneficiary(AccountID const& account) noexcept;
/**
* A Check
*/

View File

@@ -141,6 +141,13 @@ tenthBipsOfValue(T value, TenthBips<TBips> bips)
return value * bips.value() / kTenthBipsPerUnity.value();
}
/**
* The longest inactivity period a beneficiary designation may require, ten
* years in seconds. Long enough for the intended use and short enough that the
* value still means something.
*/
constexpr std::uint32_t kMaxBeneficiaryTimeLock = 10 * 365 * 24 * 60 * 60;
namespace lending {
/**
* The maximum management fee rate allowed by a loan broker in 1/10 bips.

View File

@@ -161,3 +161,4 @@ XRPL_FEATURE(AMMCurves, Supported::Yes, VoteBehavior::DefaultN
XRPL_FEATURE(OfferQualifiers, Supported::Yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(Subscription, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(Repo, Supported::No, VoteBehavior::DefaultNo)
XRPL_FEATURE(Beneficiary, Supported::No, VoteBehavior::DefaultNo)

View File

@@ -899,5 +899,19 @@ LEDGER_ENTRY(ltREPO, 0x0098, Repo, repo, ({
{sfPreviousTxnLgrSeq, SoeRequired},
}))
/** A designation of an account to receive this account's regular key after a
period of inactivity.
\sa keylet::beneficiary
*/
LEDGER_ENTRY(ltBENEFICIARY, 0x0099, Beneficiary, beneficiary, ({
{sfAccount, SoeRequired},
{sfBeneficiary, SoeRequired},
{sfTimeLock, SoeRequired},
{sfOwnerNode, SoeRequired},
{sfPreviousTxnID, SoeRequired},
{sfPreviousTxnLgrSeq, SoeRequired},
}))
#undef EXPAND
#undef LEDGER_ENTRY_DUPLICATE

View File

@@ -569,3 +569,6 @@ TYPED_SFIELD(sfMaturityDate, UINT32, 112)
TYPED_SFIELD(sfRepoID, UINT256, 50)
TYPED_SFIELD(sfCollateralAmount, AMOUNT, 45)
TYPED_SFIELD(sfPurchasePrice, AMOUNT, 46)
TYPED_SFIELD(sfTimeLock, UINT32, 113)
TYPED_SFIELD(sfLastInteraction, UINT32, 114)
TYPED_SFIELD(sfBeneficiary, ACCOUNT, 33)

View File

@@ -1557,3 +1557,9 @@ TRANSACTION(ttREPO_DEFAULT, 128, RepoDefault,
({
{sfRepoID, SoeRequired},
}))
TRANSACTION(ttBENEFICIARY_SET, 129, BeneficiarySet,
({.amendment = featureBeneficiary}),
({
{sfBeneficiary, SoeOptional},
{sfTimeLock, SoeOptional},
}))

View File

@@ -375,6 +375,13 @@ public:
beast::Journal j);
protected:
/**
* Whether this transaction was signed by the account's beneficiary rather
* than by one of the account's own keys.
*/
bool
signedByBeneficiary() const;
TER
apply();

View File

@@ -480,6 +480,42 @@ private:
};
// additional invariant checks can be declared above and then added to this
// tuple
/**
* @brief Invariant: a beneficiary designation is well formed and paired with
* its timestamp.
*
* The following checks are made for every transaction:
* - An account has a Beneficiary entry if and only if its AccountRoot carries
* sfLastInteraction.
* - The entry's Account is never equal to its Beneficiary, and TimeLock is
* neither zero nor above kMaxBeneficiaryTimeLock.
* - The entry's Account never changes after creation.
* - A Beneficiary entry is deleted only by BeneficiarySet.
* - sfLastInteraction never moves backwards.
*/
class ValidBeneficiary
{
// <before, after>. before is unseated when the entry is being created.
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> entries_;
// The accounts whose designation appeared or vanished this transaction, and
// the accounts whose sfLastInteraction did, so the two sets can be compared.
std::set<AccountID> designationAdded_;
std::set<AccountID> designationRemoved_;
std::set<AccountID> stampAdded_;
std::set<AccountID> stampRemoved_;
bool deleted_ = false;
bool stampWentBackwards_ = false;
public:
void
visitEntry(bool, SLE::const_ref, SLE::const_ref);
[[nodiscard]] bool
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&);
};
using InvariantChecks = std::tuple<
TransactionFeeCheck,
AccountRootsNotDeleted,
@@ -520,7 +556,8 @@ using InvariantChecks = std::tuple<
SponsorshipAccountCountMatchesField,
ValidTokenIssuance,
ValidCouponSchedule,
ValidBallot>;
ValidBallot,
ValidBeneficiary>;
/**
* @brief get a tuple of all invariant checks

View File

@@ -0,0 +1,44 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
namespace xrpl {
class BeneficiarySet : public Transactor
{
public:
static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal;
explicit BeneficiarySet(ApplyContext& ctx) : Transactor(ctx)
{
}
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
void
visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override;
[[nodiscard]] bool
finalizeInvariants(
STTx const& tx,
TER result,
XRPAmount fee,
ReadView const& view,
beast::Journal const& j) override;
};
} // namespace xrpl

View File

@@ -120,6 +120,7 @@ enum class LedgerNameSpace : std::uint16_t {
BallotVote = 'v',
Subscription = 'w',
Repo = 'M',
Beneficiary = 'j',
// No longer used or supported. Left here to reserve the space to avoid accidental reuse.
Generator [[deprecated]] = 'g',
@@ -370,6 +371,12 @@ sponsorship(AccountID const& sponsor, AccountID const& sponsee) noexcept
return {ltSPONSORSHIP, indexHash(LedgerNameSpace::Sponsorship, sponsor, sponsee)};
}
Keylet
beneficiary(AccountID const& account) noexcept
{
return {ltBENEFICIARY, indexHash(LedgerNameSpace::Beneficiary, account)};
}
Keylet
check(AccountID const& id, SeqProxy const& seq) noexcept
{

View File

@@ -886,6 +886,23 @@ Transactor::preCompute()
XRPL_ASSERT(accountID_ != beast::kZero, "xrpl::Transactor::preCompute : nonzero account");
}
bool
Transactor::signedByBeneficiary() const
{
auto const& signingPubKey = ctx_.tx.getSigningPubKey();
// Multi-signed transactions carry no signing key here and never reach the
// beneficiary path, which is single-sign only.
if (signingPubKey.empty() || !publicKeyType(makeSlice(signingPubKey)))
return false;
auto const sle = view().read(keylet::beneficiary(accountID_));
if (!sle)
return false;
return (*sle)[sfBeneficiary] == calcAccountID(PublicKey(makeSlice(signingPubKey)));
}
TER
Transactor::apply()
{
@@ -916,6 +933,15 @@ Transactor::apply()
if (sle->isFieldPresent(sfAccountTxnID))
sle->setFieldH256(sfAccountTxnID, ctx_.tx.getTransactionID());
// The field is present only while a beneficiary designation exists, and
// it records the owner's own activity: a transaction the beneficiary
// signed must not reset the timer, or the beneficiary's first
// transaction would shut the door behind it.
if (view().rules().enabled(featureBeneficiary) && sle->isFieldPresent(sfLastInteraction) &&
!signedByBeneficiary())
sle->setFieldU32(
sfLastInteraction, view().parentCloseTime().time_since_epoch().count());
view().update(sle);
}
@@ -1073,6 +1099,26 @@ Transactor::checkSingleSign(
}
}
// Signed by the beneficiary, once the account has been silent for the
// designated period. The designation is a second regular key that only
// starts working after the time lock, so the owner is never displaced and
// nothing about the account's own keys changes.
if (view.rules().enabled(featureBeneficiary))
{
if (auto const sle = view.read(keylet::beneficiary(idAccount));
sle && (*sle)[sfBeneficiary] == idSigner)
{
auto const last = (*sleAccount)[~sfLastInteraction];
auto const now = view.parentCloseTime().time_since_epoch().count();
if (last && now >= *last && now - *last >= (*sle)[sfTimeLock])
return tesSUCCESS;
JLOG(j.trace()) << "checkSingleSign: the account is not yet silent enough for its "
"beneficiary to sign";
return tefBAD_AUTH;
}
}
// Signed with any other key.
return tefBAD_AUTH;
}

View File

@@ -0,0 +1,128 @@
#include <xrpl/basics/Log.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/invariants/InvariantCheck.h>
namespace xrpl {
void
ValidBeneficiary::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
if (before && before->getType() == ltBENEFICIARY)
{
if (isDelete)
{
designationRemoved_.insert((*before)[sfAccount]);
deleted_ = true;
}
}
if (after && after->getType() == ltBENEFICIARY)
{
entries_.emplace_back(before, after);
if (!before)
designationAdded_.insert((*after)[sfAccount]);
}
// The timestamp lives on the AccountRoot, so its comings and goings are
// tracked separately and compared against the entries at the end.
auto const stamp = [](SLE::const_ref sle) -> std::optional<std::uint32_t> {
if (!sle || sle->getType() != ltACCOUNT_ROOT)
return std::nullopt;
return (*sle)[~sfLastInteraction];
};
if (after && after->getType() == ltACCOUNT_ROOT)
{
auto const wasStamped = before ? stamp(before) : std::nullopt;
auto const isStamped = isDelete ? std::nullopt : stamp(after);
if (!wasStamped && isStamped)
stampAdded_.insert((*after)[sfAccount]);
else if (wasStamped && !isStamped)
stampRemoved_.insert((*after)[sfAccount]);
else if (wasStamped && isStamped && *isStamped < *wasStamped)
stampWentBackwards_ = true;
}
}
bool
ValidBeneficiary::finalize(
STTx const& tx,
TER const,
XRPAmount const,
ReadView const& view,
beast::Journal const& j)
{
if (stampWentBackwards_)
{
JLOG(j.fatal()) << "Invariant failed: LastInteraction moved backwards";
return false;
}
// A designation and its timestamp are created together and removed
// together, so the two sets of accounts must match exactly.
if (designationAdded_ != stampAdded_)
{
JLOG(j.fatal()) << "Invariant failed: a beneficiary designation was created without its "
"LastInteraction, or the reverse";
return false;
}
if (designationRemoved_ != stampRemoved_)
{
JLOG(j.fatal()) << "Invariant failed: a beneficiary designation was removed without its "
"LastInteraction, or the reverse";
return false;
}
if (deleted_)
{
switch (tx.getTxnType())
{
case ttBENEFICIARY_SET:
break;
default:
JLOG(j.fatal())
<< "Invariant failed: a beneficiary designation was deleted by transaction "
"type "
<< tx.getTxnType();
return false;
}
}
for (auto const& [before, after] : entries_)
{
if ((*after)[sfAccount] == (*after)[sfBeneficiary])
{
JLOG(j.fatal()) << "Invariant failed: an account is its own beneficiary";
return false;
}
auto const timeLock = (*after)[sfTimeLock];
if (timeLock == 0 || timeLock > kMaxBeneficiaryTimeLock)
{
JLOG(j.fatal()) << "Invariant failed: the beneficiary time lock is out of range";
return false;
}
if (before && (*before)[sfAccount] != (*after)[sfAccount])
{
JLOG(j.fatal()) << "Invariant failed: the beneficiary entry changed account";
return false;
}
}
return true;
}
} // namespace xrpl

View File

@@ -0,0 +1,175 @@
#include <xrpl/tx/transactors/beneficiary/BeneficiarySet.h>
#include <xrpl/basics/Log.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
#include <memory>
namespace xrpl {
NotTEC
BeneficiarySet::preflight(PreflightContext const& ctx)
{
auto const beneficiary = ctx.tx[~sfBeneficiary];
auto const timeLock = ctx.tx[~sfTimeLock];
// The two fields describe one designation, so they arrive together or not
// at all; neither alone is a meaningful instruction.
if (beneficiary.has_value() != timeLock.has_value())
{
JLOG(ctx.j.trace()) << "BeneficiarySet: Beneficiary and TimeLock disagree";
return temMALFORMED;
}
if (!beneficiary)
return tesSUCCESS;
if (*beneficiary == ctx.tx[sfAccount])
{
JLOG(ctx.j.trace()) << "BeneficiarySet: the beneficiary is the account";
return temMALFORMED;
}
if (*beneficiary == beast::kZero)
{
JLOG(ctx.j.trace()) << "BeneficiarySet: the beneficiary is the zero account";
return temMALFORMED;
}
// Zero would make the designation invocable in the ledger that set it.
if (*timeLock == 0 || *timeLock > kMaxBeneficiaryTimeLock)
{
JLOG(ctx.j.trace()) << "BeneficiarySet: the time lock is out of range";
return temMALFORMED;
}
return tesSUCCESS;
}
TER
BeneficiarySet::preclaim(PreclaimContext const& ctx)
{
auto const beneficiary = ctx.tx[~sfBeneficiary];
if (!beneficiary)
{
if (!ctx.view.exists(keylet::beneficiary(ctx.tx[sfAccount])))
return tecNO_ENTRY;
return tesSUCCESS;
}
if (!ctx.view.exists(keylet::account(*beneficiary)))
{
JLOG(ctx.j.trace()) << "BeneficiarySet: the beneficiary does not exist";
return tecNO_TARGET;
}
return tesSUCCESS;
}
TER
BeneficiarySet::doApply()
{
auto const sleAccount = view().peek(keylet::account(accountID_));
if (!sleAccount)
return tefINTERNAL; // LCOV_EXCL_LINE
Keylet const beneficiaryKeylet = keylet::beneficiary(accountID_);
auto const sle = view().peek(beneficiaryKeylet);
// No Beneficiary field means clear: the entry goes, and so does the
// timestamp, leaving the account as it was before any designation.
if (!ctx_.tx.isFieldPresent(sfBeneficiary))
{
if (!sle)
return tecNO_ENTRY; // LCOV_EXCL_LINE
if (!view().dirRemove(keylet::ownerDir(accountID_), (*sle)[sfOwnerNode], sle->key(), true))
{
// LCOV_EXCL_START
JLOG(j_.fatal()) << "BeneficiarySet: cannot remove the entry from the owner directory";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
decreaseOwnerCountForObject(view(), sleAccount, sle, 1, j_);
view().erase(sle);
sleAccount->makeFieldAbsent(sfLastInteraction);
view().update(sleAccount);
return tesSUCCESS;
}
if (sle)
{
(*sle)[sfBeneficiary] = ctx_.tx[sfBeneficiary];
(*sle)[sfTimeLock] = ctx_.tx[sfTimeLock];
view().update(sle);
return tesSUCCESS;
}
{
auto const balance = STAmount((*sleAccount)[sfBalance]).xrp();
auto const reserve = accountReserve(view(), sleAccount, j_, {.ownerCountDelta = 1});
if (balance < reserve)
return tecINSUFFICIENT_RESERVE;
}
auto const sleNew = std::make_shared<SLE>(beneficiaryKeylet);
(*sleNew)[sfAccount] = accountID_;
(*sleNew)[sfBeneficiary] = ctx_.tx[sfBeneficiary];
(*sleNew)[sfTimeLock] = ctx_.tx[sfTimeLock];
view().insert(sleNew);
auto const page =
view().dirInsert(keylet::ownerDir(accountID_), sleNew->key(), describeOwnerDir(accountID_));
if (!page)
return tecDIR_FULL; // LCOV_EXCL_LINE
(*sleNew)[sfOwnerNode] = *page;
increaseOwnerCount(view(), sleAccount, {}, 1, j_);
// Transactor::apply already stamped the field if it was present; this is the
// first time it is not, so the timer starts here.
sleAccount->setFieldU32(sfLastInteraction, view().parentCloseTime().time_since_epoch().count());
view().update(sleAccount);
return tesSUCCESS;
}
void
BeneficiarySet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref)
{
// The ValidBeneficiary check covers this transaction.
}
bool
BeneficiarySet::finalizeInvariants(
STTx const&,
TER,
XRPAmount,
ReadView const&,
beast::Journal const&)
{
// The ValidBeneficiary check covers this transaction.
return true;
}
} // namespace xrpl

View File

@@ -0,0 +1,489 @@
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/acctdelete.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/owners.h>
#include <test/jtx/pay.h>
#include <test/jtx/regkey.h>
#include <test/jtx/sig.h>
#include <test/jtx/ter.h>
#include <test/jtx/utility.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/jss.h>
namespace xrpl::test {
class Beneficiary_test : public beast::unit_test::Suite
{
static json::Value
set(jtx::Account const& account, jtx::Account const& beneficiary, std::uint32_t timeLock)
{
json::Value jv;
jv[sfTransactionType] = jss::BeneficiarySet;
jv[sfAccount] = account.human();
jv[sfBeneficiary] = beneficiary.human();
jv[sfTimeLock] = timeLock;
return jv;
}
static json::Value
clear(jtx::Account const& account)
{
json::Value jv;
jv[sfTransactionType] = jss::BeneficiarySet;
jv[sfAccount] = account.human();
return jv;
}
static bool
exists(jtx::Env const& env, jtx::Account const& account)
{
return env.le(keylet::beneficiary(account.id())) != nullptr;
}
static std::optional<std::uint32_t>
stamp(jtx::Env const& env, jtx::Account const& account)
{
auto const sle = env.le(keylet::account(account.id()));
if (!sle)
return std::nullopt;
return (*sle)[~sfLastInteraction];
}
void
testEnabled(FeatureBitset features)
{
testcase("enabled");
using namespace jtx;
for (bool const withFeature : {false, true})
{
auto const amend = withFeature ? features : features - featureBeneficiary;
Env env{*this, amend};
Account const alice{"alice"}, bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
auto const expected = withFeature ? Ter(tesSUCCESS) : Ter(temDISABLED);
env(set(alice, bob, 3600), expected);
env.close();
if (!withFeature)
{
BEAST_EXPECT(!exists(env, alice));
continue;
}
BEAST_EXPECT(exists(env, alice));
BEAST_EXPECT(stamp(env, alice).has_value());
}
}
void
testSetMalformed(FeatureBitset features)
{
testcase("malformed set");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"}, carol{"carol"};
env.fund(XRP(10000), alice, bob);
env.close();
// An account cannot inherit from itself.
env(set(alice, alice, 3600), Ter(temMALFORMED));
// A time lock of zero is invocable in the ledger that sets it.
env(set(alice, bob, 0), Ter(temMALFORMED));
env(set(alice, bob, kMaxBeneficiaryTimeLock + 1), Ter(temMALFORMED));
// The two fields describe one designation and travel together.
{
json::Value jv = clear(alice);
jv[sfTimeLock] = 3600;
env(jv, Ter(temMALFORMED));
}
{
json::Value jv;
jv[sfTransactionType] = jss::BeneficiarySet;
jv[sfAccount] = alice.human();
jv[sfBeneficiary] = bob.human();
env(jv, Ter(temMALFORMED));
}
// The beneficiary has to be an account that exists.
env(set(alice, carol, 3600), Ter(tecNO_TARGET));
// Clearing when there is nothing to clear.
env(clear(alice), Ter(tecNO_ENTRY));
env.close();
BEAST_EXPECT(!exists(env, alice));
BEAST_EXPECT(!stamp(env, alice).has_value());
// The ceiling itself is allowed.
env(set(alice, bob, kMaxBeneficiaryTimeLock));
env.close();
BEAST_EXPECT(exists(env, alice));
}
void
testSetUpdateClear(FeatureBitset features)
{
testcase("set, update and clear");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"}, carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 0);
env(set(alice, bob, 3600));
env.close();
BEAST_EXPECT(exists(env, alice));
BEAST_EXPECT(ownerCount(env, alice) == 1);
{
auto const sle = env.le(keylet::beneficiary(alice.id()));
BEAST_EXPECT((*sle)[sfAccount] == alice.id());
BEAST_EXPECT((*sle)[sfBeneficiary] == bob.id());
BEAST_EXPECT((*sle)[sfTimeLock] == 3600);
}
// Updating overwrites in place: still one entry, still one reserve.
env(set(alice, carol, 7200));
env.close();
BEAST_EXPECT(ownerCount(env, alice) == 1);
{
auto const sle = env.le(keylet::beneficiary(alice.id()));
BEAST_EXPECT((*sle)[sfBeneficiary] == carol.id());
BEAST_EXPECT((*sle)[sfTimeLock] == 7200);
}
// Clearing leaves the account as it was before any designation.
env(clear(alice));
env.close();
BEAST_EXPECT(!exists(env, alice));
BEAST_EXPECT(ownerCount(env, alice) == 0);
BEAST_EXPECT(!stamp(env, alice).has_value());
}
// The beneficiary becomes a second regular key once the time lock has run.
// Nothing on the account changes: no key is replaced, no entry deleted.
void
testBeneficiarySigns(FeatureBitset features)
{
testcase("the beneficiary signs once the time lock has run");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"}, carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
env(set(alice, bob, 3600));
env.close();
// Before the time lock, the beneficiary is just another account.
env(pay(alice, carol, XRP(1)), Sig(bob), Ter(tefBAD_AUTH));
env.close();
env.close(std::chrono::seconds(4000));
// After it, the beneficiary signs for the account.
auto const carolBefore = env.balance(carol);
env(pay(alice, carol, XRP(1)), Sig(bob));
env.close();
BEAST_EXPECT(env.balance(carol) == carolBefore + XRP(1));
// The account is untouched: no regular key was set and the designation
// is still there.
auto const sle = env.le(keylet::account(alice.id()));
BEAST_EXPECT(!sle->isFieldPresent(sfRegularKey));
BEAST_EXPECT(exists(env, alice));
BEAST_EXPECT(ownerCount(env, alice) == 1);
// An unrelated account still cannot sign.
env(pay(alice, carol, XRP(1)), Sig(carol), Ter(tefBAD_AUTH));
env.close();
}
// The beneficiary's own transactions must not reset the timer, or the first
// one would shut the door behind it.
void
testBeneficiarySigningDoesNotResetTheTimer(FeatureBitset features)
{
testcase("a beneficiary-signed transaction does not reset the timer");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"}, carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
env(set(alice, bob, 3600));
env.close();
auto const stampAtSet = stamp(env, alice);
env.close(std::chrono::seconds(4000));
env(pay(alice, carol, XRP(1)), Sig(bob));
env.close();
BEAST_EXPECT(stamp(env, alice) == stampAtSet);
// So the beneficiary can keep signing.
env(pay(alice, carol, XRP(1)), Sig(bob));
env.close();
BEAST_EXPECT(stamp(env, alice) == stampAtSet);
}
// The owner is never locked out: signing with their own key closes the
// beneficiary's access again.
void
testOwnerReclaims(FeatureBitset features)
{
testcase("the owner reclaims by signing");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"}, carol{"carol"};
env.fund(XRP(10000), alice, bob, carol);
env.close();
env(set(alice, bob, 3600));
env.close();
env.close(std::chrono::seconds(4000));
env(pay(alice, carol, XRP(1)), Sig(bob));
env.close();
// Alice comes back and uses her own key.
env(pay(alice, carol, XRP(1)));
env.close();
// Bob is shut out again, and Alice never lost anything.
env(pay(alice, carol, XRP(1)), Sig(bob), Ter(tefBAD_AUTH));
env.close();
BEAST_EXPECT(exists(env, alice));
}
// The case that drove this design: an owner who signs with a regular key
// and goes quiet keeps their account.
void
testRegularKeyOwnerNotLockedOut(FeatureBitset features)
{
testcase("an owner signing with a regular key is not locked out");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"}, carol{"carol"}, key{"key"};
env.fund(XRP(10000), alice, bob, carol, key);
env.close();
env(regkey(alice, key));
env.close();
env(fset(alice, asfDisableMaster), Sig(alice));
env.close();
env(set(alice, bob, 3600), Sig(key));
env.close();
env.close(std::chrono::seconds(4000));
// The beneficiary can sign now.
env(pay(alice, carol, XRP(1)), Sig(bob));
env.close();
// And so can the owner, with the regular key they have always used.
// The regular key was never overwritten.
BEAST_EXPECT((*env.le(keylet::account(alice.id())))[~sfRegularKey] == key.id());
env(pay(alice, carol, XRP(1)), Sig(key));
env.close();
env(pay(alice, carol, XRP(1)), Sig(bob), Ter(tefBAD_AUTH));
env.close();
}
// The whole point of the mechanism: using the account holds it off.
void
testActivityResetsTheTimer(FeatureBitset features)
{
testcase("activity resets the timer");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
env(set(alice, bob, 3600));
env.close();
auto const first = stamp(env, alice);
BEAST_EXPECT(first.has_value());
env.close(std::chrono::seconds(3000));
// An ordinary outgoing payment, nothing to do with this amendment.
env(pay(alice, bob, XRP(1)));
env.close();
auto const second = stamp(env, alice);
BEAST_EXPECT(second.has_value() && *second > *first);
// The original deadline has now passed, and the beneficiary still
// cannot sign, because the payment moved it.
env.close(std::chrono::seconds(1000));
env(pay(alice, bob, XRP(1)), Sig(bob), Ter(tefBAD_AUTH));
env.close();
env.close(std::chrono::seconds(4000));
env(pay(alice, bob, XRP(1)), Sig(bob));
env.close();
}
// Receiving is not activity: only the sender's own transactions count.
void
testIncomingIsNotActivity(FeatureBitset features)
{
testcase("an incoming payment is not activity");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
env(set(alice, bob, 3600));
env.close();
auto const before = stamp(env, alice);
env.close(std::chrono::seconds(2000));
env(pay(bob, alice, XRP(100)));
env.close();
BEAST_EXPECT(stamp(env, alice) == before);
env.close(std::chrono::seconds(2000));
env(pay(alice, bob, XRP(1)), Sig(bob));
env.close();
}
// An account that never set a beneficiary is untouched by the amendment.
void
testUnrelatedAccountUnstamped(FeatureBitset features)
{
testcase("an account without a designation is never stamped");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
env(pay(alice, bob, XRP(1)));
env.close();
BEAST_EXPECT(!stamp(env, alice).has_value());
BEAST_EXPECT(!stamp(env, bob).has_value());
}
void
testAccountDeleteBlocked(FeatureBitset features)
{
testcase("a designation blocks account deletion");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"}, sink{"sink"};
env.fund(XRP(10000), alice, bob, sink);
env.close();
env(set(alice, bob, 3600));
env.close();
incLgrSeqForAccDel(env, alice);
env(acctdelete(alice, sink),
Fee(drops(env.current()->fees().increment)),
Ter(tecHAS_OBLIGATIONS));
env.close();
// With the designation cleared, the same account deletes.
env(clear(alice));
env.close();
env(acctdelete(alice, sink), Fee(drops(env.current()->fees().increment)));
env.close();
BEAST_EXPECT(!env.le(keylet::account(alice.id())));
}
void
testRpc(FeatureBitset features)
{
testcase("account_objects and ledger_entry");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"}, bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
env(set(alice, bob, 3600));
env.close();
{
json::Value params;
params[jss::account] = alice.human();
params[jss::type] = "beneficiary";
auto const jv = env.rpc("json", "account_objects", to_string(params))[jss::result];
BEAST_EXPECT(jv[jss::account_objects].size() == 1);
auto const& object = jv[jss::account_objects][0u];
BEAST_EXPECT(object["LedgerEntryType"].asString() == "Beneficiary");
BEAST_EXPECT(object["Beneficiary"].asString() == bob.human());
}
// The beneficiary does not own the entry, so it is not in their
// directory.
{
json::Value params;
params[jss::account] = bob.human();
params[jss::type] = "beneficiary";
auto const jv = env.rpc("json", "account_objects", to_string(params))[jss::result];
BEAST_EXPECT(jv[jss::account_objects].size() == 0);
}
{
json::Value params;
params[jss::beneficiary] = alice.human();
auto const jv = env.rpc("json", "ledger_entry", to_string(params))[jss::result];
BEAST_EXPECT(
jv[jss::index].asString() == to_string(keylet::beneficiary(alice.id()).key));
}
}
public:
void
run() override
{
using namespace jtx;
auto const all = jtx::testableAmendments();
testEnabled(all);
testSetMalformed(all);
testSetUpdateClear(all);
testBeneficiarySigns(all);
testBeneficiarySigningDoesNotResetTheTimer(all);
testOwnerReclaims(all);
testRegularKeyOwnerNotLockedOut(all);
testActivityResetsTheTimer(all);
testIncomingIsNotActivity(all);
testUnrelatedAccountUnstamped(all);
testAccountDeleteBlocked(all);
testRpc(all);
}
};
BEAST_DEFINE_TESTSUITE(Beneficiary, app, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,210 @@
#include <test/app/invariants/InvariantsBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/jss.h>
namespace xrpl::test {
class InvariantsBeneficiary_test : public InvariantsBase
{
// Put a designation on a1 so the checks have something to corrupt.
static bool
designate(jtx::Account const& a1, jtx::Account const& a2, jtx::Env& env)
{
json::Value jv;
jv[sfTransactionType] = jss::BeneficiarySet;
jv[sfAccount] = a1.human();
jv[sfBeneficiary] = a2.human();
jv[sfTimeLock] = 3600;
env(jv);
env.close();
return true;
}
void
testEntryAndStampTravelTogether()
{
testcase("a designation and its timestamp travel together");
using namespace jtx;
// The entry appears with no timestamp beside it.
doInvariantCheck(
{{"a beneficiary designation was created without its LastInteraction"}},
[](Account const& a1, Account const& a2, ApplyContext& ac) {
auto sle = std::make_shared<SLE>(keylet::beneficiary(a1.id()));
(*sle)[sfAccount] = a1.id();
(*sle)[sfBeneficiary] = a2.id();
(*sle)[sfTimeLock] = 3600;
(*sle)[sfOwnerNode] = 0;
ac.view().insert(sle);
return true;
});
// The timestamp appears with no entry beside it.
doInvariantCheck(
{{"a beneficiary designation was created without its LastInteraction"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::account(a1.id()));
if (!sle)
return false;
sle->setFieldU32(sfLastInteraction, 1);
ac.view().update(sle);
return true;
});
// The entry goes and the timestamp stays behind.
doInvariantCheck(
{{"a beneficiary designation was removed without its LastInteraction"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::beneficiary(a1.id()));
if (!sle)
return false;
ac.view().erase(sle);
return true;
},
XRPAmount{},
STTx{ttBENEFICIARY_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
designate);
}
void
testFieldBounds()
{
testcase("the designation's fields are bounded");
using namespace jtx;
doInvariantCheck(
{{"an account is its own beneficiary"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::beneficiary(a1.id()));
if (!sle)
return false;
(*sle)[sfBeneficiary] = a1.id();
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttBENEFICIARY_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
designate);
doInvariantCheck(
{{"the beneficiary time lock is out of range"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::beneficiary(a1.id()));
if (!sle)
return false;
(*sle)[sfTimeLock] = 0;
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttBENEFICIARY_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
designate);
doInvariantCheck(
{{"the beneficiary time lock is out of range"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::beneficiary(a1.id()));
if (!sle)
return false;
(*sle)[sfTimeLock] = kMaxBeneficiaryTimeLock + 1;
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttBENEFICIARY_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
designate);
doInvariantCheck(
{{"the beneficiary entry changed account"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::beneficiary(a1.id()));
if (!sle)
return false;
(*sle)[sfAccount] = Account{"someone else"}.id();
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttBENEFICIARY_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
designate);
}
void
testTimerNeverGoesBackwards()
{
testcase("the timestamp never moves backwards");
using namespace jtx;
doInvariantCheck(
{{"LastInteraction moved backwards"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::account(a1.id()));
if (!sle || !sle->isFieldPresent(sfLastInteraction) ||
(*sle)[sfLastInteraction] == 0)
return false;
sle->setFieldU32(sfLastInteraction, 1);
ac.view().update(sle);
return true;
},
XRPAmount{},
STTx{ttBENEFICIARY_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
[](Account const& a1, Account const& a2, jtx::Env& env) {
// The ledger clock starts at zero, so it is moved forward
// before the designation is made; otherwise there is no
// earlier value for the timestamp to move back to.
env.close(std::chrono::seconds(100000));
return designate(a1, a2, env);
});
}
void
testDeletedByWrongTransaction()
{
testcase("a designation is deleted by the wrong transaction");
using namespace jtx;
doInvariantCheck(
{{"a beneficiary designation was deleted by transaction type"}},
[](Account const& a1, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(keylet::beneficiary(a1.id()));
auto sleAcct = ac.view().peek(keylet::account(a1.id()));
if (!sle || !sleAcct)
return false;
ac.view().erase(sle);
sleAcct->makeFieldAbsent(sfLastInteraction);
ac.view().update(sleAcct);
return true;
},
XRPAmount{},
STTx{ttACCOUNT_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
designate);
}
public:
void
run() override
{
testEntryAndStampTravelTogether();
testFieldBounds();
testTimerNeverGoesBackwards();
testDeletedByWrongTransaction();
}
};
BEAST_DEFINE_TESTSUITE(InvariantsBeneficiary, app, xrpl);
} // namespace xrpl::test

View File

@@ -909,6 +909,22 @@ parseSignerList(
return parseObjectID(params, fieldName, "hex string");
}
static std::expected<uint256, json::Value>
parseBeneficiary(
json::Value const& params,
json::StaticString const fieldName,
[[maybe_unused]] unsigned const apiVersion)
{
// One designation per account, so the account alone identifies the entry.
auto const account = ledger_entry_helpers::parse<AccountID>(params);
if (!account)
{
return ledger_entry_helpers::invalidFieldError("malformedAddress", fieldName, "AccountID");
}
return keylet::beneficiary(*account).key;
}
static std::expected<uint256, json::Value>
parseSponsorship(
json::Value const& params,