Skip to content

[ALPHANET] Coupon Payments#35

Open
dangell7 wants to merge 1 commit into
developfrom
dangell7/coupon-payments
Open

[ALPHANET] Coupon Payments#35
dangell7 wants to merge 1 commit into
developfrom
dangell7/coupon-payments

Conversation

@dangell7

Copy link
Copy Markdown
Member

Summary

Adds featureCouponPayments — bond-style coupon payments on registered token holdings, per the Coupon Payments XLS draft. Two new ledger entries and six transactions.

Piece What it does
CouponSchedule Issuer's standing coupon offer for a bond token (IOU or MPT): fixed CouponAmount per unit, per CouponInterval, in a settlement asset ≠ the bond asset. Keyed by (Account, BondAsset) — one schedule per pair, tecDUPLICATE structural.
CouponRegistration Holder's locked bond units + accrued unclaimed coupons. Keyed by (CouponScheduleID, Holder).
CouponScheduleCreate/Delete Create (reserve-backed, dedup by keylet); delete only when RegistrationCount == 0.
CouponScheduleSet The call option: shorten-only Expiration, gated on CallNoticePeriod declared at creation (+ optional EarliestCallTime non-call window). Non-callable schedules are fully immutable.
CouponRegister/Unregister Lock/release bond units using the TokenEscrow mechanics (IOU parks on the issuer trust line — requires lsfAllowTrustLineLocking; MPT uses lockEscrowMPT/sfLockedAmount — requires lsfMPTCanEscrow).
CouponClaim Pull accrued coupons (with arrears, partial claims allowed) from the issuer's live balance — no escrow. A failed claim is the on-chain default notice. Delegatable (XLS-75). Auto-creates the holder's line/MPToken reserve-gated.

Design

  • Registered holdings, not live balances. Live-balance entitlement double-pays under transfer; registration reduces settlement sites to the transactions this amendment owns. Accrual is settled at every units-change boundary, so dates × units × CouponAmount is an identity.
  • O(1) settlement. Elapsed coupon dates are computed arithmetically from date indices — arrears of any depth settle in constant time. One downward rounding per settlement (Number + NumberRoundModeGuard), tecPRECISION_LOSS guarded.
  • Callable = shorten-only with notice. Accrual stops at the called Expiration exactly as at a natural one; registrants keep arrears and exit at leisure (no forced unwind, no holdout accrual).
  • Reuses existing primitives: TokenEscrow lock/unlock helpers and eligibility gates, accountSend (transfer-fee honoring) for claim delivery, addEmptyHolding for recipient auto-creation, VaultSet shape for the owner-only Set transactor.

RPC

ledger_entry supports coupon_schedule (issuer + asset) and coupon_registration (coupon_schedule_id + account).

Tests

Coupon suite (8 cases, 519 checks): amendment gating, create preflight/preclaim matrix + keylet dedup, register eligibility (opt-in flag, auth, funds) + lock verification, multi-period accrual + arrears + record-date semantics (units registered after a date earn nothing for it), partial claims, unregister-preserves-arrears + auto-delete at zero/zero, underfunded-issuer dishonor + funded retry, callable (owner-only, notice floor, shorten-only, accrual stop), XRP coupon asset. Escrow + Vault regression suites: 0 failures.

Spec companions (gap analysis vs the bond universe, implementation plan) live in the planning repo; XLS discussion post to follow.

* 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.

*/
[[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.

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant