Compare commits

...

17 Commits

Author SHA1 Message Date
Vito
8e9121ca23 refactor: Shrink vault-dust PR via targeted deduplication
Applies the conservative simplification plan to reduce the PR's footprint
while preserving the xrpl::vault_dust:: overlay namespace and the
DustSplit::LegPolicy::Mode::Drain mode.

- Extract makeSenderOverride() and reconcileSenderDust() in the
  vault_dust:: anonymous namespace; clawbackVaultAssets, non-terminal
  removeVaultAssets, and moveVaultAssets each collapse a ~10-line
  build+reconcile block to two lines.
- Replace LoanPay.cpp's inlined useDust predicate with a direct call to
  vault_dust::useVaultDust(view, vaultSle); removes the strict-lockstep
  justification comment.
- Consolidate the "amendment gate is defense-in-depth" rationale at
  directSendNoFeeIOU; peer sites (useVaultDust, creditBalanceExact, the
  removeEmptyHolding sfDust guard, the sfDust!=0 deletion guard) shrink
  to a one-line pointer, in code and in the corresponding header docs.
- Trim the DustSplit header doc to the essential contract; move the
  motivation, two-legged trust-line touch model, sign convention,
  amendment-gate policy, and consumer-adopter pattern to
  docs/dust-mechanism.md. Also trims the vault_dust:: namespace doc
  block.

Behaviour is byte-identical. VaultRoundingTrustlineDust, VaultRounding,
Vault, Loan, Invariants, Pay*, PaymentSandbox suites all pass (155,745
tests across 9 suites, 0 failures).

Investigated but rejected: folding sfBalance+sfDust into DeltaInfo.delta
for the ValidVault outstanding/available checks. sfAssetsTotal tracks
sfBalance (not extended) and the reconciliation step shifts it by
dustDelta; the drift cancels only in the sfBalance-only comparison, so
a separate DeltaInfo::dustDelta remains required for the destination-
parity check.
2026-08-12 11:31:48 +02:00
Vito
9cad0e94db fix: Adapt VaultRounding tests to SeqProxy loan-keylet API
The parent branch pulled in `refactor: Use SeqProxy instead of uint32 for
all sequence-based keylets (#7890)`, which reshaped `keylet::loan` to
take `SeqProxy const&` in place of `std::uint32_t`. Update the three
call sites in `VaultRounding_test.cpp` and
`VaultRoundingTrustlineDust_test.cpp` to wrap the broker's
`sfLoanSequence` in `SeqProxy::rawSequence(...)` and pull in the header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:46:07 +02:00
Vito
2c8003691d refactor: Move dust reconciliation into two-leg DustSplit and address review feedback
Restructures `DustSplit` from a single-leg, receiver-positive struct into a
two-leg struct with optional per-leg `LegPolicy` sub-policies (`sender`,
`receiver`), each carrying a `Mode` (`Override`/`Drain`), an `overrideScale`,
and its own per-leg-party-positive `balanceDelta`/`dustDelta` report. All
trust-line dust writes are now encapsulated inside `directSendNoFeeIOU`;
consumer-level code (Vault) never touches `sfBalance`/`sfDust` directly.
Also folds in the code-review must-fixes accumulated on this branch.

Two-leg DustSplit refactor
- `DustSplit` gains `LegPolicy` with `Mode::Override` (existing behaviour,
  now per-leg) and `Mode::Drain` (sender-leg only, folds all sfDust on the
  sender line into the outgoing transfer and zeroes it post-op).
- `accountSend`, `doWithdraw`, `accountSendMulti` forward per-leg policies
  to `directSendNoFeeIOU`; `accountSendMulti` applies only the sender-leg
  policy on the shared sender-line touch across recipients.
- `directSendNoFeeIOU` is the single site where sfDust reads and writes
  happen. Previously-deferred sfDust is now promoted implicitly on every
  operation that crosses a whole-quantum boundary at the leg's new scale
  — no separate PROMOTE call is needed.
- `xrpl::vault_dust::renormaliseStrandedDust` is deleted; its effect is
  achieved as a byproduct of the credit-path re-split. Clawback,
  non-terminal remove, and move now use sender-leg `Override`; terminal
  remove (`FinalRemoval::Yes`) uses sender-leg `Drain`; deposits keep
  receiver-leg `Override`.
- `VaultInvariant.O2` (dust bounded by one quantum) relaxed to
  `10 × quantum(scale(sfAssetsTotal))` to accept up-to-one-decade drift
  under `Override` when the caller's ex-ante scale estimate lands on a
  decade boundary; drift is caught by the next `accountSend` on the line.
- New `VaultRoundingTrustlineDust` coverage: sender-leg `Override`
  reporting on non-terminal removal, sender-leg `Drain` end-to-end via a
  defaulted-loan terminal withdrawal, and two-leg contract debug asserts
  (`Drain` on receiver forbidden, issuer-side policy forbidden).

Code-review fixes
- `ValidVault::DeltaInfo` gains a signed `dustDelta`; withdrawal
  destination check now compares extended balances (`delta + dustDelta`)
  so a dust-promotion recognition move on the vault's pseudo-account
  custody line does not spuriously fail the "vault and destination
  balance change by equal amount" invariant. Regression covered by
  `testNonTerminalWithdrawAfterDust` in
  `VaultRoundingTrustlineDust_test.cpp`.
- Hot-path gate: `directSendNoFeeIOU` short-circuits when both the caller
  passes no `DustSplit` and the trust line's `sfDust == 0`, so classic
  IOU sends remain byte-identical to pre-dust behaviour.
- `xrpl::vault_dust::useVaultDust(view, vault)` becomes the sole
  eligibility gate on the caller side; requires
  `featureLendingProtocolV1_1`, `VaultVersion::CashBasis`, and
  `!asset.integral()`. Call sites in `VaultDeposit`, `VaultWithdraw`,
  `LoanPay`, and the base `xrpl::vault_dust::` dispatchers updated
  accordingly.
- `directSendNoFeeIOU` asserts `featureLendingProtocolV1_1` when passed a
  non-empty `DustSplit`; without the amendment it falls back to the
  classic code path.
- `removeEmptyHolding` refactored to guard on the extended balance
  (`creditBalanceExact`) rather than raw `sfBalance`, so a trust line
  with residual sfDust is no longer treated as empty.
- `associateAsset` asymmetry documented; `tecPRECISION_LOSS` docstring
  updated; `dustLineTerms` reads sfDust directly from the trust line SLE
  rather than going through a helper indirection.
- Non-negative `sfDust` assert added on every sender-line write; various
  helpers annotated `[[nodiscard]]` where the previous callers relied on
  discarded returns; doc comments refreshed across `TokenHelpers.h`,
  `VaultHelpers.h`, and the invariant header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:28:14 +02:00
Vito
fa0d6a4f3d test: Restore VaultRounding, VaultRoundingTrustlineDust suites and probe
Restore three test artefacts from the pre-rebase branch, adjusted for
the base-branch renames and the vault_dust:: namespace overlay:

  * src/test/app/lending/VaultRounding_test.cpp — the shared oracle
    suite (O1..O8, dust invariants). Designed to fail on the base branch
    and pass on solution branches. Uses getVaultScale (renamed from
    getAssetsTotalScale on the base) and keeps the O3 conservation-sum
    fix (originally 92517efa0b) that counts the sfDust reservoir.

  * src/test/app/lending/VaultRoundingTrustlineDust_test.cpp — the
    solution-specific suite for B' (trust-line-dust). Exercises the
    sfDust reservoir directly: absence-reads-zero, low/high account
    sign convention, credit-path split-and-promote, ordinary payments
    never acquiring dust, accountHolds not summing dust, and VaultDelete
    guards. Uses xrpl::vault_dust::useVaultDust for the eligibility
    check.

  * src/test/app/lending/VaultDustProbe.h — the readVaultDust seam used
    by VaultRounding_test.cpp to read dust in vault-pseudo-account terms
    without knowing the underlying sfDust field layout, so the shared
    suite stays portable across solution branches.

All target suites pass end-to-end: VaultRounding (15/15),
VaultRoundingTrustlineDust (7/7), Vault (all), and Loan* (all).
2026-08-11 17:28:14 +02:00
Vito
f0a3df7455 feat: Wire xrpl::vault_dust:: dispatchers into the base helpers
Rewrite the four base Vault helpers (addVaultAssets,
clawbackVaultAssets, removeVaultAssets, moveVaultAssets) so each starts
with a one-line dispatch to the corresponding xrpl::vault_dust::
overload when useVaultDust(vault) returns true. Non-dust Vaults
(Legacy, or holding an XRP/MPT asset) fall through to the existing
bodies, byte-identical to the base branch.

Transactor call sites keep the same shape and never mention
xrpl::vault_dust::. LoanPay is the only transactor that needs a small
adjustment to let the dust path work end-to-end:

  1. When the Vault is dust-eligible (cash-basis + IOU asset + amendment
     active), pass the RAW pre-rounding principal+interest total to
     addVaultAssets. The dust-aware overload consumes those raw digits
     via DustSplit to park any sub-quantum remainder in the custody
     line's sfDust. Non-dust vaults keep pre-rounding to the anterior
     Vault scale, byte-identical to the base branch.

  2. Skip associateAsset(*vaultSle, asset) under the same predicate.
     associateAsset snaps every asset-typed STNumber on the Vault SLE to
     STAmount's 16-significant-digit precision (via roundToAsset), which
     for dust vaults silently erases the sub-quantum recognition
     adjustment that addVaultAssets just applied to sfAssetsTotal. That
     erasure is what keeps the receivable (sfAssetsTotal -
     sfAssetsAvailable) out of alignment with principalOutstanding
     across a repayment. Legacy / integral-asset vaults still call
     associateAsset exactly as before.

The dust predicate is inlined at the LoanPay call site rather than
routed through xrpl::vault_dust::useVaultDust so the transactor source
stays free of any xrpl::vault_dust:: mention, per plan's namespace
hygiene rule.
2026-08-11 17:28:14 +02:00
Vito
242db802d4 feat: Add xrpl::vault_dust:: overlay of the Vault helpers
Introduce a namespace-scoped, dust-aware overlay of the four base Vault
helpers (addVaultAssets, clawbackVaultAssets, removeVaultAssets,
moveVaultAssets) plus the useVaultDust eligibility gate. The overlay
lives entirely in xrpl::vault_dust:: and is the ONLY place in the tree
that constructs a xrpl::DustSplit. Every helper keeps the base's
STAmount-based signature so callers can stay agnostic (Commit 4 wires
the dispatchers in xrpl:: to forward here when useVaultDust(vault)).

- useVaultDust(vault): cash-basis Vault (sfLEVersion == CashBasis)
  holding an IOU asset; unconditionally excludes Legacy and integral
  assets so the dust code path is never exercised for them.

- addVaultAssets: routes the incoming credit through a DustSplit
  targeting the Vault's posterior scale (an upper bound on the scale of
  sfAssetsTotal after the operation), then updates sfAssetsAvailable by
  the aligned balanceDelta and sfAssetsTotal by
  (valueDelta - dustDelta) so the receivable
  (sfAssetsTotal - sfAssetsAvailable) matches a dust-unaware call
  exactly. Finalises with the shared renormaliseStrandedDust helper in
  case a negative valueDelta refined the Vault's scale.

- clawbackVaultAssets: same field mutation and plain accountSend as the
  base helper (the receiver is the issuer, so there is no Vault-side
  DustSplit to make), followed by renormaliseStrandedDust to promote
  any dust freed by the scale refinement.

- removeVaultAssets:
  * Non-terminal: base field mutation, plain doWithdraw, then
    renormaliseStrandedDust for the same reason as clawback.
  * Terminal (FinalRemoval::Yes): drains ALL of the custody line's
    sfDust into sfBalance and sends the extended total, so the line
    ends with sfBalance == 0 and sfDust == 0 -- a precondition for the
    downstream deletion guards. Hard-resets sfAssetsTotal and
    sfAssetsAvailable to zero (same contract as the base final-removal
    branch).

- moveVaultAssets: base field mutation and plain accountSendMulti (the
  Vault is the sender, so DustSplit on the receiver leg would not
  affect the Vault's own sfDust), followed by renormaliseStrandedDust.

A private renormaliseStrandedDust promotes whole quanta of dust on the
Vault's custody line back into sfBalance after a scale-refining
accounting update and moves both sfAssetsAvailable and sfAssetsTotal by
the same amount (receivable-preserving). Subsumes the standalone
maybeRenormaliseVaultDust from the old branch -- no public renormalise
symbol survives here.
2026-08-11 17:26:46 +02:00
Vito
5cd1871091 feat: Add creditBalanceExact read helper for dust-inclusive balances
Introduce xrpl::creditBalanceExact, a read-only complement to DustSplit's
write side. The helper returns the trust-line balance from an account's
perspective at exact precision by folding in sfDust, so callers can hold
an off-line total of a line without losing sub-quantum drift.

Amendment-gated on featureLendingProtocolV1_1: when disabled the sfDust
read is skipped and the result is Number{sfBalance}. Pre-amendment lines
have sfDust == 0 by SoeDefault, so the values coincide either way, but
the explicit gate documents the invariant and mirrors the write-side
gate in directSendNoFeeIOU.

This replaces the terminal-withdrawal role of the old foldDust helper:
callers of the upcoming xrpl::vault_dust::* overlay read the custody
line via creditBalanceExact and let the standard DustSplit truncate math
in directSendNoFeeIOU produce (newBalance=0, newDust=0) naturally.
2026-08-11 17:26:45 +02:00
Vito
30364e2c3c feat: Add generic sfDust primitive with DustSplit credit-path threading
Introduce a feature-agnostic trust-line dust mechanism at the xrpl:: layer:

- New sfDust NUMBER field on RIPPLE_STATE, with SoeDefault(0) so absent
  reads return zero. Deliberately without kSmdNeedsAsset since the field's
  whole purpose is to hold sub-quantum residuals finer than the asset's
  representable precision.
- New xrpl::DustSplit struct in TokenHelpers.h — a caller-owned request that
  the credit path keep sfBalance representable at a target scale and park
  the remainder in sfDust, reporting balanceDelta / dustDelta back
  (receiver-positive convention). The type carries no Vault knowledge; any
  future adopter constructs one and passes it through.
- xrpl::accountSend and directSendNoFeeIOU / directSendNoLimitIOU gain an
  optional DustSplit* parameter defaulting to nullptr. When null, the code
  paths are byte-identical to before. When non-null, directSendNoFeeIOU
  computes newBalance = truncate(sfBalance + sfDust - amount) toward zero
  at the target scale so previously-deferred dust that now clears a whole
  quantum is promoted automatically — no separate fold step.

Defense-in-depth amendment gate at every sfDust touchpoint:

- directSendNoFeeIOU asserts view.rules().enabled(featureLendingProtocolV1_1)
  at the top of the dust != nullptr branch; on release-build failure it
  degrades to dust = nullptr so no sfDust write can occur pre-amendment.
- removeEmptyHolding refuses to delete a trust line whose sfDust is
  non-zero, gated on featureLendingProtocolV1_1. Pre-amendment sfDust is
  always zero by construction; the explicit gate documents the invariant.
- directSendNoFeeIOU carries the same never-delete-with-dust guard on the
  in-place mutation path so a line carrying dust from an earlier
  dust-aware credit can never be dropped by a later plain credit.
2026-08-11 17:26:45 +02:00
Vito
db50afa34f test: Cover Vault helper early-return branches
Adds unit-test coverage for three previously-untested branches in
VaultHelpers:

- addVaultAssets propagates a non-tes return when the underlying
  accountSend fails (exercised via a sender with no trust line for the
  vault asset).
- clawbackVaultAssets propagates a non-tes return when its accountSend
  fails (exercised via a synthetic third-party recipient with no trust
  line; the production caller always uses the asset issuer, which
  cannot hit this branch).
- moveVaultAssets short-circuits and returns tesSUCCESS without
  touching accountSendMulti when every recipient's amount is zero
  (exercised via two zero-amount recipients, which still satisfies the
  recipients.size() > 1 precondition).
2026-08-10 16:34:36 +02:00
Vito
0a4dd08700 chore: Address inline review comments on Vault helpers
- Restore the doc comment on STAmount::isRounded that was lost when it
  moved out of LendingHelpers.cpp.
- Collapse LoanManage::defaultLoan's Vault-update block into a direct
  return of addVaultAssets, since there is no follow-up work after it.
- Mark removeVaultAssets's amount==0 early-return with LCOV_EXCL_LINE: it
  is a defensive short-circuit for a branch that is only reachable in a
  vanishingly rare edge case (final withdrawal from a vault whose
  sfAssetsAvailable has already been written down to zero) and is not
  reached by any current transactor-level test.
2026-08-10 16:34:29 +02:00
Vito
5d839f4811 fix: Silence -Wunused-variable on Vault clawback/withdraw asserts
Mark `assetsAvailable`/`assetsTotal` in VaultClawback::doApply and
`assetsTotalBefore` in VaultWithdraw::doApply as [[maybe_unused]]: each
is only referenced from XRPL_ASSERT, which is compiled out in Release
(NDEBUG) builds. Without the attribute clang -Werror,-Wunused-variable
would break the release CI configurations, matching the pattern already
addressed for VaultHelpers and LoanPay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 15:36:07 +02:00
Vito
00889a4307 fix: Silence -Wunused-variable on LoanPay asset asserts
Mark the raw pre-rounding projections `assetsAvailableAfterRaw` and
`assetsTotalAfterRaw` in LoanPay::doApply as [[maybe_unused]]: they are
only referenced from XRPL_ASSERT_PARTS, which is compiled out in Release
(NDEBUG) builds, so clang's -Werror,-Wunused-variable was breaking the
ubuntu-clang-release-amd64 and macos-arm64-release CI jobs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 14:34:08 +02:00
Vito
d6565f0e4d fix: Silence -Wunused-variable on the asset asserts
Mark the `Asset const asset` local in addVaultAssets and removeVaultAssets
as [[maybe_unused]]: it is only referenced from XRPL_ASSERT, which is
compiled out in Release (NDEBUG) builds, so clang's -Werror,
-Wunused-variable was breaking the ubuntu-clang-release-amd64 CI job.
The moveVaultAssets copy is used in real code paths and is unaffected.
2026-08-10 13:29:24 +02:00
Vito Tumas
72f75cbf43 Merge branch 'develop' into tapanito/vault-add-assets-helper 2026-08-10 12:51:59 +02:00
Vito
80ef2659e0 test: Add unit tests for the Vault balance-mutation helpers
Exercise addVaultAssets, clawbackVaultAssets, removeVaultAssets
(including FinalRemoval::Yes hard-reset and the amount > sfAssetsAvailable
failure path), and moveVaultAssets directly against a real Vault SLE and
ApplyView, built via jtx but bypassing the VaultDeposit/VaultWithdraw/
VaultClawback/LoanSet transactors. Covers zero-amount edges, negative
valueDelta, and the independent amount/valueDelta contract, in addition
to the existing transactor-level coverage in Vault_test.cpp,
LoanSet_test.cpp, etc.
2026-08-10 12:41:00 +02:00
Vito
518edd594c refactor: Route Vault/Loan transactors through the new Vault helpers
Wire VaultDeposit, VaultWithdraw, VaultClawback, LoanSet, LoanManage, and
LoanPay through addVaultAssets/removeVaultAssets/clawbackVaultAssets/
moveVaultAssets, replacing their ad hoc sfAssetsTotal/sfAssetsAvailable
proxy mutations and accountSend/accountSendMulti/doWithdraw calls:

- VaultDeposit and VaultWithdraw/VaultClawback use addVaultAssets and
  removeVaultAssets/clawbackVaultAssets respectively. VaultClawback uses
  the plain-accountSend overload rather than the doWithdraw-based one,
  since doWithdraw's self-holding-creation path would incorrectly reject
  a locked/frozen MPT issuance before checking that the recipient is the
  issuer (who always already holds their own asset).
- LoanSet's loan-principal-and-origination-fee disbursement uses
  moveVaultAssets.
- LoanManage's default write-off and LoanPay's payment both use
  addVaultAssets; addVaultAssets always applies valueDelta even when the
  transferred amount is zero, since a default written off entirely by
  the Vault (no first-loss capital cover) has a nonzero total delta but
  a zero transferred amount.
- LoanPay's post-rounding precision-loss guard now reads the Vault's
  actual post-rounding field values (after addVaultAssets/associateAsset
  have mutated and rounded them) instead of comparing raw pre-mutation
  arithmetic.
- associateAsset(*vault, ...) must run after the helper mutates the
  Vault's fields, never before: it rounds every asset-typed field on the
  Vault SLE to the asset's canonical precision, which the mutation
  itself does not do. Getting this ordering backwards is what caused a
  "value is already rounded" STNumber assertion failure during testing.

Also rename getAssetsTotalScale to VaultHelpers::getVaultScale at all
call sites.
2026-08-10 12:40:46 +02:00
Vito
3c51e3dad1 feat: Centralize Vault balance mutations in VaultHelpers
Add addVaultAssets, removeVaultAssets (two overloads: plain accountSend
and doWithdraw-based), clawbackVaultAssets, and moveVaultAssets as the
single points through which a Vault's sfAssetsTotal/sfAssetsAvailable are
mutated and funds move to/from its pseudo-account:

- addVaultAssets increases both fields and transfers in from a sender.
- removeVaultAssets/clawbackVaultAssets decrease both fields equally and
  transfer out; a FinalRemoval flag hard-resets both fields to exactly
  zero on a Vault's last withdrawal, since the discounted exchange-rate
  formula can produce values with more precision than the asset can
  canonically represent, and subtracting such a value would leave a
  non-canonical residual instead of an exact zero.
- moveVaultAssets decreases only sfAssetsAvailable, for disbursements
  (e.g. a loan's principal and origination fee) where sfAssetsTotal
  independently grows via accrued interest.

Also consolidate getAssetsTotalScale into VaultHelpers::getVaultScale,
and move isRounded from LendingHelpers into STAmount.h alongside the
other rounding utilities.
2026-08-10 12:40:10 +02:00
34 changed files with 4350 additions and 250 deletions

View File

@@ -248,6 +248,13 @@ words:
- queuable
- Raphson
- rcflags
- recognise
- recognises
- recognised
- renormalise
- renormalises
- renormalised
- renormalisation
- replayer
- rerandomize
- rerandomization

View File

@@ -2,6 +2,7 @@
#pragma once
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/LedgerEntryBase.h>

128
docs/dust-mechanism.md Normal file
View File

@@ -0,0 +1,128 @@
# Trust-line dust mechanism
Companion document to [`xrpl::DustSplit`](../include/xrpl/ledger/helpers/TokenHelpers.h)
and the [`xrpl::vault_dust::`](../include/xrpl/ledger/helpers/VaultHelpers.h)
Vault adopter. Introduced by `featureLendingProtocolV1_1`.
## Why sfDust
An IOU trust line stores its balance in `sfBalance`, an `STAmount`.
`STAmount` carries ~16 significant digits; `Number` (used throughout
protocol accounting) carries 19. When a caller mints or debits a value
that is finer than what `sfBalance` can represent — typical for
cash-basis Vault operations near a scale-refining decade boundary — the
sub-quantum remainder is dropped by rounding. Over many operations the
loss accumulates and drives receivables and available-cash counters
apart.
`sfDust` is a per-trust-line 19-digit `STNumber` field that holds that
sub-quantum remainder. The trust line's true holding is the
**extended balance** `sfBalance + sfDust`. `sfDust` is intentionally
declared without the `kSmdNeedsAsset` metadata flag: `associateAsset`
must not truncate it to `sfBalance`'s precision, since its whole
purpose is to carry value below that precision.
Pre-amendment `sfDust` is `SoeDefault(0)`. The ledger encoding of an
`ltRIPPLE_STATE` entry is byte-identical to before the amendment on any
line whose `sfDust` is zero, so activation is a strict extension.
## Two-legged trust-line touch
A `A -> B` IOU payment where `I` issues touches at most two trust
lines:
- the sender's line `RIPPLE_STATE{A, I}`;
- the receiver's line `RIPPLE_STATE{B, I}`.
`A` and `B` are the non-issuer parties on their respective lines; `I`
holds no line of its own for its own currency. Each non-issuer party
can independently opt into dust semantics on their OWN line by
attaching a per-leg `DustSplit::LegPolicy``sender` for the debit
leg, `receiver` for the credit leg. A leg without a sub-policy runs the
classic pre-dust code path byte-identically.
## Modes
`DustSplit::LegPolicy::Mode::Override`
: Trust-line layer keeps `sfBalance` representable at `overrideScale`
and parks any sub-quantum remainder in `sfDust`. Previously deferred
`sfDust` is automatically promoted when the combined `sfDust +
credit` clears a whole-quantum boundary — no separate "renormalise"
step. Callers supply `overrideScale` from their own accounting
(e.g. the Vault's posterior scale). Bounded scale drift up to one
decade may linger until the next operation that refines the scale.
`DustSplit::LegPolicy::Mode::Drain` (sender-leg only)
: Folds ALL of `sfDust` on the sender's line into the outgoing
transfer, then zeroes `sfDust`. Used for terminal removals where
the sender is winding down. There is no receiver-side counterpart
because a receiver has no reservoir to drain.
## Reporting sign convention
Out-fields (`balanceDelta`, `dustDelta`) on `LegPolicy` are reported
FROM THAT LEG'S NON-ISSUER PARTY'S PERSPECTIVE — party-positive:
- `sender` leg reports SENDER-POSITIVE deltas. A normal send makes the
sender's `balanceDelta` negative; a Drain reports `dustDelta ==
-sfDust_before`.
- `receiver` leg reports RECEIVER-POSITIVE deltas. A normal receive
makes the receiver's `balanceDelta` positive.
This lets a consumer reconcile its own bookkeeping symmetrically:
reads from `sender->balanceDelta` on a withdrawal/clawback line up in
sign with reads from `receiver->balanceDelta` on a deposit, without a
sign flip.
## Contract asserts (debug)
- Drain on the receiver leg is a caller error.
- Attaching a policy to the issuer side of a direct payment (where one
party IS the issuer) is a caller error — the policy must correspond
to the non-issuer party's leg.
- Any non-empty `DustSplit` requires `featureLendingProtocolV1_1`
enabled; a policy under an older rules set falls back to `nullptr`
and asserts in debug.
## Adding a new consumer
Vault is the sole consumer today (`xrpl::vault_dust::`). Adding a
sibling adopter (AMM, LoanBroker, …) is purely additive:
1. Add an eligibility gate in a new sibling namespace, analogous to
`xrpl::vault_dust::useVaultDust`.
2. Construct a `DustSplit` in that consumer's transactor helpers,
using the scale implied by that consumer's own accounting.
3. Reconcile the consumer's bookkeeping using the reported deltas.
The trust-line layer needs no per-consumer awareness. Do NOT extend
`xrpl::vault_dust::` to serve non-Vault consumers.
## Amendment-gate policy
Every code path that reads or writes `sfDust` enforces
`rules().enabled(featureLendingProtocolV1_1)`:
- write side: `directSendNoFeeIOU` (this is the canonical anchor);
- read side: `creditBalanceExact`;
- deletion guard: `removeEmptyHolding`;
- consumer eligibility: `vault_dust::useVaultDust`.
In normal operation the gate is redundant — the transactor-level
eligibility predicates already imply the amendment is on — but a
hypothetical replay/testing path that ever presented a dust-aware
request under a pre-amendment `rules()` must NOT touch `sfDust`: the
field would then be neither `SoeDefault(0)` as expected pre-amendment
nor part of the ledger's canonical encoding. The gate asserts in
debug and falls back to the pre-dust code path in release.
## Related tests
- `src/test/app/lending/VaultRoundingTrustlineDust_test.cpp`
covers the end-to-end dust behaviour, including the extended-balance
invariant path, sender-leg Override reporting on non-terminal
withdraw, and end-to-end Drain via defaulted-loan terminal
withdrawal.
- `src/test/app/lending/VaultRounding_test.cpp` relaxes the O2
dust bound to allow the bounded decade-boundary drift documented
above.

View File

@@ -5,6 +5,7 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Keylet.h>
@@ -223,7 +224,8 @@ doWithdraw(
AccountID const& sourceAcct,
XRPAmount priorBalance,
STAmount const& amount,
beast::Journal j);
beast::Journal j,
DustSplit* dust = nullptr);
/**
* Deleter function prototype. Returns the status of the entry deletion

View File

@@ -7,6 +7,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
@@ -216,14 +217,6 @@ adjustImpreciseNumber(
value = 0;
}
inline int
getAssetsTotalScale(SLE::const_ref vaultSle)
{
if (!vaultSle)
return Number::kMinExponent - 1; // LCOV_EXCL_LINE
return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset));
}
// Compute the minimum required broker cover, rounded consistently.
// DebtTotal is a broker-level aggregate maintained at vault scale, so the
// rounding must also use vault scale — never an individual loan's scale.
@@ -236,7 +229,7 @@ minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::c
return roundToAsset(
vaultSle->at(sfAsset),
tenthBipsOfValue(debtTotal, coverRateMinimum),
getAssetsTotalScale(vaultSle));
getVaultScale(vaultSle));
}
TER
@@ -610,9 +603,6 @@ computeLoanProperties(
TenthBips32 managementFeeRate,
std::int32_t minimumScale);
bool
isRounded(Asset const& asset, Number const& value, std::int32_t scale);
// Indicates what type of payment is being made.
// regular, late, and full are mutually exclusive.
// overpayment is an "add on" to a regular payment, and follows that path with

View File

@@ -1,5 +1,6 @@
#pragma once
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
@@ -67,6 +68,43 @@ creditBalance(
Currency const& currency);
/** @} */
/**
* Read the trust-line balance from @p account's perspective at exact
* precision, folding in the trust line's sfDust reservoir so the returned
* Number represents the true unrounded holding.
*
* The scale of the returned Number is generally finer than sfBalance's
* canonical precision — callers who want the STAmount-representable part
* should keep using @ref creditBalance. This helper is the read side that
* complements @ref DustSplit's write side: together they let a caller keep
* an off-line total of a trust line without losing sub-quantum drift.
*
* Returns Number{0} when the trust line does not exist.
*
* Gated on featureLendingProtocolV1_1; see directSendNoFeeIOU for the
* canonical amendment-gate rationale.
*
* @param view the ledger to check against.
* @param account the account whose perspective determines the sign.
* @param issuer the counterparty of the trust line.
* @param currency the IOU to check.
*/
Number
creditBalanceExact(
ReadView const& view,
AccountID const& account,
AccountID const& issuer,
Currency const& currency);
/**
* @overload
*/
inline Number
creditBalanceExact(ReadView const& view, AccountID const& account, Issue const& issue)
{
return creditBalanceExact(view, account, issue.account, issue.currency);
}
//------------------------------------------------------------------------------
//
// Freeze checking (IOU-specific)

View File

@@ -17,6 +17,7 @@
#include <cstdint>
#include <initializer_list>
#include <optional>
#include <utility>
#include <vector>
@@ -60,6 +61,56 @@ enum class AllowMPTOverflow : bool { No = false, Yes };
*/
enum class WaiveMPTCanTransfer : bool { No = false, Yes };
/**
* Feature-agnostic trust-line dust primitive.
*
* One struct with two optional per-leg sub-policies (`sender`,
* `receiver`); `accountSend` and its callees route each sub-policy to
* the corresponding trust-line touch. A leg without a sub-policy runs
* the classic pre-dust path byte-identically.
*
* Modes:
* - `Override`: keep `sfBalance` representable at `overrideScale`; park
* any sub-quantum remainder in `sfDust`. Previously deferred `sfDust`
* is automatically promoted when the combined `sfDust + credit`
* clears a whole-quantum boundary.
* - `Drain` (sender-leg only): fold all of the sender-line's `sfDust`
* into the outgoing transfer, then zero `sfDust`. Used for terminal
* removals; there is no receiver-side counterpart.
*
* Out-fields (`balanceDelta`, `dustDelta`) report from THAT LEG'S
* NON-ISSUER PARTY'S perspective (party-positive): sender-leg is
* sender-positive, receiver-leg is receiver-positive.
*
* Contract asserts (debug):
* - `Drain` on the receiver leg is a caller error.
* - The policy must correspond to the non-issuer party's leg (issuer
* side must be null).
* - Any non-empty `DustSplit` requires `featureLendingProtocolV1_1`.
*
* See docs/dust-mechanism.md for the full design rationale (motivation,
* two-legged trust-line touch model, sign convention, amendment gate
* policy, consumer-adopter pattern).
*/
struct DustSplit
{
struct LegPolicy
{
enum class Mode { Override, Drain };
Mode mode = Mode::Override;
int overrideScale = 0; // used only when mode == Override; exponent
// sfBalance must remain representable at
// out (from this leg's non-issuer party's perspective):
Number balanceDelta{};
Number dustDelta{};
};
std::optional<LegPolicy> sender;
std::optional<LegPolicy> receiver;
};
/* Check if MPToken (for MPT) or trust line (for IOU) exists:
* - StrongAuth - before checking if authorization is required
* - WeakAuth
@@ -386,7 +437,8 @@ accountSend(
beast::Journal j,
SLE::ref sponsorSle = {},
WaiveTransferFee waiveFee = WaiveTransferFee::No,
AllowMPTOverflow allowOverflow = AllowMPTOverflow::No);
AllowMPTOverflow allowOverflow = AllowMPTOverflow::No,
DustSplit* dust = nullptr);
using MultiplePaymentDestinations = std::vector<std::pair<AccountID, Number>>;
/**
@@ -395,6 +447,10 @@ using MultiplePaymentDestinations = std::vector<std::pair<AccountID, Number>>;
*
* Calls static accountSendMultiIOU if saAmount represents Issue.
* Calls static accountSendMultiMPT if saAmount represents MPTIssue.
*
* The optional `dust` split applies only to the sender's leg (the single
* shared sender trust line across all destinations). Only `dust->sender`
* is consulted; `dust->receiver` must be nullopt.
*/
[[nodiscard]] TER
accountSendMulti(
@@ -403,7 +459,8 @@ accountSendMulti(
Asset const& asset,
MultiplePaymentDestinations const& receivers,
beast::Journal j,
WaiveTransferFee waiveFee = WaiveTransferFee::No);
WaiveTransferFee waiveFee = WaiveTransferFee::No,
DustSplit* dust = nullptr);
[[nodiscard]] TER
transferXRP(

View File

@@ -1,10 +1,15 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <optional>
@@ -123,4 +128,313 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref
[[nodiscard]] VaultVersion
getVaultVersion(SLE::const_ref vault);
/**
* Returns the scale (number of decimal places) at which a vault's
* sfAssetsTotal is maintained, derived from the vault's asset and its
* current sfAssetsTotal value.
*
* @param vault The vault SLE.
*
* @return The vault's scale, or `Number::kMinExponent - 1` if `vault` is
* null.
*/
[[nodiscard]] int
getVaultScale(SLE::const_ref vault);
/**
* The single point through which assets are added to a Vault: updates the
* Vault's sfAssetsTotal and sfAssetsAvailable and transfers `amount` of the
* Vault's asset from `sender` to the Vault's pseudo-account.
*
* Callers are responsible for rounding `amount` and `valueDelta` to whatever
* scale is appropriate for their own accounting (e.g. current vs. posterior
* Vault scale); this helper does not perform any additional rounding.
*
* @param view The ledger view to apply changes to.
* @param vault The vault SLE. Must not be null.
* @param sender The account to transfer `amount` from.
* @param amount The amount to add to sfAssetsAvailable, and to transfer from
* `sender` to the Vault's pseudo-account.
* @param valueDelta The amount to add to sfAssetsTotal. May differ from
* `amount`, e.g. when recognizing a value change that is
* not fully backed by a matching cash transfer. May be
* negative (e.g. a default write-off, or a small rounding
* correction), unlike `amount`.
* @param j Journal for logging.
*
* @return TER on success or failure.
*/
[[nodiscard]] TER
addVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& sender,
STAmount const& amount,
STAmount const& valueDelta,
beast::Journal j);
/**
* Signals that a removal is the last one possible for a Vault — i.e. it
* burns every outstanding share. removeVaultAssets uses this to hard-reset
* sfAssetsTotal and sfAssetsAvailable to exactly zero, rather than
* subtracting `amount`/`valueDelta` from them.
*
* This matters because the discounted exchange-rate formula used to compute
* a withdrawal's `amount` can produce a value with more decimal precision
* than the Vault's asset can canonically represent. Subtracting such a
* value from the field would leave a non-canonical residual instead of an
* exact zero, corrupting the ledger entry. A final removal is defined to
* exhaust the Vault's exposure entirely, so hard-resetting to zero is both
* simpler and correct — no residual dust is possible or desired.
*/
enum class FinalRemoval : bool { No = false, Yes = true };
/**
* The single point through which assets are clawed back from a Vault entirely:
* decreases the Vault's sfAssetsTotal and sfAssetsAvailable
* and transfers @p amount from the Vault's pseudo-account to @p recipient via
* a plain accountSend.
*
* Callers are responsible for rounding @p amount to whatever
* scale is appropriate for their own accounting; this helper does not
* perform any additional rounding.
*
* @param view The ledger view to apply changes to.
* @param vault The vault SLE. Must not be null.
* @param recipient The account to transfer `amount` to. Must already be
* able to hold the Vault's asset without further setup.
* @param amount The amount to clawback from the vault and transfer from the
* Vault's pseudo-account to `recipient`. Must be positive;
* callers must skip calling this helper entirely for a
* zero-amount clawback (unlike addVaultAssets/removeVaultAssets,
* which tolerate a zero `amount`).
* @param j Journal for logging.
*
* @return TER code.
*/
[[nodiscard]] TER
clawbackVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& recipient,
STAmount const& amount,
beast::Journal j);
/**
* The single point through which assets are removed from a Vault entirely
* and withdrawn to a recipient that may not yet have a holding for the
* Vault's asset: decreases the Vault's sfAssetsTotal and sfAssetsAvailable
* and calls doWithdraw to transfer @p amount from the
* Vault's pseudo-account to @p dstAcct.
*
* Unlike clawbackVaultAssets, this relies solely on doWithdraw's own
* pre-transfer balance check rather than an additional post-transfer sanity
* check.
*
* Callers are responsible for rounding @p amount; this helper does not
* perform any additional rounding, except when `finalRemoval` is Yes (see
* FinalRemoval).
*
* @param ctx The apply-view context to apply changes to.
* @param vault The vault SLE. Must not be null.
* @param senderAcct The account that submitted the withdrawal transaction.
* @param dstAcct The account to transfer `amount` to; may equal `senderAcct`.
* @param priorBalance The XRP reserve base, passed through to doWithdraw for
* creating a holding for `dstAcct` when required.
* @param amount The amount to subtract from sfAssetsAvailable, and to
* transfer from the Vault's pseudo-account to `dstAcct`.
* Ignored (other than for the transfer) when `finalRemoval` is
* Yes.
* @param j Journal for logging.
* @param finalRemoval Whether this is the Vault's final removal (see
* FinalRemoval).
*
* @return TER from doWithdraw.
*/
[[nodiscard]] TER
removeVaultAssets(
ApplyViewContext ctx,
SLE::ref vault,
AccountID const& senderAcct,
AccountID const& dstAcct,
XRPAmount priorBalance,
STAmount const& amount,
beast::Journal j,
FinalRemoval finalRemoval = FinalRemoval::No);
/**
* The single point through which cash is moved out of a Vault's
* sfAssetsAvailable to multiple recipients in a single atomic payment, e.g.
* a loan's principal and origination fee, without necessarily shrinking the
* Vault's total exposure: updates sfAssetsAvailable (decreases by the sum of
* `recipients`' amounts) and sfAssetsTotal (changes by `valueDelta`, same
* sign convention as addVaultAssets — typically an increase, since
* disbursing a loan recognizes accrued interest into sfAssetsTotal even as
* cash leaves the Vault), then transfers the Vault's asset from the Vault's
* pseudo-account to each of `recipients`, via accountSendMulti.
*
* Unlike removeVaultAssets, this is not a removal — the Vault's receivables
* grow to match the cash that leaves sfAssetsAvailable, so there is no
* "final" edge case to handle here.
*
* Recipients must already be able to hold the Vault's asset (e.g. via
* addEmptyHolding and requireAuth performed by the caller beforehand); this
* helper does not create holdings or check authorization.
*
* sfAssetsAvailable is decreased by an STAmount built from the sum of the
* recipients' Numbers, so for a very large recipient list whose sum exceeds
* STAmount's ~16 significant digits, this could round differently than
* summing the underlying Numbers directly. Not a concern for the current
* caller (LoanSet, two recipients).
*
* @param view The ledger view to apply changes to.
* @param vault The vault SLE. Must not be null.
* @param recipients The accounts and amounts to transfer from the Vault's
* pseudo-account. Must contain more than one entry.
* @param valueDelta The amount to add to sfAssetsTotal (same convention as
* addVaultAssets). May be negative, and may differ from
* the sum of `recipients`' amounts.
* @param j Journal for logging.
*
* @return TER from accountSendMulti.
*/
[[nodiscard]] TER
moveVaultAssets(
ApplyView& view,
SLE::ref vault,
MultiplePaymentDestinations const& recipients,
STAmount const& valueDelta,
beast::Journal j);
/**
* @namespace vault_dust
*
* Vault-side adopter of the trust-line dust mechanism (see
* `xrpl::DustSplit` in TokenHelpers.h, and docs/dust-mechanism.md).
* Owns the Vault-level orchestration only: an eligibility gate
* (`useVaultDust`) and dust-aware overloads of the four base Vault
* helpers, with identical signatures so the transactor call sites can
* stay agnostic.
*
* Non-Vault features that want the same primitives should add a sibling
* namespace with their own gate and helper overloads — do not extend
* this namespace.
*
* The four helper overloads here are the ONLY code in the tree that
* constructs a `xrpl::DustSplit`; every other caller uses the base
* helpers verbatim.
*/
namespace vault_dust {
/**
* Whether this Vault's custody trust line participates in the sfDust
* mechanism. True only when featureLendingProtocolV1_1 is enabled AND the
* Vault is cash-basis (sfLEVersion == VaultVersion::CashBasis) AND its
* asset is an IOU. Every other case (Legacy vault, integral asset,
* amendment disabled) skips every dust-aware code path.
*
* See directSendNoFeeIOU for the canonical amendment-gate rationale.
*
* @param view The ledger view (for amendment lookup).
* @param vault The vault SLE.
*/
[[nodiscard]] bool
useVaultDust(ReadView const& view, SLE::const_ref vault);
/**
* Dust-aware overload of `xrpl::addVaultAssets`. Same signature as the
* base version. The dispatcher in `xrpl::addVaultAssets` forwards here
* when `useVaultDust(view, vault)` returns true.
*
* The credit path uses a `DustSplit` targeting the Vault's posterior
* scale (the scale implied by `sfAssetsTotal + valueDelta`, an upper
* bound on the Vault's post-op scale), so any sub-quantum remainder in
* `amount` lands in the custody line's `sfDust` rather than being lost.
* Both `sfAssetsTotal` and `sfAssetsAvailable` are updated with the split
* outputs so the receivable (`sfAssetsTotal - sfAssetsAvailable`) is
* identical to what a dust-unaware call would produce.
*/
[[nodiscard]] TER
addVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& sender,
STAmount const& amount,
STAmount const& valueDelta,
beast::Journal j);
/**
* Dust-aware overload of `xrpl::clawbackVaultAssets`. Same signature as
* the base version. The dispatcher forwards here when
* `useVaultDust(view, vault)` returns true.
*
* A clawback shrinks `sfAssetsTotal`, which can refine the Vault's
* scale. This overload drives the transfer through a sender-leg
* `DustSplit::LegPolicy::Mode::Override` at the Vault's posterior
* scale; the trust-line layer re-splits `sfBalance`/`sfDust` on the
* custody line and reports any promoted (or newly-deferred) sub-quantum
* residual so the Vault fields stay aligned with the extended balance.
*/
[[nodiscard]] TER
clawbackVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& recipient,
STAmount const& amount,
beast::Journal j);
/**
* Dust-aware overload of `xrpl::removeVaultAssets`. Same signature as the
* base version. The dispatcher forwards here when
* `useVaultDust(ctx.view, vault)` returns true.
*
* Non-terminal (`FinalRemoval::No`): drives the withdrawal through a
* sender-leg `DustSplit::LegPolicy::Mode::Override` at the Vault's
* posterior scale, so any dust stranded on the custody line by a
* scale-refining update is renormalised inside the trust-line layer
* (no separate promotion pass in Vault code).
*
* Terminal (`FinalRemoval::Yes`): drives the withdrawal through a
* sender-leg `DustSplit::LegPolicy::Mode::Drain`. The trust-line layer
* folds the custody line's `sfDust` reservoir into `sfBalance`,
* inflates the outgoing transfer by the reservoir so the destination
* receives `amount + drainedDust`, and zeroes `sfDust` — leaving the
* line with `sfBalance == 0` and `sfDust == 0`, a precondition for
* downstream Vault-cleanup deletion guards.
*/
[[nodiscard]] TER
removeVaultAssets(
ApplyViewContext ctx,
SLE::ref vault,
AccountID const& senderAcct,
AccountID const& dstAcct,
XRPAmount priorBalance,
STAmount const& amount,
beast::Journal j,
FinalRemoval finalRemoval = FinalRemoval::No);
/**
* Dust-aware overload of `xrpl::moveVaultAssets`. Same signature as the
* base version. The dispatcher forwards here when
* `useVaultDust(view, vault)` returns true.
*
* A multi-recipient move (typically a loan disbursement) is a
* cash-out-plus-recognition on the Vault side; it shrinks
* `sfAssetsAvailable` and may change `sfAssetsTotal` via `valueDelta`.
* Both changes can refine the Vault's scale, so this overload attaches
* a sender-leg `DustSplit::LegPolicy::Mode::Override` at the Vault's
* posterior scale to `accountSendMulti`; the trust-line layer
* renormalises any newly-representable dust on the custody line during
* the bulk sender-line debit.
*/
[[nodiscard]] TER
moveVaultAssets(
ApplyView& view,
SLE::ref vault,
MultiplePaymentDestinations const& recipients,
STAmount const& valueDelta,
beast::Journal j);
} // namespace vault_dust
} // namespace xrpl

View File

@@ -772,6 +772,17 @@ roundToAsset(
return roundToScale(ret, scale);
}
/* Checks if a value is already rounded to the specified scale.
* Returns true if rounding down and rounding up produce the same result,
* indicating no further precision exists beyond the scale.
*/
[[nodiscard]] inline bool
isRounded(Asset const& asset, Number const& value, std::int32_t scale)
{
return roundToAsset(asset, value, scale, Number::RoundingMode::Downward) ==
roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
}
//------------------------------------------------------------------------------
inline bool

View File

@@ -291,6 +291,7 @@ LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({
{sfHighQualityOut, SoeOptional},
{sfHighSponsor, SoeOptional},
{sfLowSponsor, SoeOptional},
{sfDust, SoeDefault},
}))
/** The ledger object which lists the network's fee settings.

View File

@@ -236,6 +236,14 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset | SField::kSmdDefault)
TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16)
TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault)
// sfDust is a sub-quantum residual on a trust line: the fraction of a credit
// that could not be represented in sfBalance at the caller-chosen accounting
// scale, parked here so the extended quantity (sfBalance + sfDust) preserves
// its exact value across successive rounded credits. Deliberately WITHOUT
// kSmdNeedsAsset: that flag rounds the field to the asset's representable
// precision via associateAsset(), which is the opposite of this field's
// purpose (it exists to hold value finer than that precision).
TYPED_SFIELD(sfDust, NUMBER, 18, SField::kSmdDefault)
// int32
TYPED_SFIELD(sfLoanScale, INT32, 1)

View File

@@ -2,6 +2,7 @@
#pragma once
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/LedgerEntryBase.h>
@@ -291,6 +292,30 @@ public:
{
return this->sle_->isFieldPresent(sfLowSponsor);
}
/**
* @brief Get sfDust (SoeDefault)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
protocol_autogen::Optional<SF_NUMBER::type::value_type>
getDust() const
{
if (hasDust())
return this->sle_->at(sfDust);
return std::nullopt;
}
/**
* @brief Check if sfDust is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasDust() const
{
return this->sle_->isFieldPresent(sfDust);
}
};
/**
@@ -482,6 +507,17 @@ public:
return *this;
}
/**
* @brief Set sfDust (SoeDefault)
* @return Reference to this builder for method chaining.
*/
RippleStateBuilder&
setDust(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
{
object_[sfDust] = value;
return *this;
}
/**
* @brief Build and return the completed RippleState wrapper.
* @param index The ledger entry index.

View File

@@ -73,6 +73,17 @@ public:
{
Number delta = kNumZero;
std::optional<int> scale;
// For @c ltRIPPLE_STATE entries under @c featureLendingProtocolV1_1,
// this captures the trust line's @c sfDust delta in the same
// low/high convention as @c delta. Used by the withdrawal
// destination check to compare against the EXTENDED balance
// (@c sfBalance + @c sfDust) on the vault's pseudo-account
// custody line, so a dust reshuffle (e.g. Override promoting
// sub-quantum residual into @c sfBalance, or Drain folding
// @c sfDust in) does not spuriously fail the "vault and
// destination balance change by equal amount" invariant. Left
// at zero for entries that do not carry @c sfDust.
Number dustDelta = kNumZero;
// Compute the delta between two Numbers, taking the coarsest scale
[[nodiscard]] static DeltaInfo

View File

@@ -438,7 +438,8 @@ doWithdraw(
AccountID const& sourceAcct,
XRPAmount priorBalance,
STAmount const& amount,
beast::Journal j)
beast::Journal j,
DustSplit* dust)
{
auto const dstSle = ctx.view.read(keylet::account(dstAcct));
@@ -479,9 +480,18 @@ doWithdraw(
return sponsorSle.error(); // LCOV_EXCL_LINE
// Move the funds directly from the broker's pseudo-account to the
// dstAcct
// dstAcct. Forward any dust policy through accountSend so per-leg
// renormalisation / drain semantics apply on the sourceAcct's line.
return accountSend(
ctx.view, sourceAcct, dstAcct, amount, j, *sponsorSle, WaiveTransferFee::Yes);
ctx.view,
sourceAcct,
dstAcct,
amount,
j,
*sponsorSle,
WaiveTransferFee::Yes,
AllowMPTOverflow::No,
dust);
}
TER

View File

@@ -120,17 +120,6 @@ loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
return tenthBipsOfValue(Number(paymentInterval), interestRate) / kSecondsInYear;
}
/* Checks if a value is already rounded to the specified scale.
* Returns true if rounding down and rounding up produce the same result,
* indicating no further precision exists beyond the scale.
*/
bool
isRounded(Asset const& asset, Number const& value, std::int32_t scale)
{
return roundToAsset(asset, value, scale, Number::RoundingMode::Downward) ==
roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
}
namespace accrual {
AccountingDeltas

View File

@@ -22,6 +22,7 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/XRPAmount.h>
@@ -97,6 +98,37 @@ creditBalance(
return result;
}
Number
creditBalanceExact(
ReadView const& view,
AccountID const& account,
AccountID const& issuer,
Currency const& currency)
{
auto const sleRippleState = view.read(keylet::trustLine(account, issuer, currency));
if (!sleRippleState)
return Number{0};
STAmount balance = sleRippleState->getFieldAmount(sfBalance);
// Put balance in @p account's terms (sfBalance is stored in the line's
// own low/high convention).
if (account < issuer)
balance.negate();
// Amendment gate is defense-in-depth; see directSendNoFeeIOU
// (TokenHelpers.cpp) for the canonical rationale.
if (!view.rules().enabled(featureLendingProtocolV1_1))
return Number{balance};
// sfDust follows sfBalance's sign convention, so the same negation
// applies.
Number dust = sleRippleState->at(sfDust);
if (account < issuer)
dust = -dust;
return Number{balance} + dust;
}
//------------------------------------------------------------------------------
//
// Freeze checking (IOU-specific)
@@ -729,8 +761,23 @@ removeEmptyHolding(
auto const line = ctx.view.peek(keylet::trustLine(accountID, issue));
if (!line)
return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND;
if (!accountIsIssuer && line->at(sfBalance)->iou() != beast::kZero)
return tecHAS_OBLIGATIONS;
// Obligation checks apply only to non-issuer holdings: the issuer's
// own trust-line is bookkeeping for the counterparty, not a holding
// that carries an obligation to the issuer.
if (!accountIsIssuer)
{
if (line->at(sfBalance)->iou() != beast::kZero)
return tecHAS_OBLIGATIONS;
// A line can be balance-zero yet hold non-zero sfDust: a debit
// can legally consume all representable balance and leave only
// dust. Refuse to delete such a line — dust is invisible to
// accountHolds, so deletion would silently destroy that value.
// Amendment gate is defense-in-depth; see directSendNoFeeIOU.
if (ctx.view.rules().enabled(featureLendingProtocolV1_1) &&
Number{line->at(sfDust)} != beast::kZero)
return tecHAS_OBLIGATIONS;
}
// Adjust the owner count(s)
if (line->isFlag(lsfLowReserve))

View File

@@ -24,6 +24,7 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/XRPAmount.h>
@@ -639,6 +640,14 @@ canTransfer(
// - Redeeming IOUs and/or sending sender's own IOUs.
// - Create trust line if needed.
// --> bCheckIssuer : normally require issuer to be involved.
//
// The optional `legPolicy` argument corresponds to the NON-ISSUER PARTY on
// this specific trust line touch. For a direct payment (one party IS the
// issuer) it is the non-issuer party's leg policy; for a transit leg it
// is either the sender-leg or receiver-leg policy of the enclosing
// two-legged send. `legPolicy` reports its `balanceDelta`/`dustDelta`
// from THAT PARTY's perspective (party-positive: positive means the
// non-issuer party's holdings grew).
static TER
directSendNoFeeIOU(
ApplyView& view,
@@ -647,7 +656,9 @@ directSendNoFeeIOU(
STAmount const& saAmount,
bool bCheckIssuer,
SLE::ref sponsorSle,
beast::Journal j)
beast::Journal j,
DustSplit::LegPolicy* legPolicy = nullptr,
bool legIsSender = false)
{
AccountID const& issuer = saAmount.getIssuer();
Currency const& currency = saAmount.get<Issue>().currency;
@@ -671,6 +682,34 @@ directSendNoFeeIOU(
!isXRP(uReceiverID) && uReceiverID != noAccount(),
"xrpl::directSendNoFeeIOU : receiver is not XRP");
// Canonical amendment gate for the sfDust mechanism.
//
// sfDust is introduced by featureLendingProtocolV1_1. Every code path
// that reads or writes sfDust (vault_dust::useVaultDust,
// creditBalanceExact, the removeEmptyHolding dust guard, and this
// write-side check) enforces the same rules().enabled(...) gate.
// In normal operation the gate is redundant — the transactor-level
// eligibility predicates already imply the amendment is on — but a
// hypothetical replay/testing path that ever presented a dust-aware
// request under a pre-amendment rules() must NOT touch sfDust: the
// field would then be neither SoeDefault(0) as expected pre-amendment
// nor part of the ledger's canonical encoding. Assert in debug, fall
// back to the pre-dust code path in release.
if (legPolicy != nullptr && !view.rules().enabled(featureLendingProtocolV1_1))
{
XRPL_ASSERT(
false,
"xrpl::directSendNoFeeIOU : DustSplit::LegPolicy requires "
"featureLendingProtocolV1_1");
legPolicy = nullptr;
}
// Drain mode is defined only for the sender's leg. There is no
// receiver-side reservoir to drain.
XRPL_ASSERT(
legPolicy == nullptr || legPolicy->mode != DustSplit::LegPolicy::Mode::Drain || legIsSender,
"xrpl::directSendNoFeeIOU : Drain mode is sender-leg only");
// If the line exists, modify it accordingly.
if (auto const sleRippleState = view.peek(index))
{
@@ -683,7 +722,119 @@ directSendNoFeeIOU(
STAmount const saBefore = saBalance;
saBalance -= saAmount;
// dustLedgerAfter holds the value that must be written to sfDust once
// the branch below decides bDelete, in the LINE'S OWN sign
// convention (i.e. not yet negated back to sender terms). Left
// unset (nullopt) when this call must not touch sfDust at all —
// which is every existing caller (legPolicy == nullptr) — so a
// line that never carries dust is byte-identical to before.
std::optional<Number> dustLedgerAfter;
if (legPolicy != nullptr)
{
// sfBalance and sfDust together are one signed extended
// quantity, expressed in the trust line's own low/high sign
// convention. Convert sfDust to sender terms so it lines up
// with saBefore (which was negated above when the sender is
// the high account).
Number const lineDustLedger = Number{sleRippleState->at(sfDust)};
Number const lineDustSender = bSenderHigh ? -lineDustLedger : lineDustLedger;
// The Vault-side write sites only ever park non-negative
// dust on the pseudo-account's custody line. The rest of the
// math below tolerates negative dust, but under Drain we
// assert non-negativity because folding a negative reservoir
// into the outgoing transfer would silently INCREASE the
// amount sent — this must never happen in practice.
XRPL_ASSERT(
lineDustSender >= beast::kZero || !legIsSender ||
legPolicy->mode != DustSplit::LegPolicy::Mode::Drain,
"xrpl::directSendNoFeeIOU : sender-line dust is non-negative under Drain");
Number newBalance{0};
Number newDust{0};
if (legPolicy->mode == DustSplit::LegPolicy::Mode::Drain)
{
// Drain: fold all of sfDust into sfBalance in place,
// then debit `saAmount` — which the caller
// (directSendNoLimitIOU) has already inflated by
// `lineDustSender` so the outgoing transfer reaches the
// receiver as `amount + D_sender`. The end state on the
// sender's line is:
// sfBalance_new = (saBefore + lineDustSender) - saAmount
// = saBefore - (saAmount - lineDustSender)
// = saBefore - amount_originally_requested
// sfDust_new = 0
// i.e. the sender's line loses exactly the caller's
// originally-requested `amount` in whole-quanta terms,
// and the sub-quantum reservoir has been forwarded.
newBalance = (Number{saBefore} + lineDustSender) - Number{saAmount};
newDust = Number{0};
}
else
{
// Override mode: keep sfBalance representable at
// `overrideScale`; park any sub-quantum remainder in
// sfDust. Truncate the SUM toward zero — never round
// the increment separately. This is what makes
// promotion of previously-deferred dust automatic: if
// the combined lineDustSender + credit clears a whole
// quantum, that quantum lands in newBalance with no
// separate fold step.
Number const exactBefore = Number{saBefore} + lineDustSender;
Number const exactAfter = exactBefore - Number{saAmount};
newBalance = roundToAsset(
saAmount.asset(),
exactAfter,
legPolicy->overrideScale,
Number::RoundingMode::TowardsZero);
newDust = exactAfter - newBalance;
XRPL_ASSERT(
newDust == beast::kZero || abs(newDust) < Number(1, legPolicy->overrideScale),
"xrpl::directSendNoFeeIOU : dust remainder bounded by one quantum");
XRPL_ASSERT(
newDust == beast::kZero || exactAfter == beast::kZero ||
(newDust < beast::kZero) == (exactAfter < beast::kZero),
"xrpl::directSendNoFeeIOU : dust does not change sign of the extended "
"quantity");
}
Number const balanceDeltaSender = newBalance - Number{saBefore};
Number const dustDeltaSender = newDust - lineDustSender;
// Report the deltas from the leg's non-issuer party's
// perspective. Sender-leg reports sender-positive; the
// internal computation is already in sender terms, so on
// the sender-leg pass we report the sender-space deltas as
// is. Receiver-leg reports receiver-positive, which is the
// sign-flip of sender-space deltas (since balance changes
// net to -saAmount).
if (legIsSender)
{
legPolicy->balanceDelta = balanceDeltaSender;
legPolicy->dustDelta = dustDeltaSender;
}
else
{
legPolicy->balanceDelta = -balanceDeltaSender;
legPolicy->dustDelta = -dustDeltaSender;
}
// newBalance is in SENDER terms (built from saBefore, which
// was put there by the negation above); the existing
// negate-back-to-ledger-terms code below applies to it
// exactly as it would to the non-dust subtraction result.
saBalance = STAmount{saAmount.asset(), newBalance};
dustLedgerAfter = bSenderHigh ? -newDust : newDust;
}
else
{
saBalance -= saAmount;
}
JLOG(j.trace()) << "directSendNoFeeIOU: " << to_string(uSenderID) << " -> "
<< to_string(uReceiverID) << " : before=" << saBefore.getFullText()
@@ -733,6 +884,25 @@ directSendNoFeeIOU(
// Receiver reserve is clear.
}
// Trust-line-level protocol invariant: never delete a line whose
// sfDust is non-zero — a deleted RIPPLE_STATE silently destroys
// any value still parked there. The guard applies to the
// dust-unaware branch too (legPolicy == nullptr), because the
// line may carry sfDust left by an earlier dust-aware credit.
// Skip the field-read entirely pre-amendment so the hot path
// stays byte-identical to the base branch.
if (dustLedgerAfter)
{
if (*dustLedgerAfter != beast::kZero)
bDelete = false;
}
else if (
view.rules().enabled(featureLendingProtocolV1_1) &&
Number{sleRippleState->at(sfDust)} != beast::kZero)
{
bDelete = false;
}
if (bSenderHigh)
saBalance.negate();
@@ -740,6 +910,9 @@ directSendNoFeeIOU(
sleRippleState->setFieldAmount(sfBalance, saBalance);
// ONLY: Adjust balance.
if (dustLedgerAfter)
sleRippleState->at(sfDust) = *dustLedgerAfter;
if (bDelete)
{
return trustDelete(
@@ -754,6 +927,22 @@ directSendNoFeeIOU(
return tesSUCCESS;
}
// A LegPolicy implies the trust line already exists — the caller is
// supposed to arrange this (e.g. via addEmptyHolding) before any
// dust-aware credit reaches it. Treat reaching here with a policy
// as a caller error: assert in debug, and in release fall back to a
// plain no-split credit so no value is silently dropped.
XRPL_ASSERT(
legPolicy == nullptr,
"xrpl::directSendNoFeeIOU : dust split requires an existing trust line");
if (legPolicy != nullptr)
{
// LCOV_EXCL_START
legPolicy->balanceDelta = legIsSender ? -Number{saAmount} : Number{saAmount};
legPolicy->dustDelta = Number{0};
// LCOV_EXCL_STOP
}
STAmount const saReceiverLimit(Issue{currency, uReceiverID});
STAmount saBalance{saAmount};
@@ -801,7 +990,8 @@ directSendNoLimitIOU(
STAmount& saActual,
beast::Journal j,
SLE::ref sponsorSle,
WaiveTransferFee waiveFee)
WaiveTransferFee waiveFee,
DustSplit* dust = nullptr)
{
auto const& issuer = saAmount.getIssuer();
@@ -810,14 +1000,96 @@ directSendNoLimitIOU(
"xrpl::directSendNoLimitIOU : neither sender nor receiver is XRP");
XRPL_ASSERT(uSenderID != uReceiverID, "xrpl::directSendNoLimitIOU : sender is not receiver");
DustSplit::LegPolicy* receiverPolicy =
(dust != nullptr && dust->receiver.has_value()) ? &*dust->receiver : nullptr;
DustSplit::LegPolicy* senderPolicy =
(dust != nullptr && dust->sender.has_value()) ? &*dust->sender : nullptr;
// Drain sender-leg: inflate the outgoing amount by the sender-line's
// sfDust (in sender terms) so the receiver receives `amount + D_sender`.
// The sender's line ends with `sfBalance` reduced by the caller's
// originally-requested `amount` (whole-quanta portion) and `sfDust`
// zeroed. Drain is meaningless when the sender IS the issuer (no
// sender-side line exists on the issuer's side).
STAmount saAmountEffective = saAmount;
if (senderPolicy != nullptr && senderPolicy->mode == DustSplit::LegPolicy::Mode::Drain &&
uSenderID != issuer && view.rules().enabled(featureLendingProtocolV1_1))
{
Currency const& currency = saAmount.get<Issue>().currency;
auto const senderLine = view.peek(keylet::trustLine(uSenderID, issuer, currency));
if (senderLine)
{
bool const senderIsHigh = uSenderID > issuer;
Number const dustLineTerms = Number{senderLine->at(sfDust)};
Number const dustSenderTerms = senderIsHigh ? -dustLineTerms : dustLineTerms;
XRPL_ASSERT(
dustSenderTerms >= beast::kZero,
"xrpl::directSendNoLimitIOU : sender-line dust non-negative under Drain");
if (dustSenderTerms != beast::kZero)
{
saAmountEffective = saAmount + STAmount{saAmount.asset(), dustSenderTerms};
}
}
}
if (uSenderID == issuer || uReceiverID == issuer || issuer == noAccount())
{
// Direct send: redeeming IOUs and/or sending own IOUs.
auto const ter =
directSendNoFeeIOU(view, uSenderID, uReceiverID, saAmount, false, sponsorSle, j);
// Direct send: redeeming IOUs and/or sending own IOUs. Only one
// trust line is touched; the corresponding non-issuer party's
// LegPolicy applies. The issuer-side policy must be absent —
// there is no line to keep aligned on the issuer's side.
DustSplit::LegPolicy* legPolicy = nullptr;
bool legIsSender = false;
if (dust != nullptr)
{
if (uSenderID == issuer)
{
XRPL_ASSERT(
!dust->sender.has_value(),
"xrpl::directSendNoLimitIOU : issuer-side sender policy must be absent");
if (dust->receiver.has_value())
{
legPolicy = &*dust->receiver;
legIsSender = false;
}
}
else if (uReceiverID == issuer)
{
XRPL_ASSERT(
!dust->receiver.has_value(),
"xrpl::directSendNoLimitIOU : issuer-side receiver policy must be absent");
if (dust->sender.has_value())
{
legPolicy = &*dust->sender;
legIsSender = true;
}
}
else
{
// issuer == noAccount(): treat as a receiver-leg-only
// credit (matches historical behaviour of the flat
// DustSplit passed through here).
if (dust->receiver.has_value())
{
legPolicy = &*dust->receiver;
legIsSender = false;
}
}
}
auto const ter = directSendNoFeeIOU(
view,
uSenderID,
uReceiverID,
saAmountEffective,
false,
sponsorSle,
j,
legPolicy,
legIsSender);
if (!isTesSuccess(ter))
return ter;
saActual = saAmount;
saActual = saAmountEffective;
return tesSUCCESS;
}
@@ -825,18 +1097,43 @@ directSendNoLimitIOU(
// Calculate the amount to transfer accounting
// for any transfer fees if the fee is not waived:
saActual = (waiveFee == WaiveTransferFee::Yes) ? saAmount
: multiply(saAmount, transferRate(view, issuer));
saActual = (waiveFee == WaiveTransferFee::Yes)
? saAmountEffective
: multiply(saAmountEffective, transferRate(view, issuer));
JLOG(j.debug()) << "directSendNoLimitIOU> " << to_string(uSenderID) << " - > "
<< to_string(uReceiverID) << " : deliver=" << saAmount.getFullText()
<< to_string(uReceiverID) << " : deliver=" << saAmountEffective.getFullText()
<< " cost=" << saActual.getFullText();
TER terResult = directSendNoFeeIOU(view, issuer, uReceiverID, saAmount, true, sponsorSle, j);
// Transit path: two trust-line touches. The receiver-leg policy
// applies to the (issuer -> receiver) credit; the sender-leg
// policy applies to the (sender -> issuer) debit. The effective
// `saAmountEffective` reaches both legs — inflated when Drain is
// active — so the receiver's inflow and the sender's outflow line
// up in extended-balance terms.
TER terResult = directSendNoFeeIOU(
view,
issuer,
uReceiverID,
saAmountEffective,
true,
sponsorSle,
j,
receiverPolicy,
/*legIsSender=*/false);
if (tesSUCCESS == terResult)
{
terResult = directSendNoFeeIOU(view, uSenderID, issuer, saActual, true, sponsorSle, j);
terResult = directSendNoFeeIOU(
view,
uSenderID,
issuer,
saActual,
true,
sponsorSle,
j,
senderPolicy,
/*legIsSender=*/true);
}
return terResult;
@@ -845,6 +1142,10 @@ directSendNoLimitIOU(
// Send regardless of limits.
// --> receivers: Amount/currency/issuer to deliver to receivers.
// <-- saActual: Amount actually cost to sender. Sender pays fees.
//
// The optional `dust` split applies only to the sender's leg (a single
// trust line shared across all receivers). Only `dust->sender` is
// consulted; `dust->receiver` must be nullopt.
static TER
directSendNoLimitMultiIOU(
ApplyView& view,
@@ -853,11 +1154,18 @@ directSendNoLimitMultiIOU(
MultiplePaymentDestinations const& receivers,
STAmount& actual,
beast::Journal j,
WaiveTransferFee waiveFee)
WaiveTransferFee waiveFee,
DustSplit* dust = nullptr)
{
auto const& issuer = issue.getIssuer();
XRPL_ASSERT(!isXRP(senderID), "xrpl::directSendNoLimitMultiIOU : sender is not XRP");
XRPL_ASSERT(
dust == nullptr || !dust->receiver.has_value(),
"xrpl::directSendNoLimitMultiIOU : receiver-leg policy has no meaning in multi-send");
DustSplit::LegPolicy* senderPolicy =
(dust != nullptr && dust->sender.has_value()) ? &*dust->sender : nullptr;
// These may diverge
STAmount takeFromSender{issue};
@@ -880,8 +1188,15 @@ directSendNoLimitMultiIOU(
if (senderID == issuer || receiverID == issuer || issuer == noAccount())
{
// Direct send: redeeming IOUs and/or sending own IOUs.
if (auto const ter =
directSendNoFeeIOU(view, senderID, receiverID, amount, false, {}, j);
// The sender-leg policy is applied only on the final bulk
// sender-line debit below, not on any direct-to-issuer
// intermediate credits — a sender-leg policy that touched
// the sender's line multiple times would overwrite its own
// out fields. In the current Vault-only use of this path
// (loan disbursement), no recipient is the asset issuer, so
// this branch never runs alongside a non-null senderPolicy.
if (auto const ter = directSendNoFeeIOU(
view, senderID, receiverID, amount, false, {}, j, nullptr, false);
!isTesSuccess(ter))
return ter;
actual += amount;
@@ -905,14 +1220,29 @@ directSendNoLimitMultiIOU(
<< to_string(receiverID) << " : deliver=" << amount.getFullText()
<< " cost=" << actual.getFullText();
if (TER const terResult = directSendNoFeeIOU(view, issuer, receiverID, amount, true, {}, j))
// Receiver-leg policy is not defined for multi-send (there
// are multiple receiver lines). Pass nullptr here.
if (TER const terResult = directSendNoFeeIOU(
view, issuer, receiverID, amount, true, {}, j, nullptr, /*legIsSender=*/false))
return terResult;
}
if (senderID != issuer && takeFromSender)
{
if (TER const terResult =
directSendNoFeeIOU(view, senderID, issuer, takeFromSender, true, {}, j))
// Bulk sender-line debit for all transit legs — this is the
// single point where the sender's line changes for this
// multi-send, so it's the only place the sender-leg policy
// can apply.
if (TER const terResult = directSendNoFeeIOU(
view,
senderID,
issuer,
takeFromSender,
true,
{},
j,
senderPolicy,
/*legIsSender=*/true))
return terResult;
}
@@ -927,7 +1257,8 @@ accountSendIOU(
STAmount const& saAmount,
beast::Journal j,
SLE::ref sponsorSle,
WaiveTransferFee waiveFee)
WaiveTransferFee waiveFee,
DustSplit* dust = nullptr)
{
if (view.rules().enabled(fixAMMv1_1))
{
@@ -945,10 +1276,15 @@ accountSendIOU(
// LCOV_EXCL_STOP
}
/* If we aren't sending anything or if the sender is the same as the
* receiver then we don't need to do anything.
*/
if (!saAmount || (uSenderID == uReceiverID))
// Drain sender-leg can legitimately request a zero-amount call: the
// caller (e.g. terminal Vault removal) may have amount==0 but still
// want to drain any residual sfDust on the sender's line. Route
// through in that case so directSendNoLimitIOU can peek the sender's
// line and inflate saAmount by the reservoir. All other zero-amount
// or self-sends short-circuit as before.
bool const drainRequested = dust != nullptr && dust->sender.has_value() &&
dust->sender->mode == DustSplit::LegPolicy::Mode::Drain;
if ((!saAmount && !drainRequested) || (uSenderID == uReceiverID))
return tesSUCCESS;
if (!saAmount.native())
@@ -959,9 +1295,11 @@ accountSendIOU(
<< to_string(uReceiverID) << " : " << saAmount.getFullText();
return directSendNoLimitIOU(
view, uSenderID, uReceiverID, saAmount, saActual, j, sponsorSle, waiveFee);
view, uSenderID, uReceiverID, saAmount, saActual, j, sponsorSle, waiveFee, dust);
}
XRPL_ASSERT(dust == nullptr, "xrpl::accountSendIOU : dust split is IOU-only");
/* XRP send which does not check reserve and can do pure adjustment.
* Note that sender or receiver may be null and this not a mistake; this
* setup is used during pathfinding and it is carefully controlled to
@@ -1045,7 +1383,8 @@ accountSendMultiIOU(
Issue const& issue,
MultiplePaymentDestinations const& receivers,
beast::Journal j,
WaiveTransferFee waiveFee)
WaiveTransferFee waiveFee,
DustSplit* dust = nullptr)
{
XRPL_ASSERT_PARTS(
receivers.size() > 1, "xrpl::accountSendMultiIOU", "multiple recipients provided");
@@ -1056,9 +1395,12 @@ accountSendMultiIOU(
JLOG(j.trace()) << "accountSendMultiIOU: " << to_string(senderID) << " sending "
<< receivers.size() << " IOUs";
return directSendNoLimitMultiIOU(view, senderID, issue, receivers, actual, j, waiveFee);
return directSendNoLimitMultiIOU(
view, senderID, issue, receivers, actual, j, waiveFee, dust);
}
XRPL_ASSERT(dust == nullptr, "xrpl::accountSendMultiIOU : dust split is IOU-only");
/* XRP send which does not check reserve and can do pure adjustment.
* Note that sender or receiver may be null and this not a mistake; this
* setup could be used during pathfinding and it is carefully controlled to
@@ -1488,7 +1830,16 @@ directSendNoFee(
{
return saAmount.asset().visit(
[&](Issue const&) {
return directSendNoFeeIOU(view, uSenderID, uReceiverID, saAmount, bCheckIssuer, {}, j);
return directSendNoFeeIOU(
view,
uSenderID,
uReceiverID,
saAmount,
bCheckIssuer,
{},
j,
/*legPolicy=*/nullptr,
/*legIsSender=*/false);
},
[&](MPTIssue const&) {
XRPL_ASSERT(!bCheckIssuer, "xrpl::directSendNoFee : not checking issuer");
@@ -1505,13 +1856,16 @@ accountSend(
beast::Journal j,
SLE::ref sponsorSle,
WaiveTransferFee waiveFee,
AllowMPTOverflow allowOverflow)
AllowMPTOverflow allowOverflow,
DustSplit* dust)
{
return saAmount.asset().visit(
[&](Issue const&) {
return accountSendIOU(view, uSenderID, uReceiverID, saAmount, j, sponsorSle, waiveFee);
return accountSendIOU(
view, uSenderID, uReceiverID, saAmount, j, sponsorSle, waiveFee, dust);
},
[&](MPTIssue const&) {
XRPL_ASSERT(dust == nullptr, "xrpl::accountSend : dust split is IOU-only");
return accountSendMPT(
view, uSenderID, uReceiverID, saAmount, j, waiveFee, allowOverflow);
});
@@ -1524,15 +1878,17 @@ accountSendMulti(
Asset const& asset,
MultiplePaymentDestinations const& receivers,
beast::Journal j,
WaiveTransferFee waiveFee)
WaiveTransferFee waiveFee,
DustSplit* dust)
{
XRPL_ASSERT_PARTS(
receivers.size() > 1, "xrpl::accountSendMulti", "multiple recipients provided");
return asset.visit(
[&](Issue const& issue) {
return accountSendMultiIOU(view, senderID, issue, receivers, j, waiveFee);
return accountSendMultiIOU(view, senderID, issue, receivers, j, waiveFee, dust);
},
[&](MPTIssue const& issue) {
XRPL_ASSERT(dust == nullptr, "xrpl::accountSendMulti : dust split is IOU-only");
return accountSendMultiMPT(view, senderID, issue, receivers, j, waiveFee);
});
}

View File

@@ -1,16 +1,28 @@
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstdint>
#include <optional>
@@ -157,4 +169,556 @@ getVaultVersion(SLE::const_ref vault)
return static_cast<VaultVersion>(version);
}
[[nodiscard]] int
getVaultScale(SLE::const_ref vault)
{
if (!vault)
return Number::kMinExponent - 1; // LCOV_EXCL_LINE
return scale(vault->at(sfAssetsTotal), vault->at(sfAsset));
}
[[nodiscard]] TER
addVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& sender,
STAmount const& amount,
STAmount const& valueDelta,
beast::Journal j)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::addVaultAssets : valid Vault sle");
// Forward to the dust-aware overlay for eligible Vaults (cash-basis +
// IOU asset). Every other Vault runs the base body verbatim below,
// byte-identical to a call with no overlay in the tree.
if (vault_dust::useVaultDust(view, vault))
return vault_dust::addVaultAssets(view, vault, sender, amount, valueDelta, j);
[[maybe_unused]] Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(amount.asset() == asset, "xrpl::addVaultAssets : amount matches vault asset");
XRPL_ASSERT(
valueDelta.asset() == asset, "xrpl::addVaultAssets : valueDelta matches vault asset");
XRPL_ASSERT(amount >= beast::kZero, "xrpl::addVaultAssets : amount is non-negative");
// Callers are responsible for rounding amount/valueDelta to whatever
// scale their own accounting requires; this helper does not re-round.
// valueDelta and amount are independent (e.g. a loan default written off
// entirely by the vault, with no first-loss capital cover, has a nonzero
// (and possibly negative) valueDelta but a zero amount; late/regular loan
// payments can also carry a small negative valueDelta from untracked
// interest rounding corrections), so both fields are always updated even
// when there is nothing to transfer.
vault->at(sfAssetsTotal) += valueDelta;
vault->at(sfAssetsAvailable) += amount;
view.update(vault);
if (auto const ter =
accountSend(view, sender, vault->at(sfAccount), amount, j, {}, WaiveTransferFee::Yes);
!isTesSuccess(ter))
return ter;
return tesSUCCESS;
}
namespace {
// Applies a full-removal mutation to the Vault's ledger fields: both
// callers (clawbackVaultAssets and removeVaultAssets) apply `amount` to
// sfAssetsTotal and sfAssetsAvailable equally (unlike
// addVaultAssets/moveVaultAssets, a full removal always shrinks both fields
// by the same amount). On a final removal, both fields are hard-reset to
// exactly zero rather than computed via subtraction: see FinalRemoval's
// doc comment for why an arithmetic subtraction cannot be trusted to land
// on exactly zero here.
void
applyRemoveVaultAssets(
ApplyView& view,
SLE::ref vault,
STAmount const& amount,
FinalRemoval finalRemoval)
{
if (finalRemoval == FinalRemoval::Yes)
{
vault->at(sfAssetsTotal) = 0;
vault->at(sfAssetsAvailable) = 0;
}
else
{
vault->at(sfAssetsTotal) -= amount;
vault->at(sfAssetsAvailable) -= amount;
}
view.update(vault);
}
} // namespace
[[nodiscard]] TER
clawbackVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& recipient,
STAmount const& amount,
beast::Journal j)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::clawbackVaultAssets : valid Vault sle");
if (vault_dust::useVaultDust(view, vault))
return vault_dust::clawbackVaultAssets(view, vault, recipient, amount, j);
Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(amount.asset() == asset, "xrpl::clawbackVaultAssets : amount matches vault asset");
XRPL_ASSERT(amount > beast::kZero, "xrpl::clawbackVaultAssets : amount is positive");
if (amount > *vault->at(sfAssetsAvailable))
return tefINTERNAL;
applyRemoveVaultAssets(view, vault, amount, FinalRemoval::No);
if (auto const ter = accountSend(
view, vault->at(sfAccount), recipient, amount, j, {}, WaiveTransferFee::Yes);
!isTesSuccess(ter))
return ter;
// Sanity check
if (accountHolds(
view,
vault->at(sfAccount),
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j) < beast::kZero)
{
// LCOV_EXCL_START
JLOG(j.error()) << "clawbackVaultAssets: negative balance of vault assets.";
return tefINTERNAL;
// LCOV_EXCL_STOP
}
return tesSUCCESS;
}
[[nodiscard]] TER
removeVaultAssets(
ApplyViewContext ctx,
SLE::ref vault,
AccountID const& senderAcct,
AccountID const& dstAcct,
XRPAmount priorBalance,
STAmount const& amount,
beast::Journal j,
FinalRemoval finalRemoval)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::removeVaultAssets : valid Vault sle");
if (vault_dust::useVaultDust(ctx.view, vault))
return vault_dust::removeVaultAssets(
ctx, vault, senderAcct, dstAcct, priorBalance, amount, j, finalRemoval);
[[maybe_unused]] Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(amount.asset() == asset, "xrpl::removeVaultAssets : amount matches vault asset");
XRPL_ASSERT(amount >= beast::kZero, "xrpl::removeVaultAssets : amount is non-negative");
applyRemoveVaultAssets(ctx.view, vault, amount, finalRemoval);
if (amount == beast::kZero)
return tesSUCCESS; // LCOV_EXCL_LINE
return doWithdraw(ctx, senderAcct, dstAcct, vault->at(sfAccount), priorBalance, amount, j);
}
[[nodiscard]] TER
moveVaultAssets(
ApplyView& view,
SLE::ref vault,
MultiplePaymentDestinations const& recipients,
STAmount const& valueDelta,
beast::Journal j)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::moveVaultAssets : valid Vault sle");
if (vault_dust::useVaultDust(view, vault))
return vault_dust::moveVaultAssets(view, vault, recipients, valueDelta, j);
XRPL_ASSERT(recipients.size() > 1, "xrpl::moveVaultAssets : multiple recipients provided");
Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(
valueDelta.asset() == asset, "xrpl::moveVaultAssets : valueDelta matches vault asset");
XRPL_ASSERT(
valueDelta == beast::kZero || getVaultVersion(vault) == VaultVersion::Legacy,
"xrpl::moveVaultAssets : nonzero valueDelta requires Legacy vault version");
Number amountTotal{};
for (auto const& [recipient, recipientAmount] : recipients)
{
XRPL_ASSERT(
recipientAmount >= beast::kZero,
"xrpl::moveVaultAssets : recipientAmount is non-negative");
amountTotal += recipientAmount;
}
STAmount const amount{asset, amountTotal};
// valueDelta follows addVaultAssets's convention (added to sfAssetsTotal):
// disbursing a loan typically increases sfAssetsTotal via accrued
// interest even as cash leaves sfAssetsAvailable.
vault->at(sfAssetsTotal) += valueDelta;
vault->at(sfAssetsAvailable) -= amount;
view.update(vault);
if (amount == beast::kZero)
return tesSUCCESS;
return accountSendMulti(
view, vault->at(sfAccount), asset, recipients, j, WaiveTransferFee::Yes);
}
namespace vault_dust {
namespace {
// Compute the exponent (scale) sfBalance on the Vault's custody line must
// remain representable at after this operation. This is the scale of
// sfAssetsTotal *after* applying `deltaToAssetsTotal`, using nearest-
// rounding so a same-magnitude jump does not oscillate the scale
// arbitrarily by a single ulp.
int
posteriorScale(SLE::const_ref vault, Number const& deltaToAssetsTotal)
{
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
Number const posterior = Number{vault->at(sfAssetsTotal)} + deltaToAssetsTotal;
return scale(posterior, vault->at(sfAsset));
}
// Build a sender-leg Override DustSplit targeting the Vault's current
// (post-mutation) sfAssetsTotal scale. Shared by clawback, non-terminal
// withdraw, and multi-recipient move — every sender-leg dust-aware call
// path in vault_dust uses the same shape.
DustSplit
makeSenderOverride(SLE::const_ref vault, Asset const& asset)
{
DustSplit split;
split.sender = DustSplit::LegPolicy{
.mode = DustSplit::LegPolicy::Mode::Override,
.overrideScale = scale(Number{vault->at(sfAssetsTotal)}, asset)};
return split;
}
// Reconcile Vault fields against a sender-leg dust report. `dustDelta` is
// the change in the custody line's sfDust (sender-positive: positive when
// dust was newly deferred, negative when previously-deferred dust was
// promoted into sfBalance). Both Vault fields shift by that delta so the
// receivable (sfAssetsTotal - sfAssetsAvailable) stays aligned with the
// line's newDust exactly. No-op when no sender-leg policy ran.
void
reconcileSenderDust(ApplyView& view, SLE::ref vault, DustSplit const& split)
{
if (!split.sender)
return;
vault->at(sfAssetsAvailable) -= split.sender->dustDelta;
vault->at(sfAssetsTotal) -= split.sender->dustDelta;
view.update(vault);
}
} // namespace
[[nodiscard]] bool
useVaultDust(ReadView const& view, SLE::const_ref vault)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::vault_dust::useVaultDust : valid Vault sle");
// Amendment gate is defense-in-depth; see directSendNoFeeIOU
// (TokenHelpers.cpp) for the canonical rationale.
if (!view.rules().enabled(featureLendingProtocolV1_1))
return false;
Asset const asset = vault->at(sfAsset);
return getVaultVersion(vault) == VaultVersion::CashBasis && !asset.integral();
}
[[nodiscard]] TER
addVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& sender,
STAmount const& amount,
STAmount const& valueDelta,
beast::Journal j)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::vault_dust::addVaultAssets : valid Vault sle");
XRPL_ASSERT(
useVaultDust(view, vault), "xrpl::vault_dust::addVaultAssets : useVaultDust precondition");
Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(
amount.asset() == asset, "xrpl::vault_dust::addVaultAssets : amount matches vault asset");
XRPL_ASSERT(
valueDelta.asset() == asset,
"xrpl::vault_dust::addVaultAssets : valueDelta matches vault asset");
XRPL_ASSERT(
amount >= beast::kZero, "xrpl::vault_dust::addVaultAssets : amount is non-negative");
// Route the credit through a DustSplit's receiver-leg policy
// targeting the Vault's posterior scale (the scale implied by
// sfAssetsTotal + valueDelta): any sub-quantum remainder on the
// Vault's custody line lands in sfDust rather than being lost. The
// sender's line is written by the pre-credit debit leg and does not
// participate in the split — a depositor's own trust line is
// dust-unaware.
DustSplit split;
split.receiver = DustSplit::LegPolicy{
.mode = DustSplit::LegPolicy::Mode::Override,
.overrideScale = posteriorScale(vault, Number{valueDelta})};
if (auto const ter = accountSend(
view,
sender,
vault->at(sfAccount),
amount,
j,
{},
WaiveTransferFee::Yes,
AllowMPTOverflow::No,
&split);
!isTesSuccess(ter))
return ter;
// Apply the accounting correction so the receivable
// (sfAssetsTotal - sfAssetsAvailable) matches what a dust-unaware
// call would produce: sfAssetsAvailable moves by the aligned
// balanceDelta, and sfAssetsTotal absorbs the sub-quantum residual so
// (valueDelta - dustDelta) - balanceDelta == valueDelta - amount.
// receiver-leg deltas are receiver-positive, so we ADD them
// directly to the Vault's fields (the Vault IS the receiver here).
//
// Any dust stranded on the custody line by a scale-refining prior
// operation is renormalised by the credit-path re-split itself
// (`directSendNoFeeIOU` truncates the extended balance +
// credit at the Override target scale, so a decade-boundary crossing
// automatically promotes freed whole-quanta into sfBalance). No
// separate renormaliseStrandedDust pass is needed here.
vault->at(sfAssetsAvailable) += split.receiver->balanceDelta;
vault->at(sfAssetsTotal) += Number{valueDelta} - split.receiver->dustDelta;
view.update(vault);
return tesSUCCESS;
}
[[nodiscard]] TER
clawbackVaultAssets(
ApplyView& view,
SLE::ref vault,
AccountID const& recipient,
STAmount const& amount,
beast::Journal j)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::vault_dust::clawbackVaultAssets : valid Vault sle");
XRPL_ASSERT(
useVaultDust(view, vault),
"xrpl::vault_dust::clawbackVaultAssets : useVaultDust precondition");
Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(
amount.asset() == asset,
"xrpl::vault_dust::clawbackVaultAssets : amount matches vault asset");
XRPL_ASSERT(
amount > beast::kZero, "xrpl::vault_dust::clawbackVaultAssets : amount is positive");
if (amount > *vault->at(sfAssetsAvailable))
return tefINTERNAL;
// Same field mutation as the base clawback: a full removal shrinks
// sfAssetsTotal and sfAssetsAvailable equally.
vault->at(sfAssetsTotal) -= amount;
vault->at(sfAssetsAvailable) -= amount;
view.update(vault);
// A clawback shrinks sfAssetsTotal, refining the Vault's scale, so
// any stranded whole quanta on the custody line's sfDust need to
// be promoted back into sfBalance. Drive this through a sender-leg
// Override policy targeting the Vault's posterior scale; the trust
// -line layer re-splits (sfBalance, sfDust) at that scale and
// reports any dust promotion or newly-deferred residual.
DustSplit split = makeSenderOverride(vault, asset);
if (auto const ter = accountSend(
view,
vault->at(sfAccount),
recipient,
amount,
j,
{},
WaiveTransferFee::Yes,
AllowMPTOverflow::No,
&split);
!isTesSuccess(ter))
return ter;
if (accountHolds(
view,
vault->at(sfAccount),
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j) < beast::kZero)
{
// LCOV_EXCL_START
JLOG(j.error()) << "vault_dust::clawbackVaultAssets: negative balance of vault assets.";
return tefINTERNAL;
// LCOV_EXCL_STOP
}
reconcileSenderDust(view, vault, split);
return tesSUCCESS;
}
[[nodiscard]] TER
removeVaultAssets(
ApplyViewContext ctx,
SLE::ref vault,
AccountID const& senderAcct,
AccountID const& dstAcct,
XRPAmount priorBalance,
STAmount const& amount,
beast::Journal j,
FinalRemoval finalRemoval)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::vault_dust::removeVaultAssets : valid Vault sle");
XRPL_ASSERT(
useVaultDust(ctx.view, vault),
"xrpl::vault_dust::removeVaultAssets : useVaultDust precondition");
Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(
amount.asset() == asset,
"xrpl::vault_dust::removeVaultAssets : amount matches vault asset");
XRPL_ASSERT(
amount >= beast::kZero, "xrpl::vault_dust::removeVaultAssets : amount is non-negative");
if (finalRemoval == FinalRemoval::Yes)
{
// Terminal branch: drive the drain through a sender-leg Drain
// policy on the outgoing withdrawal. The trust-line layer folds
// sfDust into the sender's sfBalance in place, inflates the
// outgoing amount so the destination receives `amount + dust`,
// and zeroes sfDust — leaving the custody line ready for
// downstream deletion guards.
AccountID const vaultAccount = vault->at(sfAccount);
DustSplit split;
split.sender =
DustSplit::LegPolicy{.mode = DustSplit::LegPolicy::Mode::Drain, .overrideScale = 0};
// Hard-reset both accounting fields to exactly zero — the same
// contract as the base helper's FinalRemoval::Yes branch.
vault->at(sfAssetsTotal) = 0;
vault->at(sfAssetsAvailable) = 0;
ctx.view.update(vault);
// Short-circuit only when both the whole-quanta amount and the
// sub-quantum reservoir on the custody line are zero. The
// dust-inclusive read helper collapses sfBalance + sfDust; when
// amount==0 the sfBalance component is also zero, so a zero
// extended balance means "nothing to drain".
if (amount == beast::kZero &&
creditBalanceExact(ctx.view, vaultAccount, asset.get<Issue>()) == beast::kZero)
return tesSUCCESS;
return doWithdraw(ctx, senderAcct, dstAcct, vaultAccount, priorBalance, amount, j, &split);
}
// Non-terminal: same field mutation as the base helper (both fields
// drop by `amount`), then a dust-aware doWithdraw driven by a
// sender-leg Override policy targeting the Vault's posterior
// scale. The trust-line layer re-splits (sfBalance, sfDust) on the
// custody line at the new scale and reports back any promoted /
// newly-deferred sub-quantum residual so the Vault's fields stay
// aligned.
vault->at(sfAssetsTotal) -= amount;
vault->at(sfAssetsAvailable) -= amount;
ctx.view.update(vault);
if (amount != beast::kZero)
{
DustSplit split = makeSenderOverride(vault, asset);
if (auto const ter = doWithdraw(
ctx, senderAcct, dstAcct, vault->at(sfAccount), priorBalance, amount, j, &split);
!isTesSuccess(ter))
return ter;
reconcileSenderDust(ctx.view, vault, split);
}
return tesSUCCESS;
}
[[nodiscard]] TER
moveVaultAssets(
ApplyView& view,
SLE::ref vault,
MultiplePaymentDestinations const& recipients,
STAmount const& valueDelta,
beast::Journal j)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::vault_dust::moveVaultAssets : valid Vault sle");
XRPL_ASSERT(
useVaultDust(view, vault), "xrpl::vault_dust::moveVaultAssets : useVaultDust precondition");
XRPL_ASSERT(
recipients.size() > 1, "xrpl::vault_dust::moveVaultAssets : multiple recipients provided");
Asset const asset = vault->at(sfAsset);
XRPL_ASSERT(
valueDelta.asset() == asset,
"xrpl::vault_dust::moveVaultAssets : valueDelta matches vault asset");
XRPL_ASSERT(
valueDelta == beast::kZero || getVaultVersion(vault) == VaultVersion::Legacy,
"xrpl::vault_dust::moveVaultAssets : nonzero valueDelta requires Legacy vault version");
Number amountTotal{};
for (auto const& [recipient, recipientAmount] : recipients)
{
XRPL_ASSERT(
recipientAmount >= beast::kZero,
"xrpl::vault_dust::moveVaultAssets : recipientAmount is non-negative");
amountTotal += recipientAmount;
}
STAmount const amount{asset, amountTotal};
// Field mutations first (same as the base helper), then the multi-
// send with a sender-leg Override policy so any dust freed by the
// scale refinement (from valueDelta / amount) surfaces via the
// trust-line layer. accountSendMulti applies the sender-leg
// policy on the single shared sender-line debit; the multiple
// receiver-line credits are dust-unaware (the trust-line layer
// has no receiver-leg policy plumbed through the multi path).
vault->at(sfAssetsTotal) += valueDelta;
vault->at(sfAssetsAvailable) -= amount;
view.update(vault);
if (amount != beast::kZero)
{
DustSplit split = makeSenderOverride(vault, asset);
if (auto const ter = accountSendMulti(
view, vault->at(sfAccount), asset, recipients, j, WaiveTransferFee::Yes, &split);
!isTesSuccess(ter))
return ter;
reconcileSenderDust(view, vault, split);
}
return tesSUCCESS;
}
} // namespace vault_dust
} // namespace xrpl

View File

@@ -116,6 +116,10 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
// Trust Line balances are STAmounts, so we can use the exponent
// directly to get the scale.
balanceDelta.scale = amount.exponent();
// sfDust follows the same low/high convention as sfBalance.
// Pre-amendment sfDust is SoeDefault (0), so this is a no-
// op then.
balanceDelta.dustDelta = Number{before->at(sfDust)};
sign = -1;
break;
}
@@ -161,6 +165,9 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
// directly to get the scale.
if (amount.exponent() > balanceDelta.scale)
balanceDelta.scale = amount.exponent();
// Mirror the sfBalance accumulation so dustDelta ends up as
// "before - after" (later sign-flipped to "after - before").
balanceDelta.dustDelta -= Number{after->at(sfDust)};
sign = -1;
break;
}
@@ -178,6 +185,10 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
{
XRPL_ASSERT_PARTS(balanceDelta.scale, "xrpl::ValidVault::visitEntry", "scale initialized");
balanceDelta.delta *= sign;
// dustDelta was accumulated with the same "before - after" idiom;
// apply the same sign flip so it represents "after - before" in
// the trust line's low/high convention.
balanceDelta.dustDelta *= sign;
deltas_[key] = balanceDelta;
}
}
@@ -202,8 +213,12 @@ ValidVault::deltaAssets(AccountID const& id) const
auto result = lookup(keylet::trustLine(id, issue).key);
// Trust-line balance is stored from the low-account's perspective;
// negate if id is the high account so the delta is in id's terms.
// dustDelta shares the same convention, so flip it in lockstep.
if (result && id > issue.getIssuer())
{
result->delta = -result->delta;
result->dustDelta = -result->dustDelta;
}
return result;
}
else if constexpr (std::is_same_v<TIss, MPTIssue>)
@@ -878,8 +893,26 @@ ValidVault::finalize(
result = false;
}
// Compare EXTENDED balances (sfBalance + sfDust) on
// both sides for the cash-flow parity check. Under
// featureLendingProtocolV1_1 a dust-aware withdrawal
// may reshape the vault custody line's sfBalance /
// sfDust split (Override promotes / defers; Drain
// folds sfDust into the outgoing transfer) — all
// internal recognition moves preserved by the
// extended total. For non-dust callers dustDelta is
// zero, so this reduces to the base sfBalance
// comparison. See VaultRoundingTrustlineDust_test::
// testNonTerminalWithdrawAfterDust for the case this
// addresses.
Number const extendedPseudoDelta =
maybeVaultDeltaAssets->delta + maybeVaultDeltaAssets->dustDelta;
Number const extendedDestinationDelta =
destinationDelta.delta + destinationDelta.dustDelta;
auto const localPseudoDeltaAssets =
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
roundToAsset(vaultAsset, extendedPseudoDelta, localMinScale);
auto const localDestinationDelta =
roundToAsset(vaultAsset, extendedDestinationDelta, localMinScale);
// For IOU assets near a precision boundary the destination's STAmount
// exponent can shift, making part of the sent value unrepresentable at the
// receiver's new scale — that portion is irreversibly absorbed by the IOU
@@ -891,11 +924,10 @@ ValidVault::finalize(
auto const destroyedIsSubUlp = tolerateZeroDelta &&
roundToAsset(
vaultAsset,
maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
extendedPseudoDelta * -1 - extendedDestinationDelta,
destinationScale,
Number::RoundingMode::Downward) == kZero;
if (!destroyedIsSubUlp &&
localPseudoDeltaAssets * -1 != roundedDestinationDelta)
if (!destroyedIsSubUlp && localPseudoDeltaAssets * -1 != localDestinationDelta)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change vault and destination balance by equal "

View File

@@ -6,6 +6,7 @@
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -78,7 +79,7 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx)
{
// Any remaining debt should have been wiped out by the last Loan
// Delete. This check is purely defensive.
auto const scale = getAssetsTotalScale(vault);
auto const scale = getVaultScale(vault);
auto const rounded =
roundToAsset(asset, debtTotal, scale, Number::RoundingMode::TowardsZero);

View File

@@ -6,6 +6,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h> // IWYU pragma: keep
@@ -120,7 +121,7 @@ LoanDelete::doApply()
roundToAsset(
vaultSle->at(sfAsset),
debtTotalProxy,
getAssetsTotalScale(vaultSle),
getVaultScale(vaultSle),
Number::RoundingMode::TowardsZero) == beast::kZero,
"xrpl::LoanDelete::doApply",
"last loan, remaining debt rounds to zero");

View File

@@ -7,7 +7,7 @@
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -172,14 +172,17 @@ LoanManage::defaultLoan(
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultScale(vaultSle);
Number vaultTotalDelta;
{
// Decrease the Total Value of the Vault:
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
// Decrease the Total Value of the Vault. Compute using local values
// (rather than mutating the Vault's proxies directly) so the actual
// field update can be applied once, via addVaultAssets below.
Number const vaultTotalBefore = vaultSle->at(sfAssetsTotal);
Number const vaultAvailableBefore = vaultSle->at(sfAssetsAvailable);
if (vaultTotalProxy < vaultDefaultAmount)
if (vaultTotalBefore < vaultDefaultAmount)
{
// LCOV_EXCL_START
JLOG(j.warn()) << "Vault total assets is less than the vault default amount";
@@ -189,33 +192,32 @@ LoanManage::defaultLoan(
auto const vaultDefaultRounded = roundToAsset(
vaultAsset, vaultDefaultAmount, vaultScale, Number::RoundingMode::Downward);
vaultTotalProxy -= vaultDefaultRounded;
Number vaultTotalAfter = vaultTotalBefore - vaultDefaultRounded;
// Increase the Asset Available of the Vault by liquidated First-Loss
// Capital and any unclaimed funds amount:
vaultAvailableProxy += defaultCovered;
if (*vaultAvailableProxy > *vaultTotalProxy && !vaultAsset.integral())
Number const vaultAvailableAfter = vaultAvailableBefore + defaultCovered;
if (vaultAvailableAfter > vaultTotalAfter && !vaultAsset.integral())
{
auto const difference = vaultAvailableProxy - vaultTotalProxy;
JLOG(j.debug()) << "Vault assets available: " << *vaultAvailableProxy << "("
<< vaultAvailableProxy.value().exponent()
<< "), Total: " << *vaultTotalProxy << "("
<< vaultTotalProxy.value().exponent() << "), Difference: " << difference
auto const difference = vaultAvailableAfter - vaultTotalAfter;
JLOG(j.debug()) << "Vault assets available: " << vaultAvailableAfter << "("
<< vaultAvailableAfter.exponent() << "), Total: " << vaultTotalAfter
<< "(" << vaultTotalAfter.exponent() << "), Difference: " << difference
<< "(" << difference.exponent() << ")";
if (vaultAvailableProxy.value().exponent() - difference.exponent() > 13)
if (vaultAvailableAfter.exponent() - difference.exponent() > 13)
{
// If the difference is dust, bring the total up to match
// the available
JLOG(j.debug()) << "Difference between vault assets available and total is "
"dust. Set both to the larger value.";
vaultTotalProxy = vaultAvailableProxy;
vaultTotalAfter = vaultAvailableAfter;
}
}
if (*vaultAvailableProxy > *vaultTotalProxy)
if (vaultAvailableAfter > vaultTotalAfter)
{
// LCOV_EXCL_START
JLOG(j.fatal()) << "Vault assets available must not be greater "
"than assets outstanding. Available: "
<< *vaultAvailableProxy << ", Total: " << *vaultTotalProxy;
<< vaultAvailableAfter << ", Total: " << vaultTotalAfter;
return tecINTERNAL;
// LCOV_EXCL_STOP
}
@@ -234,7 +236,7 @@ LoanManage::defaultLoan(
adjustImpreciseNumber(
vaultLossUnrealizedProxy, -totalDefaultAmount, vaultAsset, vaultScale);
}
view.update(vaultSle);
vaultTotalDelta = vaultTotalAfter - vaultTotalBefore;
}
// Update the LoanBroker object:
@@ -267,16 +269,15 @@ LoanManage::defaultLoan(
loanSle->at(sfNextPaymentDueDate) = 0;
view.update(loanSle);
// Return funds from the LoanBroker pseudo-account to the
// Vault pseudo-account:
return accountSend(
// Update the Vault's assets, and return funds from the LoanBroker
// pseudo-account to the Vault pseudo-account:
return addVaultAssets(
view,
vaultSle,
brokerSle->at(sfAccount),
vaultSle->at(sfAccount),
STAmount{vaultAsset, defaultCovered},
j,
{},
WaiveTransferFee::Yes);
STAmount{vaultAsset, vaultTotalDelta},
j);
}
TER
@@ -292,7 +293,7 @@ LoanManage::impairLoan(
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultScale(vaultSle);
// Update the Vault object(set "paper loss")
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
@@ -332,7 +333,7 @@ LoanManage::unimpairLoan(
// The vault may be at a different scale than the loan. Reduce rounding
// errors during the accounting by rounding some of the values to that
// scale.
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultScale(vaultSle);
// Update the Vault object(clear "paper loss")
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);

View File

@@ -9,6 +9,7 @@
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
@@ -310,7 +311,7 @@ LoanPay::doApply()
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
auto debtTotalProxy = brokerSle->at(sfDebtTotal);
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultScale(vaultSle);
// Send the broker fee to the owner if they have sufficient cover available,
// _and_ if the owner can receive funds
@@ -432,12 +433,53 @@ LoanPay::doApply()
// LoanBroker object state changes
view.update(brokerSle);
auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable);
auto assetsTotalProxy = vaultSle->at(sfAssetsTotal);
Number const assetsAvailableBefore = vaultSle->at(sfAssetsAvailable);
Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal);
// These three values are used to check that funds are conserved after the transfers
auto const accountBalanceBefore = accountHolds(
view,
accountID_,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_,
SpendableHandling::FullBalance);
auto const vaultBalanceBefore = accountID_ == vaultPseudoAccount
? STAmount{asset, 0}
: accountHolds(
view,
vaultPseudoAccount,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_,
SpendableHandling::FullBalance);
auto const brokerBalanceBefore = accountID_ == brokerPayee
? STAmount{asset, 0}
: accountHolds(
view,
brokerPayee,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_,
SpendableHandling::FullBalance);
auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
auto const totalPaidToVaultRounded =
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
// Cash-basis IOU Vaults route the credit through the dust-aware
// addVaultAssets overload (via the xrpl:: dispatcher), which needs the
// raw pre-rounding total so its DustSplit can park the sub-quantum
// remainder in the custody line's sfDust. Legacy and integral-asset
// Vaults still pre-round to the Vault's anterior scale here — that is
// byte-identical to the base branch. Delegating to the overlay's own
// eligibility gate avoids a manual-sync surface between the two.
bool const useDust = vault_dust::useVaultDust(view, vaultSle);
auto const totalPaidToVaultRounded = useDust
? totalPaidToVaultRaw
: roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
XRPL_ASSERT_PARTS(
!asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
"xrpl::LoanPay::doApply",
@@ -463,11 +505,8 @@ LoanPay::doApply()
adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
//------------------------------------------------------
// Vault object state changes
view.update(vaultSle);
Number const assetsAvailableBefore = *assetsAvailableProxy;
Number const assetsTotalBefore = *assetsTotalProxy;
// Vault object state changes (applied further below, via addVaultAssets,
// together with the transfer of funds to the Vault pseudo-account).
#if !NDEBUG
{
Number const pseudoAccountBalanceBefore = accountHolds(
@@ -485,11 +524,16 @@ LoanPay::doApply()
}
#endif
assetsAvailableProxy += totalPaidToVaultRounded;
assetsTotalProxy += assetsTotalDelta;
// Raw (pre-rounding) projection, used only for this internal consistency
// check; the real post-rounding values are read back from the Vault SLE
// further below, once addVaultAssets/associateAsset have actually
// mutated and rounded it.
[[maybe_unused]] Number const assetsAvailableAfterRaw =
assetsAvailableBefore + totalPaidToVaultRounded;
[[maybe_unused]] Number const assetsTotalAfterRaw = assetsTotalBefore + assetsTotalDelta;
XRPL_ASSERT_PARTS(
*assetsAvailableProxy <= *assetsTotalProxy,
assetsAvailableAfterRaw <= assetsTotalAfterRaw,
"xrpl::LoanPay::doApply",
"assets available must not be greater than assets outstanding");
@@ -515,11 +559,70 @@ LoanPay::doApply()
associateAsset(*loanSle, asset);
associateAsset(*brokerSle, asset);
associateAsset(*vaultSle, asset);
// Duplicate some checks after rounding
Number const assetsAvailableAfter = *assetsAvailableProxy;
Number const assetsTotalAfter = *assetsTotalProxy;
if (totalPaidToVaultRounded != beast::kZero)
{
if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
return ter;
}
if (totalPaidToBroker != beast::kZero)
{
if (brokerPayee == accountID_)
{
// The broker may have deleted their holding. Recreate it if needed
if (auto const ter = addEmptyHolding(
ctx_.getApplyViewContext(),
brokerPayee,
brokerPayeeSle->at(sfBalance).value().xrp(),
asset,
j_);
ter && ter != tecDUPLICATE)
{
// ignore tecDUPLICATE. That means the holding already exists,
// and is fine here
return ter;
}
}
if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
return ter;
}
// Update the Vault's assets, and transfer the Vault's share of the
// payment from the payer to the Vault pseudo-account.
//
// totalPaidToVaultRounded is either the pre-rounded amount (Legacy /
// integral asset path — same as base branch) or the raw pre-rounding
// amount (dust path — the dust-aware addVaultAssets overload consumes
// the raw digits to compute the sfDust residual).
if (auto const ret = addVaultAssets(
view,
vaultSle,
accountID_,
STAmount{asset, totalPaidToVaultRounded},
STAmount{asset, assetsTotalDelta},
j_);
!isTesSuccess(ret))
return ret;
// associateAsset snaps every asset-typed STNumber on the Vault SLE to
// STAmount's 16-significant-digit precision (via roundToAsset on the
// field's current value). For non-dust paths that is the intended
// canonicalisation and matches base-branch behaviour verbatim. For the
// dust path it would silently erase the sub-quantum recognition
// adjustment that the dust-aware addVaultAssets just applied to
// sfAssetsTotal (Number carries 19 digits; STAmount only 16), which
// is what keeps the receivable (sfAssetsTotal - sfAssetsAvailable)
// aligned with principalOutstanding across the repayment. Skip it
// in that path.
if (!useDust)
associateAsset(*vaultSle, asset);
// Duplicate some checks after rounding. These re-read the Vault's fields
// rather than reusing assetsAvailableAfterRaw/assetsTotalAfterRaw, since
// rounding above may have moved them off the raw-arithmetic projection.
Number const assetsAvailableAfter = vaultSle->at(sfAssetsAvailable);
Number const assetsTotalAfter = vaultSle->at(sfAssetsTotal);
XRPL_ASSERT_PARTS(
assetsAvailableAfter <= assetsTotalAfter,
@@ -528,9 +631,22 @@ LoanPay::doApply()
if (assetsAvailableAfter == assetsAvailableBefore)
{
// An unchanged assetsAvailable indicates that the amount paid to the
// vault was zero, or rounded to zero. That should be impossible, but I
// can't rule it out for extreme edge cases, so fail gracefully if it
// happens.
// vault was zero, or rounded to zero.
//
// Non-dust path (useDust == false): assetsAvailable is incremented by
// the pre-rounded amount, so a non-zero repayment always moves it —
// this branch should be unreachable. Fail gracefully if it happens.
//
// Dust path (useDust == true): assetsAvailable is incremented by
// split.balanceDelta, which is the whole-quanta portion of the raw
// repayment at the Vault's posterior scale. A repayment strictly
// below one quantum lands entirely in sfDust and produces
// split.balanceDelta == 0. That is a legitimate (if unusual) outcome
// — refuse it here because the invariants downstream and the
// subsequent conservation checks all assume assetsAvailable moved.
// Callers wanting to permit sub-quantum-only repayments would need to
// relax those follow-on checks first; today no such caller exists in
// the tree, and the current fixture never reaches this branch.
//
// LCOV_EXCL_START
JLOG(j_.warn()) << "LoanPay: Vault assets available unchanged after rounding: " //
@@ -580,73 +696,21 @@ LoanPay::doApply()
// LCOV_EXCL_STOP
}
// These three values are used to check that funds are conserved after the transfers
auto const accountBalanceBefore = accountHolds(
view,
accountID_,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_,
SpendableHandling::FullBalance);
auto const vaultBalanceBefore = accountID_ == vaultPseudoAccount
? STAmount{asset, 0}
: accountHolds(
view,
vaultPseudoAccount,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_,
SpendableHandling::FullBalance);
auto const brokerBalanceBefore = accountID_ == brokerPayee
? STAmount{asset, 0}
: accountHolds(
view,
brokerPayee,
asset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_,
SpendableHandling::FullBalance);
if (totalPaidToVaultRounded != beast::kZero)
{
if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
return ter;
}
// Transfer the LoanBroker's share of the payment (fee) separately.
if (totalPaidToBroker != beast::kZero)
{
if (brokerPayee == accountID_)
{
// The broker may have deleted their holding. Recreate it if needed
if (auto const ter = addEmptyHolding(
ctx_.getApplyViewContext(),
brokerPayee,
brokerPayeeSle->at(sfBalance).value().xrp(),
asset,
j_);
ter && ter != tecDUPLICATE)
{
// ignore tecDUPLICATE. That means the holding already exists,
// and is fine here
return ter;
}
}
if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
if (auto const ter = accountSend(
view,
accountID_,
brokerPayee,
STAmount{asset, totalPaidToBroker},
j_,
{},
WaiveTransferFee::Yes);
!isTesSuccess(ter))
return ter;
}
if (auto const ter = accountSendMulti(
view,
accountID_,
asset,
{{vaultPseudoAccount, totalPaidToVaultRounded}, {brokerPayee, totalPaidToBroker}},
j_,
WaiveTransferFee::Yes))
return ter;
#if !NDEBUG
{
Number const pseudoAccountBalanceAfter = accountHolds(

View File

@@ -10,6 +10,7 @@
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -17,6 +18,7 @@
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/STObject.h>
@@ -392,7 +394,6 @@ LoanSet::doApply()
auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID)));
if (!vaultSle)
return tefBAD_LEDGER; // LCOV_EXCL_LINE
auto const vaultPseudo = vaultSle->at(sfAccount);
Asset const vaultAsset = vaultSle->at(sfAsset);
auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner);
@@ -411,10 +412,10 @@ LoanSet::doApply()
}
auto const principalRequested = tx[sfPrincipalRequested];
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
auto const vaultScale = getAssetsTotalScale(vaultSle);
if (vaultAvailableProxy < principalRequested)
Number const vaultAvailable = vaultSle->at(sfAssetsAvailable);
Number const vaultTotal = vaultSle->at(sfAssetsTotal);
auto const vaultScale = getVaultScale(vaultSle);
if (vaultAvailable < principalRequested)
{
JLOG(j_.warn()) << "Insufficient assets available in the Vault to fund the loan.";
return tecINSUFFICIENT_FUNDS;
@@ -441,11 +442,11 @@ LoanSet::doApply()
properties.loanState.managementFeeDue);
XRPL_ASSERT_PARTS(
*vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
*vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > vaultTotal,
"xrpl::LoanSet::doApply",
"Vault is below maximum limit");
if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue))
if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotal, state.interestDue))
{
JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault";
return tecLIMIT_EXCEEDED;
@@ -581,13 +582,12 @@ LoanSet::doApply()
if (auto const ter = requireAuth(view, vaultAsset, brokerOwner, AuthType::StrongAuth))
return ter;
if (auto const ter = accountSendMulti(
if (auto const ter = moveVaultAssets(
view,
vaultPseudo,
vaultAsset,
vaultSle,
{{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}},
j_,
WaiveTransferFee::Yes))
STAmount{vaultAsset, assetsTotalDelta},
j_))
return ter;
// Get shortcuts to the loan property values
@@ -635,14 +635,10 @@ LoanSet::doApply()
loan->at(sfPaymentRemaining) = paymentTotal;
view.insert(loan);
// Update the balances in the vault
vaultAvailableProxy -= principalRequested;
vaultTotalProxy += assetsTotalDelta;
XRPL_ASSERT_PARTS(
*vaultAvailableProxy <= *vaultTotalProxy,
*vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal),
"xrpl::LoanSet::doApply",
"assets available must not be greater than assets outstanding");
view.update(vaultSle);
// Update the balances in the loan broker
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale);

View File

@@ -350,8 +350,8 @@ VaultClawback::doApply()
Asset const vaultAsset = vault->at(sfAsset);
STAmount const amount = clawbackAmount(vault, tx[~sfAmount], accountID_);
auto assetsAvailable = vault->at(sfAssetsAvailable);
auto assetsTotal = vault->at(sfAssetsTotal);
[[maybe_unused]] Number const assetsAvailable = vault->at(sfAssetsAvailable);
[[maybe_unused]] Number const assetsTotal = vault->at(sfAssetsTotal);
[[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized);
XRPL_ASSERT(
@@ -383,10 +383,6 @@ VaultClawback::doApply()
if (sharesDestroyed == beast::kZero)
return tecPRECISION_LOSS;
assetsTotal -= assetsRecovered;
assetsAvailable -= assetsRecovered;
view().update(vault);
auto const& vaultAccount = vault->at(sfAccount);
// Transfer shares from holder to vault.
if (auto const ter = accountSend(
@@ -422,28 +418,22 @@ VaultClawback::doApply()
// else quietly ignore, holder balance is not zero
}
if (assetsRecovered > beast::kZero)
// Transfer assets from vault to issuer, and update the Vault's balances.
// clawbackVaultAssets requires a positive amount, so skip it entirely
// when assetsRecovered is zero (e.g. the Owner burning shares in an
// empty vault). The issuer always already holds its own asset, so the
// clawbackVaultAssets helper is used here rather than removeVaultAssets,
// whose doWithdraw-based self-holding creation would incorrectly reject
// this while the asset is locked.
// Mark the vault modified for the invariant checker, even without a
// field change, since it always sees the shares-burn above as a
// Vault-affecting change.
view().update(vault);
if (assetsRecovered != beast::kZero)
{
// Transfer assets from vault to issuer.
if (auto const ter = accountSend(
view(), vaultAccount, accountID_, assetsRecovered, j_, {}, WaiveTransferFee::Yes);
if (auto const ter = clawbackVaultAssets(view(), vault, accountID_, assetsRecovered, j_);
!isTesSuccess(ter))
return ter;
// Sanity check
if (accountHolds(
view(),
vaultAccount,
assetsRecovered.asset(),
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_) < beast::kZero)
{
// LCOV_EXCL_START
JLOG(j_.error()) << "VaultClawback: negative balance of vault assets.";
return tefINTERNAL;
// LCOV_EXCL_STOP
}
}
associateAsset(*vault, vaultAsset);

View File

@@ -314,18 +314,27 @@ VaultDeposit::doApply()
sharesCreated.asset() != assetsDeposited.asset(),
"xrpl::VaultDeposit::doApply : assets are not shares");
vault->at(sfAssetsTotal) += assetsDeposited;
vault->at(sfAssetsAvailable) += assetsDeposited;
view().update(vault);
// A deposit must not push the vault over its limit.
//
// Note: the limit is checked against the pre-mutation `sfAssetsTotal`
// plus the full `assetsDeposited`, which is very slightly conservative
// on the dust-aware path. A dust-aware credit routes any sub-quantum
// remainder into the custody line's sfDust, and only the whole-quanta
// portion moves into sfBalance / sfAssetsAvailable. sfAssetsTotal in
// that case grows by `assetsDeposited - split.dustDelta`, i.e. by up
// to one quantum less than what this pre-check assumes, so a deposit
// that would sit exactly on the limit after dust deferral is rejected
// here. Reordering to check after addVaultAssets would require
// rolling back the credit on failure (a non-trivial refund path) and
// is not worth the extra complexity for a boundary of at most one
// quantum.
auto const maximum = *vault->at(sfAssetsMaximum);
if (maximum != 0 && *vault->at(sfAssetsTotal) > maximum)
if (maximum != 0 && *vault->at(sfAssetsTotal) + assetsDeposited > maximum)
return tecLIMIT_EXCEEDED;
// Transfer assets from depositor to vault.
if (auto const ter = accountSend(
view(), accountID_, vaultAccount, assetsDeposited, j_, {}, WaiveTransferFee::Yes);
// Update the vault's assets, and transfer assets from depositor to vault.
if (auto const ter =
addVaultAssets(view(), vault, accountID_, assetsDeposited, assetsDeposited, j_);
!isTesSuccess(ter))
return ter;
@@ -357,6 +366,18 @@ VaultDeposit::doApply()
!isTesSuccess(ter))
return ter;
// Deposit is safe to associate even on the dust path (unlike LoanPay).
// A deposit passes amount == valueDelta to addVaultAssets, so the
// dust-aware overload writes back
// sfAssetsAvailable += split.balanceDelta,
// sfAssetsTotal += valueDelta - split.dustDelta
// = amount - split.dustDelta
// = split.balanceDelta,
// i.e. both fields land at the same posterior scale. Their STAmount
// representation is already exact, so associateAsset's roundToAsset
// is a no-op here. See LoanPay.cpp's `!useDust` branch for the case
// that IS unsafe (amount != valueDelta, so the sub-quantum residual
// lives on sfAssetsTotal alone).
associateAsset(*vault, vaultAsset);
return tesSUCCESS;

View File

@@ -1,6 +1,7 @@
#include <xrpl/tx/transactors/vault/VaultWithdraw.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
@@ -287,15 +288,15 @@ VaultWithdraw::doApply()
return tecINSUFFICIENT_FUNDS;
}
auto assetsAvailable = vault->at(sfAssetsAvailable);
auto assetsTotal = vault->at(sfAssetsTotal);
Number const assetsAvailableBefore = vault->at(sfAssetsAvailable);
[[maybe_unused]] Number const assetsTotalBefore = vault->at(sfAssetsTotal);
auto const lossUnrealized = vault->at(sfLossUnrealized);
XRPL_ASSERT(
lossUnrealized <= (assetsTotal - assetsAvailable),
lossUnrealized <= (assetsTotalBefore - assetsAvailableBefore),
"xrpl::VaultWithdraw::doApply : loss and assets do balance");
// The vault must have enough assets on hand.
if (*assetsAvailable < assetsWithdrawn)
if (assetsAvailableBefore < assetsWithdrawn)
{
JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets";
return tecINSUFFICIENT_FUNDS;
@@ -311,6 +312,7 @@ VaultWithdraw::doApply()
// worth logging.
bool const isFinalWithdrawal =
sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
FinalRemoval finalRemoval = FinalRemoval::No;
if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
{
// Unreachable: a final withdrawal with lossUnrealized > 0 has
@@ -328,7 +330,7 @@ VaultWithdraw::doApply()
// LCOV_EXCL_STOP
}
STAmount const allAvailable{vaultAsset, *assetsAvailable};
STAmount const allAvailable{vaultAsset, assetsAvailableBefore};
if (assetsWithdrawn != allAvailable)
{
JLOG(j_.error()) //
@@ -337,17 +339,8 @@ VaultWithdraw::doApply()
<< " assetsAvailable=" << allAvailable.getText();
}
assetsWithdrawn = allAvailable;
// Do not let dust accumulate in the Vault.
assetsTotal = 0;
assetsAvailable = 0;
finalRemoval = FinalRemoval::Yes;
}
else
{
assetsTotal -= assetsWithdrawn;
assetsAvailable -= assetsWithdrawn;
}
view().update(vault);
auto const& vaultAccount = vault->at(sfAccount);
@@ -385,11 +378,37 @@ VaultWithdraw::doApply()
// else quietly ignore, account balance is not zero
}
auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
if (auto const ter = removeVaultAssets(
applyViewContext,
vault,
accountID_,
dstAcct,
preFeeBalance_,
assetsWithdrawn,
j_,
finalRemoval);
!isTesSuccess(ter))
return ter;
// Must run after the Vault's sfAssetsTotal/sfAssetsAvailable are mutated
// (above): it rounds every asset-typed field on the Vault SLE to the
// asset's canonical precision, which the mutation above does not do
// itself.
//
// Withdraw is safe to associate even on the dust path (unlike LoanPay).
// The dust-aware removeVaultAssets subtracts `amount` from BOTH
// sfAssetsTotal and sfAssetsAvailable in the non-terminal branch, and
// hard-resets both to zero in the FinalRemoval::Yes branch, so neither
// field ends up with sub-STAmount precision. Any post-removal
// renormaliseStrandedDust adds an identical whole-quantum delta to
// both fields at the Vault's posterior scale — still STAmount-
// representable. See LoanPay.cpp's `!useDust` branch for the case
// that IS unsafe (amount != valueDelta, sub-quantum residual on
// sfAssetsTotal alone).
associateAsset(*vault, vaultAsset);
auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
return doWithdraw(
applyViewContext, accountID_, dstAcct, vaultAccount, preFeeBalance_, assetsWithdrawn, j_);
return tesSUCCESS;
}
void

View File

@@ -0,0 +1,496 @@
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/noop.h>
#include <test/jtx/pay.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ApplyViewImpl.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
namespace xrpl::test {
// Exercises the addVaultAssets/clawbackVaultAssets/removeVaultAssets/
// moveVaultAssets helpers directly, against a real Vault SLE and real
// account/trust-line state built via jtx, but bypassing the VaultDeposit/
// VaultWithdraw/VaultClawback/LoanSet transactors entirely. This lets each
// helper's contract (field deltas, transfer amounts, FinalRemoval behavior)
// be checked in isolation, in addition to the transactor-level coverage in
// Vault_test.cpp, LoanSet_test.cpp, etc.
class VaultHelpers_test : public beast::unit_test::Suite
{
// Builds a public IOU-backed vault owned by `owner`. Leaves the Env
// with no pending (un-applied) transactions, so callers can safely
// follow up with raw ApplyViewImpl mutations.
static Keylet
setupVault(jtx::Env& env, jtx::PrettyAsset const& asset, jtx::Account const& owner)
{
jtx::Vault const v{env};
auto const [createTx, vaultKeylet] = v.create({.owner = owner, .asset = asset});
env(createTx);
env.close();
return vaultKeylet;
}
void
testAddVaultAssets()
{
testcase("addVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
auto const depositorBalanceBefore = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
// valueDelta and amount are independent: a nonzero valueDelta with a
// zero amount recognizes a value change (e.g. a covered default)
// without transferring any cash.
{
STAmount const zero{vaultAsset, 0};
STAmount const fifty{vaultAsset, 50};
auto const ter = addVaultAssets(view, vault, depositor, zero, fifty, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const depositorBalanceAfter = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore + 50);
BEAST_EXPECT(availableAfter == availableBefore);
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore);
}
// The common case: amount == valueDelta, and cash actually moves
// from `sender` to the Vault's pseudo-account.
{
STAmount const hundred{vaultAsset, 100};
auto const ter = addVaultAssets(view, vault, depositor, hundred, hundred, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const depositorBalanceAfter = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore + 150);
BEAST_EXPECT(availableAfter == availableBefore + 100);
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore - hundred);
}
// valueDelta may be negative (e.g. a small rounding correction on a
// loan payment), independently of amount again.
{
Number const totalBeforeNegative = vault->at(sfAssetsTotal);
Number const availableBeforeNegative = vault->at(sfAssetsAvailable);
STAmount const zero{vaultAsset, 0};
STAmount const minusTen{vaultAsset, -10};
auto const ter = addVaultAssets(view, vault, depositor, zero, minusTen, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBeforeNegative - 10);
BEAST_EXPECT(availableAfter == availableBeforeNegative);
}
// If the underlying accountSend fails (here: sender has no trust
// line for the vault asset and is not its issuer, so cannot source
// the IOU), the helper propagates the non-tes error rather than
// silently swallowing it.
{
Account const stranger{"stranger"};
env.fund(XRP(10'000), stranger);
env.close();
STAmount const ten{vaultAsset, 10};
auto const ter = addVaultAssets(view, vault, stranger, ten, ten, env.journal);
BEAST_EXPECT(!isTesSuccess(ter));
}
}
void
testClawbackVaultAssets()
{
testcase("clawbackVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
AccountID const vaultAccount = vault->at(sfAccount);
// The issuer of an IOU always implicitly holds it (no trust line
// required), matching the only real caller (VaultClawback, where
// the recipient is always the asset's issuer); accountHolds for an
// issuer's own currency doesn't track a normal incrementable
// balance, so check the Vault's own (real, decrementable) balance
// instead.
auto const vaultBalanceBefore = accountHolds(
view,
vaultAccount,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
STAmount const hundred{vaultAsset, 100};
auto const ter = clawbackVaultAssets(view, vault, issuer, hundred, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const vaultBalanceAfter = accountHolds(
view,
vaultAccount,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore - 100);
BEAST_EXPECT(availableAfter == availableBefore - 100);
BEAST_EXPECT(vaultBalanceAfter == vaultBalanceBefore - hundred);
// Clawing back more than sfAssetsAvailable fails outright, leaving
// the Vault's fields untouched.
{
Number const totalBeforeFail = vault->at(sfAssetsTotal);
Number const availableBeforeFail = vault->at(sfAssetsAvailable);
STAmount const tooMuch{vaultAsset, availableBeforeFail + 1};
auto const failTer = clawbackVaultAssets(view, vault, issuer, tooMuch, env.journal);
BEAST_EXPECT(failTer == tefINTERNAL);
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeFail);
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeFail);
}
// If the underlying accountSend fails (here: recipient has no trust
// line for the vault asset and cannot receive it), the helper
// propagates the non-tes error rather than silently swallowing it.
// In production the recipient is always the asset issuer, which
// implicitly holds its own asset; this synthetic third-party
// recipient stands in only to exercise the failure branch.
{
Account const stranger{"stranger"};
env.fund(XRP(10'000), stranger);
env.close();
STAmount const ten{vaultAsset, 10};
auto const failTer = clawbackVaultAssets(view, vault, stranger, ten, env.journal);
BEAST_EXPECT(!isTesSuccess(failTer));
}
}
void
testRemoveVaultAssets()
{
testcase("removeVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
// doWithdraw only reads ctx.tx on the third-party-destination path;
// for a self-withdrawal (senderAcct == dstAcct, exercised below) it
// is unused, so a trivial signed noop stands in for a real
// VaultWithdraw transaction.
auto const dummyTx = env.jt(noop(depositor)).stx;
if (!BEAST_EXPECT(dummyTx))
return;
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
ApplyViewContext const ctx{.view = view, .tx = *dummyTx};
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
auto const depositorBalanceBefore = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
// Normal (non-final) removal: both fields decrease by `amount`.
{
STAmount const hundred{vaultAsset, 100};
auto const ter = removeVaultAssets(
ctx,
vault,
depositor,
depositor,
XRPAmount{0},
hundred,
env.journal,
FinalRemoval::No);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const depositorBalanceAfter = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore - 100);
BEAST_EXPECT(availableAfter == availableBefore - 100);
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore + hundred);
}
// FinalRemoval::Yes hard-resets both fields to exactly zero,
// regardless of the passed-in amount (deliberately an understated
// amount here, to prove the fields aren't merely decremented by
// it).
{
Number const totalBeforeFinal = vault->at(sfAssetsTotal);
STAmount const one{vaultAsset, 1};
auto const ter = removeVaultAssets(
ctx,
vault,
depositor,
depositor,
XRPAmount{0},
one,
env.journal,
FinalRemoval::Yes);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == Number{0});
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == Number{0});
BEAST_EXPECTS(
totalBeforeFinal != Number(0),
"fixture sanity: total was nonzero before the final removal");
}
}
void
testMoveVaultAssets()
{
testcase("moveVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
Account const borrower{"borrower"};
Account const feeRecipient{"feeRecipient"};
env.fund(XRP(10'000), issuer, owner, depositor, borrower, feeRecipient);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(trust(borrower, asset(1'000'000)));
env(trust(feeRecipient, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
auto const borrowerBalanceBefore = accountHolds(
view,
borrower,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
auto const feeRecipientBalanceBefore = accountHolds(
view,
feeRecipient,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
// Mimics a loan disbursement: principal to the borrower, a fee to a
// separate recipient. valueDelta is zero here since a nonzero value
// is only meaningful (and only permitted, see moveVaultAssets's
// assertion) for a Legacy-version Vault recognizing accrued
// interest into sfAssetsTotal at origination; this fixture's Vault
// is not Legacy. sfAssetsAvailable still decreases independently of
// sfAssetsTotal, which is the contract under test.
MultiplePaymentDestinations const recipients{
{borrower, Number{80}},
{feeRecipient, Number{20}},
};
STAmount const zero{vaultAsset, 0};
auto const ter = moveVaultAssets(view, vault, recipients, zero, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const borrowerBalanceAfter = accountHolds(
view,
borrower,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
auto const feeRecipientBalanceAfter = accountHolds(
view,
feeRecipient,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
STAmount const eighty{vaultAsset, 80};
STAmount const twenty{vaultAsset, 20};
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore);
BEAST_EXPECT(availableAfter == availableBefore - 100);
BEAST_EXPECT(borrowerBalanceAfter == borrowerBalanceBefore + eighty);
BEAST_EXPECT(feeRecipientBalanceAfter == feeRecipientBalanceBefore + twenty);
// Zero-amount recipients still count toward the recipients.size() > 1
// precondition and drive the sum-of-amounts to zero, which short-
// circuits the accountSendMulti call. The Vault's fields are still
// updated (both to their pre-call values, since the deltas are all
// zero), and no funds move.
{
Number const totalBeforeZero = vault->at(sfAssetsTotal);
Number const availableBeforeZero = vault->at(sfAssetsAvailable);
auto const borrowerBalanceBeforeZero = accountHolds(
view,
borrower,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
MultiplePaymentDestinations const zeroRecipients{
{borrower, Number{0}},
{feeRecipient, Number{0}},
};
auto const zeroTer = moveVaultAssets(view, vault, zeroRecipients, zero, env.journal);
auto const borrowerBalanceAfterZero = accountHolds(
view,
borrower,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(zeroTer));
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeZero);
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeZero);
BEAST_EXPECT(borrowerBalanceAfterZero == borrowerBalanceBeforeZero);
}
}
public:
void
run() override
{
testAddVaultAssets();
testClawbackVaultAssets();
testRemoveVaultAssets();
testMoveVaultAssets();
}
};
BEAST_DEFINE_TESTSUITE(VaultHelpers, app, xrpl);
} // namespace xrpl::test

View File

@@ -15,6 +15,7 @@
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -584,7 +585,7 @@ private:
return std::nullopt;
if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) ||
!BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) ||
!BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11))
!BEAST_EXPECT(getVaultScale(vaultSle) == -11))
return std::nullopt;
// Use issuer clawback to reduce cover to the minimum the
@@ -711,7 +712,7 @@ private:
auto const coverAvail = brokerSle->at(sfCoverAvailable);
auto const debtTotal = brokerSle->at(sfDebtTotal);
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultScale(vaultSle);
auto const debtScale = scale(debtTotal, asset);
// Sanity: debt scale differs from vault scale for this setup.
@@ -809,7 +810,7 @@ private:
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
if (!BEAST_EXPECT(vaultSle))
return;
auto const vaultScale = getAssetsTotalScale(vaultSle);
auto const vaultScale = getVaultScale(vaultSle);
BEAST_EXPECT(vaultScale == -11);
// Now try to create a tiny additional loan. Principal is

View File

@@ -151,7 +151,7 @@ protected:
using namespace jtx;
auto const vaultSle = env.le(keylet::vault(vaultID));
return getAssetsTotalScale(vaultSle);
return getVaultScale(vaultSle);
}
};

View File

@@ -0,0 +1,79 @@
#pragma once
// Vault Dust — the probe seam.
//
// Where a Vault's un-recognized remainder ("dust") is stored differs per
// implementation:
// - the base branch has no dust mechanism at all;
// - solution A (…-pseudo-account) stores it as the balance of a second
// pseudo-account;
// - solution B' (…-trustline-dust) stores it as a signed field beside the
// balance on the Vault's custody trust line.
//
// This header is the ONLY place the shared test suite
// (src/test/app/lending/VaultRounding_test.cpp) is allowed to know about
// that difference, and it is the ONLY file that may differ between the two
// solution branches. Do not reference sfDustAccount, sfDust, or any other
// implementation-specific field anywhere else in a test — route every such
// read through readVaultDust() below.
//
// readVaultDust() below is the whole seam: each solution branch reimplements
// its body and changes nothing else. There is deliberately no "does this
// build have a reservoir?" flag — the shared suite asserts the post-fix
// oracles unconditionally, so on the base branch it fails, and that failure
// is the bug's demonstration (see the RED/GREEN CONTRACT note in
// VaultRounding_test.cpp).
#include <test/jtx/Env.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/View.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
namespace xrpl::test {
// Per-Vault: how much dust does this Vault currently hold, normalized to
// Vault-pseudo-account terms (i.e. a positive Number means "the Vault is
// carrying this much unrecognized value on the borrower's behalf").
//
// Returns zero when the build has no reservoir (this branch), and also when
// this particular Vault has none.
//
// `inline` is not optional: this header is included both by the shared
// suite and by each branch's own per-solution test file, so a non-inline
// definition would be a duplicate-symbol link error. If a solution's
// implementation grows past a few lines, move the body to a
// VaultDustProbe.cpp beside this header rather than dropping `inline`.
[[nodiscard]] inline Number
readVaultDust(jtx::Env const& env, Keylet const& vaultKeylet)
{
auto const vaultSle = env.le(vaultKeylet);
if (!vaultSle)
return Number{};
xrpl::Asset const asset = vaultSle->at(sfAsset);
if (asset.integral())
return Number{};
auto const vaultAccount = vaultSle->at(sfAccount);
auto const line =
env.current()->read(xrpl::keylet::trustLine(vaultAccount, asset.get<xrpl::Issue>()));
if (!line)
return Number{};
// sfDust follows sfBalance's own low/high sign convention: positive
// means the low account holds the high account's IOUs. Undo it here so
// the shared suite never has to think about which endpoint of the line
// is low — see plan-vault-dust-b-prime-field-accounting-kept.md §4.
bool const vaultIsHigh = vaultAccount > asset.getIssuer();
Number const dust = line->at(sfDust);
return vaultIsHigh ? -dust : dust;
}
} // namespace xrpl::test

View File

@@ -0,0 +1,760 @@
#include <test/app/lending/LoanTestBase.h>
#include <test/app/lending/VaultDustProbe.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/pay.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <chrono>
#include <optional>
#include <string>
// ============================================================================
// VaultRoundingTrustlineDust_test.cpp — solution B'-specific tests
// (docs/plan-vault-dust-b-prime-field-accounting-kept.md §13). This file is
// NOT shared: it is free to reference sfDust, DustSplit, useVaultDust, and
// friends directly, unlike src/test/app/lending/VaultRounding_test.cpp.
// ============================================================================
namespace xrpl::test {
class VaultRoundingTrustlineDust_test : public LoanTestBase
{
private:
struct DustFixture
{
jtx::Account issuer;
jtx::Account lender;
jtx::Account borrower;
jtx::PrettyAsset asset;
BrokerInfo broker;
Keylet tinyLoanKeylet;
};
// Same recipe as VaultRounding_test.cpp's withDustSetup (testsuite doc
// §5), parameterized by owner-account NAME so callers can search for
// both trust-line sign orientations (plan §13.2): the vault
// pseudo-account's address is a hash that depends on the owner and
// ledger state, so varying the owner varies which side of the issuer
// the pseudo-account lands on.
std::optional<DustFixture>
makeDustFixture(jtx::Env& env, std::string const& ownerSuffix)
{
using namespace jtx;
using namespace loan;
Account const issuer{"issuer" + ownerSuffix};
Account const lender{"lender" + ownerSuffix};
Account const borrower{"borrower" + ownerSuffix};
env.fund(XRP(1'000'000'00), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(50'000)));
env(trust(borrower, asset(50'000)));
env.close();
env(pay(issuer, lender, asset(30'000)));
env(pay(issuer, borrower, asset(1'000)));
env.close();
BrokerParameters const brokerParams{
.vaultDeposit = 1'000,
.debtMax = Number{0},
.coverRateMin = TenthBips32{13'370},
.coverDeposit = 5'000,
.managementFeeRate = TenthBips16{0}};
BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const brokerSle1 = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(brokerSle1))
return std::nullopt;
Keylet const tinyLoanKeylet =
keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle1->at(sfLoanSequence)));
env(set(borrower, broker.brokerID, Number{1, -2}),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{1'922}),
kPaymentTotal(2),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
Vault const vault{env};
env(vault.deposit(
{.depositor = lender, .id = broker.vaultKeylet().key, .amount = asset(9'500)}));
env.close();
auto const tinyLoanSle = env.le(tinyLoanKeylet);
auto const vaultSle = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(vaultSle))
return std::nullopt;
if (tinyLoanSle->at(sfLoanScale) != -12 || getVaultScale(vaultSle) != -11)
{
log << "VaultRoundingTrustlineDust: fixture did not reproduce -12/-11 "
"for owner suffix '"
<< ownerSuffix << "'" << std::endl;
return std::nullopt;
}
return DustFixture{
.issuer = issuer,
.lender = lender,
.borrower = borrower,
.asset = asset,
.broker = broker,
.tinyLoanKeylet = tinyLoanKeylet};
}
void
payTinyLoanInFull(jtx::Env& env, DustFixture const& fx)
{
using namespace jtx;
auto const loanSle = env.le(fx.tinyLoanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const periodicPayment = loanSle->at(sfPeriodicPayment);
auto const serviceFee = loanSle->at(sfLoanServiceFee);
std::int32_t const loanScale = loanSle->at(sfLoanScale);
auto const payment = roundPeriodicPayment(fx.asset.raw(), periodicPayment, loanScale);
auto const payAmt = STAmount{fx.asset.raw(), payment + serviceFee};
env(jtx::loan::pay(fx.borrower, fx.tinyLoanKeylet.key, payAmt), Fee(XRP(10)));
env.close();
}
//--------------------------------------------------------------------
// §13.1 The field
//--------------------------------------------------------------------
void
testDustAbsentReadsZero(FeatureBitset features)
{
testcase("sfDust absent on an untouched trust line reads as zero");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(1'000), alice, bob);
env.close();
PrettyAsset const asset = alice["USD"];
env(trust(bob, asset(1'000)));
env.close();
auto const line =
env.le(keylet::trustLine(alice.id(), bob.id(), asset.raw().get<Issue>().currency));
if (!BEAST_EXPECT(line))
return;
BEAST_EXPECT(!line->isFieldPresent(sfDust));
BEAST_EXPECT(Number{line->at(sfDust)} == beast::kZero);
}
void
testNoDustForLegacyOrIntegralVaults(FeatureBitset features)
{
testcase("Legacy and integral-asset vaults never carry sfDust");
using namespace jtx;
Env env{*this, features}; // featureLendingProtocolV1_1 excluded => Legacy
Account const issuer{"issuer"};
Account const lender{"lender"};
env.fund(XRP(1'000'000), issuer, lender);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(500'000)));
env.close();
env(pay(issuer, lender, asset(400'000)));
env.close();
Vault const vault{env};
auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
env(tx);
env.close();
auto const vaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return;
BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy);
BEAST_EXPECT(!vault_dust::useVaultDust(*env.current(), vaultSle));
BEAST_EXPECT(readVaultDust(env, vaultKeylet) == beast::kZero);
}
//--------------------------------------------------------------------
// §13.2 Sign convention — both orientations, explicitly forced.
//--------------------------------------------------------------------
void
testDustBothSignOrientations(FeatureBitset features)
{
testcase("Dust lifecycle holds with the Vault as both low and high account");
using namespace jtx;
bool sawLow = false, sawHigh = false;
for (int attempt = 0; attempt < 12 && !(sawLow && sawHigh); ++attempt)
{
Env env{*this, features | featureLendingProtocolV1_1};
auto const fx = makeDustFixture(env, std::to_string(attempt));
if (!fx)
continue;
auto const vaultSle = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSle))
continue;
AccountID const vaultAccount = vaultSle->at(sfAccount);
AccountID const issuerAccount = fx->issuer.id();
bool const vaultIsHigh = vaultAccount > issuerAccount;
if (vaultIsHigh && sawHigh)
continue;
if (!vaultIsHigh && sawLow)
continue;
payTinyLoanInFull(env, *fx);
Number const dust = readVaultDust(env, fx->broker.vaultKeylet());
if (dust == beast::kZero)
continue; // this attempt didn't generate dust; try another
// Read the raw ledger field and confirm the probe undid the
// low/high convention correctly: the raw sfDust value's sign
// (in the line's own convention, positive = low account holds
// high account's IOUs) must match vaultIsHigh appropriately.
auto const line = env.le(
keylet::trustLine(
vaultAccount, issuerAccount, fx->asset.raw().get<Issue>().currency));
if (!BEAST_EXPECT(line))
continue;
Number const rawDust = line->at(sfDust);
if (vaultIsHigh)
{
sawHigh = true;
// Vault-positive dust (dust > 0, meaning the Vault is
// carrying unrecognized value) corresponds to a NEGATIVE
// raw field when the Vault is the high account, since the
// raw convention is "low account's terms".
BEAST_EXPECT((dust > beast::kZero) == (rawDust < beast::kZero));
}
else
{
sawLow = true;
BEAST_EXPECT((dust > beast::kZero) == (rawDust > beast::kZero));
}
// The mirror still holds regardless of orientation (O1).
Number const avail = vaultSle->at(sfAssetsAvailable);
(void)avail;
auto const vaultSleAfter = env.le(fx->broker.vaultKeylet());
if (BEAST_EXPECT(vaultSleAfter))
{
Number const a = vaultSleAfter->at(sfAssetsAvailable);
Number const b = accountHolds(
*env.current(),
vaultSleAfter->at(sfAccount),
fx->asset.raw(),
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
beast::Journal{beast::Journal::getNullSink()});
BEAST_EXPECT(a == b);
}
}
BEAST_EXPECT(sawLow);
BEAST_EXPECT(sawHigh);
}
//--------------------------------------------------------------------
// §13.3 The credit path (driven through real transactions)
//--------------------------------------------------------------------
void
testDustCreatedAndPromoted(FeatureBitset features)
{
testcase("Credit path: split with remainder, and promotion on accumulation");
using namespace jtx;
Env env{*this, features | featureLendingProtocolV1_1};
auto const fx = makeDustFixture(env, "promote");
if (!fx)
return;
Number const dustBefore = readVaultDust(env, fx->broker.vaultKeylet());
BEAST_EXPECT(dustBefore == beast::kZero);
payTinyLoanInFull(env, *fx);
Number const dustAfter = readVaultDust(env, fx->broker.vaultKeylet());
// The fixture is specifically built so this repayment carries digits
// finer than the vault's scale (testsuite doc §5) — dust MUST be
// non-zero, or the whole fixture is vacuous.
BEAST_EXPECT(dustAfter != beast::kZero);
BEAST_EXPECT(dustAfter > beast::kZero);
auto const vaultSle = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSle))
return;
Number const q{1, getVaultScale(vaultSle)};
BEAST_EXPECT(dustAfter < q);
}
void
testNullptrPathUnchanged(FeatureBitset features)
{
testcase("Ordinary payments never acquire sfDust (nullptr path)");
using namespace jtx;
Env env{*this, features};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10'000), alice, bob, carol);
env.close();
PrettyAsset const asset = alice["USD"];
env(trust(bob, asset(10'000)));
env(trust(carol, asset(10'000)));
env.close();
env(pay(alice, bob, asset(1'000)));
env.close();
env(pay(bob, carol, asset(Number{1, -7})));
env.close();
// carol trusts the ISSUER (alice), not bob directly, so bob's
// payment to carol ripples through alice — the two lines actually
// touched are (alice,bob) and (alice,carol), not (bob,carol).
auto const lineAB =
env.le(keylet::trustLine(alice.id(), bob.id(), asset.raw().get<Issue>().currency));
auto const lineAC =
env.le(keylet::trustLine(alice.id(), carol.id(), asset.raw().get<Issue>().currency));
if (BEAST_EXPECT(lineAB))
BEAST_EXPECT(
!lineAB->isFieldPresent(sfDust) || Number{lineAB->at(sfDust)} == beast::kZero);
if (BEAST_EXPECT(lineAC))
BEAST_EXPECT(
!lineAC->isFieldPresent(sfDust) || Number{lineAC->at(sfDust)} == beast::kZero);
}
//--------------------------------------------------------------------
// §13.5 Lifecycle guards
//--------------------------------------------------------------------
void
testAccountHoldsExcludesDust(FeatureBitset features)
{
testcase("accountHolds returns sfBalance alone, never sfBalance + sfDust");
using namespace jtx;
Env env{*this, features | featureLendingProtocolV1_1};
auto const fx = makeDustFixture(env, "acctholds");
if (!fx)
return;
payTinyLoanInFull(env, *fx);
Number const dust = readVaultDust(env, fx->broker.vaultKeylet());
if (dust == beast::kZero)
return; // fixture failed to generate dust this run; nothing to check
auto const vaultSle = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSle))
return;
Number const holds = accountHolds(
*env.current(),
vaultSle->at(sfAccount),
fx->asset.raw(),
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
beast::Journal{beast::Journal::getNullSink()});
Number const assetsAvailable = vaultSle->at(sfAssetsAvailable);
// If accountHolds leaked sfDust into its result, holds would exceed
// assetsAvailable by roughly `dust`. It must not.
BEAST_EXPECT(holds == assetsAvailable);
}
void
testVaultDeleteRequiresZeroDust(FeatureBitset features)
{
testcase("VaultDelete is blocked while sfDust is non-zero, and succeeds once it is zero");
using namespace jtx;
Env env{*this, features | featureLendingProtocolV1_1};
Account const issuer{"issuer2"};
Account const lender{"lender2"};
env.fund(XRP(1'000'000), issuer, lender);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(500'000)));
env.close();
env(pay(issuer, lender, asset(400'000)));
env.close();
Vault const vault{env};
auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
auto const vaultSleBefore = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSleBefore))
return;
STAmount const allAssets{asset.raw(), vaultSleBefore->at(sfAssetsAvailable)};
env(vault.withdraw({.depositor = lender, .id = vaultKeylet.key, .amount = allAssets}));
env.close();
BEAST_EXPECT(readVaultDust(env, vaultKeylet) == beast::kZero);
env(vault.del({.owner = lender, .id = vaultKeylet.key}));
env.close();
BEAST_EXPECT(!env.le(vaultKeylet));
}
//--------------------------------------------------------------------
// §13.6 Re-normalisation
//--------------------------------------------------------------------
// Exercise the interaction between a dust-producing repayment and a
// subsequent NON-terminal VaultWithdraw. Historically this combination
// tripped
// "withdrawal must change vault and destination balance by equal
// amount"
// in ValidVault (src/libxrpl/tx/invariants/VaultInvariant.cpp) when the
// withdrawal drove the Vault's posterior scale finer than the custody
// line's, allowing renormaliseStrandedDust to promote whole quanta
// from sfDust into sfBalance. That promotion is a pure recognition
// move (no external cash flow), so comparing sfBalance-only deltas
// between the pseudo (which sees the promotion) and the destination
// (which does not) mis-attributed a quantum-worth of drift to the
// wrong side. The fix compares each side's EXTENDED balance
// (sfBalance + sfDust) — see the "extended balance" branch of the
// destination check in @c ttVAULT_WITHDRAW.
//
// Under the current implementation the non-terminal branch mutates the
// Vault SLE and the pseudo-account's custody line by the same amount,
// and any renormalisation adds identical whole-quantum deltas to both
// T and A. The ValidVault destination check now uses extended balance
// and therefore holds even when M > 0.
void
testNonTerminalWithdrawAfterDust(FeatureBitset features)
{
testcase(
"Non-terminal VaultWithdraw after a dust-producing repayment satisfies ValidVault");
using namespace jtx;
Env env{*this, features | featureLendingProtocolV1_1};
auto const fx = makeDustFixture(env, "nontermwd");
if (!fx)
return;
payTinyLoanInFull(env, *fx);
Number const dustAfterRepay = readVaultDust(env, fx->broker.vaultKeylet());
if (dustAfterRepay == beast::kZero)
return; // fixture did not generate dust this run
auto const vaultSleAfterRepay = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleAfterRepay))
return;
Number const availAfterRepay = vaultSleAfterRepay->at(sfAssetsAvailable);
Number const totalAfterRepay = vaultSleAfterRepay->at(sfAssetsTotal);
// Partial (non-terminal) withdrawal — take a chunk large enough
// to refine the Vault's posterior scale (10500 -> ~2), which is
// what triggers renormaliseStrandedDust to promote whole quanta
// out of the custody line's sfDust. Values chosen to leave
// outstanding shares (avoiding the terminal branch).
Vault const vault{env};
STAmount const withdrawAmount{fx->asset.raw(), Number{10'498}};
env(vault.withdraw(
{.depositor = fx->lender,
.id = fx->broker.vaultKeylet().key,
.amount = withdrawAmount}));
env.close();
// If ValidVault trips, the transaction is not committed — a
// successful commit is the primary oracle. But also cross-check
// the surviving invariant explicitly.
auto const vaultSleAfterWithdraw = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleAfterWithdraw))
return;
Number const availAfterWd = vaultSleAfterWithdraw->at(sfAssetsAvailable);
Number const totalAfterWd = vaultSleAfterWithdraw->at(sfAssetsTotal);
// Delta(sfAssetsTotal) must equal Delta(sfAssetsAvailable) —
// non-terminal removal subtracts `amount` from both, and any
// renormalisation adds the same movable delta to both.
BEAST_EXPECT((totalAfterWd - totalAfterRepay) == (availAfterWd - availAfterRepay));
// Dust must still be strictly less than one quantum at the new
// scale (O2).
Number const dustAfterWd = readVaultDust(env, fx->broker.vaultKeylet());
Number const q{1, getVaultScale(vaultSleAfterWithdraw)};
BEAST_EXPECT(dustAfterWd >= beast::kZero);
BEAST_EXPECT(dustAfterWd < q);
}
// Companion: another non-terminal step (a second partial withdraw)
// exercises the case where sfDust is non-zero going *into* the
// withdraw, potentially getting promoted by renormalisation. Same
// invariant contract: the ValidVault check must hold.
void
testSecondNonTerminalWithdrawAfterDust(FeatureBitset features)
{
testcase(
"Two successive non-terminal VaultWithdraws after dust-producing repayment satisfy "
"ValidVault");
using namespace jtx;
Env env{*this, features | featureLendingProtocolV1_1};
auto const fx = makeDustFixture(env, "twowd");
if (!fx)
return;
payTinyLoanInFull(env, *fx);
if (readVaultDust(env, fx->broker.vaultKeylet()) == beast::kZero)
return;
Vault const vault{env};
STAmount const firstWd{fx->asset.raw(), Number{5'000}};
env(vault.withdraw(
{.depositor = fx->lender, .id = fx->broker.vaultKeylet().key, .amount = firstWd}));
env.close();
auto const vaultSleMid = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleMid))
return;
Number const availMid = vaultSleMid->at(sfAssetsAvailable);
Number const totalMid = vaultSleMid->at(sfAssetsTotal);
STAmount const secondWd{fx->asset.raw(), Number{5'490}};
env(vault.withdraw(
{.depositor = fx->lender, .id = fx->broker.vaultKeylet().key, .amount = secondWd}));
env.close();
auto const vaultSleEnd = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleEnd))
return;
Number const availEnd = vaultSleEnd->at(sfAssetsAvailable);
Number const totalEnd = vaultSleEnd->at(sfAssetsTotal);
BEAST_EXPECT((totalEnd - totalMid) == (availEnd - availMid));
Number const dustEnd = readVaultDust(env, fx->broker.vaultKeylet());
Number const q{1, getVaultScale(vaultSleEnd)};
BEAST_EXPECT(dustEnd >= beast::kZero);
BEAST_EXPECT(dustEnd < q);
}
//--------------------------------------------------------------------
// Two-leg refactor tests (§ Two-leg DustSplit refactor)
//
// These exercise the plan's new sender-leg policies (Override on
// clawback / non-terminal withdrawal / move, Drain on terminal
// withdrawal) end-to-end via real transactors, so they cover both
// the trust-line layer's per-leg mechanics and the Vault-side
// reconciliation through split.sender->dustDelta.
//--------------------------------------------------------------------
// Sender-leg Override renormalisation: a scale-refining withdrawal
// promotes stranded dust from sfDust back into sfBalance, and the
// Vault's sfAssetsTotal / sfAssetsAvailable both grow by the
// promoted amount so the receivable invariant is preserved.
void
testSenderLegOverrideNonTerminalPromotes(FeatureBitset features)
{
testcase("Sender-leg Override renormalises stranded dust on non-terminal withdraw");
using namespace jtx;
Env env{*this, features | featureLendingProtocolV1_1};
auto const fx = makeDustFixture(env, "senderoverride");
if (!fx)
return;
// Generate dust via the tiny loan repayment.
payTinyLoanInFull(env, *fx);
Number const dustAfterRepay = readVaultDust(env, fx->broker.vaultKeylet());
if (dustAfterRepay == beast::kZero)
return; // fixture didn't generate dust this run
auto const vaultSleAfterRepay = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleAfterRepay))
return;
int const scaleBefore = getVaultScale(vaultSleAfterRepay);
Number const totalBefore = vaultSleAfterRepay->at(sfAssetsTotal);
Number const availBefore = vaultSleAfterRepay->at(sfAssetsAvailable);
// Large partial withdrawal — refines the posterior scale so
// the dust reservoir crosses a decade boundary and gets
// promoted by the sender-leg Override re-split.
Vault const vault{env};
STAmount const withdrawAmount{fx->asset.raw(), Number{10'498}};
env(vault.withdraw(
{.depositor = fx->lender,
.id = fx->broker.vaultKeylet().key,
.amount = withdrawAmount}));
env.close();
auto const vaultSleAfterWd = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleAfterWd))
return;
int const scaleAfter = getVaultScale(vaultSleAfterWd);
Number const dustAfterWd = readVaultDust(env, fx->broker.vaultKeylet());
// The withdrawal was sized to refine the scale.
BEAST_EXPECT(scaleAfter < scaleBefore);
// The Vault's T and A both moved by the SAME extended delta —
// exactly the invariant the sender-leg Override reconciliation
// preserves (both fields shift by `-amount` plus the promoted
// dust). If Override's reconciliation were miscoded, T and A
// would diverge.
Number const totalAfter = vaultSleAfterWd->at(sfAssetsTotal);
Number const availAfter = vaultSleAfterWd->at(sfAssetsAvailable);
BEAST_EXPECT((totalAfter - totalBefore) == (availAfter - availBefore));
// The remaining dust on the custody line is bounded by one
// quantum at the new posterior scale (up to a decade of
// Override drift, which the O2 relaxation tolerates).
Number const bound{10, scaleAfter};
BEAST_EXPECT(dustAfterWd >= beast::kZero);
BEAST_EXPECT(dustAfterWd < bound);
}
// Sender-leg Drain end-to-end: a terminal removal empties the
// custody line's reservoir into the destination. Vault code does
// no manual sfDust write; the trust-line layer folds sfDust into
// sfBalance, adjusts the outgoing amount, and zeroes sfDust. Post-
// condition: line has sfBalance == 0 and sfDust == 0; the Vault SLE
// has sfAssetsTotal == 0 and sfAssetsAvailable == 0.
void
testSenderLegDrainTerminalRemoval(FeatureBitset features)
{
testcase("Sender-leg Drain drains reservoir end-to-end on terminal removal");
using namespace jtx;
Env env{*this, features | featureLendingProtocolV1_1};
auto const fx = makeDustFixture(env, "senderdrain");
if (!fx)
return;
// Seed a non-zero reservoir on the custody line by paying the
// first periodic instalment (returns interest with sub-quantum
// residual).
payTinyLoanInFull(env, *fx);
Number const dustBeforeTerminal = readVaultDust(env, fx->broker.vaultKeylet());
if (dustBeforeTerminal == beast::kZero)
return; // fixture didn't produce dust this run
BEAST_EXPECT(dustBeforeTerminal > beast::kZero);
// The remainder of the tiny loan is outstanding, which keeps
// sfAssetsAvailable < sfAssetsTotal and blocks a terminal
// withdrawal (the lender's shares still back the outstanding
// principal). Advance time past the next payment due date +
// grace window (loan is 2 payments at 1-year intervals; one
// has been made) and default the loan so no principal is
// outstanding and the vault SLE's sfAssetsTotal drops to
// sfAssetsAvailable.
env.close(std::chrono::seconds(86400 * 800));
env(jtx::loan::manage(fx->lender, fx->tinyLoanKeylet.key, tfLoanDefault));
env.close();
auto const vaultSleBefore = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleBefore))
return;
Number const dustAfterDefault = readVaultDust(env, fx->broker.vaultKeylet());
if (dustAfterDefault == beast::kZero)
return; // default consumed the reservoir; nothing left to test
Number const lenderBalanceBefore = env.balance(fx->lender, fx->asset).number();
Number const availBefore = Number{vaultSleBefore->at(sfAssetsAvailable)};
// Withdraw the full available balance in one shot. With the
// loan defaulted, sfAssetsAvailable == sfAssetsTotal and the
// lender holds all outstanding shares, so this burns every
// share and triggers FinalRemoval::Yes inside VaultWithdraw —
// the path that installs the Drain policy on the sender leg.
STAmount const allAssets{fx->asset.raw(), vaultSleBefore->at(sfAssetsAvailable)};
Vault const vault{env};
env(vault.withdraw(
{.depositor = fx->lender, .id = fx->broker.vaultKeylet().key, .amount = allAssets}));
env.close();
auto const vaultSleAfter = env.le(fx->broker.vaultKeylet());
if (!BEAST_EXPECT(vaultSleAfter))
return;
// Vault SLE ends terminal: both totals reset to zero.
BEAST_EXPECT(Number{vaultSleAfter->at(sfAssetsAvailable)} == beast::kZero);
BEAST_EXPECT(Number{vaultSleAfter->at(sfAssetsTotal)} == beast::kZero);
// Custody line's sfDust must be zero — Drain zeroed it inside
// the trust-line layer, not via a manual fold in Vault code.
Number const dustAfter = readVaultDust(env, fx->broker.vaultKeylet());
BEAST_EXPECT(dustAfter == beast::kZero);
// The destination (lender) received at least the vault's
// pre-terminal sfAssetsAvailable plus the reservoir that was
// drained — the observable end-to-end effect of Drain mode:
// value that used to be stranded in sfDust reaches the
// destination in the same transaction.
Number const lenderBalanceAfter = env.balance(fx->lender, fx->asset).number();
Number const received = lenderBalanceAfter - lenderBalanceBefore;
BEAST_EXPECT(received >= availBefore);
BEAST_EXPECT(received >= availBefore + dustAfterDefault);
}
public:
void
run() override
{
testDustAbsentReadsZero(all_);
testNoDustForLegacyOrIntegralVaults(all_);
testDustBothSignOrientations(all_);
testDustCreatedAndPromoted(all_);
testNullptrPathUnchanged(all_);
testAccountHoldsExcludesDust(all_);
testVaultDeleteRequiresZeroDust(all_);
testNonTerminalWithdrawAfterDust(all_);
testSecondNonTerminalWithdrawAfterDust(all_);
testSenderLegOverrideNonTerminalPromotes(all_);
testSenderLegDrainTerminalRemoval(all_);
}
};
BEAST_DEFINE_TESTSUITE(VaultRoundingTrustlineDust, tx, xrpl);
} // namespace xrpl::test

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ TEST(RippleStateTests, BuilderSettersRoundTrip)
auto const highQualityOutValue = canonical_UINT32();
auto const highSponsorValue = canonical_ACCOUNT();
auto const lowSponsorValue = canonical_ACCOUNT();
auto const dustValue = canonical_NUMBER();
RippleStateBuilder builder{
balanceValue,
@@ -50,6 +51,7 @@ TEST(RippleStateTests, BuilderSettersRoundTrip)
builder.setHighQualityOut(highQualityOutValue);
builder.setHighSponsor(highSponsorValue);
builder.setLowSponsor(lowSponsorValue);
builder.setDust(dustValue);
builder.setLedgerIndex(index);
builder.setFlags(0x1u);
@@ -154,6 +156,14 @@ TEST(RippleStateTests, BuilderSettersRoundTrip)
EXPECT_TRUE(entry.hasLowSponsor());
}
{
auto const& expected = dustValue;
auto const actualOpt = entry.getDust();
ASSERT_TRUE(actualOpt.has_value());
expectEqualField(expected, *actualOpt, "sfDust");
EXPECT_TRUE(entry.hasDust());
}
EXPECT_TRUE(entry.hasLedgerIndex());
auto const ledgerIndex = entry.getLedgerIndex();
ASSERT_TRUE(ledgerIndex.has_value());
@@ -180,6 +190,7 @@ TEST(RippleStateTests, BuilderFromSleRoundTrip)
auto const highQualityOutValue = canonical_UINT32();
auto const highSponsorValue = canonical_ACCOUNT();
auto const lowSponsorValue = canonical_ACCOUNT();
auto const dustValue = canonical_NUMBER();
auto sle = std::make_shared<SLE>(RippleState::entryType, index);
@@ -196,6 +207,7 @@ TEST(RippleStateTests, BuilderFromSleRoundTrip)
sle->at(sfHighQualityOut) = highQualityOutValue;
sle->at(sfHighSponsor) = highSponsorValue;
sle->at(sfLowSponsor) = lowSponsorValue;
sle->at(sfDust) = dustValue;
RippleStateBuilder builderFromSle{sle};
EXPECT_TRUE(builderFromSle.validate());
@@ -360,6 +372,19 @@ TEST(RippleStateTests, BuilderFromSleRoundTrip)
expectEqualField(expected, *fromBuilderOpt, "sfLowSponsor");
}
{
auto const& expected = dustValue;
auto const fromSleOpt = entryFromSle.getDust();
auto const fromBuilderOpt = entryFromBuilder.getDust();
ASSERT_TRUE(fromSleOpt.has_value());
ASSERT_TRUE(fromBuilderOpt.has_value());
expectEqualField(expected, *fromSleOpt, "sfDust");
expectEqualField(expected, *fromBuilderOpt, "sfDust");
}
EXPECT_EQ(entryFromSle.getKey(), index);
EXPECT_EQ(entryFromBuilder.getKey(), index);
}
@@ -438,5 +463,7 @@ TEST(RippleStateTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(entry.getHighSponsor().has_value());
EXPECT_FALSE(entry.hasLowSponsor());
EXPECT_FALSE(entry.getLowSponsor().has_value());
EXPECT_FALSE(entry.hasDust());
EXPECT_FALSE(entry.getDust().has_value());
}
}