Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions include/xrpl/ledger/helpers/CouponHelpers.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#pragma once

#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>

#include <cstdint>
#include <optional>

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The accrual computation constructs additionAmt via STAmount{couponAmount.asset(), addition} (CouponHelpers.cpp:68) before the canAdd overflow guard on the next line has any chance to run. When count * RegisteredUnits * CouponAmount exceeds the asset's representable range — which is fully attacker-controlled via registration units and elapsed intervals — the STAmount constructor throws std::overflow_error or std::runtime_error. None of the three transactors that call couponSettle (CouponClaim, CouponRegister, CouponUnregister) catch this exception, so it bubbles to the outer applySteps handler and becomes tefEXCEPTION. Because sfLastSettledTime is written on lines 77-78, after the throw, it never advances — and since CouponUnregister::doApply calls couponSettle before releasing locked units, the holder can neither claim nor exit, permanently locking their registered bond units. Fix: bound-check the Number multiplication before constructing the STAmount, or wrap the construction in a try/catch that translates to tecPRECISION_LOSS.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IOU specialization of the lock preclaim uses canAdd(spendable, units) to guard the locking operation, but the actual operation is a debit — directSendNoFee subtracts units from the holder's trust-line balance. For IOU amounts, canSubtract always returns true (subtraction is always representable), while canAdd performs a round-trip precision check that can return false for large balance/unit combinations where addition precision would be lossy. The mismatch means valid lock operations on large IOU balances can be spuriously rejected with tecPRECISION_LOSS. This is the same incorrect-direction precision check confirmed in escrowLockPreclaimHelper<Issue> in EscrowHelpers.h. Replace canAdd(spendable, units) with canSubtract(spendable, units) to match the actual arithmetic direction.

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<TER>
couponDeleteRegistrationIfEmpty(
ApplyView& view,
SLE::ref schedule,
SLE::ref registration,
AccountID const& holder,
beast::Journal j);

} // namespace xrpl
18 changes: 18 additions & 0 deletions include/xrpl/protocol/Indexes.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions include/xrpl/protocol/detail/features.macro
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions include/xrpl/protocol/detail/ledger_entries.macro
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
11 changes: 11 additions & 0 deletions include/xrpl/protocol/detail/sfields.macro
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sfEarliestCallTime accepts 0 and any past timestamp — there is no check that it is non-zero, greater than the current ledger close time, or at or after sfFirstCouponTime. The call-protection enforcement in CouponScheduleSet compares newExpiration < sfEarliestCallTime, so when sfEarliestCallTime is 0 that comparison is trivially false against any real timestamp, rendering the call-protection window completely non-functional. An issuer can create a callable bond with sfFirstCouponTime one year out and sfEarliestCallTime = 0, attract holders who lock their units via CouponRegister, then immediately submit CouponScheduleSet to expire the bond one notice-period later — before a single coupon is ever paid — while investors cannot recover their principal until they submit CouponUnregister themselves. Add a preflight rejection for sfEarliestCallTime == 0 and sfEarliestCallTime < sfFirstCouponTime, and a preclaim rejection for sfEarliestCallTime <= parentCloseTime, consistent with how sfFirstCouponTime is already validated against the live ledger time.

Suggested fix

In CouponScheduleCreate::preflight: (1) reject sfEarliestCallTime == 0 with temMALFORMED; (2) reject sfEarliestCallTime < sfFirstCouponTime with temBAD_EXPIRATION. In preclaim: (3) reject sfEarliestCallTime <= parentCloseTime.time_since_epoch().count() with tecEXPIRED, mirroring the existing sfFirstCouponTime check.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sfEarliestCallTime accepts 0 and any past timestamp — there is no check that it is non-zero, greater than the current ledger close time, or at or after sfFirstCouponTime. The call-protection enforcement in CouponScheduleSet compares newExpiration < sfEarliestCallTime, so when sfEarliestCallTime is 0 that comparison is trivially false against any real timestamp, rendering the call-protection window completely non-functional. An issuer can create a callable bond with sfFirstCouponTime one year out and sfEarliestCallTime = 0, attract holders who lock their units via CouponRegister, then immediately submit CouponScheduleSet to expire the bond one notice-period later — before a single coupon is ever paid — while investors cannot recover their principal until they submit CouponUnregister themselves. Add a preflight rejection for sfEarliestCallTime == 0 and sfEarliestCallTime < sfFirstCouponTime, and a preclaim rejection for sfEarliestCallTime <= parentCloseTime, consistent with how sfFirstCouponTime is already validated against the live ledger time.

Suggested fix

In CouponScheduleCreate::preflight: (1) reject sfEarliestCallTime == 0 with temMALFORMED; (2) reject sfEarliestCallTime < sfFirstCouponTime with temBAD_EXPIRATION. In preclaim: (3) reject sfEarliestCallTime <= parentCloseTime.time_since_epoch().count() with tecEXPIRED, mirroring the existing sfFirstCouponTime check.

TYPED_SFIELD(sfRegistrationCount, UINT32, 80)

// 64-bit integers (common)
TYPED_SFIELD(sfIndexNext, UINT64, 1)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions include/xrpl/protocol/detail/transactions.macro
Original file line number Diff line number Diff line change
Expand Up @@ -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 <xrpl/tx/transactors/coupon/CouponScheduleCreate.h>
#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 <xrpl/tx/transactors/coupon/CouponScheduleSet.h>
#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 <xrpl/tx/transactors/coupon/CouponScheduleDelete.h>
#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 <xrpl/tx/transactors/coupon/CouponRegister.h>
#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 <xrpl/tx/transactors/coupon/CouponUnregister.h>
#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 <xrpl/tx/transactors/coupon/CouponClaim.h>
#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
Expand Down
1 change: 1 addition & 0 deletions include/xrpl/protocol/jss.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions include/xrpl/tx/transactors/coupon/CouponClaim.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#pragma once

#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>

namespace xrpl {

class 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
43 changes: 43 additions & 0 deletions include/xrpl/tx/transactors/coupon/CouponRegister.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#pragma once

#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>

namespace xrpl {

class 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
Loading