From cff54dfb6899bc6e91fa3fa1800ea32c44312fda Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Sat, 18 Jul 2026 12:00:52 -0400 Subject: [PATCH] feat: add CouponPayments amendment --- include/xrpl/ledger/helpers/CouponHelpers.h | 86 +++ include/xrpl/protocol/Indexes.h | 18 + include/xrpl/protocol/detail/features.macro | 1 + .../xrpl/protocol/detail/ledger_entries.macro | 38 ++ include/xrpl/protocol/detail/sfields.macro | 11 + .../xrpl/protocol/detail/transactions.macro | 84 +++ include/xrpl/protocol/jss.h | 1 + .../xrpl/tx/transactors/coupon/CouponClaim.h | 43 ++ .../tx/transactors/coupon/CouponRegister.h | 43 ++ .../transactors/coupon/CouponScheduleCreate.h | 43 ++ .../transactors/coupon/CouponScheduleDelete.h | 43 ++ .../tx/transactors/coupon/CouponScheduleSet.h | 43 ++ .../tx/transactors/coupon/CouponUnregister.h | 43 ++ src/libxrpl/ledger/helpers/CouponHelpers.cpp | 256 ++++++++ src/libxrpl/protocol/Indexes.cpp | 29 + .../tx/transactors/coupon/CouponClaim.cpp | 185 ++++++ .../tx/transactors/coupon/CouponRegister.cpp | 132 ++++ .../coupon/CouponScheduleCreate.cpp | 167 +++++ .../coupon/CouponScheduleDelete.cpp | 79 +++ .../transactors/coupon/CouponScheduleSet.cpp | 100 +++ .../transactors/coupon/CouponUnregister.cpp | 116 ++++ src/test/app/Coupon_test.cpp | 588 ++++++++++++++++++ src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp | 47 ++ 23 files changed, 2196 insertions(+) create mode 100644 include/xrpl/ledger/helpers/CouponHelpers.h create mode 100644 include/xrpl/tx/transactors/coupon/CouponClaim.h create mode 100644 include/xrpl/tx/transactors/coupon/CouponRegister.h create mode 100644 include/xrpl/tx/transactors/coupon/CouponScheduleCreate.h create mode 100644 include/xrpl/tx/transactors/coupon/CouponScheduleDelete.h create mode 100644 include/xrpl/tx/transactors/coupon/CouponScheduleSet.h create mode 100644 include/xrpl/tx/transactors/coupon/CouponUnregister.h create mode 100644 src/libxrpl/ledger/helpers/CouponHelpers.cpp create mode 100644 src/libxrpl/tx/transactors/coupon/CouponClaim.cpp create mode 100644 src/libxrpl/tx/transactors/coupon/CouponRegister.cpp create mode 100644 src/libxrpl/tx/transactors/coupon/CouponScheduleCreate.cpp create mode 100644 src/libxrpl/tx/transactors/coupon/CouponScheduleDelete.cpp create mode 100644 src/libxrpl/tx/transactors/coupon/CouponScheduleSet.cpp create mode 100644 src/libxrpl/tx/transactors/coupon/CouponUnregister.cpp create mode 100644 src/test/app/Coupon_test.cpp diff --git a/include/xrpl/ledger/helpers/CouponHelpers.h b/include/xrpl/ledger/helpers/CouponHelpers.h new file mode 100644 index 00000000000..810604cc403 --- /dev/null +++ b/include/xrpl/ledger/helpers/CouponHelpers.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +/** + * Settle coupon accrual on a registration. + * + * Computes the coupon dates elapsed since the registration's + * LastSettledTime (strictly before the schedule's Expiration, if any), + * adds count * RegisteredUnits * CouponAmount to AccruedAmount — + * rounded once, downward, to the coupon asset's precision — and + * advances LastSettledTime to the latest elapsed date. O(1) in the + * number of elapsed dates. The caller must view.update() the + * registration on tesSUCCESS. + */ +[[nodiscard]] TER +couponSettle( + SLE::const_ref schedule, + SLE::ref registration, + std::uint32_t parentCloseTime, + beast::Journal j); + +/** + * Eligibility for locking bond units into a registration. + * + * Mirrors the token Escrow lock gates: issuer opt-in + * (lsfAllowTrustLineLocking / lsfMPTCanEscrow), authorization, freeze + * and lock state, and the holder's spendable balance. + */ +[[nodiscard]] TER +couponLockPreclaim( + ReadView const& view, + AccountID const& holder, + STAmount const& units, + beast::Journal j); + +/** + * Lock bond units: IOU balances park on the bond issuer's trust line, + * MPT amounts move into sfLockedAmount — the token Escrow mechanics. + */ +[[nodiscard]] TER +couponLockUnits(ApplyView& view, AccountID const& holder, STAmount const& units, beast::Journal j); + +/** + * Release locked bond units back to the holder's spendable balance. + * No transfer fee applies (round trip to self). + */ +[[nodiscard]] TER +couponUnlockUnits( + ApplyViewContext ctx, + SLE::ref sleHolder, + XRPAmount preFeeBalance, + STAmount const& units, + AccountID const& holder, + beast::Journal j); + +/** + * Delete a registration if its RegisteredUnits and AccruedAmount are + * both zero: remove it from the holder's directory, refund the owner + * reserve, and decrement the schedule's RegistrationCount. The caller + * must view.update() the schedule when a deletion is reported. + * + * @return std::nullopt if the registration is not empty (not deleted); + * tesSUCCESS if it was deleted; an error TER on failure. + */ +[[nodiscard]] std::optional +couponDeleteRegistrationIfEmpty( + ApplyView& view, + SLE::ref schedule, + SLE::ref registration, + AccountID const& holder, + beast::Journal j); + +} // namespace xrpl diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 07493da0bd6..d8cef8cbf5b 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -388,6 +388,24 @@ loan(uint256 const& key) return {ltLOAN, key}; } +Keylet +couponSchedule(AccountID const& issuer, Asset const& bondAsset) noexcept; + +inline Keylet +couponSchedule(uint256 const& scheduleKey) +{ + return {ltCOUPON_SCHEDULE, scheduleKey}; +} + +Keylet +couponRegistration(uint256 const& couponScheduleID, AccountID const& holder) noexcept; + +inline Keylet +couponRegistration(uint256 const& key) +{ + return {ltCOUPON_REGISTRATION, key}; +} + Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index bfe03a63037..372cb95ca0b 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(CouponPayments, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Sponsor, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(BatchV1_1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocolV1_1, Supported::No, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2d..bfefffdde44 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -621,6 +621,44 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ {sfLoanScale, SoeDefault}, })) +/** A ledger object representing a bond coupon schedule: the issuer's + standing offer of fixed periodic coupon payments per unit of a bond + asset, in a settlement asset. One schedule per (Account, BondAsset). + + \sa keylet::couponSchedule + */ +LEDGER_ENTRY(ltCOUPON_SCHEDULE, 0x008A, CouponSchedule, coupon_schedule, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfAccount, SoeRequired}, + {sfBondAsset, SoeRequired}, + {sfCouponAmount, SoeRequired}, + {sfCouponInterval, SoeRequired}, + {sfFirstCouponTime, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfCallNoticePeriod, SoeOptional}, + {sfEarliestCallTime, SoeOptional}, + {sfRegistrationCount, SoeDefault}, +})) + +/** A ledger object representing a holder's registration into a coupon + schedule: locked bond units accruing coupons, and coupons accrued + but not yet claimed. One registration per (CouponSchedule, Holder). + + \sa keylet::couponRegistration + */ +LEDGER_ENTRY(ltCOUPON_REGISTRATION, 0x008B, CouponRegistration, coupon_registration, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfAccount, SoeRequired}, + {sfCouponScheduleID, SoeRequired}, + {sfRegisteredUnits, SoeRequired}, + {sfAccruedAmount, SoeRequired}, + {sfLastSettledTime, 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 4ef76c8b759..2605dcf092a 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -119,6 +119,12 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfCouponInterval, UINT32, 75) +TYPED_SFIELD(sfFirstCouponTime, UINT32, 76) +TYPED_SFIELD(sfCallNoticePeriod, UINT32, 77) +TYPED_SFIELD(sfEarliestCallTime, UINT32, 78) +TYPED_SFIELD(sfLastSettledTime, UINT32, 79) +TYPED_SFIELD(sfRegistrationCount, UINT32, 80) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -216,6 +222,7 @@ TYPED_SFIELD(sfLoanID, UINT256, 38) TYPED_SFIELD(sfReferenceHolding, UINT256, 39) TYPED_SFIELD(sfBlindingFactor, UINT256, 40) TYPED_SFIELD(sfObjectID, UINT256, 41) +TYPED_SFIELD(sfCouponScheduleID, UINT256, 42) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) @@ -277,6 +284,9 @@ TYPED_SFIELD(sfMinAccountCreateAmount, AMOUNT, 30) TYPED_SFIELD(sfLPTokenBalance, AMOUNT, 31) TYPED_SFIELD(sfFeeAmount, AMOUNT, 32) TYPED_SFIELD(sfMaxFee, AMOUNT, 33) +TYPED_SFIELD(sfCouponAmount, AMOUNT, 34) +TYPED_SFIELD(sfRegisteredUnits, AMOUNT, 35) +TYPED_SFIELD(sfAccruedAmount, AMOUNT, 36) // variable length (common) TYPED_SFIELD(sfPublicKey, VL, 1) @@ -377,6 +387,7 @@ TYPED_SFIELD(sfLockingChainIssue, ISSUE, 1) TYPED_SFIELD(sfIssuingChainIssue, ISSUE, 2) TYPED_SFIELD(sfAsset, ISSUE, 3) TYPED_SFIELD(sfAsset2, ISSUE, 4) +TYPED_SFIELD(sfBondAsset, ISSUE, 5) // bridge TYPED_SFIELD(sfXChainBridge, XCHAIN_BRIDGE, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c008..11b23256950 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1194,6 +1194,90 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCount, SoeOptional}, })) +/** This transaction creates a coupon schedule for a bond asset. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCOUPON_SCHEDULE_CREATE, 92, CouponScheduleCreate, + Delegation::NotDelegable, + featureCouponPayments, + NoPriv, + ({ + {sfBondAsset, SoeRequired}, + {sfCouponAmount, SoeRequired, SoeMptSupported}, + {sfCouponInterval, SoeRequired}, + {sfFirstCouponTime, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfCallNoticePeriod, SoeOptional}, + {sfEarliestCallTime, SoeOptional}, +})) + +/** This transaction exercises the issuer's call option on a callable + coupon schedule by shortening its Expiration. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCOUPON_SCHEDULE_SET, 93, CouponScheduleSet, + Delegation::NotDelegable, + featureCouponPayments, + NoPriv, + ({ + {sfCouponScheduleID, SoeRequired}, + {sfExpiration, SoeRequired}, +})) + +/** This transaction deletes an empty coupon schedule. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCOUPON_SCHEDULE_DELETE, 94, CouponScheduleDelete, + Delegation::NotDelegable, + featureCouponPayments, + NoPriv, + ({ + {sfCouponScheduleID, SoeRequired}, +})) + +/** This transaction locks bond units into a coupon registration. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCOUPON_REGISTER, 95, CouponRegister, + Delegation::NotDelegable, + featureCouponPayments, + NoPriv, + ({ + {sfCouponScheduleID, SoeRequired}, + {sfAmount, SoeRequired, SoeMptSupported}, +})) + +/** This transaction releases bond units from a coupon registration. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCOUPON_UNREGISTER, 96, CouponUnregister, + Delegation::NotDelegable, + featureCouponPayments, + NoPriv, + ({ + {sfCouponScheduleID, SoeRequired}, + {sfAmount, SoeOptional, SoeMptSupported}, +})) + +/** This transaction claims accrued coupons, pulling payment from the + schedule issuer's live balance. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttCOUPON_CLAIM, 97, CouponClaim, + Delegation::Delegable, + featureCouponPayments, + NoPriv, + ({ + {sfCouponScheduleID, SoeRequired}, + {sfAmount, SoeOptional, SoeMptSupported}, +})) + /** 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..ebd3e77cf15 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -367,6 +367,7 @@ JSS(load_factor_local); // out: NetworkOPs JSS(load_factor_net); // out: NetworkOPs JSS(load_factor_server); // out: NetworkOPs JSS(load_fee); // out: LoadFeeTrackImp, NetworkOPs +JSS(coupon_schedule_id); // in: LedgerEntry JSS(loan_broker_id); // in: LedgerEntry JSS(loan_seq); // in: LedgerEntry JSS(local); // out: resource/Logic.h diff --git a/include/xrpl/tx/transactors/coupon/CouponClaim.h b/include/xrpl/tx/transactors/coupon/CouponClaim.h new file mode 100644 index 00000000000..1be22e0c5b0 --- /dev/null +++ b/include/xrpl/tx/transactors/coupon/CouponClaim.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class CouponClaim : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit CouponClaim(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/coupon/CouponRegister.h b/include/xrpl/tx/transactors/coupon/CouponRegister.h new file mode 100644 index 00000000000..2049af64287 --- /dev/null +++ b/include/xrpl/tx/transactors/coupon/CouponRegister.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class CouponRegister : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit CouponRegister(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/coupon/CouponScheduleCreate.h b/include/xrpl/tx/transactors/coupon/CouponScheduleCreate.h new file mode 100644 index 00000000000..55abf9cfb0b --- /dev/null +++ b/include/xrpl/tx/transactors/coupon/CouponScheduleCreate.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class CouponScheduleCreate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit CouponScheduleCreate(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/coupon/CouponScheduleDelete.h b/include/xrpl/tx/transactors/coupon/CouponScheduleDelete.h new file mode 100644 index 00000000000..98eaa95ae0e --- /dev/null +++ b/include/xrpl/tx/transactors/coupon/CouponScheduleDelete.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class CouponScheduleDelete : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit CouponScheduleDelete(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/coupon/CouponScheduleSet.h b/include/xrpl/tx/transactors/coupon/CouponScheduleSet.h new file mode 100644 index 00000000000..ce58f545e70 --- /dev/null +++ b/include/xrpl/tx/transactors/coupon/CouponScheduleSet.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class CouponScheduleSet : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit CouponScheduleSet(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/coupon/CouponUnregister.h b/include/xrpl/tx/transactors/coupon/CouponUnregister.h new file mode 100644 index 00000000000..614b4143934 --- /dev/null +++ b/include/xrpl/tx/transactors/coupon/CouponUnregister.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class CouponUnregister : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit CouponUnregister(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/CouponHelpers.cpp b/src/libxrpl/ledger/helpers/CouponHelpers.cpp new file mode 100644 index 00000000000..89540f463f9 --- /dev/null +++ b/src/libxrpl/ledger/helpers/CouponHelpers.cpp @@ -0,0 +1,256 @@ +#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 { + +TER +couponSettle( + SLE::const_ref schedule, + SLE::ref registration, + std::uint32_t parentCloseTime, + beast::Journal j) +{ + std::uint32_t const first = schedule->at(sfFirstCouponTime); + std::uint32_t const interval = schedule->at(sfCouponInterval); + auto const expiration = schedule->at(~sfExpiration); + std::uint32_t const lastSettled = registration->at(sfLastSettledTime); + + // Coupon dates occur strictly before Expiration. + std::int64_t effectiveNow = parentCloseTime; + if (expiration && static_cast(*expiration) <= effectiveNow) + effectiveNow = static_cast(*expiration) - 1; + + if (effectiveNow < static_cast(first)) + return tesSUCCESS; + + // Coupon dates are first + k * interval. Work in date indices so a + // LastSettledTime before the first date (the "nothing settled yet" + // state) needs no sentinel arithmetic. + std::int64_t const latestK = (effectiveNow - static_cast(first)) / interval; + std::int64_t const lastK = + lastSettled < first ? -1 : static_cast((lastSettled - first) / interval); + + if (latestK <= lastK) + return tesSUCCESS; + + std::int64_t const count = latestK - lastK; + STAmount const units = registration->at(sfRegisteredUnits); + if (units != beast::kZero) + { + STAmount const couponAmount = schedule->at(sfCouponAmount); + STAmount const accrued = registration->at(sfAccruedAmount); + + NumberRoundModeGuard const mg(Number::RoundingMode::Downward); + Number const addition = Number(count) * Number(units) * Number(couponAmount); + STAmount const additionAmt{couponAmount.asset(), addition}; + + if (!canAdd(accrued, additionAmt)) + { + JLOG(j.debug()) << "couponSettle: accrual addition loses precision."; + return tecPRECISION_LOSS; + } + registration->at(sfAccruedAmount) = accrued + additionAmt; + } + registration->at(sfLastSettledTime) = + static_cast(static_cast(first) + latestK * interval); + + return tesSUCCESS; +} + +template +static TER +couponLockPreclaimHelper( + ReadView const& view, + AccountID const& holder, + STAmount const& units, + beast::Journal j); + +template <> +TER +couponLockPreclaimHelper( + ReadView const& view, + AccountID const& holder, + STAmount const& units, + beast::Journal j) +{ + auto const& issue = units.get(); + AccountID const& issuer = units.getIssuer(); + if (issuer == holder) + return tecNO_PERMISSION; + + auto const sleIssuer = view.read(keylet::account(issuer)); + if (!sleIssuer) + return tecNO_ISSUER; + if (!sleIssuer->isFlag(lsfAllowTrustLineLocking)) + return tecNO_PERMISSION; + + auto const sleRippleState = view.read(keylet::trustLine(holder, issuer, issue.currency)); + if (!sleRippleState) + return tecNO_LINE; + + if (auto const ter = requireAuth(view, issue, holder); !isTesSuccess(ter)) + return ter; + + if (isFrozen(view, holder, issue)) + return tecFROZEN; + + STAmount const spendable = + accountHolds(view, holder, issue.currency, issuer, FreezeHandling::IgnoreFreeze, j); + if (spendable <= beast::kZero || spendable < units) + return tecINSUFFICIENT_FUNDS; + + if (!canAdd(spendable, units)) + return tecPRECISION_LOSS; + + return tesSUCCESS; +} + +template <> +TER +couponLockPreclaimHelper( + ReadView const& view, + AccountID const& holder, + STAmount const& units, + beast::Journal j) +{ + AccountID const issuer = units.getIssuer(); + if (issuer == holder) + return tecNO_PERMISSION; + + auto const& mptIssue = units.get(); + auto const issuanceKey = keylet::mptokenIssuance(mptIssue.getMptID()); + auto const sleIssuance = view.read(issuanceKey); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanEscrow)) + return tecNO_PERMISSION; + + if (!view.exists(keylet::mptoken(issuanceKey.key, holder))) + return tecOBJECT_NOT_FOUND; + + if (auto const ter = requireAuth(view, mptIssue, holder, AuthType::WeakAuth); + !isTesSuccess(ter)) + return ter; + + if (isFrozen(view, holder, mptIssue)) + return tecLOCKED; + + if (auto const ter = canTransfer(view, mptIssue, holder, issuer); !isTesSuccess(ter)) + return ter; + + STAmount const spendable = accountHolds( + view, holder, mptIssue, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j); + if (spendable <= beast::kZero || spendable < units) + return tecINSUFFICIENT_FUNDS; + + return tesSUCCESS; +} + +TER +couponLockPreclaim( + ReadView const& view, + AccountID const& holder, + STAmount const& units, + beast::Journal j) +{ + return std::visit( + [&](T const&) { return couponLockPreclaimHelper(view, holder, units, j); }, + units.asset().value()); +} + +TER +couponLockUnits(ApplyView& view, AccountID const& holder, STAmount const& units, beast::Journal j) +{ + AccountID const issuer = units.getIssuer(); + if (issuer == holder) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (units.holds()) + return lockEscrowMPT(view, holder, units, j); + + return directSendNoFee(view, holder, issuer, units, true, j); +} + +TER +couponUnlockUnits( + ApplyViewContext ctx, + SLE::ref sleHolder, + XRPAmount preFeeBalance, + STAmount const& units, + AccountID const& holder, + beast::Journal j) +{ + return std::visit( + [&](T const&) { + return escrowUnlockApplyHelper( + ctx, + kParityRate, + sleHolder, + preFeeBalance, + units, + units.getIssuer(), + holder, // sender and receiver are the same + holder, + true, + j); + }, + units.asset().value()); +} + +std::optional +couponDeleteRegistrationIfEmpty( + ApplyView& view, + SLE::ref schedule, + SLE::ref registration, + AccountID const& holder, + beast::Journal j) +{ + STAmount const units = registration->at(sfRegisteredUnits); + STAmount const accrued = registration->at(sfAccruedAmount); + if (units != beast::kZero || accrued != beast::kZero) + return std::nullopt; + + if (!view.dirRemove( + keylet::ownerDir(holder), registration->at(sfOwnerNode), registration->key(), false)) + { + // LCOV_EXCL_START + JLOG(j.fatal()) << "couponDeleteRegistrationIfEmpty: failed to remove dir link."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + decreaseOwnerCount(view, holder, std::nullopt, 1, j); + view.erase(registration); + + std::uint32_t const regCount = schedule->at(sfRegistrationCount); + if (regCount == 0) + return tefINTERNAL; // LCOV_EXCL_LINE + schedule->at(sfRegistrationCount) = regCount - 1; + + return tesSUCCESS; +} + +} // namespace xrpl diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 95416d0f2ac..a7316b60dd0 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -104,6 +104,8 @@ enum class LedgerNameSpace : std::uint16_t { LoanBroker = 'l', // lower-case L Loan = 'L', Sponsorship = '>', + CouponSchedule = 'b', + CouponRegistration = 'y', // No longer used or supported. Left here to reserve the space to avoid accidental reuse. Contract [[deprecated]] = 'c', @@ -599,6 +601,33 @@ loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept return loan(indexHash(LedgerNameSpace::Loan, loanBrokerID, loanSeq)); } +Keylet +couponSchedule(AccountID const& issuer, Asset const& bondAsset) noexcept +{ + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + { + return couponSchedule(indexHash( + LedgerNameSpace::CouponSchedule, issuer, issue.account, issue.currency)); + } + else + { + return couponSchedule( + indexHash(LedgerNameSpace::CouponSchedule, issuer, issue.getMptID())); + } + }, + bondAsset.value()); +} + +Keylet +couponRegistration(uint256 const& couponScheduleID, AccountID const& holder) noexcept +{ + return { + ltCOUPON_REGISTRATION, + indexHash(LedgerNameSpace::CouponRegistration, couponScheduleID, holder)}; +} + Keylet permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept { diff --git a/src/libxrpl/tx/transactors/coupon/CouponClaim.cpp b/src/libxrpl/tx/transactors/coupon/CouponClaim.cpp new file mode 100644 index 00000000000..0bd36adb6bc --- /dev/null +++ b/src/libxrpl/tx/transactors/coupon/CouponClaim.cpp @@ -0,0 +1,185 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +NotTEC +CouponClaim::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfCouponScheduleID] == beast::kZero) + return temMALFORMED; + + if (auto const amount = ctx.tx[~sfAmount]; amount && *amount <= beast::kZero) + return temBAD_AMOUNT; + + return tesSUCCESS; +} + +TER +CouponClaim::preclaim(PreclaimContext const& ctx) +{ + auto const scheduleID = ctx.tx[sfCouponScheduleID]; + AccountID const holder = ctx.tx[sfAccount]; + + auto const registration = ctx.view.read(keylet::couponRegistration(scheduleID, holder)); + if (!registration) + return tecNO_ENTRY; + + auto const schedule = ctx.view.read(keylet::couponSchedule(scheduleID)); + if (!schedule) + return tefINTERNAL; // LCOV_EXCL_LINE + + STAmount const couponAmount = schedule->at(sfCouponAmount); + auto const& couponAsset = couponAmount.asset(); + + if (auto const amount = ctx.tx[~sfAmount]; amount && amount->asset() != couponAsset) + return tecWRONG_ASSET; + + if (!couponAsset.native()) + { + AccountID const payer = schedule->at(sfAccount); + bool const mpt = couponAsset.holds(); + + // Coupon-asset eligibility on both sides. + for (auto const& party : {payer, holder}) + { + if (party == couponAsset.getIssuer()) + continue; + if (auto const ter = requireAuth(ctx.view, couponAsset, party); !isTesSuccess(ter)) + return ter; + if (isFrozen(ctx.view, party, couponAsset)) + return mpt ? tecLOCKED : tecFROZEN; + } + } + + return tesSUCCESS; +} + +TER +CouponClaim::doApply() +{ + auto const& tx = ctx_.tx; + auto const scheduleID = tx[sfCouponScheduleID]; + + auto schedule = view().peek(keylet::couponSchedule(scheduleID)); + if (!schedule) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto registration = view().peek(keylet::couponRegistration(scheduleID, accountID_)); + if (!registration) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const now = view().header().parentCloseTime.time_since_epoch().count(); + if (auto const ter = couponSettle(schedule, registration, static_cast(now), j_); + !isTesSuccess(ter)) + return ter; + + STAmount const accrued = registration->at(sfAccruedAmount); + if (accrued <= beast::kZero) + return tecNO_PERMISSION; + + STAmount const requested = tx[~sfAmount].value_or(accrued); + STAmount const pay = std::min(requested, accrued); + + AccountID const payer = schedule->at(sfAccount); + auto const& couponAsset = pay.asset(); + + // The Issuer does not escrow coupons: claims pull from the live + // balance. A failed claim is the visible, timestamped record that + // the coupon was presented and not honored. + if (couponAsset.native()) + { + if (xrpLiquid(view(), payer, 0, j_) < pay.xrp()) + return tecINSUFFICIENT_FUNDS; + } + else if (payer != couponAsset.getIssuer()) + { + STAmount const spendable = std::visit( + [&](T const& issue) { + if constexpr (std::is_same_v) + return accountHolds( + view(), + payer, + issue.currency, + issue.account, + FreezeHandling::ZeroIfFrozen, + j_); + else + return accountHolds( + view(), + payer, + issue, + FreezeHandling::ZeroIfFrozen, + AuthHandling::IgnoreAuth, + j_); + }, + couponAsset.value()); + if (spendable < pay) + return tecINSUFFICIENT_FUNDS; + } + + // Reserve-gated auto-creation of the holder's trust line / MPToken. + if (!couponAsset.native() && accountID_ != couponAsset.getIssuer()) + { + auto const ter = std::visit( + [&](T const& issue) { + return addEmptyHolding( + ctx_.getApplyViewContext(), accountID_, preFeeBalance_, issue, j_); + }, + couponAsset.value()); + // An existing holding is fine. + if (!isTesSuccess(ter) && ter != tecDUPLICATE) + return ter; + } + + if (auto const ter = accountSend(view(), payer, accountID_, pay, j_); !isTesSuccess(ter)) + return ter; + + registration->at(sfAccruedAmount) = accrued - pay; + + if (auto const deleted = + couponDeleteRegistrationIfEmpty(view(), schedule, registration, accountID_, j_)) + { + if (!isTesSuccess(*deleted)) + return *deleted; // LCOV_EXCL_LINE + view().update(schedule); + } + else + { + view().update(registration); + } + + return tesSUCCESS; +} + +void +CouponClaim::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +CouponClaim::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/coupon/CouponRegister.cpp b/src/libxrpl/tx/transactors/coupon/CouponRegister.cpp new file mode 100644 index 00000000000..19826b72328 --- /dev/null +++ b/src/libxrpl/tx/transactors/coupon/CouponRegister.cpp @@ -0,0 +1,132 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +CouponRegister::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfCouponScheduleID] == beast::kZero) + return temMALFORMED; + + STAmount const units = ctx.tx[sfAmount]; + if (units <= beast::kZero || units.native()) + return temBAD_AMOUNT; + + return tesSUCCESS; +} + +TER +CouponRegister::preclaim(PreclaimContext const& ctx) +{ + auto const schedule = ctx.view.read(keylet::couponSchedule(ctx.tx[sfCouponScheduleID])); + if (!schedule) + return tecNO_ENTRY; + + STAmount const units = ctx.tx[sfAmount]; + if (units.asset() != schedule->at(sfBondAsset)) + return tecWRONG_ASSET; + + // Registering into a schedule with no future coupon dates is always + // a mistake. + auto const now = ctx.view.header().parentCloseTime.time_since_epoch().count(); + if (auto const expiration = schedule->at(~sfExpiration); + expiration && *expiration <= static_cast(now)) + return tecEXPIRED; + + return couponLockPreclaim(ctx.view, ctx.tx[sfAccount], units, ctx.j); +} + +TER +CouponRegister::doApply() +{ + auto const& tx = ctx_.tx; + auto const scheduleID = tx[sfCouponScheduleID]; + + auto schedule = view().peek(keylet::couponSchedule(scheduleID)); + if (!schedule) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const holderSle = view().peek(keylet::account(accountID_)); + if (!holderSle) + return tefINTERNAL; // LCOV_EXCL_LINE + + STAmount const units = tx[sfAmount]; + STAmount const couponAmount = schedule->at(sfCouponAmount); + + auto const regKeylet = keylet::couponRegistration(scheduleID, accountID_); + auto registration = view().peek(regKeylet); + bool const created = !registration; + if (created) + { + registration = std::make_shared(regKeylet); + registration->at(sfAccount) = accountID_; + registration->at(sfCouponScheduleID) = scheduleID; + registration->at(sfRegisteredUnits) = STAmount{units.asset()}; + registration->at(sfAccruedAmount) = STAmount{couponAmount.asset()}; + // Any value before FirstCouponTime means "nothing settled yet"; + // couponSettle advances it to the most recent elapsed date, so + // newly registered units earn nothing for dates already past. + registration->at(sfLastSettledTime) = 0; + + if (auto const ter = dirLink(view(), accountID_, registration)) + return ter; // LCOV_EXCL_LINE + increaseOwnerCount(view(), holderSle, {}, 1, j_); + if (preFeeBalance_ < accountReserve(view(), holderSle, j_)) + return tecINSUFFICIENT_RESERVE; + } + + auto const now = view().header().parentCloseTime.time_since_epoch().count(); + if (auto const ter = couponSettle(schedule, registration, static_cast(now), j_); + !isTesSuccess(ter)) + return ter; + + if (auto const ter = couponLockUnits(view(), accountID_, units, j_); !isTesSuccess(ter)) + return ter; + + registration->at(sfRegisteredUnits) = STAmount(registration->at(sfRegisteredUnits)) + units; + + if (created) + { + schedule->at(sfRegistrationCount) = schedule->at(sfRegistrationCount) + 1; + view().update(schedule); + view().insert(registration); + } + else + { + view().update(registration); + } + + return tesSUCCESS; +} + +void +CouponRegister::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +CouponRegister::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/coupon/CouponScheduleCreate.cpp b/src/libxrpl/tx/transactors/coupon/CouponScheduleCreate.cpp new file mode 100644 index 00000000000..8afb75d2df2 --- /dev/null +++ b/src/libxrpl/tx/transactors/coupon/CouponScheduleCreate.cpp @@ -0,0 +1,167 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +NotTEC +CouponScheduleCreate::preflight(PreflightContext const& ctx) +{ + auto const bondAsset = ctx.tx[sfBondAsset]; + STAmount const couponAmount = ctx.tx[sfCouponAmount]; + + // The bond is a token: IOU or MPT, never XRP. + if (bondAsset.native()) + { + JLOG(ctx.j.debug()) << "CouponScheduleCreate: bond asset cannot be XRP."; + return temMALFORMED; + } + + if (couponAmount <= beast::kZero) + return temBAD_AMOUNT; + + // A bond paying coupons in itself is rebasing, not interest. + if (bondAsset == couponAmount.asset()) + { + JLOG(ctx.j.debug()) << "CouponScheduleCreate: bond and coupon asset are the same."; + return temMALFORMED; + } + + if (ctx.tx[sfCouponInterval] == 0) + return temMALFORMED; + + std::uint32_t const first = ctx.tx[sfFirstCouponTime]; + if (first == 0) + return temMALFORMED; + + auto const expiration = ctx.tx[~sfExpiration]; + if (expiration && *expiration <= first) + return temBAD_EXPIRATION; + + if (auto const notice = ctx.tx[~sfCallNoticePeriod]; notice && *notice == 0) + return temMALFORMED; + + if (auto const earliestCall = ctx.tx[~sfEarliestCallTime]) + { + // Call protection is meaningless on a non-callable schedule. + if (!ctx.tx.isFieldPresent(sfCallNoticePeriod)) + return temMALFORMED; + if (expiration && *earliestCall >= *expiration) + return temBAD_EXPIRATION; + } + + return tesSUCCESS; +} + +TER +CouponScheduleCreate::preclaim(PreclaimContext const& ctx) +{ + auto const now = ctx.view.header().parentCloseTime.time_since_epoch().count(); + if (ctx.tx[sfFirstCouponTime] <= now) + return tecEXPIRED; + + AccountID const account = ctx.tx[sfAccount]; + auto const bondAsset = ctx.tx[sfBondAsset]; + + // One schedule per (Account, BondAsset): the keylet is derived from + // the pair, so a duplicate collides here. + if (ctx.view.exists(keylet::couponSchedule(account, bondAsset))) + return tecDUPLICATE; + + auto const assetExists = [&](Asset const& asset) -> TER { + return std::visit( + [&](T const& issue) -> TER { + if constexpr (std::is_same_v) + { + if (!issue.native() && !ctx.view.exists(keylet::account(issue.account))) + return tecNO_ISSUER; + } + else + { + if (!ctx.view.exists(keylet::mptokenIssuance(issue.getMptID()))) + return tecOBJECT_NOT_FOUND; + } + return tesSUCCESS; + }, + asset.value()); + }; + + if (auto const ter = assetExists(bondAsset); !isTesSuccess(ter)) + return ter; + + STAmount const couponAmount = ctx.tx[sfCouponAmount]; + if (auto const ter = assetExists(couponAmount.asset()); !isTesSuccess(ter)) + return ter; + + return tesSUCCESS; +} + +TER +CouponScheduleCreate::doApply() +{ + auto const& tx = ctx_.tx; + + auto const owner = view().peek(keylet::account(accountID_)); + if (!owner) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const bondAsset = tx[sfBondAsset]; + auto schedule = std::make_shared(keylet::couponSchedule(accountID_, bondAsset)); + + schedule->at(sfAccount) = accountID_; + schedule->setFieldIssue(sfBondAsset, STIssue{sfBondAsset, bondAsset}); + schedule->at(sfCouponAmount) = tx[sfCouponAmount]; + schedule->at(sfCouponInterval) = tx[sfCouponInterval]; + schedule->at(sfFirstCouponTime) = tx[sfFirstCouponTime]; + if (auto const expiration = tx[~sfExpiration]) + schedule->at(sfExpiration) = *expiration; + if (auto const notice = tx[~sfCallNoticePeriod]) + schedule->at(sfCallNoticePeriod) = *notice; + if (auto const earliestCall = tx[~sfEarliestCallTime]) + schedule->at(sfEarliestCallTime) = *earliestCall; + + if (auto const ter = dirLink(view(), accountID_, schedule)) + return ter; // LCOV_EXCL_LINE + increaseOwnerCount(view(), owner, {}, 1, j_); + if (preFeeBalance_ < accountReserve(view(), owner, j_)) + return tecINSUFFICIENT_RESERVE; + + view().insert(schedule); + + return tesSUCCESS; +} + +void +CouponScheduleCreate::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +CouponScheduleCreate::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/coupon/CouponScheduleDelete.cpp b/src/libxrpl/tx/transactors/coupon/CouponScheduleDelete.cpp new file mode 100644 index 00000000000..cb875b35e2e --- /dev/null +++ b/src/libxrpl/tx/transactors/coupon/CouponScheduleDelete.cpp @@ -0,0 +1,79 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +CouponScheduleDelete::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfCouponScheduleID] == beast::kZero) + return temMALFORMED; + + return tesSUCCESS; +} + +TER +CouponScheduleDelete::preclaim(PreclaimContext const& ctx) +{ + auto const schedule = ctx.view.read(keylet::couponSchedule(ctx.tx[sfCouponScheduleID])); + if (!schedule) + return tecNO_ENTRY; + + if (ctx.tx[sfAccount] != schedule->at(sfAccount)) + return tecNO_PERMISSION; + + if (schedule->at(sfRegistrationCount) > 0) + return tecHAS_OBLIGATIONS; + + return tesSUCCESS; +} + +TER +CouponScheduleDelete::doApply() +{ + auto const k = keylet::couponSchedule(ctx_.tx[sfCouponScheduleID]); + auto schedule = view().peek(k); + if (!schedule) + return tefINTERNAL; // LCOV_EXCL_LINE + + if (!view().dirRemove(keylet::ownerDir(accountID_), schedule->at(sfOwnerNode), k.key, false)) + { + // LCOV_EXCL_START + JLOG(j_.fatal()) << "CouponScheduleDelete: failed to remove dir link."; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + decreaseOwnerCount(view(), accountID_, std::nullopt, 1, j_); + view().erase(schedule); + + return tesSUCCESS; +} + +void +CouponScheduleDelete::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +CouponScheduleDelete::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/coupon/CouponScheduleSet.cpp b/src/libxrpl/tx/transactors/coupon/CouponScheduleSet.cpp new file mode 100644 index 00000000000..060891d7a2f --- /dev/null +++ b/src/libxrpl/tx/transactors/coupon/CouponScheduleSet.cpp @@ -0,0 +1,100 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +NotTEC +CouponScheduleSet::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfCouponScheduleID] == beast::kZero) + return temMALFORMED; + + if (ctx.tx[sfExpiration] == 0) + return temMALFORMED; + + return tesSUCCESS; +} + +TER +CouponScheduleSet::preclaim(PreclaimContext const& ctx) +{ + auto const schedule = ctx.view.read(keylet::couponSchedule(ctx.tx[sfCouponScheduleID])); + if (!schedule) + return tecNO_ENTRY; + + if (ctx.tx[sfAccount] != schedule->at(sfAccount)) + return tecNO_PERMISSION; + + // Non-callable schedules are fully immutable. + auto const notice = schedule->at(~sfCallNoticePeriod); + if (!notice) + { + JLOG(ctx.j.debug()) << "CouponScheduleSet: schedule is not callable."; + return tecNO_PERMISSION; + } + + std::int64_t const newExpiration = ctx.tx[sfExpiration]; + std::int64_t const now = ctx.view.header().parentCloseTime.time_since_epoch().count(); + + // The notice floor: a call never takes effect with less warning than + // the schedule declared at creation. + if (newExpiration < now + static_cast(*notice)) + return tecNO_PERMISSION; + + // Call protection. + if (auto const earliestCall = schedule->at(~sfEarliestCallTime); + earliestCall && newExpiration < static_cast(*earliestCall)) + return tecNO_PERMISSION; + + // Shorten-only: a call can never extend the coupon liability. A + // perpetual callable schedule may have Expiration set for the first + // time. + if (auto const expiration = schedule->at(~sfExpiration); + expiration && newExpiration >= static_cast(*expiration)) + return tecNO_PERMISSION; + + return tesSUCCESS; +} + +TER +CouponScheduleSet::doApply() +{ + auto schedule = view().peek(keylet::couponSchedule(ctx_.tx[sfCouponScheduleID])); + if (!schedule) + return tefINTERNAL; // LCOV_EXCL_LINE + + schedule->at(sfExpiration) = ctx_.tx[sfExpiration]; + view().update(schedule); + + return tesSUCCESS; +} + +void +CouponScheduleSet::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +CouponScheduleSet::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/coupon/CouponUnregister.cpp b/src/libxrpl/tx/transactors/coupon/CouponUnregister.cpp new file mode 100644 index 00000000000..9b59a99e887 --- /dev/null +++ b/src/libxrpl/tx/transactors/coupon/CouponUnregister.cpp @@ -0,0 +1,116 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +CouponUnregister::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfCouponScheduleID] == beast::kZero) + return temMALFORMED; + + if (auto const units = ctx.tx[~sfAmount]; units && (*units <= beast::kZero || units->native())) + return temBAD_AMOUNT; + + return tesSUCCESS; +} + +TER +CouponUnregister::preclaim(PreclaimContext const& ctx) +{ + auto const scheduleID = ctx.tx[sfCouponScheduleID]; + auto const registration = + ctx.view.read(keylet::couponRegistration(scheduleID, ctx.tx[sfAccount])); + if (!registration) + return tecNO_ENTRY; + + if (auto const units = ctx.tx[~sfAmount]) + { + STAmount const registered = registration->at(sfRegisteredUnits); + if (units->asset() != registered.asset()) + return tecWRONG_ASSET; + if (*units > registered) + return tecINSUFFICIENT_FUNDS; + } + + return tesSUCCESS; +} + +TER +CouponUnregister::doApply() +{ + auto const& tx = ctx_.tx; + auto const scheduleID = tx[sfCouponScheduleID]; + + auto schedule = view().peek(keylet::couponSchedule(scheduleID)); + if (!schedule) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto registration = view().peek(keylet::couponRegistration(scheduleID, accountID_)); + if (!registration) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const holderSle = view().peek(keylet::account(accountID_)); + if (!holderSle) + return tefINTERNAL; // LCOV_EXCL_LINE + + auto const now = view().header().parentCloseTime.time_since_epoch().count(); + if (auto const ter = couponSettle(schedule, registration, static_cast(now), j_); + !isTesSuccess(ter)) + return ter; + + STAmount const registered = registration->at(sfRegisteredUnits); + STAmount const release = tx[~sfAmount].value_or(registered); + if (release > registered) + return tecINSUFFICIENT_FUNDS; + + if (auto const ter = couponUnlockUnits( + ctx_.getApplyViewContext(), holderSle, preFeeBalance_, release, accountID_, j_); + !isTesSuccess(ter)) + return ter; + + registration->at(sfRegisteredUnits) = registered - release; + + if (auto const deleted = + couponDeleteRegistrationIfEmpty(view(), schedule, registration, accountID_, j_)) + { + if (!isTesSuccess(*deleted)) + return *deleted; // LCOV_EXCL_LINE + view().update(schedule); + } + else + { + view().update(registration); + } + + return tesSUCCESS; +} + +void +CouponUnregister::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +CouponUnregister::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/Coupon_test.cpp b/src/test/app/Coupon_test.cpp new file mode 100644 index 00000000000..c1967879c55 --- /dev/null +++ b/src/test/app/Coupon_test.cpp @@ -0,0 +1,588 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace xrpl { + +class Coupon_test : public beast::unit_test::Suite +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + static json::Value + scheduleCreate( + test::jtx::Account const& issuer, + Asset const& bondAsset, + STAmount const& couponAmount, + std::uint32_t interval, + std::uint32_t firstCouponTime) + { + json::Value jv; + jv[jss::TransactionType] = jss::CouponScheduleCreate; + jv[jss::Account] = issuer.human(); + jv[sfBondAsset] = toJson(bondAsset); + jv[sfCouponAmount] = toJson(couponAmount); + jv[sfCouponInterval] = interval; + jv[sfFirstCouponTime] = firstCouponTime; + return jv; + } + + static json::Value + scheduleSet(test::jtx::Account const& issuer, uint256 const& id, std::uint32_t expiration) + { + json::Value jv; + jv[jss::TransactionType] = jss::CouponScheduleSet; + jv[jss::Account] = issuer.human(); + jv[sfCouponScheduleID] = to_string(id); + jv[sfExpiration] = expiration; + return jv; + } + + static json::Value + scheduleDelete(test::jtx::Account const& issuer, uint256 const& id) + { + json::Value jv; + jv[jss::TransactionType] = jss::CouponScheduleDelete; + jv[jss::Account] = issuer.human(); + jv[sfCouponScheduleID] = to_string(id); + return jv; + } + + static json::Value + couponRegister(test::jtx::Account const& holder, uint256 const& id, STAmount const& units) + { + json::Value jv; + jv[jss::TransactionType] = jss::CouponRegister; + jv[jss::Account] = holder.human(); + jv[sfCouponScheduleID] = to_string(id); + jv[jss::Amount] = toJson(units); + return jv; + } + + static json::Value + couponUnregister( + test::jtx::Account const& holder, + uint256 const& id, + std::optional const& units = std::nullopt) + { + json::Value jv; + jv[jss::TransactionType] = jss::CouponUnregister; + jv[jss::Account] = holder.human(); + jv[sfCouponScheduleID] = to_string(id); + if (units) + jv[jss::Amount] = toJson(*units); + return jv; + } + + static json::Value + couponClaim( + test::jtx::Account const& holder, + uint256 const& id, + std::optional const& amount = std::nullopt) + { + json::Value jv; + jv[jss::TransactionType] = jss::CouponClaim; + jv[jss::Account] = holder.human(); + jv[sfCouponScheduleID] = to_string(id); + if (amount) + jv[jss::Amount] = toJson(*amount); + return jv; + } + + static std::uint32_t + nowSeconds(test::jtx::Env& env) + { + return env.now().time_since_epoch().count(); + } + + void + testDisabled(FeatureBitset features) + { + testcase("disabled"); + using namespace test::jtx; + + Env env(*this, features - featureCouponPayments); + Account const issuer{"issuer"}; + Account const gw{"gw"}; + env.fund(XRP(10'000), issuer, gw); + env.close(); + + auto const BND = issuer["BND"]; + auto const USD = gw["USD"]; + env(scheduleCreate(issuer, BND.issue(), USD(1), 1'000, nowSeconds(env) + 100), + Ter(temDISABLED)); + } + + void + testScheduleCreate(FeatureBitset features) + { + testcase("schedule create"); + using namespace test::jtx; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const gw{"gw"}; + env.fund(XRP(10'000), issuer, gw); + env.close(); + + auto const BND = issuer["BND"]; + auto const USD = gw["USD"]; + std::uint32_t const future = nowSeconds(env) + 1'000; + + // Bond asset cannot be XRP. + { + auto jv = scheduleCreate(issuer, xrpIssue(), USD(1), 1'000, future); + env(jv, Ter(temMALFORMED)); + } + // Coupon amount must be positive. + env(scheduleCreate(issuer, BND.issue(), USD(0), 1'000, future), Ter(temBAD_AMOUNT)); + // A bond paying coupons in itself is rebasing, not interest. + env(scheduleCreate(issuer, BND.issue(), BND(1), 1'000, future), Ter(temMALFORMED)); + // Interval must be > 0. + env(scheduleCreate(issuer, BND.issue(), USD(1), 0, future), Ter(temMALFORMED)); + // Expiration must be after FirstCouponTime. + { + auto jv = scheduleCreate(issuer, BND.issue(), USD(1), 1'000, future); + jv[sfExpiration] = future; + env(jv, Ter(temBAD_EXPIRATION)); + } + // CallNoticePeriod must be > 0 when present. + { + auto jv = scheduleCreate(issuer, BND.issue(), USD(1), 1'000, future); + jv[sfCallNoticePeriod] = 0; + env(jv, Ter(temMALFORMED)); + } + // EarliestCallTime requires CallNoticePeriod. + { + auto jv = scheduleCreate(issuer, BND.issue(), USD(1), 1'000, future); + jv[sfEarliestCallTime] = future + 10; + env(jv, Ter(temMALFORMED)); + } + // FirstCouponTime must be in the future. (The jtx clock starts + // near the NetClock epoch, so 1 is always in the past.) + env(scheduleCreate(issuer, BND.issue(), USD(1), 1'000, 1), Ter(tecEXPIRED)); + + // Success. + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + env(scheduleCreate(issuer, BND.issue(), USD(2.5), 1'000, future)); + env.close(); + { + auto const sle = env.le(k); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT((*sle)[sfAccount] == issuer.id()); + BEAST_EXPECT((*sle)[sfCouponInterval] == 1'000); + BEAST_EXPECT((*sle)[sfFirstCouponTime] == future); + BEAST_EXPECT((*sle)[sfRegistrationCount] == 0); + } + BEAST_EXPECT(env.ownerCount(issuer) == 1); + + // One schedule per (Account, BondAsset). + env(scheduleCreate(issuer, BND.issue(), USD(2.5), 1'000, nowSeconds(env) + 500), + Ter(tecDUPLICATE)); + + // A different bond asset gets its own schedule. + auto const BD2 = issuer["BD2"]; + env(scheduleCreate(issuer, BD2.issue(), USD(1), 1'000, nowSeconds(env) + 500)); + env.close(); + BEAST_EXPECT(env.ownerCount(issuer) == 2); + } + + void + testRegister(FeatureBitset features) + { + testcase("register"); + using namespace test::jtx; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const gw{"gw"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, gw, alice); + env.close(); + + auto const BND = issuer["BND"]; + auto const USD = gw["USD"]; + std::uint32_t const future = nowSeconds(env) + 1'000; + + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + env(scheduleCreate(issuer, BND.issue(), USD(2.5), 1'000, future)); + env.close(); + + // No such schedule. + env(couponRegister(alice, keylet::couponSchedule(gw.id(), BND.issue()).key, BND(10)), + Ter(tecNO_ENTRY)); + + // Wrong asset. + env(couponRegister(alice, k.key, USD(10)), Ter(tecWRONG_ASSET)); + + // Bond issuer has not opted into trust-line locking. + env.trust(BND(1'000), alice); + env(pay(issuer, alice, BND(100))); + env.close(); + env(couponRegister(alice, k.key, BND(10)), Ter(tecNO_PERMISSION)); + // Seal the tec into a closed ledger so the open-ledger retry + // cannot succeed once the flag below is set. + env.close(); + + env(fset(issuer, asfAllowTrustLineLocking)); + env.close(); + + // More than the spendable balance. + env(couponRegister(alice, k.key, BND(500)), Ter(tecINSUFFICIENT_FUNDS)); + + // Success: units are locked, registration exists. + env(couponRegister(alice, k.key, BND(100))); + env.close(); + + auto const regKeylet = keylet::couponRegistration(k.key, alice.id()); + { + auto const reg = env.le(regKeylet); + if (!BEAST_EXPECT(reg)) + return; + BEAST_EXPECT((*reg)[sfRegisteredUnits] == BND(100)); + BEAST_EXPECT((*reg)[sfAccruedAmount] == USD(0)); + } + env.require(Balance(alice, BND(0))); + { + auto const sle = env.le(k); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT((*sle)[sfRegistrationCount] == 1); + } + + // Registered units cannot be spent. + env(pay(alice, issuer, BND(10)), Ter(tecPATH_DRY)); + } + + void + testAccrualAndClaim(FeatureBitset features) + { + testcase("accrual and claim"); + using namespace test::jtx; + using namespace std::chrono_literals; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const gw{"gw"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10'000), issuer, gw, alice, bob); + env.close(); + + auto const BND = issuer["BND"]; + auto const USD = gw["USD"]; + + env(fset(issuer, asfAllowTrustLineLocking)); + env.trust(BND(10'000), alice, bob); + env.trust(USD(1'000'000), issuer); + env(pay(issuer, alice, BND(100))); + env(pay(issuer, bob, BND(100))); + env(pay(gw, issuer, USD(10'000))); + env.close(); + + std::uint32_t const interval = 1'000; + std::uint32_t const first = nowSeconds(env) + 100; + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + env(scheduleCreate(issuer, BND.issue(), USD(2.5), interval, first)); + env.close(); + + // Alice registers before the first coupon date. + env(couponRegister(alice, k.key, BND(100))); + env.close(); + + // Nothing accrued yet. + env(couponClaim(alice, k.key), Ter(tecNO_PERMISSION)); + env.close(); + + // Cross the first coupon date: 100 units * 2.5 USD = 250 USD. + env.close(env.now() + 200s); + env(couponClaim(alice, k.key)); + env.close(); + env.require(Balance(alice, USD(250))); + + // Bob registers after the first date: he earns nothing for it. + env(couponRegister(bob, k.key, BND(100))); + env.close(); + env(couponClaim(bob, k.key), Ter(tecNO_PERMISSION)); + env.close(); + + // Cross two more dates: arrears accumulate and settle in one claim. + env.close(env.now() + std::chrono::seconds(2 * interval)); + env(couponClaim(alice, k.key)); + env.close(); + env.require(Balance(alice, USD(750))); + + // Bob was registered for those two dates. + env(couponClaim(bob, k.key)); + env.close(); + env.require(Balance(bob, USD(500))); + + // Partial claim. + env.close(env.now() + std::chrono::seconds(interval)); + env(couponClaim(alice, k.key, USD(100))); + env.close(); + env.require(Balance(alice, USD(850))); + { + auto const reg = env.le(keylet::couponRegistration(k.key, alice.id())); + if (!BEAST_EXPECT(reg)) + return; + BEAST_EXPECT((*reg)[sfAccruedAmount] == USD(150)); + } + + // Unregister does not pay accrued coupons, and the claim to + // arrears survives exiting the position. + env(couponUnregister(alice, k.key)); + env.close(); + env.require(Balance(alice, BND(100))); + { + auto const reg = env.le(keylet::couponRegistration(k.key, alice.id())); + if (!BEAST_EXPECT(reg)) + return; + BEAST_EXPECT((*reg)[sfRegisteredUnits] == BND(0)); + BEAST_EXPECT((*reg)[sfAccruedAmount] == USD(150)); + } + env(couponClaim(alice, k.key)); + env.close(); + env.require(Balance(alice, USD(1'000))); + // Registration auto-deleted at zero/zero. + BEAST_EXPECT(!env.le(keylet::couponRegistration(k.key, alice.id()))); + } + + void + testUnderfundedIssuer(FeatureBitset features) + { + testcase("underfunded issuer"); + using namespace test::jtx; + using namespace std::chrono_literals; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const gw{"gw"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, gw, alice); + env.close(); + + auto const BND = issuer["BND"]; + auto const USD = gw["USD"]; + + env(fset(issuer, asfAllowTrustLineLocking)); + env.trust(BND(10'000), alice); + env.trust(USD(1'000'000), issuer); + env(pay(issuer, alice, BND(100))); + env.close(); + + std::uint32_t const first = nowSeconds(env) + 100; + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + env(scheduleCreate(issuer, BND.issue(), USD(2.5), 1'000, first)); + env.close(); + env(couponRegister(alice, k.key, BND(100))); + env.close(); + + env.close(env.now() + 200s); + + // The issuer has no USD: the dishonored coupon is a visible, + // timestamped failed claim — and the accrual survives it. + env(couponClaim(alice, k.key), Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // The issuer funds up; the retry succeeds. + env(pay(gw, issuer, USD(10'000))); + env.close(); + env(couponClaim(alice, k.key)); + env.close(); + env.require(Balance(alice, USD(250))); + } + + void + testCallable(FeatureBitset features) + { + testcase("callable"); + using namespace test::jtx; + using namespace std::chrono_literals; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const gw{"gw"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, gw, alice); + env.close(); + + auto const BND = issuer["BND"]; + auto const USD = gw["USD"]; + + env(fset(issuer, asfAllowTrustLineLocking)); + env.trust(BND(10'000), alice); + env.trust(USD(1'000'000), issuer); + env(pay(issuer, alice, BND(100))); + env(pay(gw, issuer, USD(10'000))); + env.close(); + + std::uint32_t const interval = 1'000; + + // A non-callable schedule is fully immutable. + { + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + env(scheduleCreate(issuer, BND.issue(), USD(2.5), interval, nowSeconds(env) + 100)); + env.close(); + env(scheduleSet(issuer, k.key, nowSeconds(env) + 10'000), Ter(tecNO_PERMISSION)); + env(scheduleDelete(issuer, k.key)); + env.close(); + } + + // Callable: notice floor and shorten-only are enforced; accrual + // stops at the called Expiration. + std::uint32_t const notice = 2'000; + std::uint32_t const first = nowSeconds(env) + 100; + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + { + auto jv = scheduleCreate(issuer, BND.issue(), USD(2.5), interval, first); + jv[sfCallNoticePeriod] = notice; + env(jv); + env.close(); + } + env(couponRegister(alice, k.key, BND(100))); + env.close(); + + // Only the issuer can call. + env(scheduleSet(alice, k.key, nowSeconds(env) + notice + 100), Ter(tecNO_PERMISSION)); + // The notice floor. + env(scheduleSet(issuer, k.key, nowSeconds(env) + notice - 500), Ter(tecNO_PERMISSION)); + + // A valid call. + std::uint32_t const callTime = nowSeconds(env) + notice + 500; + env(scheduleSet(issuer, k.key, callTime)); + env.close(); + { + auto const sle = env.le(k); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT((*sle)[~sfExpiration] == callTime); + } + + // Shorten-only: the call cannot be extended. + env(scheduleSet(issuer, k.key, callTime + 10'000), Ter(tecNO_PERMISSION)); + + // Cross well past the called expiration: only the dates strictly + // before Expiration accrue. + env.close(env.now() + std::chrono::seconds(6 * interval)); + env(couponClaim(alice, k.key)); + env.close(); + // Dates: first, first+1000, first+2000 (all < callTime ≈ first+2400). + env.require(Balance(alice, USD(750))); + + // Nothing further ever accrues. + env.close(env.now() + std::chrono::seconds(2 * interval)); + env(couponClaim(alice, k.key), Ter(tecNO_PERMISSION)); + } + + void + testScheduleDelete(FeatureBitset features) + { + testcase("schedule delete"); + using namespace test::jtx; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const gw{"gw"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, gw, alice); + env.close(); + + auto const BND = issuer["BND"]; + auto const USD = gw["USD"]; + + env(fset(issuer, asfAllowTrustLineLocking)); + env.trust(BND(10'000), alice); + env(pay(issuer, alice, BND(100))); + env.close(); + + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + env(scheduleCreate(issuer, BND.issue(), USD(2.5), 1'000, nowSeconds(env) + 100)); + env.close(); + + env(scheduleDelete(alice, k.key), Ter(tecNO_PERMISSION)); + + env(couponRegister(alice, k.key, BND(50))); + env.close(); + + // A schedule with registrants cannot be deleted. + env(scheduleDelete(issuer, k.key), Ter(tecHAS_OBLIGATIONS)); + // Seal the tec so its open-ledger retry cannot delete the + // schedule after the unregister below. + env.close(); + + env(couponUnregister(alice, k.key)); + env.close(); + + env(scheduleDelete(issuer, k.key)); + env.close(); + BEAST_EXPECT(!env.le(k)); + BEAST_EXPECT(env.ownerCount(issuer) == 0); + } + + void + testXRPCoupon(FeatureBitset features) + { + testcase("XRP coupon asset"); + using namespace test::jtx; + using namespace std::chrono_literals; + + Env env(*this, features); + Account const issuer{"issuer"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, alice); + env.close(); + + auto const BND = issuer["BND"]; + + env(fset(issuer, asfAllowTrustLineLocking)); + env.trust(BND(10'000), alice); + env(pay(issuer, alice, BND(100))); + env.close(); + + auto const k = keylet::couponSchedule(issuer.id(), BND.issue()); + env(scheduleCreate(issuer, BND.issue(), XRP(1), 1'000, nowSeconds(env) + 100)); + env.close(); + env(couponRegister(alice, k.key, BND(100))); + env.close(); + + auto const before = env.balance(alice); + env.close(env.now() + 200s); + env(couponClaim(alice, k.key), Fee(drops(10))); + env.close(); + // 100 units * 1 XRP - the claim fee. + BEAST_EXPECT(env.balance(alice) == before + XRP(100) - drops(10)); + } + +public: + void + run() override + { + testDisabled(all_); + testScheduleCreate(all_); + testRegister(all_); + testAccrualAndClaim(all_); + testUnderfundedIssuer(all_); + testCallable(all_); + testScheduleDelete(all_); + testXRPCoupon(all_); + } +}; + +BEAST_DEFINE_TESTSUITE(Coupon, app, xrpl); + +} // namespace xrpl diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 784be779bbe..6682371e860 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -475,6 +475,53 @@ parseLedgerHashes( return parseFixed(keylet::skip(), params, fieldName, apiVersion); } +static std::expected +parseCouponSchedule( + json::Value const& params, + json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName); + } + + auto const issuer = + LedgerEntryHelpers::requiredAccountID(params, jss::issuer, "malformedIssuer"); + if (!issuer) + return std::unexpected(issuer.error()); + + auto const asset = LedgerEntryHelpers::requiredAsset(params, jss::asset, "malformedRequest"); + if (!asset) + return std::unexpected(asset.error()); + + return keylet::couponSchedule(*issuer, *asset).key; +} + +static std::expected +parseCouponRegistration( + json::Value const& params, + json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName); + } + + auto const scheduleID = + LedgerEntryHelpers::requiredUInt256(params, jss::coupon_schedule_id, "malformedRequest"); + if (!scheduleID) + return std::unexpected(scheduleID.error()); + + auto const holder = + LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress"); + if (!holder) + return std::unexpected(holder.error()); + + return keylet::couponRegistration(*scheduleID, *holder).key; +} + static std::expected parseLoanBroker( json::Value const& params,