Skip to content

feat: Add xrpl::vault_dust:: overlay, wire dispatchers, relax ValidVault - #8018

Open
Tapanito wants to merge 8 commits into
tapanito/vault-dust-primitivefrom
tapanito/vault-dust-overlay
Open

feat: Add xrpl::vault_dust:: overlay, wire dispatchers, relax ValidVault#8018
Tapanito wants to merge 8 commits into
tapanito/vault-dust-primitivefrom
tapanito/vault-dust-overlay

Conversation

@Tapanito

@Tapanito Tapanito commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Third and final slice of the Vault-dust custody design, split out from #7970. Wires the Vault as the sole consumer of DustSplit today, and relaxes ValidVault to accept the extended-balance semantics introduced by scale-refinement dust promotion.

Also lands docs/dust-mechanism.md, which describes the full mechanism (model, DustSplit shape, Override/Drain semantics, reporting sign convention, contract asserts, how to add a new consumer, amendment-gate policy, ValidVault sign-handling).

Stack:

Each slice ships its own tests. This PR ships the Vault-flow / invariant / integration tests.

Design

xrpl::vault_dust:: overlay

  • useVaultDust(view, vault): eligibility gate — featureLendingProtocolV1_1 + VaultVersion::CashBasis + !asset.integral(). Also the dispatcher used by the base xrpl::addVaultAssets / clawbackVaultAssets / removeVaultAssets / moveVaultAssets to route eligible vaults through the vault_dust:: implementations.
  • Deposits (addVaultAssets): receiver-leg Override at posteriorScale(vault, valueDelta). Vault reconciles sfAssetsAvailable += split.receiver->balanceDelta, sfAssetsTotal += valueDelta - split.receiver->dustDelta.
  • Clawback / non-terminal remove / move: sender-leg Override at scale(sfAssetsTotal_post_op). Vault reconciles the promoted amount (-split.sender->dustDelta) back into sfAssetsAvailable and sfAssetsTotal.
  • Terminal remove (FinalRemoval::Yes): sender-leg Drain. sfDust and sfBalance land at zero on the vault's custody line; sfAssetsTotal and sfAssetsAvailable hard-reset to zero.
  • No trust-line writes anywhere in vault_dust:: code. Renormalisation is implicit: the credit path re-splits the extended balance on every accountSend under Override mode.

ValidVault relaxation

  • DeltaInfo gains a signed dustDelta alongside delta. ttVAULT_WITHDRAW now compares the extended pseudo-account delta (delta + dustDelta) against the destination's extended delta, so a scale-refinement dust promotion (an internal move on the pseudo-account's line) does not spuriously fail "vault and destination balance must change by equal amount".
  • O2 dust bounded by one quantum relaxed to 10 × quantum(scale(sfAssetsTotal)) to accept up-to-one-decade drift under Override mode when the caller's ex-ante scale estimate lands on a decade boundary. Drift is caught on the next accountSend.

Wiring

  • LoanPay routes through the vault_dust:: overlay when useVaultDust is true.
  • VaultDeposit / VaultWithdraw pass the DustSplit through into the base helpers.

Tests

Three suite additions and two augmentations, exercising the overlay, invariant, and wiring together:

  • src/test/app/lending/VaultRoundingTrustlineDust_test.cpp — 12 dust-lifecycle tests driven through real transactions (deposits, loan payments, withdrawals, terminal drain, invariant regressions, multi-loan interleaving), guarded by a floor on dust-observing runs so the suite can't go silently vacuous.
  • src/test/app/lending/VaultRounding_test.cpp — 16 Vault-facing rounding scenarios (O1-O8 identities, dust accumulation and promotion, scale-crossing, terminal drain, LoanManage::defaultLoan interaction).
  • src/test/app/lending/VaultDustProbe.h — shared instrumentation used by both suites to read raw sfDust in the Vault's sign convention.
  • src/test/app/VaultHelpers_test.cpp — new "moveVaultAssets: sender-leg Override renormalises, recipients dust-unaware" case for the overlay dispatcher.
  • src/test/app/lending/LoanPay_test.cpp — new "broker fee debit shares the borrower's trust line with the vault debit" case for the LoanPay wiring.

Protocol-layer and DustSplit-primitive tests live earlier in the stack (#8016 RippleStateSfDust_test, #8017 DustSplitCreditPath_test).

Extensibility

The DustSplit struct is consumer-agnostic — no Vault-specific fields, no Purpose enum. Enforcement of "Vault-only today" is layered:

  1. useVaultDust(view, vault) is the sole caller-side gate today; only vault_dust:: code constructs a DustSplit.
  2. directSendNoFeeIOU asserts featureLendingProtocolV1_1 on any non-empty DustSplit.
  3. A new consumer (AMM, LoanBroker, …) adds its own eligibility gate, constructs a DustSplit from its transactor helpers, and reconciles its own bookkeeping. No trust-line-layer code changes required per new consumer.

Test plan

  • All new suites pass locally (VaultRoundingTrustlineDust_test, VaultRounding_test, VaultHelpers_test, LoanPay_test)
  • Existing Vault / Loan suites remain green with featureLendingProtocolV1_1 disabled
  • With the amendment enabled, existing Vault / Loan suites remain green

Non-goals

  • No changes to Legacy vaults or integral-asset vaults.
  • No non-Vault consumers of DustSplit.
  • No RIPPLE_STATE field-order changes.
  • No amendment-gate changes (still featureLendingProtocolV1_1).
  • No Natural DustSplit mode (deferred).

Two protocol-level tests for the sfDust field:
- Absent sfDust reads as zero on an untouched trust line (SoeDefault
  read path).
- SoeDefault byte-encoding contract: absent and explicit-zero encodings
  are byte-identical, so a pre-amendment ledger and a post-amendment
  ledger stay hash-compatible for any RippleState that has never carried
  non-zero dust.

Both tests use only SLE / trust-line primitives — no DustSplit and no
vault_dust:: consumer (both land in follow-up PRs).

Also picks up 'reserialise*' in cspell, used in the byte-compat test's
docstring.
Introduces DustSplit, a per-leg dust policy struct threaded through
directSendNoFeeIOU / accountSend / accountSendMulti / doWithdraw, plus
the read-side creditBalanceExact helper.

DustSplit models an IOU payment as two independent trust-line touches
(sender leg, receiver leg). Each leg carries an optional LegPolicy in
one of two modes:

- Override: caller supplies the target STAmount scale; the credit path
  computes the extended balance (sfBalance + sfDust) in the leg's
  non-issuer party's terms, applies the delta, and re-splits at the
  requested scale. Any stranded sfDust above one quantum at the new
  scale is promoted into sfBalance as part of the same re-split, so
  scale-refinement is fold-in-place.
- Drain (sender-leg only): folds all sender-line sfDust into the
  outgoing transfer, leaving sfBalance == 0 and sfDust == 0. Used for
  terminal removals.

Reports back the observed per-leg deltas (balanceDelta and signed
dustDelta) in the leg-party's own sign convention, letting the caller
reconcile its own bookkeeping without re-reading the trust line.

directSendNoFeeIOU is the single site where sfDust writes happen.
Feature-gated: directSendNoFeeIOU asserts featureLendingProtocolV1_1
on any non-empty DustSplit; without the amendment the classic code
path runs unchanged.

creditBalanceExact reads the extended balance in the holder's terms;
classic accountHolds remains STAmount-only.

No consumers wired in yet; that lands in follow-ups.
Three trust-line-layer tests for the DustSplit primitive and its
credit-path threading:

- Ordinary payments (nullptr policy) never acquire sfDust — the
  feature-gated code path is inert without a caller-supplied DustSplit.
- Plain accountSend across a line seeded with non-zero sfDust must
  preserve that reservoir byte-for-byte; the nullptr-policy path is
  dust-agnostic in both directions.
- removeEmptyHolding returns tecHAS_OBLIGATIONS while sfDust != 0 and
  tesSUCCESS once sfDust is zero, pinning both sides of the deletion
  guard.

Uses only the trust-line layer (Sandbox-level SLE mutation +
accountSend/removeEmptyHolding). No vault_dust:: consumer is exercised
here — that lands in the follow-up overlay PR.
Introduces the Vault-side consumer of DustSplit and its invariant
adjustments, wired via a single eligibility gate.

Overlay (xrpl::vault_dust::):
- useVaultDust(view, vault): eligibility gate — featureLendingProtocolV1_1
  + VaultVersion::CashBasis + !asset.integral(). Also the dispatcher
  used by the base xrpl::addVaultAssets / clawbackVaultAssets /
  removeVaultAssets / moveVaultAssets to route eligible vaults into
  vault_dust:: implementations.
- addVaultAssets: receiver-leg Override at posteriorScale(vault, delta).
- clawbackVaultAssets / non-terminal removeVaultAssets /
  moveVaultAssets: sender-leg Override at scale(sfAssetsTotal_post_op).
- Terminal removeVaultAssets (FinalRemoval::Yes): sender-leg Drain,
  landing sfDust and sfBalance at zero on the vault's custody line.

No trust-line writes anywhere in vault_dust:: code. Renormalisation is
implicit: the credit path re-splits the extended balance at every
accountSend under Override mode.

Invariant relaxation:
- ValidVault::DeltaInfo gains a signed dustDelta alongside delta.
  ttVAULT_WITHDRAW now compares the extended pseudo-account delta
  (delta + dustDelta) against the destination's extended delta, so a
  scale-refinement dust promotion (an internal move on the
  pseudo-account's line) doesn't spuriously fail "vault and destination
  balance must change by equal amount".
- O2 "dust bounded by one quantum" relaxed to
  10 x quantum(scale(sfAssetsTotal)) to accept up-to-one-decade drift
  under Override mode when the ex-ante scale estimate lands on a
  decade boundary; drift is caught on the next accountSend.

Wiring:
- LoanPay routes through the vault_dust:: overlay when useVaultDust is
  true.
- VaultDeposit / VaultWithdraw pass the DustSplit through into the
  base helpers.

Docs:
- docs/dust-mechanism.md describes the model, DustSplit shape, Override
  vs Drain semantics, reporting sign convention, contract asserts,
  how to add a new consumer, amendment-gate policy, and the ValidVault
  sign-handling.
Adds the Vault-flow test suites that exercise the vault_dust:: overlay,
the ValidVault relaxation, and the LoanPay / VaultHelpers wiring
introduced in this PR.

New suites:
- VaultRoundingTrustlineDust_test: 12 dust-lifecycle tests driven
  through real transactions (deposits, loan payments, withdrawals,
  terminal drain, invariant regressions, multi-loan interleaving),
  guarded by a floor on dust-observing runs so the suite can't go
  silently vacuous.
- VaultRounding_test: 16 Vault-facing rounding scenarios (O1-O8
  identities, dust accumulation and promotion, scale-crossing,
  terminal drain, LoanManage::defaultLoan interaction, dust
  characterisation on the base branch).
- VaultDustProbe: shared instrumentation used by both suites to read
  raw sfDust in the Vault's sign convention.

Additional coverage:
- VaultHelpers_test: new "moveVaultAssets: sender-leg Override
  renormalises, recipients dust-unaware" case for the overlay
  dispatcher.
- LoanPay_test: new "broker fee debit shares the borrower's trust
  line with the vault debit" case for the LoanPay wiring.

Also adds 'parameterise*' to .cspell.config.yaml, used across the
new suites.

Protocol-layer and DustSplit-primitive tests live earlier in the
stack (RippleStateSfDust and DustSplitCreditPath suites).
@github-actions

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

@Tapanito
Tapanito force-pushed the tapanito/vault-dust-primitive branch from cc34b81 to 81c9462 Compare August 13, 2026 12:13
…-overlay

# Conflicts:
#	include/xrpl/ledger/helpers/VaultHelpers.h
#	src/libxrpl/ledger/helpers/RippleStateHelpers.cpp
#	src/libxrpl/ledger/helpers/TokenHelpers.cpp
#	src/libxrpl/ledger/helpers/VaultHelpers.cpp
#	src/libxrpl/tx/transactors/lending/LoanPay.cpp
@github-actions

Copy link
Copy Markdown

All conflicts have been resolved. Assigned reviewers can now start or resume their review.

Replace the per-transactor `if (!useDust)` gate around
`associateAsset(*vaultSle, asset)` and the pre-truncation
`- split.receiver->dustDelta` compensation in the dust-aware
Vault writes with a metadata-driven exemption on sfAssetsTotal
itself. Under featureLendingProtocolV1_1, sfAssetsTotal now
retains full Number (19-digit) precision instead of being
truncated to STAmount (16-digit) precision at end of transactor,
so it can absorb the same sub-quantum residual that sfDust
absorbs on the custody line. The receivable invariant becomes
`sfAssetsTotal − (sfAssetsAvailable + custodyLine.sfDust) =
Σ PrincipalOutstanding`, which reduces to the classic form
pre-amendment (dust always zero) and holds byte-for-byte across
arbitrarily long dust-bearing sequences post-amendment.

Design (no signature changes, no plumbing at any of the ~19
associateAsset call sites):

- Introduce `SField::kSmdAssetPreLend11 = 0x100`, semantically
  "swept only when featureLendingProtocolV1_1 is not enabled."
- Retag sfAssetsTotal from `kSmdNeedsAsset | kSmdDefault` to
  `kSmdAssetPreLend11 | kSmdDefault`. sfAssetsAvailable,
  sfAssetsMaximum, sfLossUnrealized keep kSmdNeedsAsset.
- `associateAsset(SLE&, Asset const&)` gate becomes the union
  `kSmdNeedsAsset || (kSmdAssetPreLend11 &&
  !isFeatureEnabled(featureLendingProtocolV1_1))`, read from the
  thread-local CurrentTransactionRulesGuard. No rules installed
  → returns false → sweep runs (safe legacy behavior).
- Widen the Debug-only sanity assertion in
  `STNumber::associateAsset` to accept either bit. The
  serialisation-path check at STNumber::add remains keyed on
  kSmdNeedsAsset only — that is what keeps sfAssetsTotal at full
  precision on the wire post-amendment.

Consequences:

- addVaultAssets: `sfAssetsTotal += Number{valueDelta}` (no
  `- dustDelta` compensation).
- reconcileSenderDust: only sfAssetsAvailable moves on a
  sender-leg dust reshuffle; sfAssetsTotal is unchanged because
  the underlying quantity on the custody line is unchanged.
- LoanPay: unconditional `associateAsset(*vaultSle, asset)`; the
  20-line docblock explaining the local skip is gone.
- VaultInvariant: sfAssetsTotal delta comparisons in the
  deposit / withdraw / clawback branches now use the extended
  delta `delta + dustDelta`. Pre-amendment dustDelta is always
  zero, so the check reduces to the classic form there.

Tests:

- New src/tests/libxrpl/protocol/STTakesAsset.cpp — 6 targeted
  gtest cases covering the sweep for (kSmdNeedsAsset-only,
  kSmdAssetPreLend11-only, both, neither) × (rules on, rules
  off, no rules installed).
- Existing Vault/Loan suites re-baselined to the dust-inclusive
  invariant via `readVaultDust()`; the shared VaultRounding_test
  fixtures now degrade cleanly to the classic RED contract on
  the base branch where readVaultDust() returns zero.
@Tapanito
Tapanito requested a review from ximinez August 20, 2026 15:24
@Tapanito
Tapanito marked this pull request as ready for review August 20, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant