diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 0836cffaf73..3478c0605a5 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -169,6 +169,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 */ diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 1b88eea4563..ba8bdac9236 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -141,6 +141,13 @@ tenthBipsOfValue(T value, TenthBips 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. diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index e63a7f515dc..17ef400c3ce 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(Beneficiary, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 18c71b572cd..450be2f5652 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -153,6 +153,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({ {sfAMMID, SoeOptional}, // pseudo-account designator {sfVaultID, SoeOptional}, // pseudo-account designator {sfLoanBrokerID, SoeOptional}, // pseudo-account designator + {sfLastInteraction, SoeOptional}, })) /** A ledger object which contains a list of object identifiers. @@ -649,5 +650,19 @@ LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ {sfSponseeNode, SoeRequired}, })) +/** A designation of an account to receive this account's regular key after a + period of inactivity. + + \sa keylet::beneficiary + */ +LEDGER_ENTRY(ltBENEFICIARY, 0x0096, Beneficiary, beneficiary, ({ + {sfAccount, SoeRequired}, + {sfBeneficiary, SoeRequired}, + {sfTimeLock, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + #undef EXPAND #undef LEDGER_ENTRY_DUPLICATE diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2cf35743aea..1e2e19fbfdc 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -128,6 +128,8 @@ TYPED_SFIELD(sfBytecodeSizeLimit, UINT32, 82) TYPED_SFIELD(sfGasPrice, UINT32, 83) TYPED_SFIELD(sfGas, UINT32, 84) TYPED_SFIELD(sfGasUsed, UINT32, 85) +TYPED_SFIELD(sfTimeLock, UINT32, 96) +TYPED_SFIELD(sfLastInteraction, UINT32, 97) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -360,6 +362,7 @@ TYPED_SFIELD(sfHighSponsor, ACCOUNT, 28) TYPED_SFIELD(sfLowSponsor, ACCOUNT, 29) TYPED_SFIELD(sfCounterpartySponsor, ACCOUNT, 30) TYPED_SFIELD(sfSponsee, ACCOUNT, 31) +TYPED_SFIELD(sfBeneficiary, ACCOUNT, 33) // vector of 256-bit TYPED_SFIELD(sfIndexes, VECTOR256, 1, SField::kSmdNever) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 454aa85ffd0..38c14244e43 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1134,6 +1134,17 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCountDelta, SoeOptional}, })) +/** This transaction designates, updates or clears an account's beneficiary. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttBENEFICIARY_SET, 119, BeneficiarySet, + ({.amendment = featureBeneficiary}), + ({ + {sfBeneficiary, SoeOptional}, + {sfTimeLock, SoeOptional}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index aabde69ff95..a0ebd7459b7 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -375,6 +375,13 @@ class Transactor : public TxInvariantCheck 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(); diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index e8dafbd3017..431fe140fa2 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -431,6 +431,42 @@ class ObjectHasPseudoAccount }; // 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 is unseated when the entry is being created. + std::vector> 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 designationAdded_; + std::set designationRemoved_; + std::set stampAdded_; + std::set 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, @@ -463,7 +499,8 @@ using InvariantChecks = std::tuple< ValidMPTTransfer, ObjectHasPseudoAccount, SponsorshipOwnerCountsMatch, - SponsorshipAccountCountMatchesField>; + SponsorshipAccountCountMatchesField, + ValidBeneficiary>; /** * @brief get a tuple of all invariant checks diff --git a/include/xrpl/tx/transactors/beneficiary/BeneficiarySet.h b/include/xrpl/tx/transactors/beneficiary/BeneficiarySet.h new file mode 100644 index 00000000000..74ce021fb4a --- /dev/null +++ b/include/xrpl/tx/transactors/beneficiary/BeneficiarySet.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 91ed5c893fd..0cc726f6d15 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -104,6 +104,7 @@ enum class LedgerNameSpace : std::uint16_t { LoanBroker = 'l', // lower-case L Loan = 'L', Sponsorship = '>', + Beneficiary = 'Y', // No longer used or supported. Left here to reserve the space to avoid accidental reuse. Contract [[deprecated]] = 'c', @@ -355,6 +356,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 { diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 6bf99e567de..8e21c54b84d 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -878,6 +878,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() { @@ -908,6 +925,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); } @@ -1043,6 +1069,26 @@ Transactor::checkSingleSign( return tefMASTER_DISABLED; } + // 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; } diff --git a/src/libxrpl/tx/invariants/BeneficiaryInvariant.cpp b/src/libxrpl/tx/invariants/BeneficiaryInvariant.cpp new file mode 100644 index 00000000000..e1773e23b3c --- /dev/null +++ b/src/libxrpl/tx/invariants/BeneficiaryInvariant.cpp @@ -0,0 +1,128 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 { + 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 diff --git a/src/libxrpl/tx/transactors/beneficiary/BeneficiarySet.cpp b/src/libxrpl/tx/transactors/beneficiary/BeneficiarySet.cpp new file mode 100644 index 00000000000..d658c99a964 --- /dev/null +++ b/src/libxrpl/tx/transactors/beneficiary/BeneficiarySet.cpp @@ -0,0 +1,175 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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(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 diff --git a/src/test/app/Beneficiary_test.cpp b/src/test/app/Beneficiary_test.cpp new file mode 100644 index 00000000000..89502dc9098 --- /dev/null +++ b/src/test/app/Beneficiary_test.cpp @@ -0,0 +1,489 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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 + 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 diff --git a/src/test/app/invariants/InvariantsBeneficiary_test.cpp b/src/test/app/invariants/InvariantsBeneficiary_test.cpp new file mode 100644 index 00000000000..04abd1f0cb6 --- /dev/null +++ b/src/test/app/invariants/InvariantsBeneficiary_test.cpp @@ -0,0 +1,210 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +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(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 diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 5271720b34f..8035c961280 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -729,6 +729,22 @@ parseSignerList( return parseObjectID(params, fieldName, "hex string"); } +static std::expected +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(params); + if (!account) + { + return ledger_entry_helpers::invalidFieldError("malformedAddress", fieldName, "AccountID"); + } + + return keylet::beneficiary(*account).key; +} + static std::expected parseSponsorship( json::Value const& params,