From f3e2670dfa26263526bdac00386f7c78c36a302a Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Sat, 12 Sep 2026 06:35:47 -0400 Subject: [PATCH] featureSubscription --- .cspell.config.yaml | 1 + .../xrpl/ledger/helpers/SubscriptionHelpers.h | 429 ++ include/xrpl/protocol/Indexes.h | 9 + include/xrpl/protocol/LedgerFormats.h | 5 +- include/xrpl/protocol/TxFlags.h | 4 + include/xrpl/protocol/detail/features.macro | 1 + .../xrpl/protocol/detail/ledger_entries.macro | 20 + include/xrpl/protocol/detail/sfields.macro | 4 + .../xrpl/protocol/detail/transactions.macro | 41 + include/xrpl/protocol/jss.h | 2 + .../ledger_entries/Subscription.h | 431 ++ .../transactions/SubscriptionCancel.h | 131 + .../transactions/SubscriptionClaim.h | 157 + .../transactions/SubscriptionSet.h | 355 ++ include/xrpl/tx/invariants/InvariantCheck.h | 2 + .../tx/invariants/SubscriptionInvariant.h | 35 + .../subscription/SubscriptionCancel.h | 44 + .../subscription/SubscriptionClaim.h | 44 + .../subscription/SubscriptionSet.h | 47 + src/libxrpl/protocol/Indexes.cpp | 7 + .../tx/invariants/SubscriptionInvariant.cpp | 66 + .../subscription/SubscriptionCancel.cpp | 91 + .../subscription/SubscriptionClaim.cpp | 303 ++ .../subscription/SubscriptionSet.cpp | 358 ++ src/test/app/Subscription_test.cpp | 4777 +++++++++++++++++ src/test/jtx/impl/subscription.cpp | 88 + src/test/jtx/subscription.h | 66 + .../ledger_entries/SubscriptionTests.cpp | 412 ++ .../transactions/SubscriptionCancelTests.cpp | 146 + .../transactions/SubscriptionClaimTests.cpp | 162 + .../transactions/SubscriptionSetTests.cpp | 300 ++ src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp | 26 + 32 files changed, 8563 insertions(+), 1 deletion(-) create mode 100644 include/xrpl/ledger/helpers/SubscriptionHelpers.h create mode 100644 include/xrpl/protocol_autogen/ledger_entries/Subscription.h create mode 100644 include/xrpl/protocol_autogen/transactions/SubscriptionCancel.h create mode 100644 include/xrpl/protocol_autogen/transactions/SubscriptionClaim.h create mode 100644 include/xrpl/protocol_autogen/transactions/SubscriptionSet.h create mode 100644 include/xrpl/tx/invariants/SubscriptionInvariant.h create mode 100644 include/xrpl/tx/transactors/subscription/SubscriptionCancel.h create mode 100644 include/xrpl/tx/transactors/subscription/SubscriptionClaim.h create mode 100644 include/xrpl/tx/transactors/subscription/SubscriptionSet.h create mode 100644 src/libxrpl/tx/invariants/SubscriptionInvariant.cpp create mode 100644 src/libxrpl/tx/transactors/subscription/SubscriptionCancel.cpp create mode 100644 src/libxrpl/tx/transactors/subscription/SubscriptionClaim.cpp create mode 100644 src/libxrpl/tx/transactors/subscription/SubscriptionSet.cpp 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/tests/libxrpl/protocol_autogen/ledger_entries/SubscriptionTests.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/SubscriptionCancelTests.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/SubscriptionClaimTests.cpp create mode 100644 src/tests/libxrpl/protocol_autogen/transactions/SubscriptionSetTests.cpp diff --git a/.cspell.config.yaml b/.cspell.config.yaml index c1af739255e..98c1c7eb533 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -347,6 +347,7 @@ words: - unflatten - unfund - unimpair + - unmetered - unroutable - unscalable - unserviced diff --git a/include/xrpl/ledger/helpers/SubscriptionHelpers.h b/include/xrpl/ledger/helpers/SubscriptionHelpers.h new file mode 100644 index 00000000000..dd9a7a7eda1 --- /dev/null +++ b/include/xrpl/ledger/helpers/SubscriptionHelpers.h @@ -0,0 +1,429 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +template +TER +canTransferTokenHelper( + ReadView const& view, + AccountID const& account, + AccountID const& dest, + STAmount const& amount, + beast::Journal const& j); + +template <> +inline TER +canTransferTokenHelper( + ReadView const& view, + AccountID const& account, + AccountID const& dest, + STAmount const& amount, + beast::Journal const& j) +{ + AccountID issuer = amount.getIssuer(); + if (issuer == account) + { + JLOG(j.trace()) << "canTransferTokenHelper: Issuer is the same as the account."; + return tesSUCCESS; + } + + // If the issuer does not exist, return tecNO_ISSUER + auto const sleIssuer = view.read(keylet::account(issuer)); + if (!sleIssuer) + { + JLOG(j.trace()) << "canTransferTokenHelper: Issuer does not exist."; + return tecNO_ISSUER; + } + + // If the account does not have a trustline to the issuer, return tecNO_LINE + auto const sleRippleState = + view.read(keylet::trustLine(account, issuer, amount.get().currency)); + if (!sleRippleState) + { + JLOG(j.trace()) << "canTransferTokenHelper: Trust line does not exist."; + return tecNO_LINE; + } + + STAmount const balance = (*sleRippleState)[sfBalance]; + + // If balance is positive, issuer must have higher address than account + if (balance > beast::kZero && issuer < account) + { + JLOG(j.trace()) << "canTransferTokenHelper: Invalid trust line state."; + return tecNO_PERMISSION; + } + + // If balance is negative, issuer must have lower address than account + if (balance < beast::kZero && issuer > account) + { + JLOG(j.trace()) << "canTransferTokenHelper: Invalid trust line state."; + return tecNO_PERMISSION; + } + + // If the issuer has requireAuth set, check if the account is authorized + if (auto const ter = requireAuth(view, amount.get(), account); ter != tesSUCCESS) + { + JLOG(j.trace()) << "canTransferTokenHelper: Account is not authorized"; + return ter; + } + + // If the issuer has requireAuth set, check if the destination is authorized + if (auto const ter = requireAuth(view, amount.get(), dest); ter != tesSUCCESS) + { + JLOG(j.trace()) << "canTransferTokenHelper: Destination is not authorized."; + return ter; + } + + // If the issuer has frozen the account, return tecFROZEN + if (isFrozen(view, account, amount.get()) || + isDeepFrozen(view, account, amount.get().currency, amount.get().account)) + { + JLOG(j.trace()) << "canTransferTokenHelper: Account is frozen."; + return tecFROZEN; + } + + // If the issuer has frozen the destination, return tecFROZEN + if (isFrozen(view, dest, amount.get()) || + isDeepFrozen(view, dest, amount.get().currency, amount.get().account)) + { + JLOG(j.trace()) << "canTransferTokenHelper: Destination is frozen."; + return tecFROZEN; + } + + STAmount const spendableAmount = accountHolds( + view, account, amount.get().currency, issuer, FreezeHandling::IgnoreFreeze, j); + + // If the balance is less than or equal to 0, return + // tecINSUFFICIENT_FUNDS + if (spendableAmount <= beast::kZero) + { + JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less " + "than or equal to 0."; + return tecINSUFFICIENT_FUNDS; + } + + // If the spendable amount is less than the amount, return + // tecINSUFFICIENT_FUNDS + if (spendableAmount < amount) + { + JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less " + "than the amount."; + return tecINSUFFICIENT_FUNDS; + } + + // If the amount is not addable to the balance, return tecPRECISION_LOSS + if (!canAdd(spendableAmount, amount)) + return tecPRECISION_LOSS; + + return tesSUCCESS; +} + +template <> +inline TER +canTransferTokenHelper( + ReadView const& view, + AccountID const& account, + AccountID const& dest, + STAmount const& amount, + beast::Journal const& j) +{ + AccountID issuer = amount.getIssuer(); + if (issuer == account) + { + JLOG(j.trace()) << "canTransferTokenHelper: Issuer is the same as the account."; + return tesSUCCESS; + } + + // If the mpt does not exist, return tecOBJECT_NOT_FOUND + auto const issuanceKey = keylet::mptokenIssuance(amount.get().getMptID()); + auto const sleIssuance = view.read(issuanceKey); + if (!sleIssuance) + { + JLOG(j.trace()) << "canTransferTokenHelper: MPT issuance does not exist."; + return tecOBJECT_NOT_FOUND; + } + + // If the issuer is not the same as the issuer of the mpt, return + // tecNO_PERMISSION + if (sleIssuance->getAccountID(sfIssuer) != issuer) + { + JLOG(j.trace()) << "canTransferTokenHelper: Issuer is not the same as " + "the issuer of the MPT."; + return tecNO_PERMISSION; + } + + // If the account does not have the mpt, return tecOBJECT_NOT_FOUND + if (!view.exists(keylet::mptoken(issuanceKey.key, account))) + { + JLOG(j.trace()) << "canTransferTokenHelper: Account does not have the MPT."; + return tecOBJECT_NOT_FOUND; + } + + // If the issuer has requireAuth set, check if the account is + // authorized + auto const& mptIssue = amount.get(); + if (auto const ter = requireAuth(view, mptIssue, account, AuthType::WeakAuth); + ter != tesSUCCESS) + { + JLOG(j.trace()) << "canTransferTokenHelper: Account is not authorized."; + return ter; + } + + // If the issuer has requireAuth set, check if the destination is + // authorized + if (auto const ter = requireAuth(view, mptIssue, dest, AuthType::WeakAuth); ter != tesSUCCESS) + { + JLOG(j.trace()) << "canTransferTokenHelper: Destination is not authorized."; + return ter; + } + + // If the issuer has locked the account, return tecLOCKED + if (isFrozen(view, account, mptIssue)) + { + JLOG(j.trace()) << "canTransferTokenHelper: Account is locked."; + return tecLOCKED; + } + + // If the issuer has locked the destination, return tecLOCKED + if (isFrozen(view, dest, mptIssue)) + { + JLOG(j.trace()) << "canTransferTokenHelper: Destination is locked."; + return tecLOCKED; + } + + // If the mpt cannot be transferred, return tecNO_AUTH + if (auto const ter = canTransfer(view, mptIssue, account, dest); ter != tesSUCCESS) + { + JLOG(j.trace()) << "canTransferTokenHelper: MPT cannot be transferred."; + return ter; + } + + STAmount const spendableAmount = accountHolds( + view, + account, + amount.get(), + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + j); + + // If the balance is less than or equal to 0, return + // tecINSUFFICIENT_FUNDS + if (spendableAmount <= beast::kZero) + { + JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less " + "than or equal to 0."; + return tecINSUFFICIENT_FUNDS; + } + + // If the spendable amount is less than the amount, return + // tecINSUFFICIENT_FUNDS + if (spendableAmount < amount) + { + JLOG(j.trace()) << "canTransferTokenHelper: Spendable amount is less " + "than the amount."; + return tecINSUFFICIENT_FUNDS; + } + + // If the amount is not addable to the balance, return tecPRECISION_LOSS + if (!canAdd(spendableAmount, amount)) + return tecPRECISION_LOSS; + + return tesSUCCESS; +} + +template +TER +doTransferTokenHelper( + ApplyView& view, + SLE::ref sleDest, + STAmount const& xrpBalance, + STAmount const& amount, + AccountID const& issuer, + AccountID const& sender, + AccountID const& receiver, + bool createAsset, + beast::Journal journal); + +template <> +inline TER +doTransferTokenHelper( + ApplyView& view, + SLE::ref sleDest, + STAmount const& xrpBalance, + STAmount const& amount, + AccountID const& issuer, + AccountID const& sender, + AccountID const& receiver, + bool createAsset, + beast::Journal journal) +{ + Keylet const trustLineKey = keylet::trustLine(receiver, amount.get()); + bool const recvLow = issuer > receiver; + + // Review Note: We could remove this and just say to use batch to auth the + // token first + if (!view.exists(trustLineKey) && createAsset && issuer != receiver) + { + // Can the account cover the trust line's reserve? + if (xrpBalance < accountReserve(view, sleDest, journal, {.ownerCountDelta = 1})) + { + JLOG(journal.trace()) << "doTransferTokenHelper: Trust line does not exist. " + "Insufficent reserve to create line."; + + return tecNO_LINE_INSUF_RESERVE; + } + + Currency const currency = amount.get().currency; + STAmount initialBalance(amount.get()); + initialBalance.get().account = noAccount(); + + // clang-format off + if (TER const ter = trustCreate( + view, // payment sandbox + recvLow, // is dest low? + issuer, // source + receiver, // destination + trustLineKey.key, // ledger index + sleDest, // Account to add to + false, // authorize account + (sleDest->getFlags() & lsfDefaultRipple) == 0, + false, // freeze trust line + false, // deep freeze trust line + initialBalance, // zero initial balance + Issue(currency, receiver), // limit of zero + 0, // quality in + 0, // quality out + SLE::pointer(), // sponsor + journal); // journal + !isTesSuccess(ter)) + { + JLOG(journal.trace()) << "doTransferTokenHelper: Failed to create trust line: " << transToken(ter); + return ter; + } + // clang-format on + + view.update(sleDest); + } + + if (!view.exists(trustLineKey) && issuer != receiver) + return tecNO_LINE; + + auto const ter = + accountSend(view, sender, receiver, amount, journal, SLE::pointer(), WaiveTransferFee::No); + if (ter != tesSUCCESS) + { + JLOG(journal.trace()) << "doTransferTokenHelper: Failed to send token: " << transToken(ter); + return ter; // LCOV_EXCL_LINE + } + + return tesSUCCESS; +} + +template <> +inline TER +doTransferTokenHelper( + ApplyView& view, + SLE::ref sleDest, + STAmount const& xrpBalance, + STAmount const& amount, + AccountID const& issuer, + AccountID const& sender, + AccountID const& receiver, + bool createAsset, + beast::Journal journal) +{ + auto const mptID = amount.get().getMptID(); + auto const issuanceKey = keylet::mptokenIssuance(mptID); + if (!view.exists(keylet::mptoken(issuanceKey.key, receiver)) && createAsset && + issuer != receiver) + { + if (xrpBalance < accountReserve(view, sleDest, journal, {.ownerCountDelta = 1})) + { + JLOG(journal.trace()) << "doTransferTokenHelper: MPT does not exist. " + "Insufficent reserve to create MPT."; + return tecINSUFFICIENT_RESERVE; + } + + if (auto const ter = createMPToken(view, mptID, receiver, SLE::pointer(), 0); + !isTesSuccess(ter)) + { + JLOG(journal.trace()) << "doTransferTokenHelper: Failed to create MPT: " + << transToken(ter); + return ter; + } + + // Update owner count. + increaseOwnerCount(view, sleDest, SLE::pointer(), 1, journal); + } + + if (issuer != receiver && !view.exists(keylet::mptoken(issuanceKey.key, receiver))) + { + JLOG(journal.trace()) << "doTransferTokenHelper: MPT does not exist."; + return tecNO_PERMISSION; + } + + auto const ter = + accountSend(view, sender, receiver, amount, journal, SLE::pointer(), WaiveTransferFee::No); + if (ter != tesSUCCESS) + { + JLOG(journal.trace()) << "doTransferTokenHelper: Failed to send MPT: " << transToken(ter); + return ter; // LCOV_EXCL_LINE + } + + return tesSUCCESS; +} + +// Remove a subscription from both owner directories, release the owner's +// reserve, and erase the object. Shared by SubscriptionCancel and the +// single-use claim path so the two never diverge. +inline TER +deleteSubscription(ApplyView& view, SLE::ref sleSub, beast::Journal journal) +{ + AccountID const account{sleSub->getAccountID(sfAccount)}; + AccountID const dstAcct{sleSub->getAccountID(sfDestination)}; + + std::uint64_t const ownerPage{(*sleSub)[sfOwnerNode]}; + if (!view.dirRemove(keylet::ownerDir(account), ownerPage, sleSub->key(), true)) + { + JLOG(journal.fatal()) << "deleteSubscription: Unable to delete from source."; + return tefBAD_LEDGER; + } + + std::uint64_t const destPage{(*sleSub)[sfDestinationNode]}; + if (!view.dirRemove(keylet::ownerDir(dstAcct), destPage, sleSub->key(), true)) + { + JLOG(journal.fatal()) << "deleteSubscription: Unable to delete from destination."; + return tefBAD_LEDGER; + } + + auto const sleSrc = view.peek(keylet::account(account)); + decreaseOwnerCount(view, sleSrc, SLE::pointer(), 1, journal); + view.erase(sleSub); + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 0836cffaf73..2b4ca64391f 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -386,6 +386,15 @@ permissionedDomain(AccountID const& account, SeqProxy const& seq) noexcept; Keylet permissionedDomain(uint256 const& domainID) noexcept; + +Keylet +subscription(AccountID const& account, AccountID const& dest, std::uint32_t 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 68205e27e62..daad29f846e 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -208,7 +208,10 @@ enum LedgerEntryType : std::uint16_t { \ LEDGER_OBJECT(Sponsorship, \ LSF_FLAG(lsfSponsorshipRequireSignForFee, 0x00010000) \ - LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) + LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) \ + \ + LEDGER_OBJECT(Subscription, \ + LSF_FLAG(lsfSingleUse, 0x00010000)) /* True, delete on first successful claim */ // clang-format on diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 40edf2239ba..01bf9bdb5b5 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -238,6 +238,10 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; TF_FLAG(tfSponsorshipEnd, 0x00010000) \ TF_FLAG(tfSponsorshipCreate, 0x00020000) \ TF_FLAG(tfSponsorshipReassign, 0x00040000), \ + MASK_ADJ(0)) \ + \ + TRANSACTION(SubscriptionSet, /* True, delete the subscription on the first successful claim */ \ + TF_FLAG(tfSingleUse, 0x00010000), \ MASK_ADJ(0)) // clang-format on diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index e63a7f515dc..b05fa908816 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(Subscription, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(SmartEscrow, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocolV1_2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_5_0, Supported::Yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 18c71b572cd..ac5aa946cff 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -634,6 +634,26 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ {sfLoanScale, SoeDefault}, })) +/** A ledger object representing a subscription. + + \sa keylet::subscription + */ +LEDGER_ENTRY(ltSUBSCRIPTION, 0x008A, Subscription, subscription, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfSequence, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfAccount, SoeRequired}, + {sfDestination, SoeRequired}, + {sfDestinationTag, SoeOptional}, + {sfAmount, SoeRequired}, + {sfBalance, SoeRequired}, + {sfFrequency, SoeRequired}, + {sfNextClaimTime, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfDestinationNode, SoeRequired}, +})) + /** A ledger object representing a sponsorship. \sa keylet::sponsorship */ diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2cf35743aea..6eb68288a0c 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -128,6 +128,9 @@ TYPED_SFIELD(sfBytecodeSizeLimit, UINT32, 82) TYPED_SFIELD(sfGasPrice, UINT32, 83) TYPED_SFIELD(sfGas, UINT32, 84) TYPED_SFIELD(sfGasUsed, UINT32, 85) +TYPED_SFIELD(sfFrequency, UINT32, 86) +TYPED_SFIELD(sfStartTime, UINT32, 87) +TYPED_SFIELD(sfNextClaimTime, UINT32, 88) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -220,6 +223,7 @@ TYPED_SFIELD(sfLoanID, UINT256, 38) TYPED_SFIELD(sfReferenceHolding, UINT256, 39) TYPED_SFIELD(sfBlindingFactor, UINT256, 40) TYPED_SFIELD(sfObjectID, UINT256, 41) +TYPED_SFIELD(sfSubscriptionID, UINT256, 42) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 454aa85ffd0..0444f07947e 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1134,6 +1134,47 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCountDelta, SoeOptional}, })) +/** This transaction type creates or updates a subscription. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttSUBSCRIPTION_SET, 92, SubscriptionSet, + ({.delegable = Delegation::Delegable, .amendment = featureSubscription}), + ({ + {sfDestination, SoeOptional}, + {sfAmount, SoeRequired, SoeMptSupported}, + {sfFrequency, SoeOptional}, + {sfStartTime, SoeOptional}, + {sfExpiration, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfSubscriptionID, SoeOptional}, +})) + +/** This transaction type cancels a subscription. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttSUBSCRIPTION_CANCEL, 93, SubscriptionCancel, + ({.delegable = Delegation::Delegable, .amendment = featureSubscription}), + ({ + {sfSubscriptionID, SoeRequired}, +})) + +/** This transaction type claims a payment from a subscription. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttSUBSCRIPTION_CLAIM, 94, SubscriptionClaim, + ({ + .delegable = Delegation::Delegable, + .amendment = featureSubscription, + .privileges = Privilege::MayCreateMpt, + }), + ({ + {sfAmount, SoeRequired, SoeMptSupported}, + {sfSubscriptionID, SoeRequired}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca311..2d32ba9f018 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -47,6 +47,7 @@ JSS(Destination); // in: TransactionSign; field. JSS(EPrice); // in: AMM Deposit option JSS(Fee); // in/out: TransactionSign; field. JSS(Flags); // in/out: TransactionSign; field. +JSS(Frequency); // in: Subscription transactions JSS(Holder); // field. JSS(Invalid); // JSS(Issuer); // in: Credential transactions @@ -81,6 +82,7 @@ JSS(Signer); // field. JSS(Signers); // field. JSS(SigningPubKey); // field. JSS(Subject); // in: Credential transactions +JSS(SubscriptionID); // in: Subscription transactions JSS(TakerGets); // field. JSS(TakerPays); // field. JSS(TradingFee); // in/out: AMM trading fee diff --git a/include/xrpl/protocol_autogen/ledger_entries/Subscription.h b/include/xrpl/protocol_autogen/ledger_entries/Subscription.h new file mode 100644 index 00000000000..62157623a2d --- /dev/null +++ b/include/xrpl/protocol_autogen/ledger_entries/Subscription.h @@ -0,0 +1,431 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::ledger_entries { + +class SubscriptionBuilder; + +/** + * @brief Ledger Entry: Subscription + * + * Type: ltSUBSCRIPTION (0x008A) + * RPC Name: subscription + * + * Immutable wrapper around SLE providing type-safe field access. + * Use SubscriptionBuilder to construct new ledger entries. + */ +class Subscription : public LedgerEntryBase +{ +public: + static constexpr LedgerEntryType entryType = ltSUBSCRIPTION; + + /** + * @brief Construct a Subscription ledger entry wrapper from an existing SLE object. + * @throws std::runtime_error if the ledger entry type doesn't match. + */ + explicit Subscription(SLE::const_pointer sle) + : LedgerEntryBase(std::move(sle)) + { + // Verify ledger entry type + if (sle_->getType() != entryType) + { + throw std::runtime_error("Invalid ledger entry type for Subscription"); + } + } + + // Ledger entry-specific field getters + + /** + * @brief Get sfPreviousTxnID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getPreviousTxnID() const + { + return this->sle_->at(sfPreviousTxnID); + } + + /** + * @brief Get sfPreviousTxnLgrSeq (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getPreviousTxnLgrSeq() const + { + return this->sle_->at(sfPreviousTxnLgrSeq); + } + + /** + * @brief Get sfSequence (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getSequence() const + { + return this->sle_->at(sfSequence); + } + + /** + * @brief Get sfOwnerNode (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getOwnerNode() const + { + return this->sle_->at(sfOwnerNode); + } + + /** + * @brief Get sfAccount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_ACCOUNT::type::value_type + getAccount() const + { + return this->sle_->at(sfAccount); + } + + /** + * @brief Get sfDestination (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_ACCOUNT::type::value_type + getDestination() const + { + return this->sle_->at(sfDestination); + } + + /** + * @brief Get sfDestinationTag (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDestinationTag() const + { + if (hasDestinationTag()) + return this->sle_->at(sfDestinationTag); + return std::nullopt; + } + + /** + * @brief Check if sfDestinationTag is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDestinationTag() const + { + return this->sle_->isFieldPresent(sfDestinationTag); + } + + /** + * @brief Get sfAmount (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_AMOUNT::type::value_type + getAmount() const + { + return this->sle_->at(sfAmount); + } + + /** + * @brief Get sfBalance (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_AMOUNT::type::value_type + getBalance() const + { + return this->sle_->at(sfBalance); + } + + /** + * @brief Get sfFrequency (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getFrequency() const + { + return this->sle_->at(sfFrequency); + } + + /** + * @brief Get sfNextClaimTime (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT32::type::value_type + getNextClaimTime() const + { + return this->sle_->at(sfNextClaimTime); + } + + /** + * @brief Get sfExpiration (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getExpiration() const + { + if (hasExpiration()) + return this->sle_->at(sfExpiration); + return std::nullopt; + } + + /** + * @brief Check if sfExpiration is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasExpiration() const + { + return this->sle_->isFieldPresent(sfExpiration); + } + + /** + * @brief Get sfDestinationNode (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT64::type::value_type + getDestinationNode() const + { + return this->sle_->at(sfDestinationNode); + } +}; + +/** + * @brief Builder for Subscription ledger entries. + * + * Provides a fluent interface for constructing ledger entries with method chaining. + * Uses STObject internally for flexible ledger entry construction. + * Inherits common field setters from LedgerEntryBuilderBase. + */ +class SubscriptionBuilder : public LedgerEntryBuilderBase +{ +public: + /** + * @brief Construct a new SubscriptionBuilder with required fields. + * @param previousTxnID The sfPreviousTxnID field value. + * @param previousTxnLgrSeq The sfPreviousTxnLgrSeq field value. + * @param sequence The sfSequence field value. + * @param ownerNode The sfOwnerNode field value. + * @param account The sfAccount field value. + * @param destination The sfDestination field value. + * @param amount The sfAmount field value. + * @param balance The sfBalance field value. + * @param frequency The sfFrequency field value. + * @param nextClaimTime The sfNextClaimTime field value. + * @param destinationNode The sfDestinationNode field value. + */ + SubscriptionBuilder(std::decay_t const& previousTxnID,std::decay_t const& previousTxnLgrSeq,std::decay_t const& sequence,std::decay_t const& ownerNode,std::decay_t const& account,std::decay_t const& destination,std::decay_t const& amount,std::decay_t const& balance,std::decay_t const& frequency,std::decay_t const& nextClaimTime,std::decay_t const& destinationNode) + : LedgerEntryBuilderBase(ltSUBSCRIPTION) + { + setPreviousTxnID(previousTxnID); + setPreviousTxnLgrSeq(previousTxnLgrSeq); + setSequence(sequence); + setOwnerNode(ownerNode); + setAccount(account); + setDestination(destination); + setAmount(amount); + setBalance(balance); + setFrequency(frequency); + setNextClaimTime(nextClaimTime); + setDestinationNode(destinationNode); + } + + /** + * @brief Construct a SubscriptionBuilder from an existing SLE object. + * @param sle The existing ledger entry to copy from. + * @throws std::runtime_error if the ledger entry type doesn't match. + */ + SubscriptionBuilder(SLE::const_pointer sle) + { + if (sle->at(sfLedgerEntryType) != ltSUBSCRIPTION) + { + throw std::runtime_error("Invalid ledger entry type for Subscription"); + } + object_ = *sle; + } + + /** + * @brief Ledger entry-specific field setters + */ + + /** + * @brief Set sfPreviousTxnID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setPreviousTxnID(std::decay_t const& value) + { + object_[sfPreviousTxnID] = value; + return *this; + } + + /** + * @brief Set sfPreviousTxnLgrSeq (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setPreviousTxnLgrSeq(std::decay_t const& value) + { + object_[sfPreviousTxnLgrSeq] = value; + return *this; + } + + /** + * @brief Set sfSequence (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setSequence(std::decay_t const& value) + { + object_[sfSequence] = value; + return *this; + } + + /** + * @brief Set sfOwnerNode (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setOwnerNode(std::decay_t const& value) + { + object_[sfOwnerNode] = value; + return *this; + } + + /** + * @brief Set sfAccount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setAccount(std::decay_t const& value) + { + object_[sfAccount] = value; + return *this; + } + + /** + * @brief Set sfDestination (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setDestination(std::decay_t const& value) + { + object_[sfDestination] = value; + return *this; + } + + /** + * @brief Set sfDestinationTag (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setDestinationTag(std::decay_t const& value) + { + object_[sfDestinationTag] = value; + return *this; + } + + /** + * @brief Set sfAmount (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setAmount(std::decay_t const& value) + { + object_[sfAmount] = value; + return *this; + } + + /** + * @brief Set sfBalance (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setBalance(std::decay_t const& value) + { + object_[sfBalance] = value; + return *this; + } + + /** + * @brief Set sfFrequency (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setFrequency(std::decay_t const& value) + { + object_[sfFrequency] = value; + return *this; + } + + /** + * @brief Set sfNextClaimTime (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setNextClaimTime(std::decay_t const& value) + { + object_[sfNextClaimTime] = value; + return *this; + } + + /** + * @brief Set sfExpiration (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setExpiration(std::decay_t const& value) + { + object_[sfExpiration] = value; + return *this; + } + + /** + * @brief Set sfDestinationNode (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionBuilder& + setDestinationNode(std::decay_t const& value) + { + object_[sfDestinationNode] = value; + return *this; + } + + /** + * @brief Build and return the completed Subscription wrapper. + * @param index The ledger entry index. + * @return The constructed ledger entry wrapper. + */ + Subscription + build(uint256 const& index) + { + return Subscription{std::make_shared(std::move(object_), index)}; + } +}; + +} // namespace xrpl::ledger_entries diff --git a/include/xrpl/protocol_autogen/transactions/SubscriptionCancel.h b/include/xrpl/protocol_autogen/transactions/SubscriptionCancel.h new file mode 100644 index 00000000000..87e344bd934 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/SubscriptionCancel.h @@ -0,0 +1,131 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class SubscriptionCancelBuilder; + +/** + * @brief Transaction: SubscriptionCancel + * + * Type: ttSUBSCRIPTION_CANCEL (93) + * Delegable: Delegation::Delegable + * Amendment: featureSubscription + * Privileges: Privilege::NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use SubscriptionCancelBuilder to construct new transactions. + */ +class SubscriptionCancel : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttSUBSCRIPTION_CANCEL; + + /** + * @brief Construct a SubscriptionCancel transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit SubscriptionCancel(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for SubscriptionCancel"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfSubscriptionID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getSubscriptionID() const + { + return this->tx_->at(sfSubscriptionID); + } +}; + +/** + * @brief Builder for SubscriptionCancel transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class SubscriptionCancelBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new SubscriptionCancelBuilder with required fields. + * @param account The account initiating the transaction. + * @param subscriptionID The sfSubscriptionID field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + SubscriptionCancelBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& subscriptionID, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttSUBSCRIPTION_CANCEL, account, sequence, fee) + { + setSubscriptionID(subscriptionID); + } + + /** + * @brief Construct a SubscriptionCancelBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + SubscriptionCancelBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttSUBSCRIPTION_CANCEL) + { + throw std::runtime_error("Invalid transaction type for SubscriptionCancelBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfSubscriptionID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionCancelBuilder& + setSubscriptionID(std::decay_t const& value) + { + object_[sfSubscriptionID] = value; + return *this; + } + + /** + * @brief Build and return the SubscriptionCancel wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + SubscriptionCancel + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return SubscriptionCancel{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/SubscriptionClaim.h b/include/xrpl/protocol_autogen/transactions/SubscriptionClaim.h new file mode 100644 index 00000000000..6490c3964a6 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/SubscriptionClaim.h @@ -0,0 +1,157 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class SubscriptionClaimBuilder; + +/** + * @brief Transaction: SubscriptionClaim + * + * Type: ttSUBSCRIPTION_CLAIM (94) + * Delegable: Delegation::Delegable + * Amendment: featureSubscription + * Privileges: Privilege::MayCreateMpt + * + * Immutable wrapper around STTx providing type-safe field access. + * Use SubscriptionClaimBuilder to construct new transactions. + */ +class SubscriptionClaim : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttSUBSCRIPTION_CLAIM; + + /** + * @brief Construct a SubscriptionClaim transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit SubscriptionClaim(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for SubscriptionClaim"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfAmount (SoeRequired) + * @note This field supports MPT (Multi-Purpose Token) amounts. + * @return The field value. + */ + [[nodiscard]] + SF_AMOUNT::type::value_type + getAmount() const + { + return this->tx_->at(sfAmount); + } + + /** + * @brief Get sfSubscriptionID (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getSubscriptionID() const + { + return this->tx_->at(sfSubscriptionID); + } +}; + +/** + * @brief Builder for SubscriptionClaim transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class SubscriptionClaimBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new SubscriptionClaimBuilder with required fields. + * @param account The account initiating the transaction. + * @param amount The sfAmount field value. + * @param subscriptionID The sfSubscriptionID field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + SubscriptionClaimBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& amount, std::decay_t const& subscriptionID, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttSUBSCRIPTION_CLAIM, account, sequence, fee) + { + setAmount(amount); + setSubscriptionID(subscriptionID); + } + + /** + * @brief Construct a SubscriptionClaimBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + SubscriptionClaimBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttSUBSCRIPTION_CLAIM) + { + throw std::runtime_error("Invalid transaction type for SubscriptionClaimBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfAmount (SoeRequired) + * @note This field supports MPT (Multi-Purpose Token) amounts. + * @return Reference to this builder for method chaining. + */ + SubscriptionClaimBuilder& + setAmount(std::decay_t const& value) + { + object_[sfAmount] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionID (SoeRequired) + * @return Reference to this builder for method chaining. + */ + SubscriptionClaimBuilder& + setSubscriptionID(std::decay_t const& value) + { + object_[sfSubscriptionID] = value; + return *this; + } + + /** + * @brief Build and return the SubscriptionClaim wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + SubscriptionClaim + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return SubscriptionClaim{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/protocol_autogen/transactions/SubscriptionSet.h b/include/xrpl/protocol_autogen/transactions/SubscriptionSet.h new file mode 100644 index 00000000000..e538f7ce384 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/SubscriptionSet.h @@ -0,0 +1,355 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class SubscriptionSetBuilder; + +/** + * @brief Transaction: SubscriptionSet + * + * Type: ttSUBSCRIPTION_SET (92) + * Delegable: Delegation::Delegable + * Amendment: featureSubscription + * Privileges: Privilege::NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use SubscriptionSetBuilder to construct new transactions. + */ +class SubscriptionSet : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttSUBSCRIPTION_SET; + + /** + * @brief Construct a SubscriptionSet transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit SubscriptionSet(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for SubscriptionSet"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfDestination (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDestination() const + { + if (hasDestination()) + { + return this->tx_->at(sfDestination); + } + return std::nullopt; + } + + /** + * @brief Check if sfDestination is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDestination() const + { + return this->tx_->isFieldPresent(sfDestination); + } + + /** + * @brief Get sfAmount (SoeRequired) + * @note This field supports MPT (Multi-Purpose Token) amounts. + * @return The field value. + */ + [[nodiscard]] + SF_AMOUNT::type::value_type + getAmount() const + { + return this->tx_->at(sfAmount); + } + + /** + * @brief Get sfFrequency (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getFrequency() const + { + if (hasFrequency()) + { + return this->tx_->at(sfFrequency); + } + return std::nullopt; + } + + /** + * @brief Check if sfFrequency is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasFrequency() const + { + return this->tx_->isFieldPresent(sfFrequency); + } + + /** + * @brief Get sfStartTime (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getStartTime() const + { + if (hasStartTime()) + { + return this->tx_->at(sfStartTime); + } + return std::nullopt; + } + + /** + * @brief Check if sfStartTime is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasStartTime() const + { + return this->tx_->isFieldPresent(sfStartTime); + } + + /** + * @brief Get sfExpiration (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getExpiration() const + { + if (hasExpiration()) + { + return this->tx_->at(sfExpiration); + } + return std::nullopt; + } + + /** + * @brief Check if sfExpiration is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasExpiration() const + { + return this->tx_->isFieldPresent(sfExpiration); + } + + /** + * @brief Get sfDestinationTag (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getDestinationTag() const + { + if (hasDestinationTag()) + { + return this->tx_->at(sfDestinationTag); + } + return std::nullopt; + } + + /** + * @brief Check if sfDestinationTag is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasDestinationTag() const + { + return this->tx_->isFieldPresent(sfDestinationTag); + } + + /** + * @brief Get sfSubscriptionID (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionID() const + { + if (hasSubscriptionID()) + { + return this->tx_->at(sfSubscriptionID); + } + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionID is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionID() const + { + return this->tx_->isFieldPresent(sfSubscriptionID); + } +}; + +/** + * @brief Builder for SubscriptionSet transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class SubscriptionSetBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new SubscriptionSetBuilder with required fields. + * @param account The account initiating the transaction. + * @param amount The sfAmount field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + SubscriptionSetBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& amount, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttSUBSCRIPTION_SET, account, sequence, fee) + { + setAmount(amount); + } + + /** + * @brief Construct a SubscriptionSetBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + SubscriptionSetBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttSUBSCRIPTION_SET) + { + throw std::runtime_error("Invalid transaction type for SubscriptionSetBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfDestination (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionSetBuilder& + setDestination(std::decay_t const& value) + { + object_[sfDestination] = value; + return *this; + } + + /** + * @brief Set sfAmount (SoeRequired) + * @note This field supports MPT (Multi-Purpose Token) amounts. + * @return Reference to this builder for method chaining. + */ + SubscriptionSetBuilder& + setAmount(std::decay_t const& value) + { + object_[sfAmount] = value; + return *this; + } + + /** + * @brief Set sfFrequency (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionSetBuilder& + setFrequency(std::decay_t const& value) + { + object_[sfFrequency] = value; + return *this; + } + + /** + * @brief Set sfStartTime (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionSetBuilder& + setStartTime(std::decay_t const& value) + { + object_[sfStartTime] = value; + return *this; + } + + /** + * @brief Set sfExpiration (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionSetBuilder& + setExpiration(std::decay_t const& value) + { + object_[sfExpiration] = value; + return *this; + } + + /** + * @brief Set sfDestinationTag (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionSetBuilder& + setDestinationTag(std::decay_t const& value) + { + object_[sfDestinationTag] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionID (SoeOptional) + * @return Reference to this builder for method chaining. + */ + SubscriptionSetBuilder& + setSubscriptionID(std::decay_t const& value) + { + object_[sfSubscriptionID] = value; + return *this; + } + + /** + * @brief Build and return the SubscriptionSet wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + SubscriptionSet + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return SubscriptionSet{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index e8dafbd3017..71b5769ae5f 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -457,6 +458,7 @@ using InvariantChecks = std::tuple< ValidLoanBroker, ValidLoan, ValidVault, + ValidSubscription, ValidConfidentialMPToken, ValidMPTBalanceChanges, ValidAmounts, diff --git a/include/xrpl/tx/invariants/SubscriptionInvariant.h b/include/xrpl/tx/invariants/SubscriptionInvariant.h new file mode 100644 index 00000000000..7004d4bded9 --- /dev/null +++ b/include/xrpl/tx/invariants/SubscriptionInvariant.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +/** + * @brief Invariant: a Subscription ledger entry holds a well-formed balance and + * distinct parties. + * + * Enforces XLS-78 2.1.1.7 for every Subscription entry the transaction leaves in + * the ledger: Balance is not negative, Balance and Amount are denominated in the + * same asset, and Account differs from Destination. + */ +class ValidSubscription +{ + std::vector> subscriptions_; + +public: + void + visitEntry(bool, SLE::const_ref, SLE::const_ref); + + bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/subscription/SubscriptionCancel.h b/include/xrpl/tx/transactors/subscription/SubscriptionCancel.h new file mode 100644 index 00000000000..d26de4c6ae4 --- /dev/null +++ b/include/xrpl/tx/transactors/subscription/SubscriptionCancel.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class SubscriptionCancel : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit SubscriptionCancel(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/subscription/SubscriptionClaim.h b/include/xrpl/tx/transactors/subscription/SubscriptionClaim.h new file mode 100644 index 00000000000..97a2dc80292 --- /dev/null +++ b/include/xrpl/tx/transactors/subscription/SubscriptionClaim.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class SubscriptionClaim : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit SubscriptionClaim(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/subscription/SubscriptionSet.h b/include/xrpl/tx/transactors/subscription/SubscriptionSet.h new file mode 100644 index 00000000000..fadeaa4aec4 --- /dev/null +++ b/include/xrpl/tx/transactors/subscription/SubscriptionSet.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class SubscriptionSet : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit SubscriptionSet(ApplyContext& ctx) : Transactor(ctx) + { + } + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 91ed5c893fd..270bad2865a 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -104,6 +104,7 @@ enum class LedgerNameSpace : std::uint16_t { LoanBroker = 'l', // lower-case L Loan = 'L', Sponsorship = '>', + Subscription = 'U', // No longer used or supported. Left here to reserve the space to avoid accidental reuse. Contract [[deprecated]] = 'c', @@ -610,6 +611,12 @@ permissionedDomain(uint256 const& domainID) noexcept return {ltPERMISSIONED_DOMAIN, domainID}; } +Keylet +subscription(AccountID const& account, AccountID const& dest, std::uint32_t seq) noexcept +{ + return {ltSUBSCRIPTION, indexHash(LedgerNameSpace::Subscription, account, dest, seq)}; +} + } // namespace keylet } // namespace xrpl diff --git a/src/libxrpl/tx/invariants/SubscriptionInvariant.cpp b/src/libxrpl/tx/invariants/SubscriptionInvariant.cpp new file mode 100644 index 00000000000..bded327fae5 --- /dev/null +++ b/src/libxrpl/tx/invariants/SubscriptionInvariant.cpp @@ -0,0 +1,66 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +void +ValidSubscription::visitEntry(bool isDelete, SLE::const_ref, SLE::const_ref after) +{ + // A deleted entry imposes no constraint on the resulting ledger. + if (isDelete || !after || after->getType() != ltSUBSCRIPTION) + return; + + subscriptions_.push_back(after); +} + +bool +ValidSubscription::finalize( + STTx const&, + TER const result, + XRPAmount const, + ReadView const&, + beast::Journal const& j) +{ + if (!isTesSuccess(result)) + return true; + + for (auto const& sleSub : subscriptions_) + { + STAmount const balance = sleSub->getFieldAmount(sfBalance); + STAmount const amount = sleSub->getFieldAmount(sfAmount); + + if (balance.signum() < 0) + { + JLOG(j.fatal()) << "Invariant failed: subscription balance is negative"; + return false; + } + + if (balance.asset() != amount.asset()) + { + JLOG(j.fatal()) << "Invariant failed: subscription balance and amount " + "are denominated in different assets"; + return false; + } + + if (sleSub->getAccountID(sfAccount) == sleSub->getAccountID(sfDestination)) + { + JLOG(j.fatal()) << "Invariant failed: subscription account and " + "destination are the same"; + return false; + } + } + + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/subscription/SubscriptionCancel.cpp b/src/libxrpl/tx/transactors/subscription/SubscriptionCancel.cpp new file mode 100644 index 00000000000..2c57983e6b6 --- /dev/null +++ b/src/libxrpl/tx/transactors/subscription/SubscriptionCancel.cpp @@ -0,0 +1,91 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +NotTEC +SubscriptionCancel::preflight(PreflightContext const& ctx) +{ + return tesSUCCESS; +} + +TER +SubscriptionCancel::preclaim(PreclaimContext const& ctx) +{ + auto const sleSub = ctx.view.read(keylet::subscription(ctx.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(ctx.j.debug()) << "SubscriptionCancel: Subscription does not exist."; + return tecNO_ENTRY; + } + + // The owner or the destination may cancel at any time; anyone may cancel + // once the subscription has expired. + if (!hasExpired(ctx.view, (*sleSub)[~sfExpiration])) + { + AccountID const account = ctx.tx.getAccountID(sfAccount); + if (account != sleSub->getAccountID(sfAccount) && + account != sleSub->getAccountID(sfDestination)) + { + JLOG(ctx.j.debug()) << "SubscriptionCancel: Account is not the owner " + "or destination of the subscription."; + return tecNO_PERMISSION; + } + } + + return tesSUCCESS; +} + +TER +SubscriptionCancel::doApply() +{ + Sandbox sb(&ctx_.view()); + + auto const sleSub = sb.peek(keylet::subscription(ctx_.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(ctx_.journal.debug()) << "SubscriptionCancel: Subscription does not exist."; + return tecINTERNAL; + } + + auto viewJ = ctx_.registry.get().getJournal("View"); + if (auto const ter = deleteSubscription(sb, sleSub, viewJ); !isTesSuccess(ter)) + return ter; + + sb.apply(ctx_.rawView()); + return tesSUCCESS; +} + +void +SubscriptionCancel::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +SubscriptionCancel::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + // No transaction-specific invariants yet (future work). + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/subscription/SubscriptionClaim.cpp b/src/libxrpl/tx/transactors/subscription/SubscriptionClaim.cpp new file mode 100644 index 00000000000..6c10964a494 --- /dev/null +++ b/src/libxrpl/tx/transactors/subscription/SubscriptionClaim.cpp @@ -0,0 +1,303 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +NotTEC +SubscriptionClaim::preflight(PreflightContext const& ctx) +{ + return tesSUCCESS; +} + +TER +SubscriptionClaim::preclaim(PreclaimContext const& ctx) +{ + auto const sleSub = ctx.view.read(keylet::subscription(ctx.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: Subscription does not exist."; + return tecNO_ENTRY; + } + + // Only claim a subscription with this account as the destination. + AccountID const dest = sleSub->getAccountID(sfDestination); + if (ctx.tx[sfAccount] != dest) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: Cashing a subscription with " + "wrong Destination."; + return tecNO_PERMISSION; + } + AccountID const account = sleSub->getAccountID(sfAccount); + if (account == dest) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: Malformed transaction: " + "Cashing subscription to self."; + return tecINTERNAL; + } + { + auto const sleSrc = ctx.view.read(keylet::account(account)); + auto const sleDst = ctx.view.read(keylet::account(dest)); + if (!sleSrc || !sleDst) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: source or destination not in ledger"; + return tecNO_ENTRY; + } + } + + { + STAmount const amount = ctx.tx.getFieldAmount(sfAmount); + STAmount const sleAmount = sleSub->getFieldAmount(sfAmount); + if (amount.asset() != sleAmount.asset()) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: Subscription claim does " + "not match subscription currency."; + return tecWRONG_ASSET; + } + + if (amount > sleAmount) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: Claim amount exceeds " + "subscription amount."; + return tecLIMIT_EXCEEDED; + } + + // Time/period context + std::uint32_t const currentTime = + ctx.view.header().parentCloseTime.time_since_epoch().count(); + std::uint32_t const nextClaimTime = sleSub->getFieldU32(sfNextClaimTime); + std::uint32_t const frequency = sleSub->getFieldU32(sfFrequency); + + // Determine effective available balance: + // - If we have crossed into a later period AND the previous period had + // a partial + // balance remaining (carryover not allowed), then the effective + // period rolls forward once and its balance resets to sleAmount. + // - Otherwise we operate on the period at nextClaimTime with its stored + // balance. + STAmount balance = sleSub->getFieldAmount(sfBalance); + bool const arrears = currentTime >= nextClaimTime + frequency; + if (arrears && balance != sleAmount) + { + // We will effectively operate on (nextClaimTime + frequency) with a + // full balance. + balance = sleAmount; + } + + if (amount > balance) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: Claim amount exceeds remaining " + "balance for this period."; + return tecINSUFFICIENT_FUNDS; + } + + if (isXRP(amount)) + { + if (xrpLiquid(ctx.view, account, 0, ctx.j) < amount) + return tecINSUFFICIENT_FUNDS; + } + else + { + if (auto const ret = std::visit( + [&](T const&) { + return canTransferTokenHelper(ctx.view, account, dest, amount, ctx.j); + }, + amount.asset().value()); + !isTesSuccess(ret)) + return ret; + } + } + + // An expired subscription can no longer be claimed; it can only be + // cancelled. + if (hasExpired(ctx.view, (*sleSub)[~sfExpiration])) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: The subscription has expired."; + return tecEXPIRED; + } + + // Must be at or past the start of the effective period. + if (!hasExpired(ctx.view, sleSub->getFieldU32(sfNextClaimTime))) + { + JLOG(ctx.j.trace()) << "SubscriptionClaim: The subscription has not " + "reached the next claim time."; + return tecTOO_SOON; + } + + return tesSUCCESS; +} + +TER +SubscriptionClaim::doApply() +{ + PaymentSandbox psb(&ctx_.view()); + auto viewJ = ctx_.registry.get().getJournal("View"); + + auto sleSub = psb.peek(keylet::subscription(ctx_.tx.getFieldH256(sfSubscriptionID))); + if (!sleSub) + { + JLOG(j_.trace()) << "SubscriptionClaim: Subscription does not exist."; + return tecINTERNAL; + } + + AccountID const account = sleSub->getAccountID(sfAccount); + if (!psb.exists(keylet::account(account))) + { + JLOG(j_.trace()) << "SubscriptionClaim: Account does not exist."; + return tecINTERNAL; + } + + AccountID const dest = sleSub->getAccountID(sfDestination); + if (!psb.exists(keylet::account(dest))) + { + JLOG(j_.trace()) << "SubscriptionClaim: Account does not exist."; + return tecINTERNAL; + } + + if (dest != ctx_.tx.getAccountID(sfAccount)) + { + JLOG(j_.trace()) << "SubscriptionClaim: Account is not the " + "destination of the subscription."; + return tecNO_PERMISSION; + } + + STAmount const sleAmount = sleSub->getFieldAmount(sfAmount); + STAmount const deliverAmount = ctx_.tx.getFieldAmount(sfAmount); + + // Pull current period info + std::uint32_t const currentTime = psb.header().parentCloseTime.time_since_epoch().count(); + std::uint32_t nextClaimTime = sleSub->getFieldU32(sfNextClaimTime); + std::uint32_t const frequency = sleSub->getFieldU32(sfFrequency); + + STAmount availableBalance = sleSub->getFieldAmount(sfBalance); + bool const arrears = currentTime >= nextClaimTime + frequency; + + // If we crossed into a later period and the previous period was partially + // used, forfeit the leftover and roll forward exactly one period; reset the + // balance. + if (arrears && availableBalance != sleAmount) + { + nextClaimTime += frequency; + availableBalance = sleAmount; + + // Reflect the rollover immediately in the SLE so subsequent logic is + // consistent. + sleSub->setFieldU32(sfNextClaimTime, nextClaimTime); + sleSub->setFieldAmount(sfBalance, availableBalance); + } + + // Enforce available balance for the effective period. + if (deliverAmount > availableBalance) + { + JLOG(j_.trace()) << "SubscriptionClaim: Claim amount exceeds remaining " + << "balance for this period."; + return tecINTERNAL; + } + + // Perform the transfer + if (isXRP(deliverAmount)) + { + if (TER const ter{transferXRP(psb, account, dest, deliverAmount, viewJ)}; ter != tesSUCCESS) + { + return ter; + } + } + else + { + if (auto const ret = std::visit( + [&](T const&) { + return doTransferTokenHelper( + psb, + psb.peek(keylet::account(dest)), + preFeeBalance_, + deliverAmount, + deliverAmount.getIssuer(), + account, + dest, + true, // create asset + viewJ); + }, + deliverAmount.asset().value()); + !isTesSuccess(ret)) + return ret; + } + + // Metered accounting: advance/reset the period. Unmetered subscriptions + // (Frequency == 0) cap each claim at Amount and never touch Balance or + // NextClaimTime. + if (frequency != 0) + { + STAmount const newBalance = availableBalance - deliverAmount; + if (newBalance == sleAmount.zeroed()) + { + // Full period claimed: advance exactly one period and reset next + // period balance. + nextClaimTime += frequency; + sleSub->setFieldU32(sfNextClaimTime, nextClaimTime); + sleSub->setFieldAmount(sfBalance, sleAmount); + } + else + { + // Partial claim within the same effective period. + sleSub->setFieldAmount(sfBalance, newBalance); + // Do not advance nextClaimTime; if we had a rollover-forfeit above, + // we already moved nextClaimTime forward exactly once. + } + } + + // Single-use subscriptions are removed on the first successful claim, + // regardless of Frequency or whether the claim was partial. + if (sleSub->isFlag(lsfSingleUse)) + { + if (auto const ter = deleteSubscription(psb, sleSub, viewJ); !isTesSuccess(ter)) + return ter; + } + else + { + psb.update(sleSub); + } + + psb.apply(ctx_.rawView()); + return tesSUCCESS; +} + +void +SubscriptionClaim::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +SubscriptionClaim::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + // No transaction-specific invariants yet (future work). + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/subscription/SubscriptionSet.cpp b/src/libxrpl/tx/transactors/subscription/SubscriptionSet.cpp new file mode 100644 index 00000000000..9d37d0c7ae6 --- /dev/null +++ b/src/libxrpl/tx/transactors/subscription/SubscriptionSet.cpp @@ -0,0 +1,358 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl { + +template +static NotTEC +setPreflightHelper(PreflightContext const& ctx); + +template <> +NotTEC +setPreflightHelper(PreflightContext const& ctx) +{ + STAmount const amount = ctx.tx[sfAmount]; + if (amount.native() || amount <= beast::kZero) + return temBAD_AMOUNT; + + if (badCurrency() == amount.get().currency) + return temBAD_CURRENCY; + + return tesSUCCESS; +} + +template <> +NotTEC +setPreflightHelper(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureMPTokensV1)) + return temDISABLED; + + auto const amount = ctx.tx[sfAmount]; + if (amount.native() || amount.mpt() > MPTAmount{kMaxMpTokenAmount} || amount <= beast::kZero) + return temBAD_AMOUNT; + + return tesSUCCESS; +} + +std::uint32_t +SubscriptionSet::getFlagsMask(PreflightContext const& ctx) +{ + return tfSubscriptionSetMask; +} + +NotTEC +SubscriptionSet::preflight(PreflightContext const& ctx) +{ + if (ctx.tx.isFieldPresent(sfSubscriptionID)) + { + // update + if (!ctx.tx.isFieldPresent(sfAmount)) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: Malformed transaction: SubscriptionID " + "is present, but Amount is not."; + return temMALFORMED; + } + + if (ctx.tx.isFieldPresent(sfDestination) || ctx.tx.isFieldPresent(sfStartTime)) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: Malformed transaction: SubscriptionID " + "is present, but immutable fields are also present."; + return temMALFORMED; + } + + // lsfSingleUse is fixed at creation and cannot be changed on update. + if (ctx.tx.getFlags() & tfSingleUse) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: tfSingleUse cannot be set on update."; + return temINVALID_FLAG; + } + } + else + { + // create + if (!ctx.tx.isFieldPresent(sfDestination) || !ctx.tx.isFieldPresent(sfAmount) || + !ctx.tx.isFieldPresent(sfFrequency)) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: 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.trace()) << "SubscriptionSet: Malformed transaction: Account " + "is the same as the destination."; + return temDST_IS_SRC; + } + } + + STAmount const amount = ctx.tx.getFieldAmount(sfAmount); + if (amount.native()) + { + if (!isLegalNet(amount) || amount <= beast::kZero) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: Malformed transaction: bad amount: " + << amount.getFullText(); + return temBAD_AMOUNT; + } + } + else + { + if (auto const ret = std::visit( + [&](T const&) { return setPreflightHelper(ctx); }, + amount.asset().value()); + !isTesSuccess(ret)) + return ret; + } + + return tesSUCCESS; +} + +TER +SubscriptionSet::preclaim(PreclaimContext const& ctx) +{ + STAmount const amount = ctx.tx.getFieldAmount(sfAmount); + AccountID const account = ctx.tx.getAccountID(sfAccount); + AccountID dest = ctx.tx.getAccountID(sfDestination); + if (ctx.tx.isFieldPresent(sfSubscriptionID)) + { + // update + auto sle = ctx.view.read(keylet::subscription(ctx.tx.getFieldH256(sfSubscriptionID))); + if (!sle) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: Subscription does not exist."; + return tecNO_ENTRY; + } + + if (sle->getAccountID(sfAccount) != ctx.tx.getAccountID(sfAccount)) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: Account is not the " + "owner of the subscription."; + return tecNO_PERMISSION; + } + + if (amount.asset() != sle->getFieldAmount(sfAmount).asset()) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: Amount asset does not " + "match the subscription asset."; + return tecWRONG_ASSET; + } + + dest = sle->getAccountID(sfDestination); + } + else + { + // create + auto const sleDest = ctx.view.read(keylet::account(ctx.tx.getAccountID(sfDestination))); + if (!sleDest) + { + JLOG(ctx.j.trace()) << "SubscriptionSet: Destination account does not exist."; + return tecNO_DST; + } + + auto const flags = sleDest->getFlags(); + if ((flags & lsfRequireDestTag) && !ctx.tx[~sfDestinationTag]) + return tecDST_TAG_NEEDED; + + // Frequency == 0 denotes an unmetered subscription: no period + // accounting, each claim capped at Amount. + } + + if (!isXRP(amount)) + { + if (auto const ret = std::visit( + [&](T const&) { + return canTransferTokenHelper(ctx.view, account, dest, amount, ctx.j); + }, + amount.asset().value()); + !isTesSuccess(ret)) + return ret; + } + return tesSUCCESS; +} + +TER +SubscriptionSet::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.trace()) << "SubscriptionSet: Account does not exist."; + return tecINTERNAL; + } + + if (ctx_.tx.isFieldPresent(sfSubscriptionID)) + { + // update + auto const currentTime = sb.header().parentCloseTime.time_since_epoch().count(); + auto sle = sb.peek(keylet::subscription(ctx_.tx.getFieldH256(sfSubscriptionID))); + sle->setFieldAmount(sfAmount, ctx_.tx.getFieldAmount(sfAmount)); + + // Changing Frequency starts a clean period: reset the anchor to now and + // restore the full balance. This covers metered<->unmetered and + // metered->metered transitions uniformly. + if (ctx_.tx.isFieldPresent(sfFrequency)) + { + sle->setFieldU32(sfFrequency, ctx_.tx.getFieldU32(sfFrequency)); + sle->setFieldU32(sfNextClaimTime, currentTime); + sle->setFieldAmount(sfBalance, ctx_.tx.getFieldAmount(sfAmount)); + } + + if (ctx_.tx.isFieldPresent(sfExpiration)) + { + auto const expiration = ctx_.tx.getFieldU32(sfExpiration); + + // Expiration == 0 removes any existing expiration. + if (expiration == 0) + { + if (sle->isFieldPresent(sfExpiration)) + sle->makeFieldAbsent(sfExpiration); + } + else if (expiration < currentTime) + { + JLOG(ctx_.journal.trace()) + << "SubscriptionSet: The expiration time is in the past."; + return tecEXPIRED; + } + else + { + sle->setFieldU32(sfExpiration, expiration); + } + } + + sb.update(sle); + } + else + { + auto const currentTime = sb.header().parentCloseTime.time_since_epoch().count(); + auto startTime = currentTime; + auto nextClaimTime = currentTime; + + // create + { + auto const balance = STAmount((*sleAccount)[sfBalance]).xrp(); + auto const reserve = + accountReserve(sb, sleAccount, ctx_.journal, {.ownerCountDelta = 1}); + if (balance < reserve) + return tecINSUFFICIENT_RESERVE; + } + + AccountID const dest = ctx_.tx.getAccountID(sfDestination); + 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); + sle->setFieldU32(sfSequence, ctx_.tx.getSeqProxy().value()); + if (ctx_.tx.getFlags() & tfSingleUse) + sle->setFlag(lsfSingleUse); + if (ctx_.tx.isFieldPresent(sfDestinationTag)) + sle->setFieldU32(sfDestinationTag, ctx_.tx.getFieldU32(sfDestinationTag)); + sle->setFieldAmount(sfAmount, ctx_.tx.getFieldAmount(sfAmount)); + sle->setFieldAmount(sfBalance, ctx_.tx.getFieldAmount(sfAmount)); + sle->setFieldU32(sfFrequency, ctx_.tx.getFieldU32(sfFrequency)); + if (ctx_.tx.isFieldPresent(sfStartTime)) + { + startTime = ctx_.tx.getFieldU32(sfStartTime); + nextClaimTime = startTime; + if (startTime < currentTime) + { + JLOG(ctx_.journal.trace()) << "SubscriptionSet: The start time is in the past."; + return tecNO_PERMISSION; + } + } + + sle->setFieldU32(sfNextClaimTime, nextClaimTime); + if (ctx_.tx.isFieldPresent(sfExpiration)) + { + auto const expiration = ctx_.tx.getFieldU32(sfExpiration); + + if (expiration < currentTime) + { + JLOG(ctx_.journal.trace()) + << "SubscriptionSet: The expiration time is in the past."; + return tecEXPIRED; + } + + if (expiration < nextClaimTime) + { + JLOG(ctx_.journal.trace()) << "SubscriptionSet: The expiration time is " + "less than the next claim time."; + return tecEXPIRED; + } + sle->setFieldU32(sfExpiration, expiration); + } + + { + auto page = + sb.dirInsert(keylet::ownerDir(account), subKeylet, describeOwnerDir(account)); + if (!page) + return tecDIR_FULL; + (*sle)[sfOwnerNode] = *page; + } + + { + auto page = sb.dirInsert(keylet::ownerDir(dest), subKeylet, describeOwnerDir(dest)); + if (!page) + return tecDIR_FULL; + (*sle)[sfDestinationNode] = *page; + } + + increaseOwnerCount(sb, sleAccount, SLE::pointer(), 1, ctx_.journal); + sb.insert(sle); + } + sb.apply(ctx_.rawView()); + return tesSUCCESS; +} + +void +SubscriptionSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +SubscriptionSet::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + // No transaction-specific invariants yet (future work). + return true; +} + +} // namespace xrpl diff --git a/src/test/app/Subscription_test.cpp b/src/test/app/Subscription_test.cpp new file mode 100644 index 00000000000..fab2fe60e05 --- /dev/null +++ b/src/test/app/Subscription_test.cpp @@ -0,0 +1,4777 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::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) + { + 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) + { + 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); + } + + jtx::PrettyAmount + issuerBalance(jtx::Env& env, jtx::Account const& account, Issue const& issue) + { + json::Value params; + params[jss::account] = account.human(); + auto jrr = env.rpc("json", "gateway_balances", to_string(params)); + auto const result = jrr[jss::result]; + auto const obligations = result[jss::obligations][to_string(issue.currency)]; + if (obligations.isNull()) + return {STAmount(issue, 0), account.name()}; + STAmount const amount = amountFromString(issue, obligations.asString()); + return {amount, account.name()}; + } + + std::uint32_t + getNextPaymentTime(ReadView const& view, uint256 const& subId) + { + auto const [_, sleSub] = subKeyAndSle(view, subId); + return sleSub->getFieldU32(sfNextClaimTime); + } + + void + validateSubscription( + jtx::Env& env, + uint256 const& subId, + STAmount const& amount, + STAmount const& balance, + std::uint32_t const& frequency, + std::uint32_t const& nextClaimTime) + { + auto const [id, sle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT(sle); + BEAST_EXPECT(sle->getFieldAmount(sfAmount) == amount); + BEAST_EXPECT(sle->getFieldAmount(sfBalance) == balance); + BEAST_EXPECT(sle->getFieldU32(sfFrequency) == frequency); + BEAST_EXPECT(sle->getFieldU32(sfNextClaimTime) == nextClaimTime); + } + + 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 + testSetPreflightInvalid(FeatureBitset features) + { + testcase("set preflight invalid"); + 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(); + + /* + CREATE + */ + + // temINVALID_FLAG + { + env(subscription::create(alice, bob, XRP(10), 100s), + Txflags(0x00020000), + Ter(temINVALID_FLAG)); + env.close(); + } + + // temBAD_FEE: Exercises invalid preflight1 + { + env(subscription::create(alice, bob, XRP(10), 100s), Fee(XRP(-1)), Ter(temBAD_FEE)); + env.close(); + } + + // temMALFORMED: no sfDestination + { + json::Value txn; + txn[jss::TransactionType] = jss::SubscriptionSet; + txn[jss::Account] = alice.human(); + txn[sfAmount.jsonName] = XRP(10).value().getJson(JsonOptions::Values::None); + NetClock::duration const frequency = 100s; + txn[sfFrequency.jsonName] = frequency.count(); + env(txn, Ter(temMALFORMED)); + env.close(); + } + + // temMALFORMED: no sfAmount + { + json::Value txn; + txn[jss::TransactionType] = jss::SubscriptionSet; + txn[jss::Account] = alice.human(); + txn[sfDestination.jsonName] = bob.human(); + NetClock::duration const frequency = 100s; + txn[sfFrequency.jsonName] = frequency.count(); + env(txn, Ter(temMALFORMED)); + env.close(); + } + + // temMALFORMED: no sfFrequency + { + json::Value txn; + txn[jss::TransactionType] = jss::SubscriptionSet; + txn[jss::Account] = alice.human(); + txn[sfDestination.jsonName] = bob.human(); + txn[sfAmount.jsonName] = XRP(10).value().getJson(JsonOptions::Values::None); + env(txn, Ter(temMALFORMED)); + env.close(); + } + + // temDST_IS_SRC + { + env(subscription::create(alice, alice, XRP(10), 100s), Ter(temDST_IS_SRC)); + env.close(); + } + + /* + UPDATE + */ + + // temMALFORMED: sfDestination present with sfSubscriptionID + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + json::Value txn = subscription::update(alice, subId, XRP(10)); + txn[sfDestination.jsonName] = bob.human(); + env(txn, Ter(temMALFORMED)); + env.close(); + } + + // temMALFORMED: sfStartTime present with sfSubscriptionID + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + json::Value txn = subscription::update(alice, subId, XRP(10)); + auto const startTime = env.now() + 0s; + txn[sfStartTime.jsonName] = to_string(startTime.time_since_epoch().count()); + env(txn, Ter(temMALFORMED)); + env.close(); + } + + /* + BOTH CREATE AND UPDATE + */ + + //---------------------------------------------------------------------- + // XRP + + // temBAD_AMOUNT: negative XRP + { + env(subscription::create(alice, bob, XRP(-10), 100s), Ter(temBAD_AMOUNT)); + env.close(); + } + + // temBAD_AMOUNT: zero XRP + { + env(subscription::create(alice, bob, XRP(0), 100s), Ter(temBAD_AMOUNT)); + env.close(); + } + } + + void + testSetPreclaimInvalid(FeatureBitset features) + { + testcase("set preclaim invalid"); + 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(); + } + + // tecNO_PERMISSION: start time in the past + { + auto const start = env.now() - 10s; + env(subscription::create(alice, bob, XRP(10), 100s), + subscription::StartTime(start), + Ter(tecNO_PERMISSION)); + env.close(); + } + + // tecEXPIRED: expiration in the past + { + auto const expire = env.now() - 10s; + env(subscription::create(alice, bob, XRP(10), 100s, expire), Ter(tecEXPIRED)); + env.close(); + } + + // tecEXPIRED: expiration before start time + { + auto const start = env.now() + 100s; + auto const expire = env.now() + 50s; + env(subscription::create(alice, bob, XRP(10), 100s, expire), + subscription::StartTime(start), + Ter(tecEXPIRED)); + env.close(); + } + + // tecDST_TAG_NEEDED + { + env(fset(bob, asfRequireDest)); + env.close(); + + env(subscription::create(alice, bob, XRP(10), 100s), Ter(tecDST_TAG_NEEDED)); + env.close(); + + // clear flag for other tests + env(fclear(bob, asfRequireDest)); + env.close(); + } + + /* + UPDATE + */ + + // tecNO_ENTRY: subscription doesn't exist + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::update(alice, subId, XRP(100)), Ter(tecNO_ENTRY)); + env.close(); + } + + // tecNO_PERMISSION: non-owner tries to update + { + 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(); + } + + // tecEXPIRED: update with past expiration + { + 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(tecEXPIRED)); + env.close(); + } + } + + void + testSetDoApplyInvalid(FeatureBitset features) + { + testcase("set doApply invalid"); + 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}; + + /* + CREATE + */ + + // tecINSUFFICIENT_RESERVE + { + auto const reserve = env.current()->fees().accountReserve(0, 1); + auto const incReserve = env.current()->fees().increment; + + env.fund(reserve + incReserve - XRP(1), alice); + env.fund(XRP(1000), bob); + env.close(); + + env(subscription::create(alice, bob, XRP(10), 100s), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + } + } + + void + testCancelPreflightInvalid(FeatureBitset features) + { + testcase("cancel preflight invalid"); + 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 + testCancelPreclaimInvalid(FeatureBitset features) + { + testcase("cancel preclaim invalid"); + 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(); + } + BEAST_EXPECT(1 == 1); + } + + void + testClaimPreflightInvalid(FeatureBitset features) + { + testcase("claim preflight invalid"); + 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::claim(bob, subId, XRP(10)), + Txflags(tfSetfAuth), + Ter(temINVALID_FLAG)); + env.close(); + } + } + + void + testClaimPreclaimInvalid(FeatureBitset features) + { + testcase("claim preclaim invalid"); + 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_ENTRY: subscription doesn't exist + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecNO_ENTRY)); + env.close(); + } + + // tecNO_PERMISSION: wrong destination + { + auto const carol = Account("carol"); + env.fund(XRP(1000), carol); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::claim(carol, subId, XRP(1)), Ter(tecNO_PERMISSION)); + env.close(); + } + + // tecWRONG_ASSET: wrong currency/asset + { + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + env.fund(XRP(1000), gw); + env.close(); + env.trust(USD(10000), alice, bob); + env.close(); + env(pay(gw, alice, USD(1000))); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // Try to claim with wrong currency + env(subscription::claim(bob, subId, USD(1)), Ter(tecWRONG_ASSET)); + env.close(); + } + + // tecLIMIT_EXCEEDED: claim more than subscription amount + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, XRP(11)), Ter(tecLIMIT_EXCEEDED)); + env.close(); + } + + // tecUNFUNDED: insufficient subscription balance + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, XRP(1))); + env.close(); + + env(subscription::claim(bob, subId, XRP(11)), Ter(tecLIMIT_EXCEEDED)); + env.close(); + } + + // tecTOO_SOON: subscription hasn't reached next payment time + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + auto const startTime = env.now() + 1000s; + env(subscription::create(alice, bob, XRP(10), 100s), + subscription::StartTime(startTime)); + env.close(); + + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + } + } + + void + testClaimDoApplyInvalid(FeatureBitset features) + { + testcase("claim doApply invalid"); + 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_PERMISSION: account claims own subscription + { + 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(); + } + + // tecINSUFFICIENT_FUNDS: XRP + { + auto const aliceSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, aliceSeq); + + env(subscription::create(alice, bob, XRP(1000), 100s)); + env.close(); + + env(subscription::claim(bob, subId, XRP(1000)), Ter(tecINSUFFICIENT_FUNDS)); + 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(sfNextClaimTime) == 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::StartTime(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(sfNextClaimTime) == 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 + 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) + { + testcase("claim"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // First Claim Partial & Second Claim Full + { + 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); + + auto const frequency = 100s; + auto const startTime = env.now().time_since_epoch().count(); + env(subscription::create(alice, bob, XRP(10), frequency)); + env.close(); + + validateSubscription(env, subId, XRP(10), XRP(10), frequency.count(), startTime); + + auto preAlice = env.balance(alice); + auto preBob = env.balance(bob); + + // First Partial claim + env(subscription::claim(bob, subId, XRP(5))); + env.close(); + + validateSubscription(env, subId, XRP(10), XRP(5), frequency.count(), startTime); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(5)); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base + XRP(5)); + + preAlice = env.balance(alice); + preBob = env.balance(bob); + + // Claim too soon, do not have sufficient funds + env(subscription::claim(bob, subId, XRP(10)), Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + validateSubscription(env, subId, XRP(10), XRP(5), frequency.count(), startTime); + BEAST_EXPECT( + env.now().time_since_epoch().count() < + getNextPaymentTime(*env.current(), subId) + frequency.count()); + + // Advance time + env.close(60s); + BEAST_EXPECT( + env.now().time_since_epoch().count() == + getNextPaymentTime(*env.current(), subId) + frequency.count()); + + // Can claim full amount + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + validateSubscription( + env, + subId, + XRP(10), + XRP(10), + frequency.count(), + startTime + (frequency.count() * 2)); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - (env.current()->fees().base * 2) + XRP(10)); + + // Cannot claim again yet + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + } + + // First Claim Full & Second Claim Full + { + 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); + + auto const frequency = 100s; + auto const startTime = env.now().time_since_epoch().count(); + env(subscription::create(alice, bob, XRP(10), frequency)); + env.close(); + + validateSubscription(env, subId, XRP(10), XRP(10), frequency.count(), startTime); + + auto preAlice = env.balance(alice); + auto preBob = env.balance(bob); + + // First Partial claim + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + validateSubscription( + env, subId, XRP(10), XRP(10), frequency.count(), startTime + frequency.count()); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base + XRP(10)); + + preAlice = env.balance(alice); + preBob = env.balance(bob); + + // Cannot claim full amount yet + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + validateSubscription( + env, subId, XRP(10), XRP(10), frequency.count(), startTime + frequency.count()); + BEAST_EXPECT( + env.now().time_since_epoch().count() < getNextPaymentTime(*env.current(), subId)); + + // Advance time + env.close(60s); + BEAST_EXPECT( + env.now().time_since_epoch().count() == getNextPaymentTime(*env.current(), subId)); + + // Can claim full amount + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + validateSubscription( + env, + subId, + XRP(10), + XRP(10), + frequency.count(), + startTime + (frequency.count() * 2)); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - (env.current()->fees().base * 2) + XRP(10)); + + // Cannot claim again yet + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + } + + // Test Arrears + { + 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); + + auto const frequency = 100s; + auto const startTime = env.now().time_since_epoch().count(); + env(subscription::create(alice, bob, XRP(10), frequency)); + env.close(); + + validateSubscription(env, subId, XRP(10), XRP(10), frequency.count(), startTime); + + auto preAlice = env.balance(alice); + auto preBob = env.balance(bob); + + // Advance time 3x + env.close(frequency); + env.close(frequency); + env.close(frequency); + BEAST_EXPECT( + env.now().time_since_epoch().count() > + getNextPaymentTime(*env.current(), subId) + frequency.count() * 3); + + for (int i = 0; i < 4; ++i) + { + // Can claim full amount + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + validateSubscription( + env, + subId, + XRP(10), + XRP(10), + frequency.count(), + startTime + (frequency.count() * (i + 1))); + } + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + validateSubscription( + env, + subId, + XRP(10), + XRP(10), + frequency.count(), + startTime + (frequency.count() * 4)); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(40)); + BEAST_EXPECT(env.balance(bob) == preBob - (env.current()->fees().base * 5) + XRP(40)); + } + } + + 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)); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + BEAST_EXPECT(subSle->isFieldPresent(sfDestinationTag)); + BEAST_EXPECT(subSle->getFieldU32(sfDestinationTag) == 1); + } + } + + void + testMetaAndOwnership(FeatureBitset features) + { + testcase("meta and ownership"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + 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(); + + // Create subscription + { + 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 sub = env.le(keylet::subscription(subId)); + BEAST_EXPECT(sub); + + // Check owner directories + Dir aliceDir(*env.current(), keylet::ownerDir(alice.id())); + BEAST_EXPECT(std::distance(aliceDir.begin(), aliceDir.end()) == 1); + BEAST_EXPECT(std::find(aliceDir.begin(), aliceDir.end(), sub) != aliceDir.end()); + + Dir bobDir(*env.current(), keylet::ownerDir(bob.id())); + BEAST_EXPECT(std::distance(bobDir.begin(), bobDir.end()) == 1); + BEAST_EXPECT(std::find(bobDir.begin(), bobDir.end(), sub) != bobDir.end()); + + // Cancel subscription + env(subscription::cancel(alice, subId)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::subscription(subId))); + + Dir aliceDir2(*env.current(), keylet::ownerDir(alice.id())); + BEAST_EXPECT(std::distance(aliceDir2.begin(), aliceDir2.end()) == 0); + + Dir bobDir2(*env.current(), keylet::ownerDir(bob.id())); + BEAST_EXPECT(std::distance(bobDir2.begin(), bobDir2.end()) == 0); + } + + // Multiple subscriptions + { + auto const seq1 = env.seq(alice); + auto const subId1 = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + auto const seq2 = env.seq(alice); + auto const subId2 = getSubscriptionIndex(alice, carol, seq2); + env(subscription::create(alice, carol, XRP(20), 200s)); + env.close(); + + auto const seq3 = env.seq(bob); + auto const subId3 = getSubscriptionIndex(bob, carol, seq3); + env(subscription::create(bob, carol, XRP(30), 300s)); + env.close(); + + // Check owner counts + Dir aliceDir(*env.current(), keylet::ownerDir(alice.id())); + BEAST_EXPECT(std::distance(aliceDir.begin(), aliceDir.end()) == 2); + + Dir bobDir(*env.current(), keylet::ownerDir(bob.id())); + BEAST_EXPECT(std::distance(bobDir.begin(), bobDir.end()) == 2); + + Dir carolDir(*env.current(), keylet::ownerDir(carol.id())); + BEAST_EXPECT(std::distance(carolDir.begin(), carolDir.end()) == 2); + + // Clean up + env(subscription::cancel(alice, subId1)); + env(subscription::cancel(alice, subId2)); + env(subscription::cancel(bob, subId3)); + env.close(); + } + } + + 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(); + + 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; + + // Create / 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); + } + + // Create / 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 + testExpiredSubscription(FeatureBitset features) + { + testcase("expired subscription"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + 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); + + auto const expire = env.now() + 200s; + env(subscription::create(alice, bob, XRP(10), 100s, expire)); + env.close(); + + // First payment before expiration + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + // Advance time past expiration + env.close(200s); + + // Claims after expiration fail; the object remains on the ledger + env(subscription::claim(bob, subId, XRP(10)), Ter(tecEXPIRED)); + env.close(); + + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + + // Anyone may cancel an expired subscription; the owner reserve is + // released and both directory entries are removed + auto const carol = Account("carol"); + env.fund(XRP(1000), carol); + env.close(); + + auto const preOwnerCount = ownerCount(env, alice); + env(subscription::cancel(carol, subId)); + env.close(); + + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerCount(env, alice) == preOwnerCount - 1); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 0); + + // Further claims should fail + env(subscription::claim(bob, subId, XRP(10)), Ter(tecNO_ENTRY)); + env.close(); + } + + void + testTimingBoundaries(FeatureBitset features) + { + testcase("timing boundaries"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + // StartTime in the future: a claim one ledger before NextClaimTime + // fails with tecTOO_SOON; a claim in the ledger whose parent close + // time is exactly NextClaimTime succeeds. + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + auto const frequency = 100s; + auto const start = env.now() + 100s; + env(subscription::create(alice, bob, XRP(10), frequency), + subscription::StartTime(start)); + env.close(); + + // Well before the start time + BEAST_EXPECT(env.now() < start); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + // One ledger before the boundary + for (; env.now() < start - 10s; env.close()) + { + } + BEAST_EXPECT(env.now() == start - 10s); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + // Exactly at the boundary: parentCloseTime == NextClaimTime + BEAST_EXPECT(env.now() == start); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + validateSubscription( + env, + subId, + XRP(10), + XRP(10), + frequency.count(), + (start + frequency).time_since_epoch().count()); + } + + // A claim in the ledger whose parent close time is exactly Expiration + // fails with tecEXPIRED; one ledger earlier it still succeeds. + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + auto const expire = env.now() + 200s; + env(subscription::create(alice, bob, XRP(10), 100s, expire)); + env.close(); + + // One ledger before expiration the claim succeeds + for (; env.now() < expire - 10s; env.close()) + { + } + BEAST_EXPECT(env.now() == expire - 10s); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + // parentCloseTime == Expiration: expiry uses >=, so the claim is + // rejected exactly at the boundary and the object remains + BEAST_EXPECT(env.now() == expire); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + } + + // Create with Expiration == current close time is allowed: + // SubscriptionSet rejects only an expiration strictly less than + // parentCloseTime, so the boundary value creates an already-expired + // subscription. + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + auto const expire = env.now(); + env(subscription::create(alice, bob, XRP(10), 100s, expire)); + env.close(); + + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + + // ... and it can never be claimed + env(subscription::claim(bob, subId, XRP(10)), Ter(tecEXPIRED)); + env.close(); + } + + // StartTime in the past is rejected + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + env(subscription::create(alice, bob, XRP(10), 100s), + subscription::StartTime(env.now() - 10s), + Ter(tecNO_PERMISSION)); + env.close(); + } + } + + void + testConsequences(FeatureBitset features) + { + testcase("consequences"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + env.memoize(alice); + env.memoize(bob); + + uint256 const subId = getSubscriptionIndex(alice, bob, 1); + + // None of the subscription transactors define makeTxConsequences, so + // all three report the default consequences: fee only, no potential + // spend. + { + auto const jtx = + env.jt(subscription::create(alice, bob, XRP(1000), 100s), Seq(1), Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jtx.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0)); + } + + { + auto const jtx = + env.jt(subscription::claim(bob, subId, XRP(1000)), Seq(1), Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jtx.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0)); + } + + { + auto const jtx = env.jt(subscription::cancel(alice, subId), Seq(1), Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jtx.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0)); + } + } + + void + testMultipleSubscriptionsSamePair(FeatureBitset features) + { + testcase("multiple subscriptions same pair"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const start1 = env.now().time_since_epoch().count(); + auto const sub1 = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + auto const start2 = env.now().time_since_epoch().count(); + auto const sub2 = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(20), 200s)); + env.close(); + + auto const start3 = env.now().time_since_epoch().count(); + auto const sub3 = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(30), 300s)); + env.close(); + + BEAST_EXPECT(sub1 != sub2 && sub2 != sub3 && sub1 != sub3); + BEAST_EXPECT(subscriptionExists(*env.current(), sub1)); + BEAST_EXPECT(subscriptionExists(*env.current(), sub2)); + BEAST_EXPECT(subscriptionExists(*env.current(), sub3)); + + // Only the owner carries the reserve; the destination just holds + // directory entries + BEAST_EXPECT(ownerCount(env, alice) == 3); + BEAST_EXPECT(ownerCount(env, bob) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == 3); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 3); + + // Independent claims: claiming one leaves the others untouched + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + env(subscription::claim(bob, sub2, XRP(20))); + env.close(); + + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(20)); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base + XRP(20)); + validateSubscription(env, sub1, XRP(10), XRP(10), 100, start1); + validateSubscription(env, sub2, XRP(20), XRP(20), 200, start2 + 200); + validateSubscription(env, sub3, XRP(30), XRP(30), 300, start3); + + // Independent cancels + env(subscription::cancel(alice, sub1)); + env.close(); + BEAST_EXPECT(!subscriptionExists(*env.current(), sub1)); + BEAST_EXPECT(subscriptionExists(*env.current(), sub2)); + BEAST_EXPECT(subscriptionExists(*env.current(), sub3)); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + env(subscription::cancel(bob, sub3)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), sub2)); + BEAST_EXPECT(!subscriptionExists(*env.current(), sub3)); + BEAST_EXPECT(ownerCount(env, alice) == 1); + + env(subscription::cancel(alice, sub2)); + env.close(); + BEAST_EXPECT(!subscriptionExists(*env.current(), sub2)); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 0); + } + + void + testRegularKey(FeatureBitset features) + { + testcase("regular key"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const alie = Account("alie"); + auto const bobby = Account("bobby"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + env(regkey(alice, alie)); + env(regkey(bob, bobby)); + env(fset(alice, asfDisableMaster), Sig(alice)); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), Sig(alie)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + env(subscription::claim(bob, subId, XRP(10)), Sig(bobby)); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base + XRP(10)); + + env(subscription::cancel(alice, subId), Sig(alie)); + env.close(); + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + + void + testMultisign(FeatureBitset features) + { + testcase("multisign"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + auto const daria = Account("daria"); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + env.fund(XRP(1000), alice, bob, carol, daria); + env.close(); + + env(signers(alice, 1, {{carol, 1}})); + env(signers(bob, 1, {{daria, 1}})); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), Msig(carol), Fee(2 * baseFee)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + env(subscription::claim(bob, subId, XRP(10)), Msig(daria), Fee(2 * baseFee)); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - (baseFee * 2) + XRP(10)); + + env(subscription::cancel(alice, subId), Msig(carol), Fee(2 * baseFee)); + env.close(); + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + + void + testDelegation(FeatureBitset features) + { + testcase("delegation"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + auto const dave = Account("dave"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob, carol, dave); + env.close(); + + // All three subscription transactions are delegable + env(delegate::set(alice, dave, {"SubscriptionSet", "SubscriptionCancel"})); + env(delegate::set(bob, dave, {"SubscriptionClaim"})); + env.close(); + + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), delegate::As(dave)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + + env(subscription::claim(bob, subId, XRP(1)), delegate::As(dave)); + env.close(); + + env(subscription::cancel(alice, subId), delegate::As(dave)); + env.close(); + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + + // A delegate without the subscription permissions is rejected + env(delegate::set(alice, carol, {"Payment"})); + env(delegate::set(bob, carol, {"Payment"})); + env.close(); + + // A missing tx-type permission is reported with the retry code + // terNO_DELEGATE_PERMISSION (no fee, no sequence consumed), matching + // every other delegable transaction + { + env(subscription::create(alice, bob, XRP(10), 100s), + delegate::As(carol), + Ter(terNO_DELEGATE_PERMISSION)); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, XRP(1)), + delegate::As(carol), + Ter(terNO_DELEGATE_PERMISSION)); + env.close(); + + env(subscription::cancel(alice, subId), + delegate::As(carol), + Ter(terNO_DELEGATE_PERMISSION)); + env.close(); + + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + env(subscription::cancel(alice, subId)); + env.close(); + } + } + + void + testAccountObjectsRPC(FeatureBitset features) + { + testcase("account_objects RPC"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // The "subscription" type filter returns the object for the owner + // and for the destination + auto checkAccountObjects = [&](Account const& acct) { + json::Value params; + params[jss::account] = acct.human(); + params[jss::type] = jss::subscription; + auto const resp = env.rpc("json", "account_objects", to_string(params)); + auto const& objects = resp[jss::result][jss::account_objects]; + if (!BEAST_EXPECT(objects.isArray() && objects.size() == 1)) + return; + BEAST_EXPECT(objects[0u][sfLedgerEntryType.jsonName] == jss::Subscription); + BEAST_EXPECT(objects[0u][jss::index] == to_string(subId)); + BEAST_EXPECT(objects[0u][sfAccount.jsonName] == alice.human()); + BEAST_EXPECT(objects[0u][sfDestination.jsonName] == bob.human()); + }; + + checkAccountObjects(alice); + checkAccountObjects(bob); + } + + void + testLedgerEntryRPC(FeatureBitset features) + { + testcase("ledger_entry RPC"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const createSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, createSeq); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // By hex index + { + json::Value params; + params[jss::subscription] = to_string(subId); + auto const jrr = env.rpc("json", "ledger_entry", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::index] == to_string(subId)); + BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::Subscription); + BEAST_EXPECT(jrr[jss::node][sfAccount.jsonName] == alice.human()); + BEAST_EXPECT(jrr[jss::node][sfDestination.jsonName] == bob.human()); + BEAST_EXPECT(jrr[jss::node][sfSequence.jsonName].asUInt() == createSeq); + } + + // By {account, destination, seq} object + { + json::Value params; + params[jss::subscription][jss::account] = alice.human(); + params[jss::subscription][jss::destination] = bob.human(); + params[jss::subscription][jss::seq] = createSeq; + auto const jrr = env.rpc("json", "ledger_entry", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::index] == to_string(subId)); + BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::Subscription); + } + + auto checkError = [&](json::Value const& params, std::string const& err) { + auto const jrr = env.rpc("json", "ledger_entry", to_string(params))[jss::result]; + BEAST_EXPECTS(jrr[jss::error] == err, jrr.toStyledString()); + }; + + // Missing account: a missing field always reports malformedRequest; + // the malformedAccount code is used for present-but-invalid values + { + json::Value params; + params[jss::subscription][jss::destination] = bob.human(); + params[jss::subscription][jss::seq] = createSeq; + checkError(params, "malformedRequest"); + } + + // Bad account + { + json::Value params; + params[jss::subscription][jss::account] = "not_an_account"; + params[jss::subscription][jss::destination] = bob.human(); + params[jss::subscription][jss::seq] = createSeq; + checkError(params, "malformedAccount"); + } + + // Bad destination + { + json::Value params; + params[jss::subscription][jss::account] = alice.human(); + params[jss::subscription][jss::destination] = "not_an_account"; + params[jss::subscription][jss::seq] = createSeq; + checkError(params, "malformedDestination"); + } + + // Missing seq + { + json::Value params; + params[jss::subscription][jss::account] = alice.human(); + params[jss::subscription][jss::destination] = bob.human(); + checkError(params, "malformedRequest"); + } + } + + void + testDepositAuthDestination(FeatureBitset features) + { + testcase("deposit auth destination"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // The destination signs the claim itself, so its own DepositAuth + // flag does not block the delivery + env(fset(bob, asfDepositAuth)); + env.close(); + + auto preAlice = env.balance(alice); + auto preBob = env.balance(bob); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base + XRP(10)); + + // An owner with DepositAuth set can still be claimed from + env(fset(alice, asfDepositAuth)); + env.close(100s); + + preAlice = env.balance(alice); + preBob = env.balance(bob); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base + XRP(10)); + } + + void + testExploitThirdPartyCancel(FeatureBitset features) + { + testcase("exploit: third party cancel"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + 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(); + + // A third party cannot cancel an unexpired subscription; the owner + // can + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::cancel(carol, subId), Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + + env(subscription::cancel(alice, subId)); + env.close(); + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + + // ... and so can the destination + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::cancel(carol, subId), Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + + env(subscription::cancel(bob, subId)); + env.close(); + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + } + } + + void + testExploitExpiredDrain(FeatureBitset features) + { + testcase("exploit: expired drain"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + 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)); + auto const expire = env.now() + 400s; + env(subscription::create(alice, bob, XRP(10), 100s, expire)); + env.close(); + + // Let three full periods accrue unclaimed, then let the subscription + // expire + for (; env.now() < expire; env.close()) + { + } + BEAST_EXPECT(env.now() == expire); + + // The accrued arrears cannot be drained once expired + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(env.balance(alice) == preAlice); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base); + + // Any third party may reap the expired object; the owner reserve is + // released and both directory entries are removed + auto const preAliceOwners = ownerCount(env, alice); + env(subscription::cancel(carol, subId)); + env.close(); + + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerCount(env, alice) == preAliceOwners - 1); + BEAST_EXPECT(ownerCount(env, bob) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 0); + + // Nothing further to claim + env(subscription::claim(bob, subId, XRP(10)), Ter(tecNO_ENTRY)); + env.close(); + } + + void + testExploitAssetSwitchUpdate(FeatureBitset features) + { + testcase("exploit: asset switch update"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const gw2 = Account{"gateway2"}; + auto const USD = gw["USD"]; + auto const EUR = gw["EUR"]; + auto const USD2 = gw2["USD"]; + + // IOU and XRP subscriptions + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob, gw); + env.close(); + env.trust(USD(10000), alice, bob); + env.close(); + env(pay(gw, alice, USD(1000))); + env.close(); + + auto const subUSD = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, USD(10), 1000s)); + env.close(); + + // Different currency + env(subscription::update(alice, subUSD, EUR(10)), Ter(tecWRONG_ASSET)); + env.close(); + // Different issuer, same currency code + env(subscription::update(alice, subUSD, USD2(10)), Ter(tecWRONG_ASSET)); + env.close(); + // IOU -> XRP + env(subscription::update(alice, subUSD, XRP(10)), Ter(tecWRONG_ASSET)); + env.close(); + + auto const subXRP = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 1000s)); + env.close(); + + // XRP -> IOU + env(subscription::update(alice, subXRP, USD(10)), Ter(tecWRONG_ASSET)); + env.close(); + + // Same-asset update with a new value succeeds; the stored + // Balance is NOT clamped to the new Amount, but any claim is + // still capped at the new Amount + env(subscription::claim(bob, subUSD, USD(4))); + env.close(); + + env(subscription::update(alice, subUSD, USD(5))); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subUSD); + if (BEAST_EXPECT(subSle)) + { + BEAST_EXPECT(subSle->getFieldAmount(sfAmount) == USD(5)); + // Balance still holds the pre-update remainder of the period + BEAST_EXPECT(subSle->getFieldAmount(sfBalance) == USD(6)); + } + + env(subscription::claim(bob, subUSD, USD(6)), Ter(tecLIMIT_EXCEEDED)); + env.close(); + + env(subscription::claim(bob, subUSD, USD(5))); + env.close(); + auto const [key2, subSle2] = subKeyAndSle(*env.current(), subUSD); + if (BEAST_EXPECT(subSle2)) + BEAST_EXPECT(subSle2->getFieldAmount(sfBalance) == USD(1)); + } + + // MPT subscription cannot be switched to an IOU + { + Env env{*this, features}; + auto const gwM = Account("gw"); + env.fund(XRP(5000), bob); + env.close(); + + MPTTester mptGw(env, gwM, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gwM, alice, MPT(10000))); + env.close(); + + auto const subMPT = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, MPT(100), 1000s)); + env.close(); + + env(subscription::update(alice, subMPT, USD(10)), Ter(tecWRONG_ASSET)); + env.close(); + } + } + + void + testExploitClaimOverdraw(FeatureBitset features) + { + testcase("exploit: claim overdraw"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + // Use a frequency far larger than the test's ledger time so the next + // period cannot start during the test + auto const frequency = 10000s; + + // Fully drain the period, then try to claim the same period again: + // the full claim advanced NextClaimTime a full period and no time + // has passed, so any further claim is too soon + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), frequency)); + env.close(); + + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + env(subscription::claim(bob, subId, XRP(1)), Ter(tecTOO_SOON)); + env.close(); + } + + // Partial claim, then a claim exceeding the remainder of the period + { + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), frequency)); + env.close(); + + env(subscription::claim(bob, subId, XRP(4))); + env.close(); + + env(subscription::claim(bob, subId, XRP(7)), Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // Claim above the per-period Amount + env(subscription::claim(bob, subId, XRP(11)), Ter(tecLIMIT_EXCEEDED)); + env.close(); + } + } + + void + testExploitBoundaryStraddle(FeatureBitset features) + { + testcase("exploit: boundary straddle"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const start = env.now(); + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // Claim the full amount in the very last ledger of the first period + for (; env.now() < start + 90s; env.close()) + { + } + BEAST_EXPECT(env.now() == start + 90s); + auto const preAlice = env.balance(alice); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + // Documented tumbling-window property: the full claim advanced + // NextClaimTime to the period boundary, which the very next ledger + // reaches, so two full-Amount claims succeed back-to-back + BEAST_EXPECT(env.now() == start + 100s); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(20)); + validateSubscription( + env, subId, XRP(10), XRP(10), 100, (start + 200s).time_since_epoch().count()); + } + + void + testExploitArrearsExactness(FeatureBitset features) + { + testcase("exploit: arrears exactness"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const start = env.now(); + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // Advance into the third period without claiming: two periods fully + // missed plus the in-progress period make exactly three claimable + // full claims + for (; env.now() < start + 250s; env.close()) + { + } + BEAST_EXPECT(env.now() == start + 250s); + + auto const preAlice = env.balance(alice); + for (int i = 0; i < 3; ++i) + { + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + } + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(30)); + + // The fourth claim needs the next period boundary + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + validateSubscription( + env, subId, XRP(10), XRP(10), 100, (start + 300s).time_since_epoch().count()); + } + + void + testExploitOwnerClaim(FeatureBitset features) + { + testcase("exploit: owner claim"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + 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 start = env.now().time_since_epoch().count(); + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // The owner cannot claim its own subscription + env(subscription::claim(alice, subId, XRP(1)), Ter(tecNO_PERMISSION)); + env.close(); + + // Neither can an unrelated account + env(subscription::claim(carol, subId, XRP(1)), Ter(tecNO_PERMISSION)); + env.close(); + + // The subscription is untouched + validateSubscription(env, subId, XRP(10), XRP(10), 100, start); + } + + void + testUpdateRequireAuth(FeatureBitset features) + { + testcase("update requireauth"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + auto const aliceUSD = alice["USD"]; + auto const bobUSD = bob["USD"]; + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob, gw); + env(fset(gw, asfRequireAuth)); + env.close(); + + env(trust(gw, aliceUSD(10000)), Txflags(tfSetfAuth)); + env(trust(alice, USD(10000))); + env(trust(gw, bobUSD(10000)), Txflags(tfSetfAuth)); + env(trust(bob, USD(10000))); + env.close(); + env(pay(gw, alice, USD(1000))); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, USD(100), 100s)); + env.close(); + + // Regression: the update path takes the destination from the + // subscription object, so the RequireAuth checks pass for an + // authorized pair + env(subscription::update(alice, subId, USD(200))); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + BEAST_EXPECT(subSle->getFieldAmount(sfAmount) == USD(200)); + + env(subscription::claim(bob, subId, USD(100))); + env.close(); + BEAST_EXPECT(env.balance(bob, USD) == USD(100)); + } + + void + testSequenceField(FeatureBitset features) + { + testcase("sequence field"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto lookupBySeq = [&](std::uint32_t seq) { + json::Value params; + params[jss::subscription][jss::account] = alice.human(); + params[jss::subscription][jss::destination] = bob.human(); + params[jss::subscription][jss::seq] = seq; + return env.rpc("json", "ledger_entry", to_string(params))[jss::result]; + }; + + // Sequence-created subscription records the consumed sequence + { + auto const createSeq = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, createSeq); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + BEAST_EXPECT(subSle->getFieldU32(sfSequence) == createSeq); + + auto const jrr = lookupBySeq(createSeq); + BEAST_EXPECT(jrr[jss::index] == to_string(subId)); + BEAST_EXPECT(jrr[jss::node][sfSequence.jsonName].asUInt() == createSeq); + } + + // Ticket-created subscription records the consumed ticket sequence + { + std::uint32_t const ticketSeq{env.seq(alice) + 1}; + env(ticket::create(alice, 1)); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, ticketSeq); + env(subscription::create(alice, bob, XRP(10), 100s), ticket::Use(ticketSeq)); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + BEAST_EXPECT(subSle->getFieldU32(sfSequence) == ticketSeq); + + auto const jrr = lookupBySeq(ticketSeq); + BEAST_EXPECT(jrr[jss::index] == to_string(subId)); + BEAST_EXPECT(jrr[jss::node][sfSequence.jsonName].asUInt() == ticketSeq); + } + } + + void + testReserveEdge(FeatureBitset features) + { + testcase("reserve edge"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + // Create at the owner-reserve boundary + { + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + auto const reserve = env.current()->fees().accountReserve(1, 1); + + env.fund(XRP(1000), bob); + // One drop below: after the fee alice cannot cover the reserve + // for the new owner entry + env.fund(reserve + baseFee - drops(1), alice); + env.close(); + + env(subscription::create(alice, bob, XRP(1), 100s), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + + // Top alice back up to exactly reserve + fee: creation succeeds + // with a post-fee balance exactly at the reserve + env(pay(env.master, alice, drops(baseFee.drops() + 1))); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(1), 100s)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(env.balance(alice) == drops(reserve.drops())); + } + + // IOU claim where the destination cannot cover the reserve for the + // auto-created trust line + { + Env env{*this, features}; + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + auto const carol = Account("carol"); + auto const reserve = env.current()->fees().accountReserve(0, 1); + auto const incReserve = env.current()->fees().increment; + + env.fund(XRP(1000), alice, gw); + env.fund(reserve + incReserve - drops(1), carol); + env.close(); + env.trust(USD(10000), alice); + env.close(); + env(pay(gw, alice, USD(1000))); + env.close(); + + auto const subId = getSubscriptionIndex(alice, carol, env.seq(alice)); + env(subscription::create(alice, carol, USD(10), 100s)); + env.close(); + + env(subscription::claim(carol, subId, USD(10)), Ter(tecNO_LINE_INSUF_RESERVE)); + env.close(); + } + + // MPT claim where the destination cannot cover the reserve for the + // new MPToken + { + Env env{*this, features}; + auto const gw = Account("gw"); + auto const reserve = env.current()->fees().accountReserve(0, 1); + auto const incReserve = env.current()->fees().increment; + + env.fund(reserve + incReserve - drops(1), bob); + env.close(); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, MPT(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, MPT(10)), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + } + } + + void + testUnmeteredMode(FeatureBitset features) + { + testcase("unmetered mode"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + // Frequency == 0 is valid and denotes an unmetered subscription: + // Balance == Amount, NextClaimTime == create/close time, no period. + { + auto const startTime = env.now().time_since_epoch().count(); + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 0s)); + env.close(); + + validateSubscription(env, subId, XRP(10), XRP(10), 0, startTime); + } + + // A post-dated StartTime still gates the first claim with tecTOO_SOON. + { + auto const start = env.now() + 200s; + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 0s), subscription::StartTime(start)); + env.close(); + + validateSubscription(env, subId, XRP(10), XRP(10), 0, start.time_since_epoch().count()); + + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + for (; env.now() < start; env.close()) + { + } + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + // Balance and NextClaimTime are untouched by the claim. + validateSubscription(env, subId, XRP(10), XRP(10), 0, start.time_since_epoch().count()); + } + + // Expiration still gates an unmetered subscription with tecEXPIRED. + { + auto const expire = env.now() + 100s; + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 0s, expire)); + env.close(); + + env.close(100s); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecEXPIRED)); + env.close(); + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + } + } + + void + testUnmeteredClaims(FeatureBitset features) + { + testcase("unmetered claims"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const startTime = env.now().time_since_epoch().count(); + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 0s)); + env.close(); + + // A claim exceeding Amount is rejected. + env(subscription::claim(bob, subId, XRP(11)), Ter(tecLIMIT_EXCEEDED)); + env.close(); + + // Unlimited claims, each capped at Amount. Partial claims do NOT + // reduce a running balance: the next claim is still capped at the full + // Amount, and Balance/NextClaimTime never change. + auto const baseFee = env.current()->fees().base; + + for (auto const& amt : {XRP(3), XRP(10), XRP(1), XRP(10)}) + { + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + env(subscription::claim(bob, subId, amt)); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - amt); + BEAST_EXPECT(env.balance(bob) == preBob - baseFee + amt); + // Balance stays == Amount across every partial and full claim. + validateSubscription(env, subId, XRP(10), XRP(10), 0, startTime); + } + } + + void + testSingleUse(FeatureBitset features) + { + testcase("single use"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + // Metered single-use: the first (full) claim deletes the object with + // full cleanup. + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), + Txflags(tfSingleUse | tfFullyCanonicalSig)); + env.close(); + + // lsfSingleUse is recorded on the object. + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (!BEAST_EXPECT(subSle)) + return; + BEAST_EXPECT(subSle->getFieldU32(sfFlags) & lsfSingleUse); + + auto const preAliceOwners = ownerCount(env, alice); + auto const preAlice = env.balance(alice); + auto const preBob = env.balance(bob); + + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + // Gone from the ledger, both directories emptied, reserve released. + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerCount(env, alice) == preAliceOwners - 1); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 0); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + BEAST_EXPECT(env.balance(bob) == preBob - env.current()->fees().base + XRP(10)); + + // Second claim fails: the object is gone. + env(subscription::claim(bob, subId, XRP(10)), Ter(tecNO_ENTRY)); + env.close(); + } + + // Unmetered one-shot: single-use composed with Frequency == 0. The + // first claim (here partial) deletes the object. + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 0s), + Txflags(tfSingleUse | tfFullyCanonicalSig)); + env.close(); + + auto const preAliceOwners = ownerCount(env, alice); + env(subscription::claim(bob, subId, XRP(4))); + env.close(); + + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerCount(env, alice) == preAliceOwners - 1); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 0); + + env(subscription::claim(bob, subId, XRP(4)), Ter(tecNO_ENTRY)); + env.close(); + } + } + + void + testSingleUseMetered(FeatureBitset features) + { + testcase("single use metered"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + // A single-use metered subscription is deleted on the first claim even + // when that claim is partial and the period is not exhausted. + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), + Txflags(tfSingleUse | tfFullyCanonicalSig)); + env.close(); + + auto const preAliceOwners = ownerCount(env, alice); + auto const preAlice = env.balance(alice); + env(subscription::claim(bob, subId, XRP(5))); + env.close(); + + BEAST_EXPECT(!subscriptionExists(*env.current(), subId)); + BEAST_EXPECT(ownerCount(env, alice) == preAliceOwners - 1); + BEAST_EXPECT(ownerDirCount(*env.current(), alice) == 0); + BEAST_EXPECT(ownerDirCount(*env.current(), bob) == 0); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(5)); + + env(subscription::claim(bob, subId, XRP(5)), Ter(tecNO_ENTRY)); + env.close(); + } + + void + testSingleUseImmutable(FeatureBitset features) + { + testcase("single use immutable"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + // A subscription created without lsfSingleUse cannot gain it via update. + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + json::Value txn = subscription::update(alice, subId, XRP(10)); + txn[jss::Flags] = tfSingleUse | tfFullyCanonicalSig; + env(txn, Ter(temINVALID_FLAG)); + env.close(); + + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + BEAST_EXPECT(!(subSle->getFieldU32(sfFlags) & lsfSingleUse)); + } + + void + testUpdateFrequency(FeatureBitset features) + { + testcase("update frequency"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // Claim the full period so NextClaimTime is advanced. + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + // Updating Frequency changes it and resets a clean period: + // NextClaimTime = current close time, Balance = Amount. + env(subscription::update(alice, subId, XRP(10), std::nullopt, 200s)); + env.close(); + + auto const resetTime = env.now().time_since_epoch().count(); + { + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (!BEAST_EXPECT(subSle)) + return; + BEAST_EXPECT(subSle->getFieldU32(sfFrequency) == 200); + BEAST_EXPECT(subSle->getFieldAmount(sfBalance) == XRP(10)); + // NextClaimTime was reset to the update's close time. + BEAST_EXPECT(subSle->getFieldU32(sfNextClaimTime) < resetTime); + } + + // A fresh full claim is immediately available after the reset. + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + // ... and the new (longer) period now gates the next one. + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + } + + void + testUpdateFrequencyTransitions(FeatureBitset features) + { + testcase("update frequency transitions"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + // metered -> unmetered (N -> 0) + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + // Metered: a second immediate claim is too soon. + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + env(subscription::update(alice, subId, XRP(10), std::nullopt, 0s)); + env.close(); + auto const resetTime = env.now().time_since_epoch().count(); + + // Now unmetered: repeated claims succeed, each capped at Amount, + // and Balance/NextClaimTime never change. + for (int i = 0; i < 3; ++i) + { + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (!BEAST_EXPECT(subSle)) + return; + BEAST_EXPECT(subSle->getFieldU32(sfFrequency) == 0); + BEAST_EXPECT(subSle->getFieldAmount(sfBalance) == XRP(10)); + BEAST_EXPECT(subSle->getFieldU32(sfNextClaimTime) < resetTime); + } + } + + // unmetered -> metered (0 -> N) + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 0s)); + env.close(); + + // Unmetered: two back-to-back claims both succeed. + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + + env(subscription::update(alice, subId, XRP(10), std::nullopt, 100s)); + env.close(); + + { + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (!BEAST_EXPECT(subSle)) + return; + BEAST_EXPECT(subSle->getFieldU32(sfFrequency) == 100); + BEAST_EXPECT(subSle->getFieldAmount(sfBalance) == XRP(10)); + } + + // Now metered: one full claim, then the period gates the next. + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + } + + // metered -> metered (N -> M) + { + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + + env(subscription::update(alice, subId, XRP(10), std::nullopt, 300s)); + env.close(); + + { + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (!BEAST_EXPECT(subSle)) + return; + BEAST_EXPECT(subSle->getFieldU32(sfFrequency) == 300); + BEAST_EXPECT(subSle->getFieldAmount(sfBalance) == XRP(10)); + } + + // Fresh full claim immediately available after the reset. + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + } + } + + void + testUpdateRemoveExpiration(FeatureBitset features) + { + testcase("update remove expiration"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + auto const expire = env.now() + 150s; + env(subscription::create(alice, bob, XRP(10), 100s, expire)); + env.close(); + + { + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + BEAST_EXPECT( + subSle->getFieldU32(sfExpiration) == expire.time_since_epoch().count()); + } + + // Update with Expiration > now changes it. + auto const expire2 = env.now() + 250s; + env(subscription::update(alice, subId, XRP(10), expire2)); + env.close(); + { + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + BEAST_EXPECT( + subSle->getFieldU32(sfExpiration) == expire2.time_since_epoch().count()); + } + + // Update with a past (nonzero) Expiration is rejected. + env(subscription::update(alice, subId, XRP(10), env.now() - 10s), Ter(tecEXPIRED)); + env.close(); + + // Update with Expiration == 0 removes the field entirely. + env(subscription::update(alice, subId, XRP(10), NetClock::time_point{})); + env.close(); + { + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + BEAST_EXPECT(!subSle->isFieldPresent(sfExpiration)); + } + + // With expiration removed, a claim succeeds past the original expiry. + env.close(300s); + auto const preAlice = env.balance(alice); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + } + + void + testDelegatedPull(FeatureBitset features) + { + testcase("delegated pull"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // The puller/receiver split is delivered through XLS-75 delegation, + // NOT a dedicated field on the subscription object. The merchant + // (Destination) delegates SubscriptionClaim to a processor account; + // the processor claims on the merchant's behalf and funds move + // payer -> merchant. The processor is never named on the object. + auto const payer = Account("payer"); + auto const merchant = Account("merchant"); + auto const processor = Account("processor"); + auto const stranger = Account("stranger"); + + Env env{*this, features}; + env.fund(XRP(1000), payer, merchant, processor, stranger); + env.close(); + + auto const subId = getSubscriptionIndex(payer, merchant, env.seq(payer)); + env(subscription::create(payer, merchant, XRP(10), 100s)); + env.close(); + + // The merchant delegates SubscriptionClaim to the processor. + env(delegate::set(merchant, processor, {"SubscriptionClaim"})); + env.close(); + + auto const baseFee = env.current()->fees().base; + auto const predPayer = env.balance(payer); + auto const preMerchant = env.balance(merchant); + auto const preProcessor = env.balance(processor); + + // The processor claims on the merchant's behalf: funds move + // payer -> merchant. Under XLS-75 delegation the delegate (processor) + // pays the transaction fee, so the merchant receives the full claim. + env(subscription::claim(merchant, subId, XRP(10)), delegate::As(processor)); + env.close(); + BEAST_EXPECT(env.balance(payer) == predPayer - XRP(10)); + BEAST_EXPECT(env.balance(merchant) == preMerchant + XRP(10)); + BEAST_EXPECT(env.balance(processor) == preProcessor - baseFee); + + // The processor is not recorded on the object. + { + auto const [key, subSle] = subKeyAndSle(*env.current(), subId); + if (BEAST_EXPECT(subSle)) + { + BEAST_EXPECT(subSle->getAccountID(sfAccount) == payer.id()); + BEAST_EXPECT(subSle->getAccountID(sfDestination) == merchant.id()); + } + } + + env.close(100s); + + // Revoking the delegation (removing the SubscriptionClaim permission) + // makes the processor's next claim fail with the delegate retry code. + env(delegate::set(merchant, processor, {"Payment"})); + env.close(); + + env(subscription::claim(merchant, subId, XRP(10)), + delegate::As(processor), + Ter(terNO_DELEGATE_PERMISSION)); + env.close(); + + // A stranger submitting a claim directly (not as a delegate) is not + // the destination: tecNO_PERMISSION. + env(subscription::claim(stranger, subId, XRP(10)), Ter(tecNO_PERMISSION)); + env.close(); + + // An account that was never delegated the permission fails with the + // delegate retry code. + env(subscription::claim(merchant, subId, XRP(10)), + delegate::As(stranger), + Ter(terNO_DELEGATE_PERMISSION)); + env.close(); + } + + void + testExploitSingleUseReplay(FeatureBitset features) + { + testcase("exploit: single use replay"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s), + Txflags(tfSingleUse | tfFullyCanonicalSig)); + env.close(); + + auto const preAlice = env.balance(alice); + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + + // Replaying the same claim cannot double-spend: the object is gone. + env(subscription::claim(bob, subId, XRP(10)), Ter(tecNO_ENTRY)); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + } + + void + testExploitUnmeteredPartialDrain(FeatureBitset features) + { + testcase("exploit: unmetered partial drain"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const startTime = env.now().time_since_epoch().count(); + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 0s)); + env.close(); + + // A partial claim does not shrink the per-claim cap: the following + // claim is still capped at the full Amount, not the remainder. There + // is no running balance to drain below the cap. + env(subscription::claim(bob, subId, XRP(3))); + env.close(); + validateSubscription(env, subId, XRP(10), XRP(10), 0, startTime); + + // Claiming above Amount is still rejected. + env(subscription::claim(bob, subId, XRP(11)), Ter(tecLIMIT_EXCEEDED)); + env.close(); + + // A full-Amount claim right after the partial one succeeds (the cap + // did not drop to the remainder of 7). + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + validateSubscription(env, subId, XRP(10), XRP(10), 0, startTime); + } + + void + testExploitFrequencyUpdateArrearsReset(FeatureBitset features) + { + testcase("exploit: frequency update arrears reset"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + Env env{*this, features}; + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const start = env.now(); + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, XRP(10), 100s)); + env.close(); + + // Let three periods accrue unclaimed. Without an update these arrears + // would permit three back-to-back full claims. + for (; env.now() < start + 300s; env.close()) + { + } + + // A Frequency update resets a clean period: the accrued arrears are + // forfeited, not carried over. + env(subscription::update(alice, subId, XRP(10), std::nullopt, 100s)); + env.close(); + + auto const preAlice = env.balance(alice); + + // Exactly one full claim is available immediately after the reset. + env(subscription::claim(bob, subId, XRP(10))); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + + // The arrears did not survive the reset: no second claim yet. + env(subscription::claim(bob, subId, XRP(10)), Ter(tecTOO_SOON)); + env.close(); + BEAST_EXPECT(env.balance(alice) == preAlice - XRP(10)); + } + + void + testIOUEnablement(FeatureBitset features) + { + testcase("IOU Enablement"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Test with and without Subscription feature + for (bool const withSubscription : {true, false}) + { + auto const amend = withSubscription ? features : features - featureSubscription; + Env env{*this, amend}; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(5000), alice, bob, gw); + env.close(); + env.trust(USD(10000), alice, bob); + env.close(); + env(pay(gw, alice, USD(5000))); + env(pay(gw, bob, USD(5000))); + env.close(); + + auto const createResult = withSubscription ? Ter(tesSUCCESS) : Ter(temDISABLED); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, USD(100), 100s), createResult); + env.close(); + + if (withSubscription) + { + BEAST_EXPECT(subscriptionExists(*env.current(), subId)); + env(subscription::claim(bob, subId, USD(100))); + env.close(); + env(subscription::cancel(alice, subId)); + env.close(); + } + } + } + + void + testIOUSetPreflightInvalid(FeatureBitset features) + { + testcase("IOU Set Preflight Invalid"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + + // temBAD_AMOUNT: negative IOU + { + env(subscription::create(alice, bob, USD(-1), 100s), Ter(temBAD_AMOUNT)); + env.close(); + } + + // temBAD_AMOUNT: zero IOU + { + env(subscription::create(alice, bob, USD(0), 100s), Ter(temBAD_AMOUNT)); + env.close(); + } + + // temBAD_CURRENCY + { + IOU const BAD{gw, badCurrency()}; + env(subscription::create(alice, bob, BAD(10), 100s), Ter(temBAD_CURRENCY)); + env.close(); + } + } + + void + testIOUSetPreclaimInvalid(FeatureBitset features) + { + testcase("IOU Set Preclaim Invalid"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + + // tecNO_ISSUER: issuer doesn't exist + { + auto const dneGw = Account{"dneGateway"}; + auto const DNE = dneGw["USD"]; + env.memoize(dneGw); + + env(subscription::create(alice, bob, DNE(10), 100s), Ter(tecNO_ISSUER)); + env.close(); + } + + // tecNO_LINE: account doesn't have trustline to issuer + { + env(subscription::create(alice, bob, USD(10), 100s), Ter(tecNO_LINE)); + env.close(); + } + + // Setup for remaining tests + env(fset(gw, asfRequireAuth)); + env.close(); + env.trust(USD(10000), alice, bob); + env.close(); + + // tecNO_AUTH: requireAuth set, account not authorized + { + env(subscription::create(alice, bob, USD(10), 100s), Ter(tecNO_AUTH)); + env.close(); + } + + // tecNO_AUTH: requireAuth set, destination not authorized + { + auto const aliceUSD = alice["USD"]; + env(trust(gw, aliceUSD(10'000)), Txflags(tfSetfAuth)); + env(subscription::create(alice, bob, USD(10), 100s), Ter(tecNO_AUTH)); + env.close(); + + env(fclear(gw, asfRequireAuth)); + env.close(); + } + + env(fclear(gw, asfRequireAuth)); + env.close(); + env(pay(gw, alice, USD(5000))); + env(pay(gw, bob, USD(5000))); + env.close(); + + // tecFROZEN: account is frozen + { + env(trust(gw, USD(10000), alice, tfSetFreeze)); + env.close(); + + env(subscription::create(alice, bob, USD(10), 100s), Ter(tecFROZEN)); + env.close(); + + env(trust(gw, USD(10000), alice, tfClearFreeze)); + env.close(); + } + + // tecFROZEN: destination is frozen + { + env(trust(gw, USD(10000), bob, tfSetFreeze)); + env.close(); + + env(subscription::create(alice, bob, USD(10), 100s), Ter(tecFROZEN)); + env.close(); + + env(trust(gw, USD(10000), bob, tfClearFreeze)); + env.close(); + } + + // tecINSUFFICIENT_FUNDS: balance is zero + { + env(pay(alice, gw, USD(5000))); + env.close(); + + env(subscription::create(alice, bob, USD(10), 100s), Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + env(pay(gw, alice, USD(5000))); + env.close(); + } + + // tecINSUFFICIENT_FUNDS: balance less than amount + { + env(subscription::create(alice, bob, USD(6000), 100s), Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + } + } + + void + testIOUClaimPreclaimInvalid(FeatureBitset features) + { + testcase("IOU Claim Preclaim Invalid"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // TODO: Will need to retest all of the functionality here. + + // tecNO_AUTH: dest not authorized after subscription created + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + auto const aliceUSD = alice["USD"]; + auto const bobUSD = bob["USD"]; + env.fund(XRP(5000), alice, bob, gw); + env(fset(gw, asfAllowTrustLineLocking)); + env(fset(gw, asfRequireAuth)); + env.close(); + env(trust(gw, aliceUSD(10'000)), Txflags(tfSetfAuth)); + env(trust(gw, bobUSD(10'000)), Txflags(tfSetfAuth)); + env.trust(USD(10'000), alice, bob); + env.close(); + env(pay(gw, alice, USD(10'000))); + env(pay(gw, bob, USD(10'000))); + env.close(); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, USD(100), 100s)); + env.close(); + + // Unauthorize dest + env(pay(bob, gw, USD(10'000))); + env(trust(gw, bobUSD(0)), Txflags(tfSetfAuth)); + env(trust(bob, USD(0))); + env.close(); + + env.trust(USD(10'000), bob); + env.close(); + + env(subscription::claim(bob, subId, USD(100)), Ter(tecNO_AUTH)); + env.close(); + } + } + + void + testIOUClaimDoApplyInvalid(FeatureBitset features) + { + testcase("IOU Claim DoApply Invalid"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + env.trust(USD(10000), alice, bob); + env.close(); + env(pay(gw, alice, USD(5000))); + env(pay(gw, bob, USD(5000))); + env.close(); + + // tecNO_LINE_INSUF_RESERVE: insufficient reserve to create trustline + { + auto const reserve = env.current()->fees().accountReserve(0, 1); + auto const incReserve = env.current()->fees().increment; + + env.fund(reserve + (incReserve - 1), carol); + env.close(); + + auto const subId = getSubscriptionIndex(alice, carol, env.seq(alice)); + env(subscription::create(alice, carol, USD(10), 100s)); + env.close(); + + env(subscription::claim(carol, subId, USD(10)), Ter(tecNO_LINE_INSUF_RESERVE)); + env.close(); + } + } + + void + testIOUBalances(FeatureBitset features) + { + testcase("IOU Balances"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(5000), alice, bob, gw); + env.close(); + env.trust(USD(10000), alice, bob); + env.close(); + env(pay(gw, alice, USD(5000))); + env(pay(gw, bob, USD(5000))); + env.close(); + + auto const outstandingUSD = USD(10000); + + // Create & Claim Subscription + { + auto const preAliceUSD = env.balance(alice, USD); + auto const preBobUSD = env.balance(bob, USD); + + auto const subId = getSubscriptionIndex(alice, bob, env.seq(alice)); + env(subscription::create(alice, bob, USD(1000), 100s)); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == preAliceUSD); + BEAST_EXPECT(env.balance(bob, USD) == preBobUSD); + BEAST_EXPECT(issuerBalance(env, gw, USD) == outstandingUSD); + + env(subscription::claim(bob, subId, USD(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == preAliceUSD - USD(1000)); + BEAST_EXPECT(env.balance(bob, USD) == preBobUSD + USD(1000)); + BEAST_EXPECT(issuerBalance(env, gw, USD) == outstandingUSD); + + // Second claim + env.close(100s); + env(subscription::claim(bob, subId, USD(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == preAliceUSD - USD(2000)); + BEAST_EXPECT(env.balance(bob, USD) == preBobUSD + USD(2000)); + } + } + + void + testIOUMetaAndOwnership(FeatureBitset features) + { + testcase("IOU Meta and Ownership"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(5000), alice, bob, carol, gw); + env.close(); + env.trust(USD(10000), alice, bob, carol); + env.close(); + env(pay(gw, alice, USD(5000))); + env(pay(gw, bob, USD(5000))); + env(pay(gw, carol, USD(5000))); + env.close(); + + // Create subscriptions and check ownership + { + auto const seq1 = env.seq(alice); + auto const subId1 = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, USD(100), 100s)); + env.close(); + + auto const sub1 = env.le(keylet::subscription(subId1)); + BEAST_EXPECT(sub1); + + Dir aod(*env.current(), keylet::ownerDir(alice.id())); + BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 2); // trustline + subscription + BEAST_EXPECT(std::find(aod.begin(), aod.end(), sub1) != aod.end()); + + Dir bod(*env.current(), keylet::ownerDir(bob.id())); + BEAST_EXPECT(std::distance(bod.begin(), bod.end()) == 2); // trustline + subscription + BEAST_EXPECT(std::find(bod.begin(), bod.end(), sub1) != bod.end()); + + env(subscription::cancel(alice, subId1)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::subscription(subId1))); + } + } + + void + testIOURippleState(FeatureBitset features) + { + testcase("IOU RippleState"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + struct TestAccountData + { + jtx::Account src; + jtx::Account dst; + jtx::Account gw; + bool hasTrustline; + }; + + std::array tests = {{ + {Account("alice2"), Account("bob0"), Account{"gw0"}, false}, + {Account("carol0"), Account("dan1"), Account{"gw1"}, false}, + {Account("alice2"), Account("bob0"), Account{"gw0"}, true}, + {Account("carol0"), Account("dan1"), Account{"gw1"}, true}, + }}; + + for (auto const& t : tests) + { + Env env{*this, features}; + auto const USD = t.gw["USD"]; + + env.fund(XRP(5000), t.src, t.dst, t.gw); + env.close(); + + if (t.hasTrustline) + env.trust(USD(100000), t.src, t.dst); + else + env.trust(USD(100000), t.src); + env.close(); + + env(pay(t.gw, t.src, USD(10000))); + if (t.hasTrustline) + env(pay(t.gw, t.dst, USD(10000))); + env.close(); + + auto const seq1 = env.seq(t.src); + auto const subId = getSubscriptionIndex(t.src, t.dst, seq1); + auto const delta = USD(1000); + + env(subscription::create(t.src, t.dst, delta, 100s)); + env.close(); + + auto const preSrc = env.balance(t.src, USD); + auto const preDst = env.balance(t.dst, USD); + + env(subscription::claim(t.dst, subId, delta)); + env.close(); + + BEAST_EXPECT(env.balance(t.src, USD) == preSrc - delta); + BEAST_EXPECT(env.balance(t.dst, USD) == preDst + delta); + } + } + + void + testIOUGateway(FeatureBitset features) + { + testcase("IOU Gateway"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Issuer as source + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(5000), alice, gw); + env.close(); + env.trust(USD(100000), alice); + env.close(); + env(pay(gw, alice, USD(10000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(gw, alice, seq1); + auto const preSrc = env.balance(alice, USD); + + env(subscription::create(gw, alice, USD(1000), 100s)); + env.close(); + + env(subscription::claim(alice, subId, USD(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == preSrc + USD(1000)); + BEAST_EXPECT(env.balance(gw, USD) == USD(0)); + } + + // Issuer as destination + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(5000), alice, gw); + env.close(); + env.trust(USD(100000), alice); + env.close(); + env(pay(gw, alice, USD(10000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, gw, seq1); + auto const preSrc = env.balance(alice, USD); + + env(subscription::create(alice, gw, USD(1000), 100s)); + env.close(); + + env(subscription::claim(gw, subId, USD(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == preSrc - USD(1000)); + BEAST_EXPECT(env.balance(gw, USD) == USD(0)); + } + } + + void + testIOUTransferRate(FeatureBitset features) + { + testcase("IOU Transfer Rate"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env(rate(gw, 1.25)); + env.close(); + env.trust(USD(100000), alice, bob); + env.close(); + env(pay(gw, alice, USD(10000))); + env(pay(gw, bob, USD(10000))); + env.close(); + + // Create subscription with transfer rate + { + auto const preAlice = env.balance(alice, USD); + auto const preBob = env.balance(bob, USD); + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + + env(subscription::create(alice, bob, USD(125), 100s)); + env.close(); + + // Rate changes after subscription creation + env(rate(gw, 1.00)); + env.close(); + + // Claim with new rate (should apply new rate for subscriptions) + env(subscription::claim(bob, subId, USD(125))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == preAlice - USD(125)); + BEAST_EXPECT(env.balance(bob, USD) == preBob + USD(125)); + } + } + + void + testIOULimitAmount(FeatureBitset features) + { + testcase("IOU Limit Amount"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Create subscription and verify limit isn't changed + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + env.trust(USD(10000), alice, bob); + env.close(); + env(pay(gw, alice, USD(1000))); + env(pay(gw, bob, USD(1000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + auto const preBobLimit = env.limit(bob, USD); + + env(subscription::create(alice, bob, USD(125), 100s)); + env.close(); + + env(subscription::claim(bob, subId, USD(125))); + env.close(); + + auto const postBobLimit = env.limit(bob, USD); + BEAST_EXPECT(postBobLimit == preBobLimit); + } + + // Create subscription and verify initial 0 limit + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + env.trust(USD(10000), alice); + env.close(); + env(pay(gw, alice, USD(1000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + auto const preBobLimit = env.limit(bob, USD); + + env(subscription::create(alice, bob, USD(125), 100s)); + env.close(); + + env(subscription::claim(bob, subId, USD(125))); + env.close(); + + auto const postBobLimit = env.limit(bob, USD); + BEAST_EXPECT(postBobLimit == preBobLimit); + } + } + + void + testIOURequireAuth(FeatureBitset features) + { + testcase("IOU Require Auth"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + auto const aliceUSD = alice["USD"]; + auto const bobUSD = bob["USD"]; + + env.fund(XRP(1000), alice, bob, gw); + env(fset(gw, asfRequireAuth)); + env.close(); + + env(trust(gw, aliceUSD(10000)), Txflags(tfSetfAuth)); + env(trust(alice, USD(10000))); + env(trust(bob, USD(10000))); + env.close(); + env(pay(gw, alice, USD(1000))); + env.close(); + + // Cannot create subscription without dest auth + { + env(subscription::create(alice, bob, USD(125), 100s), Ter(tecNO_AUTH)); + env.close(); + } + + // Set auth on bob and retry + { + env(trust(gw, bobUSD(10000)), Txflags(tfSetfAuth)); + env(trust(bob, USD(10000))); + env.close(); + env(pay(gw, bob, USD(1000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + + env(subscription::create(alice, bob, USD(125), 100s)); + env.close(); + + env(subscription::claim(bob, subId, USD(125))); + env.close(); + + env(subscription::cancel(alice, subId)); + env.close(); + } + } + + void + testIOUFreeze(FeatureBitset features) + { + testcase("IOU Freeze"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Global Freeze + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(USD(100000), alice, bob); + env.close(); + env(pay(gw, alice, USD(10000))); + env(pay(gw, bob, USD(10000))); + env.close(); + + env(fset(gw, asfGlobalFreeze)); + env.close(); + + // Cannot create subscription with frozen assets + env(subscription::create(alice, bob, USD(125), 100s), Ter(tecFROZEN)); + env.close(); + + env(fclear(gw, asfGlobalFreeze)); + env.close(); + + // Can create after unfreezing + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + + env(subscription::create(alice, bob, USD(125), 100s)); + env.close(); + + // Freeze again + env(fset(gw, asfGlobalFreeze)); + env.close(); + + // Cannot claim with frozen assets + env(subscription::claim(bob, subId, USD(125)), Ter(tecFROZEN)); + env.close(); + + env(fclear(gw, asfGlobalFreeze)); + env(subscription::cancel(alice, subId)); + env.close(); + } + + // Individual Freeze + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(USD(100000), alice, bob); + env.close(); + env(pay(gw, alice, USD(10000))); + env(pay(gw, bob, USD(10000))); + env.close(); + + // Freeze alice trustline + env(trust(gw, USD(10000), alice, tfSetFreeze)); + env.close(); + + // Cannot create subscription with frozen account + env(subscription::create(alice, bob, USD(125), 100s), Ter(tecFROZEN)); + env.close(); + + env(trust(gw, USD(10000), alice, tfClearFreeze)); + env.close(); + + // Create subscription + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + + env(subscription::create(alice, bob, USD(125), 100s)); + env.close(); + + // Freeze bob trustline + env(trust(gw, USD(10000), bob, tfSetFreeze)); + env.close(); + + // Cannot claim with frozen destination + env(subscription::claim(bob, subId, USD(125)), Ter(tecFROZEN)); + env.close(); + + env(trust(gw, USD(10000), bob, tfClearFreeze)); + env(subscription::cancel(alice, subId)); + env.close(); + } + } + + void + testIOUPrecisionLoss(FeatureBitset features) + { + testcase("IOU Precision Loss"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const USD = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(USD(100000000000000000), alice, bob); + env.close(); + env(pay(gw, alice, USD(10000000000000000))); + env(pay(gw, bob, USD(1))); + env.close(); + + // Cannot create subscription with precision loss amount + { + // Large-mantissa amendments make this amount representable + bool const largeMantissa = + features[featureSingleAssetVault] || features[featureLendingProtocol]; + + env(subscription::create(alice, bob, USD(1), 100s), + Ter(largeMantissa ? (TER)tesSUCCESS : (TER)tecPRECISION_LOSS)); + env.close(); + + // This amount works + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + + env(subscription::create(alice, bob, USD(1000), 100s)); + env.close(); + + env(subscription::claim(bob, subId, USD(1000))); + env.close(); + } + } + + void + testMPTEnablement(FeatureBitset features) + { + testcase("MPT Enablement"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + for (bool const withSubscription : {true, false}) + { + auto const amend = withSubscription ? features : features - featureSubscription; + Env env{*this, amend}; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + env.fund(XRP(5000), bob); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env.close(); + + auto const createResult = withSubscription ? Ter(tesSUCCESS) : Ter(temDISABLED); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(1000), 100s), createResult); + env.close(); + + if (withSubscription) + { + env(subscription::claim(bob, subId, MPT(1000))); + env.close(); + env(subscription::cancel(alice, subId)); + env.close(); + } + } + } + + void + testMPTSetPreflightInvalid(FeatureBitset features) + { + testcase("MPT Set Preflight Invalid"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + + // temBAD_AMOUNT: negative MPT + { + env(subscription::create(alice, bob, MPT(-1), 100s), Ter(temBAD_AMOUNT)); + env.close(); + } + + // temBAD_AMOUNT: zero MPT + { + env(subscription::create(alice, bob, MPT(0), 100s), Ter(temBAD_AMOUNT)); + env.close(); + } + + // temBAD_AMOUNT: exceeds max MPT amount + // DA: Not Testable + } + + void + testMPTSetPreclaimInvalid(FeatureBitset features) + { + testcase("MPT Set Preclaim Invalid"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // tecOBJECT_NOT_FOUND: mpt does not exist + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const mpt = jtx::MPT(alice.name(), makeMptID(env.seq(alice), alice)); + json::Value jv = subscription::create(alice, bob, mpt(10), 100s); + jv[jss::Amount][jss::mpt_issuance_id] = + "00000004A407AF5856CCF3C42619DAA925813FC955C72983"; + env(jv, Ter(tecOBJECT_NOT_FOUND)); + env.close(); + } + + // tecOBJECT_NOT_FOUND: account does not have the mpt + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + auto const MPT = mptGw["MPT"]; + + env(subscription::create(alice, bob, MPT(4), 100s), Ter(tecOBJECT_NOT_FOUND)); + env.close(); + } + + // tecNO_AUTH: requireAuth set: account not authorized + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTRequireAuth}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = gw, .holder = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10'000))); + env.close(); + + // unauthorize account + mptGw.authorize({.account = gw, .holder = alice, .flags = tfMPTUnauthorize}); + + env(subscription::create(alice, bob, MPT(5), 100s), Ter(tecNO_AUTH)); + env.close(); + } + + // tecNO_AUTH: requireAuth set: dest not authorized + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTRequireAuth}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = gw, .holder = alice}); + mptGw.authorize({.account = bob}); + mptGw.authorize({.account = gw, .holder = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10'000))); + env(pay(gw, bob, MPT(10'000))); + env.close(); + + // unauthorize dest + mptGw.authorize({.account = gw, .holder = bob, .flags = tfMPTUnauthorize}); + + env(subscription::create(alice, bob, MPT(6), 100s), Ter(tecNO_AUTH)); + env.close(); + } + + // tecLOCKED: issuer has locked the account + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTCanLock}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10'000))); + env(pay(gw, bob, MPT(10'000))); + env.close(); + + // lock account + mptGw.set({.account = gw, .holder = alice, .flags = tfMPTLock}); + + env(subscription::create(alice, bob, MPT(7), 100s), Ter(tecLOCKED)); + env.close(); + } + + // tecLOCKED: issuer has locked the dest + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTCanLock}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10'000))); + env(pay(gw, bob, MPT(10'000))); + env.close(); + + // lock dest + mptGw.set({.account = gw, .holder = bob, .flags = tfMPTLock}); + + env(subscription::create(alice, bob, MPT(8), 100s), Ter(tecLOCKED)); + env.close(); + } + + // tecNO_AUTH: mpt cannot be transferred + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10'000))); + env(pay(gw, bob, MPT(10'000))); + env.close(); + + env(subscription::create(alice, bob, MPT(9), 100s), Ter(tecNO_AUTH)); + env.close(); + } + + // tecINSUFFICIENT_FUNDS: spendable amount is zero + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, bob, MPT(10))); + env.close(); + + env(subscription::create(alice, bob, MPT(10), 100s), Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + } + + // tecINSUFFICIENT_FUNDS: spendable amount is less than the amount + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10))); + env(pay(gw, bob, MPT(10))); + env.close(); + + env(subscription::create(alice, bob, MPT(11), 100s), Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + } + } + + void + testMPTClaimPreclaimInvalid(FeatureBitset features) + { + testcase("MPT Claim Preclaim Invalid"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // tecNO_AUTH: dest not authorized after subscription created + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer | tfMPTRequireAuth}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = gw, .holder = alice}); + mptGw.authorize({.account = bob}); + mptGw.authorize({.account = gw, .holder = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env(pay(gw, bob, MPT(10000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(10), 100s)); + env.close(); + + // Unauthorize dest + mptGw.authorize({.account = gw, .holder = bob, .flags = tfMPTUnauthorize}); + + env(subscription::claim(bob, subId, MPT(10)), Ter(tecNO_AUTH)); + env.close(); + } + + // tecLOCKED: dest is locked + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer | tfMPTCanLock}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env(pay(gw, bob, MPT(10000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(8), 100s)); + env.close(); + + // Lock dest + mptGw.set({.account = gw, .holder = bob, .flags = tfMPTLock}); + + env(subscription::claim(bob, subId, MPT(8)), Ter(tecLOCKED)); + env.close(); + } + } + + void + testMPTClaimDoApply(FeatureBitset features) + { + testcase("MPT Claim DoApply"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // tecINSUFFICIENT_RESERVE: insufficient reserve to create MPToken + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const reserve = env.current()->fees().accountReserve(0, 1); + auto const incReserve = env.current()->fees().increment; + + env.fund(reserve + (incReserve - 1), bob); + env.close(); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, MPT(10)), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + } + + // tesSUCCESS: bob submits; finish MPT created + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + env.fund(XRP(10'000), bob); + env.close(); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10'000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(10), 100s)); + env.close(); + + env(subscription::claim(bob, subId, MPT(10)), Ter(tesSUCCESS)); + env.close(); + } + + // tecNO_PERMISSION: MPToken not created for destination with + // requireAuth + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const carol = Account("carol"); + auto const gw = Account("gw"); + env.fund(XRP(10'000), bob, carol); + env.close(); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10'000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(10), 100s)); + env.close(); + + env(subscription::claim(carol, subId, MPT(10)), Ter(tecNO_PERMISSION)); + env.close(); + } + } + + void + testMPTBalances(FeatureBitset features) + { + testcase("MPT Balances"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + env.fund(XRP(5000), bob); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env.close(); + + auto outstandingMPT = env.balance(gw, MPT); + + // Create & Claim Subscription + { + auto const preAliceMPT = env.balance(alice, MPT); + auto const preBobMPT = env.balance(bob, MPT); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(1000), 100s)); + env.close(); + + BEAST_EXPECT(env.balance(alice, MPT) == preAliceMPT); + BEAST_EXPECT(env.balance(bob, MPT) == preBobMPT); + BEAST_EXPECT(env.balance(gw, MPT) == outstandingMPT); + + env(subscription::claim(bob, subId, MPT(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, MPT) == preAliceMPT - MPT(1000)); + BEAST_EXPECT(env.balance(bob, MPT) == preBobMPT + MPT(1000)); + BEAST_EXPECT(env.balance(gw, MPT) == outstandingMPT); + + // Second claim + env.close(100s); + env(subscription::claim(bob, subId, MPT(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, MPT) == preAliceMPT - MPT(2000)); + BEAST_EXPECT(env.balance(bob, MPT) == preBobMPT + MPT(2000)); + } + } + + void + testMPTMetaAndOwnership(FeatureBitset features) + { + testcase("MPT Meta and Ownership"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env(pay(gw, bob, MPT(10000))); + env.close(); + + // Create subscription and check ownership + { + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(100), 100s)); + env.close(); + + auto const sub = env.le(keylet::subscription(subId)); + BEAST_EXPECT(sub); + + Dir aod(*env.current(), keylet::ownerDir(alice.id())); + BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 2); // mptoken + subscription + BEAST_EXPECT(std::find(aod.begin(), aod.end(), sub) != aod.end()); + + Dir bod(*env.current(), keylet::ownerDir(bob.id())); + BEAST_EXPECT(std::distance(bod.begin(), bod.end()) == 2); // mptoken + subscription + BEAST_EXPECT(std::find(bod.begin(), bod.end(), sub) != bod.end()); + + env(subscription::cancel(alice, subId)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::subscription(subId))); + } + } + + void + testMPTGateway(FeatureBitset features) + { + testcase("MPT Gateway"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + // Issuer as source + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env.close(); + + auto const seq1 = env.seq(gw); + auto const subId = getSubscriptionIndex(gw, alice, seq1); + auto const preAliceMPT = env.balance(alice, MPT); + auto const preOutstanding = env.balance(gw, MPT); + + env(subscription::create(gw, alice, MPT(1000), 100s)); + env.close(); + + env(subscription::claim(alice, subId, MPT(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, MPT) == preAliceMPT + MPT(1000)); + BEAST_EXPECT(env.balance(gw, MPT) == preOutstanding - MPT(1000)); + } + + // Issuer as destination + { + Env env{*this, features}; + auto const alice = Account("alice"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env.close(); + + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, gw, seq1); + auto const preAliceMPT = env.balance(alice, MPT); + auto const preOutstanding = env.balance(gw, MPT); + + env(subscription::create(alice, gw, MPT(1000), 100s)); + env.close(); + + env(subscription::claim(gw, subId, MPT(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, MPT) == preAliceMPT - MPT(1000)); + BEAST_EXPECT(env.balance(gw, MPT) == preOutstanding + MPT(1000)); + } + } + + void + testMPTTransferRate(FeatureBitset features) + { + testcase("MPT Transfer Rate"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.transferFee = 25000, // 2.5% + .ownerCount = 1, + .holderCount = 0, + .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env(pay(gw, bob, MPT(10000))); + env.close(); + + // Create subscription with transfer fee + { + auto const preAlice = env.balance(alice, MPT); + auto const preBob = env.balance(bob, MPT); + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + + env(subscription::create(alice, bob, MPT(125), 100s)); + env.close(); + + env(subscription::claim(bob, subId, MPT(125))); + env.close(); + + BEAST_EXPECT(env.balance(alice, MPT) == preAlice - MPT(156)); + // Bob receives 125 + BEAST_EXPECT(env.balance(bob, MPT) == preBob + MPT(125)); + + env(subscription::cancel(alice, subId)); + env.close(); + } + } + + void + testMPTRequireAuth(FeatureBitset features) + { + testcase("MPT Require Auth"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer | tfMPTRequireAuth}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = gw, .holder = alice}); + mptGw.authorize({.account = bob}); + mptGw.authorize({.account = gw, .holder = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env.close(); + + // Create subscription with both authorized + { + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + + env(subscription::create(alice, bob, MPT(100), 100s)); + env.close(); + + env(subscription::claim(bob, subId, MPT(100))); + env.close(); + + env(subscription::cancel(alice, subId)); + env.close(); + } + } + + void + testMPTLock(FeatureBitset features) + { + testcase("MPT Lock"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer | tfMPTCanLock}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env(pay(gw, bob, MPT(10000))); + env.close(); + + // Create subscription + { + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, bob, seq1); + env(subscription::create(alice, bob, MPT(100), 100s)); + env.close(); + + // Lock both accounts + mptGw.set({.account = gw, .holder = alice, .flags = tfMPTLock}); + mptGw.set({.account = gw, .holder = bob, .flags = tfMPTLock}); + + // Cannot claim when locked + env(subscription::claim(bob, subId, MPT(100)), Ter(tecLOCKED)); + env.close(); + + // Unlock and cleanup + mptGw.set({.account = gw, .holder = alice, .flags = tfMPTUnlock}); + mptGw.set({.account = gw, .holder = bob, .flags = tfMPTUnlock}); + env(subscription::cancel(alice, subId)); + env.close(); + } + } + + void + testMPTCanTransfer(FeatureBitset features) + { + if (!features[featureMPTokensV1]) + return; + + testcase("MPT Can Transfer"); + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = 0}); // No tfMPTCanTransfer + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const MPT = mptGw["MPT"]; + env(pay(gw, alice, MPT(10000))); + env(pay(gw, bob, MPT(10000))); + env.close(); + + // Cannot create subscription to non-issuer without transfer + { + env(subscription::create(alice, bob, MPT(100), 100s), Ter(tecNO_AUTH)); + env.close(); + } + + // Can create subscription to issuer + { + auto const seq1 = env.seq(alice); + auto const subId = getSubscriptionIndex(alice, gw, seq1); + env(subscription::create(alice, gw, MPT(100), 100s)); + env.close(); + + env(subscription::claim(gw, subId, MPT(100))); + env.close(); + + env(subscription::cancel(alice, subId)); + env.close(); + } + } + + void + testIOUWithFeats(FeatureBitset features) + { + testIOUEnablement(features); + testIOUSetPreflightInvalid(features); + testIOUSetPreclaimInvalid(features); + // testIOUClaimPreclaimInvalid(features); // TODO: Extra Duplication + testIOUClaimDoApplyInvalid(features); + testIOUBalances(features); + testIOUMetaAndOwnership(features); + testIOURippleState(features); + testIOUGateway(features); + testIOUTransferRate(features); + testIOULimitAmount(features); + testIOURequireAuth(features); + testIOUFreeze(features); + testIOUPrecisionLoss(features); + } + + void + testMPTWithFeats(FeatureBitset features) + { + testMPTEnablement(features); + testMPTSetPreflightInvalid(features); + testMPTSetPreclaimInvalid(features); + // testMPTClaimPreclaimInvalid(features); // TODO: Extra Duplication + testMPTClaimDoApply(features); + testMPTBalances(features); + testMPTMetaAndOwnership(features); + testMPTGateway(features); + testMPTTransferRate(features); + testMPTRequireAuth(features); + testMPTLock(features); + testMPTCanTransfer(features); + } + + void + testWithFeats(FeatureBitset features) + { + testEnabled(features); + testSetPreflightInvalid(features); + testSetPreclaimInvalid(features); + testSetDoApplyInvalid(features); + testCancelPreflightInvalid(features); + testCancelPreclaimInvalid(features); + testClaimPreflightInvalid(features); + testClaimPreclaimInvalid(features); + testClaimDoApplyInvalid(features); + testSet(features); + testUpdate(features); + testCancel(features); + testClaim(features); + testDstTag(features); + testMetaAndOwnership(features); + testAccountDelete(features); + testUsingTickets(features); + testExpiredSubscription(features); + testTimingBoundaries(features); + testConsequences(features); + testMultipleSubscriptionsSamePair(features); + testRegularKey(features); + testMultisign(features); + testDelegation(features); + testAccountObjectsRPC(features); + testLedgerEntryRPC(features); + testDepositAuthDestination(features); + testExploitThirdPartyCancel(features); + testExploitExpiredDrain(features); + testExploitAssetSwitchUpdate(features); + testExploitClaimOverdraw(features); + testExploitBoundaryStraddle(features); + testExploitArrearsExactness(features); + testExploitOwnerClaim(features); + testUpdateRequireAuth(features); + testSequenceField(features); + testReserveEdge(features); + + // Unmetered mode, single-use, flexible updates, delegated pull + testUnmeteredMode(features); + testUnmeteredClaims(features); + testSingleUse(features); + testSingleUseMetered(features); + testSingleUseImmutable(features); + testUpdateFrequency(features); + testUpdateFrequencyTransitions(features); + testUpdateRemoveExpiration(features); + testDelegatedPull(features); + testExploitSingleUseReplay(features); + testExploitUnmeteredPartialDrain(features); + testExploitFrequencyUpdateArrearsReset(features); + + // IOU-specific tests + testIOUWithFeats(features); + + // MPT-specific tests + testMPTWithFeats(features); + + // TODO: Can a MPT/Token/Issuance be destroyed while a subscription + // exists? + } + +public: + void + run() override + { + using namespace test::jtx; + auto const sa = testableAmendments() | featureSubscription; + testWithFeats(sa); + } +}; + +BEAST_DEFINE_TESTSUITE(Subscription, app, xrpl); +} // namespace xrpl::test diff --git a/src/test/jtx/impl/subscription.cpp b/src/test/jtx/impl/subscription.cpp new file mode 100644 index 00000000000..99d798de0f9 --- /dev/null +++ b/src/test/jtx/impl/subscription.cpp @@ -0,0 +1,88 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl::test::jtx::subscription { + +void +StartTime::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, + std::uint32_t flags) +{ + 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::Values::None); + jv[jss::Frequency] = frequency.count(); + jv[jss::Flags] = flags; + 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, + std::optional const& frequency) +{ + 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::Values::None); + jv[jss::Flags] = tfFullyCanonicalSig; + if (expiration) + jv[sfExpiration.jsonName] = expiration->time_since_epoch().count(); + if (frequency) + jv[jss::Frequency] = frequency->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] = tfFullyCanonicalSig; + 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::Values::None); + jv[jss::Flags] = tfFullyCanonicalSig; + return jv; +} + +} // namespace xrpl::test::jtx::subscription diff --git a/src/test/jtx/subscription.h b/src/test/jtx/subscription.h new file mode 100644 index 00000000000..2eb4404e0bb --- /dev/null +++ b/src/test/jtx/subscription.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::test::jtx { + +/** Subscription operations. */ +namespace subscription { + +/** Create a subscription. Pass a frequency of zero for an unmetered + subscription and set tfSingleUse in flags for a one-shot 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, + std::uint32_t flags = tfFullyCanonicalSig); + +/** Update a subscription. An engaged expiration of zero removes any existing + expiration; an engaged frequency changes it and resets the period. */ +json::Value +update( + jtx::Account const& account, + uint256 const& subscriptionId, + STAmount const& amount, + std::optional const& expiration = std::nullopt, + std::optional const& frequency = std::nullopt); + +/** Cancel a subscription. */ +json::Value +cancel(jtx::Account const& account, uint256 const& subscriptionId); + +/** Claim a subscription payment. */ +json::Value +claim(jtx::Account const& account, uint256 const& subscriptionId, STAmount const& amount); + +/** Set the "StartTime" time tag on a JTx. */ +class StartTime +{ +private: + NetClock::time_point value_; + +public: + explicit StartTime(NetClock::time_point const& value) : value_(value) + { + } + + void + operator()(Env&, JTx& jt) const; +}; + +} // namespace subscription + +} // namespace xrpl::test::jtx diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/SubscriptionTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/SubscriptionTests.cpp new file mode 100644 index 00000000000..fb5c845e9ec --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/SubscriptionTests.cpp @@ -0,0 +1,412 @@ +// Auto-generated unit tests for ledger entry Subscription + + +#include + +#include + +#include +#include +#include + +#include + +namespace xrpl::ledger_entries { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed for both the +// builder's STObject and the wrapper's SLE. +TEST(SubscriptionTests, BuilderSettersRoundTrip) +{ + uint256 const index{1u}; + + auto const previousTxnIDValue = canonical_UINT256(); + auto const previousTxnLgrSeqValue = canonical_UINT32(); + auto const sequenceValue = canonical_UINT32(); + auto const ownerNodeValue = canonical_UINT64(); + auto const accountValue = canonical_ACCOUNT(); + auto const destinationValue = canonical_ACCOUNT(); + auto const destinationTagValue = canonical_UINT32(); + auto const amountValue = canonical_AMOUNT(); + auto const balanceValue = canonical_AMOUNT(); + auto const frequencyValue = canonical_UINT32(); + auto const nextClaimTimeValue = canonical_UINT32(); + auto const expirationValue = canonical_UINT32(); + auto const destinationNodeValue = canonical_UINT64(); + + SubscriptionBuilder builder{ + previousTxnIDValue, + previousTxnLgrSeqValue, + sequenceValue, + ownerNodeValue, + accountValue, + destinationValue, + amountValue, + balanceValue, + frequencyValue, + nextClaimTimeValue, + destinationNodeValue + }; + + builder.setDestinationTag(destinationTagValue); + builder.setExpiration(expirationValue); + + builder.setLedgerIndex(index); + builder.setFlags(0x1u); + + EXPECT_TRUE(builder.validate()); + + auto const entry = builder.build(index); + + EXPECT_TRUE(entry.validate()); + + { + auto const& expected = previousTxnIDValue; + auto const actual = entry.getPreviousTxnID(); + expectEqualField(expected, actual, "sfPreviousTxnID"); + } + + { + auto const& expected = previousTxnLgrSeqValue; + auto const actual = entry.getPreviousTxnLgrSeq(); + expectEqualField(expected, actual, "sfPreviousTxnLgrSeq"); + } + + { + auto const& expected = sequenceValue; + auto const actual = entry.getSequence(); + expectEqualField(expected, actual, "sfSequence"); + } + + { + auto const& expected = ownerNodeValue; + auto const actual = entry.getOwnerNode(); + expectEqualField(expected, actual, "sfOwnerNode"); + } + + { + auto const& expected = accountValue; + auto const actual = entry.getAccount(); + expectEqualField(expected, actual, "sfAccount"); + } + + { + auto const& expected = destinationValue; + auto const actual = entry.getDestination(); + expectEqualField(expected, actual, "sfDestination"); + } + + { + auto const& expected = amountValue; + auto const actual = entry.getAmount(); + expectEqualField(expected, actual, "sfAmount"); + } + + { + auto const& expected = balanceValue; + auto const actual = entry.getBalance(); + expectEqualField(expected, actual, "sfBalance"); + } + + { + auto const& expected = frequencyValue; + auto const actual = entry.getFrequency(); + expectEqualField(expected, actual, "sfFrequency"); + } + + { + auto const& expected = nextClaimTimeValue; + auto const actual = entry.getNextClaimTime(); + expectEqualField(expected, actual, "sfNextClaimTime"); + } + + { + auto const& expected = destinationNodeValue; + auto const actual = entry.getDestinationNode(); + expectEqualField(expected, actual, "sfDestinationNode"); + } + + { + auto const& expected = destinationTagValue; + auto const actualOpt = entry.getDestinationTag(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfDestinationTag"); + EXPECT_TRUE(entry.hasDestinationTag()); + } + + { + auto const& expected = expirationValue; + auto const actualOpt = entry.getExpiration(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfExpiration"); + EXPECT_TRUE(entry.hasExpiration()); + } + + EXPECT_TRUE(entry.hasLedgerIndex()); + auto const ledgerIndex = entry.getLedgerIndex(); + ASSERT_TRUE(ledgerIndex.has_value()); + EXPECT_EQ(*ledgerIndex, index); + EXPECT_EQ(entry.getKey(), index); +} + +// 2 & 4) Start from an SLE, set fields directly on it, construct a builder +// from that SLE, build a new wrapper, and verify all fields (and validate()). +TEST(SubscriptionTests, BuilderFromSleRoundTrip) +{ + uint256 const index{2u}; + + auto const previousTxnIDValue = canonical_UINT256(); + auto const previousTxnLgrSeqValue = canonical_UINT32(); + auto const sequenceValue = canonical_UINT32(); + auto const ownerNodeValue = canonical_UINT64(); + auto const accountValue = canonical_ACCOUNT(); + auto const destinationValue = canonical_ACCOUNT(); + auto const destinationTagValue = canonical_UINT32(); + auto const amountValue = canonical_AMOUNT(); + auto const balanceValue = canonical_AMOUNT(); + auto const frequencyValue = canonical_UINT32(); + auto const nextClaimTimeValue = canonical_UINT32(); + auto const expirationValue = canonical_UINT32(); + auto const destinationNodeValue = canonical_UINT64(); + + auto sle = std::make_shared(Subscription::entryType, index); + + sle->at(sfPreviousTxnID) = previousTxnIDValue; + sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; + sle->at(sfSequence) = sequenceValue; + sle->at(sfOwnerNode) = ownerNodeValue; + sle->at(sfAccount) = accountValue; + sle->at(sfDestination) = destinationValue; + sle->at(sfDestinationTag) = destinationTagValue; + sle->at(sfAmount) = amountValue; + sle->at(sfBalance) = balanceValue; + sle->at(sfFrequency) = frequencyValue; + sle->at(sfNextClaimTime) = nextClaimTimeValue; + sle->at(sfExpiration) = expirationValue; + sle->at(sfDestinationNode) = destinationNodeValue; + + SubscriptionBuilder builderFromSle{sle}; + EXPECT_TRUE(builderFromSle.validate()); + + auto const entryFromBuilder = builderFromSle.build(index); + + Subscription entryFromSle{sle}; + EXPECT_TRUE(entryFromBuilder.validate()); + EXPECT_TRUE(entryFromSle.validate()); + + { + auto const& expected = previousTxnIDValue; + + auto const fromSle = entryFromSle.getPreviousTxnID(); + auto const fromBuilder = entryFromBuilder.getPreviousTxnID(); + + expectEqualField(expected, fromSle, "sfPreviousTxnID"); + expectEqualField(expected, fromBuilder, "sfPreviousTxnID"); + } + + { + auto const& expected = previousTxnLgrSeqValue; + + auto const fromSle = entryFromSle.getPreviousTxnLgrSeq(); + auto const fromBuilder = entryFromBuilder.getPreviousTxnLgrSeq(); + + expectEqualField(expected, fromSle, "sfPreviousTxnLgrSeq"); + expectEqualField(expected, fromBuilder, "sfPreviousTxnLgrSeq"); + } + + { + auto const& expected = sequenceValue; + + auto const fromSle = entryFromSle.getSequence(); + auto const fromBuilder = entryFromBuilder.getSequence(); + + expectEqualField(expected, fromSle, "sfSequence"); + expectEqualField(expected, fromBuilder, "sfSequence"); + } + + { + auto const& expected = ownerNodeValue; + + auto const fromSle = entryFromSle.getOwnerNode(); + auto const fromBuilder = entryFromBuilder.getOwnerNode(); + + expectEqualField(expected, fromSle, "sfOwnerNode"); + expectEqualField(expected, fromBuilder, "sfOwnerNode"); + } + + { + auto const& expected = accountValue; + + auto const fromSle = entryFromSle.getAccount(); + auto const fromBuilder = entryFromBuilder.getAccount(); + + expectEqualField(expected, fromSle, "sfAccount"); + expectEqualField(expected, fromBuilder, "sfAccount"); + } + + { + auto const& expected = destinationValue; + + auto const fromSle = entryFromSle.getDestination(); + auto const fromBuilder = entryFromBuilder.getDestination(); + + expectEqualField(expected, fromSle, "sfDestination"); + expectEqualField(expected, fromBuilder, "sfDestination"); + } + + { + auto const& expected = amountValue; + + auto const fromSle = entryFromSle.getAmount(); + auto const fromBuilder = entryFromBuilder.getAmount(); + + expectEqualField(expected, fromSle, "sfAmount"); + expectEqualField(expected, fromBuilder, "sfAmount"); + } + + { + auto const& expected = balanceValue; + + auto const fromSle = entryFromSle.getBalance(); + auto const fromBuilder = entryFromBuilder.getBalance(); + + expectEqualField(expected, fromSle, "sfBalance"); + expectEqualField(expected, fromBuilder, "sfBalance"); + } + + { + auto const& expected = frequencyValue; + + auto const fromSle = entryFromSle.getFrequency(); + auto const fromBuilder = entryFromBuilder.getFrequency(); + + expectEqualField(expected, fromSle, "sfFrequency"); + expectEqualField(expected, fromBuilder, "sfFrequency"); + } + + { + auto const& expected = nextClaimTimeValue; + + auto const fromSle = entryFromSle.getNextClaimTime(); + auto const fromBuilder = entryFromBuilder.getNextClaimTime(); + + expectEqualField(expected, fromSle, "sfNextClaimTime"); + expectEqualField(expected, fromBuilder, "sfNextClaimTime"); + } + + { + auto const& expected = destinationNodeValue; + + auto const fromSle = entryFromSle.getDestinationNode(); + auto const fromBuilder = entryFromBuilder.getDestinationNode(); + + expectEqualField(expected, fromSle, "sfDestinationNode"); + expectEqualField(expected, fromBuilder, "sfDestinationNode"); + } + + { + auto const& expected = destinationTagValue; + + auto const fromSleOpt = entryFromSle.getDestinationTag(); + auto const fromBuilderOpt = entryFromBuilder.getDestinationTag(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfDestinationTag"); + expectEqualField(expected, *fromBuilderOpt, "sfDestinationTag"); + } + + { + auto const& expected = expirationValue; + + auto const fromSleOpt = entryFromSle.getExpiration(); + auto const fromBuilderOpt = entryFromBuilder.getExpiration(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfExpiration"); + expectEqualField(expected, *fromBuilderOpt, "sfExpiration"); + } + + EXPECT_EQ(entryFromSle.getKey(), index); + EXPECT_EQ(entryFromBuilder.getKey(), index); +} + +// 3) Verify wrapper throws when constructed from wrong ledger entry type. +TEST(SubscriptionTests, WrapperThrowsOnWrongEntryType) +{ + uint256 const index{3u}; + + // Build a valid ledger entry of a different type + // Ticket requires: Account, OwnerNode, TicketSequence, PreviousTxnID, PreviousTxnLgrSeq + // Check requires: Account, Destination, SendMax, Sequence, OwnerNode, DestinationNode, PreviousTxnID, PreviousTxnLgrSeq + TicketBuilder wrongBuilder{ + canonical_ACCOUNT(), + canonical_UINT64(), + canonical_UINT32(), + canonical_UINT256(), + canonical_UINT32()}; + auto wrongEntry = wrongBuilder.build(index); + + EXPECT_THROW(Subscription{wrongEntry.getSle()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong ledger entry type. +TEST(SubscriptionTests, BuilderThrowsOnWrongEntryType) +{ + uint256 const index{4u}; + + // Build a valid ledger entry of a different type + TicketBuilder wrongBuilder{ + canonical_ACCOUNT(), + canonical_UINT64(), + canonical_UINT32(), + canonical_UINT256(), + canonical_UINT32()}; + auto wrongEntry = wrongBuilder.build(index); + + EXPECT_THROW(SubscriptionBuilder{wrongEntry.getSle()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(SubscriptionTests, OptionalFieldsReturnNullopt) +{ + uint256 const index{3u}; + + auto const previousTxnIDValue = canonical_UINT256(); + auto const previousTxnLgrSeqValue = canonical_UINT32(); + auto const sequenceValue = canonical_UINT32(); + auto const ownerNodeValue = canonical_UINT64(); + auto const accountValue = canonical_ACCOUNT(); + auto const destinationValue = canonical_ACCOUNT(); + auto const amountValue = canonical_AMOUNT(); + auto const balanceValue = canonical_AMOUNT(); + auto const frequencyValue = canonical_UINT32(); + auto const nextClaimTimeValue = canonical_UINT32(); + auto const destinationNodeValue = canonical_UINT64(); + + SubscriptionBuilder builder{ + previousTxnIDValue, + previousTxnLgrSeqValue, + sequenceValue, + ownerNodeValue, + accountValue, + destinationValue, + amountValue, + balanceValue, + frequencyValue, + nextClaimTimeValue, + destinationNodeValue + }; + + auto const entry = builder.build(index); + + // Verify optional fields are not present + EXPECT_FALSE(entry.hasDestinationTag()); + EXPECT_FALSE(entry.getDestinationTag().has_value()); + EXPECT_FALSE(entry.hasExpiration()); + EXPECT_FALSE(entry.getExpiration().has_value()); +} +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionCancelTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionCancelTests.cpp new file mode 100644 index 00000000000..84813adc5eb --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionCancelTests.cpp @@ -0,0 +1,146 @@ +// Auto-generated unit tests for transaction SubscriptionCancel + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsSubscriptionCancelTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testSubscriptionCancel")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const subscriptionIDValue = canonical_UINT256(); + + SubscriptionCancelBuilder builder{ + accountValue, + subscriptionIDValue, + sequenceValue, + feeValue + }; + + // Set optional fields + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = subscriptionIDValue; + auto const actual = tx.getSubscriptionID(); + expectEqualField(expected, actual, "sfSubscriptionID"); + } + + // Verify optional fields +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsSubscriptionCancelTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testSubscriptionCancelFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const subscriptionIDValue = canonical_UINT256(); + + // Build an initial transaction + SubscriptionCancelBuilder initialBuilder{ + accountValue, + subscriptionIDValue, + sequenceValue, + feeValue + }; + + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + SubscriptionCancelBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = subscriptionIDValue; + auto const actual = rebuiltTx.getSubscriptionID(); + expectEqualField(expected, actual, "sfSubscriptionID"); + } + + // Verify optional fields +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsSubscriptionCancelTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(SubscriptionCancel{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsSubscriptionCancelTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(SubscriptionCancelBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionClaimTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionClaimTests.cpp new file mode 100644 index 00000000000..77b74777c41 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionClaimTests.cpp @@ -0,0 +1,162 @@ +// Auto-generated unit tests for transaction SubscriptionClaim + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsSubscriptionClaimTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testSubscriptionClaim")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const amountValue = canonical_AMOUNT(); + auto const subscriptionIDValue = canonical_UINT256(); + + SubscriptionClaimBuilder builder{ + accountValue, + amountValue, + subscriptionIDValue, + sequenceValue, + feeValue + }; + + // Set optional fields + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = amountValue; + auto const actual = tx.getAmount(); + expectEqualField(expected, actual, "sfAmount"); + } + + { + auto const& expected = subscriptionIDValue; + auto const actual = tx.getSubscriptionID(); + expectEqualField(expected, actual, "sfSubscriptionID"); + } + + // Verify optional fields +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsSubscriptionClaimTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testSubscriptionClaimFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const amountValue = canonical_AMOUNT(); + auto const subscriptionIDValue = canonical_UINT256(); + + // Build an initial transaction + SubscriptionClaimBuilder initialBuilder{ + accountValue, + amountValue, + subscriptionIDValue, + sequenceValue, + feeValue + }; + + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + SubscriptionClaimBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = amountValue; + auto const actual = rebuiltTx.getAmount(); + expectEqualField(expected, actual, "sfAmount"); + } + + { + auto const& expected = subscriptionIDValue; + auto const actual = rebuiltTx.getSubscriptionID(); + expectEqualField(expected, actual, "sfSubscriptionID"); + } + + // Verify optional fields +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsSubscriptionClaimTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(SubscriptionClaim{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsSubscriptionClaimTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(SubscriptionClaimBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + + +} diff --git a/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionSetTests.cpp new file mode 100644 index 00000000000..e6029b8eb21 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/SubscriptionSetTests.cpp @@ -0,0 +1,300 @@ +// Auto-generated unit tests for transaction SubscriptionSet + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsSubscriptionSetTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testSubscriptionSet")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const destinationValue = canonical_ACCOUNT(); + auto const amountValue = canonical_AMOUNT(); + auto const frequencyValue = canonical_UINT32(); + auto const startTimeValue = canonical_UINT32(); + auto const expirationValue = canonical_UINT32(); + auto const destinationTagValue = canonical_UINT32(); + auto const subscriptionIDValue = canonical_UINT256(); + + SubscriptionSetBuilder builder{ + accountValue, + amountValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setDestination(destinationValue); + builder.setFrequency(frequencyValue); + builder.setStartTime(startTimeValue); + builder.setExpiration(expirationValue); + builder.setDestinationTag(destinationTagValue); + builder.setSubscriptionID(subscriptionIDValue); + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = amountValue; + auto const actual = tx.getAmount(); + expectEqualField(expected, actual, "sfAmount"); + } + + // Verify optional fields + { + auto const& expected = destinationValue; + auto const actualOpt = tx.getDestination(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestination should be present"; + expectEqualField(expected, *actualOpt, "sfDestination"); + EXPECT_TRUE(tx.hasDestination()); + } + + { + auto const& expected = frequencyValue; + auto const actualOpt = tx.getFrequency(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFrequency should be present"; + expectEqualField(expected, *actualOpt, "sfFrequency"); + EXPECT_TRUE(tx.hasFrequency()); + } + + { + auto const& expected = startTimeValue; + auto const actualOpt = tx.getStartTime(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfStartTime should be present"; + expectEqualField(expected, *actualOpt, "sfStartTime"); + EXPECT_TRUE(tx.hasStartTime()); + } + + { + auto const& expected = expirationValue; + auto const actualOpt = tx.getExpiration(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfExpiration should be present"; + expectEqualField(expected, *actualOpt, "sfExpiration"); + EXPECT_TRUE(tx.hasExpiration()); + } + + { + auto const& expected = destinationTagValue; + auto const actualOpt = tx.getDestinationTag(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestinationTag should be present"; + expectEqualField(expected, *actualOpt, "sfDestinationTag"); + EXPECT_TRUE(tx.hasDestinationTag()); + } + + { + auto const& expected = subscriptionIDValue; + auto const actualOpt = tx.getSubscriptionID(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionID should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionID"); + EXPECT_TRUE(tx.hasSubscriptionID()); + } + +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsSubscriptionSetTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testSubscriptionSetFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const destinationValue = canonical_ACCOUNT(); + auto const amountValue = canonical_AMOUNT(); + auto const frequencyValue = canonical_UINT32(); + auto const startTimeValue = canonical_UINT32(); + auto const expirationValue = canonical_UINT32(); + auto const destinationTagValue = canonical_UINT32(); + auto const subscriptionIDValue = canonical_UINT256(); + + // Build an initial transaction + SubscriptionSetBuilder initialBuilder{ + accountValue, + amountValue, + sequenceValue, + feeValue + }; + + initialBuilder.setDestination(destinationValue); + initialBuilder.setFrequency(frequencyValue); + initialBuilder.setStartTime(startTimeValue); + initialBuilder.setExpiration(expirationValue); + initialBuilder.setDestinationTag(destinationTagValue); + initialBuilder.setSubscriptionID(subscriptionIDValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + SubscriptionSetBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = amountValue; + auto const actual = rebuiltTx.getAmount(); + expectEqualField(expected, actual, "sfAmount"); + } + + // Verify optional fields + { + auto const& expected = destinationValue; + auto const actualOpt = rebuiltTx.getDestination(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestination should be present"; + expectEqualField(expected, *actualOpt, "sfDestination"); + } + + { + auto const& expected = frequencyValue; + auto const actualOpt = rebuiltTx.getFrequency(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFrequency should be present"; + expectEqualField(expected, *actualOpt, "sfFrequency"); + } + + { + auto const& expected = startTimeValue; + auto const actualOpt = rebuiltTx.getStartTime(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfStartTime should be present"; + expectEqualField(expected, *actualOpt, "sfStartTime"); + } + + { + auto const& expected = expirationValue; + auto const actualOpt = rebuiltTx.getExpiration(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfExpiration should be present"; + expectEqualField(expected, *actualOpt, "sfExpiration"); + } + + { + auto const& expected = destinationTagValue; + auto const actualOpt = rebuiltTx.getDestinationTag(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfDestinationTag should be present"; + expectEqualField(expected, *actualOpt, "sfDestinationTag"); + } + + { + auto const& expected = subscriptionIDValue; + auto const actualOpt = rebuiltTx.getSubscriptionID(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionID should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionID"); + } + +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsSubscriptionSetTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(SubscriptionSet{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsSubscriptionSetTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(SubscriptionSetBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsSubscriptionSetTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testSubscriptionSetNullopt")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 3; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific required field values + auto const amountValue = canonical_AMOUNT(); + + SubscriptionSetBuilder builder{ + accountValue, + amountValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasDestination()); + EXPECT_FALSE(tx.getDestination().has_value()); + EXPECT_FALSE(tx.hasFrequency()); + EXPECT_FALSE(tx.getFrequency().has_value()); + EXPECT_FALSE(tx.hasStartTime()); + EXPECT_FALSE(tx.getStartTime().has_value()); + EXPECT_FALSE(tx.hasExpiration()); + EXPECT_FALSE(tx.getExpiration().has_value()); + EXPECT_FALSE(tx.hasDestinationTag()); + EXPECT_FALSE(tx.getDestinationTag().has_value()); + EXPECT_FALSE(tx.hasSubscriptionID()); + EXPECT_FALSE(tx.getSubscriptionID().has_value()); +} + +} diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 5271720b34f..c9bbc786960 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -751,6 +751,32 @@ parseSponsorship( return keylet::sponsorship(*sponsorID, *sponseeID).key; } +static std::expected +parseSubscription( + json::Value const& params, + json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + if (!params.isObject()) + return parseObjectID(params, fieldName); + + auto const account = + ledger_entry_helpers::requiredAccountID(params, jss::account, "malformedAccount"); + if (!account) + return std::unexpected(account.error()); + + auto const destination = + ledger_entry_helpers::requiredAccountID(params, jss::destination, "malformedDestination"); + if (!destination) + return std::unexpected(destination.error()); + + auto const seq = ledger_entry_helpers::requiredUInt32(params, jss::seq, "malformedRequest"); + if (!seq) + return std::unexpected(seq.error()); + + return keylet::subscription(*account, *destination, *seq).key; +} + static std::expected parseTicket( json::Value const& params,