From 41372a4305a7659efaf09aebe67c6ef6a3451201 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:31:07 +0200 Subject: [PATCH 1/6] docs: Add Vault fixed-precision design notes Proposes a design-time sfPrecision ceiling for Vault rounding, decoupled from the Vault's internal running totals, plus a Dust Pseudo-account and sfDust accumulator to capture value a mandatory operation (loan repayment) would otherwise lose when that ceiling degrades. --- docs/design-vault-fixed-precision.md | 632 +++++++++++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100644 docs/design-vault-fixed-precision.md diff --git a/docs/design-vault-fixed-precision.md b/docs/design-vault-fixed-precision.md new file mode 100644 index 00000000000..d1ebeeca908 --- /dev/null +++ b/docs/design-vault-fixed-precision.md @@ -0,0 +1,632 @@ +# Vault Fixed Precision — Design Notes + +Status: draft, in progress. + +## 1. Problem + +Vault rounding precision is currently _derived_ from `AssetsTotal` rather than _declared_ once. +Amounts have a fixed budget of significant digits (16, §1.1); as `AssetsTotal` grows, less of that +budget is left for the fraction, so effective precision shrinks as the Vault grows: + +1. **Unpredictable** — the same nominal payment rounds differently depending on Vault size at the + moment, unrelated to the transaction. +2. **Precision degrades with success** — bigger, more successful Vaults get sloppier accounting. +3. **Cascading complexity** — Loan precision derives from Vault precision, a moving target. +4. **Non-deterministic edge cases** — dust/precision-loss depend on Vault size at operation time, + not fixed rules. +5. **No stable contract** — integrators can't know rounding precision ahead of time without + inspecting live (and staleable) Vault state. + +### 1.1 The Ledger & Custody Constraint + +Every Vault has two entities, both bound by the same **16 significant-digit ceiling** (rippled's +amount representation limit): + +- **SAV** — internal accounting ledger for total assets/shares. +- **Pseudo-account** — the custody account actually holding the asset. + +That 16-digit budget splits between integer and fractional parts. At high valuation (e.g. $100B) +with fine precision (e.g. 6 decimals), an amount like $1.234567 may not fit — a representation +limit, not policy. + +### 1.2 Why a naive fixed precision isn't enough + +Fixing one precision value at Vault creation and applying it everywhere — including internal running +totals — still hits the 16-digit wall: a repayment's interest/fee component can push `AssetsTotal` +past what a fixed precision can represent. Rejecting the transaction blocks a borrower from closing +debt for reasons unrelated to their loan. + +There's also an asymmetry: **optional** operations (e.g. a deposit) can tolerate silent truncation; +**mandatory** ones (a repayment closing debt) cannot — dropping the remainder breaks the +assets/shares identity or shortchanges what's owed. + +So the fix needs two things that must not be conflated: + +- A fixed, design-time precision ceiling for client-facing operations, set once at Vault creation, + giving integrators a stable contract. +- That ceiling must _not_ also bound the Vault's internal running totals, which grow unbounded and + will eventually collide with the 16-digit ceiling if forced into the same representation as a + single transaction amount. + +## 2. Mechanism + +### 2.1 `sfPrecision` — a ceiling, not an absolute value + +A new field, `sfPrecision`, set once at Vault creation, immutable thereafter. **Not** the existing +`sfScale` field — `sfScale` is already the fixed assets↔shares conversion factor used today in +`VaultHelpers.cpp`; `sfPrecision` is new, and is this design's rounding ceiling: + +``` +effective(AssetsTotal) = min(sfPrecision, maxPrecisionRepresentableGiven(AssetsTotal, 16-sig-digit budget)) +``` + +Degradation is strictly downward from the ceiling — the Vault never rounds finer than `sfPrecision`. +Integrators get a deterministic contract: the finest precision ever used, computable from +`AssetsTotal` and `sfPrecision` rather than an opaque derivation. It doesn't resolve the 16-digit +collision on internal running totals — that's §2.2. + +### 2.2 Error Accumulation (Kahan Summation & Vault Dust Custody) + +Lets a mandatory operation succeed in full when `sfPrecision` can't currently be honored, without +truncating value or rejecting the tx. + +- **Ledger tracking (SAV):** a `Dust` accumulator, stored as a new field **`sfDust`** on `ltVAULT` + (Kahan-style error term). Whenever an amount rounds down from `sfPrecision` to the effective + scale, the dropped remainder adds into `sfDust` — an exact, auditable record of deferred value. + (Written as `Dust`/`vault.Dust` elsewhere for readability; same field.) +- **Custody routing:** the representable portion flows to the main Pseudo-account as normal; the + unrepresentable fraction flows to a dedicated **Dust Pseudo-account** for that Vault. +- **The Sweep:** once dust custody accumulates a whole representable unit, it's transferred cleanly + into the main Pseudo-account. + +Invoked only where §3 says so — not a general-purpose remainder catcher for ordinary rounding. + +**`Dust` is real value, and must count as such — for both totals, not just one.** `SettleAdd` (§4.1) +only adds the _settled_ portion into `AssetsTotal`; the dust portion sits, for real, in the Dust +Pseudo-account. If the figures exposed for NAV/share pricing and liquidity don't include it, both +silently undercount the Vault: + +``` +totalAssetsForPricing = AssetsTotal + Dust +totalAssetsAvailable = AssetsAvailable + Dust +``` + +`Dust` isn't just "extra value" NAV needs to know about — it's real, liquid money, never lent to +anyone, in an account the Vault fully controls. That's precisely what `AssetsAvailable` tracks (the +not-out-on-loan, liquid portion), so `Dust` belongs in _both_ sums: leaving it out of +`AssetsAvailable` would understate how much the Vault can actually pay out right now. + +This matters beyond bookkeeping hygiene: `AssetsTotal`/`AssetsAvailable` are each bounded by an +absolute `STAmount` ceiling, independent of `sfPrecision`'s value — once pinned there, neither can +accept further inflow at _any_ scale. `Dust`, in its own account with its own headroom, becomes a +**fallover buffer** past that point. Without folding `Dust` into both sums, that fallover regime +would be invisible — depositors' claims and withdrawal liquidity would both stop reflecting real +inflows once `AssetsTotal` maxes out, even as the Vault keeps receiving (and owing) more. + +**Scope note on `AssetsAvailable`.** The fuller share-pricing model (`VaultSharePricing_test.cpp` +PoC) also tracks `interestUnrealized`/`lossUnrealized`, under which `AssetsTotal` and +`AssetsAvailable` can diverge — e.g. funding a loan moves principal out of `AssetsAvailable` without +touching `AssetsTotal`, since the loan remains an asset, just an illiquid one. **This document +doesn't model that bucket** — it's scoped to precision/dust. Within that scope, every operation in +§3 moves `AssetsAvailable` by the same `settled` delta as `AssetsTotal` (§4). Wiring into the full +interest-accrual model may require Loan funding/repayment to diverge the two — flagged in §5, not +resolved here. + +## 3. Case by case: how rounding actually works + +### 3.1 User fund movement — Deposit, Withdraw, `VaultClawback` + +**Optional operations** — round directly to the effective scale, no `sfPrecision`-first attempt: + +``` +amount_settled = round(amount_requested, effective(AssetsTotal)) +``` + +Representable by construction, so §2.2 never fires — no `Dust` entry, no custody routing. Acceptable +because none of these are obligations owed in full. + +### 3.2 Loan issuance (funding) — Vault → Loan + +Funding is a Vault-initiated disbursement, not a debt obligation — structurally a withdrawal. +Follows the §3.1 rule: rounds directly to the Vault's _current_ effective scale, no dust. + +Consequence: **the Loan's own precision is fixed once, at funding time, to the effective scale used +for that disbursement** — not the Vault's raw `sfPrecision` ceiling: + +``` +loan.precision = effective(AssetsTotal at funding time) [ = min(sfPrecision, ...) ] +``` + +Stored as the existing **`sfLoanScale`** field on `ltLOAN` (set in `LoanSet.cpp`, read in +`LoanPay.cpp`/`LoanManage.cpp`) — same relationship as `sfPrecision`/`sfScale` on the Vault side: +the field already exists, what's new is _when_ and _to what value_ it gets set (this funding-time +rule, not whatever governs it today). + +Immutable thereafter, same as `sfPrecision` is for the Vault. Operating a Loan at finer precision +than it was actually funded with would assert false precision across all its future accounting. + +**Worked example.** Vault `sfPrecision` = 6. Loan A funds while `AssetsTotal` is small (effective +scale 6) → `loan.precision` = 6. Later `AssetsTotal` grows enough to degrade effective scale to 5; +Loan B funds then → `loan.precision` = 5, permanently — same Vault, two Loans, two fixed precisions, +set by Vault size at each funding moment. + +#### 3.2.1 Pending Loans: acceptance can straddle a scale change + +Issuance is two steps: **create** (amount agreed/reserved, funds not yet moved) and **accept** +(funds move). "Funding time" above is really **acceptance time**. + +Between create and accept, `AssetsTotal` can degrade the effective scale enough that the agreed +amount is no longer representable. Two options: + +- Silently re-round at acceptance — rejected: mutates agreed loan terms invisibly, exactly the + non-determinism this design eliminates. +- **Reject the acceptance**, forcing re-creation/re-quote — worse for the borrower short-term, but + fails loudly instead of silently mutating terms. **Chosen.** + +So a pending Loan's amount is re-validated against `effective(AssetsTotal)` at acceptance; on +failure, acceptance is rejected outright, never adjusted. + +### 3.3 Loan repayment — Loan → Vault + +**Mandatory operation.** Two-stage: + +1. Round to the Loan's own fixed `loan.precision` (§3.2) — may already be coarser than the Vault's + current `sfPrecision` ceiling. +2. Check representability at the Vault's _current_ `effective(AssetsTotal)`: + - `loan.precision <= effective(AssetsTotal)`: settles in full, no dust. + - Otherwise: split. The representable portion settles into main custody; the delta routes to the + Dust Pseudo-account, recorded in `Dust` (§2.2). + +The only case that invokes dust routing. The borrower's payment is always collected in full — the +split is a custody-routing detail, invisible to the borrower's accounting. + +**Principal and interest hit `AssetsTotal` differently, so `AddAssetsToVault`/`SettleAdd` take a +separate `interest` parameter.** `AddAssetsToVault(view, vault, from, amount, interest, j)`: +`amount` (principal) goes through the normal dust/precision-routed path exactly as before — +`AssetsAvailable` moves in lockstep, dust possible. `interest` only increments `vault.AssetsTotal` +directly (§4.1) — no dust routing, no `AssetsAvailable` change, no transfer of its own (it's a +bookkeeping-only split on top of the same real cash movement as `amount`, not a second transfer). + +Why they need separating: this mirrors `LendingProtocolV1_1`'s cash-basis accounting model +([#7817](https://github.com/XRPLF/rippled/pull/7817)) — `AssetsTotal` tracks **principal only**, and +interest is recognized **only once paid**, not at origination. Principal returning at repayment +doesn't grow `AssetsTotal` (already counted as a receivable since funding); only interest is +genuinely new value. Folding both into one undifferentiated `settled` value, as this doc originally +did, would have made `AssetsTotal` cash-basis-wrong by construction. + +**Open question this raises, not resolved here:** dust/precision routing exists to protect +`AssetsTotal` additions from exceeding the 16-sig-digit ceiling (§2.2). Under cash-basis, `interest` +— not principal — is what actually drives `AssetsTotal` growth on repayment, so it's the value that +could need the same two-stage truncation `SettleAdd` already does for `amount`. This document gives +`interest` no dust routing of its own (§5) — assumed small enough to add cleanly, not verified. + +## 4. Algorithms + +Two shared primitives, invoked by every fund-moving operation in §3 instead of each implementing its +own settlement/dust logic: **add assets to Vault**, **remove assets from Vault**. + +### 4.1 Add assets to Vault + +**Precondition:** the caller has already rounded the amount to _some_ target scale (Deposit → +`effective(AssetsTotal)`; repayment → `loan.precision`). This algorithm only decides how much of +that amount `AssetsTotal` can absorb right now, and routes the rest. + +Two independent, _composable_ dust sources, not exclusive cases: + +1. **Pre-existing excess** — the amount already exceeds the current effective scale `e0` (e.g. + `loan.precision > e0`). Detectable before touching `AssetsTotal`. +2. **Addition-induced coarsening** — even after trimming to `e0`, adding the result can cross a + magnitude boundary and coarsen the scale further (`e1 = effective(AssetsTotal + settled_0) < + e0`). + +Both can fire on one call: trim against `e0`, then trim the result again against `e1`. Converges in +one extra pass — `settled_1 <= settled_0`, so re-adding it can't coarsen below `e1`. + +The truncation/bookkeeping core is `SettleAdd`, which never sweeps; `AddAssetsToVault` wraps it with +an opportunistic sweep. Keeping them separate avoids `MaybeSweep` recursively triggering itself +(§4.2). + +Real fund movement goes through `accountSend`/`accountSendMulti` (`TokenHelpers.h`), same as every +existing transactor. `LoanPay.cpp` already does this exact split-destination move in one call — +settling to the Vault's Pseudo-account and the broker's payee simultaneously via `accountSendMulti`. +`SettleAdd`'s main/dust split is the same shape: one sender, up to two destinations, atomic. + +``` +function SettleAdd(view, vault, from, amount, interest, j) -> Expected: + # amount already rounded to caller's target scale; may or may not + # fit the Vault's current effective scale. Never triggers a Sweep. + # `interest` (default 0) ONLY increments vault.AssetsTotal, below — + # no dust routing, no AssetsAvailable change, no separate transfer. + + e0 = effective(vault.AssetsTotal) + settled_0, dust_0 = truncate(amount, e0) # Case 1: pre-existing excess + + projectedTotal = vault.AssetsTotal + settled_0 + e1 = effective(projectedTotal) + + if e1 < e0: + settled_1, dust_1 = truncate(settled_0, e1) # Case 2: addition-induced coarsening + else: + settled_1, dust_1 = settled_0, 0 + + settled = settled_1 + dust = dust_0 + dust_1 + + # Move the real asset first; bookkeeping only applies once it succeeds + # (mirrors existing transactors, e.g. VaultDeposit.cpp). + if dust > 0: + ter = accountSendMulti( + view, from, vault.asset, + {{vault.sle[sfAccount], settled}, {vault.sle[sfDustAccount], dust}}, + j, WaiveTransferFee::Yes) + else: + ter = accountSend( + view, from, vault.sle[sfAccount], settled, j, {}, WaiveTransferFee::Yes) + if ter is not tesSUCCESS: + return Unexpected(ter) + + vault.AssetsTotal += settled + vault.AssetsAvailable += settled # §2.2 scope note: moves in lockstep + # with AssetsTotal within this doc + if dust > 0: + vault.Dust += dust + vault.AssetsTotal += interest # separate from settled/dust above + + return settled # callers needing the actually-landed amount (e.g. + # share issuance math) must use this, not `amount` + + +function AddAssetsToVault(view, vault, from, amount, interest, j) -> Expected: + settledResult = SettleAdd(view, vault, from, amount, interest, j) + if settledResult is error: return settledResult + MaybeSweep(view, vault, j) # §4.2 — cheap no-op if dust is insufficient + return settledResult +``` + +Notes: + +- `truncate(x, scale)` rounds down only — dust is always non-negative, never created in the caller's + favor. +- Deposit's precondition guarantees `dust_0 = 0`; only `dust_1` can fire for it. Repayment is the + case where `dust_0` is the common one. Same function handles both. +- `dust > 0` is only possible when `vault.sle[sfDustAccount]` exists — i.e. an IOU Vault (§4.4.4). + XRP/MPT Vaults never produce dust; that falls out of the truncation math, not an extra check. +- `WaiveTransferFee::Yes` matches every existing Vault-related transfer — an issuer's transfer rate + shouldn't eat into internal accounting. + +### 4.2 `MaybeSweep` + +Opportunistically moves as much of dust custody as is currently representable back into the main +Pseudo-account/`AssetsTotal`. Called at the end of every `AddAssetsToVault`; a no-op below one +representable unit of dust. + +The dust-to-main leg is a real transfer too, so it also goes through `SettleAdd`, with the Dust +Pseudo-account as `from`. Guaranteed a no-op for XRP/MPT Vaults (no `sfDustAccount`, no balance). + +``` +function MaybeSweep(view, vault, j) -> TER: + dustBalance = accountHolds(view, vault.sle[sfDustAccount], vault.asset, ...) + if dustBalance == 0: + return tesSUCCESS + + e = effective(vault.AssetsTotal) + candidate = truncate(dustBalance, e) + if candidate == 0: + return tesSUCCESS # not enough accumulated yet + + # NOT AddAssetsToVault (see below). `from` is the Dust Pseudo-account + # itself; a sweep never carries an interest component. + result = SettleAdd(view, vault, vault.sle[sfDustAccount], candidate, 0, j) + if result is error: return result.error() + return tesSUCCESS + # If this transfer induces further coarsening (§4.1 Case 2), + # SettleAdd's accountSendMulti sends the residual back to + # vault.sle[sfDustAccount] — the same account it came from (from==to; + # needs verifying that's a clean no-op — §5). Net effect on dust + # custody is always a decrease of exactly `settled`. +``` + +Why `SettleAdd`, not `AddAssetsToVault`: the latter would trigger another `MaybeSweep`, which in the +degenerate `settled == 0` case recomputes the same `candidate` against unchanged state — infinite +recursion. `SettleAdd` bounds this to one attempt; nothing else calls `MaybeSweep` from inside +itself. + +`MaybeSweep` never rejects or blocks — it strictly improves (or leaves unchanged) how much of +`AssetsTotal` is backed by main custody. + +### 4.3 Remove assets from Vault + +**Precondition:** same shape as §4.1; callers are Withdraw, `VaultClawback`, Loan +issuance/acceptance — all already rounded to `effective(AssetsTotal)` before calling. No §4.1-Case-1 +analogue (no caller targets a different, finer precision). + +No §4.1-Case-2 analogue either — the key asymmetry: removing only ever **shrinks** `AssetsTotal`, +and effective scale is non-increasing in magnitude, so `effective(AssetsTotal - amount) >= +effective(AssetsTotal)` always. A removal can only hold the scale steady or refine it, never coarsen +it. **Removing assets never generates dust.** + +Fund movement mirrors §4.1: a single-destination `accountSend` out of main custody, the same shape +`doWithdraw` (`View.cpp`) already uses for Withdraw/`LoanBrokerCoverWithdraw`. Destination-specific +setup (trust-line/MPToken creation, `verifyDepositPreauth`) stays the caller's job, same split as +`VaultWithdraw::doApply`/`doWithdraw` today. + +**Terminal withdrawal: all `Dust` settles to the last withdrawer.** §2.2 established `Dust` counts +toward every shareholder's value — but while _other_ shares remain outstanding, there's no clean way +to pay out one depositor's fractional slice of unswept dust without leaving the rest ambiguous (§5). +That ambiguity disappears the moment a withdrawal drains the Vault to zero: no other shareholders +remain to divide anything with, so the entire `Dust` balance pays out to the one withdrawer closing +out the Vault. This also leaves `Dust` custody at zero, which `VaultDelete` needs anyway (§4.4.4). + +``` +function RemoveAssetsFromVault(view, vault, to, amount, j) -> Expected: + # amount already rounded to effective(vault.AssetsTotal); sufficiency + # checked upstream (preclaim) — not this algorithm's concern. + + ter = accountSend(view, vault.sle[sfAccount], to, amount, j, {}, WaiveTransferFee::Yes) + if ter is not tesSUCCESS: + return Unexpected(ter) + + vault.AssetsTotal -= amount + vault.AssetsAvailable -= amount # §2.2 scope note: lockstep with AssetsTotal + + if vault.AssetsTotal == 0 and vault.SharesTotal == 0 and vault.sle[sfDustAccount] is present: + # Terminal: no other shareholder to preserve Dust for, so it + # doesn't go through truncate()/effective() at all — just pay out + # whatever's left, in full. + dustBalance = accountHolds(view, vault.sle[sfDustAccount], vault.asset, ...) + if dustBalance > 0: + ter = accountSend(view, vault.sle[sfDustAccount], to, dustBalance, j, {}, WaiveTransferFee::Yes) + if ter is not tesSUCCESS: + return Unexpected(ter) + vault.Dust -= dustBalance + else: + MaybeSweep(view, vault, j) # non-terminal: still worth attempting + + return amount # always settles in full +``` + +Why check both `AssetsTotal == 0` and `SharesTotal == 0`: they should move to zero together on a +full redemption, but are tracked independently — requiring both avoids treating a same-value +coincidence as "terminal" when shares are still outstanding. + +Why call `MaybeSweep` on the non-terminal path despite removal never creating dust: removal +_refines_ the effective scale, which can retroactively unlock a sweep that wasn't previously +possible (a smaller required unit). §4.1 calls it because an addition may have _created_ dust; §4.3 +because a removal may have _unlocked_ dust already sitting there. + +### 4.4 When is the Dust Pseudo-account created? + +Only **IOU Vaults** ever need one — XRP/MPT force `sfPrecision` to 0, so dust routing never applies +(§4.1/§4.3). Like any ledger object, it carries an owner-reserve cost. + +**Option A — eager, at Vault creation.** Every IOU Vault gets it up front, used or not. + +- Simple: settlement logic never branches on "does it exist yet." +- Costs reserve for a resource many Vaults may never use. +- Reserve charged normally as part of `VaultCreate` — explicit, user-initiated; failing it blocks + nothing mandatory. + +**Option B — lazy, on first dust.** Created the first time `SettleAdd` needs to defer a nonzero +remainder (only ever inside a repayment). + +- Reserve spent only if actually needed. +- More complex: settlement must handle "no dust account yet." +- **Reserve must be forgiven**: creation is now a side effect of a _mandatory_ operation. Failing it + would either reject a repayment for a reserve shortfall unrelated to the borrower (§1.2's exact + anti-pattern) or leave inconsistent state — so this object is exempt from the usual reserve rule. + Bounded exposure: at most one unfunded object per Vault. + +**Decision: Option A.** §4.4.3 found a griefing vector specific to lazy creation — its address stays +publicly predictable for the Vault's whole life while creation timing is unpredictable, letting an +attacker pre-squat the address and fail a mandatory repayment. Eager creation collapses the window +to ordinary transaction-propagation risk, same as every other pseudo-account. §4.4.1 and §4.4.3 are +kept below for the record; §4.4.4 is what's actually built. + +#### 4.4.1 Option B mechanics (rejected — see §4.4.3) + +Kept for the record. + +`createPseudoAccount(view, pseudoOwnerKey, ownerField)` creates the `ACCOUNT_ROOT`, same helper the +main Pseudo-account already uses. The codebase's reserve-**sponsorship** mechanism (`sfSponsor`, +`checkReserve` with a sponsor) doesn't help here — it's allow-listed per transaction type and still +_enforces_ a check, just against a different account; it relocates the failure, doesn't remove it. +The real precedent is `adjustLoanBrokerOwnerCount`, which maintains a LoanBroker owner-count field +deliberately _not_ wired into `checkReserve`/`increaseOwnerCount` at all. + +``` +function EnsureDustPseudoAccount(view, vault) -> AccountID: + if vault.sle[sfDustAccount] is present: + return vault.sle[sfDustAccount] + + # Must not fail the enclosing mandatory transaction — no + # checkReserve/increaseOwnerCount, per adjustLoanBrokerOwnerCount's + # precedent. Same seed as the main Pseudo-account; the retry loop + # lands on the next free address. + sleResult = createPseudoAccount(view, vault->key(), sfVaultDustID) + vault.sle[sfDustAccount] = sleResult->key() + return sleResult->key() + +function SettleAdd(view, vault, amount) -> settled: + ... (unchanged truncation math from §4.1) ... + if dust > 0: + dustAccountId = EnsureDustPseudoAccount(view, vault) # lazily, on first dust + vault.Dust += dust + DustPseudoAccount(dustAccountId).balance += dust + ... +``` + +Consequence: whichever repayment happens to be first pays a one-time `ACCOUNT_ROOT`-creation cost no +other repayment on that Vault pays — asymmetric, but bounded to once per Vault. + +#### 4.4.2 Storage and key generation (background, applies to either option) + +**Storage.** `ltVAULT.sfAccount` (required) already points at the main Pseudo-account. Symmetric new +field **`sfDustAccount`** (`SoeOptional` — only IOU Vaults populate it). + +**Reverse link.** `ltACCOUNT_ROOT` already has designator fields (`sfAMMID`, `sfVaultID`, +`sfLoanBrokerID`, flagged `sMD_PseudoAccount` for `getPseudoAccountFields()`/`isPseudoAccount()`). +The Dust Pseudo-account needs its own — **`sfVaultDustID`** — rather than reusing `sfVaultID`, which +would make two different `ACCOUNT_ROOT`s for the same Vault indistinguishable by field, breaking the +implicit one-designator-one-pseudo-account assumption invariant checks rely on. + +(`ltVAULT` (`0x0084`) has `0x0085`–`0x0087` reserved for future Vault-related objects — capacity +exists if a fuller object type is ever needed, but a bare `ACCOUNT_ROOT` is enough here.) + +**Key generation.** No new primitive needed — `createPseudoAccount`/`pseudoAccountAddress` (hashing +`(retryIndex, parentHash, pseudoOwnerKey)`, retrying up to 256 times) is the same machinery every +pseudo-account already uses. The only choice is the `pseudoOwnerKey` seed: **Option A** reuses +`vault->key()` unchanged (the retry loop naturally lands on the next free address after the main +account); **Option B** derives an explicitly distinct seed (e.g. `sha512Half(vault->key(), +)`). **Both are equally vulnerable to §4.4.3's griefing attack** — the vulnerability +is about _when_ creation happens relative to how long the seed's been public, not which formula +computes it. + +#### 4.4.3 Address predictability is a griefing vector under lazy creation + +`pseudoAccountAddress` hashes only public inputs: `vault->key()` (public and permanent once the +Vault exists) and `parentHash` (public the instant a ledger closes). Fine for the **main** +Pseudo-account, created atomically with `ltVAULT` — the only exposure is ordinary +transaction-propagation time. + +Not fine for a **lazily**-created Dust Pseudo-account: the seed is public for the Vault's entire +remaining life while creation timing stays unpredictable. Attack: + +1. Watch Vault X for a repayment likely to generate dust (the whole reason lazy creation exists is + that this moment is unpredictable). +2. Once visible, precompute the exact candidate address `EnsureDustPseudoAccount` will try at retry + index 1 for that ledger's `parentHash` (index 0 is always the main account). +3. Submit a trivial Payment there first, auto-creating an `ACCOUNT_ROOT`. The real call advances to + index 2 — also precomputable and squattable. +4. Squat all 256 candidates in that ledger → `pseudoAccountAddress` returns `beast::kZero`, + `createPseudoAccount` returns `tecDUPLICATE`. The triggering repayment — a mandatory operation + for a borrower unrelated to this attack — fails. + +That's exactly the failure mode §1.2 exists to eliminate. Squatting 256 addresses costs real (if +modest) XRP, and `parentHash` changes every ledger so a squat set is single-use — but the attacker +only needs to deny one repayment, at a moment of their choosing, with no advance preparation window +required (the seed has been public since Vault creation). + +**Implication:** structural argument for eager creation — atomic creation with `VaultCreate` +collapses the window to the same risk every other pseudo-account already accepts; no seed formula +(§4.4.2) fixes lazy creation's exposure. **Decision: eager (§4.4.4).** + +#### 4.4.4 Option A, worked out: eager creation and tracking + +Creation moves entirely into `VaultCreate` — no lazy-check anywhere in the settlement path; by the +time any `SettleAdd` runs, the Dust Pseudo-account (for an IOU Vault) already exists. + +``` +function VaultCreate(view, ...): + ... (existing logic: build the ltVAULT SLE, ...) + + mainPseudo = createPseudoAccount(view, vault->key(), sfVaultID) + if mainPseudo is error: return mainPseudo.error() + vault.sle[sfAccount] = mainPseudo->key() + # ... existing reserve accounting for the main Pseudo-account ... + + if vault.asset is IOU: # §4.4: IOU-only + dustPseudo = createPseudoAccount(view, vault->key(), sfVaultDustID) + if dustPseudo is error: return dustPseudo.error() + vault.sle[sfDustAccount] = dustPseudo->key() + # Normal reserve check/increment, same as the main account above — + # no carve-out needed: VaultCreate is explicit, blocks nothing + # mandatory. + + ... +``` + +Both accounts use the same seed, `vault->key()`; the retry loop assigns main → index 0, dust → index +1, entirely within one atomic transaction — no gap between the seed becoming known and either +address being claimed, so §4.4.3's window doesn't exist here. + +**Tracking, in full:** + +- `ltVAULT.sfAccount` → main Pseudo-account (existing, required). +- `ltVAULT.sfDustAccount` → Dust Pseudo-account (optional; present iff IOU). Set once at + `VaultCreate`, never reassigned. +- `ltACCOUNT_ROOT.sfVaultID`/`sfVaultDustID` — reverse links, distinguishing the two roles per Vault + (§4.4.2). +- **Lifecycle symmetry:** since `sfDustAccount` is now unconditional for every IOU Vault, whatever + deletes the main Pseudo-account on `VaultDelete` must be extended to delete the Dust + Pseudo-account too, under the same invariants (e.g. zero balance). Worked out in §4.4.5. + +#### 4.4.5 `VaultDelete`: cleaning up the Dust Pseudo-account + +`VaultDelete::preclaim` already requires `sfAssetsAvailable == 0`, `sfAssetsTotal == 0`, and zero +outstanding shares before allowing deletion. §4.3's terminal-withdrawal rule means `Dust` is drained +to zero by the same event that drives `AssetsTotal`/`SharesTotal` to zero — so by the time +`preclaim`'s checks pass, the Dust Pseudo-account should already be empty. "Should" isn't +"guaranteed by construction independent of bugs elsewhere," though, so `doApply` re-verifies it +directly, the same defensive-depth style already used for the main Pseudo-account (re-checking +balance/owner-count/directory right before erasing, even though `preclaim` already implies zero). + +`doApply` today, for the main Pseudo-account: `removeEmptyHolding` to destroy its trust-line/MPToken +holding, destroy the share issuance, confirm the pseudo-account's owner directory is empty, confirm +zero balance/owner count/no directory, erase it, then `decreaseOwnerCountForObject(view(), owner, +vault, 2, j_)` for the two objects destroyed (Vault + main Pseudo-account). + +Extension, mirrored for the Dust Pseudo-account, conditional on `vault.sle[sfDustAccount]` being +present (IOU Vaults only): + +``` +function VaultDelete::doApply(): + ... (existing: removeEmptyHolding + erase main pseudo-account, per above) ... + + dustCount = 0 + if vault.sle[sfDustAccount] is present: + dustPseudoAcct = view().peek(keylet::account(vault.sle[sfDustAccount])) + if not dustPseudoAcct: return tefBAD_LEDGER # LCOV_EXCL_LINE — should never happen + + if ter := removeEmptyHolding(applyViewContext, vault.sle[sfDustAccount], asset, j_); + not isTesSuccess(ter): return ter + + # Same defensive re-check as the main Pseudo-account, not just + # trusting preclaim's zero-AssetsTotal implication. + if dustPseudoAcct[sfBalance] != 0: return tecHAS_OBLIGATIONS + if dustPseudoAcct[sfOwnerCount] != 0: return tecHAS_OBLIGATIONS + if view().exists(keylet::ownerDir(vault.sle[sfDustAccount])): return tecHAS_OBLIGATIONS + + view().erase(dustPseudoAcct) + dustCount = 1 + + ... (existing: dirRemove vault from owner's directory) ... + + # Was `decreaseOwnerCountForObject(view(), owner, vault, 2, j_)` for + # Vault + main Pseudo-account; now 2 + dustCount, so IOU Vaults with a + # Dust Pseudo-account correctly release its reserve too. + decreaseOwnerCountForObject(view(), owner, vault, 2 + dustCount, j_) + + view().erase(vault) +``` + +The Dust Pseudo-account never held a share-issuance-style object of its own (only ever a plain asset +holding, same as the main account), so there's no analogue to the share-issuance-destruction block — +just the holding, the balance/owner-count/directory checks, and the erase. + +## 5. Open questions / not yet resolved + +- **Does `interest` (§3.3/§4.1) need its own dust/precision routing?** `AddAssetsToVault`'s + `interest` parameter only does `vault.AssetsTotal += interest`, with no routing, unlike `amount`. + Under cash-basis accounting ([#7817](https://github.com/XRPLF/rippled/pull/7817)), `interest` is + what actually drives `AssetsTotal` growth at repayment — so it's the value that could exceed the + 16-sig-digit ceiling `SettleAdd` exists to protect against, not principal. Currently assumed small + enough to add cleanly; not verified. +- **No invariant-check design.** Candidates implied by the design but never stated: Dust + Pseudo-account's real balance ≡ `vault.Dust`; post-`MaybeSweep`, dust custody never exceeds one + representable unit at the current effective scale; a global conservation identity across + `AssetsTotal`/`Dust`/`AssetsAvailable`. +- **`sfPrecision` immutability asserted, not enforced.** No confirmation `VaultSet` rejects attempts + to change it post-creation. +- **PoC test file is stale.** Predates the `Dust` rename, eager-creation decision (§4.4.4), + terminal-withdrawal rule (§4.3), and real `accountSend` integration (§4.1–§4.3). +- **`LoanPay.cpp`'s real repayment splits three ways** (vault main custody, vault dust custody, + broker payee); `SettleAdd`'s `accountSendMulti` only models the first two. Whether the broker-fee + leg composes alongside `SettleAdd` or stays entirely the caller's own separate transfer was never + spelled out. + +## 6. Next steps + +- Audit Loan/LoanBroker code paths for assumptions of dynamic Vault scale — confirm `loan.precision` + is captured at funding (§3.2), not recomputed per-operation. +- Resolve §5 before finalizing the dust-custody mechanism. + From 54c68f2970f334744875220747456c92dac59e22 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:52:24 +0200 Subject: [PATCH 2/6] docs: Fix cash-basis and loan-scale gaps in Vault precision design Corrects SettleAdd's AssetsTotal update to stop double-counting principal under cash-basis accounting, resolves the contradiction between optional-operation and Case 2 dust routing, folds the loan's own total-value floor into the loan.precision formula, and documents several accepted risks and non-issues surfaced during review (Loan acceptance griefing, DebtTotal's separate rounding behavior, and Dust sweep-timing attribution). --- .cspell.config.yaml | 2 + docs/design-vault-fixed-precision.md | 289 +++++++++++++++++++++------ 2 files changed, 229 insertions(+), 62 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index e3764fd2a2f..f51e47f769d 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -95,6 +95,7 @@ words: - ctest - ctid - currenttxhash + - custodied - daria - dcmake - dearmor @@ -148,6 +149,7 @@ words: - jemalloc - jlog - jtnofill + - Kahan - keylet - keylets - keyvadb diff --git a/docs/design-vault-fixed-precision.md b/docs/design-vault-fixed-precision.md index d1ebeeca908..5b2dc140070 100644 --- a/docs/design-vault-fixed-precision.md +++ b/docs/design-vault-fixed-precision.md @@ -103,14 +103,73 @@ accept further inflow at _any_ scale. `Dust`, in its own account with its own he would be invisible — depositors' claims and withdrawal liquidity would both stop reflecting real inflows once `AssetsTotal` maxes out, even as the Vault keeps receiving (and owing) more. +**Why folding `Dust` into both totals doesn't cause anyone to be overpaid.** This looks, at first +glance, like it could commit the Vault to paying out value that only exists in a separate account no +withdrawal ever touches (except the terminal case, §4.3). It doesn't, for two reasons that hold +together: + +- **Day to day, it's mathematically real but numerically invisible.** `Dust` only exists because + it's smaller than one representable unit at the current `effective(AssetsTotal)` — that's the + definition of dust (§4.1). Adding a sub-unit quantity into either total before computing a share + price or a withdrawal amount changes that computation by less than one representable unit — which + then rounds away in the same settlement step (§3.1's `round(amount_requested, +effective(AssetsTotal))`) that produces the amount actually paid. Including `Dust` in the formula + is correct, but for a healthy Vault it never changes what any single depositor or withdrawer + actually receives. +- **Once it's not invisible, it's not `Dust` for long.** `MaybeSweep` (§4.2) runs after every + fund-moving operation and folds any dust balance that's grown past one representable unit straight + into `AssetsTotal`/`AssetsAvailable` — at which point it's ordinary, fully-backed Vault money, not a + separate pool anything else needs to account for. The only way `Dust` sits at an economically + material level is transiently, between the moment it crosses that threshold and the next operation + that triggers a sweep. + +So both totals in §2.2 are correct to include even though nothing pays out of the Dust Pseudo-account +directly in the common case — the inclusion only ever matters in the regime this design already calls +out separately: near the absolute `STAmount` ceiling, where `AssetsTotal` itself can't grow further +and `Dust`'s own headroom is what keeps the Vault's reported totals honest. + **Scope note on `AssetsAvailable`.** The fuller share-pricing model (`VaultSharePricing_test.cpp` PoC) also tracks `interestUnrealized`/`lossUnrealized`, under which `AssetsTotal` and -`AssetsAvailable` can diverge — e.g. funding a loan moves principal out of `AssetsAvailable` without -touching `AssetsTotal`, since the loan remains an asset, just an illiquid one. **This document -doesn't model that bucket** — it's scoped to precision/dust. Within that scope, every operation in -§3 moves `AssetsAvailable` by the same `settled` delta as `AssetsTotal` (§4). Wiring into the full -interest-accrual model may require Loan funding/repayment to diverge the two — flagged in §5, not -resolved here. +`AssetsAvailable` can diverge further still — e.g. funding a loan moves principal out of +`AssetsAvailable` without touching `AssetsTotal`, since the loan remains an asset, just an illiquid +one. **This document doesn't model that bucket** — it's scoped to precision/dust. + +Within that narrower scope, `AssetsTotal` and `AssetsAvailable` already diverge on their own, by +construction, not as future work: `SettleAdd` (§4.1) moves `AssetsAvailable` by the full dust-routed +settlement (`settled` — real cash landing in custody), but moves `AssetsTotal` by a separately +supplied `assetsTotalDelta`, which is **not** `settled` for a cash-basis repayment (§3.3) — Deposit +is the one degenerate case where the two happen to coincide. That divergence is required for +cash-basis correctness (§3.3); it's not the same divergence as the interest-accrual bucket above, +which is what's flagged in §5, not resolved here. + +### 2.3 Why `DebtTotal` doesn't need this + +`ltLOAN_BROKER.sfDebtTotal` has its own rounding mechanism, already in production, that looks +structurally similar to the problem §2.2 solves: `adjustImpreciseNumber` (`LendingHelpers.h`) rounds +every adjustment to `DebtTotal` to the Vault's current `vaultScale`, and its own comment at the +`LoanPay.cpp` call site says so plainly — "despite our best efforts, it's possible for rounding +errors to accumulate in the loan broker's debt total... because the broker may have more than one +loan with significantly different scales." That's the same shape of problem: an aggregate bounded by +the 16-sig-digit ceiling, fed by inputs at multiple different fixed scales, coarsening as it grows. +Worth stating explicitly why this document doesn't extend `Dust`/`SettleAdd` to cover it too: + +- **`DebtTotal` isn't custodied money.** `AssetsTotal` must reconcile exactly with a real balance — + the Vault's Pseudo-account — which is why a dropped remainder needs somewhere to go (`Dust`). + `DebtTotal` is a receivables _estimate_ (how much principal this broker's loans still owe); no + account balance anywhere corresponds to it, so there's nothing for its rounding error to + misplace. +- **The error is symmetric, not a one-directional drain.** `Number`'s default rounding mode is + `ToNearest`, so `DebtTotal`'s accumulated error random-walks rather than systematically leaking in + one direction. +- **Its one safety-relevant consumer already rounds conservatively.** `minimumBrokerCover` rounds + its result _up_, specifically "to be conservative... ensures `CoverAvailable` never drops below + the theoretical minimum" — so any drift in `DebtTotal` can only make the required cover look + slightly larger than the true debt, never smaller, which is the safe direction for solvency. +- **It predates this design and isn't changed by it.** This is existing, already-accepted behavior, + not something introduced or made worse by `sfPrecision`/`Dust` — nothing in §3–§4 touches how + `DebtTotal` is computed or rounded. + +**Conclusion: left as-is, deliberately, not an oversight.** ## 3. Case by case: how rounding actually works @@ -122,8 +181,15 @@ resolved here. amount_settled = round(amount_requested, effective(AssetsTotal)) ``` -Representable by construction, so §2.2 never fires — no `Dust` entry, no custody routing. Acceptable -because none of these are obligations owed in full. +This pre-rounding eliminates Case 1 (§4.1) — `dust_0` is always `0` for these callers, unlike +repayment. It does **not** eliminate Case 2: a Deposit that pushes `AssetsTotal` across a magnitude +boundary can still coarsen the effective scale between the caller's rounding and the moment +`SettleAdd` applies it, producing `dust_1` the same way a repayment can (§4.1's notes). That's rare — +it only fires exactly at a growth boundary — but it isn't structurally impossible, so Deposit still +goes through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate dust-free path. +What _is_ true for these operations: none of them are obligations owed in full, so if Case 2 fires, +it's the caller's own requested amount that silently absorbs the truncation (§2.2) — never an +already-agreed, mandatory amount the way a repayment's `loan.precision` mismatch is. ### 3.2 Loan issuance (funding) — Vault → Loan @@ -131,12 +197,44 @@ Funding is a Vault-initiated disbursement, not a debt obligation — structurall Follows the §3.1 rule: rounds directly to the Vault's _current_ effective scale, no dust. Consequence: **the Loan's own precision is fixed once, at funding time, to the effective scale used -for that disbursement** — not the Vault's raw `sfPrecision` ceiling: +for that disbursement** — not the Vault's raw `sfPrecision` ceiling. But "the effective scale used +for that disbursement" isn't just the Vault's own scale: it's the coarser of two independent floors — +one knowable only from the Vault's current state, the other knowable in full from the Loan's own +terms, before a single payment is ever made: ``` -loan.precision = effective(AssetsTotal at funding time) [ = min(sfPrecision, ...) ] +loan.precision = max( + effective(AssetsTotal at funding time), # Vault-side floor: what the Vault can + # represent right now + scale(totalValueOutstanding, asset) # Loan-side floor: what this Loan's own + # projected lifetime value needs +) ``` +The Loan-side floor is `scale(principal + all future interest, asset)` — Equation (30) of the XLS-66 +spec, `totalValueOutstanding = periodicPayment × paymentsRemaining`. `computeLoanProperties` +(`LendingHelpers.cpp`) already computes exactly this today, as +`loanScale = max(minimumScale, amount.exponent())`, where `minimumScale` is the Vault-side floor +passed in by the caller (see its "base the loan scale on the total value, since that's going to be +the biggest number involved" comment). This document's earlier formula dropped that second term — +worth stating explicitly why it's safe, and necessary, to fold back in rather than leave as an open +unknown: + +- **The Loan-side floor is fully deterministic at creation time.** Interest rate, payment interval, + and payment count are all fixed loan terms chosen at `LoanSet`, so `totalValueOutstanding` — and + therefore its natural scale — is computable synchronously, with no dependency on anything that + hasn't happened yet. A modest principal at a high rate over a long term can have + `principal + total future interest` land at a materially larger order of magnitude than the Vault's current + `AssetsTotal`, even though the principal itself came from (and is bounded by) the Vault — nothing + bounds future interest by the Vault's present size, especially under cash-basis accounting, where + `AssetsTotal` doesn't recognize interest until it's actually paid (§3.3). +- **The Vault-side floor is not knowable in advance.** How much _more_ the Vault's own effective + scale might degrade over the Loan's life — from other loans funding/repaying, deposits, sweeps, + other borrowers' interest accruing — depends on the whole Vault's future activity, not this Loan's + terms. That unpredictability is the actual reason `loan.precision` must be pinned immutably at + funding at all: if it were knowable in advance the way the Loan-side floor is, there'd be no need + to freeze it. + Stored as the existing **`sfLoanScale`** field on `ltLOAN` (set in `LoanSet.cpp`, read in `LoanPay.cpp`/`LoanManage.cpp`) — same relationship as `sfPrecision`/`sfScale` on the Vault side: the field already exists, what's new is _when_ and _to what value_ it gets set (this funding-time @@ -146,9 +244,12 @@ Immutable thereafter, same as `sfPrecision` is for the Vault. Operating a Loan a than it was actually funded with would assert false precision across all its future accounting. **Worked example.** Vault `sfPrecision` = 6. Loan A funds while `AssetsTotal` is small (effective -scale 6) → `loan.precision` = 6. Later `AssetsTotal` grows enough to degrade effective scale to 5; -Loan B funds then → `loan.precision` = 5, permanently — same Vault, two Loans, two fixed precisions, -set by Vault size at each funding moment. +scale 6) and Loan A's own `totalValueOutstanding` also fits at scale 6 (short term, low rate) → +`loan.precision` = 6, the Vault-side floor binds. Later `AssetsTotal` grows enough to degrade +effective scale to 5; Loan B funds then, with a long term and high rate whose projected +`totalValueOutstanding` alone needs scale 4 → `loan.precision` = 4, the coarser of the two floors — +Loan B's _own_ terms are the binding constraint here, not the Vault's size at all. Same Vault, two +Loans, two fixed precisions, each set by whichever floor is coarser at that Loan's funding moment. #### 3.2.1 Pending Loans: acceptance can straddle a scale change @@ -156,7 +257,9 @@ Issuance is two steps: **create** (amount agreed/reserved, funds not yet moved) (funds move). "Funding time" above is really **acceptance time**. Between create and accept, `AssetsTotal` can degrade the effective scale enough that the agreed -amount is no longer representable. Two options: +amount is no longer representable. Only the Vault-side floor (above) can move in that window — the +Loan-side floor is already fixed at create time, from the Loan's own agreed terms, and doesn't +change while pending. Two options: - Silently re-round at acceptance — rejected: mutates agreed loan terms invisibly, exactly the non-determinism this design eliminates. @@ -166,39 +269,74 @@ amount is no longer representable. Two options: So a pending Loan's amount is re-validated against `effective(AssetsTotal)` at acceptance; on failure, acceptance is rejected outright, never adjusted. +**This is griefable, not just naturally rare — accepted anyway.** `effective(AssetsTotal)` is a +public, deterministic function of `AssetsTotal`, and a pending Loan's agreed principal/scale is +visible on-ledger from the moment it's created. Anyone able to deposit into the Vault can compute +exactly how much more `AssetsTotal` needs to grow to cross the next magnitude boundary, deposit that +amount right before the borrower's `LoanAccept`, degrade the effective scale, and force the +acceptance to fail — at essentially no cost, since the deposit isn't spent, it converts to shares the +attacker still holds (and can withdraw right back out afterward). A motivated party could grief a +specific pending Loan's acceptance repeatedly, each time forcing the borrower to eat a re-quote +(possibly at worse terms if the market's moved), for reasons unrelated to that Loan's own terms. + +Unlike §4.4.3's address-squatting vector — which costs real (if modest) XRP per attempt and is +single-ledger-use, and which this document treats as worth designing around — this one is cheaper +and repeatable. **Decision: accepted risk anyway**, on the same cost/benefit basis as ordinary +transaction-propagation risk elsewhere: the failure mode is a rejected acceptance and a forced +re-quote, not a loss of funds or a stuck Vault, and building in tolerance (e.g. letting acceptance +absorb one boundary crossing via the Dust mechanism, the way repayment does, instead of hard- +rejecting) is real added complexity for a griefing outcome that's merely annoying, not unsafe. Flagged +here rather than fixed. + ### 3.3 Loan repayment — Loan → Vault **Mandatory operation.** Two-stage: 1. Round to the Loan's own fixed `loan.precision` (§3.2) — may already be coarser than the Vault's - current `sfPrecision` ceiling. + current `sfPrecision` ceiling. This applies to the combined cash figure that actually settles — + principal plus interest together — not principal alone; `LoanPay.cpp` already rounds + `principalPaid + interestPaid` as one figure before this point today. 2. Check representability at the Vault's _current_ `effective(AssetsTotal)`: - `loan.precision <= effective(AssetsTotal)`: settles in full, no dust. - Otherwise: split. The representable portion settles into main custody; the delta routes to the Dust Pseudo-account, recorded in `Dust` (§2.2). -The only case that invokes dust routing. The borrower's payment is always collected in full — the -split is a custody-routing detail, invisible to the borrower's accounting. - -**Principal and interest hit `AssetsTotal` differently, so `AddAssetsToVault`/`SettleAdd` take a -separate `interest` parameter.** `AddAssetsToVault(view, vault, from, amount, interest, j)`: -`amount` (principal) goes through the normal dust/precision-routed path exactly as before — -`AssetsAvailable` moves in lockstep, dust possible. `interest` only increments `vault.AssetsTotal` -directly (§4.1) — no dust routing, no `AssetsAvailable` change, no transfer of its own (it's a -bookkeeping-only split on top of the same real cash movement as `amount`, not a second transfer). - -Why they need separating: this mirrors `LendingProtocolV1_1`'s cash-basis accounting model -([#7817](https://github.com/XRPLF/rippled/pull/7817)) — `AssetsTotal` tracks **principal only**, and -interest is recognized **only once paid**, not at origination. Principal returning at repayment -doesn't grow `AssetsTotal` (already counted as a receivable since funding); only interest is -genuinely new value. Folding both into one undifferentiated `settled` value, as this doc originally -did, would have made `AssetsTotal` cash-basis-wrong by construction. +The common case for Case 1 dust (§4.1) — a loan's fixed precision going stale as the Vault has grown +since funding. The borrower's payment is always collected in full — the split is a custody-routing +detail, invisible to the borrower's accounting. + +**`AssetsTotal` and custody settlement are driven by two different numbers, so `AddAssetsToVault`/ +`SettleAdd` take them as two separate parameters, not one.** `AddAssetsToVault(view, vault, from, +amount, assetsTotalDelta, j)`: + +- `amount` — the real cash settling into custody (principal + interest combined, for a repayment). + Goes through the full dust/precision-routed path (§4.1): `AssetsAvailable` moves by whatever of it + actually lands (`settled`); any truncated remainder routes to `Dust`. +- `assetsTotalDelta` — how much `vault.AssetsTotal` itself moves. Supplied by the caller, defaulting + to `settled` (§4.1) when not overridden — that default is what makes Deposit's case (§3.1) and + `MaybeSweep`'s sweep leg (§4.2) work correctly, since for both of those, landed custody genuinely + _is_ new value to `AssetsTotal`. A cash-basis repayment is the one caller that must override this + default, passing `interestPaid` instead of letting it default to `settled` — never dust-routed, + never changing `AssetsAvailable`, no transfer of its own (bookkeeping only, layered on the same + real cash movement as `amount`). + +Why a cash-basis repayment can't just take the default: this mirrors `LendingProtocolV1_1`'s +cash-basis accounting model ([#7817](https://github.com/XRPLF/rippled/pull/7817)) — `AssetsTotal` +tracks **principal only**, as a receivable recognized once, at origination; interest is recognized +**only once paid**, not at origination. Principal cash returning at repayment must _not_ grow +`AssetsTotal` again — it's the same receivable converting from loan back to cash, not new value — +whereas `settled` (the thing `SettleAdd` defaults `assetsTotalDelta` to) is principal _and_ interest +combined. Letting `AssetsTotal` default to `settled` here, as this doc's algorithm originally did +unconditionally, double-counts principal into `AssetsTotal` on every cash-basis repayment — that was +a real bug in the original `SettleAdd` pseudocode (§4.1), not just an accounting simplification, and +is why the override exists as an explicit, required parameter rather than an afterthought. **Open question this raises, not resolved here:** dust/precision routing exists to protect -`AssetsTotal` additions from exceeding the 16-sig-digit ceiling (§2.2). Under cash-basis, `interest` -— not principal — is what actually drives `AssetsTotal` growth on repayment, so it's the value that -could need the same two-stage truncation `SettleAdd` already does for `amount`. This document gives -`interest` no dust routing of its own (§5) — assumed small enough to add cleanly, not verified. +`AssetsTotal` additions from exceeding the 16-sig-digit ceiling (§2.2). Under cash-basis, +`assetsTotalDelta` (i.e. `interestPaid`) is the only thing that grows `AssetsTotal` on repayment, so +it's the value that could, in principle, need the same two-stage truncation `SettleAdd` already does +for `amount`. This document gives it no dust routing of its own (§5) — assumed small enough relative +to `AssetsTotal` to add cleanly, not verified. ## 4. Algorithms @@ -207,9 +345,11 @@ own settlement/dust logic: **add assets to Vault**, **remove assets from Vault** ### 4.1 Add assets to Vault -**Precondition:** the caller has already rounded the amount to _some_ target scale (Deposit → -`effective(AssetsTotal)`; repayment → `loan.precision`). This algorithm only decides how much of -that amount `AssetsTotal` can absorb right now, and routes the rest. +**Precondition:** the caller has already rounded `amount` — the cash settlement figure — to _some_ +target scale (Deposit → `effective(AssetsTotal)`; repayment → `loan.precision`, applied to +principal+interest combined). This algorithm decides how much of `amount` custody +(`AssetsAvailable`) can absorb right now and routes the rest to `Dust`; `AssetsTotal` moves +separately, driven by the caller-supplied `assetsTotalDelta` (§3.3), not by this truncation. Two independent, _composable_ dust sources, not exclusive cases: @@ -217,7 +357,7 @@ Two independent, _composable_ dust sources, not exclusive cases: `loan.precision > e0`). Detectable before touching `AssetsTotal`. 2. **Addition-induced coarsening** — even after trimming to `e0`, adding the result can cross a magnitude boundary and coarsen the scale further (`e1 = effective(AssetsTotal + settled_0) < - e0`). +e0`). Both can fire on one call: trim against `e0`, then trim the result again against `e1`. Converges in one extra pass — `settled_1 <= settled_0`, so re-adding it can't coarsen below `e1`. @@ -232,11 +372,19 @@ settling to the Vault's Pseudo-account and the broker's payee simultaneously via `SettleAdd`'s main/dust split is the same shape: one sender, up to two destinations, atomic. ``` -function SettleAdd(view, vault, from, amount, interest, j) -> Expected: +function SettleAdd(view, vault, from, amount, assetsTotalDelta, j) -> Expected: # amount already rounded to caller's target scale; may or may not # fit the Vault's current effective scale. Never triggers a Sweep. - # `interest` (default 0) ONLY increments vault.AssetsTotal, below — - # no dust routing, no AssetsAvailable change, no separate transfer. + # + # assetsTotalDelta is the amount by which vault.AssetsTotal moves. + # It defaults to `settled` (computed below) when the caller omits it — + # correct for Deposit (§3.1) and MaybeSweep's sweep leg (§4.2), where + # landed custody genuinely is new value to AssetsTotal. A cash-basis + # repayment (§3.3) is the one caller that MUST override this default, + # passing `interestPaid` instead — `settled` there is principal+interest + # combined, and principal must not double-count into AssetsTotal. + # assetsTotalDelta is never dust-routed and never changes + # AssetsAvailable — bookkeeping only, no transfer of its own. e0 = effective(vault.AssetsTotal) settled_0, dust_0 = truncate(amount, e0) # Case 1: pre-existing excess @@ -252,6 +400,9 @@ function SettleAdd(view, vault, from, amount, interest, j) -> Expected 0: @@ -265,19 +416,17 @@ function SettleAdd(view, vault, from, amount, interest, j) -> Expected 0: vault.Dust += dust - vault.AssetsTotal += interest # separate from settled/dust above + vault.AssetsTotal += assetsTotalDelta # sole driver of AssetsTotal — NOT `settled` return settled # callers needing the actually-landed amount (e.g. # share issuance math) must use this, not `amount` -function AddAssetsToVault(view, vault, from, amount, interest, j) -> Expected: - settledResult = SettleAdd(view, vault, from, amount, interest, j) +function AddAssetsToVault(view, vault, from, amount, assetsTotalDelta, j) -> Expected: + settledResult = SettleAdd(view, vault, from, amount, assetsTotalDelta, j) if settledResult is error: return settledResult MaybeSweep(view, vault, j) # §4.2 — cheap no-op if dust is insufficient return settledResult @@ -315,8 +464,12 @@ function MaybeSweep(view, vault, j) -> TER: return tesSUCCESS # not enough accumulated yet # NOT AddAssetsToVault (see below). `from` is the Dust Pseudo-account - # itself; a sweep never carries an interest component. - result = SettleAdd(view, vault, vault.sle[sfDustAccount], candidate, 0, j) + # itself; assetsTotalDelta is omitted (None) so it defaults to + # whatever this call's own `settled` turns out to be (§4.1/§3.3) — + # swept dust was never counted in AssetsTotal to begin with, so + # landing it here is exactly the "new value" default case, same as + # Deposit. + result = SettleAdd(view, vault, vault.sle[sfDustAccount], candidate, None, j) if result is error: return result.error() return tesSUCCESS # If this transfer induces further coarsening (§4.1 Case 2), @@ -351,11 +504,16 @@ setup (trust-line/MPToken creation, `verifyDepositPreauth`) stays the caller's j `VaultWithdraw::doApply`/`doWithdraw` today. **Terminal withdrawal: all `Dust` settles to the last withdrawer.** §2.2 established `Dust` counts -toward every shareholder's value — but while _other_ shares remain outstanding, there's no clean way -to pay out one depositor's fractional slice of unswept dust without leaving the rest ambiguous (§5). -That ambiguity disappears the moment a withdrawal drains the Vault to zero: no other shareholders -remain to divide anything with, so the entire `Dust` balance pays out to the one withdrawer closing -out the Vault. This also leaves `Dust` custody at zero, which `VaultDelete` needs anyway (§4.4.4). +toward every shareholder's value, and also explains why that's never a live problem for non-terminal +withdrawers: `MaybeSweep` keeps `Dust` folded back into `AssetsTotal` as soon as it's big enough to +move any settlement, so anyone withdrawing while other shares remain outstanding is pricing against a +total that's already either recognized `Dust` for real or is carrying an amount too small to change +their payout. What's left once a withdrawal drains the Vault to zero is only the residual that's +_structurally_ un-sweepable — below one representable unit at closure, with no future operation left +to ever cross that threshold, and no other shareholder left to divide it with. That's genuinely the +one case needing a special rule: pay the whole residual to the one withdrawer closing out the Vault, +rather than leave it stranded forever. This also leaves `Dust` custody at zero, which `VaultDelete` +needs anyway (§4.4.4). ``` function RemoveAssetsFromVault(view, vault, to, amount, j) -> Expected: @@ -605,16 +763,24 @@ just the holding, the balance/owner-count/directory checks, and the erase. ## 5. Open questions / not yet resolved -- **Does `interest` (§3.3/§4.1) need its own dust/precision routing?** `AddAssetsToVault`'s - `interest` parameter only does `vault.AssetsTotal += interest`, with no routing, unlike `amount`. - Under cash-basis accounting ([#7817](https://github.com/XRPLF/rippled/pull/7817)), `interest` is - what actually drives `AssetsTotal` growth at repayment — so it's the value that could exceed the - 16-sig-digit ceiling `SettleAdd` exists to protect against, not principal. Currently assumed small - enough to add cleanly; not verified. +- **Does `assetsTotalDelta` (§3.3/§4.1) need its own dust/precision routing?** For a cash-basis + repayment it's `interestPaid`, and `SettleAdd` only does `vault.AssetsTotal += assetsTotalDelta`, + with no routing, unlike `amount`. Under cash-basis accounting + ([#7817](https://github.com/XRPLF/rippled/pull/7817)), `interestPaid` is what actually drives + `AssetsTotal` growth at repayment — so it's the value that could exceed the 16-sig-digit ceiling + `SettleAdd` exists to protect against, not principal. Currently assumed small enough to add + cleanly; not verified. - **No invariant-check design.** Candidates implied by the design but never stated: Dust Pseudo-account's real balance ≡ `vault.Dust`; post-`MaybeSweep`, dust custody never exceeds one representable unit at the current effective scale; a global conservation identity across `AssetsTotal`/`Dust`/`AssetsAvailable`. +- **Dust-sweep timing has a narrow, bounded attribution quirk — accepted, not fixed.** A withdrawal's + own settlement amount is computed against `AssetsTotal` _before_ that same transaction's + `MaybeSweep` call (§4.3) runs. If unswept `Dust` crosses the representable-unit threshold as a side + effect of this withdrawal, the withdrawer doesn't get credit for it — the next depositor's or + withdrawer's exchange rate does. Bounded to less than one representable unit by construction + (§2.2), the same order of magnitude as ordinary rounding noise elsewhere in this design; not + considered worth a dedicated fix. - **`sfPrecision` immutability asserted, not enforced.** No confirmation `VaultSet` rejects attempts to change it post-creation. - **PoC test file is stale.** Predates the `Dust` rename, eager-creation decision (§4.4.4), @@ -629,4 +795,3 @@ just the holding, the balance/owner-count/directory checks, and the erase. - Audit Loan/LoanBroker code paths for assumptions of dynamic Vault scale — confirm `loan.precision` is captured at funding (§3.2), not recomputed per-operation. - Resolve §5 before finalizing the dust-custody mechanism. - From 6b3c7179da8af2073d2f9631913b6ae06987863d Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:15:16 +0200 Subject: [PATCH 3/6] docs: Restructure Vault precision design and drop redundant Dust field Reorganizes the document into Problem / Summary / Technical Details (Fields, Algorithm) / Appendix for easier reading, fixes an sfLoanScale mislabel, and removes the sfDust ledger field in favor of the Dust Pseudo-account's real balance as the single source of truth - the old field was never decremented on the normal sweep path and would have drifted from the account it was meant to mirror. --- docs/design-vault-fixed-precision.md | 679 +++++++++++++++------------ 1 file changed, 366 insertions(+), 313 deletions(-) diff --git a/docs/design-vault-fixed-precision.md b/docs/design-vault-fixed-precision.md index 5b2dc140070..75dc03293d7 100644 --- a/docs/design-vault-fixed-precision.md +++ b/docs/design-vault-fixed-precision.md @@ -48,13 +48,64 @@ So the fix needs two things that must not be conflated: will eventually collide with the 16-digit ceiling if forced into the same representation as a single transaction amount. -## 2. Mechanism - -### 2.1 `sfPrecision` — a ceiling, not an absolute value - -A new field, `sfPrecision`, set once at Vault creation, immutable thereafter. **Not** the existing -`sfScale` field — `sfScale` is already the fixed assets↔shares conversion factor used today in -`VaultHelpers.cpp`; `sfPrecision` is new, and is this design's rounding ceiling: +## 2. Summary of the solution + +Two new fields on `ltVAULT` (`sfPrecision`, `sfDustAccount`), one new reverse-link field on +`ltACCOUNT_ROOT`, one new pseudo-account per IOU Vault, and one existing, unchanged `ltLOAN` field +(`sfLoanScale`) whose Vault-side input and validation timing change (full detail in **Technical +Details**, below): + +- **`sfPrecision`** — a rounding _ceiling_, set once at Vault creation, immutable thereafter. The + Vault's effective rounding scale is the finer of that ceiling and whatever the 16-sig-digit budget + currently allows given `AssetsTotal`'s size — it only ever degrades from the ceiling as the Vault + grows, never below it. This gives integrators a stable, computable contract instead of an opaque, + ever-shifting derivation. +- **`sfDustAccount`**, a new **Dust Pseudo-account** per IOU Vault — captures the value a + _mandatory_ operation (a Loan repayment) would otherwise have to drop when the Vault's current + effective scale can no longer represent it. The representable portion settles normally; the + unrepresentable remainder transfers into the Dust Pseudo-account. There's no separate `sfDust` + counter — the account's own real balance _is_ the record of deferred value, so it can't drift out + of sync with itself. Once enough dust accumulates to be representable again, it's swept back into + ordinary custody. Nothing is ever silently dropped, and a repayment is never rejected for reasons + unrelated to the loan itself. +- **`sfLoanScale`** (existing field, existing `max`-of-two-floors formula, unchanged) — a Loan's own + rounding precision, fixed once at issuance and immutable thereafter, set to the coarser of what the + Vault can currently represent and what the Loan's own total lifetime value (principal plus all + future interest) independently needs. Both floors are knowable at issuance time, so nothing about a + Loan's precision ever needs to be recomputed later. What this design actually changes: the + Vault-side floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's raw, + unbounded scale, and a pending Loan's already-fixed value gets re-validated once, at acceptance, + against that floor — a check `LoanAccept` doesn't do today. + +Put together: optional operations (Deposit, Withdraw, `VaultClawback`) round directly to the Vault's +current effective scale and essentially never touch `Dust`. Loan funding is treated the same way, and +that's the moment a Loan's own precision gets fixed. Loan repayment is the one mandatory, two-stage +case: round to the Loan's own fixed precision, then — if the Vault has since grown coarser — split the +payment between main custody and the Dust Pseudo-account rather than truncating or rejecting. A +Vault's final withdrawal, which drains it to zero, pays out whatever `Dust` is left in full, since +there's no other shareholder left to divide it with. + +This gives the Vault a fixed, declared precision contract for anything a client interacts with, while +keeping its unbounded internal totals safely decoupled from that same 16-digit ceiling. + +## 3. Technical details + +### 3.1 Fields + +| Field | Object | Notes | Set | Mutable | Purpose | +| --------------- | ---------------- | ------------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sfPrecision` | `ltVAULT` | New | `VaultCreate` | No — immutable (assertion only, not yet enforced; Appendix → Open questions) | Design-time rounding ceiling — see _Precision ceiling_ below. | +| `sfDustAccount` | `ltVAULT` | New, `SoeOptional` — IOU Vaults only | `VaultCreate` | No — set once, never reassigned | Points at the Vault's Dust Pseudo-account. Its real balance, not a separate ledger field, is the authoritative amount of dust — see _Dust: error accumulation and custody routing_ below. | +| `sfVaultDustID` | `ltACCOUNT_ROOT` | New designator field | `VaultCreate` (on the Dust Pseudo-account) | No | Reverse link distinguishing the Dust Pseudo-account from the main one (`sfVaultID`) — two `ACCOUNT_ROOT`s for the same Vault must stay distinguishable by field. | +| `sfLoanScale` | `ltLOAN` | Existing field, existing formula | `LoanSet` (create), exactly as today | No — immutable once set | The Loan's own fixed rounding precision — see _Loan issuance_ below. Re-validated (never re-set) at `LoanAccept`. | + +**Not new, but relevant:** `sfScale` (`ltVAULT`, existing) is the fixed assets↔shares conversion +exponent already used in `VaultHelpers.cpp` — unrelated to `sfPrecision` despite the similar naming; +the two must not be conflated. + +### 3.2 Algorithm + +#### Precision ceiling: `effective(AssetsTotal)` ``` effective(AssetsTotal) = min(sfPrecision, maxPrecisionRepresentableGiven(AssetsTotal, 16-sig-digit budget)) @@ -63,28 +114,34 @@ effective(AssetsTotal) = min(sfPrecision, maxPrecisionRepresentableGiven(AssetsT Degradation is strictly downward from the ceiling — the Vault never rounds finer than `sfPrecision`. Integrators get a deterministic contract: the finest precision ever used, computable from `AssetsTotal` and `sfPrecision` rather than an opaque derivation. It doesn't resolve the 16-digit -collision on internal running totals — that's §2.2. +collision on internal running totals — that's the Dust mechanism, next. -### 2.2 Error Accumulation (Kahan Summation & Vault Dust Custody) +#### Dust: error accumulation and custody routing Lets a mandatory operation succeed in full when `sfPrecision` can't currently be honored, without truncating value or rejecting the tx. -- **Ledger tracking (SAV):** a `Dust` accumulator, stored as a new field **`sfDust`** on `ltVAULT` - (Kahan-style error term). Whenever an amount rounds down from `sfPrecision` to the effective - scale, the dropped remainder adds into `sfDust` — an exact, auditable record of deferred value. - (Written as `Dust`/`vault.Dust` elsewhere for readability; same field.) +- **Ledger tracking:** no separate accumulator field. `Dust` _is_ the Dust Pseudo-account's real + balance (`accountHolds(vault.sle[sfDustAccount], asset)`), read directly wherever a "how much dust + exists right now" answer is needed. Whenever an amount rounds down from `sfPrecision` to the + effective scale, the dropped remainder transfers into that account — the transfer itself is the + record; there's no separate ledger number that could drift out of sync with it. (An earlier version + of this design kept a redundant `sfDust` counter on `ltVAULT` alongside the real balance — dropped: + every consuming path would have had to remember to decrement it exactly in step with the account's + real balance, and the sweep path didn't, which is exactly the kind of bug a derived, single-source + balance can't have.) - **Custody routing:** the representable portion flows to the main Pseudo-account as normal; the - unrepresentable fraction flows to a dedicated **Dust Pseudo-account** for that Vault. + unrepresentable fraction flows to the dedicated Dust Pseudo-account for that Vault. - **The Sweep:** once dust custody accumulates a whole representable unit, it's transferred cleanly into the main Pseudo-account. -Invoked only where §3 says so — not a general-purpose remainder catcher for ordinary rounding. +Invoked only where the case-by-case rules below say so — not a general-purpose remainder catcher for +ordinary rounding. -**`Dust` is real value, and must count as such — for both totals, not just one.** `SettleAdd` (§4.1) -only adds the _settled_ portion into `AssetsTotal`; the dust portion sits, for real, in the Dust -Pseudo-account. If the figures exposed for NAV/share pricing and liquidity don't include it, both -silently undercount the Vault: +**`Dust` is real value, and must count as such — for both totals, not just one.** `SettleAdd` (_Core +primitives_, below) only adds the _settled_ portion into `AssetsTotal`; the dust portion sits, for +real, in the Dust Pseudo-account. If the figures exposed for NAV/share pricing and liquidity don't +include it, both silently undercount the Vault: ``` totalAssetsForPricing = AssetsTotal + Dust @@ -105,25 +162,24 @@ inflows once `AssetsTotal` maxes out, even as the Vault keeps receiving (and owi **Why folding `Dust` into both totals doesn't cause anyone to be overpaid.** This looks, at first glance, like it could commit the Vault to paying out value that only exists in a separate account no -withdrawal ever touches (except the terminal case, §4.3). It doesn't, for two reasons that hold +withdrawal ever touches (except the terminal case, below). It doesn't, for two reasons that hold together: - **Day to day, it's mathematically real but numerically invisible.** `Dust` only exists because it's smaller than one representable unit at the current `effective(AssetsTotal)` — that's the - definition of dust (§4.1). Adding a sub-unit quantity into either total before computing a share - price or a withdrawal amount changes that computation by less than one representable unit — which - then rounds away in the same settlement step (§3.1's `round(amount_requested, -effective(AssetsTotal))`) that produces the amount actually paid. Including `Dust` in the formula - is correct, but for a healthy Vault it never changes what any single depositor or withdrawer - actually receives. -- **Once it's not invisible, it's not `Dust` for long.** `MaybeSweep` (§4.2) runs after every - fund-moving operation and folds any dust balance that's grown past one representable unit straight - into `AssetsTotal`/`AssetsAvailable` — at which point it's ordinary, fully-backed Vault money, not a - separate pool anything else needs to account for. The only way `Dust` sits at an economically - material level is transiently, between the moment it crosses that threshold and the next operation - that triggers a sweep. - -So both totals in §2.2 are correct to include even though nothing pays out of the Dust Pseudo-account + definition of dust. Adding a sub-unit quantity into either total before computing a share price or a + withdrawal amount changes that computation by less than one representable unit — which then rounds + away in the same settlement step (`round(amount_requested, effective(AssetsTotal))`, _Case by case_ + below) that produces the amount actually paid. Including `Dust` in the formula is correct, but for a + healthy Vault it never changes what any single depositor or withdrawer actually receives. +- **Once it's not invisible, it's not `Dust` for long.** `MaybeSweep` (_Core primitives_, below) runs + after every fund-moving operation and folds any dust balance that's grown past one representable + unit straight into `AssetsTotal`/`AssetsAvailable` — at which point it's ordinary, fully-backed + Vault money, not a separate pool anything else needs to account for. The only way `Dust` sits at an + economically material level is transiently, between the moment it crosses that threshold and the + next operation that triggers a sweep. + +So both totals above are correct to include even though nothing pays out of the Dust Pseudo-account directly in the common case — the inclusion only ever matters in the regime this design already calls out separately: near the absolute `STAmount` ceiling, where `AssetsTotal` itself can't grow further and `Dust`'s own headroom is what keeps the Vault's reported totals honest. @@ -135,45 +191,16 @@ PoC) also tracks `interestUnrealized`/`lossUnrealized`, under which `AssetsTotal one. **This document doesn't model that bucket** — it's scoped to precision/dust. Within that narrower scope, `AssetsTotal` and `AssetsAvailable` already diverge on their own, by -construction, not as future work: `SettleAdd` (§4.1) moves `AssetsAvailable` by the full dust-routed +construction, not as future work: `SettleAdd` moves `AssetsAvailable` by the full dust-routed settlement (`settled` — real cash landing in custody), but moves `AssetsTotal` by a separately -supplied `assetsTotalDelta`, which is **not** `settled` for a cash-basis repayment (§3.3) — Deposit -is the one degenerate case where the two happen to coincide. That divergence is required for -cash-basis correctness (§3.3); it's not the same divergence as the interest-accrual bucket above, -which is what's flagged in §5, not resolved here. +supplied `assetsTotalDelta`, which is **not** `settled` for a cash-basis repayment (_Loan repayment_, +below) — Deposit is the one degenerate case where the two happen to coincide. That divergence is +required for cash-basis correctness; it's not the same divergence as the interest-accrual bucket +above, which is what's flagged in the Appendix's open questions, not resolved here. -### 2.3 Why `DebtTotal` doesn't need this +#### Case by case: how rounding actually works -`ltLOAN_BROKER.sfDebtTotal` has its own rounding mechanism, already in production, that looks -structurally similar to the problem §2.2 solves: `adjustImpreciseNumber` (`LendingHelpers.h`) rounds -every adjustment to `DebtTotal` to the Vault's current `vaultScale`, and its own comment at the -`LoanPay.cpp` call site says so plainly — "despite our best efforts, it's possible for rounding -errors to accumulate in the loan broker's debt total... because the broker may have more than one -loan with significantly different scales." That's the same shape of problem: an aggregate bounded by -the 16-sig-digit ceiling, fed by inputs at multiple different fixed scales, coarsening as it grows. -Worth stating explicitly why this document doesn't extend `Dust`/`SettleAdd` to cover it too: - -- **`DebtTotal` isn't custodied money.** `AssetsTotal` must reconcile exactly with a real balance — - the Vault's Pseudo-account — which is why a dropped remainder needs somewhere to go (`Dust`). - `DebtTotal` is a receivables _estimate_ (how much principal this broker's loans still owe); no - account balance anywhere corresponds to it, so there's nothing for its rounding error to - misplace. -- **The error is symmetric, not a one-directional drain.** `Number`'s default rounding mode is - `ToNearest`, so `DebtTotal`'s accumulated error random-walks rather than systematically leaking in - one direction. -- **Its one safety-relevant consumer already rounds conservatively.** `minimumBrokerCover` rounds - its result _up_, specifically "to be conservative... ensures `CoverAvailable` never drops below - the theoretical minimum" — so any drift in `DebtTotal` can only make the required cover look - slightly larger than the true debt, never smaller, which is the safe direction for solvency. -- **It predates this design and isn't changed by it.** This is existing, already-accepted behavior, - not something introduced or made worse by `sfPrecision`/`Dust` — nothing in §3–§4 touches how - `DebtTotal` is computed or rounded. - -**Conclusion: left as-is, deliberately, not an oversight.** - -## 3. Case by case: how rounding actually works - -### 3.1 User fund movement — Deposit, Withdraw, `VaultClawback` +##### User fund movement — Deposit, Withdraw, `VaultClawback` **Optional operations** — round directly to the effective scale, no `sfPrecision`-first attempt: @@ -181,20 +208,20 @@ Worth stating explicitly why this document doesn't extend `Dust`/`SettleAdd` to amount_settled = round(amount_requested, effective(AssetsTotal)) ``` -This pre-rounding eliminates Case 1 (§4.1) — `dust_0` is always `0` for these callers, unlike -repayment. It does **not** eliminate Case 2: a Deposit that pushes `AssetsTotal` across a magnitude -boundary can still coarsen the effective scale between the caller's rounding and the moment -`SettleAdd` applies it, producing `dust_1` the same way a repayment can (§4.1's notes). That's rare — -it only fires exactly at a growth boundary — but it isn't structurally impossible, so Deposit still -goes through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate dust-free path. -What _is_ true for these operations: none of them are obligations owed in full, so if Case 2 fires, -it's the caller's own requested amount that silently absorbs the truncation (§2.2) — never an -already-agreed, mandatory amount the way a repayment's `loan.precision` mismatch is. +This pre-rounding eliminates Case 1 (_Core primitives_, below) — `dust_0` is always `0` for these +callers, unlike repayment. It does **not** eliminate Case 2: a Deposit that pushes `AssetsTotal` +across a magnitude boundary can still coarsen the effective scale between the caller's rounding and +the moment `SettleAdd` applies it, producing `dust_1` the same way a repayment can. That's rare — it +only fires exactly at a growth boundary — but it isn't structurally impossible, so Deposit still goes +through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate dust-free path. What +_is_ true for these operations: none of them are obligations owed in full, so if Case 2 fires, it's +the caller's own requested amount that silently absorbs the truncation — never an already-agreed, +mandatory amount the way a repayment's `loan.precision` mismatch is. -### 3.2 Loan issuance (funding) — Vault → Loan +##### Loan issuance (funding) — Vault → Loan Funding is a Vault-initiated disbursement, not a debt obligation — structurally a withdrawal. -Follows the §3.1 rule: rounds directly to the Vault's _current_ effective scale, no dust. +Follows the rule above: rounds directly to the Vault's _current_ effective scale, no dust. Consequence: **the Loan's own precision is fixed once, at funding time, to the effective scale used for that disbursement** — not the Vault's raw `sfPrecision` ceiling. But "the effective scale used @@ -224,10 +251,10 @@ unknown: and payment count are all fixed loan terms chosen at `LoanSet`, so `totalValueOutstanding` — and therefore its natural scale — is computable synchronously, with no dependency on anything that hasn't happened yet. A modest principal at a high rate over a long term can have - `principal + total future interest` land at a materially larger order of magnitude than the Vault's current - `AssetsTotal`, even though the principal itself came from (and is bounded by) the Vault — nothing - bounds future interest by the Vault's present size, especially under cash-basis accounting, where - `AssetsTotal` doesn't recognize interest until it's actually paid (§3.3). + `principal + total future interest` land at a materially larger order of magnitude than the Vault's + current `AssetsTotal`, even though the principal itself came from (and is bounded by) the Vault — + nothing bounds future interest by the Vault's present size, especially under cash-basis accounting, + where `AssetsTotal` doesn't recognize interest until it's actually paid (_Loan repayment_, below). - **The Vault-side floor is not knowable in advance.** How much _more_ the Vault's own effective scale might degrade over the Loan's life — from other loans funding/repaying, deposits, sweeps, other borrowers' interest accruing — depends on the whole Vault's future activity, not this Loan's @@ -235,10 +262,12 @@ unknown: funding at all: if it were knowable in advance the way the Loan-side floor is, there'd be no need to freeze it. -Stored as the existing **`sfLoanScale`** field on `ltLOAN` (set in `LoanSet.cpp`, read in -`LoanPay.cpp`/`LoanManage.cpp`) — same relationship as `sfPrecision`/`sfScale` on the Vault side: -the field already exists, what's new is _when_ and _to what value_ it gets set (this funding-time -rule, not whatever governs it today). +Stored as the existing `sfLoanScale` field on `ltLOAN`, set in `LoanSet.cpp` exactly as today — +`computeLoanProperties` already computes this two-floor `max` at creation. What's new: the Vault-side +floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's raw, unbounded +`getAssetsTotalScale`; and `LoanAccept` gains a re-validation step it doesn't have today (see +_Pending Loans_ below) — the already-fixed value is checked, never recomputed or re-set, at +acceptance. Immutable thereafter, same as `sfPrecision` is for the Vault. Operating a Loan at finer precision than it was actually funded with would assert false precision across all its future accounting. @@ -251,7 +280,7 @@ effective scale to 5; Loan B funds then, with a long term and high rate whose pr Loan B's _own_ terms are the binding constraint here, not the Vault's size at all. Same Vault, two Loans, two fixed precisions, each set by whichever floor is coarser at that Loan's funding moment. -#### 3.2.1 Pending Loans: acceptance can straddle a scale change +###### Pending Loans: acceptance can straddle a scale change Issuance is two steps: **create** (amount agreed/reserved, funds not yet moved) and **accept** (funds move). "Funding time" above is really **acceptance time**. @@ -279,46 +308,46 @@ attacker still holds (and can withdraw right back out afterward). A motivated pa specific pending Loan's acceptance repeatedly, each time forcing the borrower to eat a re-quote (possibly at worse terms if the market's moved), for reasons unrelated to that Loan's own terms. -Unlike §4.4.3's address-squatting vector — which costs real (if modest) XRP per attempt and is -single-ledger-use, and which this document treats as worth designing around — this one is cheaper -and repeatable. **Decision: accepted risk anyway**, on the same cost/benefit basis as ordinary -transaction-propagation risk elsewhere: the failure mode is a rejected acceptance and a forced -re-quote, not a loss of funds or a stuck Vault, and building in tolerance (e.g. letting acceptance -absorb one boundary crossing via the Dust mechanism, the way repayment does, instead of hard- -rejecting) is real added complexity for a griefing outcome that's merely annoying, not unsafe. Flagged -here rather than fixed. +Unlike the address-squatting vector below (Appendix → _Address predictability is a griefing vector +under lazy creation_) — which costs real (if modest) XRP per attempt and is single-ledger-use, and +which this document treats as worth designing around — this one is cheaper and repeatable. +**Decision: accepted risk anyway**, on the same cost/benefit basis as ordinary transaction-propagation +risk elsewhere: the failure mode is a rejected acceptance and a forced re-quote, not a loss of funds +or a stuck Vault, and building in tolerance (e.g. letting acceptance absorb one boundary crossing via +the Dust mechanism, the way repayment does, instead of hard-rejecting) is real added complexity for a +griefing outcome that's merely annoying, not unsafe. Flagged here rather than fixed. -### 3.3 Loan repayment — Loan → Vault +##### Loan repayment — Loan → Vault **Mandatory operation.** Two-stage: -1. Round to the Loan's own fixed `loan.precision` (§3.2) — may already be coarser than the Vault's +1. Round to the Loan's own fixed `loan.precision` (above) — may already be coarser than the Vault's current `sfPrecision` ceiling. This applies to the combined cash figure that actually settles — principal plus interest together — not principal alone; `LoanPay.cpp` already rounds `principalPaid + interestPaid` as one figure before this point today. 2. Check representability at the Vault's _current_ `effective(AssetsTotal)`: - `loan.precision <= effective(AssetsTotal)`: settles in full, no dust. - Otherwise: split. The representable portion settles into main custody; the delta routes to the - Dust Pseudo-account, recorded in `Dust` (§2.2). + Dust Pseudo-account, recorded in `Dust`. -The common case for Case 1 dust (§4.1) — a loan's fixed precision going stale as the Vault has grown -since funding. The borrower's payment is always collected in full — the split is a custody-routing -detail, invisible to the borrower's accounting. +The common case for Case 1 dust (_Core primitives_, below) — a loan's fixed precision going stale as +the Vault has grown since funding. The borrower's payment is always collected in full — the split is +a custody-routing detail, invisible to the borrower's accounting. **`AssetsTotal` and custody settlement are driven by two different numbers, so `AddAssetsToVault`/ `SettleAdd` take them as two separate parameters, not one.** `AddAssetsToVault(view, vault, from, amount, assetsTotalDelta, j)`: - `amount` — the real cash settling into custody (principal + interest combined, for a repayment). - Goes through the full dust/precision-routed path (§4.1): `AssetsAvailable` moves by whatever of it - actually lands (`settled`); any truncated remainder routes to `Dust`. + Goes through the full dust/precision-routed path (_Core primitives_, below): `AssetsAvailable` + moves by whatever of it actually lands (`settled`); any truncated remainder routes to `Dust`. - `assetsTotalDelta` — how much `vault.AssetsTotal` itself moves. Supplied by the caller, defaulting - to `settled` (§4.1) when not overridden — that default is what makes Deposit's case (§3.1) and - `MaybeSweep`'s sweep leg (§4.2) work correctly, since for both of those, landed custody genuinely - _is_ new value to `AssetsTotal`. A cash-basis repayment is the one caller that must override this - default, passing `interestPaid` instead of letting it default to `settled` — never dust-routed, - never changing `AssetsAvailable`, no transfer of its own (bookkeeping only, layered on the same - real cash movement as `amount`). + to `settled` when not overridden — that default is what makes Deposit's case (above) and + `MaybeSweep`'s sweep leg (_Core primitives_, below) work correctly, since for both of those, landed + custody genuinely _is_ new value to `AssetsTotal`. A cash-basis repayment is the one caller that + must override this default, passing `interestPaid` instead of letting it default to `settled` — + never dust-routed, never changing `AssetsAvailable`, no transfer of its own (bookkeeping only, + layered on the same real cash movement as `amount`). Why a cash-basis repayment can't just take the default: this mirrors `LendingProtocolV1_1`'s cash-basis accounting model ([#7817](https://github.com/XRPLF/rippled/pull/7817)) — `AssetsTotal` @@ -328,28 +357,29 @@ tracks **principal only**, as a receivable recognized once, at origination; inte whereas `settled` (the thing `SettleAdd` defaults `assetsTotalDelta` to) is principal _and_ interest combined. Letting `AssetsTotal` default to `settled` here, as this doc's algorithm originally did unconditionally, double-counts principal into `AssetsTotal` on every cash-basis repayment — that was -a real bug in the original `SettleAdd` pseudocode (§4.1), not just an accounting simplification, and -is why the override exists as an explicit, required parameter rather than an afterthought. +a real bug in the original `SettleAdd` pseudocode, not just an accounting simplification, and is why +the override exists as an explicit, required parameter rather than an afterthought. **Open question this raises, not resolved here:** dust/precision routing exists to protect -`AssetsTotal` additions from exceeding the 16-sig-digit ceiling (§2.2). Under cash-basis, -`assetsTotalDelta` (i.e. `interestPaid`) is the only thing that grows `AssetsTotal` on repayment, so -it's the value that could, in principle, need the same two-stage truncation `SettleAdd` already does -for `amount`. This document gives it no dust routing of its own (§5) — assumed small enough relative -to `AssetsTotal` to add cleanly, not verified. +`AssetsTotal` additions from exceeding the 16-sig-digit ceiling. Under cash-basis, `assetsTotalDelta` +(i.e. `interestPaid`) is the only thing that grows `AssetsTotal` on repayment, so it's the value that +could, in principle, need the same two-stage truncation `SettleAdd` already does for `amount`. This +document gives it no dust routing of its own (Appendix → Open questions) — assumed small enough +relative to `AssetsTotal` to add cleanly, not verified. -## 4. Algorithms +#### Core primitives -Two shared primitives, invoked by every fund-moving operation in §3 instead of each implementing its +Two shared primitives, invoked by every fund-moving operation above instead of each implementing its own settlement/dust logic: **add assets to Vault**, **remove assets from Vault**. -### 4.1 Add assets to Vault +##### Add assets to Vault **Precondition:** the caller has already rounded `amount` — the cash settlement figure — to _some_ target scale (Deposit → `effective(AssetsTotal)`; repayment → `loan.precision`, applied to principal+interest combined). This algorithm decides how much of `amount` custody (`AssetsAvailable`) can absorb right now and routes the rest to `Dust`; `AssetsTotal` moves -separately, driven by the caller-supplied `assetsTotalDelta` (§3.3), not by this truncation. +separately, driven by the caller-supplied `assetsTotalDelta` (_Loan repayment_, above), not by this +truncation. Two independent, _composable_ dust sources, not exclusive cases: @@ -364,7 +394,7 @@ one extra pass — `settled_1 <= settled_0`, so re-adding it can't coarsen below The truncation/bookkeeping core is `SettleAdd`, which never sweeps; `AddAssetsToVault` wraps it with an opportunistic sweep. Keeping them separate avoids `MaybeSweep` recursively triggering itself -(§4.2). +(below). Real fund movement goes through `accountSend`/`accountSendMulti` (`TokenHelpers.h`), same as every existing transactor. `LoanPay.cpp` already does this exact split-destination move in one call — @@ -378,11 +408,11 @@ function SettleAdd(view, vault, from, amount, assetsTotalDelta, j) -> Expected Expected Expected 0: - vault.Dust += dust + # No separate Dust bookkeeping here — the accountSendMulti call above + # already moved `dust` into vault.sle[sfDustAccount]; that account's + # own real balance is the record, nothing else to update. vault.AssetsTotal += assetsTotalDelta # sole driver of AssetsTotal — NOT `settled` return settled # callers needing the actually-landed amount (e.g. @@ -428,7 +459,7 @@ function SettleAdd(view, vault, from, amount, assetsTotalDelta, j) -> Expected Expected: settledResult = SettleAdd(view, vault, from, amount, assetsTotalDelta, j) if settledResult is error: return settledResult - MaybeSweep(view, vault, j) # §4.2 — cheap no-op if dust is insufficient + MaybeSweep(view, vault, j) # below — cheap no-op if dust is insufficient return settledResult ``` @@ -438,12 +469,13 @@ Notes: favor. - Deposit's precondition guarantees `dust_0 = 0`; only `dust_1` can fire for it. Repayment is the case where `dust_0` is the common one. Same function handles both. -- `dust > 0` is only possible when `vault.sle[sfDustAccount]` exists — i.e. an IOU Vault (§4.4.4). - XRP/MPT Vaults never produce dust; that falls out of the truncation math, not an extra check. +- `dust > 0` is only possible when `vault.sle[sfDustAccount]` exists — i.e. an IOU Vault (_Dust + Pseudo-account lifecycle_, below). XRP/MPT Vaults never produce dust; that falls out of the + truncation math, not an extra check. - `WaiveTransferFee::Yes` matches every existing Vault-related transfer — an issuer's transfer rate shouldn't eat into internal accounting. -### 4.2 `MaybeSweep` +##### `MaybeSweep` Opportunistically moves as much of dust custody as is currently representable back into the main Pseudo-account/`AssetsTotal`. Called at the end of every `AddAssetsToVault`; a no-op below one @@ -465,18 +497,17 @@ function MaybeSweep(view, vault, j) -> TER: # NOT AddAssetsToVault (see below). `from` is the Dust Pseudo-account # itself; assetsTotalDelta is omitted (None) so it defaults to - # whatever this call's own `settled` turns out to be (§4.1/§3.3) — - # swept dust was never counted in AssetsTotal to begin with, so - # landing it here is exactly the "new value" default case, same as - # Deposit. + # whatever this call's own `settled` turns out to be — swept dust was + # never counted in AssetsTotal to begin with, so landing it here is + # exactly the "new value" default case, same as Deposit. result = SettleAdd(view, vault, vault.sle[sfDustAccount], candidate, None, j) if result is error: return result.error() return tesSUCCESS - # If this transfer induces further coarsening (§4.1 Case 2), + # If this transfer induces further coarsening (Case 2, above), # SettleAdd's accountSendMulti sends the residual back to # vault.sle[sfDustAccount] — the same account it came from (from==to; - # needs verifying that's a clean no-op — §5). Net effect on dust - # custody is always a decrease of exactly `settled`. + # needs verifying that's a clean no-op — Appendix → Open questions). + # Net effect on dust custody is always a decrease of exactly `settled`. ``` Why `SettleAdd`, not `AddAssetsToVault`: the latter would trigger another `MaybeSweep`, which in the @@ -487,33 +518,33 @@ itself. `MaybeSweep` never rejects or blocks — it strictly improves (or leaves unchanged) how much of `AssetsTotal` is backed by main custody. -### 4.3 Remove assets from Vault +##### Remove assets from Vault -**Precondition:** same shape as §4.1; callers are Withdraw, `VaultClawback`, Loan -issuance/acceptance — all already rounded to `effective(AssetsTotal)` before calling. No §4.1-Case-1 +**Precondition:** same shape as above; callers are Withdraw, `VaultClawback`, Loan +issuance/acceptance — all already rounded to `effective(AssetsTotal)` before calling. No Case-1 analogue (no caller targets a different, finer precision). -No §4.1-Case-2 analogue either — the key asymmetry: removing only ever **shrinks** `AssetsTotal`, -and effective scale is non-increasing in magnitude, so `effective(AssetsTotal - amount) >= +No Case-2 analogue either — the key asymmetry: removing only ever **shrinks** `AssetsTotal`, and +effective scale is non-increasing in magnitude, so `effective(AssetsTotal - amount) >= effective(AssetsTotal)` always. A removal can only hold the scale steady or refine it, never coarsen it. **Removing assets never generates dust.** -Fund movement mirrors §4.1: a single-destination `accountSend` out of main custody, the same shape -`doWithdraw` (`View.cpp`) already uses for Withdraw/`LoanBrokerCoverWithdraw`. Destination-specific -setup (trust-line/MPToken creation, `verifyDepositPreauth`) stays the caller's job, same split as -`VaultWithdraw::doApply`/`doWithdraw` today. - -**Terminal withdrawal: all `Dust` settles to the last withdrawer.** §2.2 established `Dust` counts -toward every shareholder's value, and also explains why that's never a live problem for non-terminal -withdrawers: `MaybeSweep` keeps `Dust` folded back into `AssetsTotal` as soon as it's big enough to -move any settlement, so anyone withdrawing while other shares remain outstanding is pricing against a -total that's already either recognized `Dust` for real or is carrying an amount too small to change -their payout. What's left once a withdrawal drains the Vault to zero is only the residual that's -_structurally_ un-sweepable — below one representable unit at closure, with no future operation left -to ever cross that threshold, and no other shareholder left to divide it with. That's genuinely the -one case needing a special rule: pay the whole residual to the one withdrawer closing out the Vault, -rather than leave it stranded forever. This also leaves `Dust` custody at zero, which `VaultDelete` -needs anyway (§4.4.4). +Fund movement mirrors the algorithm above: a single-destination `accountSend` out of main custody, +the same shape `doWithdraw` (`View.cpp`) already uses for Withdraw/`LoanBrokerCoverWithdraw`. +Destination-specific setup (trust-line/MPToken creation, `verifyDepositPreauth`) stays the caller's +job, same split as `VaultWithdraw::doApply`/`doWithdraw` today. + +**Terminal withdrawal: all `Dust` settles to the last withdrawer.** The Dust section above +established `Dust` counts toward every shareholder's value, and also explains why that's never a +live problem for non-terminal withdrawers: `MaybeSweep` keeps `Dust` folded back into `AssetsTotal` +as soon as it's big enough to move any settlement, so anyone withdrawing while other shares remain +outstanding is pricing against a total that's already either recognized `Dust` for real or is +carrying an amount too small to change their payout. What's left once a withdrawal drains the Vault +to zero is only the residual that's _structurally_ un-sweepable — below one representable unit at +closure, with no future operation left to ever cross that threshold, and no other shareholder left to +divide it with. That's genuinely the one case needing a special rule: pay the whole residual to the +one withdrawer closing out the Vault, rather than leave it stranded forever. This also leaves `Dust` +custody at zero, which `VaultDelete` needs anyway (_Dust Pseudo-account lifecycle_, below). ``` function RemoveAssetsFromVault(view, vault, to, amount, j) -> Expected: @@ -525,7 +556,7 @@ function RemoveAssetsFromVault(view, vault, to, amount, j) -> Expected Expected AccountID: - if vault.sle[sfDustAccount] is present: - return vault.sle[sfDustAccount] - - # Must not fail the enclosing mandatory transaction — no - # checkReserve/increaseOwnerCount, per adjustLoanBrokerOwnerCount's - # precedent. Same seed as the main Pseudo-account; the retry loop - # lands on the next free address. - sleResult = createPseudoAccount(view, vault->key(), sfVaultDustID) - vault.sle[sfDustAccount] = sleResult->key() - return sleResult->key() +**Decision: created eagerly, at Vault creation.** Every IOU Vault gets it up front, used or not. This +was chosen over lazily creating it on first dust because lazy creation has a griefing vector specific +to it — its address stays publicly predictable for the Vault's whole life while creation timing is +unpredictable, letting an attacker pre-squat the address and fail a mandatory repayment. Eager +creation collapses the window to ordinary transaction-propagation risk, same as every other +pseudo-account. Full analysis of the rejected lazy-creation option and the griefing vector that ruled +it out live in the Appendix, for the record; what follows is what's actually built. -function SettleAdd(view, vault, amount) -> settled: - ... (unchanged truncation math from §4.1) ... - if dust > 0: - dustAccountId = EnsureDustPseudoAccount(view, vault) # lazily, on first dust - vault.Dust += dust - DustPseudoAccount(dustAccountId).balance += dust - ... -``` - -Consequence: whichever repayment happens to be first pays a one-time `ACCOUNT_ROOT`-creation cost no -other repayment on that Vault pays — asymmetric, but bounded to once per Vault. - -#### 4.4.2 Storage and key generation (background, applies to either option) - -**Storage.** `ltVAULT.sfAccount` (required) already points at the main Pseudo-account. Symmetric new -field **`sfDustAccount`** (`SoeOptional` — only IOU Vaults populate it). - -**Reverse link.** `ltACCOUNT_ROOT` already has designator fields (`sfAMMID`, `sfVaultID`, -`sfLoanBrokerID`, flagged `sMD_PseudoAccount` for `getPseudoAccountFields()`/`isPseudoAccount()`). -The Dust Pseudo-account needs its own — **`sfVaultDustID`** — rather than reusing `sfVaultID`, which -would make two different `ACCOUNT_ROOT`s for the same Vault indistinguishable by field, breaking the -implicit one-designator-one-pseudo-account assumption invariant checks rely on. - -(`ltVAULT` (`0x0084`) has `0x0085`–`0x0087` reserved for future Vault-related objects — capacity -exists if a fuller object type is ever needed, but a bare `ACCOUNT_ROOT` is enough here.) - -**Key generation.** No new primitive needed — `createPseudoAccount`/`pseudoAccountAddress` (hashing -`(retryIndex, parentHash, pseudoOwnerKey)`, retrying up to 256 times) is the same machinery every -pseudo-account already uses. The only choice is the `pseudoOwnerKey` seed: **Option A** reuses -`vault->key()` unchanged (the retry loop naturally lands on the next free address after the main -account); **Option B** derives an explicitly distinct seed (e.g. `sha512Half(vault->key(), -)`). **Both are equally vulnerable to §4.4.3's griefing attack** — the vulnerability -is about _when_ creation happens relative to how long the seed's been public, not which formula -computes it. - -#### 4.4.3 Address predictability is a griefing vector under lazy creation - -`pseudoAccountAddress` hashes only public inputs: `vault->key()` (public and permanent once the -Vault exists) and `parentHash` (public the instant a ledger closes). Fine for the **main** -Pseudo-account, created atomically with `ltVAULT` — the only exposure is ordinary -transaction-propagation time. - -Not fine for a **lazily**-created Dust Pseudo-account: the seed is public for the Vault's entire -remaining life while creation timing stays unpredictable. Attack: - -1. Watch Vault X for a repayment likely to generate dust (the whole reason lazy creation exists is - that this moment is unpredictable). -2. Once visible, precompute the exact candidate address `EnsureDustPseudoAccount` will try at retry - index 1 for that ledger's `parentHash` (index 0 is always the main account). -3. Submit a trivial Payment there first, auto-creating an `ACCOUNT_ROOT`. The real call advances to - index 2 — also precomputable and squattable. -4. Squat all 256 candidates in that ledger → `pseudoAccountAddress` returns `beast::kZero`, - `createPseudoAccount` returns `tecDUPLICATE`. The triggering repayment — a mandatory operation - for a borrower unrelated to this attack — fails. - -That's exactly the failure mode §1.2 exists to eliminate. Squatting 256 addresses costs real (if -modest) XRP, and `parentHash` changes every ledger so a squat set is single-use — but the attacker -only needs to deny one repayment, at a moment of their choosing, with no advance preparation window -required (the seed has been public since Vault creation). - -**Implication:** structural argument for eager creation — atomic creation with `VaultCreate` -collapses the window to the same risk every other pseudo-account already accepts; no seed formula -(§4.4.2) fixes lazy creation's exposure. **Decision: eager (§4.4.4).** - -#### 4.4.4 Option A, worked out: eager creation and tracking +##### Eager creation and tracking Creation moves entirely into `VaultCreate` — no lazy-check anywhere in the settlement path; by the time any `SettleAdd` runs, the Dust Pseudo-account (for an IOU Vault) already exists. @@ -682,7 +611,7 @@ function VaultCreate(view, ...): vault.sle[sfAccount] = mainPseudo->key() # ... existing reserve accounting for the main Pseudo-account ... - if vault.asset is IOU: # §4.4: IOU-only + if vault.asset is IOU: # IOU-only dustPseudo = createPseudoAccount(view, vault->key(), sfVaultDustID) if dustPseudo is error: return dustPseudo.error() vault.sle[sfDustAccount] = dustPseudo->key() @@ -694,8 +623,8 @@ function VaultCreate(view, ...): ``` Both accounts use the same seed, `vault->key()`; the retry loop assigns main → index 0, dust → index -1, entirely within one atomic transaction — no gap between the seed becoming known and either -address being claimed, so §4.4.3's window doesn't exist here. +1, entirely within one atomic transaction — no gap between the seed becoming known and either address +being claimed, so the griefing window described in the Appendix doesn't exist here. **Tracking, in full:** @@ -703,16 +632,16 @@ address being claimed, so §4.4.3's window doesn't exist here. - `ltVAULT.sfDustAccount` → Dust Pseudo-account (optional; present iff IOU). Set once at `VaultCreate`, never reassigned. - `ltACCOUNT_ROOT.sfVaultID`/`sfVaultDustID` — reverse links, distinguishing the two roles per Vault - (§4.4.2). + (see Appendix → _Storage and key generation_). - **Lifecycle symmetry:** since `sfDustAccount` is now unconditional for every IOU Vault, whatever deletes the main Pseudo-account on `VaultDelete` must be extended to delete the Dust - Pseudo-account too, under the same invariants (e.g. zero balance). Worked out in §4.4.5. + Pseudo-account too, under the same invariants (e.g. zero balance). Worked out next. -#### 4.4.5 `VaultDelete`: cleaning up the Dust Pseudo-account +##### `VaultDelete`: cleaning up the Dust Pseudo-account `VaultDelete::preclaim` already requires `sfAssetsAvailable == 0`, `sfAssetsTotal == 0`, and zero -outstanding shares before allowing deletion. §4.3's terminal-withdrawal rule means `Dust` is drained -to zero by the same event that drives `AssetsTotal`/`SharesTotal` to zero — so by the time +outstanding shares before allowing deletion. The terminal-withdrawal rule above means `Dust` is +drained to zero by the same event that drives `AssetsTotal`/`SharesTotal` to zero — so by the time `preclaim`'s checks pass, the Dust Pseudo-account should already be empty. "Should" isn't "guaranteed by construction independent of bugs elsewhere," though, so `doApply` re-verifies it directly, the same defensive-depth style already used for the main Pseudo-account (re-checking @@ -761,37 +690,161 @@ The Dust Pseudo-account never held a share-issuance-style object of its own (onl holding, same as the main account), so there's no analogue to the share-issuance-destruction block — just the holding, the balance/owner-count/directory checks, and the erase. -## 5. Open questions / not yet resolved +## 4. Appendix + +### 4.1 Why `DebtTotal` doesn't need this + +`ltLOAN_BROKER.sfDebtTotal` has its own rounding mechanism, already in production, that looks +structurally similar to the problem the Dust mechanism solves: `adjustImpreciseNumber` +(`LendingHelpers.h`) rounds every adjustment to `DebtTotal` to the Vault's current `vaultScale`, and +its own comment at the `LoanPay.cpp` call site says so plainly — "despite our best efforts, it's +possible for rounding errors to accumulate in the loan broker's debt total... because the broker may +have more than one loan with significantly different scales." That's the same shape of problem: an +aggregate bounded by the 16-sig-digit ceiling, fed by inputs at multiple different fixed scales, +coarsening as it grows. Worth stating explicitly why this document doesn't extend `Dust`/`SettleAdd` +to cover it too: + +- **`DebtTotal` isn't custodied money.** `AssetsTotal` must reconcile exactly with a real balance — + the Vault's Pseudo-account — which is why a dropped remainder needs somewhere to go (`Dust`). + `DebtTotal` is a receivables _estimate_ (how much principal this broker's loans still owe); no + account balance anywhere corresponds to it, so there's nothing for its rounding error to + misplace. +- **The error is symmetric, not a one-directional drain.** `Number`'s default rounding mode is + `ToNearest`, so `DebtTotal`'s accumulated error random-walks rather than systematically leaking in + one direction. +- **Its one safety-relevant consumer already rounds conservatively.** `minimumBrokerCover` rounds + its result _up_, specifically "to be conservative... ensures `CoverAvailable` never drops below + the theoretical minimum" — so any drift in `DebtTotal` can only make the required cover look + slightly larger than the true debt, never smaller, which is the safe direction for solvency. +- **It predates this design and isn't changed by it.** This is existing, already-accepted behavior, + not something introduced or made worse by `sfPrecision`/`Dust` — nothing in this design touches how + `DebtTotal` is computed or rounded. + +**Conclusion: left as-is, deliberately, not an oversight.** + +### 4.2 Rejected alternative: lazy Dust-account creation + +Kept for the record; superseded by eager creation (Technical Details → _Dust Pseudo-account +lifecycle_) once §4.3 below found a griefing vector specific to it. + +`createPseudoAccount(view, pseudoOwnerKey, ownerField)` creates the `ACCOUNT_ROOT`, same helper the +main Pseudo-account already uses. The codebase's reserve-**sponsorship** mechanism (`sfSponsor`, +`checkReserve` with a sponsor) doesn't help here — it's allow-listed per transaction type and still +_enforces_ a check, just against a different account; it relocates the failure, doesn't remove it. +The real precedent is `adjustLoanBrokerOwnerCount`, which maintains a LoanBroker owner-count field +deliberately _not_ wired into `checkReserve`/`increaseOwnerCount` at all. + +``` +function EnsureDustPseudoAccount(view, vault) -> AccountID: + if vault.sle[sfDustAccount] is present: + return vault.sle[sfDustAccount] + + # Must not fail the enclosing mandatory transaction — no + # checkReserve/increaseOwnerCount, per adjustLoanBrokerOwnerCount's + # precedent. Same seed as the main Pseudo-account; the retry loop + # lands on the next free address. + sleResult = createPseudoAccount(view, vault->key(), sfVaultDustID) + vault.sle[sfDustAccount] = sleResult->key() + return sleResult->key() + +function SettleAdd(view, vault, amount) -> settled: + ... (unchanged truncation math) ... + if dust > 0: + dustAccountId = EnsureDustPseudoAccount(view, vault) # lazily, on first dust + vault.Dust += dust + DustPseudoAccount(dustAccountId).balance += dust + ... +``` + +Consequence: whichever repayment happens to be first pays a one-time `ACCOUNT_ROOT`-creation cost no +other repayment on that Vault pays — asymmetric, but bounded to once per Vault. + +### 4.3 Address predictability is a griefing vector under lazy creation + +`pseudoAccountAddress` hashes only public inputs: `vault->key()` (public and permanent once the +Vault exists) and `parentHash` (public the instant a ledger closes). Fine for the **main** +Pseudo-account, created atomically with `ltVAULT` — the only exposure is ordinary +transaction-propagation time. + +Not fine for a **lazily**-created Dust Pseudo-account (§4.2, above): the seed is public for the +Vault's entire remaining life while creation timing stays unpredictable. Attack: + +1. Watch Vault X for a repayment likely to generate dust (the whole reason lazy creation exists is + that this moment is unpredictable). +2. Once visible, precompute the exact candidate address `EnsureDustPseudoAccount` will try at retry + index 1 for that ledger's `parentHash` (index 0 is always the main account). +3. Submit a trivial Payment there first, auto-creating an `ACCOUNT_ROOT`. The real call advances to + index 2 — also precomputable and squattable. +4. Squat all 256 candidates in that ledger → `pseudoAccountAddress` returns `beast::kZero`, + `createPseudoAccount` returns `tecDUPLICATE`. The triggering repayment — a mandatory operation + for a borrower unrelated to this attack — fails. -- **Does `assetsTotalDelta` (§3.3/§4.1) need its own dust/precision routing?** For a cash-basis - repayment it's `interestPaid`, and `SettleAdd` only does `vault.AssetsTotal += assetsTotalDelta`, - with no routing, unlike `amount`. Under cash-basis accounting - ([#7817](https://github.com/XRPLF/rippled/pull/7817)), `interestPaid` is what actually drives - `AssetsTotal` growth at repayment — so it's the value that could exceed the 16-sig-digit ceiling - `SettleAdd` exists to protect against, not principal. Currently assumed small enough to add +That's exactly the failure mode §1.2 exists to eliminate. Squatting 256 addresses costs real (if +modest) XRP, and `parentHash` changes every ledger so a squat set is single-use — but the attacker +only needs to deny one repayment, at a moment of their choosing, with no advance preparation window +required (the seed has been public since Vault creation). + +**Implication:** structural argument for eager creation — atomic creation with `VaultCreate` +collapses the window to the same risk every other pseudo-account already accepts; no seed formula +(§4.4, below) fixes lazy creation's exposure. **Decision: eager** (Technical Details → _Dust +Pseudo-account lifecycle_). + +### 4.4 Storage and key generation (background, applies to either option) + +**Storage.** `ltVAULT.sfAccount` (required) already points at the main Pseudo-account. Symmetric new +field **`sfDustAccount`** (`SoeOptional` — only IOU Vaults populate it). + +**Reverse link.** `ltACCOUNT_ROOT` already has designator fields (`sfAMMID`, `sfVaultID`, +`sfLoanBrokerID`, flagged `sMD_PseudoAccount` for `getPseudoAccountFields()`/`isPseudoAccount()`). +The Dust Pseudo-account needs its own — **`sfVaultDustID`** — rather than reusing `sfVaultID`, which +would make two different `ACCOUNT_ROOT`s for the same Vault indistinguishable by field, breaking the +implicit one-designator-one-pseudo-account assumption invariant checks rely on. + +(`ltVAULT` (`0x0084`) has `0x0085`–`0x0087` reserved for future Vault-related objects — capacity +exists if a fuller object type is ever needed, but a bare `ACCOUNT_ROOT` is enough here.) + +**Key generation.** No new primitive needed — `createPseudoAccount`/`pseudoAccountAddress` (hashing +`(retryIndex, parentHash, pseudoOwnerKey)`, retrying up to 256 times) is the same machinery every +pseudo-account already uses. The only choice is the `pseudoOwnerKey` seed: **eager creation** reuses +`vault->key()` unchanged (the retry loop naturally lands on the next free address after the main +account); **lazy creation** would have derived an explicitly distinct seed (e.g. +`sha512Half(vault->key(), )`). **Both are equally vulnerable to §4.3's griefing +attack** — the vulnerability is about _when_ creation happens relative to how long the seed's been +public, not which formula computes it. + +### 4.5 Open questions / not yet resolved + +- **Does `assetsTotalDelta` (Technical Details → _Loan repayment_ / _Add assets to Vault_) need its + own dust/precision routing?** For a cash-basis repayment it's `interestPaid`, and `SettleAdd` only + does `vault.AssetsTotal += assetsTotalDelta`, with no routing, unlike `amount`. Under cash-basis + accounting ([#7817](https://github.com/XRPLF/rippled/pull/7817)), `interestPaid` is what actually + drives `AssetsTotal` growth at repayment — so it's the value that could exceed the 16-sig-digit + ceiling `SettleAdd` exists to protect against, not principal. Currently assumed small enough to add cleanly; not verified. -- **No invariant-check design.** Candidates implied by the design but never stated: Dust - Pseudo-account's real balance ≡ `vault.Dust`; post-`MaybeSweep`, dust custody never exceeds one - representable unit at the current effective scale; a global conservation identity across - `AssetsTotal`/`Dust`/`AssetsAvailable`. +- **No invariant-check design.** Candidates implied by the design but never stated: post-`MaybeSweep`, + dust custody never exceeds one representable unit at the current effective scale; a global + conservation identity across `AssetsTotal`/`Dust`/`AssetsAvailable`. (An earlier candidate — "Dust + Pseudo-account's real balance ≡ `vault.Dust`" — no longer applies: dropping the redundant `sfDust` + field, above, means there's only one number left, so there's nothing separate for it to disagree + with.) - **Dust-sweep timing has a narrow, bounded attribution quirk — accepted, not fixed.** A withdrawal's own settlement amount is computed against `AssetsTotal` _before_ that same transaction's - `MaybeSweep` call (§4.3) runs. If unswept `Dust` crosses the representable-unit threshold as a side - effect of this withdrawal, the withdrawer doesn't get credit for it — the next depositor's or - withdrawer's exchange rate does. Bounded to less than one representable unit by construction - (§2.2), the same order of magnitude as ordinary rounding noise elsewhere in this design; not - considered worth a dedicated fix. + `MaybeSweep` call runs. If unswept `Dust` crosses the representable-unit threshold as a side effect + of this withdrawal, the withdrawer doesn't get credit for it — the next depositor's or withdrawer's + exchange rate does. Bounded to less than one representable unit by construction (Technical Details + → _Dust: error accumulation and custody routing_), the same order of magnitude as ordinary rounding + noise elsewhere in this design; not considered worth a dedicated fix. - **`sfPrecision` immutability asserted, not enforced.** No confirmation `VaultSet` rejects attempts to change it post-creation. -- **PoC test file is stale.** Predates the `Dust` rename, eager-creation decision (§4.4.4), - terminal-withdrawal rule (§4.3), and real `accountSend` integration (§4.1–§4.3). +- **PoC test file is stale.** Predates the `Dust` rename, eager-creation decision, terminal-withdrawal + rule, and real `accountSend` integration. - **`LoanPay.cpp`'s real repayment splits three ways** (vault main custody, vault dust custody, broker payee); `SettleAdd`'s `accountSendMulti` only models the first two. Whether the broker-fee leg composes alongside `SettleAdd` or stays entirely the caller's own separate transfer was never spelled out. -## 6. Next steps +### 4.6 Next steps - Audit Loan/LoanBroker code paths for assumptions of dynamic Vault scale — confirm `loan.precision` - is captured at funding (§3.2), not recomputed per-operation. -- Resolve §5 before finalizing the dust-custody mechanism. + is captured at funding (Technical Details → _Loan issuance_), not recomputed per-operation. +- Resolve the open questions above before finalizing the dust-custody mechanism. From 570b8b3d83e90204208be35f157df9a07f3064aa Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:44:47 +0200 Subject: [PATCH 4/6] formatting --- docs/design-vault-fixed-precision.md | 690 ++++++++++++--------------- 1 file changed, 309 insertions(+), 381 deletions(-) diff --git a/docs/design-vault-fixed-precision.md b/docs/design-vault-fixed-precision.md index 75dc03293d7..88dc1dba19a 100644 --- a/docs/design-vault-fixed-precision.md +++ b/docs/design-vault-fixed-precision.md @@ -4,89 +4,78 @@ Status: draft, in progress. ## 1. Problem -Vault rounding precision is currently _derived_ from `AssetsTotal` rather than _declared_ once. -Amounts have a fixed budget of significant digits (16, §1.1); as `AssetsTotal` grows, less of that -budget is left for the fraction, so effective precision shrinks as the Vault grows: +Vault rounding precision is _derived_ from `AssetsTotal`, not _declared_. Amounts have a fixed budget +of 16 significant digits; as `AssetsTotal` grows, less budget remains for the fraction, so effective +precision shrinks as the Vault grows. + +Consequences: 1. **Unpredictable** — the same nominal payment rounds differently depending on Vault size at the moment, unrelated to the transaction. 2. **Precision degrades with success** — bigger, more successful Vaults get sloppier accounting. 3. **Cascading complexity** — Loan precision derives from Vault precision, a moving target. -4. **Non-deterministic edge cases** — dust/precision-loss depend on Vault size at operation time, +4. **Non-deterministic edge cases** — dust/precision-loss depends on Vault size at operation time, not fixed rules. 5. **No stable contract** — integrators can't know rounding precision ahead of time without inspecting live (and staleable) Vault state. -### 1.1 The Ledger & Custody Constraint - -Every Vault has two entities, both bound by the same **16 significant-digit ceiling** (rippled's -amount representation limit): - -- **SAV** — internal accounting ledger for total assets/shares. -- **Pseudo-account** — the custody account actually holding the asset. +### 1.1 The ledger & custody constraint -That 16-digit budget splits between integer and fractional parts. At high valuation (e.g. $100B) -with fine precision (e.g. 6 decimals), an amount like $1.234567 may not fit — a representation -limit, not policy. +- **SAV** (internal accounting ledger for total assets/shares) and **Pseudo-account** (the custody + account actually holding the asset) both share the same **16-significant-digit `STAmount` + ceiling**. +- That budget splits between integer and fractional parts. At high valuation (e.g. $100B) with fine + precision (e.g. 6 decimals), an amount like $1.234567 may not fit — a representation limit, not + policy. ### 1.2 Why a naive fixed precision isn't enough -Fixing one precision value at Vault creation and applying it everywhere — including internal running -totals — still hits the 16-digit wall: a repayment's interest/fee component can push `AssetsTotal` -past what a fixed precision can represent. Rejecting the transaction blocks a borrower from closing -debt for reasons unrelated to their loan. +- Fixing one precision value at creation and applying it everywhere — including internal running + totals — still hits the 16-digit wall: a repayment's interest/fee component can push `AssetsTotal` + past what a fixed precision can represent. Rejecting the transaction blocks a borrower from closing + debt for reasons unrelated to their loan. +- **Asymmetry:** optional operations (e.g. a deposit) can tolerate silent truncation; mandatory ones + (a repayment closing debt) cannot — dropping the remainder breaks the assets/shares identity or + shortchanges what's owed. -There's also an asymmetry: **optional** operations (e.g. a deposit) can tolerate silent truncation; -**mandatory** ones (a repayment closing debt) cannot — dropping the remainder breaks the -assets/shares identity or shortchanges what's owed. +**Requirement — two things that must not be conflated:** -So the fix needs two things that must not be conflated: - -- A fixed, design-time precision ceiling for client-facing operations, set once at Vault creation, - giving integrators a stable contract. -- That ceiling must _not_ also bound the Vault's internal running totals, which grow unbounded and - will eventually collide with the 16-digit ceiling if forced into the same representation as a - single transaction amount. +- A fixed, design-time precision ceiling for client-facing operations, set once at Vault creation — + a stable contract for integrators. +- That ceiling must _not_ bound the Vault's internal running totals, which grow unbounded and will + eventually collide with the 16-digit ceiling if forced into the same representation as a single + transaction amount. ## 2. Summary of the solution -Two new fields on `ltVAULT` (`sfPrecision`, `sfDustAccount`), one new reverse-link field on -`ltACCOUNT_ROOT`, one new pseudo-account per IOU Vault, and one existing, unchanged `ltLOAN` field -(`sfLoanScale`) whose Vault-side input and validation timing change (full detail in **Technical -Details**, below): - -- **`sfPrecision`** — a rounding _ceiling_, set once at Vault creation, immutable thereafter. The - Vault's effective rounding scale is the finer of that ceiling and whatever the 16-sig-digit budget - currently allows given `AssetsTotal`'s size — it only ever degrades from the ceiling as the Vault - grows, never below it. This gives integrators a stable, computable contract instead of an opaque, - ever-shifting derivation. -- **`sfDustAccount`**, a new **Dust Pseudo-account** per IOU Vault — captures the value a - _mandatory_ operation (a Loan repayment) would otherwise have to drop when the Vault's current - effective scale can no longer represent it. The representable portion settles normally; the - unrepresentable remainder transfers into the Dust Pseudo-account. There's no separate `sfDust` - counter — the account's own real balance _is_ the record of deferred value, so it can't drift out - of sync with itself. Once enough dust accumulates to be representable again, it's swept back into - ordinary custody. Nothing is ever silently dropped, and a repayment is never rejected for reasons - unrelated to the loan itself. -- **`sfLoanScale`** (existing field, existing `max`-of-two-floors formula, unchanged) — a Loan's own - rounding precision, fixed once at issuance and immutable thereafter, set to the coarser of what the - Vault can currently represent and what the Loan's own total lifetime value (principal plus all - future interest) independently needs. Both floors are knowable at issuance time, so nothing about a - Loan's precision ever needs to be recomputed later. What this design actually changes: the - Vault-side floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's raw, - unbounded scale, and a pending Loan's already-fixed value gets re-validated once, at acceptance, - against that floor — a check `LoanAccept` doesn't do today. - -Put together: optional operations (Deposit, Withdraw, `VaultClawback`) round directly to the Vault's -current effective scale and essentially never touch `Dust`. Loan funding is treated the same way, and -that's the moment a Loan's own precision gets fixed. Loan repayment is the one mandatory, two-stage -case: round to the Loan's own fixed precision, then — if the Vault has since grown coarser — split the -payment between main custody and the Dust Pseudo-account rather than truncating or rejecting. A -Vault's final withdrawal, which drains it to zero, pays out whatever `Dust` is left in full, since -there's no other shareholder left to divide it with. - -This gives the Vault a fixed, declared precision contract for anything a client interacts with, while -keeping its unbounded internal totals safely decoupled from that same 16-digit ceiling. +New/changed fields (full detail in **Technical Details**, below): + +- **`sfPrecision`** (`ltVAULT`, new) — rounding ceiling, set once at creation, immutable. Effective + scale = finer of the ceiling and whatever the 16-digit budget allows given `AssetsTotal`'s size; + degrades only downward as the Vault grows. +- **`sfDustAccount`** (`ltVAULT`, new) — a Dust Pseudo-account per IOU Vault. Catches the remainder a + _mandatory_ repayment can't represent at the current effective scale. Representable portion + settles normally; the rest transfers into this account, whose real balance is the record (no + separate counter to keep in sync). Swept back into ordinary custody once representable again. + Nothing silently dropped; no rejection for reasons unrelated to the loan. +- **`sfLoanScale`** (`ltLOAN`, existing field, existing formula) — a Loan's own precision, fixed at + issuance, immutable: the coarser of what the Vault can currently represent and what the Loan's own + lifetime value (principal + future interest) independently needs. What changes: the Vault-side + floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's raw scale, and + `LoanAccept` re-validates the already-fixed value against it — a check it doesn't do today. + +**Behavior by operation:** + +- Deposit / Withdraw / `VaultClawback` — round to current effective scale, essentially never touch + `Dust`. +- Loan funding — same rule; this is the moment `loan.precision` gets fixed. +- Loan repayment — mandatory, two-stage: round to `loan.precision`, then split between main custody + and `Dust` if the Vault has grown coarser since funding. +- Final withdrawal (drains the Vault to zero) — pays out all remaining `Dust`, since no other + shareholder is left to divide it. + +**Net effect:** a fixed, declared precision contract for client-facing operations, decoupled from the +Vault's unbounded internal totals. ## 3. Technical details @@ -99,9 +88,9 @@ keeping its unbounded internal totals safely decoupled from that same 16-digit c | `sfVaultDustID` | `ltACCOUNT_ROOT` | New designator field | `VaultCreate` (on the Dust Pseudo-account) | No | Reverse link distinguishing the Dust Pseudo-account from the main one (`sfVaultID`) — two `ACCOUNT_ROOT`s for the same Vault must stay distinguishable by field. | | `sfLoanScale` | `ltLOAN` | Existing field, existing formula | `LoanSet` (create), exactly as today | No — immutable once set | The Loan's own fixed rounding precision — see _Loan issuance_ below. Re-validated (never re-set) at `LoanAccept`. | -**Not new, but relevant:** `sfScale` (`ltVAULT`, existing) is the fixed assets↔shares conversion -exponent already used in `VaultHelpers.cpp` — unrelated to `sfPrecision` despite the similar naming; -the two must not be conflated. +**Note:** `sfScale` (`ltVAULT`, existing) is the fixed assets↔shares conversion exponent already used +in `VaultHelpers.cpp` — unrelated to `sfPrecision` despite the similar naming; the two must not be +conflated. ### 3.2 Algorithm @@ -111,92 +100,75 @@ the two must not be conflated. effective(AssetsTotal) = min(sfPrecision, maxPrecisionRepresentableGiven(AssetsTotal, 16-sig-digit budget)) ``` -Degradation is strictly downward from the ceiling — the Vault never rounds finer than `sfPrecision`. -Integrators get a deterministic contract: the finest precision ever used, computable from -`AssetsTotal` and `sfPrecision` rather than an opaque derivation. It doesn't resolve the 16-digit -collision on internal running totals — that's the Dust mechanism, next. +- Degrades only downward from the ceiling — the Vault never rounds finer than `sfPrecision`. +- Deterministic contract: the finest precision ever used is computable from `AssetsTotal` and + `sfPrecision`, not an opaque derivation. +- Doesn't resolve the 16-digit collision on internal running totals — that's the Dust mechanism, + next. #### Dust: error accumulation and custody routing -Lets a mandatory operation succeed in full when `sfPrecision` can't currently be honored, without -truncating value or rejecting the tx. - +- **Purpose:** let a mandatory operation succeed in full when `sfPrecision` can't currently be + honored, without truncating value or rejecting the tx. - **Ledger tracking:** no separate accumulator field. `Dust` _is_ the Dust Pseudo-account's real - balance (`accountHolds(vault.sle[sfDustAccount], asset)`), read directly wherever a "how much dust - exists right now" answer is needed. Whenever an amount rounds down from `sfPrecision` to the - effective scale, the dropped remainder transfers into that account — the transfer itself is the - record; there's no separate ledger number that could drift out of sync with it. (An earlier version - of this design kept a redundant `sfDust` counter on `ltVAULT` alongside the real balance — dropped: - every consuming path would have had to remember to decrement it exactly in step with the account's - real balance, and the sweep path didn't, which is exactly the kind of bug a derived, single-source - balance can't have.) -- **Custody routing:** the representable portion flows to the main Pseudo-account as normal; the - unrepresentable fraction flows to the dedicated Dust Pseudo-account for that Vault. -- **The Sweep:** once dust custody accumulates a whole representable unit, it's transferred cleanly - into the main Pseudo-account. - -Invoked only where the case-by-case rules below say so — not a general-purpose remainder catcher for -ordinary rounding. - -**`Dust` is real value, and must count as such — for both totals, not just one.** `SettleAdd` (_Core -primitives_, below) only adds the _settled_ portion into `AssetsTotal`; the dust portion sits, for -real, in the Dust Pseudo-account. If the figures exposed for NAV/share pricing and liquidity don't -include it, both silently undercount the Vault: + balance (`accountHolds(vault.sle[sfDustAccount], asset)`), read directly wherever "how much dust + exists right now" is needed. A `sfDust` counter on `ltVAULT` would be redundant with the real + balance, and dangerously so: every consuming path would have to decrement it in lockstep, and it's + easy to miss one — e.g. the sweep path. +- **Custody routing:** representable portion → main Pseudo-account, as normal; unrepresentable + fraction → the dedicated Dust Pseudo-account for that Vault. +- **The Sweep:** once dust custody accumulates a whole representable unit, transfer it cleanly into + main custody. +- **Scope:** invoked only where the case-by-case rules below say so — not a general-purpose + remainder catcher for ordinary rounding. + +**`Dust` must count in both NAV/liquidity totals, not just one:** ``` totalAssetsForPricing = AssetsTotal + Dust totalAssetsAvailable = AssetsAvailable + Dust ``` -`Dust` isn't just "extra value" NAV needs to know about — it's real, liquid money, never lent to -anyone, in an account the Vault fully controls. That's precisely what `AssetsAvailable` tracks (the -not-out-on-loan, liquid portion), so `Dust` belongs in _both_ sums: leaving it out of -`AssetsAvailable` would understate how much the Vault can actually pay out right now. - -This matters beyond bookkeeping hygiene: `AssetsTotal`/`AssetsAvailable` are each bounded by an -absolute `STAmount` ceiling, independent of `sfPrecision`'s value — once pinned there, neither can -accept further inflow at _any_ scale. `Dust`, in its own account with its own headroom, becomes a -**fallover buffer** past that point. Without folding `Dust` into both sums, that fallover regime -would be invisible — depositors' claims and withdrawal liquidity would both stop reflecting real -inflows once `AssetsTotal` maxes out, even as the Vault keeps receiving (and owing) more. - -**Why folding `Dust` into both totals doesn't cause anyone to be overpaid.** This looks, at first -glance, like it could commit the Vault to paying out value that only exists in a separate account no -withdrawal ever touches (except the terminal case, below). It doesn't, for two reasons that hold -together: - -- **Day to day, it's mathematically real but numerically invisible.** `Dust` only exists because +- `SettleAdd` (_Core primitives_, below) only adds the _settled_ portion into `AssetsTotal`; the + dust portion sits, for real, in the Dust Pseudo-account. Leaving it out of either total silently + undercounts the Vault. +- `Dust` is real, liquid money the Vault fully controls, never lent to anyone — exactly what + `AssetsAvailable` tracks. Omitting it there understates how much the Vault can actually pay out + right now. +- `AssetsTotal`/`AssetsAvailable` are each bounded by an absolute `STAmount` ceiling, independent of + `sfPrecision`. Once pinned there, neither accepts further inflow at _any_ scale — `Dust`, in its + own account with its own headroom, becomes a **fallover buffer** past that point. Without folding + it into both sums, that regime is invisible: claims and liquidity stop reflecting real inflows once + `AssetsTotal` maxes out, even as the Vault keeps receiving (and owing) more. + +**Why this doesn't overpay anyone:** + +- **Day to day, `Dust` is mathematically real but numerically invisible.** It only exists because it's smaller than one representable unit at the current `effective(AssetsTotal)` — that's the - definition of dust. Adding a sub-unit quantity into either total before computing a share price or a - withdrawal amount changes that computation by less than one representable unit — which then rounds - away in the same settlement step (`round(amount_requested, effective(AssetsTotal))`, _Case by case_ - below) that produces the amount actually paid. Including `Dust` in the formula is correct, but for a - healthy Vault it never changes what any single depositor or withdrawer actually receives. -- **Once it's not invisible, it's not `Dust` for long.** `MaybeSweep` (_Core primitives_, below) runs - after every fund-moving operation and folds any dust balance that's grown past one representable - unit straight into `AssetsTotal`/`AssetsAvailable` — at which point it's ordinary, fully-backed - Vault money, not a separate pool anything else needs to account for. The only way `Dust` sits at an - economically material level is transiently, between the moment it crosses that threshold and the - next operation that triggers a sweep. - -So both totals above are correct to include even though nothing pays out of the Dust Pseudo-account -directly in the common case — the inclusion only ever matters in the regime this design already calls -out separately: near the absolute `STAmount` ceiling, where `AssetsTotal` itself can't grow further -and `Dust`'s own headroom is what keeps the Vault's reported totals honest. + definition of dust. Including it in either total changes the result by less than one representable + unit, which rounds away in the same settlement step (`round(amount_requested, +effective(AssetsTotal))`, _Case by case_ below) that produces the amount actually paid. For a + healthy Vault it never changes what a depositor or withdrawer receives. +- **Once material, it's swept before it matters.** `MaybeSweep` (_Core primitives_, below) runs after + every fund-moving operation and folds any dust past one representable unit straight into + `AssetsTotal`/`AssetsAvailable`, where it becomes ordinary, fully-backed Vault money. `Dust` can + only sit at an economically material level transiently, between crossing that threshold and the + next sweep. +- So both totals above are correct to include even though nothing pays out of the Dust Pseudo-account + directly in the common case — the inclusion only matters near the absolute `STAmount` ceiling, + where `AssetsTotal` itself can't grow further and `Dust`'s headroom is what keeps the Vault's + reported totals honest. **Scope note on `AssetsAvailable`.** The fuller share-pricing model (`VaultSharePricing_test.cpp` -PoC) also tracks `interestUnrealized`/`lossUnrealized`, under which `AssetsTotal` and -`AssetsAvailable` can diverge further still — e.g. funding a loan moves principal out of -`AssetsAvailable` without touching `AssetsTotal`, since the loan remains an asset, just an illiquid -one. **This document doesn't model that bucket** — it's scoped to precision/dust. - -Within that narrower scope, `AssetsTotal` and `AssetsAvailable` already diverge on their own, by -construction, not as future work: `SettleAdd` moves `AssetsAvailable` by the full dust-routed -settlement (`settled` — real cash landing in custody), but moves `AssetsTotal` by a separately -supplied `assetsTotalDelta`, which is **not** `settled` for a cash-basis repayment (_Loan repayment_, -below) — Deposit is the one degenerate case where the two happen to coincide. That divergence is -required for cash-basis correctness; it's not the same divergence as the interest-accrual bucket -above, which is what's flagged in the Appendix's open questions, not resolved here. +PoC) also tracks `interestUnrealized`/`lossUnrealized`, under which `AssetsTotal`/`AssetsAvailable` +diverge further still — e.g. funding a loan moves principal out of `AssetsAvailable` without touching +`AssetsTotal`, since the loan remains an asset, just an illiquid one. **Not modeled here** — this +document is scoped to precision/dust. Within that narrower scope, the two already diverge on their +own, by construction: `SettleAdd` moves `AssetsAvailable` by the full dust-routed settlement +(`settled`), but `AssetsTotal` by a separately supplied `assetsTotalDelta`, which is **not** `settled` +for a cash-basis repayment (_Loan repayment_, below) — Deposit is the one case where the two +coincide. Required for cash-basis correctness; not the same divergence as the interest-accrual bucket +above, which stays an open question (Appendix). #### Case by case: how rounding actually works @@ -208,26 +180,22 @@ above, which is what's flagged in the Appendix's open questions, not resolved he amount_settled = round(amount_requested, effective(AssetsTotal)) ``` -This pre-rounding eliminates Case 1 (_Core primitives_, below) — `dust_0` is always `0` for these -callers, unlike repayment. It does **not** eliminate Case 2: a Deposit that pushes `AssetsTotal` -across a magnitude boundary can still coarsen the effective scale between the caller's rounding and -the moment `SettleAdd` applies it, producing `dust_1` the same way a repayment can. That's rare — it -only fires exactly at a growth boundary — but it isn't structurally impossible, so Deposit still goes -through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate dust-free path. What -_is_ true for these operations: none of them are obligations owed in full, so if Case 2 fires, it's -the caller's own requested amount that silently absorbs the truncation — never an already-agreed, -mandatory amount the way a repayment's `loan.precision` mismatch is. +- Eliminates Case 1 (_Core primitives_, below) — `dust_0` is always `0` for these callers. +- Does **not** eliminate Case 2: a Deposit that pushes `AssetsTotal` across a magnitude boundary can + still coarsen the effective scale before `SettleAdd` applies it, producing `dust_1` the same way a + repayment can — rare (only at a growth boundary), not structurally impossible. Deposit still goes + through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate dust-free path. +- None of these are obligations owed in full: if Case 2 fires, it's the caller's own requested amount + that silently absorbs the truncation — never an already-agreed, mandatory amount the way a + repayment's `loan.precision` mismatch is. ##### Loan issuance (funding) — Vault → Loan -Funding is a Vault-initiated disbursement, not a debt obligation — structurally a withdrawal. -Follows the rule above: rounds directly to the Vault's _current_ effective scale, no dust. - -Consequence: **the Loan's own precision is fixed once, at funding time, to the effective scale used -for that disbursement** — not the Vault's raw `sfPrecision` ceiling. But "the effective scale used -for that disbursement" isn't just the Vault's own scale: it's the coarser of two independent floors — -one knowable only from the Vault's current state, the other knowable in full from the Loan's own -terms, before a single payment is ever made: +- Funding is a Vault-initiated disbursement, not a debt obligation — structurally a withdrawal. + Rounds directly to the Vault's _current_ effective scale, no dust. +- Consequence: **the Loan's own precision is fixed once, at funding time**, to the coarser of two + independent floors — one knowable only from the Vault's current state, the other knowable in full + from the Loan's own terms, before a single payment is made: ``` loan.precision = max( @@ -238,131 +206,113 @@ loan.precision = max( ) ``` -The Loan-side floor is `scale(principal + all future interest, asset)` — Equation (30) of the XLS-66 -spec, `totalValueOutstanding = periodicPayment × paymentsRemaining`. `computeLoanProperties` -(`LendingHelpers.cpp`) already computes exactly this today, as -`loanScale = max(minimumScale, amount.exponent())`, where `minimumScale` is the Vault-side floor -passed in by the caller (see its "base the loan scale on the total value, since that's going to be -the biggest number involved" comment). This document's earlier formula dropped that second term — -worth stating explicitly why it's safe, and necessary, to fold back in rather than leave as an open -unknown: - -- **The Loan-side floor is fully deterministic at creation time.** Interest rate, payment interval, - and payment count are all fixed loan terms chosen at `LoanSet`, so `totalValueOutstanding` — and - therefore its natural scale — is computable synchronously, with no dependency on anything that - hasn't happened yet. A modest principal at a high rate over a long term can have - `principal + total future interest` land at a materially larger order of magnitude than the Vault's - current `AssetsTotal`, even though the principal itself came from (and is bounded by) the Vault — - nothing bounds future interest by the Vault's present size, especially under cash-basis accounting, - where `AssetsTotal` doesn't recognize interest until it's actually paid (_Loan repayment_, below). -- **The Vault-side floor is not knowable in advance.** How much _more_ the Vault's own effective - scale might degrade over the Loan's life — from other loans funding/repaying, deposits, sweeps, - other borrowers' interest accruing — depends on the whole Vault's future activity, not this Loan's - terms. That unpredictability is the actual reason `loan.precision` must be pinned immutably at - funding at all: if it were knowable in advance the way the Loan-side floor is, there'd be no need - to freeze it. - -Stored as the existing `sfLoanScale` field on `ltLOAN`, set in `LoanSet.cpp` exactly as today — -`computeLoanProperties` already computes this two-floor `max` at creation. What's new: the Vault-side -floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's raw, unbounded -`getAssetsTotalScale`; and `LoanAccept` gains a re-validation step it doesn't have today (see -_Pending Loans_ below) — the already-fixed value is checked, never recomputed or re-set, at -acceptance. - -Immutable thereafter, same as `sfPrecision` is for the Vault. Operating a Loan at finer precision -than it was actually funded with would assert false precision across all its future accounting. +- The Loan-side floor is `scale(principal + all future interest, asset)` — Equation (30) of the + XLS-66 spec, `totalValueOutstanding = periodicPayment × paymentsRemaining`. `computeLoanProperties` + (`LendingHelpers.cpp`) already computes exactly this today, as + `loanScale = max(minimumScale, amount.exponent())`, where `minimumScale` is the Vault-side floor + passed in by the caller. Both floors matter — they aren't interchangeable: + - **Loan-side is deterministic at creation.** Interest rate, payment interval, and payment count + are all fixed at `LoanSet`, so `totalValueOutstanding` — and its natural scale — is computable + synchronously. A modest principal at a high rate over a long term can have + `principal + total future interest` land at a materially larger order of magnitude than the + Vault's current `AssetsTotal`, even though the principal itself came from (and is bounded by) the + Vault — nothing bounds future interest by the Vault's present size, especially under cash-basis + accounting, where `AssetsTotal` doesn't recognize interest until it's actually paid (_Loan + repayment_, below). + - **Vault-side is not knowable in advance.** How much _more_ the Vault's effective scale might + degrade over the Loan's life — from other loans, deposits, sweeps, other borrowers' interest + accruing — depends on the whole Vault's future activity, not this Loan's terms. That's why + `loan.precision` must be pinned immutably at funding at all: if it were knowable in advance like + the Loan-side floor, there'd be no need to freeze it. +- Stored in the existing `sfLoanScale` field on `ltLOAN`, set in `LoanSet.cpp` exactly as today — + `computeLoanProperties` already computes this two-floor `max` at creation. What's new: the + Vault-side floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's + raw, unbounded `getAssetsTotalScale`; and `LoanAccept` gains a re-validation step it doesn't have + today (see _Pending Loans_, below) — the already-fixed value is checked, never recomputed or + re-set, at acceptance. +- Immutable thereafter, same as `sfPrecision` is for the Vault. Operating at finer precision than + actually funded would assert false precision across all future accounting. **Worked example.** Vault `sfPrecision` = 6. Loan A funds while `AssetsTotal` is small (effective -scale 6) and Loan A's own `totalValueOutstanding` also fits at scale 6 (short term, low rate) → -`loan.precision` = 6, the Vault-side floor binds. Later `AssetsTotal` grows enough to degrade -effective scale to 5; Loan B funds then, with a long term and high rate whose projected -`totalValueOutstanding` alone needs scale 4 → `loan.precision` = 4, the coarser of the two floors — -Loan B's _own_ terms are the binding constraint here, not the Vault's size at all. Same Vault, two -Loans, two fixed precisions, each set by whichever floor is coarser at that Loan's funding moment. +scale 6) and its own `totalValueOutstanding` also fits scale 6 (short term, low rate) → +`loan.precision` = 6, Vault-side floor binds. `AssetsTotal` later degrades effective scale to 5; Loan +B funds then, with a long term and high rate whose own `totalValueOutstanding` needs scale 4 → +`loan.precision` = 4 — Loan B's own terms bind, not the Vault's size. Same Vault, two Loans, two +fixed precisions, set by whichever floor is coarser at each funding moment. ###### Pending Loans: acceptance can straddle a scale change -Issuance is two steps: **create** (amount agreed/reserved, funds not yet moved) and **accept** -(funds move). "Funding time" above is really **acceptance time**. - -Between create and accept, `AssetsTotal` can degrade the effective scale enough that the agreed -amount is no longer representable. Only the Vault-side floor (above) can move in that window — the -Loan-side floor is already fixed at create time, from the Loan's own agreed terms, and doesn't -change while pending. Two options: - -- Silently re-round at acceptance — rejected: mutates agreed loan terms invisibly, exactly the - non-determinism this design eliminates. -- **Reject the acceptance**, forcing re-creation/re-quote — worse for the borrower short-term, but - fails loudly instead of silently mutating terms. **Chosen.** - -So a pending Loan's amount is re-validated against `effective(AssetsTotal)` at acceptance; on -failure, acceptance is rejected outright, never adjusted. - -**This is griefable, not just naturally rare — accepted anyway.** `effective(AssetsTotal)` is a -public, deterministic function of `AssetsTotal`, and a pending Loan's agreed principal/scale is -visible on-ledger from the moment it's created. Anyone able to deposit into the Vault can compute -exactly how much more `AssetsTotal` needs to grow to cross the next magnitude boundary, deposit that -amount right before the borrower's `LoanAccept`, degrade the effective scale, and force the -acceptance to fail — at essentially no cost, since the deposit isn't spent, it converts to shares the -attacker still holds (and can withdraw right back out afterward). A motivated party could grief a -specific pending Loan's acceptance repeatedly, each time forcing the borrower to eat a re-quote -(possibly at worse terms if the market's moved), for reasons unrelated to that Loan's own terms. - -Unlike the address-squatting vector below (Appendix → _Address predictability is a griefing vector -under lazy creation_) — which costs real (if modest) XRP per attempt and is single-ledger-use, and -which this document treats as worth designing around — this one is cheaper and repeatable. -**Decision: accepted risk anyway**, on the same cost/benefit basis as ordinary transaction-propagation -risk elsewhere: the failure mode is a rejected acceptance and a forced re-quote, not a loss of funds -or a stuck Vault, and building in tolerance (e.g. letting acceptance absorb one boundary crossing via -the Dust mechanism, the way repayment does, instead of hard-rejecting) is real added complexity for a -griefing outcome that's merely annoying, not unsafe. Flagged here rather than fixed. +- Issuance is two steps: **create** (terms agreed, funds not moved) and **accept** (funds move). + "Funding time" above is really acceptance time. Only the Vault-side floor can move between create + and accept — the Loan-side floor is already fixed at create time and doesn't change while pending. +- Between create and accept, `AssetsTotal` can degrade the effective scale enough that the agreed + amount is no longer representable: + - Silently re-round at acceptance — rejected: mutates agreed terms invisibly. + - **Reject the acceptance, forcing re-creation/re-quote — chosen.** Worse for the borrower + short-term, but fails loudly instead of silently mutating terms. +- So a pending Loan's amount is re-validated against `effective(AssetsTotal)` at acceptance; on + failure, acceptance is rejected outright, never adjusted. + +**Griefable, not just naturally rare — accepted anyway.** `effective(AssetsTotal)` is a public, +deterministic function of `AssetsTotal`, and a pending Loan's agreed principal/scale is visible +on-ledger from creation. Anyone able to deposit into the Vault can compute exactly how much more +`AssetsTotal` needs to grow to cross the next magnitude boundary, deposit that amount right before +the borrower's `LoanAccept`, and force the acceptance to fail — at essentially no cost, since the +deposit converts to shares the attacker still holds and can withdraw right back out. A motivated +party could grief a specific Loan's acceptance repeatedly, forcing the borrower to eat a re-quote +each time, for reasons unrelated to the Loan's own terms. + +Unlike the address-squatting vector (Appendix → _Address predictability is a griefing vector under +lazy creation_), which costs real XRP per attempt and is single-ledger-use, this one is cheaper and +repeatable — but **accepted anyway**, on the same cost/benefit basis as ordinary +transaction-propagation risk elsewhere: the failure mode is a rejected acceptance and a forced +re-quote, not a loss of funds or a stuck Vault. Tolerating one boundary crossing (e.g. via the Dust +mechanism, the way repayment does) is real added complexity for an outcome that's merely annoying, +not unsafe. Flagged here rather than fixed. ##### Loan repayment — Loan → Vault **Mandatory operation.** Two-stage: 1. Round to the Loan's own fixed `loan.precision` (above) — may already be coarser than the Vault's - current `sfPrecision` ceiling. This applies to the combined cash figure that actually settles — - principal plus interest together — not principal alone; `LoanPay.cpp` already rounds + current `sfPrecision` ceiling. Applies to the combined cash figure that actually settles — + principal plus interest together, not principal alone; `LoanPay.cpp` already rounds `principalPaid + interestPaid` as one figure before this point today. 2. Check representability at the Vault's _current_ `effective(AssetsTotal)`: - `loan.precision <= effective(AssetsTotal)`: settles in full, no dust. - - Otherwise: split. The representable portion settles into main custody; the delta routes to the - Dust Pseudo-account, recorded in `Dust`. + - Otherwise: split. Representable portion → main custody; delta → the Dust Pseudo-account, + recorded in `Dust`. The common case for Case 1 dust (_Core primitives_, below) — a loan's fixed precision going stale as -the Vault has grown since funding. The borrower's payment is always collected in full — the split is +the Vault has grown since funding. The borrower's payment is always collected in full; the split is a custody-routing detail, invisible to the borrower's accounting. -**`AssetsTotal` and custody settlement are driven by two different numbers, so `AddAssetsToVault`/ -`SettleAdd` take them as two separate parameters, not one.** `AddAssetsToVault(view, vault, from, -amount, assetsTotalDelta, j)`: +**Two separate parameters drive settlement, not one:** `AddAssetsToVault(view, vault, from, amount, +assetsTotalDelta, j)`: - `amount` — the real cash settling into custody (principal + interest combined, for a repayment). Goes through the full dust/precision-routed path (_Core primitives_, below): `AssetsAvailable` - moves by whatever of it actually lands (`settled`); any truncated remainder routes to `Dust`. + moves by whatever actually lands (`settled`); any truncated remainder routes to `Dust`. - `assetsTotalDelta` — how much `vault.AssetsTotal` itself moves. Supplied by the caller, defaulting - to `settled` when not overridden — that default is what makes Deposit's case (above) and - `MaybeSweep`'s sweep leg (_Core primitives_, below) work correctly, since for both of those, landed - custody genuinely _is_ new value to `AssetsTotal`. A cash-basis repayment is the one caller that - must override this default, passing `interestPaid` instead of letting it default to `settled` — - never dust-routed, never changing `AssetsAvailable`, no transfer of its own (bookkeeping only, - layered on the same real cash movement as `amount`). - -Why a cash-basis repayment can't just take the default: this mirrors `LendingProtocolV1_1`'s -cash-basis accounting model ([#7817](https://github.com/XRPLF/rippled/pull/7817)) — `AssetsTotal` -tracks **principal only**, as a receivable recognized once, at origination; interest is recognized -**only once paid**, not at origination. Principal cash returning at repayment must _not_ grow -`AssetsTotal` again — it's the same receivable converting from loan back to cash, not new value — -whereas `settled` (the thing `SettleAdd` defaults `assetsTotalDelta` to) is principal _and_ interest -combined. Letting `AssetsTotal` default to `settled` here, as this doc's algorithm originally did -unconditionally, double-counts principal into `AssetsTotal` on every cash-basis repayment — that was -a real bug in the original `SettleAdd` pseudocode, not just an accounting simplification, and is why -the override exists as an explicit, required parameter rather than an afterthought. - -**Open question this raises, not resolved here:** dust/precision routing exists to protect -`AssetsTotal` additions from exceeding the 16-sig-digit ceiling. Under cash-basis, `assetsTotalDelta` -(i.e. `interestPaid`) is the only thing that grows `AssetsTotal` on repayment, so it's the value that + to `settled` when not overridden — correct for Deposit (above) and `MaybeSweep`'s sweep leg (_Core + primitives_, below), where landed custody genuinely _is_ new value to `AssetsTotal`. A cash-basis + repayment is the one caller that must override this default, passing `interestPaid` instead — never + dust-routed, never changing `AssetsAvailable`, no transfer of its own (bookkeeping only, layered on + the same real cash movement as `amount`). + +**Why the override is required, not optional:** cash-basis accounting +([#7817](https://github.com/XRPLF/rippled/pull/7817), `LendingProtocolV1_1`) tracks `AssetsTotal` as +**principal only** — a receivable recognized once, at origination; interest is recognized **only +once paid**. Principal cash returning at repayment must _not_ grow `AssetsTotal` again — it's the +same receivable converting from loan back to cash, not new value — whereas `settled` (what +`SettleAdd` defaults `assetsTotalDelta` to) is principal _and_ interest combined. Defaulting +`AssetsTotal` to `settled` here, as this doc's algorithm originally did unconditionally, double-counts +principal into `AssetsTotal` on every cash-basis repayment — a real bug, not just an accounting +simplification, and why the override is an explicit, required parameter rather than an afterthought. + +**Open question, not resolved here:** dust/precision routing exists to protect `AssetsTotal` +additions from exceeding the 16-sig-digit ceiling. Under cash-basis, `assetsTotalDelta` (i.e. +`interestPaid`) is the only thing that grows `AssetsTotal` on repayment, so it's the value that could, in principle, need the same two-stage truncation `SettleAdd` already does for `amount`. This document gives it no dust routing of its own (Appendix → Open questions) — assumed small enough relative to `AssetsTotal` to add cleanly, not verified. @@ -374,32 +324,25 @@ own settlement/dust logic: **add assets to Vault**, **remove assets from Vault** ##### Add assets to Vault -**Precondition:** the caller has already rounded `amount` — the cash settlement figure — to _some_ -target scale (Deposit → `effective(AssetsTotal)`; repayment → `loan.precision`, applied to -principal+interest combined). This algorithm decides how much of `amount` custody -(`AssetsAvailable`) can absorb right now and routes the rest to `Dust`; `AssetsTotal` moves -separately, driven by the caller-supplied `assetsTotalDelta` (_Loan repayment_, above), not by this -truncation. - -Two independent, _composable_ dust sources, not exclusive cases: - -1. **Pre-existing excess** — the amount already exceeds the current effective scale `e0` (e.g. - `loan.precision > e0`). Detectable before touching `AssetsTotal`. -2. **Addition-induced coarsening** — even after trimming to `e0`, adding the result can cross a - magnitude boundary and coarsen the scale further (`e1 = effective(AssetsTotal + settled_0) < +- **Precondition:** the caller has already rounded `amount` — the cash settlement figure — to _some_ + target scale (Deposit → `effective(AssetsTotal)`; repayment → `loan.precision`, applied to + principal+interest combined). This algorithm decides how much of `amount` custody + (`AssetsAvailable`) can absorb right now and routes the rest to `Dust`; `AssetsTotal` moves + separately, driven by the caller-supplied `assetsTotalDelta` (_Loan repayment_, above). +- **Two independent, composable dust sources**, not exclusive cases: + 1. **Pre-existing excess** — the amount already exceeds the current effective scale `e0` (e.g. + `loan.precision > e0`). Detectable before touching `AssetsTotal`. + 2. **Addition-induced coarsening** — even after trimming to `e0`, adding the result can cross a + magnitude boundary and coarsen the scale further (`e1 = effective(AssetsTotal + settled_0) < e0`). - -Both can fire on one call: trim against `e0`, then trim the result again against `e1`. Converges in -one extra pass — `settled_1 <= settled_0`, so re-adding it can't coarsen below `e1`. - -The truncation/bookkeeping core is `SettleAdd`, which never sweeps; `AddAssetsToVault` wraps it with -an opportunistic sweep. Keeping them separate avoids `MaybeSweep` recursively triggering itself -(below). - -Real fund movement goes through `accountSend`/`accountSendMulti` (`TokenHelpers.h`), same as every -existing transactor. `LoanPay.cpp` already does this exact split-destination move in one call — -settling to the Vault's Pseudo-account and the broker's payee simultaneously via `accountSendMulti`. -`SettleAdd`'s main/dust split is the same shape: one sender, up to two destinations, atomic. + - Both can fire on one call: trim against `e0`, then trim the result again against `e1`. Converges + in one extra pass — `settled_1 <= settled_0`, so re-adding it can't coarsen below `e1`. +- `SettleAdd` is the truncation/bookkeeping core and never sweeps; `AddAssetsToVault` wraps it with + an opportunistic sweep. Kept separate to avoid `MaybeSweep` recursively triggering itself (below). +- Real fund movement goes through `accountSend`/`accountSendMulti` (`TokenHelpers.h`), same as every + existing transactor — `LoanPay.cpp` already does this exact split-destination move in one call, + settling to the Vault's Pseudo-account and the broker's payee simultaneously. `SettleAdd`'s + main/dust split is the same shape: one sender, up to two destinations, atomic. ``` function SettleAdd(view, vault, from, amount, assetsTotalDelta, j) -> Expected: @@ -477,12 +420,11 @@ Notes: ##### `MaybeSweep` -Opportunistically moves as much of dust custody as is currently representable back into the main -Pseudo-account/`AssetsTotal`. Called at the end of every `AddAssetsToVault`; a no-op below one -representable unit of dust. - -The dust-to-main leg is a real transfer too, so it also goes through `SettleAdd`, with the Dust -Pseudo-account as `from`. Guaranteed a no-op for XRP/MPT Vaults (no `sfDustAccount`, no balance). +- Opportunistically moves as much of dust custody as is currently representable back into the main + Pseudo-account/`AssetsTotal`. Called at the end of every `AddAssetsToVault`; a no-op below one + representable unit of dust. +- The dust-to-main leg is a real transfer too, so it also goes through `SettleAdd`, with the Dust + Pseudo-account as `from`. Guaranteed a no-op for XRP/MPT Vaults (no `sfDustAccount`, no balance). ``` function MaybeSweep(view, vault, j) -> TER: @@ -510,41 +452,36 @@ function MaybeSweep(view, vault, j) -> TER: # Net effect on dust custody is always a decrease of exactly `settled`. ``` -Why `SettleAdd`, not `AddAssetsToVault`: the latter would trigger another `MaybeSweep`, which in the -degenerate `settled == 0` case recomputes the same `candidate` against unchanged state — infinite -recursion. `SettleAdd` bounds this to one attempt; nothing else calls `MaybeSweep` from inside -itself. - -`MaybeSweep` never rejects or blocks — it strictly improves (or leaves unchanged) how much of -`AssetsTotal` is backed by main custody. +- Why `SettleAdd`, not `AddAssetsToVault`: the latter would trigger another `MaybeSweep`, which in + the degenerate `settled == 0` case recomputes the same `candidate` against unchanged state — + infinite recursion. `SettleAdd` bounds this to one attempt; nothing else calls `MaybeSweep` from + inside itself. +- `MaybeSweep` never rejects or blocks — it strictly improves (or leaves unchanged) how much of + `AssetsTotal` is backed by main custody. ##### Remove assets from Vault -**Precondition:** same shape as above; callers are Withdraw, `VaultClawback`, Loan -issuance/acceptance — all already rounded to `effective(AssetsTotal)` before calling. No Case-1 -analogue (no caller targets a different, finer precision). - -No Case-2 analogue either — the key asymmetry: removing only ever **shrinks** `AssetsTotal`, and -effective scale is non-increasing in magnitude, so `effective(AssetsTotal - amount) >= -effective(AssetsTotal)` always. A removal can only hold the scale steady or refine it, never coarsen -it. **Removing assets never generates dust.** - -Fund movement mirrors the algorithm above: a single-destination `accountSend` out of main custody, -the same shape `doWithdraw` (`View.cpp`) already uses for Withdraw/`LoanBrokerCoverWithdraw`. -Destination-specific setup (trust-line/MPToken creation, `verifyDepositPreauth`) stays the caller's -job, same split as `VaultWithdraw::doApply`/`doWithdraw` today. - -**Terminal withdrawal: all `Dust` settles to the last withdrawer.** The Dust section above -established `Dust` counts toward every shareholder's value, and also explains why that's never a -live problem for non-terminal withdrawers: `MaybeSweep` keeps `Dust` folded back into `AssetsTotal` -as soon as it's big enough to move any settlement, so anyone withdrawing while other shares remain -outstanding is pricing against a total that's already either recognized `Dust` for real or is -carrying an amount too small to change their payout. What's left once a withdrawal drains the Vault -to zero is only the residual that's _structurally_ un-sweepable — below one representable unit at -closure, with no future operation left to ever cross that threshold, and no other shareholder left to -divide it with. That's genuinely the one case needing a special rule: pay the whole residual to the -one withdrawer closing out the Vault, rather than leave it stranded forever. This also leaves `Dust` -custody at zero, which `VaultDelete` needs anyway (_Dust Pseudo-account lifecycle_, below). +- **Precondition:** same shape as above; callers are Withdraw, `VaultClawback`, Loan + issuance/acceptance — all already rounded to `effective(AssetsTotal)` before calling. No Case-1 + analogue (no caller targets a different, finer precision). +- **No Case-2 analogue either** — the key asymmetry: removing only ever **shrinks** `AssetsTotal`, + and effective scale is non-increasing in magnitude, so `effective(AssetsTotal - amount) >= +effective(AssetsTotal)` always. A removal can only hold the scale steady or refine it, never + coarsen it. **Removing assets never generates dust.** +- Fund movement mirrors the algorithm above: a single-destination `accountSend` out of main custody, + the same shape `doWithdraw` (`View.cpp`) already uses for Withdraw/`LoanBrokerCoverWithdraw`. + Destination-specific setup (trust-line/MPToken creation, `verifyDepositPreauth`) stays the + caller's job, same split as `VaultWithdraw::doApply`/`doWithdraw` today. +- **Terminal withdrawal: all `Dust` settles to the last withdrawer.** Non-terminal withdrawers never + have a live problem here (Dust section, above): `MaybeSweep` folds `Dust` back into `AssetsTotal` + as soon as it's big enough to move any settlement, so anyone withdrawing while other shares remain + outstanding is pricing against a total that's already recognized `Dust` for real, or is carrying an + amount too small to change their payout. What's left once a withdrawal drains the Vault to zero is + only the residual that's _structurally_ un-sweepable — below one representable unit at closure, + with no other shareholder left to divide it with. That's the one case needing a special rule: pay + the whole residual to the withdrawer closing out the Vault, rather than leave it stranded. This + also leaves `Dust` custody at zero, which `VaultDelete` needs anyway (_Dust Pseudo-account + lifecycle_, below). ``` function RemoveAssetsFromVault(view, vault, to, amount, j) -> Expected: @@ -575,27 +512,25 @@ function RemoveAssetsFromVault(view, vault, to, amount, j) -> Expectedkey()`; the retry loop assigns main → index 0, dust → index -1, entirely within one atomic transaction — no gap between the seed becoming known and either address -being claimed, so the griefing window described in the Appendix doesn't exist here. - -**Tracking, in full:** - -- `ltVAULT.sfAccount` → main Pseudo-account (existing, required). -- `ltVAULT.sfDustAccount` → Dust Pseudo-account (optional; present iff IOU). Set once at - `VaultCreate`, never reassigned. -- `ltACCOUNT_ROOT.sfVaultID`/`sfVaultDustID` — reverse links, distinguishing the two roles per Vault - (see Appendix → _Storage and key generation_). -- **Lifecycle symmetry:** since `sfDustAccount` is now unconditional for every IOU Vault, whatever - deletes the main Pseudo-account on `VaultDelete` must be extended to delete the Dust - Pseudo-account too, under the same invariants (e.g. zero balance). Worked out next. +- Both accounts use the same seed, `vault->key()`; the retry loop assigns main → index 0, dust → + index 1, entirely within one atomic transaction — no gap between the seed becoming known and either + address being claimed, so the griefing window described in the Appendix doesn't exist here. +- **Tracking, in full:** + - `ltVAULT.sfAccount` → main Pseudo-account (existing, required). + - `ltVAULT.sfDustAccount` → Dust Pseudo-account (optional; present iff IOU). Set once at + `VaultCreate`, never reassigned. + - `ltACCOUNT_ROOT.sfVaultID`/`sfVaultDustID` — reverse links, distinguishing the two roles per + Vault (see Appendix → _Storage and key generation_). + - **Lifecycle symmetry:** since `sfDustAccount` is now unconditional for every IOU Vault, whatever + deletes the main Pseudo-account on `VaultDelete` must be extended to delete the Dust + Pseudo-account too, under the same invariants (e.g. zero balance). Worked out next. ##### `VaultDelete`: cleaning up the Dust Pseudo-account -`VaultDelete::preclaim` already requires `sfAssetsAvailable == 0`, `sfAssetsTotal == 0`, and zero -outstanding shares before allowing deletion. The terminal-withdrawal rule above means `Dust` is -drained to zero by the same event that drives `AssetsTotal`/`SharesTotal` to zero — so by the time -`preclaim`'s checks pass, the Dust Pseudo-account should already be empty. "Should" isn't -"guaranteed by construction independent of bugs elsewhere," though, so `doApply` re-verifies it -directly, the same defensive-depth style already used for the main Pseudo-account (re-checking -balance/owner-count/directory right before erasing, even though `preclaim` already implies zero). - -`doApply` today, for the main Pseudo-account: `removeEmptyHolding` to destroy its trust-line/MPToken -holding, destroy the share issuance, confirm the pseudo-account's owner directory is empty, confirm -zero balance/owner count/no directory, erase it, then `decreaseOwnerCountForObject(view(), owner, +- `VaultDelete::preclaim` already requires `sfAssetsAvailable == 0`, `sfAssetsTotal == 0`, and zero + outstanding shares before allowing deletion. The terminal-withdrawal rule above means `Dust` is + drained to zero by the same event that drives `AssetsTotal`/`SharesTotal` to zero — so by the time + `preclaim`'s checks pass, the Dust Pseudo-account should already be empty. "Should" isn't + "guaranteed by construction independent of bugs elsewhere," though, so `doApply` re-verifies it + directly, the same defensive-depth style already used for the main Pseudo-account. +- `doApply` today, for the main Pseudo-account: `removeEmptyHolding` to destroy its trust-line/MPToken + holding, destroy the share issuance, confirm the pseudo-account's owner directory is empty, confirm + zero balance/owner count/no directory, erase it, then `decreaseOwnerCountForObject(view(), owner, vault, 2, j_)` for the two objects destroyed (Vault + main Pseudo-account). - -Extension, mirrored for the Dust Pseudo-account, conditional on `vault.sle[sfDustAccount]` being -present (IOU Vaults only): +- Extension, mirrored for the Dust Pseudo-account, conditional on `vault.sle[sfDustAccount]` being + present (IOU Vaults only): ``` function VaultDelete::doApply(): @@ -686,9 +616,9 @@ function VaultDelete::doApply(): view().erase(vault) ``` -The Dust Pseudo-account never held a share-issuance-style object of its own (only ever a plain asset -holding, same as the main account), so there's no analogue to the share-issuance-destruction block — -just the holding, the balance/owner-count/directory checks, and the erase. +- The Dust Pseudo-account never held a share-issuance-style object of its own (only ever a plain + asset holding, same as the main account), so there's no analogue to the share-issuance-destruction + block — just the holding, the balance/owner-count/directory checks, and the erase. ## 4. Appendix @@ -699,16 +629,14 @@ structurally similar to the problem the Dust mechanism solves: `adjustImpreciseN (`LendingHelpers.h`) rounds every adjustment to `DebtTotal` to the Vault's current `vaultScale`, and its own comment at the `LoanPay.cpp` call site says so plainly — "despite our best efforts, it's possible for rounding errors to accumulate in the loan broker's debt total... because the broker may -have more than one loan with significantly different scales." That's the same shape of problem: an -aggregate bounded by the 16-sig-digit ceiling, fed by inputs at multiple different fixed scales, -coarsening as it grows. Worth stating explicitly why this document doesn't extend `Dust`/`SettleAdd` -to cover it too: +have more than one loan with significantly different scales." Same shape of problem: an aggregate +bounded by the 16-sig-digit ceiling, fed by inputs at multiple different fixed scales, coarsening as +it grows. Why this document doesn't extend `Dust`/`SettleAdd` to cover it too: - **`DebtTotal` isn't custodied money.** `AssetsTotal` must reconcile exactly with a real balance — the Vault's Pseudo-account — which is why a dropped remainder needs somewhere to go (`Dust`). `DebtTotal` is a receivables _estimate_ (how much principal this broker's loans still owe); no - account balance anywhere corresponds to it, so there's nothing for its rounding error to - misplace. + account balance anywhere corresponds to it, so there's nothing for its rounding error to misplace. - **The error is symmetric, not a one-directional drain.** `Number`'s default rounding mode is `ToNearest`, so `DebtTotal`'s accumulated error random-walks rather than systematically leaking in one direction. @@ -716,8 +644,8 @@ to cover it too: its result _up_, specifically "to be conservative... ensures `CoverAvailable` never drops below the theoretical minimum" — so any drift in `DebtTotal` can only make the required cover look slightly larger than the true debt, never smaller, which is the safe direction for solvency. -- **It predates this design and isn't changed by it.** This is existing, already-accepted behavior, - not something introduced or made worse by `sfPrecision`/`Dust` — nothing in this design touches how +- **It predates this design and isn't changed by it.** Existing, already-accepted behavior, not + something introduced or made worse by `sfPrecision`/`Dust` — nothing in this design touches how `DebtTotal` is computed or rounded. **Conclusion: left as-is, deliberately, not an oversight.** From 5340b9a7a3f28df9fff73a7db8537f550f1cc955 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:33:26 +0200 Subject: [PATCH 5/6] docs: Restructure doc, fix AssetsTotal dust routing, rename variables Reorganizes the design into a tighter Problem/Summary/Technical Details(Fields, Algorithm)/Appendix layout and trims narrative prose into scannable bullets without dropping content. Closes the assetsTotalDelta open question: interest recognized on a cash-basis repayment now gets the same two-stage dust truncation as the settled custody amount, keyed to what AssetsTotal actually gains rather than what custody gains, so a large Vault can no longer round an entire interest payment to zero and lose it untracked. Renames SettleAdd/MaybeSweep's pseudocode variables (e0/e1/settled_0/ dust_1/etc.) to descriptive names throughout. --- docs/design-vault-fixed-precision.md | 218 ++++++++++++++++----------- 1 file changed, 130 insertions(+), 88 deletions(-) diff --git a/docs/design-vault-fixed-precision.md b/docs/design-vault-fixed-precision.md index 88dc1dba19a..08eea2b4018 100644 --- a/docs/design-vault-fixed-precision.md +++ b/docs/design-vault-fixed-precision.md @@ -34,7 +34,7 @@ Consequences: totals — still hits the 16-digit wall: a repayment's interest/fee component can push `AssetsTotal` past what a fixed precision can represent. Rejecting the transaction blocks a borrower from closing debt for reasons unrelated to their loan. -- **Asymmetry:** optional operations (e.g. a deposit) can tolerate silent truncation; mandatory ones +- **Asymmetry:** optional operations (e.g. a deposit) can tolerating rounding to Vault precision; mandatory ones (a repayment closing debt) cannot — dropping the remainder breaks the assets/shares identity or shortchanges what's owed. @@ -62,15 +62,14 @@ New/changed fields (full detail in **Technical Details**, below): issuance, immutable: the coarser of what the Vault can currently represent and what the Loan's own lifetime value (principal + future interest) independently needs. What changes: the Vault-side floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's raw scale, and - `LoanAccept` re-validates the already-fixed value against it — a check it doesn't do today. + `LoanAccept` re-validates the already-fixed value against it. **Behavior by operation:** -- Deposit / Withdraw / `VaultClawback` — round to current effective scale, essentially never touch - `Dust`. -- Loan funding — same rule; this is the moment `loan.precision` gets fixed. -- Loan repayment — mandatory, two-stage: round to `loan.precision`, then split between main custody - and `Dust` if the Vault has grown coarser since funding. +- Deposit / Withdraw / `VaultClawback` — round to current effective scale. +- Loan funding — same rule; this is the moment `loan.LoanScale` gets fixed. +- Loan repayment — mandatory, two-stage: round to `loan.LoanScale`, then split between main custody + and `DustAccount` if the Vault has grown coarser since funding. - Final withdrawal (drains the Vault to zero) — pays out all remaining `Dust`, since no other shareholder is left to divide it. @@ -86,7 +85,6 @@ Vault's unbounded internal totals. | `sfPrecision` | `ltVAULT` | New | `VaultCreate` | No — immutable (assertion only, not yet enforced; Appendix → Open questions) | Design-time rounding ceiling — see _Precision ceiling_ below. | | `sfDustAccount` | `ltVAULT` | New, `SoeOptional` — IOU Vaults only | `VaultCreate` | No — set once, never reassigned | Points at the Vault's Dust Pseudo-account. Its real balance, not a separate ledger field, is the authoritative amount of dust — see _Dust: error accumulation and custody routing_ below. | | `sfVaultDustID` | `ltACCOUNT_ROOT` | New designator field | `VaultCreate` (on the Dust Pseudo-account) | No | Reverse link distinguishing the Dust Pseudo-account from the main one (`sfVaultID`) — two `ACCOUNT_ROOT`s for the same Vault must stay distinguishable by field. | -| `sfLoanScale` | `ltLOAN` | Existing field, existing formula | `LoanSet` (create), exactly as today | No — immutable once set | The Loan's own fixed rounding precision — see _Loan issuance_ below. Re-validated (never re-set) at `LoanAccept`. | **Note:** `sfScale` (`ltVAULT`, existing) is the fixed assets↔shares conversion exponent already used in `VaultHelpers.cpp` — unrelated to `sfPrecision` despite the similar naming; the two must not be @@ -130,8 +128,7 @@ totalAssetsAvailable = AssetsAvailable + Dust ``` - `SettleAdd` (_Core primitives_, below) only adds the _settled_ portion into `AssetsTotal`; the - dust portion sits, for real, in the Dust Pseudo-account. Leaving it out of either total silently - undercounts the Vault. + dust portion sits in the Dust Pseudo-account. - `Dust` is real, liquid money the Vault fully controls, never lent to anyone — exactly what `AssetsAvailable` tracks. Omitting it there understates how much the Vault can actually pay out right now. @@ -159,46 +156,33 @@ effective(AssetsTotal))`, _Case by case_ below) that produces the amount actuall where `AssetsTotal` itself can't grow further and `Dust`'s headroom is what keeps the Vault's reported totals honest. -**Scope note on `AssetsAvailable`.** The fuller share-pricing model (`VaultSharePricing_test.cpp` -PoC) also tracks `interestUnrealized`/`lossUnrealized`, under which `AssetsTotal`/`AssetsAvailable` -diverge further still — e.g. funding a loan moves principal out of `AssetsAvailable` without touching -`AssetsTotal`, since the loan remains an asset, just an illiquid one. **Not modeled here** — this -document is scoped to precision/dust. Within that narrower scope, the two already diverge on their -own, by construction: `SettleAdd` moves `AssetsAvailable` by the full dust-routed settlement -(`settled`), but `AssetsTotal` by a separately supplied `assetsTotalDelta`, which is **not** `settled` -for a cash-basis repayment (_Loan repayment_, below) — Deposit is the one case where the two -coincide. Required for cash-basis correctness; not the same divergence as the interest-accrual bucket -above, which stays an open question (Appendix). - #### Case by case: how rounding actually works ##### User fund movement — Deposit, Withdraw, `VaultClawback` -**Optional operations** — round directly to the effective scale, no `sfPrecision`-first attempt: +Round directly to the effective scale, no `sfPrecision`-first attempt: ``` amount_settled = round(amount_requested, effective(AssetsTotal)) ``` -- Eliminates Case 1 (_Core primitives_, below) — `dust_0` is always `0` for these callers. +- Eliminates Case 1 (_Core primitives_, below) — `dustFromExcess` is always `0` for these callers. - Does **not** eliminate Case 2: a Deposit that pushes `AssetsTotal` across a magnitude boundary can - still coarsen the effective scale before `SettleAdd` applies it, producing `dust_1` the same way a - repayment can — rare (only at a growth boundary), not structurally impossible. Deposit still goes - through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate dust-free path. -- None of these are obligations owed in full: if Case 2 fires, it's the caller's own requested amount - that silently absorbs the truncation — never an already-agreed, mandatory amount the way a - repayment's `loan.precision` mismatch is. + still coarsen the effective scale before `SettleAdd` applies it, producing `dustFromCoarsening` the + same way a repayment can — rare (only at a growth boundary), not structurally impossible. Deposit + still goes through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate + dust-free path. ##### Loan issuance (funding) — Vault → Loan -- Funding is a Vault-initiated disbursement, not a debt obligation — structurally a withdrawal. +- Funding is a Vault-initiated disbursement, structurally a withdrawal. Rounds directly to the Vault's _current_ effective scale, no dust. - Consequence: **the Loan's own precision is fixed once, at funding time**, to the coarser of two independent floors — one knowable only from the Vault's current state, the other knowable in full from the Loan's own terms, before a single payment is made: ``` -loan.precision = max( +loan.scale = max( effective(AssetsTotal at funding time), # Vault-side floor: what the Vault can # represent right now scale(totalValueOutstanding, asset) # Loan-side floor: what this Loan's own @@ -222,7 +206,7 @@ loan.precision = max( - **Vault-side is not knowable in advance.** How much _more_ the Vault's effective scale might degrade over the Loan's life — from other loans, deposits, sweeps, other borrowers' interest accruing — depends on the whole Vault's future activity, not this Loan's terms. That's why - `loan.precision` must be pinned immutably at funding at all: if it were knowable in advance like + `loan.LoanScale` must be pinned immutably at funding at all: if it were knowable in advance like the Loan-side floor, there'd be no need to freeze it. - Stored in the existing `sfLoanScale` field on `ltLOAN`, set in `LoanSet.cpp` exactly as today — `computeLoanProperties` already computes this two-floor `max` at creation. What's new: the @@ -235,9 +219,9 @@ loan.precision = max( **Worked example.** Vault `sfPrecision` = 6. Loan A funds while `AssetsTotal` is small (effective scale 6) and its own `totalValueOutstanding` also fits scale 6 (short term, low rate) → -`loan.precision` = 6, Vault-side floor binds. `AssetsTotal` later degrades effective scale to 5; Loan +`loan.scale` = 6, Vault-side floor binds. `AssetsTotal` later degrades effective scale to 5; Loan B funds then, with a long term and high rate whose own `totalValueOutstanding` needs scale 4 → -`loan.precision` = 4 — Loan B's own terms bind, not the Vault's size. Same Vault, two Loans, two +`loan.scale` = 4 — Loan B's own terms bind, not the Vault's size. Same Vault, two Loans, two fixed precisions, set by whichever floor is coarser at each funding moment. ###### Pending Loans: acceptance can straddle a scale change @@ -274,14 +258,13 @@ not unsafe. Flagged here rather than fixed. **Mandatory operation.** Two-stage: -1. Round to the Loan's own fixed `loan.precision` (above) — may already be coarser than the Vault's +1. Round to the Loan's own fixed `loan.scale` (above) — may already be coarser than the Vault's current `sfPrecision` ceiling. Applies to the combined cash figure that actually settles — principal plus interest together, not principal alone; `LoanPay.cpp` already rounds `principalPaid + interestPaid` as one figure before this point today. 2. Check representability at the Vault's _current_ `effective(AssetsTotal)`: - - `loan.precision <= effective(AssetsTotal)`: settles in full, no dust. - - Otherwise: split. Representable portion → main custody; delta → the Dust Pseudo-account, - recorded in `Dust`. + - `loan.scale <= effective(AssetsTotal)`: settles in full, no dust. + - Otherwise: split. Representable portion → main custody; delta → the Dust Pseudo-account. The common case for Case 1 dust (_Core primitives_, below) — a loan's fixed precision going stale as the Vault has grown since funding. The borrower's payment is always collected in full; the split is @@ -296,9 +279,10 @@ assetsTotalDelta, j)`: - `assetsTotalDelta` — how much `vault.AssetsTotal` itself moves. Supplied by the caller, defaulting to `settled` when not overridden — correct for Deposit (above) and `MaybeSweep`'s sweep leg (_Core primitives_, below), where landed custody genuinely _is_ new value to `AssetsTotal`. A cash-basis - repayment is the one caller that must override this default, passing `interestPaid` instead — never - dust-routed, never changing `AssetsAvailable`, no transfer of its own (bookkeeping only, layered on - the same real cash movement as `amount`). + repayment is the one caller that must override this default, passing `interestPaid` instead. When + overridden, `assetsTotalDelta` gets its _own_ two-stage truncation, the same shape `amount` gets + (_Core primitives_, below) — it is dust-routed, just against what `AssetsTotal` itself is about to + gain, not against `amount`. **Why the override is required, not optional:** cash-basis accounting ([#7817](https://github.com/XRPLF/rippled/pull/7817), `LendingProtocolV1_1`) tracks `AssetsTotal` as @@ -310,12 +294,24 @@ same receivable converting from loan back to cash, not new value — whereas `se principal into `AssetsTotal` on every cash-basis repayment — a real bug, not just an accounting simplification, and why the override is an explicit, required parameter rather than an afterthought. -**Open question, not resolved here:** dust/precision routing exists to protect `AssetsTotal` -additions from exceeding the 16-sig-digit ceiling. Under cash-basis, `assetsTotalDelta` (i.e. -`interestPaid`) is the only thing that grows `AssetsTotal` on repayment, so it's the value that -could, in principle, need the same two-stage truncation `SettleAdd` already does for `amount`. This -document gives it no dust routing of its own (Appendix → Open questions) — assumed small enough -relative to `AssetsTotal` to add cleanly, not verified. +**Resolved: `assetsTotalDelta` needs its own dust routing, tied to `settled` — it can't just be +skipped.** Dust/precision routing exists to protect `AssetsTotal` additions from exceeding the +16-sig-digit ceiling. Under cash-basis, `assetsTotalDelta` (`interestPaid`) is the only thing that +grows `AssetsTotal` on repayment, so it's the value that actually needs the same two-stage truncation +`SettleAdd` already does for `amount` — and for a large enough Vault, this isn't a remote edge case: +a Vault near the top of its representable range can round an entire interest payment down to zero +when added to `AssetsTotal` directly. If that shortfall were simply dropped, it would vanish +completely — real cash that landed in custody (as part of `settled`) but is recognized nowhere, +neither in `AssetsTotal` nor in `Dust`. That's a strictly worse failure than any dust case `SettleAdd` +already handles, because there'd be no bucket account for it and nothing to sweep later. + +The fix: give `assetsTotalDelta` the same Case 1 + Case 2 truncation `amount` gets, but keyed to what +`AssetsTotal` actually gains (`vault.AssetsTotal + assetsTotalDelta`), not what custody gains +(`vault.AssetsTotal + settled`) — those differ precisely when the override applies. Whatever +`assetsTotalDelta` can't represent at that scale is real value that already landed in custody and +must move out of `settled` and into `dust`, keeping `settled + dust = amount` intact while ensuring +`AssetsAvailable` never silently holds more than `AssetsTotal` and `Dust` together account for. See +_Core primitives_, below, for the worked-through pseudocode. #### Core primitives @@ -325,18 +321,22 @@ own settlement/dust logic: **add assets to Vault**, **remove assets from Vault** ##### Add assets to Vault - **Precondition:** the caller has already rounded `amount` — the cash settlement figure — to _some_ - target scale (Deposit → `effective(AssetsTotal)`; repayment → `loan.precision`, applied to + target scale (Deposit → `effective(AssetsTotal)`; repayment → `loan.scale`, applied to principal+interest combined). This algorithm decides how much of `amount` custody (`AssetsAvailable`) can absorb right now and routes the rest to `Dust`; `AssetsTotal` moves - separately, driven by the caller-supplied `assetsTotalDelta` (_Loan repayment_, above). -- **Two independent, composable dust sources**, not exclusive cases: - 1. **Pre-existing excess** — the amount already exceeds the current effective scale `e0` (e.g. - `loan.precision > e0`). Detectable before touching `AssetsTotal`. - 2. **Addition-induced coarsening** — even after trimming to `e0`, adding the result can cross a - magnitude boundary and coarsen the scale further (`e1 = effective(AssetsTotal + settled_0) < -e0`). - - Both can fire on one call: trim against `e0`, then trim the result again against `e1`. Converges - in one extra pass — `settled_1 <= settled_0`, so re-adding it can't coarsen below `e1`. + separately, driven by the caller-supplied `assetsTotalDelta` (_Loan repayment_, above) — which, when + overridden, gets routed through the same truncation as `amount`, not skipped. +- **Two independent, composable dust sources**, not exclusive cases — applied to `amount` always, + and to an overridden `assetsTotalDelta` too: + 1. **Pre-existing excess** — the value already exceeds the current effective scale + `scaleBeforeAdd` (e.g. `loan.scale > scaleBeforeAdd`). Detectable before touching `AssetsTotal`. + 2. **Addition-induced coarsening** — even after trimming to `scaleBeforeAdd`, adding the result can + cross a magnitude boundary and coarsen the scale further (`scaleAfterAdd = +effective(AssetsTotal + assetsTotalDelta) < scaleBeforeAdd` — using what `AssetsTotal` actually + gains, which is `settled` by default but can differ from it when overridden). + - Both can fire on one call: trim against `scaleBeforeAdd`, then trim the result again against + `scaleAfterAdd`. Converges in one extra pass — the second truncation can't reduce a value below + what it already trimmed to, so it can't coarsen below `scaleAfterAdd`. - `SettleAdd` is the truncation/bookkeeping core and never sweeps; `AddAssetsToVault` wraps it with an opportunistic sweep. Kept separate to avoid `MaybeSweep` recursively triggering itself (below). - Real fund movement goes through `accountSend`/`accountSendMulti` (`TokenHelpers.h`), same as every @@ -356,25 +356,47 @@ function SettleAdd(view, vault, from, amount, assetsTotalDelta, j) -> Expected Exp return settledResult ``` +Worked example (the case this closes): a Vault near the top of its representable range — +`effective(AssetsTotal)` = whole units only (`scaleBeforeAdd = 0`) — receives a repayment of +principal 100 plus 37 cents of interest (`amount = 100.37`, `assetsTotalDelta = 0.37`). Under the +old, un-truncated `assetsTotalDelta`: `settled = 100`, `dust = 0.37` (from `amount`'s own Case 1), +and `vault.AssetsTotal += 0.37` would silently round to `+= 0` on the underlying `STAmount` add — the +37 cents is real cash sitting in `AssetsAvailable`, recognized nowhere. With the fix: +`deltaAfterExcess, deltaDustFromExcess = truncate(0.37, scaleBeforeAdd=0) = (0, 0.37)`; no Case 2 +boundary crossing, so `deltaShortfall = 0.37`. Final: `settled = 100 - 0.37 = 99.63`, `dust = 0.37 + +0.37 = 0.74`, `assetsTotalDelta = 0`. Check: `settled + dust = 99.63 + 0.74 = 100.37 = amount`. The +interest doesn't vanish — it joins `Dust`, tracked and sweepable, exactly like principal's own excess +already is. + +**Open edge case, not resolved here:** `deltaShortfall` is bounded to less than one representable +unit per truncation stage, same as any other dust source — but `settledAfterCoarsening` could in +principle be smaller than `deltaShortfall` for a pathological, interest-heavy repayment against a +very coarse scale (e.g. a late-fee-only payment that's almost entirely "interest" in a huge Vault), +which would make `settled` go negative. Not expected in practice given typical principal/interest +ratios, but not proven impossible either — flagged in the Appendix rather than bounded here. + Notes: - `truncate(x, scale)` rounds down only — dust is always non-negative, never created in the caller's favor. -- Deposit's precondition guarantees `dust_0 = 0`; only `dust_1` can fire for it. Repayment is the - case where `dust_0` is the common one. Same function handles both. +- Deposit's precondition guarantees `dustFromExcess = 0`; only `dustFromCoarsening` can fire for it. + Repayment is the case where `dustFromExcess` is the common one. Same function handles both. - `dust > 0` is only possible when `vault.sle[sfDustAccount]` exists — i.e. an IOU Vault (_Dust Pseudo-account lifecycle_, below). XRP/MPT Vaults never produce dust; that falls out of the truncation math, not an extra check. @@ -432,9 +473,9 @@ function MaybeSweep(view, vault, j) -> TER: if dustBalance == 0: return tesSUCCESS - e = effective(vault.AssetsTotal) - candidate = truncate(dustBalance, e) - if candidate == 0: + scale = effective(vault.AssetsTotal) + sweepable = truncate(dustBalance, scale) + if sweepable == 0: return tesSUCCESS # not enough accumulated yet # NOT AddAssetsToVault (see below). `from` is the Dust Pseudo-account @@ -442,7 +483,7 @@ function MaybeSweep(view, vault, j) -> TER: # whatever this call's own `settled` turns out to be — swept dust was # never counted in AssetsTotal to begin with, so landing it here is # exactly the "new value" default case, same as Deposit. - result = SettleAdd(view, vault, vault.sle[sfDustAccount], candidate, None, j) + result = SettleAdd(view, vault, vault.sle[sfDustAccount], sweepable, None, j) if result is error: return result.error() return tesSUCCESS # If this transfer induces further coarsening (Case 2, above), @@ -742,13 +783,14 @@ public, not which formula computes it. ### 4.5 Open questions / not yet resolved -- **Does `assetsTotalDelta` (Technical Details → _Loan repayment_ / _Add assets to Vault_) need its - own dust/precision routing?** For a cash-basis repayment it's `interestPaid`, and `SettleAdd` only - does `vault.AssetsTotal += assetsTotalDelta`, with no routing, unlike `amount`. Under cash-basis - accounting ([#7817](https://github.com/XRPLF/rippled/pull/7817)), `interestPaid` is what actually - drives `AssetsTotal` growth at repayment — so it's the value that could exceed the 16-sig-digit - ceiling `SettleAdd` exists to protect against, not principal. Currently assumed small enough to add - cleanly; not verified. +- **`SettleAdd`'s `deltaShortfall` reconciliation can drive `settled` negative in a pathological + case.** (Technical Details → _Add assets to Vault_.) Moving an overridden `assetsTotalDelta`'s own + truncation shortfall out of `settled` and into `dust` assumes `settledAfterCoarsening >= +deltaShortfall`. Not + proven for every combination of principal/interest split and Vault scale — e.g. a late-fee-only + repayment that's almost entirely "interest" against a very coarse `effective(AssetsTotal)`. Not + expected in practice given typical principal/interest ratios; needs a bound or a clamp before this + ships. - **No invariant-check design.** Candidates implied by the design but never stated: post-`MaybeSweep`, dust custody never exceeds one representable unit at the current effective scale; a global conservation identity across `AssetsTotal`/`Dust`/`AssetsAvailable`. (An earlier candidate — "Dust @@ -773,6 +815,6 @@ public, not which formula computes it. ### 4.6 Next steps -- Audit Loan/LoanBroker code paths for assumptions of dynamic Vault scale — confirm `loan.precision` +- Audit Loan/LoanBroker code paths for assumptions of dynamic Vault scale — confirm `loan.scale` is captured at funding (Technical Details → _Loan issuance_), not recomputed per-operation. - Resolve the open questions above before finalizing the dust-custody mechanism. From db4775e0fd5dcaf4b3fabd58b5d33196c4b394d1 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:26:35 +0200 Subject: [PATCH 6/6] docs: Consolidate precision design docs, fix SettleAdd algorithm Replaces the three separate documents (fixed-precision, derived- precision, and the comparison memo) with a single design-vault- precision.md that presents sfPrecision and the no-field alternative as Option A / Option B, sharing the identical mechanism once and branching only where they actually diverge. Rewrites SettleAdd to perform the real AssetsTotal addition first and read off the resulting scale, instead of independently truncating amount and assetsTotalDelta against a precomputed boundary and subtracting the shortfall afterward. The old approach could produce a settled value that wasn't aligned to the vault's scale; the new one can't, by construction, and also proves settled can never go negative. Simplifies MaybeSweep to a direct transfer instead of routing through SettleAdd, since the swept amount is already trimmed to fit before the transfer happens. --- ...precision.md => design-vault-precision.md} | 530 +++++++++++------- 1 file changed, 315 insertions(+), 215 deletions(-) rename docs/{design-vault-fixed-precision.md => design-vault-precision.md} (60%) diff --git a/docs/design-vault-fixed-precision.md b/docs/design-vault-precision.md similarity index 60% rename from docs/design-vault-fixed-precision.md rename to docs/design-vault-precision.md index 08eea2b4018..78e7952c181 100644 --- a/docs/design-vault-fixed-precision.md +++ b/docs/design-vault-precision.md @@ -1,7 +1,14 @@ -# Vault Fixed Precision — Design Notes +# Vault Precision — Design Notes Status: draft, in progress. +Two ways to fix Vault rounding precision are on the table, differing on exactly one axis: whether a +new `sfPrecision` ceiling field exists. This document presents both as **Option A** and **Option B**, +shares everything that's identical between them exactly once, and branches only at the specific +points where they actually differ — the definition of `effective(AssetsTotal)`, `loan.scale`'s +Vault-side floor, and a couple of open questions. Read §2 first if you only need the decision; §3–§4 +are the full technical spec both options build on. + ## 1. Problem Vault rounding precision is _derived_ from `AssetsTotal`, not _declared_. Amounts have a fixed budget @@ -34,66 +41,109 @@ Consequences: totals — still hits the 16-digit wall: a repayment's interest/fee component can push `AssetsTotal` past what a fixed precision can represent. Rejecting the transaction blocks a borrower from closing debt for reasons unrelated to their loan. -- **Asymmetry:** optional operations (e.g. a deposit) can tolerating rounding to Vault precision; mandatory ones - (a repayment closing debt) cannot — dropping the remainder breaks the assets/shares identity or - shortchanges what's owed. - -**Requirement — two things that must not be conflated:** - -- A fixed, design-time precision ceiling for client-facing operations, set once at Vault creation — - a stable contract for integrators. -- That ceiling must _not_ bound the Vault's internal running totals, which grow unbounded and will - eventually collide with the 16-digit ceiling if forced into the same representation as a single - transaction amount. - -## 2. Summary of the solution - -New/changed fields (full detail in **Technical Details**, below): - -- **`sfPrecision`** (`ltVAULT`, new) — rounding ceiling, set once at creation, immutable. Effective - scale = finer of the ceiling and whatever the 16-digit budget allows given `AssetsTotal`'s size; - degrades only downward as the Vault grows. -- **`sfDustAccount`** (`ltVAULT`, new) — a Dust Pseudo-account per IOU Vault. Catches the remainder a - _mandatory_ repayment can't represent at the current effective scale. Representable portion - settles normally; the rest transfers into this account, whose real balance is the record (no - separate counter to keep in sync). Swept back into ordinary custody once representable again. - Nothing silently dropped; no rejection for reasons unrelated to the loan. -- **`sfLoanScale`** (`ltLOAN`, existing field, existing formula) — a Loan's own precision, fixed at - issuance, immutable: the coarser of what the Vault can currently represent and what the Loan's own - lifetime value (principal + future interest) independently needs. What changes: the Vault-side - floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's raw scale, and - `LoanAccept` re-validates the already-fixed value against it. - -**Behavior by operation:** - -- Deposit / Withdraw / `VaultClawback` — round to current effective scale. -- Loan funding — same rule; this is the moment `loan.LoanScale` gets fixed. -- Loan repayment — mandatory, two-stage: round to `loan.LoanScale`, then split between main custody - and `DustAccount` if the Vault has grown coarser since funding. -- Final withdrawal (drains the Vault to zero) — pays out all remaining `Dust`, since no other - shareholder is left to divide it. - -**Net effect:** a fixed, declared precision contract for client-facing operations, decoupled from the -Vault's unbounded internal totals. +- **Asymmetry:** optional operations (e.g. a deposit) can tolerate silent truncation to whatever the + Vault's current scale allows; mandatory ones (a repayment closing debt) cannot — dropping the + remainder breaks the assets/shares identity or shortchanges what's owed. + +**Requirement:** decoupling the Vault's unbounded internal running totals from whatever gets exposed +to client-facing operations, so a repayment's interest/fee component can never collide with the +16-digit ceiling and force a rejection unrelated to the loan. Both options below solve this identically +(§3.2, _Dust_). They diverge on a second, narrower question: whether to also give the Vault a +_declared_, design-time precision ceiling (resolving item 5 above at the Vault level), or to accept +that gap in exchange for one fewer field. That's the whole decision — see §2. + +## 2. Two design options + +### 2.1 Option A: `sfPrecision` ceiling + +``` +effective(AssetsTotal) = min(sfPrecision, maxPrecisionRepresentableGiven(AssetsTotal, 16-sig-digit budget)) +``` + +A new, immutable `ltVAULT` field set at creation gives the Vault a declared rounding ceiling; +effective scale can only degrade downward from it as `AssetsTotal` grows, never below it. + +**Advantages** + +- **A real policy knob.** A Vault creator can choose a ceiling deliberately coarser than the digit + budget would otherwise force, independent of `AssetsTotal`'s size — not available at all without + the field. +- **A declared contract, not just an inspectable one.** An integrator knows the _finest_ precision a + Vault will ever use the moment it's created, without touching live state. +- **Bounds `loan.scale`'s Vault-side floor.** A new Loan's Vault-side floor can never be coarser than + `sfPrecision`, capping how imprecise a Loan can ever become. + +**Disadvantages** + +- **It's a one-sided bound, not a fixed guarantee.** Effective scale still degrades _below_ + `sfPrecision` as `AssetsTotal` grows — the field caps the best case, it doesn't pin the actual + behavior. Problem item 1 ("unpredictable") is only partly addressed. +- **Requires an actual formula change from what's shipped.** `loan.scale`'s Vault-side floor moves + from today's raw, unbounded `getAssetsTotalScale` to `effective(AssetsTotal)` bounded by + `sfPrecision` — new code, not a reuse of an existing computation. + +### 2.2 Option B: derived precision (no `sfPrecision`) + +``` +effective(AssetsTotal) = maxPrecisionRepresentableGiven(AssetsTotal, 16-sig-digit budget) +``` + +The same raw derivation `getAssetsTotalScale` already performs today. No new ceiling field; +everything else (Dust, `loan.scale`, the lifecycle, the griefing analysis) is identical to Option A. + +**Advantages** + +- **No new field, no new state to design, store, or enforce.** One field and one open question + (unenforced immutability) fewer than Option A. +- **Zero code change for `loan.scale`'s Vault-side floor.** It already _is_ + `effective(AssetsTotal)` as this option defines it — `computeLoanProperties` needs no formula + change at all, only the acceptance-time re-validation both options add. +- **Nothing hidden.** `effective(AssetsTotal)` is still a live, fully inspectable quantity — this + option removes a guarantee, not visibility. + +**Disadvantages** + +- **No Vault-level stable contract.** Problem item 5 stands unresolved for anyone interacting with + the Vault directly (Deposit, Withdraw, a new Loan's Vault-side floor) rather than through an + already-funded Loan. An integrator still can't know a Vault's rounding precision ahead of time + without inspecting live state — exactly what Option A was built to fix. +- **The Vault-side floor has no ceiling at all.** It can keep degrading indefinitely as the Vault + grows; Option A at least caps how coarse a new Loan's floor can become. + +### 2.3 Comparison + +| | Option A (`sfPrecision`) | Option B (derived) | +| ------------------------------------------ | ------------------------------------------------ | ----------------------------------------- | +| New fields | `sfPrecision` + `sfDustAccount`, `sfVaultDustID` | `sfDustAccount`, `sfVaultDustID` only | +| Vault-level stable contract | Yes, partial (a ceiling, not a fixed value) | No | +| Loan-level stable contract (`loan.scale`) | Yes | Yes | +| `loan.scale` Vault-side floor code change | New: bounded by `sfPrecision` | None: matches `getAssetsTotalScale` today | +| Open questions unique to this option | `sfPrecision` immutability unenforced | Whether the Vault-level gap is acceptable | +| Mandatory operations never fail/lose value | Yes (Dust) | Yes (Dust, identical) | ## 3. Technical details ### 3.1 Fields -| Field | Object | Notes | Set | Mutable | Purpose | -| --------------- | ---------------- | ------------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `sfPrecision` | `ltVAULT` | New | `VaultCreate` | No — immutable (assertion only, not yet enforced; Appendix → Open questions) | Design-time rounding ceiling — see _Precision ceiling_ below. | -| `sfDustAccount` | `ltVAULT` | New, `SoeOptional` — IOU Vaults only | `VaultCreate` | No — set once, never reassigned | Points at the Vault's Dust Pseudo-account. Its real balance, not a separate ledger field, is the authoritative amount of dust — see _Dust: error accumulation and custody routing_ below. | -| `sfVaultDustID` | `ltACCOUNT_ROOT` | New designator field | `VaultCreate` (on the Dust Pseudo-account) | No | Reverse link distinguishing the Dust Pseudo-account from the main one (`sfVaultID`) — two `ACCOUNT_ROOT`s for the same Vault must stay distinguishable by field. | +| Field | Object | Applies to | Notes | Set | Mutable | Purpose | +| --------------- | ---------------- | ------------- | ------------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sfPrecision` | `ltVAULT` | Option A only | New | `VaultCreate` | No — immutable (assertion only, not yet enforced; Appendix → Open questions) | Design-time rounding ceiling — see _Precision ceiling_ below. | +| `sfDustAccount` | `ltVAULT` | Both | New, `SoeOptional` — IOU Vaults only | `VaultCreate` | No — set once, never reassigned | Points at the Vault's Dust Pseudo-account. Its real balance, not a separate ledger field, is the authoritative amount of dust — see _Dust: error accumulation and custody routing_ below. | +| `sfVaultDustID` | `ltACCOUNT_ROOT` | Both | New designator field | `VaultCreate` (on the Dust Pseudo-account) | No | Reverse link distinguishing the Dust Pseudo-account from the main one (`sfVaultID`) — two `ACCOUNT_ROOT`s for the same Vault must stay distinguishable by field. | +| `sfLoanScale` | `ltLOAN` | Both | Existing field, existing formula | `LoanSet` (create), exactly as today | No — immutable once set | The Loan's own fixed rounding precision — see _Loan issuance_ below. Vault-side floor's definition differs by option; re-validated (never re-set) at `LoanAccept` in both. | **Note:** `sfScale` (`ltVAULT`, existing) is the fixed assets↔shares conversion exponent already used -in `VaultHelpers.cpp` — unrelated to `sfPrecision` despite the similar naming; the two must not be -conflated. +in `VaultHelpers.cpp` — unrelated to `sfPrecision`/`effective(AssetsTotal)` despite the similar +naming; the two must not be conflated. ### 3.2 Algorithm #### Precision ceiling: `effective(AssetsTotal)` +This is the one place the two options actually branch: + +**Option A:** + ``` effective(AssetsTotal) = min(sfPrecision, maxPrecisionRepresentableGiven(AssetsTotal, 16-sig-digit budget)) ``` @@ -101,12 +151,25 @@ effective(AssetsTotal) = min(sfPrecision, maxPrecisionRepresentableGiven(AssetsT - Degrades only downward from the ceiling — the Vault never rounds finer than `sfPrecision`. - Deterministic contract: the finest precision ever used is computable from `AssetsTotal` and `sfPrecision`, not an opaque derivation. -- Doesn't resolve the 16-digit collision on internal running totals — that's the Dust mechanism, - next. + +**Option B:** + +``` +effective(AssetsTotal) = maxPrecisionRepresentableGiven(AssetsTotal, 16-sig-digit budget) +``` + +- Purely derived from `AssetsTotal`'s current magnitude — no declared ceiling to take a `min` + against. This is the same raw derivation `getAssetsTotalScale` performs today. +- Not a deterministic, declarable contract — an integrator has to read live Vault state to know it + (§2.2). + +Everything from here on refers to `effective(AssetsTotal)` generically; the rest of this section +doesn't care which definition is in effect. Neither option resolves the 16-digit collision on +internal running totals by itself — that's the Dust mechanism, next, and it's identical either way. #### Dust: error accumulation and custody routing -- **Purpose:** let a mandatory operation succeed in full when `sfPrecision` can't currently be +- **Purpose:** let a mandatory operation succeed in full when the current effective scale can't be honored, without truncating value or rejecting the tx. - **Ledger tracking:** no separate accumulator field. `Dust` _is_ the Dust Pseudo-account's real balance (`accountHolds(vault.sle[sfDustAccount], asset)`), read directly wherever "how much dust @@ -132,11 +195,12 @@ totalAssetsAvailable = AssetsAvailable + Dust - `Dust` is real, liquid money the Vault fully controls, never lent to anyone — exactly what `AssetsAvailable` tracks. Omitting it there understates how much the Vault can actually pay out right now. -- `AssetsTotal`/`AssetsAvailable` are each bounded by an absolute `STAmount` ceiling, independent of - `sfPrecision`. Once pinned there, neither accepts further inflow at _any_ scale — `Dust`, in its - own account with its own headroom, becomes a **fallover buffer** past that point. Without folding - it into both sums, that regime is invisible: claims and liquidity stop reflecting real inflows once - `AssetsTotal` maxes out, even as the Vault keeps receiving (and owing) more. +- `AssetsTotal`/`AssetsAvailable` are each bounded by an absolute `STAmount` ceiling, regardless of + what precision is currently in effect. Once pinned there, neither accepts further inflow at _any_ + scale — `Dust`, in its own account with its own headroom, becomes a **fallover buffer** past that + point. Without folding it into both sums, that regime is invisible: claims and liquidity stop + reflecting real inflows once `AssetsTotal` maxes out, even as the Vault keeps receiving (and owing) + more. **Why this doesn't overpay anyone:** @@ -160,23 +224,22 @@ effective(AssetsTotal))`, _Case by case_ below) that produces the amount actuall ##### User fund movement — Deposit, Withdraw, `VaultClawback` -Round directly to the effective scale, no `sfPrecision`-first attempt: +Round directly to the effective scale: ``` amount_settled = round(amount_requested, effective(AssetsTotal)) ``` -- Eliminates Case 1 (_Core primitives_, below) — `dustFromExcess` is always `0` for these callers. -- Does **not** eliminate Case 2: a Deposit that pushes `AssetsTotal` across a magnitude boundary can - still coarsen the effective scale before `SettleAdd` applies it, producing `dustFromCoarsening` the - same way a repayment can — rare (only at a growth boundary), not structurally impossible. Deposit - still goes through `SettleAdd`/`AddAssetsToVault` like every other operation, not a separate - dust-free path. +This pre-rounding doesn't guarantee `SettleAdd` (_Core primitives_, below) produces zero dust: a +Deposit that pushes `AssetsTotal` across a magnitude boundary can still coarsen the effective scale +between this rounding and the moment `SettleAdd` applies it — rare (only at a growth boundary), not +structurally impossible. Deposit still goes through `SettleAdd`/`AddAssetsToVault` like every other +operation, not a separate dust-free path. ##### Loan issuance (funding) — Vault → Loan -- Funding is a Vault-initiated disbursement, structurally a withdrawal. - Rounds directly to the Vault's _current_ effective scale, no dust. +- Funding is a Vault-initiated disbursement, structurally a withdrawal. Rounds directly to the + Vault's _current_ effective scale, no dust. - Consequence: **the Loan's own precision is fixed once, at funding time**, to the coarser of two independent floors — one knowable only from the Vault's current state, the other knowable in full from the Loan's own terms, before a single payment is made: @@ -184,7 +247,8 @@ amount_settled = round(amount_requested, effective(AssetsTotal)) ``` loan.scale = max( effective(AssetsTotal at funding time), # Vault-side floor: what the Vault can - # represent right now + # represent right now (Option A: bounded by + # sfPrecision; Option B: unbounded) scale(totalValueOutstanding, asset) # Loan-side floor: what this Loan's own # projected lifetime value needs ) @@ -206,23 +270,31 @@ loan.scale = max( - **Vault-side is not knowable in advance.** How much _more_ the Vault's effective scale might degrade over the Loan's life — from other loans, deposits, sweeps, other borrowers' interest accruing — depends on the whole Vault's future activity, not this Loan's terms. That's why - `loan.LoanScale` must be pinned immutably at funding at all: if it were knowable in advance like - the Loan-side floor, there'd be no need to freeze it. + `loan.scale` must be pinned immutably at funding at all: if it were knowable in advance like the + Loan-side floor, there'd be no need to freeze it. Under Option B, this floor also has no upper + bound at all — it can keep degrading indefinitely as the Vault grows; Option A's `sfPrecision` + caps how coarse it can ever get. - Stored in the existing `sfLoanScale` field on `ltLOAN`, set in `LoanSet.cpp` exactly as today — - `computeLoanProperties` already computes this two-floor `max` at creation. What's new: the - Vault-side floor becomes `effective(AssetsTotal)` (bounded by `sfPrecision`) instead of today's - raw, unbounded `getAssetsTotalScale`; and `LoanAccept` gains a re-validation step it doesn't have - today (see _Pending Loans_, below) — the already-fixed value is checked, never recomputed or - re-set, at acceptance. -- Immutable thereafter, same as `sfPrecision` is for the Vault. Operating at finer precision than - actually funded would assert false precision across all future accounting. - -**Worked example.** Vault `sfPrecision` = 6. Loan A funds while `AssetsTotal` is small (effective -scale 6) and its own `totalValueOutstanding` also fits scale 6 (short term, low rate) → -`loan.scale` = 6, Vault-side floor binds. `AssetsTotal` later degrades effective scale to 5; Loan -B funds then, with a long term and high rate whose own `totalValueOutstanding` needs scale 4 → -`loan.scale` = 4 — Loan B's own terms bind, not the Vault's size. Same Vault, two Loans, two -fixed precisions, set by whichever floor is coarser at each funding moment. + `computeLoanProperties` already computes this two-floor `max` at creation. What's new depends on + the option: **Option A** changes the Vault-side floor from today's raw, unbounded + `getAssetsTotalScale` to `effective(AssetsTotal)` bounded by `sfPrecision` — an actual formula + change. **Option B** needs no such change at all — the Vault-side floor it uses + (`getAssetsTotalScale`) already _is_ `effective(AssetsTotal)` as this option defines it. Both + options add the same new step: `LoanAccept` gains a re-validation it doesn't have today (see + _Pending Loans_, below) — the already-fixed value is checked, never recomputed or re-set, at + acceptance. +- Immutable thereafter (Option A: same as `sfPrecision` is for the Vault). Operating at finer + precision than actually funded would assert false precision across all future accounting. + +**Worked example.** Suppose the Vault's effective scale is 6 when Loan A funds, and Loan A's own +`totalValueOutstanding` also fits scale 6 (short term, low rate) → `loan.scale` = 6, Vault-side floor +binds. `AssetsTotal` later grows enough to degrade the effective scale to 5; Loan B funds then, with +a long term and high rate whose own `totalValueOutstanding` needs scale 4 → `loan.scale` = 4 — Loan +B's own terms bind, not the Vault's size. Same Vault, two Loans, two fixed precisions, set by +whichever floor is coarser at each funding moment. (Under Option A, if `sfPrecision` itself were set +coarser than 6 — say 4 — Loan A's floor would already have been 4 from the start, independent of +`AssetsTotal`'s size at the time; under Option B there's no such ceiling to short-circuit the +derivation.) ###### Pending Loans: acceptance can straddle a scale change @@ -244,7 +316,9 @@ on-ledger from creation. Anyone able to deposit into the Vault can compute exact the borrower's `LoanAccept`, and force the acceptance to fail — at essentially no cost, since the deposit converts to shares the attacker still holds and can withdraw right back out. A motivated party could grief a specific Loan's acceptance repeatedly, forcing the borrower to eat a re-quote -each time, for reasons unrelated to the Loan's own terms. +each time, for reasons unrelated to the Loan's own terms. This is identical under both options — +dropping `sfPrecision` doesn't make the attack any easier or harder, since it only ever depends on +crossing a magnitude boundary in `AssetsTotal`, never on where any declared ceiling sits. Unlike the address-squatting vector (Appendix → _Address predictability is a griefing vector under lazy creation_), which costs real XRP per attempt and is single-ledger-use, this one is cheaper and @@ -259,16 +333,18 @@ not unsafe. Flagged here rather than fixed. **Mandatory operation.** Two-stage: 1. Round to the Loan's own fixed `loan.scale` (above) — may already be coarser than the Vault's - current `sfPrecision` ceiling. Applies to the combined cash figure that actually settles — - principal plus interest together, not principal alone; `LoanPay.cpp` already rounds - `principalPaid + interestPaid` as one figure before this point today. + current effective scale. Applies to the combined cash figure that actually settles — principal + plus interest together, not principal alone; `LoanPay.cpp` already rounds `principalPaid + +interestPaid` as one figure before this point today. 2. Check representability at the Vault's _current_ `effective(AssetsTotal)`: - `loan.scale <= effective(AssetsTotal)`: settles in full, no dust. - Otherwise: split. Representable portion → main custody; delta → the Dust Pseudo-account. The common case for Case 1 dust (_Core primitives_, below) — a loan's fixed precision going stale as -the Vault has grown since funding. The borrower's payment is always collected in full; the split is -a custody-routing detail, invisible to the borrower's accounting. +the Vault has grown since funding. (Reachable sooner under Option B, which has no ceiling capping how +coarse a young, small Vault's scale needed to be relative to an older, larger one.) The borrower's +payment is always collected in full; the split is a custody-routing detail, invisible to the +borrower's accounting. **Two separate parameters drive settlement, not one:** `AddAssetsToVault(view, vault, from, amount, assetsTotalDelta, j)`: @@ -277,12 +353,14 @@ assetsTotalDelta, j)`: Goes through the full dust/precision-routed path (_Core primitives_, below): `AssetsAvailable` moves by whatever actually lands (`settled`); any truncated remainder routes to `Dust`. - `assetsTotalDelta` — how much `vault.AssetsTotal` itself moves. Supplied by the caller, defaulting - to `settled` when not overridden — correct for Deposit (above) and `MaybeSweep`'s sweep leg (_Core - primitives_, below), where landed custody genuinely _is_ new value to `AssetsTotal`. A cash-basis - repayment is the one caller that must override this default, passing `interestPaid` instead. When - overridden, `assetsTotalDelta` gets its _own_ two-stage truncation, the same shape `amount` gets - (_Core primitives_, below) — it is dust-routed, just against what `AssetsTotal` itself is about to - gain, not against `amount`. + to `amount` itself when not overridden — correct for Deposit (above), where landed custody + genuinely _is_ new value to `AssetsTotal`. A cash-basis repayment is the one caller that must + override this default, passing `interestPaid` instead. Either way, it's added to `AssetsTotal` for + real first, and whatever of it doesn't land (`deltaLost`, _Core primitives_, below) comes out of + `amount` before `amount` settles into custody — so it's still dust-routed, just against what + `AssetsTotal` itself actually gains, not against `amount`. (`MaybeSweep`, below, is the one caller + that skips this whole primitive — its `sweepable` is already trimmed to fit before the transfer, so + there's nothing to reconcile.) **Why the override is required, not optional:** cash-basis accounting ([#7817](https://github.com/XRPLF/rippled/pull/7817), `LendingProtocolV1_1`) tracks `AssetsTotal` as @@ -290,9 +368,11 @@ assetsTotalDelta, j)`: once paid**. Principal cash returning at repayment must _not_ grow `AssetsTotal` again — it's the same receivable converting from loan back to cash, not new value — whereas `settled` (what `SettleAdd` defaults `assetsTotalDelta` to) is principal _and_ interest combined. Defaulting -`AssetsTotal` to `settled` here, as this doc's algorithm originally did unconditionally, double-counts -principal into `AssetsTotal` on every cash-basis repayment — a real bug, not just an accounting -simplification, and why the override is an explicit, required parameter rather than an afterthought. +`AssetsTotal` to `settled` here, as an earlier draft of this algorithm did unconditionally, +double-counts principal into `AssetsTotal` on every cash-basis repayment — a real bug, not just an +accounting simplification, and why the override is an explicit, required parameter rather than an +afterthought. Nothing about this reasoning depends on which precision option is in effect — it +applies identically under both. **Resolved: `assetsTotalDelta` needs its own dust routing, tied to `settled` — it can't just be skipped.** Dust/precision routing exists to protect `AssetsTotal` additions from exceeding the @@ -303,7 +383,10 @@ a Vault near the top of its representable range can round an entire interest pay when added to `AssetsTotal` directly. If that shortfall were simply dropped, it would vanish completely — real cash that landed in custody (as part of `settled`) but is recognized nowhere, neither in `AssetsTotal` nor in `Dust`. That's a strictly worse failure than any dust case `SettleAdd` -already handles, because there'd be no bucket account for it and nothing to sweep later. +already handles, because there'd be no bucket account for it and nothing to sweep later. This risk is +if anything _more_ pressing under Option B: without `sfPrecision` capping how coarse `AssetsTotal`'s +scale can get, a very large, very successful Vault degrades further than Option A ever allows, making +the "entire interest payment rounds to zero" scenario easier to reach, not harder. The fix: give `assetsTotalDelta` the same Case 1 + Case 2 truncation `amount` gets, but keyed to what `AssetsTotal` actually gains (`vault.AssetsTotal + assetsTotalDelta`), not what custody gains @@ -316,7 +399,9 @@ _Core primitives_, below, for the worked-through pseudocode. #### Core primitives Two shared primitives, invoked by every fund-moving operation above instead of each implementing its -own settlement/dust logic: **add assets to Vault**, **remove assets from Vault**. +own settlement/dust logic: **add assets to Vault**, **remove assets from Vault**. Identical under +both options — nothing here reads `sfPrecision` directly; everything only ever calls `effective()`, +which is defined differently per option (above) but has the same call shape either way. ##### Add assets to Vault @@ -324,21 +409,19 @@ own settlement/dust logic: **add assets to Vault**, **remove assets from Vault** target scale (Deposit → `effective(AssetsTotal)`; repayment → `loan.scale`, applied to principal+interest combined). This algorithm decides how much of `amount` custody (`AssetsAvailable`) can absorb right now and routes the rest to `Dust`; `AssetsTotal` moves - separately, driven by the caller-supplied `assetsTotalDelta` (_Loan repayment_, above) — which, when - overridden, gets routed through the same truncation as `amount`, not skipped. -- **Two independent, composable dust sources**, not exclusive cases — applied to `amount` always, - and to an overridden `assetsTotalDelta` too: - 1. **Pre-existing excess** — the value already exceeds the current effective scale - `scaleBeforeAdd` (e.g. `loan.scale > scaleBeforeAdd`). Detectable before touching `AssetsTotal`. - 2. **Addition-induced coarsening** — even after trimming to `scaleBeforeAdd`, adding the result can - cross a magnitude boundary and coarsen the scale further (`scaleAfterAdd = -effective(AssetsTotal + assetsTotalDelta) < scaleBeforeAdd` — using what `AssetsTotal` actually - gains, which is `settled` by default but can differ from it when overridden). - - Both can fire on one call: trim against `scaleBeforeAdd`, then trim the result again against - `scaleAfterAdd`. Converges in one extra pass — the second truncation can't reduce a value below - what it already trimmed to, so it can't coarsen below `scaleAfterAdd`. + separately, driven by the caller-supplied `assetsTotalDelta` (_Loan repayment_, above) — not + exempt from precision limits just because it's overridden. +- **One truncation, not two.** `assetsTotalDelta` (default: `amount` itself) is added to + `AssetsTotal` for real, and whatever scale that addition actually lands at — + `effective(AssetsTotal + assetsTotalDelta)` — is the scale `amount` gets rounded to for custody. + This single step covers what used to need two separate cases (the value already exceeding the + Vault's current scale before touching `AssetsTotal`, and the addition itself crossing a magnitude + boundary): the real addition's resulting scale already reflects whichever one actually happened, + so there's nothing left to detect separately. - `SettleAdd` is the truncation/bookkeeping core and never sweeps; `AddAssetsToVault` wraps it with - an opportunistic sweep. Kept separate to avoid `MaybeSweep` recursively triggering itself (below). + an opportunistic sweep. Kept as two functions for readability — settlement logic versus + settlement-plus-sweep — not to avoid recursion: `MaybeSweep` (below) doesn't call either one, so + there's no cycle to break in the first place. - Real fund movement goes through `accountSend`/`accountSendMulti` (`TokenHelpers.h`), same as every existing transactor — `LoanPay.cpp` already does this exact split-destination move in one call, settling to the Vault's Pseudo-account and the broker's payee simultaneously. `SettleAdd`'s @@ -350,53 +433,39 @@ function SettleAdd(view, vault, from, amount, assetsTotalDelta, j) -> Expected Expected Exp ``` Worked example (the case this closes): a Vault near the top of its representable range — -`effective(AssetsTotal)` = whole units only (`scaleBeforeAdd = 0`) — receives a repayment of -principal 100 plus 37 cents of interest (`amount = 100.37`, `assetsTotalDelta = 0.37`). Under the -old, un-truncated `assetsTotalDelta`: `settled = 100`, `dust = 0.37` (from `amount`'s own Case 1), -and `vault.AssetsTotal += 0.37` would silently round to `+= 0` on the underlying `STAmount` add — the -37 cents is real cash sitting in `AssetsAvailable`, recognized nowhere. With the fix: -`deltaAfterExcess, deltaDustFromExcess = truncate(0.37, scaleBeforeAdd=0) = (0, 0.37)`; no Case 2 -boundary crossing, so `deltaShortfall = 0.37`. Final: `settled = 100 - 0.37 = 99.63`, `dust = 0.37 + -0.37 = 0.74`, `assetsTotalDelta = 0`. Check: `settled + dust = 99.63 + 0.74 = 100.37 = amount`. The -interest doesn't vanish — it joins `Dust`, tracked and sweepable, exactly like principal's own excess -already is. - -**Open edge case, not resolved here:** `deltaShortfall` is bounded to less than one representable -unit per truncation stage, same as any other dust source — but `settledAfterCoarsening` could in -principle be smaller than `deltaShortfall` for a pathological, interest-heavy repayment against a -very coarse scale (e.g. a late-fee-only payment that's almost entirely "interest" in a huge Vault), -which would make `settled` go negative. Not expected in practice given typical principal/interest -ratios, but not proven impossible either — flagged in the Appendix rather than bounded here. +`effective(AssetsTotal)` = whole units only — receives a repayment of principal 100 plus 37 cents of +interest (`amount = 100.37`, `assetsTotalDelta = 0.37`). Under the old, un-truncated +`assetsTotalDelta`: `settled = 100`, `dust = 0.37` (from `amount`'s own truncation), and +`vault.AssetsTotal += 0.37` would silently round to `+= 0` on the underlying `STAmount` add — the 37 +cents is real cash sitting in `AssetsAvailable`, recognized nowhere. With the fix: +`assetsTotalAfterAdd = truncate(AssetsTotal + 0.37, whole units) = AssetsTotal` (unchanged) — +`deltaActual = 0`, `deltaLost = 0.37`. `wantToSettle = 100.37 - 0.37 = 100`, `settled = +truncate(100, 0) = 100`, `dust = 0.37`. Check: `settled + dust = 100.37 = amount`. The interest +doesn't vanish — it joins `Dust`, tracked and sweepable. (Under Option B, a Vault can reach this +scale — or coarser — purely by growing, with no `sfPrecision` ever forcing it to stop earlier; under +Option A this scenario requires `sfPrecision` to be set high enough to permit it in the first +place.) + +A second worked example, showing why `round_down` must be the _last_ step: same Vault, but +`principal = 99.63`, `interest = 0.74` (`amount` is still `100.37`, `assetsTotalDelta = 0.74`). +`assetsTotalAfterAdd = truncate(AssetsTotal + 0.74, whole units) = AssetsTotal` — `deltaActual = 0`, +`deltaLost = 0.74`. `wantToSettle = 100.37 - 0.74 = 99.63`, `settled = truncate(99.63, 0) = 99`, +`dust = 1.37`. `settled` is a whole number, as the Vault's current scale requires — an earlier +version of this algorithm truncated `amount` to scale first (giving `100`) and _then_ subtracted +`deltaLost` (`100 - 0.74 = 99.26`), which is not a whole number at all: a real correctness bug, not +just an inelegance, since `settled` is what lands in `AssetsAvailable` and the entire model assumes +it's always exactly representable at the Vault's current scale. Truncating `wantToSettle` once, last, +is slightly more conservative (`99` here, versus a theoretical minimum of `99.26`, wasting a little +extra into `Dust`), but that's consistent with `truncate`'s existing bias — dust is never created in +the caller's favor — and the "waste" is only ever transient, since `MaybeSweep` reclaims it as soon +as it's representable again. + +This formula also resolves what was previously an open, unbounded edge case: could `settled` go +negative for a pathological, interest-heavy repayment? No — `deltaLost < candidateDelta <= amount` +always holds (a cash-basis repayment's `assetsTotalDelta` is `interestPaid`, which can never exceed +`amount = principalPaid + interestPaid`), so `wantToSettle = amount - deltaLost` is always `>= 0`, +and `truncate` of a non-negative value stays non-negative. `settled` can never go negative under this +formula. Notes: - `truncate(x, scale)` rounds down only — dust is always non-negative, never created in the caller's favor. -- Deposit's precondition guarantees `dustFromExcess = 0`; only `dustFromCoarsening` can fire for it. - Repayment is the case where `dustFromExcess` is the common one. Same function handles both. +- For Deposit, `deltaLost` is usually `0` (the pre-rounded `amount_settled` fits `AssetsTotal`'s + current scale) — dust only fires at a growth boundary. For a repayment, `deltaLost` is the common + case (a loan's fixed `loan.scale` going stale as the Vault has grown since funding). Same function + handles both without branching on which. - `dust > 0` is only possible when `vault.sle[sfDustAccount]` exists — i.e. an IOU Vault (_Dust Pseudo-account lifecycle_, below). XRP/MPT Vaults never produce dust; that falls out of the truncation math, not an extra check. @@ -464,8 +551,12 @@ Notes: - Opportunistically moves as much of dust custody as is currently representable back into the main Pseudo-account/`AssetsTotal`. Called at the end of every `AddAssetsToVault`; a no-op below one representable unit of dust. -- The dust-to-main leg is a real transfer too, so it also goes through `SettleAdd`, with the Dust - Pseudo-account as `from`. Guaranteed a no-op for XRP/MPT Vaults (no `sfDustAccount`, no balance). +- **Direct accounting, not `SettleAdd`.** `sweepable` is already trimmed to fit + `effective(vault.AssetsTotal)` _before_ this transfer happens — unlike every other caller of + `SettleAdd`, there's no independent `amount` and `assetsTotalDelta` to reconcile here, both are the + same `sweepable` figure. Running it through the full formula would (barring the rare case noted + below) just come back out as `deltaLost = 0`, `settled = sweepable`, `dust = 0` — so skip the + machinery and do the transfer and both bookkeeping updates directly. ``` function MaybeSweep(view, vault, j) -> TER: @@ -473,30 +564,34 @@ function MaybeSweep(view, vault, j) -> TER: if dustBalance == 0: return tesSUCCESS - scale = effective(vault.AssetsTotal) - sweepable = truncate(dustBalance, scale) + sweepable = truncate(dustBalance, effective(vault.AssetsTotal)) if sweepable == 0: return tesSUCCESS # not enough accumulated yet - # NOT AddAssetsToVault (see below). `from` is the Dust Pseudo-account - # itself; assetsTotalDelta is omitted (None) so it defaults to - # whatever this call's own `settled` turns out to be — swept dust was - # never counted in AssetsTotal to begin with, so landing it here is - # exactly the "new value" default case, same as Deposit. - result = SettleAdd(view, vault, vault.sle[sfDustAccount], sweepable, None, j) - if result is error: return result.error() + ter = accountSend( + view, vault.sle[sfDustAccount], vault.sle[sfAccount], sweepable, j, {}, WaiveTransferFee::Yes) + if ter is not tesSUCCESS: + return ter + + # Both totals move by the same amount — this is real value that was + # already being counted via the `+ Dust` term in the NAV formulas + # (above); moving it into AssetsTotal and decreasing Dust by the same + # amount keeps AssetsTotal + Dust unchanged. If AssetsTotal only + # increased without a matching Dust decrease (or vice versa), a sweep + # would silently inflate or deflate reported NAV for doing nothing + # but reshuffling custody. + vault.AssetsAvailable += sweepable + vault.AssetsTotal += sweepable return tesSUCCESS - # If this transfer induces further coarsening (Case 2, above), - # SettleAdd's accountSendMulti sends the residual back to - # vault.sle[sfDustAccount] — the same account it came from (from==to; - # needs verifying that's a clean no-op — Appendix → Open questions). - # Net effect on dust custody is always a decrease of exactly `settled`. ``` -- Why `SettleAdd`, not `AddAssetsToVault`: the latter would trigger another `MaybeSweep`, which in - the degenerate `settled == 0` case recomputes the same `candidate` against unchanged state — - infinite recursion. `SettleAdd` bounds this to one attempt; nothing else calls `MaybeSweep` from - inside itself. +- **Rare, unhandled edge case:** if adding `sweepable` to `AssetsTotal` itself crosses a magnitude + boundary (only possible when `AssetsTotal` sits exactly at one), the addition could lose a sliver + the same way any other addition to `AssetsTotal` can — but unlike `SettleAdd`, this path doesn't + detect or re-route that sliver back to `Dust`. Bounded to less than one representable unit, and a + missed sweep just waits for the next opportunity rather than losing anything permanently — not + engineered around here, consistent with how the design already accepts similarly-bounded rounding + noise elsewhere (Appendix → Open questions). - `MaybeSweep` never rejects or blocks — it strictly improves (or leaves unchanged) how much of `AssetsTotal` is backed by main custody. @@ -534,7 +629,7 @@ function RemoveAssetsFromVault(view, vault, to, amount, j) -> Expected Expected= -deltaShortfall`. Not - proven for every combination of principal/interest split and Vault scale — e.g. a late-fee-only - repayment that's almost entirely "interest" against a very coarse `effective(AssetsTotal)`. Not - expected in practice given typical principal/interest ratios; needs a bound or a clamp before this - ships. +- **Is the Vault-level stable-contract tradeoff (§2) actually acceptable to integrators?** The + central open question distinguishing the two options: Option B means Deposit/Withdraw and a new + Loan's Vault-side floor round against a live, uninspectable-in-advance quantity — exactly the + complaint Problem item 5 raises. Option A resolves this at real cost (a new immutable field, plus + its own unenforced-immutability question below). Whether integrators building against Vaults + actually need a Vault-level precision guarantee, or whether the Loan-level guarantee (`loan.scale`, + identical under both options) is sufficient for the flows that matter, isn't resolved here — see + §2.4. - **No invariant-check design.** Candidates implied by the design but never stated: post-`MaybeSweep`, dust custody never exceeds one representable unit at the current effective scale; a global conservation identity across `AssetsTotal`/`Dust`/`AssetsAvailable`. (An earlier candidate — "Dust - Pseudo-account's real balance ≡ `vault.Dust`" — no longer applies: dropping the redundant `sfDust` - field, above, means there's only one number left, so there's nothing separate for it to disagree + Pseudo-account's real balance ≡ `vault.Dust`" — no longer applies: there's no separate `sfDust` + field in either option, so there's only one number left, and nothing separate for it to disagree with.) - **Dust-sweep timing has a narrow, bounded attribution quirk — accepted, not fixed.** A withdrawal's own settlement amount is computed against `AssetsTotal` _before_ that same transaction's @@ -804,8 +902,8 @@ deltaShortfall`. Not exchange rate does. Bounded to less than one representable unit by construction (Technical Details → _Dust: error accumulation and custody routing_), the same order of magnitude as ordinary rounding noise elsewhere in this design; not considered worth a dedicated fix. -- **`sfPrecision` immutability asserted, not enforced.** No confirmation `VaultSet` rejects attempts - to change it post-creation. +- **`sfPrecision` immutability asserted, not enforced — Option A only.** No confirmation `VaultSet` + rejects attempts to change it post-creation. Doesn't apply under Option B, which has no such field. - **PoC test file is stale.** Predates the `Dust` rename, eager-creation decision, terminal-withdrawal rule, and real `accountSend` integration. - **`LoanPay.cpp`'s real repayment splits three ways** (vault main custody, vault dust custody, @@ -815,6 +913,8 @@ deltaShortfall`. Not ### 4.6 Next steps +- Decide the Option A vs. Option B question (§2.4) before anything else — it's the one choice that + changes which fields and code paths actually get built. - Audit Loan/LoanBroker code paths for assumptions of dynamic Vault scale — confirm `loan.scale` is captured at funding (Technical Details → _Loan issuance_), not recomputed per-operation. -- Resolve the open questions above before finalizing the dust-custody mechanism. +- Resolve the remaining open questions above before finalizing the dust-custody mechanism.