From 9324fc49135179b8d5e421f7fe67bc231b2c180c Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Sun, 8 Sep 2024 14:51:25 +0200 Subject: [PATCH 1/7] featureSubscription --- include/xrpl/protocol/Feature.h | 3 +- include/xrpl/protocol/Indexes.h | 9 + include/xrpl/protocol/LedgerFormats.h | 5 + include/xrpl/protocol/SField.h | 4 + include/xrpl/protocol/TxFormats.h | 9 + include/xrpl/protocol/jss.h | 7 + src/libxrpl/protocol/Feature.cpp | 2 + src/libxrpl/protocol/Indexes.cpp | 7 + src/libxrpl/protocol/LedgerFormats.cpp | 17 + src/libxrpl/protocol/SField.cpp | 4 + src/libxrpl/protocol/TxFormats.cpp | 28 + src/test/app/Subscription_test.cpp | 793 +++++++++++++++++++++ src/test/jtx.h | 1 + src/test/jtx/impl/subscription.cpp | 106 +++ src/test/jtx/subscription.h | 79 ++ src/xrpld/app/tx/detail/InvariantCheck.cpp | 1 + src/xrpld/app/tx/detail/Subscription.cpp | 535 ++++++++++++++ src/xrpld/app/tx/detail/Subscription.h | 86 +++ src/xrpld/app/tx/detail/applySteps.cpp | 7 + src/xrpld/rpc/handlers/LedgerEntry.cpp | 45 ++ 20 files changed, 1747 insertions(+), 1 deletion(-) create mode 100644 src/test/app/Subscription_test.cpp create mode 100644 src/test/jtx/impl/subscription.cpp create mode 100644 src/test/jtx/subscription.h create mode 100644 src/xrpld/app/tx/detail/Subscription.cpp create mode 100644 src/xrpld/app/tx/detail/Subscription.h diff --git a/include/xrpl/protocol/Feature.h b/include/xrpl/protocol/Feature.h index a00d6b85c1b..fda257947f4 100644 --- a/include/xrpl/protocol/Feature.h +++ b/include/xrpl/protocol/Feature.h @@ -80,7 +80,7 @@ namespace detail { // Feature.cpp. Because it's only used to reserve storage, and determine how // large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than // the actual number of amendments. A LogicError on startup will verify this. -static constexpr std::size_t numFeatures = 79; +static constexpr std::size_t numFeatures = 80; /** Amendments that this server supports and the default voting behavior. Whether they are enabled depends on the Rules defined in the validated @@ -372,6 +372,7 @@ extern uint256 const fixEnforceNFTokenTrustline; extern uint256 const fixInnerObjTemplate2; extern uint256 const featureInvariantsV1_1; extern uint256 const fixNFTokenPageLinks; +extern uint256 const featureSubscription; } // namespace ripple diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index f179bbacfab..694d4b3efaf 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -287,6 +287,15 @@ did(AccountID const& account) noexcept; Keylet oracle(AccountID const& account, std::uint32_t const& documentID) noexcept; +Keylet +subscription(AccountID const& account, AccountID const& dest, std::uint32_t const& seq) noexcept; + +inline Keylet +subscription(uint256 const& key) noexcept +{ + return {ltSUBSCRIPTION, key}; +} + } // namespace keylet // Everything below is deprecated and should be removed in favor of keylets: diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 0ee6c992d8d..b8952d72057 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -197,6 +197,11 @@ enum LedgerEntryType : std::uint16_t */ ltORACLE = 0x0080, + /** A ledger object which tracks Subscription + \sa keylet::subscription + */ + ltSUBSCRIPTION = 0x0055, + //--------------------------------------------------------------------------- /** A special type, matching any ledger entry type. diff --git a/include/xrpl/protocol/SField.h b/include/xrpl/protocol/SField.h index 7f54201a4b8..23052a7f089 100644 --- a/include/xrpl/protocol/SField.h +++ b/include/xrpl/protocol/SField.h @@ -442,6 +442,9 @@ extern SF_UINT32 const sfEmitGeneration; extern SF_UINT32 const sfVoteWeight; extern SF_UINT32 const sfFirstNFTokenSequence; extern SF_UINT32 const sfOracleDocumentID; +extern SF_UINT32 const sfFrequency; +extern SF_UINT32 const sfStartTime; +extern SF_UINT32 const sfNextPaymentTime; // 64-bit integers (common) extern SF_UINT64 const sfIndexNext; @@ -511,6 +514,7 @@ extern SF_UINT256 const sfHookStateKey; extern SF_UINT256 const sfHookHash; extern SF_UINT256 const sfHookNamespace; extern SF_UINT256 const sfHookSetTxnID; +extern SF_UINT256 const sfSubscriptionID; // currency amount (common) extern SF_AMOUNT const sfAmount; diff --git a/include/xrpl/protocol/TxFormats.h b/include/xrpl/protocol/TxFormats.h index a3f5cca108c..b480e04680e 100644 --- a/include/xrpl/protocol/TxFormats.h +++ b/include/xrpl/protocol/TxFormats.h @@ -199,6 +199,15 @@ enum TxType : std::uint16_t /** This transaction type fixes a problem in the ledger state */ ttLEDGER_STATE_FIX = 53, + /** This transaction type creates an Subscription instance */ + ttSUBSCRIPTION_SET = 54, + + /** This transaction type cancels an Subscription instance */ + ttSUBSCRIPTION_CANCEL = 55, + + /** This transaction type claims an Subscription instance */ + ttSUBSCRIPTION_CLAIM = 56, + /** This system-generated transaction type is used to update the status of the various amendments. diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index e3eda80b44f..2faabfae2f0 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -89,6 +89,7 @@ JSS(EscrowFinish); // transaction type. JSS(Fee); // in/out: TransactionSign; field. JSS(FeeSettings); // ledger type. JSS(Flags); // in/out: TransactionSign; field. +JSS(Frequency); // field. JSS(Invalid); // JSS(LastLedgerSequence); // in: TransactionSign; field JSS(LastUpdateTime); // field. @@ -140,6 +141,11 @@ JSS(SetRegularKey); // transaction type. JSS(SignerList); // ledger type. JSS(SignerListSet); // transaction type. JSS(SigningPubKey); // field. +JSS(Subscription); // ledger type. +JSS(SubscriptionCancel); // transaction type. +JSS(SubscriptionClaim); // transaction type. +JSS(SubscriptionID); // field. +JSS(SubscriptionSet); // transaction type. JSS(TakerGets); // field. JSS(TakerPays); // field. JSS(Ticket); // ledger type. @@ -651,6 +657,7 @@ JSS(streams); // in: Subscribe, Unsubscribe JSS(strict); // in: AccountCurrencies, AccountInfo JSS(sub_index); // in: LedgerEntry JSS(subcommand); // in: PathFind +JSS(subscription); // in: LedgerEntry JSS(success); // rpc JSS(supported); // out: AmendmentTableImpl JSS(sync_mode); // in: Submit diff --git a/src/libxrpl/protocol/Feature.cpp b/src/libxrpl/protocol/Feature.cpp index 078369bf20c..032fa6241ac 100644 --- a/src/libxrpl/protocol/Feature.cpp +++ b/src/libxrpl/protocol/Feature.cpp @@ -498,6 +498,8 @@ REGISTER_FIX (fixReducedOffersV2, Supported::yes, VoteBehavior::De REGISTER_FIX (fixEnforceNFTokenTrustline, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FIX (fixInnerObjTemplate2, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FIX (fixNFTokenPageLinks, Supported::yes, VoteBehavior::DefaultNo); +REGISTER_FEATURE(Subscription, Supported::yes, VoteBehavior::DefaultNo); + // InvariantsV1_1 will be changes to Supported::yes when all the // invariants expected to be included under it are complete. REGISTER_FEATURE(InvariantsV1_1, Supported::no, VoteBehavior::DefaultNo); diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 30d97416cfa..7500b9edb29 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -73,6 +73,7 @@ enum class LedgerNameSpace : std::uint16_t { XCHAIN_CREATE_ACCOUNT_CLAIM_ID = 'K', DID = 'I', ORACLE = 'R', + SUBSCRIPTION = 'U', // No longer used or supported. Left here to reserve the space // to avoid accidental reuse. @@ -451,6 +452,12 @@ oracle(AccountID const& account, std::uint32_t const& documentID) noexcept return {ltORACLE, indexHash(LedgerNameSpace::ORACLE, account, documentID)}; } +Keylet +subscription(AccountID const& account, AccountID const& dest, std::uint32_t const& seq) noexcept +{ + return {ltSUBSCRIPTION, indexHash(LedgerNameSpace::SUBSCRIPTION, account, dest, seq)}; +} + } // namespace keylet } // namespace ripple diff --git a/src/libxrpl/protocol/LedgerFormats.cpp b/src/libxrpl/protocol/LedgerFormats.cpp index 9401c00278b..9bf715aaee7 100644 --- a/src/libxrpl/protocol/LedgerFormats.cpp +++ b/src/libxrpl/protocol/LedgerFormats.cpp @@ -364,6 +364,23 @@ LedgerFormats::LedgerFormats() {sfPreviousTxnLgrSeq, soeREQUIRED} }, commonFields); + + add(jss::Subscription, + ltSUBSCRIPTION, + { + {sfAccount, soeREQUIRED}, + {sfDestination, soeREQUIRED}, + {sfDestinationTag, soeOPTIONAL}, + {sfAmount, soeREQUIRED}, + {sfFrequency, soeREQUIRED}, + {sfNextPaymentTime, soeREQUIRED}, + {sfExpiration, soeOPTIONAL}, + {sfOwnerNode, soeREQUIRED}, + {sfPreviousTxnID, soeREQUIRED}, + {sfPreviousTxnLgrSeq, soeREQUIRED}, + {sfDestinationNode, soeREQUIRED}, + }, + commonFields); // clang-format on } diff --git a/src/libxrpl/protocol/SField.cpp b/src/libxrpl/protocol/SField.cpp index f8eb2d6f877..4eb442e0a53 100644 --- a/src/libxrpl/protocol/SField.cpp +++ b/src/libxrpl/protocol/SField.cpp @@ -168,6 +168,9 @@ CONSTRUCT_TYPED_SFIELD(sfEmitGeneration, "EmitGeneration", UINT32, CONSTRUCT_TYPED_SFIELD(sfVoteWeight, "VoteWeight", UINT32, 48); CONSTRUCT_TYPED_SFIELD(sfFirstNFTokenSequence, "FirstNFTokenSequence", UINT32, 50); CONSTRUCT_TYPED_SFIELD(sfOracleDocumentID, "OracleDocumentID", UINT32, 51); +CONSTRUCT_TYPED_SFIELD(sfFrequency, "Frequency", UINT32, 52); +CONSTRUCT_TYPED_SFIELD(sfStartTime, "StartTime", UINT32, 53); +CONSTRUCT_TYPED_SFIELD(sfNextPaymentTime, "NextPaymentTime", UINT32, 54); // 64-bit integers (common) CONSTRUCT_TYPED_SFIELD(sfIndexNext, "IndexNext", UINT64, 1); @@ -238,6 +241,7 @@ CONSTRUCT_TYPED_SFIELD(sfHookStateKey, "HookStateKey", UINT256, CONSTRUCT_TYPED_SFIELD(sfHookHash, "HookHash", UINT256, 31); CONSTRUCT_TYPED_SFIELD(sfHookNamespace, "HookNamespace", UINT256, 32); CONSTRUCT_TYPED_SFIELD(sfHookSetTxnID, "HookSetTxnID", UINT256, 33); +CONSTRUCT_TYPED_SFIELD(sfSubscriptionID, "SubscriptionID", UINT256, 34); // currency amount (common) CONSTRUCT_TYPED_SFIELD(sfAmount, "Amount", AMOUNT, 1); diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp index 8a93232604e..da5848b20d3 100644 --- a/src/libxrpl/protocol/TxFormats.cpp +++ b/src/libxrpl/protocol/TxFormats.cpp @@ -513,6 +513,34 @@ TxFormats::TxFormats() {sfOwner, soeOPTIONAL}, }, commonFields); + + add(jss::SubscriptionSet, + ttSUBSCRIPTION_SET, + { + {sfDestination, soeOPTIONAL}, + {sfDestinationTag, soeOPTIONAL}, + {sfAmount, soeREQUIRED}, + {sfFrequency, soeOPTIONAL}, + {sfStartTime, soeOPTIONAL}, + {sfExpiration, soeOPTIONAL}, + {sfSubscriptionID, soeOPTIONAL}, + }, + commonFields); + + add(jss::SubscriptionCancel, + ttSUBSCRIPTION_CANCEL, + { + {sfSubscriptionID, soeREQUIRED}, + }, + commonFields); + + add(jss::SubscriptionClaim, + ttSUBSCRIPTION_CLAIM, + { + {sfSubscriptionID, soeREQUIRED}, + {sfAmount, soeREQUIRED}, + }, + commonFields); } TxFormats const& diff --git a/src/test/app/Subscription_test.cpp b/src/test/app/Subscription_test.cpp new file mode 100644 index 00000000000..e20c9b2790b --- /dev/null +++ b/src/test/app/Subscription_test.cpp @@ -0,0 +1,793 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include + +namespace ripple { +namespace test { +struct Subscription_test : public beast::unit_test::suite +{ + static uint256 + getSubscriptionIndex( + AccountID const& account, + AccountID const& dest, + std::uint32_t uSequence) + { + return keylet::subscription(account, dest, uSequence).key; + } + + static bool + inOwnerDir( + ReadView const& view, + jtx::Account const& acct, + std::shared_ptr const& token) + { + ripple::Dir const ownerDir(view, keylet::ownerDir(acct.id())); + return std::find(ownerDir.begin(), ownerDir.end(), token) != + ownerDir.end(); + } + static std::size_t + ownerDirCount(ReadView const& view, jtx::Account const& acct) + { + ripple::Dir const ownerDir(view, keylet::ownerDir(acct.id())); + return std::distance(ownerDir.begin(), ownerDir.end()); + }; + + static std::pair> + subKeyAndSle(ReadView const& view, uint256 const& subId) + { + auto const sle = view.read(keylet::subscription(subId)); + if (!sle) + return {}; + return {sle->key(), sle}; + } + + bool + subscriptionExists(ReadView const& view, uint256 const& subId) + { + auto const slep = view.read({ltSUBSCRIPTION, subId}); + return bool(slep); + } + + void + testEnabled(FeatureBitset features) + { + testcase("enabled"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + for (bool const withSubscription : {true, false}) + { + auto const amend = + withSubscription ? features : features - featureSubscription; + Env env{*this, amend}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const txResult = + withSubscription ? ter(tesSUCCESS) : ter(temDISABLED); + auto const ownerDir = withSubscription ? 1 : 0; + + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + // SET - (Create) + auto const frequency = 100s; + env(subscription::create(alice, bob, XRP(10), frequency), txResult); + env.close(); + + BEAST_EXPECT( + withSubscription ? subscriptionExists(*env.current(), subId) + : !subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == ownerDir); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == ownerDir); + + // CLAIM + env(subscription::claim(bob, subId, XRP(1)), txResult); + env.close(); + + BEAST_EXPECT( + withSubscription ? subscriptionExists(*env.current(), subId) + : !subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == ownerDir); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == ownerDir); + + // CANCEL + env(subscription::cancel(alice, subId), txResult); + env.close(); + + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 0); + } + } + + void + testSetPreflight(FeatureBitset features) + { + testcase("set preflight"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + + // temINVALID_FLAG: + { + env(subscription::create(alice, bob, XRP(10), 100s), + txflags(tfSetfAuth), + ter(temINVALID_FLAG)); + env.close(); + } + + // temDST_IS_SRC: SetSubscription: Malformed transaction: Account is the + // same as the destination. + { + env(subscription::create(alice, alice, XRP(10), 100s), + ter(temDST_IS_SRC)); + env.close(); + } + + // temBAD_AMOUNT: SetSubscription: Malformed transaction: bad amount: + { + env(subscription::create(alice, bob, XRP(-10), 100s), + ter(temBAD_AMOUNT)); + env.close(); + } + + // temBAD_CURRENCY: SetSubscription: Malformed transaction: Bad + // currency. + { + IOU const BAD{gw, badCurrency()}; + env(subscription::create(alice, bob, BAD(10), 100s), + ter(temBAD_CURRENCY)); + env.close(); + } + } + + void + testSetPreclaim(FeatureBitset features) + { + testcase("set preclaim"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const dne = Account("dne"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + env.memoize(dne); + + /* + CREATE + */ + + // tecNO_DST: + { + env(subscription::create(alice, dne, XRP(10), 100s), + ter(tecNO_DST)); + env.close(); + } + + // temMALFORMED: SetSubscription: The frequency is less than or equal to + // 0. + { + env(subscription::create(alice, bob, XRP(10), 0s), + ter(temMALFORMED)); + env.close(); + } + + // temMALFORMED: SetSubscription: The start time is in the past. + { + auto const start = env.now() - 10s; + env(subscription::create(alice, bob, XRP(10), 100s), + subscription::start_time(start), + ter(temMALFORMED)); + env.close(); + } + + // temBAD_EXPIRATION: SetSubscription: The expiration time is in the + // past. + { + auto const expire = env.now() - 10s; + env(subscription::create(alice, bob, XRP(10), 100s, expire), + ter(temBAD_EXPIRATION)); + env.close(); + } + + // temBAD_EXPIRATION: SetSubscription: The expiration time is less than + // the next payment time. + { + auto const start = env.now() + 0s; + auto const expire = env.now() - 10s; + env(subscription::create(alice, bob, XRP(10), 100s, expire), + subscription::start_time(start), + ter(temBAD_EXPIRATION)); + env.close(); + } + + /* + UPDATE + */ + + // tecNO_ENTRY: SetSubscription: Subscription does not exist. + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::update(alice, subId, XRP(100)), ter(tecNO_ENTRY)); + env.close(); + } + // tecNO_PERMISSION: SetSubscription: Account is not the owner of the + // subscription. + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(100), 100s)); + env.close(); + + env(subscription::update(bob, subId, XRP(100)), + ter(tecNO_PERMISSION)); + env.close(); + } + } + + void + testSetDoApply(FeatureBitset features) + { + testcase("set doApply"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + /* + CREATE + */ + + // tecINSUFFICIENT_RESERVE + + /* + UPDATE + */ + } + + void + testCancelPreflight(FeatureBitset features) + { + testcase("cancel preflight"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + // temINVALID_FLAG + { + env(subscription::cancel(alice, subId), + txflags(tfSetfAuth), + ter(temINVALID_FLAG)); + env.close(); + } + } + + void + testCancelPreclaim(FeatureBitset features) + { + testcase("cancel preclaim"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + // tecNO_ENTRY + { + env(subscription::cancel(alice, subId), ter(tecNO_ENTRY)); + env.close(); + } + } + + void + testCancelDoApply(FeatureBitset features) + { + testcase("cancel doApply"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + // tefBAD_LEDGER: TODO: Use Genesis Ledger + // tefBAD_LEDGER: TODO: Use Genesis Ledger + } + + void + testClaimPreflight(FeatureBitset features) + { + testcase("claim preflight"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + // temINVALID_FLAG + } + + void + testClaimPreclaim(FeatureBitset features) + { + testcase("claim preclaim"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + // tecNO_TARGET + // temBAD_AMOUNT: ClaimSubscription: The transaction amount is greater + // than the subscription amount. tefFAILURE: ClaimSubscription: The + // subscription has not reached the next payment time. + } + + void + testClaimDoApply(FeatureBitset features) + { + testcase("claim doApply"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + env(trust(alice, USD(10000))); + env(trust(bob, USD(10000))); + env.close(); + env(pay(gw, alice, USD(1000))); + env(pay(gw, bob, USD(1000))); + env.close(); + + // tecNO_PERMISSION + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::claim(alice, subId, XRP(1)), + ter(tecNO_PERMISSION)); + env.close(); + } + + // tecUNFUNDED_PAYMENT + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + env(subscription::create(alice, bob, XRP(10000), 100s)); + env.close(); + + env(subscription::claim(bob, subId, XRP(10000)), + ter(tecUNFUNDED_PAYMENT)); + env.close(); + } + + // tecNO_LINE_INSUF_RESERVE + // { + // auto const aliceSeq = env.seq(alice); + // auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + // env(subscription::create(alice, bob, XRP(10000), 100s)); + // env.close(); + + // env(subscription::claim(bob, subId, XRP(1)), + // ter(tecNO_LINE_INSUF_RESERVE)); env.close(); + // } + + // tecPATH_PARTIAL + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + env(subscription::create(alice, bob, USD(10000), 100s)); + env.close(); + + env(subscription::claim(bob, subId, USD(10000)), + ter(tecPATH_PARTIAL)); + env.close(); + } + } + + void + testSet(FeatureBitset features) + { + testcase("set"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + // No StartTime & No Expiration + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + auto const startTime = env.now(); + auto const frequency = 100s; + env(subscription::create(alice, bob, XRP(10), frequency)); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT(subSle->getFieldAmount(sfAmount) == XRP(10)); + BEAST_EXPECT(subSle->getFieldU32(sfFrequency) == frequency.count()); + BEAST_EXPECT( + subSle->getFieldU32(sfNextPaymentTime) == + startTime.time_since_epoch().count()); + BEAST_EXPECT(!subSle->isFieldPresent(sfExpiration)); + } + + // StartTime & Expiration + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + auto const startTime = env.now() + 100s; + auto const expiration = env.now() + 300s; + auto const frequency = 100s; + env(subscription::create( + alice, bob, XRP(10), frequency, expiration), + subscription::start_time(startTime)); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT(subSle->getFieldAmount(sfAmount) == XRP(10)); + BEAST_EXPECT(subSle->getFieldU32(sfFrequency) == frequency.count()); + BEAST_EXPECT( + subSle->getFieldU32(sfNextPaymentTime) == + startTime.time_since_epoch().count()); + BEAST_EXPECT( + subSle->getFieldU32(sfExpiration) == + expiration.time_since_epoch().count()); + } + } + + void + testUpdate(FeatureBitset features) + { + testcase("update"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + // Update Amount + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::update(alice, subId, XRP(11))); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT(subSle->getFieldAmount(sfAmount) == XRP(11)); + } + + // Update Expiration + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + auto const expire = env.now() - 10s; + env(subscription::update(alice, subId, XRP(10), expire)); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT(subSle->getFieldAmount(sfAmount) == XRP(10)); + BEAST_EXPECT( + subSle->getFieldU32(sfExpiration) == + expire.time_since_epoch().count()); + } + } + + void + testClaim(FeatureBitset features) + { + testcase("claim"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Claim XRP + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + auto const [preKey, preSubSle] = + subKeyAndSle(*env.current(), subId); + + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + BEAST_EXPECT(env.balance(alice) == preAlice - baseFee - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - baseFee + XRP(10)); + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT( + subSle->getFieldU32(sfNextPaymentTime) == + preSubSle->getFieldU32(sfNextPaymentTime) + + preSubSle->getFieldU32(sfFrequency)); + } + + // Claim IOU Has Trustline + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + env(trust(alice, USD(10000))); + env(trust(bob, USD(10000))); + env.close(); + env(pay(gw, alice, USD(1000))); + env(pay(gw, bob, USD(1000))); + env.close(); + + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + auto const preAlice = env.balance(alice, USD.issue()); + auto const preBob = env.balance(bob, USD.issue()); + + env(subscription::create(alice, bob, USD(10), 100s)); + env.close(); + + auto const [preKey, preSubSle] = + subKeyAndSle(*env.current(), subId); + + env(subscription::claim(bob, subId, USD(10))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD.issue()) == preAlice - USD(10)); + BEAST_EXPECT(env.balance(bob, USD.issue()) == preBob + USD(10)); + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT( + subSle->getFieldU32(sfNextPaymentTime) == + preSubSle->getFieldU32(sfNextPaymentTime) + + preSubSle->getFieldU32(sfFrequency)); + } + + // Claim IOU No Trustline + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + Env env{*this, features}; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + env(trust(alice, USD(10000))); + env.close(); + env(pay(gw, alice, USD(1000))); + env.close(); + + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + auto const preAlice = env.balance(alice, USD.issue()); + + env(subscription::create(alice, bob, USD(10), 100s)); + env.close(); + + auto const [preKey, preSubSle] = + subKeyAndSle(*env.current(), subId); + + env(subscription::claim(bob, subId, USD(10))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD.issue()) == preAlice - USD(10)); + BEAST_EXPECT(env.balance(bob, USD.issue()) == USD(10)); + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT( + subSle->getFieldU32(sfNextPaymentTime) == + preSubSle->getFieldU32(sfNextPaymentTime) + + preSubSle->getFieldU32(sfFrequency)); + } + + // Claim Expire + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + + auto const expire = env.now() + 10s; + env(subscription::create(alice, bob, XRP(10), 100s, expire)); + env.close(10s); + + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(env.balance(alice) == preAlice - baseFee - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - baseFee + XRP(10)); + + env(subscription::claim(bob, subId, XRP(10)), ter(tecNO_TARGET)); + env.close(); + } + } + + void + testWithFeats(FeatureBitset features) + { + testEnabled(features); + testSetPreflight(features); + testSetPreclaim(features); + testSetDoApply(features); + testCancelPreflight(features); + testCancelPreclaim(features); + testCancelDoApply(features); + testClaimPreflight(features); + testClaimPreclaim(features); + testClaimDoApply(features); + testSet(features); + testUpdate(features); + testClaim(features); + // testDstTag(features); + // testDepositAuth(features); + // testMetaAndOwnership(features); + // testGateway(features); + // testAccountDelete(features); + // testUsingTickets(features); + } + +public: + void + run() override + { + using namespace test::jtx; + auto const sa = supported_amendments(); + testWithFeats(sa); + } +}; + +BEAST_DEFINE_TESTSUITE(Subscription, app, ripple); +} // namespace test +} // namespace ripple diff --git a/src/test/jtx.h b/src/test/jtx.h index 6de7cd480fa..f48637957b5 100644 --- a/src/test/jtx.h +++ b/src/test/jtx.h @@ -58,6 +58,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/jtx/impl/subscription.cpp b/src/test/jtx/impl/subscription.cpp new file mode 100644 index 00000000000..6ebae6b6f04 --- /dev/null +++ b/src/test/jtx/impl/subscription.cpp @@ -0,0 +1,106 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include + +namespace ripple { +namespace test { +namespace jtx { + +/** Subscription operations. */ +namespace subscription { + +void +start_time::operator()(Env& env, JTx& jt) const +{ + jt.jv[sfStartTime.jsonName] = value_.time_since_epoch().count(); +} + +Json::Value +create( + jtx::Account const& account, + jtx::Account const& destination, + STAmount const& amount, + NetClock::duration const& frequency, + std::optional const& expiration) +{ + Json::Value jv; + jv[jss::TransactionType] = jss::SubscriptionSet; + jv[jss::Account] = to_string(account.id()); + jv[jss::Destination] = to_string(destination.id()); + jv[jss::Amount] = amount.getJson(JsonOptions::none); + jv[jss::Frequency] = frequency.count(); + jv[jss::Flags] = tfUniversal; + if (expiration) + jv[sfExpiration.jsonName] = expiration->time_since_epoch().count(); + return jv; +} + +Json::Value +update( + jtx::Account const& account, + uint256 const& subscriptionId, + STAmount const& amount, + std::optional const& expiration) +{ + Json::Value jv; + jv[jss::TransactionType] = jss::SubscriptionSet; + jv[jss::Account] = to_string(account.id()); + jv[jss::SubscriptionID] = to_string(subscriptionId); + jv[jss::Amount] = amount.getJson(JsonOptions::none); + jv[jss::Flags] = tfUniversal; + if (expiration) + jv[sfExpiration.jsonName] = expiration->time_since_epoch().count(); + return jv; +} + +Json::Value +cancel(jtx::Account const& account, uint256 const& subscriptionId) +{ + Json::Value jv; + jv[jss::TransactionType] = jss::SubscriptionCancel; + jv[jss::Account] = to_string(account.id()); + jv[jss::SubscriptionID] = to_string(subscriptionId); + jv[jss::Flags] = tfUniversal; + return jv; +} + +Json::Value +claim( + jtx::Account const& account, + uint256 const& subscriptionId, + STAmount const& amount) +{ + Json::Value jv; + jv[jss::TransactionType] = jss::SubscriptionClaim; + jv[jss::Account] = to_string(account.id()); + jv[jss::SubscriptionID] = to_string(subscriptionId); + jv[jss::Amount] = amount.getJson(JsonOptions::none); + jv[jss::Flags] = tfUniversal; + return jv; +} + +} // namespace subscription + +} // namespace jtx + +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/subscription.h b/src/test/jtx/subscription.h new file mode 100644 index 00000000000..9fabcfe0784 --- /dev/null +++ b/src/test/jtx/subscription.h @@ -0,0 +1,79 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2019 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_TEST_JTX_SUBSCRIPTION_H_INCLUDED +#define RIPPLE_TEST_JTX_SUBSCRIPTION_H_INCLUDED + +#include +#include + +namespace ripple { +namespace test { +namespace jtx { + +/** Subscription operations. */ +namespace subscription { + +Json::Value +create( + jtx::Account const& account, + jtx::Account const& destination, + STAmount const& amount, + NetClock::duration const& frequency, + std::optional const& expiration = std::nullopt); + +Json::Value +update( + jtx::Account const& account, + uint256 const& subscriptionId, + STAmount const& amount, + std::optional const& expiration = std::nullopt); + +Json::Value +cancel(jtx::Account const& account, uint256 const& subscriptionId); + +Json::Value +claim( + jtx::Account const& account, + uint256 const& subscriptionId, + STAmount const& amount); + +/** Set the "StartTime" time tag on a JTx */ +class start_time +{ +private: + NetClock::time_point value_; + +public: + explicit start_time(NetClock::time_point const& value) : value_(value) + { + } + + void + operator()(Env&, JTx& jtx) const; +}; + +} // namespace subscription + +} // namespace jtx + +} // namespace test +} // namespace ripple + +#endif diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index f855ad8578c..2c3e31b134f 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -478,6 +478,7 @@ LedgerEntryTypesMatch::visitEntry( case ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID: case ltDID: case ltORACLE: + case ltSUBSCRIPTION: break; default: invalidTypeAdded_ = true; diff --git a/src/xrpld/app/tx/detail/Subscription.cpp b/src/xrpld/app/tx/detail/Subscription.cpp new file mode 100644 index 00000000000..80ee644ed8c --- /dev/null +++ b/src/xrpld/app/tx/detail/Subscription.cpp @@ -0,0 +1,535 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ripple { + +NotTEC +SetSubscription::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureSubscription)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfUniversalMask) + return temINVALID_FLAG; + + // Validate txn.Account != txn.Destination + if (ctx.tx.getAccountID(sfDestination) == ctx.tx.getAccountID(sfAccount)) + { + JLOG(ctx.j.warn()) << "SetSubscription: Malformed transaction: Account " + "is the same as the destination."; + return temDST_IS_SRC; + } + + // Validate txn.Amount + STAmount const amount = ctx.tx.getFieldAmount(sfAmount); + if (!isLegalNet(amount) || amount.signum() <= 0) + { + JLOG(ctx.j.warn()) + << "SetSubscription: Malformed transaction: bad amount: " + << amount.getFullText(); + return temBAD_AMOUNT; + } + + if (badCurrency() == amount.getCurrency()) + { + JLOG(ctx.j.warn()) + << "SetSubscription: Malformed transaction: Bad currency."; + return temBAD_CURRENCY; + } + + return preflight2(ctx); +} + +TER +SetSubscription::preclaim(PreclaimContext const& ctx) +{ + if (ctx.tx.isFieldPresent(sfSubscriptionID)) + { + // update + auto sle = ctx.view.read( + keylet::subscription(ctx.tx.getFieldH256(sfSubscriptionID))); + if (!sle) + { + JLOG(ctx.j.warn()) + << "SetSubscription: Subscription does not exist."; + return tecNO_ENTRY; + } + + if (sle->getAccountID(sfAccount) != ctx.tx.getAccountID(sfAccount)) + { + JLOG(ctx.j.warn()) << "SetSubscription: Account is not the " + "owner of the subscription."; + return tecNO_PERMISSION; + } + } + else + { + auto const sleDest = + ctx.view.read(keylet::account(ctx.tx.getAccountID(sfDestination))); + if (!sleDest) + { + JLOG(ctx.j.warn()) + << "SetSubscription: Destination account does not exist."; + return tecNO_DST; + } + + // create// Validate Frequency <= 0 + if (ctx.tx.getFieldU32(sfFrequency) <= 0) + { + JLOG(ctx.j.warn()) + << "SetSubscription: The frequency is less than or equal to 0."; + return temMALFORMED; + } + + auto const currentTime = + ctx.view.info().parentCloseTime.time_since_epoch().count(); + // StartTime is less than the current time. + auto startTime = currentTime; + auto nextPaymentTime = currentTime; + if (ctx.tx.isFieldPresent(sfStartTime)) + { + startTime = ctx.tx.getFieldU32(sfStartTime); + nextPaymentTime = startTime; + if (startTime < currentTime) + { + JLOG(ctx.j.warn()) + << "SetSubscription: The start time is in the past."; + return temMALFORMED; + } + } + + if (ctx.tx.isFieldPresent(sfExpiration)) + { + auto const expiration = ctx.tx.getFieldU32(sfExpiration); + + // Expiration is less than the current time. + if (expiration < currentTime) + { + JLOG(ctx.j.warn()) + << "SetSubscription: The expiration time is in the past."; + return temBAD_EXPIRATION; + } + // Expiration is less than the NextPaymentTime. + if (expiration < nextPaymentTime) + { + JLOG(ctx.j.warn()) << "SetSubscription: The expiration time is " + "less than the next payment time."; + return temBAD_EXPIRATION; + } + } + } + // Validate Trustline Exists + // Validate Initial Payment Amount (Insufficient Funds) + return tesSUCCESS; +} + +TER +SetSubscription::doApply() +{ + Sandbox sb(&ctx_.view()); + + AccountID const account = ctx_.tx.getAccountID(sfAccount); + auto const sleAccount = sb.peek(keylet::account(account)); + if (!sleAccount) + { + JLOG(ctx_.journal.warn()) << "SetSubscription: Account does not exist."; + return tecINTERNAL; + } + + if (ctx_.tx.isFieldPresent(sfSubscriptionID)) + { + // update + auto sle = sb.peek( + keylet::subscription(ctx_.tx.getFieldH256(sfSubscriptionID))); + sle->setFieldAmount(sfAmount, ctx_.tx.getFieldAmount(sfAmount)); + if (ctx_.tx.isFieldPresent(sfExpiration)) + sle->setFieldU32(sfExpiration, ctx_.tx.getFieldU32(sfExpiration)); + + sb.update(sle); + } + else + { + // Check reserve and funds availability + { + auto const balance = STAmount((*sleAccount)[sfBalance]).xrp(); + auto const reserve = + sb.fees().accountReserve((*sleAccount)[sfOwnerCount] + 1); + if (balance < reserve) + return tecINSUFFICIENT_RESERVE; + + // TODO: DA Should you be required to have the first installment? + // if (balance < reserve + STAmount(ctx_.tx[sfAmount]).xrp()) + // return tecUNFUNDED; + } + + // create + AccountID const dest = ctx_.tx.getAccountID(sfDestination); + Keylet const subKeylet = keylet::subscription( + account, dest, ctx_.tx.getFieldU32(sfSequence)); + auto sle = std::make_shared(subKeylet); + sle->setAccountID(sfAccount, account); + sle->setAccountID(sfDestination, dest); + sle->setFieldAmount(sfAmount, ctx_.tx.getFieldAmount(sfAmount)); + sle->setFieldU32(sfFrequency, ctx_.tx.getFieldU32(sfFrequency)); + auto nextPaymentTime = + sb.info().parentCloseTime.time_since_epoch().count(); + if (ctx_.tx.isFieldPresent(sfStartTime)) + nextPaymentTime = ctx_.tx.getFieldU32(sfStartTime); + sle->setFieldU32(sfNextPaymentTime, nextPaymentTime); + if (ctx_.tx.isFieldPresent(sfExpiration)) + sle->setFieldU32(sfExpiration, ctx_.tx.getFieldU32(sfExpiration)); + + // Add subscription to sender's owner directory + { + auto page = sb.dirInsert( + keylet::ownerDir(account), + subKeylet, + describeOwnerDir(account)); + if (!page) + return tecDIR_FULL; + (*sle)[sfOwnerNode] = *page; + } + + // Add subscription to recipient's owner directory. + { + auto page = sb.dirInsert( + keylet::ownerDir(dest), subKeylet, describeOwnerDir(dest)); + if (!page) + return tecDIR_FULL; + (*sle)[sfDestinationNode] = *page; + } + + adjustOwnerCount(sb, sleAccount, 1, ctx_.journal); + sb.insert(sle); + } + sb.apply(ctx_.rawView()); + return tesSUCCESS; +} + +NotTEC +CancelSubscription::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureSubscription)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfUniversalMask) + return temINVALID_FLAG; + + return preflight2(ctx); +} + +TER +CancelSubscription::preclaim(PreclaimContext const& ctx) +{ + auto const sleSub = ctx.view.read( + keylet::subscription(ctx.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(ctx.j.warn()) + << "CancelSubscription: Subscription does not exist."; + return tecNO_ENTRY; + } + + return tesSUCCESS; +} + +TER +CancelSubscription::doApply() +{ + Sandbox sb(&ctx_.view()); + + auto const sleSub = + sb.peek(keylet::subscription(ctx_.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(ctx_.journal.warn()) + << "CancelSubscription: Subscription does not exist."; + return tecINTERNAL; + } + + AccountID const srcAcct{sleSub->getAccountID(sfAccount)}; + AccountID const dstAcct{sleSub->getAccountID(sfDestination)}; + auto viewJ = ctx_.app.journal("View"); + + std::uint64_t const ownerPage{(*sleSub)[sfOwnerNode]}; + if (!sb.dirRemove( + keylet::ownerDir(srcAcct), ownerPage, sleSub->key(), true)) + { + JLOG(j_.fatal()) << "Unable to delete check from source."; + return tefBAD_LEDGER; + } + + std::uint64_t const destPage{(*sleSub)[sfDestinationNode]}; + if (!sb.dirRemove(keylet::ownerDir(dstAcct), destPage, sleSub->key(), true)) + { + JLOG(j_.fatal()) << "Unable to delete check from destination."; + return tefBAD_LEDGER; + } + + auto const sleSrc = sb.peek(keylet::account(srcAcct)); + sb.erase(sleSub); + + adjustOwnerCount(sb, sleSrc, -1, viewJ); + + sb.apply(ctx_.rawView()); + return tesSUCCESS; +} + +NotTEC +ClaimSubscription::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureSubscription)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfUniversalMask) + return temINVALID_FLAG; + + return preflight2(ctx); +} + +TER +ClaimSubscription::preclaim(PreclaimContext const& ctx) +{ + auto sleSub = ctx.view.read( + keylet::subscription(ctx.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Subscription does not exist."; + return tecNO_TARGET; + } + + AccountID const srcAcct{sleSub->getAccountID(sfAccount)}; + if (!ctx.view.exists(keylet::account(srcAcct))) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Account does not exist."; + return terNO_ACCOUNT; + } + + AccountID const destAcct{sleSub->getAccountID(sfDestination)}; + if (!ctx.view.exists(keylet::account(destAcct))) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Account does not exist."; + return terNO_ACCOUNT; + } + + if (ctx.tx.getFieldAmount(sfAmount) > sleSub->getFieldAmount(sfAmount)) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: The transaction amount is " + "greater than the subscription amount."; + return temBAD_AMOUNT; + } + + if (ctx.view.info().parentCloseTime.time_since_epoch().count() < + sleSub->getFieldU32(sfNextPaymentTime)) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: The subscription has not " + "reached the next payment time."; + return tefFAILURE; + } + + return tesSUCCESS; +} + +TER +ClaimSubscription::doApply() +{ + PaymentSandbox psb(&ctx_.view()); + auto viewJ = ctx_.app.journal("View"); + + auto sleSub = + psb.peek(keylet::subscription(ctx_.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(ctx_.journal.warn()) + << "ClaimSubscription: Subscription does not exist."; + return tecINTERNAL; + } + + AccountID const srcAcct{sleSub->getAccountID(sfAccount)}; + if (!psb.exists(keylet::account(srcAcct))) + { + JLOG(ctx_.journal.warn()) + << "ClaimSubscription: Account does not exist."; + return tecINTERNAL; + } + + AccountID const destAcct{sleSub->getAccountID(sfDestination)}; + if (!psb.exists(keylet::account(destAcct))) + { + JLOG(ctx_.journal.warn()) + << "ClaimSubscription: Account does not exist."; + return tecINTERNAL; + } + + if (destAcct != ctx_.tx.getAccountID(sfAccount)) + { + JLOG(ctx_.journal.warn()) << "ClaimSubscription: Account is not the " + "destination of the subscription."; + return tecNO_PERMISSION; + } + + STAmount const amount = sleSub->getFieldAmount(sfAmount); + if (amount.native()) + { + STAmount const srcLiquid{xrpLiquid(psb, srcAcct, 0, viewJ)}; + STAmount const xrpDeliver{ctx_.tx.getFieldAmount(sfAmount)}; + + if (srcLiquid < xrpDeliver) + { + JLOG(ctx_.journal.warn()) + << "ClaimSubscription: Insufficient funds."; + return tecUNFUNDED_PAYMENT; + } + + if (TER const ter{ + transferXRP(psb, srcAcct, destAcct, xrpDeliver, viewJ)}; + ter != tesSUCCESS) + { + return ter; + } + } + else + { + STAmount const flowDeliver{ctx_.tx.getFieldAmount(sfAmount)}; + Issue const& trustLineIssue = flowDeliver.issue(); + AccountID const issuer = flowDeliver.getIssuer(); + AccountID const truster = issuer == destAcct ? srcAcct : destAcct; + Keylet const trustLineKey = keylet::line(truster, trustLineIssue); + bool const destLow = issuer > destAcct; + + if (!psb.exists(trustLineKey)) + { + auto const sleDst = psb.peek(keylet::account(destAcct)); + + if (std::uint32_t const ownerCount = {sleDst->at(sfOwnerCount)}; + mPriorBalance < psb.fees().accountReserve(ownerCount + 1)) + { + JLOG(j_.trace()) << "Trust line does not exist. " + "Insufficent reserve to create line."; + + return tecNO_LINE_INSUF_RESERVE; + } + + Currency const currency = flowDeliver.getCurrency(); + STAmount initialBalance(flowDeliver.issue()); + initialBalance.setIssuer(noAccount()); + + // clang-format off + if (TER const ter = trustCreate( + psb, // payment sandbox + destLow, // is dest low? + issuer, // source + destAcct, // destination + trustLineKey.key, // ledger index + sleDst, // Account to add to + false, // authorize account + (sleDst->getFlags() & lsfDefaultRipple) == 0, + false, // freeze trust line + initialBalance, // zero initial balance + Issue(currency, destAcct), // limit of zero + 0, // quality in + 0, // quality out + viewJ); // journal + !isTesSuccess(ter)) + { + return ter; + } + // clang-format on + + psb.update(sleDst); + } + + auto const sleTrustLine = psb.peek(trustLineKey); + if (!sleTrustLine) + return tecINTERNAL; + + SF_AMOUNT const& tweakedLimit = destLow ? sfLowLimit : sfHighLimit; + STAmount const savedLimit = sleTrustLine->at(tweakedLimit); + + scope_exit fixup([&psb, &trustLineKey, &tweakedLimit, &savedLimit]() { + if (auto const sleTrustLine = psb.peek(trustLineKey)) + sleTrustLine->at(tweakedLimit) = savedLimit; + }); + + STAmount const bigAmount( + trustLineIssue, STAmount::cMaxValue, STAmount::cMaxOffset); + sleTrustLine->at(tweakedLimit) = bigAmount; + + auto const result = flow( + psb, + flowDeliver, + srcAcct, + destAcct, + STPathSet{}, + true, // default path + false, // partial payment + true, // owner pays transfer fee + OfferCrossing::no, + std::nullopt, + sleSub->getFieldAmount(sfAmount), + viewJ); + + if (result.result() != tesSUCCESS) + { + JLOG(ctx_.journal.warn()) + << "flow failed when claiming subscription."; + return result.result(); + } + + ctx_.deliver(result.actualAmountOut); + } + + sleSub->setFieldU32( + sfNextPaymentTime, + sleSub->getFieldU32(sfNextPaymentTime) + + sleSub->getFieldU32(sfFrequency)); + psb.update(sleSub); + + if (sleSub->isFieldPresent(sfExpiration) && + psb.info().parentCloseTime.time_since_epoch().count() >= + sleSub->getFieldU32(sfExpiration)) + { + psb.erase(sleSub); + } + + psb.apply(ctx_.rawView()); + return tesSUCCESS; +} + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/Subscription.h b/src/xrpld/app/tx/detail/Subscription.h new file mode 100644 index 00000000000..16c22a9f461 --- /dev/null +++ b/src/xrpld/app/tx/detail/Subscription.h @@ -0,0 +1,86 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2024 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_SUBSCRIPTION_H_INCLUDED +#define RIPPLE_TX_SUBSCRIPTION_H_INCLUDED + +#include + +namespace ripple { + +class SetSubscription : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit SetSubscription(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +class CancelSubscription : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit CancelSubscription(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +class ClaimSubscription : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit ClaimSubscription(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif // RIPPLE_TX_SUBSCRIPTION_H_INCLUDED diff --git a/src/xrpld/app/tx/detail/applySteps.cpp b/src/xrpld/app/tx/detail/applySteps.cpp index cbeabb6fc9c..b0dbdaf3337 100644 --- a/src/xrpld/app/tx/detail/applySteps.cpp +++ b/src/xrpld/app/tx/detail/applySteps.cpp @@ -51,6 +51,7 @@ #include #include #include +#include #include #include @@ -168,6 +169,12 @@ with_txn_type(TxType txnType, F&& f) return f.template operator()(); case ttORACLE_DELETE: return f.template operator()(); + case ttSUBSCRIPTION_SET: + return f.template operator()(); + case ttSUBSCRIPTION_CANCEL: + return f.template operator()(); + case ttSUBSCRIPTION_CLAIM: + return f.template operator()(); default: throw UnknownTxnType(txnType); } diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index f461cd3100b..1a5641c99e0 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -644,6 +644,51 @@ doLedgerEntry(RPC::JsonContext& context) uNodeIndex = keylet::oracle(*account, *documentID).key; } } + else if (context.params.isMember(jss::subscription)) + { + expectedType = ltSUBSCRIPTION; + if (!context.params[jss::subscription].isObject()) + { + if (!uNodeIndex.parseHex( + context.params[jss::subscription].asString())) + { + uNodeIndex = beast::zero; + jvResult[jss::error] = "malformedRequest"; + } + } + // else if ( + // !context.params[jss::oracle].isMember( + // jss::oracle_document_id) || + // !context.params[jss::oracle].isMember(jss::account)) + // { + // jvResult[jss::error] = "malformedRequest"; + // } + // else + // { + // uNodeIndex = beast::zero; + // auto const& oracle = context.params[jss::oracle]; + // auto const documentID = [&]() -> std::optional { + // auto const& id = oracle[jss::oracle_document_id]; + // if (id.isUInt() || (id.isInt() && id.asInt() >= 0)) + // return std::make_optional(id.asUInt()); + // else if (id.isString()) + // { + // std::uint32_t v; + // if (beast::lexicalCastChecked(v, id.asString())) + // return std::make_optional(v); + // } + // return std::nullopt; + // }(); + // auto const account = + // parseBase58(oracle[jss::account].asString()); + // if (!account || account->isZero()) + // jvResult[jss::error] = "malformedAddress"; + // else if (!documentID) + // jvResult[jss::error] = "malformedDocumentID"; + // else + // uNodeIndex = keylet::oracle(*account, *documentID).key; + // } + } else { if (context.params.isMember("params") && From bf9f42197292bfcabaa1cc78baca2ece9ed788a5 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Sun, 15 Sep 2024 15:46:46 +0200 Subject: [PATCH 2/7] clang-format --- include/xrpl/protocol/Indexes.h | 5 ++++- src/libxrpl/protocol/Indexes.cpp | 9 +++++++-- src/libxrpl/protocol/TxFormats.cpp | 6 +++--- src/xrpld/rpc/handlers/LedgerEntry.cpp | 3 ++- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 694d4b3efaf..3b20c597f0b 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -288,7 +288,10 @@ Keylet oracle(AccountID const& account, std::uint32_t const& documentID) noexcept; Keylet -subscription(AccountID const& account, AccountID const& dest, std::uint32_t const& seq) noexcept; +subscription( + AccountID const& account, + AccountID const& dest, + std::uint32_t const& seq) noexcept; inline Keylet subscription(uint256 const& key) noexcept diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 7500b9edb29..0a1d6a49d25 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -453,9 +453,14 @@ oracle(AccountID const& account, std::uint32_t const& documentID) noexcept } Keylet -subscription(AccountID const& account, AccountID const& dest, std::uint32_t const& seq) noexcept +subscription( + AccountID const& account, + AccountID const& dest, + std::uint32_t const& seq) noexcept { - return {ltSUBSCRIPTION, indexHash(LedgerNameSpace::SUBSCRIPTION, account, dest, seq)}; + return { + ltSUBSCRIPTION, + indexHash(LedgerNameSpace::SUBSCRIPTION, account, dest, seq)}; } } // namespace keylet diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp index da5848b20d3..7f4c6475bc9 100644 --- a/src/libxrpl/protocol/TxFormats.cpp +++ b/src/libxrpl/protocol/TxFormats.cpp @@ -513,7 +513,7 @@ TxFormats::TxFormats() {sfOwner, soeOPTIONAL}, }, commonFields); - + add(jss::SubscriptionSet, ttSUBSCRIPTION_SET, { @@ -526,14 +526,14 @@ TxFormats::TxFormats() {sfSubscriptionID, soeOPTIONAL}, }, commonFields); - + add(jss::SubscriptionCancel, ttSUBSCRIPTION_CANCEL, { {sfSubscriptionID, soeREQUIRED}, }, commonFields); - + add(jss::SubscriptionClaim, ttSUBSCRIPTION_CLAIM, { diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index 1a5641c99e0..6cc85aa6a4c 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -667,7 +667,8 @@ doLedgerEntry(RPC::JsonContext& context) // { // uNodeIndex = beast::zero; // auto const& oracle = context.params[jss::oracle]; - // auto const documentID = [&]() -> std::optional { + // auto const documentID = [&]() -> std::optional + // { // auto const& id = oracle[jss::oracle_document_id]; // if (id.isUInt() || (id.isInt() && id.asInt() >= 0)) // return std::make_optional(id.asUInt()); From 15315f78aa393c75f6519dbb4c46e7808f741ae9 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Sun, 15 Sep 2024 16:29:08 +0200 Subject: [PATCH 3/7] add rpc tests --- src/test/rpc/LedgerRPC_test.cpp | 117 +++++++++++++++++++++++++ src/xrpld/rpc/detail/RPCHelpers.cpp | 3 +- src/xrpld/rpc/handlers/LedgerEntry.cpp | 61 ++++++------- 3 files changed, 147 insertions(+), 34 deletions(-) diff --git a/src/test/rpc/LedgerRPC_test.cpp b/src/test/rpc/LedgerRPC_test.cpp index 70e4ffbe8dc..bdfed510108 100644 --- a/src/test/rpc/LedgerRPC_test.cpp +++ b/src/test/rpc/LedgerRPC_test.cpp @@ -1413,6 +1413,122 @@ class LedgerRPC_test : public beast::unit_test::suite } } + void + testLedgerEntrySubscription() + { + testcase("ledger_entry Request Subscription"); + using namespace test::jtx; + Env env{*this}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + env.close(); + + using namespace std::chrono_literals; + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + std::string const ledgerHash{to_string(env.closed()->info().hash)}; + std::string subIndex; + { + // Request the subscription using account, destination and sequence. + Json::Value jvParams; + jvParams[jss::subscription] = Json::objectValue; + jvParams[jss::subscription][jss::account] = alice.human(); + jvParams[jss::subscription][jss::destination] = bob.human(); + jvParams[jss::subscription][jss::seq] = env.seq(alice) - 1; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + BEAST_EXPECT( + jrr[jss::node][jss::Amount] == XRP(10).value().getText()); + subIndex = jrr[jss::index].asString(); + } + { + // Request the subscription by index. + Json::Value jvParams; + jvParams[jss::subscription] = subIndex; + jvParams[jss::ledger_hash] = ledgerHash; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + BEAST_EXPECT( + jrr[jss::node][jss::Amount] == XRP(10).value().getText()); + } + { + // Malformed account entry. + Json::Value jvParams; + jvParams[jss::subscription] = Json::objectValue; + + std::string const badAddress = makeBadAddress(alice.human()); + jvParams[jss::subscription][jss::account] = badAddress; + jvParams[jss::subscription][jss::destination] = bob.human(); + jvParams[jss::subscription][jss::seq] = env.seq(alice) - 1; + jvParams[jss::ledger_hash] = ledgerHash; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "malformedAddress", ""); + } + { + // Missing account. + Json::Value jvParams; + jvParams[jss::subscription] = Json::objectValue; + jvParams[jss::subscription][jss::destination] = bob.human(); + jvParams[jss::subscription][jss::seq] = env.seq(alice) - 1; + jvParams[jss::ledger_hash] = ledgerHash; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "malformedRequest", ""); + } + { + // Malformed destination entry. + Json::Value jvParams; + jvParams[jss::subscription] = Json::objectValue; + + std::string const badAddress = makeBadAddress(alice.human()); + jvParams[jss::subscription][jss::account] = alice.human(); + jvParams[jss::subscription][jss::destination] = badAddress; + jvParams[jss::subscription][jss::seq] = env.seq(alice) - 1; + jvParams[jss::ledger_hash] = ledgerHash; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "malformedAddress", ""); + } + { + // Missing destination. + Json::Value jvParams; + jvParams[jss::subscription] = Json::objectValue; + jvParams[jss::subscription][jss::account] = alice.human(); + jvParams[jss::subscription][jss::seq] = env.seq(alice) - 1; + jvParams[jss::ledger_hash] = ledgerHash; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "malformedRequest", ""); + } + { + // Missing sequence. + Json::Value jvParams; + jvParams[jss::subscription] = Json::objectValue; + jvParams[jss::subscription][jss::account] = alice.human(); + jvParams[jss::subscription][jss::destination] = bob.human(); + jvParams[jss::ledger_hash] = ledgerHash; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "malformedRequest", ""); + } + { + // Non-integer sequence. + Json::Value jvParams; + jvParams[jss::subscription] = Json::objectValue; + jvParams[jss::subscription][jss::account] = alice.human(); + jvParams[jss::subscription][jss::destination] = bob.human(); + jvParams[jss::subscription][jss::seq] = + std::to_string(env.seq(alice) - 1); + jvParams[jss::ledger_hash] = ledgerHash; + Json::Value const jrr = env.rpc( + "json", "ledger_entry", to_string(jvParams))[jss::result]; + checkErrorValue(jrr, "malformedRequest", ""); + } + } + void testLedgerEntryTicket() { @@ -2380,6 +2496,7 @@ class LedgerRPC_test : public beast::unit_test::suite testLedgerEntryOffer(); testLedgerEntryPayChan(); testLedgerEntryRippleState(); + testLedgerEntrySubscription(); testLedgerEntryTicket(); testLookupLedger(); testNoQueue(); diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp index fa66fecfbba..d5fa7c97568 100644 --- a/src/xrpld/rpc/detail/RPCHelpers.cpp +++ b/src/xrpld/rpc/detail/RPCHelpers.cpp @@ -915,7 +915,7 @@ chooseLedgerEntryType(Json::Value const& params) std::pair result{RPC::Status::OK, ltANY}; if (params.isMember(jss::type)) { - static constexpr std::array, 22> + static constexpr std::array, 23> types{ {{jss::account, ltACCOUNT_ROOT}, {jss::amendments, ltAMENDMENTS}, @@ -936,6 +936,7 @@ chooseLedgerEntryType(Json::Value const& params) {jss::payment_channel, ltPAYCHAN}, {jss::signer_list, ltSIGNER_LIST}, {jss::state, ltRIPPLE_STATE}, + {jss::subscription, ltSUBSCRIPTION}, {jss::ticket, ltTICKET}, {jss::xchain_owned_claim_id, ltXCHAIN_OWNED_CLAIM_ID}, {jss::xchain_owned_create_account_claim_id, diff --git a/src/xrpld/rpc/handlers/LedgerEntry.cpp b/src/xrpld/rpc/handlers/LedgerEntry.cpp index 6cc85aa6a4c..f26d2f17645 100644 --- a/src/xrpld/rpc/handlers/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/LedgerEntry.cpp @@ -656,39 +656,34 @@ doLedgerEntry(RPC::JsonContext& context) jvResult[jss::error] = "malformedRequest"; } } - // else if ( - // !context.params[jss::oracle].isMember( - // jss::oracle_document_id) || - // !context.params[jss::oracle].isMember(jss::account)) - // { - // jvResult[jss::error] = "malformedRequest"; - // } - // else - // { - // uNodeIndex = beast::zero; - // auto const& oracle = context.params[jss::oracle]; - // auto const documentID = [&]() -> std::optional - // { - // auto const& id = oracle[jss::oracle_document_id]; - // if (id.isUInt() || (id.isInt() && id.asInt() >= 0)) - // return std::make_optional(id.asUInt()); - // else if (id.isString()) - // { - // std::uint32_t v; - // if (beast::lexicalCastChecked(v, id.asString())) - // return std::make_optional(v); - // } - // return std::nullopt; - // }(); - // auto const account = - // parseBase58(oracle[jss::account].asString()); - // if (!account || account->isZero()) - // jvResult[jss::error] = "malformedAddress"; - // else if (!documentID) - // jvResult[jss::error] = "malformedDocumentID"; - // else - // uNodeIndex = keylet::oracle(*account, *documentID).key; - // } + else if ( + !context.params[jss::subscription].isMember(jss::account) || + !context.params[jss::subscription].isMember(jss::destination) || + !context.params[jss::subscription].isMember(jss::seq) || + !context.params[jss::subscription][jss::seq].isIntegral()) + { + jvResult[jss::error] = "malformedRequest"; + } + else + { + uNodeIndex = beast::zero; + auto const& subscription = context.params[jss::subscription]; + auto const account = parseBase58( + subscription[jss::account].asString()); + auto const destination = parseBase58( + subscription[jss::destination].asString()); + if (!account || account->isZero()) + jvResult[jss::error] = "malformedAddress"; + else if (!destination || destination->isZero()) + jvResult[jss::error] = "malformedAddress"; + else + uNodeIndex = keylet::subscription( + *account, + *destination, + context.params[jss::subscription][jss::seq] + .asUInt()) + .key; + } } else { From ba7b0e30330fbe62f48d8281e5e8b4cfab550cbd Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Sun, 15 Sep 2024 17:14:31 +0200 Subject: [PATCH 4/7] add tests --- src/test/app/Subscription_test.cpp | 245 ++++++++++++++++++++++- src/xrpld/app/tx/detail/Subscription.cpp | 8 +- 2 files changed, 247 insertions(+), 6 deletions(-) diff --git a/src/test/app/Subscription_test.cpp b/src/test/app/Subscription_test.cpp index e20c9b2790b..07fb80664e8 100644 --- a/src/test/app/Subscription_test.cpp +++ b/src/test/app/Subscription_test.cpp @@ -598,6 +598,68 @@ struct Subscription_test : public beast::unit_test::suite } } + void + testCancel(FeatureBitset features) + { + testcase("cancel"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Cancel Account + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::cancel(alice, subId)); + env.close(); + + BEAST_EXPECT(env.balance(alice) == preAlice - (baseFee * 2)); + BEAST_EXPECT(env.balance(bob) == preBob); + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + + // Cancel Destination + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::cancel(bob, subId)); + env.close(); + + BEAST_EXPECT(env.balance(alice) == preAlice - baseFee); + BEAST_EXPECT(env.balance(bob) == preBob - baseFee); + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + } + void testClaim(FeatureBitset features) { @@ -754,6 +816,178 @@ struct Subscription_test : public beast::unit_test::suite } } + void + testDstTag(FeatureBitset features) + { + testcase("dst tag"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + env(fset(bob, asfRequireDest)); + env.close(); + + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), + ter(tecDST_TAG_NEEDED)); + env.close(); + + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), dtag(1)); + env.close(); + + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + } + } + + void + testAccountDelete(FeatureBitset features) + { + testcase("account delete"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto rmAccount = [this]( + Env& env, + Account const& toRm, + Account const& dst, + TER expectedTer = tesSUCCESS) { + // only allow an account to be deleted if the account's sequence + // number is at least 256 less than the current ledger sequence + for (auto minRmSeq = env.seq(toRm) + 257; + env.current()->seq() < minRmSeq; + env.close()) + { + } + + env(acctdelete(toRm, dst), + fee(drops(env.current()->fees().increment)), + ter(expectedTer)); + env.close(); + this->BEAST_EXPECT( + isTesSuccess(expectedTer) == + !env.closed()->exists(keylet::account(toRm.id()))); + }; + + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob, carol); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + rmAccount(env, alice, carol, tecHAS_OBLIGATIONS); + rmAccount(env, bob, carol, tecHAS_OBLIGATIONS); + BEAST_EXPECT(env.closed()->exists(keylet::account(alice.id()))); + BEAST_EXPECT(env.closed()->exists(keylet::account(bob.id()))); + } + + void + testUsingTickets(FeatureBitset features) + { + testcase("using tickets"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Claim / Cancel (Account) + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + std::uint32_t aliceTicketSeq{env.seq(alice) + 1}; + env(ticket::create(alice, 10)); + std::uint32_t const aliceSeq{env.seq(alice)}; + + std::uint32_t bobTicketSeq{env.seq(bob) + 1}; + env(ticket::create(bob, 10)); + std::uint32_t const bobSeq{env.seq(bob)}; + + auto const subId = getSubscriptionIndex(alice, bob, aliceTicketSeq); + env(subscription::create(alice, bob, XRP(10), 100s), + ticket::use(aliceTicketSeq++)); + env.close(); + + env.require(tickets(alice, env.seq(alice) - aliceTicketSeq)); + BEAST_EXPECT(env.seq(alice) == aliceSeq); + + env(subscription::claim(bob, subId, XRP(10)), + ticket::use(bobTicketSeq++)); + env.close(); + + env.require(tickets(bob, env.seq(bob) - bobTicketSeq)); + BEAST_EXPECT(env.seq(bob) == bobSeq); + + env(subscription::cancel(alice, subId), + ticket::use(aliceTicketSeq++)); + env.close(); + + env.require(tickets(alice, env.seq(alice) - aliceTicketSeq)); + BEAST_EXPECT(env.seq(alice) == aliceSeq); + } + + // Claim / Cancel (Destination) + { + // setup env + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + std::uint32_t aliceTicketSeq{env.seq(alice) + 1}; + env(ticket::create(alice, 10)); + std::uint32_t const aliceSeq{env.seq(alice)}; + + std::uint32_t bobTicketSeq{env.seq(bob) + 1}; + env(ticket::create(bob, 10)); + std::uint32_t const bobSeq{env.seq(bob)}; + + auto const subId = getSubscriptionIndex(alice, bob, aliceTicketSeq); + env(subscription::create(alice, bob, XRP(10), 100s), + ticket::use(aliceTicketSeq++)); + env.close(); + + env.require(tickets(alice, env.seq(alice) - aliceTicketSeq)); + BEAST_EXPECT(env.seq(alice) == aliceSeq); + + env(subscription::claim(bob, subId, XRP(10)), + ticket::use(bobTicketSeq++)); + env.close(); + + env.require(tickets(bob, env.seq(bob) - bobTicketSeq)); + BEAST_EXPECT(env.seq(bob) == bobSeq); + + env(subscription::cancel(bob, subId), ticket::use(bobTicketSeq++)); + env.close(); + + env.require(tickets(bob, env.seq(bob) - bobTicketSeq)); + BEAST_EXPECT(env.seq(bob) == bobSeq); + } + } + void testWithFeats(FeatureBitset features) { @@ -769,13 +1003,16 @@ struct Subscription_test : public beast::unit_test::suite testClaimDoApply(features); testSet(features); testUpdate(features); + testCancel(features); testClaim(features); - // testDstTag(features); + testDstTag(features); // testDepositAuth(features); - // testMetaAndOwnership(features); + // testRippleState(features); // testGateway(features); - // testAccountDelete(features); - // testUsingTickets(features); + // testRequireAuth(features); + // testFreeze(features); + testAccountDelete(features); + testUsingTickets(features); } public: diff --git a/src/xrpld/app/tx/detail/Subscription.cpp b/src/xrpld/app/tx/detail/Subscription.cpp index 80ee644ed8c..bd254566fc2 100644 --- a/src/xrpld/app/tx/detail/Subscription.cpp +++ b/src/xrpld/app/tx/detail/Subscription.cpp @@ -103,6 +103,10 @@ SetSubscription::preclaim(PreclaimContext const& ctx) return tecNO_DST; } + auto const flags = sleDest->getFlags(); + if ((flags & lsfRequireDestTag) && !ctx.tx[~sfDestinationTag]) + return tecDST_TAG_NEEDED; + // create// Validate Frequency <= 0 if (ctx.tx.getFieldU32(sfFrequency) <= 0) { @@ -194,8 +198,8 @@ SetSubscription::doApply() // create AccountID const dest = ctx_.tx.getAccountID(sfDestination); - Keylet const subKeylet = keylet::subscription( - account, dest, ctx_.tx.getFieldU32(sfSequence)); + Keylet const subKeylet = + keylet::subscription(account, dest, ctx_.tx.getSeqProxy().value()); auto sle = std::make_shared(subKeylet); sle->setAccountID(sfAccount, account); sle->setAccountID(sfDestination, dest); From 6ba7898d4f4083b623849010bc73478311cb5a66 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Sun, 15 Sep 2024 18:38:36 +0200 Subject: [PATCH 5/7] more testing --- src/test/app/Subscription_test.cpp | 148 ++++++++++++++++++----- src/xrpld/app/tx/detail/Subscription.cpp | 78 ++++++++---- 2 files changed, 171 insertions(+), 55 deletions(-) diff --git a/src/test/app/Subscription_test.cpp b/src/test/app/Subscription_test.cpp index 07fb80664e8..8d2778c50ac 100644 --- a/src/test/app/Subscription_test.cpp +++ b/src/test/app/Subscription_test.cpp @@ -130,9 +130,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testSetPreflight(FeatureBitset features) + testSetPreflightInvalid(FeatureBitset features) { - testcase("set preflight"); + testcase("set preflight invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -146,11 +146,30 @@ struct Subscription_test : public beast::unit_test::suite env.fund(XRP(1000), alice, bob, gw); env.close(); - // temINVALID_FLAG: + /* + CREATE + */ + + // temMALFORMED: SetSubscription: SubscriptionID is not present, and + // required fields are also present. { - env(subscription::create(alice, bob, XRP(10), 100s), - txflags(tfSetfAuth), - ter(temINVALID_FLAG)); + Json::Value txn; + txn[jss::TransactionType] = jss::SubscriptionSet; + txn[jss::Account] = alice.human(); + + // no sfDestination + env(txn, ter(temMALFORMED)); + env.close(); + + // no sfAmount + txn[sfDestination.jsonName] = bob.human(); + env(txn, ter(temMALFORMED)); + env.close(); + + // no sfFrequency + txn[sfDestination.jsonName] = bob.human(); + txn[sfAmount.jsonName] = XRP(10).value().getJson(JsonOptions::none); + env(txn, ter(temMALFORMED)); env.close(); } @@ -162,6 +181,50 @@ struct Subscription_test : public beast::unit_test::suite env.close(); } + /* + UPDATE + */ + + // temMALFORMED: SetSubscription: SubscriptionID is present, but + // optional fields are also present. + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + Json::Value txn = subscription::update(alice, subId, XRP(10)); + + // sfDestination + txn[sfDestination.jsonName] = bob.human(); + env(txn, ter(temMALFORMED)); + env.close(); + + // sfFrequency + auto const frequency = 100s; + txn[sfDestination.jsonName] = bob.human(); + txn[sfFrequency.jsonName] = to_string(frequency.count()); + env(txn, ter(temMALFORMED)); + env.close(); + + // sfStartTime + auto const startTime = env.now() + 0s; + txn[sfDestination.jsonName] = bob.human(); + txn[sfFrequency.jsonName] = to_string(frequency.count()); + env(txn, subscription::start_time(startTime), ter(temMALFORMED)); + env.close(); + } + + /* + BOTH + */ + + // temINVALID_FLAG: + { + env(subscription::create(alice, bob, XRP(10), 100s), + txflags(tfSetfAuth), + ter(temINVALID_FLAG)); + env.close(); + } + // temBAD_AMOUNT: SetSubscription: Malformed transaction: bad amount: { env(subscription::create(alice, bob, XRP(-10), 100s), @@ -180,9 +243,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testSetPreclaim(FeatureBitset features) + testSetPreclaimInvalid(FeatureBitset features) { - testcase("set preclaim"); + testcase("set preclaim invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -256,6 +319,7 @@ struct Subscription_test : public beast::unit_test::suite env(subscription::update(alice, subId, XRP(100)), ter(tecNO_ENTRY)); env.close(); } + // tecNO_PERMISSION: SetSubscription: Account is not the owner of the // subscription. { @@ -267,12 +331,25 @@ struct Subscription_test : public beast::unit_test::suite ter(tecNO_PERMISSION)); env.close(); } + + // temBAD_EXPIRATION: SetSubscription: The expiration time is in the + // past. + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(100), 100s)); + env.close(); + + auto const expire = env.now() - 10s; + env(subscription::update(alice, subId, XRP(100), expire), + ter(temBAD_EXPIRATION)); + env.close(); + } } void - testSetDoApply(FeatureBitset features) + testSetDoApplyInvalid(FeatureBitset features) { - testcase("set doApply"); + testcase("set doApply invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -297,9 +374,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testCancelPreflight(FeatureBitset features) + testCancelPreflightInvalid(FeatureBitset features) { - testcase("cancel preflight"); + testcase("cancel preflight invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -325,9 +402,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testCancelPreclaim(FeatureBitset features) + testCancelPreclaimInvalid(FeatureBitset features) { - testcase("cancel preclaim"); + testcase("cancel preclaim invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -351,9 +428,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testCancelDoApply(FeatureBitset features) + testCancelDoApplyInvalid(FeatureBitset features) { - testcase("cancel doApply"); + testcase("cancel doApply invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -371,9 +448,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testClaimPreflight(FeatureBitset features) + testClaimPreflightInvalid(FeatureBitset features) { - testcase("claim preflight"); + testcase("claim preflight invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -390,9 +467,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testClaimPreclaim(FeatureBitset features) + testClaimPreclaimInvalid(FeatureBitset features) { - testcase("claim preclaim"); + testcase("claim preclaim invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -412,9 +489,9 @@ struct Subscription_test : public beast::unit_test::suite } void - testClaimDoApply(FeatureBitset features) + testClaimDoApplyInvalid(FeatureBitset features) { - testcase("claim doApply"); + testcase("claim doApply invalid"); using namespace jtx; using namespace std::literals::chrono_literals; @@ -586,7 +663,7 @@ struct Subscription_test : public beast::unit_test::suite env(subscription::create(alice, bob, XRP(10), 100s)); env.close(); - auto const expire = env.now() - 10s; + auto const expire = env.now() + 10s; env(subscription::update(alice, subId, XRP(10), expire)); env.close(); @@ -889,7 +966,6 @@ struct Subscription_test : public beast::unit_test::suite env.fund(XRP(1000), alice, bob, carol); env.close(); - auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); env(subscription::create(alice, bob, XRP(10), 100s)); env.close(); @@ -992,15 +1068,15 @@ struct Subscription_test : public beast::unit_test::suite testWithFeats(FeatureBitset features) { testEnabled(features); - testSetPreflight(features); - testSetPreclaim(features); - testSetDoApply(features); - testCancelPreflight(features); - testCancelPreclaim(features); - testCancelDoApply(features); - testClaimPreflight(features); - testClaimPreclaim(features); - testClaimDoApply(features); + testSetPreflightInvalid(features); + testSetPreclaimInvalid(features); + testSetDoApplyInvalid(features); + testCancelPreflightInvalid(features); + testCancelPreclaimInvalid(features); + testCancelDoApplyInvalid(features); + testClaimPreflightInvalid(features); + testClaimPreclaimInvalid(features); + testClaimDoApplyInvalid(features); testSet(features); testUpdate(features); testCancel(features); @@ -1013,6 +1089,12 @@ struct Subscription_test : public beast::unit_test::suite // testFreeze(features); testAccountDelete(features); testUsingTickets(features); + + // TODO: Should the create subscription transaction take the first + // payment? + // TODO: Should the next payment be the sfNextPaymentTime + sfFrequency? + // Or should it be the current time + sfFrequency? + // TODO: Is there any limitations on the update to Expire Time? } public: diff --git a/src/xrpld/app/tx/detail/Subscription.cpp b/src/xrpld/app/tx/detail/Subscription.cpp index bd254566fc2..d2ea1e875b0 100644 --- a/src/xrpld/app/tx/detail/Subscription.cpp +++ b/src/xrpld/app/tx/detail/Subscription.cpp @@ -39,18 +39,45 @@ SetSubscription::preflight(PreflightContext const& ctx) if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; - if (ctx.tx.getFlags() & tfUniversalMask) - return temINVALID_FLAG; - - // Validate txn.Account != txn.Destination - if (ctx.tx.getAccountID(sfDestination) == ctx.tx.getAccountID(sfAccount)) + if (ctx.tx.isFieldPresent(sfSubscriptionID)) + { + // update + if (ctx.tx.isFieldPresent(sfDestination) || + ctx.tx.isFieldPresent(sfFrequency) || + ctx.tx.isFieldPresent(sfStartTime)) + { + JLOG(ctx.j.warn()) + << "SetSubscription: Malformed transaction: SubscriptionID " + "is present, but optional fields are also present."; + return temMALFORMED; + } + } + else { - JLOG(ctx.j.warn()) << "SetSubscription: Malformed transaction: Account " - "is the same as the destination."; - return temDST_IS_SRC; + // create + if (!ctx.tx.isFieldPresent(sfDestination) || + !ctx.tx.isFieldPresent(sfAmount) || + !ctx.tx.isFieldPresent(sfFrequency)) + { + JLOG(ctx.j.warn()) + << "SetSubscription: Malformed transaction: SubscriptionID " + "is not present, and required fields are not present."; + return temMALFORMED; + } + + if (ctx.tx.getAccountID(sfDestination) == + ctx.tx.getAccountID(sfAccount)) + { + JLOG(ctx.j.warn()) + << "SetSubscription: Malformed transaction: Account " + "is the same as the destination."; + return temDST_IS_SRC; + } } - // Validate txn.Amount + if (ctx.tx.getFlags() & tfUniversalMask) + return temINVALID_FLAG; + STAmount const amount = ctx.tx.getFieldAmount(sfAmount); if (!isLegalNet(amount) || amount.signum() <= 0) { @@ -91,9 +118,24 @@ SetSubscription::preclaim(PreclaimContext const& ctx) "owner of the subscription."; return tecNO_PERMISSION; } + + if (ctx.tx.isFieldPresent(sfExpiration)) + { + auto const currentTime = + ctx.view.info().parentCloseTime.time_since_epoch().count(); + auto const expiration = ctx.tx.getFieldU32(sfExpiration); + + if (expiration < currentTime) + { + JLOG(ctx.j.warn()) + << "SetSubscription: The expiration time is in the past."; + return temBAD_EXPIRATION; + } + } } else { + // create auto const sleDest = ctx.view.read(keylet::account(ctx.tx.getAccountID(sfDestination))); if (!sleDest) @@ -107,7 +149,6 @@ SetSubscription::preclaim(PreclaimContext const& ctx) if ((flags & lsfRequireDestTag) && !ctx.tx[~sfDestinationTag]) return tecDST_TAG_NEEDED; - // create// Validate Frequency <= 0 if (ctx.tx.getFieldU32(sfFrequency) <= 0) { JLOG(ctx.j.warn()) @@ -117,7 +158,6 @@ SetSubscription::preclaim(PreclaimContext const& ctx) auto const currentTime = ctx.view.info().parentCloseTime.time_since_epoch().count(); - // StartTime is less than the current time. auto startTime = currentTime; auto nextPaymentTime = currentTime; if (ctx.tx.isFieldPresent(sfStartTime)) @@ -136,14 +176,13 @@ SetSubscription::preclaim(PreclaimContext const& ctx) { auto const expiration = ctx.tx.getFieldU32(sfExpiration); - // Expiration is less than the current time. if (expiration < currentTime) { JLOG(ctx.j.warn()) << "SetSubscription: The expiration time is in the past."; return temBAD_EXPIRATION; } - // Expiration is less than the NextPaymentTime. + if (expiration < nextPaymentTime) { JLOG(ctx.j.warn()) << "SetSubscription: The expiration time is " @@ -152,8 +191,6 @@ SetSubscription::preclaim(PreclaimContext const& ctx) } } } - // Validate Trustline Exists - // Validate Initial Payment Amount (Insufficient Funds) return tesSUCCESS; } @@ -183,7 +220,7 @@ SetSubscription::doApply() } else { - // Check reserve and funds availability + // create { auto const balance = STAmount((*sleAccount)[sfBalance]).xrp(); auto const reserve = @@ -196,7 +233,6 @@ SetSubscription::doApply() // return tecUNFUNDED; } - // create AccountID const dest = ctx_.tx.getAccountID(sfDestination); Keylet const subKeylet = keylet::subscription(account, dest, ctx_.tx.getSeqProxy().value()); @@ -213,7 +249,6 @@ SetSubscription::doApply() if (ctx_.tx.isFieldPresent(sfExpiration)) sle->setFieldU32(sfExpiration, ctx_.tx.getFieldU32(sfExpiration)); - // Add subscription to sender's owner directory { auto page = sb.dirInsert( keylet::ownerDir(account), @@ -224,7 +259,6 @@ SetSubscription::doApply() (*sle)[sfOwnerNode] = *page; } - // Add subscription to recipient's owner directory. { auto page = sb.dirInsert( keylet::ownerDir(dest), subKeylet, describeOwnerDir(dest)); @@ -501,9 +535,9 @@ ClaimSubscription::doApply() srcAcct, destAcct, STPathSet{}, - true, // default path - false, // partial payment - true, // owner pays transfer fee + true, + false, + true, OfferCrossing::no, std::nullopt, sleSub->getFieldAmount(sfAmount), From f924b7db70bd73dec240937aa2f51ff7c581323b Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Wed, 25 Sep 2024 15:09:21 +0200 Subject: [PATCH 6/7] fix claim preclaim --- src/test/app/Subscription_test.cpp | 6 +- src/xrpld/app/tx/detail/Subscription.cpp | 248 ++++++++++++++++------- 2 files changed, 179 insertions(+), 75 deletions(-) diff --git a/src/test/app/Subscription_test.cpp b/src/test/app/Subscription_test.cpp index 8d2778c50ac..b9efe49ddf6 100644 --- a/src/test/app/Subscription_test.cpp +++ b/src/test/app/Subscription_test.cpp @@ -525,7 +525,7 @@ struct Subscription_test : public beast::unit_test::suite env.close(); } - // tecUNFUNDED_PAYMENT + // tecPATH_PARTIAL { auto const aliceSeq = env.seq(alice); auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); @@ -534,7 +534,7 @@ struct Subscription_test : public beast::unit_test::suite env.close(); env(subscription::claim(bob, subId, XRP(10000)), - ter(tecUNFUNDED_PAYMENT)); + ter(tecPATH_PARTIAL)); env.close(); } @@ -888,7 +888,7 @@ struct Subscription_test : public beast::unit_test::suite BEAST_EXPECT(env.balance(alice) == preAlice - baseFee - XRP(10)); BEAST_EXPECT(env.balance(bob) == preBob - baseFee + XRP(10)); - env(subscription::claim(bob, subId, XRP(10)), ter(tecNO_TARGET)); + env(subscription::claim(bob, subId, XRP(10)), ter(tecNO_ENTRY)); env.close(); } } diff --git a/src/xrpld/app/tx/detail/Subscription.cpp b/src/xrpld/app/tx/detail/Subscription.cpp index d2ea1e875b0..7c5827c1c5a 100644 --- a/src/xrpld/app/tx/detail/Subscription.cpp +++ b/src/xrpld/app/tx/detail/Subscription.cpp @@ -118,20 +118,6 @@ SetSubscription::preclaim(PreclaimContext const& ctx) "owner of the subscription."; return tecNO_PERMISSION; } - - if (ctx.tx.isFieldPresent(sfExpiration)) - { - auto const currentTime = - ctx.view.info().parentCloseTime.time_since_epoch().count(); - auto const expiration = ctx.tx.getFieldU32(sfExpiration); - - if (expiration < currentTime) - { - JLOG(ctx.j.warn()) - << "SetSubscription: The expiration time is in the past."; - return temBAD_EXPIRATION; - } - } } else { @@ -155,41 +141,6 @@ SetSubscription::preclaim(PreclaimContext const& ctx) << "SetSubscription: The frequency is less than or equal to 0."; return temMALFORMED; } - - auto const currentTime = - ctx.view.info().parentCloseTime.time_since_epoch().count(); - auto startTime = currentTime; - auto nextPaymentTime = currentTime; - if (ctx.tx.isFieldPresent(sfStartTime)) - { - startTime = ctx.tx.getFieldU32(sfStartTime); - nextPaymentTime = startTime; - if (startTime < currentTime) - { - JLOG(ctx.j.warn()) - << "SetSubscription: The start time is in the past."; - return temMALFORMED; - } - } - - if (ctx.tx.isFieldPresent(sfExpiration)) - { - auto const expiration = ctx.tx.getFieldU32(sfExpiration); - - if (expiration < currentTime) - { - JLOG(ctx.j.warn()) - << "SetSubscription: The expiration time is in the past."; - return temBAD_EXPIRATION; - } - - if (expiration < nextPaymentTime) - { - JLOG(ctx.j.warn()) << "SetSubscription: The expiration time is " - "less than the next payment time."; - return temBAD_EXPIRATION; - } - } } return tesSUCCESS; } @@ -214,12 +165,30 @@ SetSubscription::doApply() keylet::subscription(ctx_.tx.getFieldH256(sfSubscriptionID))); sle->setFieldAmount(sfAmount, ctx_.tx.getFieldAmount(sfAmount)); if (ctx_.tx.isFieldPresent(sfExpiration)) + { + auto const currentTime = + sb.info().parentCloseTime.time_since_epoch().count(); + auto const expiration = ctx_.tx.getFieldU32(sfExpiration); + + if (expiration < currentTime) + { + JLOG(ctx_.journal.warn()) + << "SetSubscription: The expiration time is in the past."; + return temBAD_EXPIRATION; + } + sle->setFieldU32(sfExpiration, ctx_.tx.getFieldU32(sfExpiration)); + } sb.update(sle); } else { + auto const currentTime = + sb.info().parentCloseTime.time_since_epoch().count(); + auto startTime = currentTime; + auto nextPaymentTime = currentTime; + // create { auto const balance = STAmount((*sleAccount)[sfBalance]).xrp(); @@ -241,13 +210,38 @@ SetSubscription::doApply() sle->setAccountID(sfDestination, dest); sle->setFieldAmount(sfAmount, ctx_.tx.getFieldAmount(sfAmount)); sle->setFieldU32(sfFrequency, ctx_.tx.getFieldU32(sfFrequency)); - auto nextPaymentTime = - sb.info().parentCloseTime.time_since_epoch().count(); if (ctx_.tx.isFieldPresent(sfStartTime)) - nextPaymentTime = ctx_.tx.getFieldU32(sfStartTime); + { + startTime = ctx_.tx.getFieldU32(sfStartTime); + nextPaymentTime = startTime; + if (startTime < currentTime) + { + JLOG(ctx_.journal.warn()) + << "SetSubscription: The start time is in the past."; + return temMALFORMED; + } + } + sle->setFieldU32(sfNextPaymentTime, nextPaymentTime); if (ctx_.tx.isFieldPresent(sfExpiration)) - sle->setFieldU32(sfExpiration, ctx_.tx.getFieldU32(sfExpiration)); + { + auto const expiration = ctx_.tx.getFieldU32(sfExpiration); + + if (expiration < currentTime) + { + JLOG(ctx_.journal.warn()) + << "SetSubscription: The expiration time is in the past."; + return temBAD_EXPIRATION; + } + + if (expiration < nextPaymentTime) + { + JLOG(ctx_.journal.warn()) << "SetSubscription: The expiration time is " + "less than the next payment time."; + return temBAD_EXPIRATION; + } + sle->setFieldU32(sfExpiration, expiration); + } { auto page = sb.dirInsert( @@ -326,14 +320,14 @@ CancelSubscription::doApply() if (!sb.dirRemove( keylet::ownerDir(srcAcct), ownerPage, sleSub->key(), true)) { - JLOG(j_.fatal()) << "Unable to delete check from source."; + JLOG(j_.fatal()) << "Unable to delete subscription from source."; return tefBAD_LEDGER; } std::uint64_t const destPage{(*sleSub)[sfDestinationNode]}; if (!sb.dirRemove(keylet::ownerDir(dstAcct), destPage, sleSub->key(), true)) { - JLOG(j_.fatal()) << "Unable to delete check from destination."; + JLOG(j_.fatal()) << "Unable to delete subscription from destination."; return tefBAD_LEDGER; } @@ -364,37 +358,147 @@ ClaimSubscription::preflight(PreflightContext const& ctx) TER ClaimSubscription::preclaim(PreclaimContext const& ctx) { - auto sleSub = ctx.view.read( + auto const sleSub = ctx.view.read( keylet::subscription(ctx.tx.getFieldH256(sfSubscriptionID))); if (!sleSub) { JLOG(ctx.j.warn()) << "ClaimSubscription: Subscription does not exist."; - return tecNO_TARGET; + return tecNO_ENTRY; } - AccountID const srcAcct{sleSub->getAccountID(sfAccount)}; - if (!ctx.view.exists(keylet::account(srcAcct))) + // Only claim a subscription with this account as the destination. + AccountID const dstId = sleSub->getAccountID(sfDestination); + if (ctx.tx[sfAccount] != dstId) { - JLOG(ctx.j.warn()) << "ClaimSubscription: Account does not exist."; - return terNO_ACCOUNT; + JLOG(ctx.j.warn()) << "ClaimSubscription: Cashing a subscription with " + "wrong Destination."; + return tecNO_PERMISSION; } - - AccountID const destAcct{sleSub->getAccountID(sfDestination)}; - if (!ctx.view.exists(keylet::account(destAcct))) + AccountID const srcId = sleSub->getAccountID(sfAccount); + if (srcId == dstId) { - JLOG(ctx.j.warn()) << "ClaimSubscription: Account does not exist."; - return terNO_ACCOUNT; + JLOG(ctx.j.error()) << "ClaimSubscription: Malformed transaction: " + "Cashing subscription to self."; + return tecINTERNAL; + } + { + auto const sleSrc = ctx.view.read(keylet::account(srcId)); + auto const sleDst = ctx.view.read(keylet::account(dstId)); + if (!sleSrc || !sleDst) + { + JLOG(ctx.j.warn()) + << "ClaimSubscription: source or destination not in ledger"; + return tecNO_ENTRY; + } + + if ((sleDst->getFlags() & lsfRequireDestTag) && + !sleSub->isFieldPresent(sfDestinationTag)) + { + // The tag is basically account-specific information we don't + // understand, but we can require someone to fill it in. + JLOG(ctx.j.warn()) << "ClaimSubscription: DestinationTag required " + "in subscription."; + return tecDST_TAG_NEEDED; + } } - if (ctx.tx.getFieldAmount(sfAmount) > sleSub->getFieldAmount(sfAmount)) { - JLOG(ctx.j.warn()) << "ClaimSubscription: The transaction amount is " - "greater than the subscription amount."; - return temBAD_AMOUNT; + STAmount const value = ctx.tx.getFieldAmount(sfAmount); + STAmount const sendMax = sleSub->getFieldAmount(sfAmount); + Currency const currency{value.getCurrency()}; + if (currency != sendMax.getCurrency()) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Subscription claim does " + "not match subscription currency."; + return temMALFORMED; + } + AccountID const issuerId{value.getIssuer()}; + if (issuerId != sendMax.getIssuer()) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Subscription claim does " + "not match subscription issuer."; + return temMALFORMED; + } + if (value > sendMax) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Subscription claim for " + "more than subscription sendMax."; + return tecPATH_PARTIAL; + } + + { + STAmount availableFunds{accountFunds( + ctx.view, + sleSub->at(sfAccount), + value, + fhZERO_IF_FROZEN, + ctx.j)}; + + if (value > availableFunds) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Subscription claimed " + "for more than owner's balance."; + return tecPATH_PARTIAL; + } + } + + // An issuer can always accept their own currency. + if (!value.native() && (value.getIssuer() != dstId)) + { + auto const sleTrustLine = + ctx.view.read(keylet::line(dstId, issuerId, currency)); + + auto const sleIssuer = ctx.view.read(keylet::account(issuerId)); + if (!sleIssuer) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Can't receive IOUs " + "from non-existent issuer: " + << to_string(issuerId); + return tecNO_ISSUER; + } + + if (sleIssuer->at(sfFlags) & lsfRequireAuth) + { + if (!sleTrustLine) + { + // We can only create a trust line if the issuer does not + // have requireAuth set. + return tecNO_AUTH; + } + + // Entries have a canonical representation, determined by a + // lexicographical "greater than" comparison employing strict + // weak ordering. Determine which entry we need to access. + bool const canonical_gt(dstId > issuerId); + + bool const isAuthorized( + sleTrustLine->at(sfFlags) & + (canonical_gt ? lsfLowAuth : lsfHighAuth)); + + if (!isAuthorized) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Can't receive " + "IOUs from issuer without auth."; + return tecNO_AUTH; + } + } + + // The trustline from source to issuer does not need to + // be claimed for freezing, since we already verified that the + // source has sufficient non-frozen funds available. + + // However, the trustline from destination to issuer may not + // be frozen. + if (isFrozen(ctx.view, dstId, currency, issuerId)) + { + JLOG(ctx.j.warn()) << "ClaimSubscription: Claiming a " + "subscription to a frozen trustline."; + return tecFROZEN; + } + } } - if (ctx.view.info().parentCloseTime.time_since_epoch().count() < - sleSub->getFieldU32(sfNextPaymentTime)) + if (!hasExpired(ctx.view, sleSub->getFieldU32(sfNextPaymentTime))) { JLOG(ctx.j.warn()) << "ClaimSubscription: The subscription has not " "reached the next payment time."; From 30b85388356f632e24823d0f020ccb1a14c084e7 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Wed, 25 Sep 2024 15:14:52 +0200 Subject: [PATCH 7/7] clang-format --- src/xrpld/app/tx/detail/Subscription.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/xrpld/app/tx/detail/Subscription.cpp b/src/xrpld/app/tx/detail/Subscription.cpp index 7c5827c1c5a..85c491b5873 100644 --- a/src/xrpld/app/tx/detail/Subscription.cpp +++ b/src/xrpld/app/tx/detail/Subscription.cpp @@ -221,7 +221,7 @@ SetSubscription::doApply() return temMALFORMED; } } - + sle->setFieldU32(sfNextPaymentTime, nextPaymentTime); if (ctx_.tx.isFieldPresent(sfExpiration)) { @@ -236,8 +236,9 @@ SetSubscription::doApply() if (expiration < nextPaymentTime) { - JLOG(ctx_.journal.warn()) << "SetSubscription: The expiration time is " - "less than the next payment time."; + JLOG(ctx_.journal.warn()) + << "SetSubscription: The expiration time is " + "less than the next payment time."; return temBAD_EXPIRATION; } sle->setFieldU32(sfExpiration, expiration);