mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-24 07:40:53 +00:00
Compare commits
17 Commits
develop
...
tapanito/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e9121ca23 | ||
|
|
9cad0e94db | ||
|
|
2c8003691d | ||
|
|
fa0d6a4f3d | ||
|
|
f0a3df7455 | ||
|
|
242db802d4 | ||
|
|
5cd1871091 | ||
|
|
30364e2c3c | ||
|
|
db50afa34f | ||
|
|
0a4dd08700 | ||
|
|
5d839f4811 | ||
|
|
00889a4307 | ||
|
|
d6565f0e4d | ||
|
|
72f75cbf43 | ||
|
|
80ef2659e0 | ||
|
|
518edd594c | ||
|
|
3c51e3dad1 |
@@ -248,6 +248,13 @@ words:
|
||||
- queuable
|
||||
- Raphson
|
||||
- rcflags
|
||||
- recognise
|
||||
- recognises
|
||||
- recognised
|
||||
- renormalise
|
||||
- renormalises
|
||||
- renormalised
|
||||
- renormalisation
|
||||
- replayer
|
||||
- rerandomize
|
||||
- rerandomization
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/STParsedJSON.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
#include <xrpl/protocol_autogen/LedgerEntryBase.h>
|
||||
|
||||
128
docs/dust-mechanism.md
Normal file
128
docs/dust-mechanism.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Trust-line dust mechanism
|
||||
|
||||
Companion document to [`xrpl::DustSplit`](../include/xrpl/ledger/helpers/TokenHelpers.h)
|
||||
and the [`xrpl::vault_dust::`](../include/xrpl/ledger/helpers/VaultHelpers.h)
|
||||
Vault adopter. Introduced by `featureLendingProtocolV1_1`.
|
||||
|
||||
## Why sfDust
|
||||
|
||||
An IOU trust line stores its balance in `sfBalance`, an `STAmount`.
|
||||
`STAmount` carries ~16 significant digits; `Number` (used throughout
|
||||
protocol accounting) carries 19. When a caller mints or debits a value
|
||||
that is finer than what `sfBalance` can represent — typical for
|
||||
cash-basis Vault operations near a scale-refining decade boundary — the
|
||||
sub-quantum remainder is dropped by rounding. Over many operations the
|
||||
loss accumulates and drives receivables and available-cash counters
|
||||
apart.
|
||||
|
||||
`sfDust` is a per-trust-line 19-digit `STNumber` field that holds that
|
||||
sub-quantum remainder. The trust line's true holding is the
|
||||
**extended balance** `sfBalance + sfDust`. `sfDust` is intentionally
|
||||
declared without the `kSmdNeedsAsset` metadata flag: `associateAsset`
|
||||
must not truncate it to `sfBalance`'s precision, since its whole
|
||||
purpose is to carry value below that precision.
|
||||
|
||||
Pre-amendment `sfDust` is `SoeDefault(0)`. The ledger encoding of an
|
||||
`ltRIPPLE_STATE` entry is byte-identical to before the amendment on any
|
||||
line whose `sfDust` is zero, so activation is a strict extension.
|
||||
|
||||
## Two-legged trust-line touch
|
||||
|
||||
A `A -> B` IOU payment where `I` issues touches at most two trust
|
||||
lines:
|
||||
|
||||
- the sender's line `RIPPLE_STATE{A, I}`;
|
||||
- the receiver's line `RIPPLE_STATE{B, I}`.
|
||||
|
||||
`A` and `B` are the non-issuer parties on their respective lines; `I`
|
||||
holds no line of its own for its own currency. Each non-issuer party
|
||||
can independently opt into dust semantics on their OWN line by
|
||||
attaching a per-leg `DustSplit::LegPolicy` — `sender` for the debit
|
||||
leg, `receiver` for the credit leg. A leg without a sub-policy runs the
|
||||
classic pre-dust code path byte-identically.
|
||||
|
||||
## Modes
|
||||
|
||||
`DustSplit::LegPolicy::Mode::Override`
|
||||
: Trust-line layer keeps `sfBalance` representable at `overrideScale`
|
||||
and parks any sub-quantum remainder in `sfDust`. Previously deferred
|
||||
`sfDust` is automatically promoted when the combined `sfDust +
|
||||
credit` clears a whole-quantum boundary — no separate "renormalise"
|
||||
step. Callers supply `overrideScale` from their own accounting
|
||||
(e.g. the Vault's posterior scale). Bounded scale drift up to one
|
||||
decade may linger until the next operation that refines the scale.
|
||||
|
||||
`DustSplit::LegPolicy::Mode::Drain` (sender-leg only)
|
||||
: Folds ALL of `sfDust` on the sender's line into the outgoing
|
||||
transfer, then zeroes `sfDust`. Used for terminal removals where
|
||||
the sender is winding down. There is no receiver-side counterpart
|
||||
because a receiver has no reservoir to drain.
|
||||
|
||||
## Reporting sign convention
|
||||
|
||||
Out-fields (`balanceDelta`, `dustDelta`) on `LegPolicy` are reported
|
||||
FROM THAT LEG'S NON-ISSUER PARTY'S PERSPECTIVE — party-positive:
|
||||
|
||||
- `sender` leg reports SENDER-POSITIVE deltas. A normal send makes the
|
||||
sender's `balanceDelta` negative; a Drain reports `dustDelta ==
|
||||
-sfDust_before`.
|
||||
- `receiver` leg reports RECEIVER-POSITIVE deltas. A normal receive
|
||||
makes the receiver's `balanceDelta` positive.
|
||||
|
||||
This lets a consumer reconcile its own bookkeeping symmetrically:
|
||||
reads from `sender->balanceDelta` on a withdrawal/clawback line up in
|
||||
sign with reads from `receiver->balanceDelta` on a deposit, without a
|
||||
sign flip.
|
||||
|
||||
## Contract asserts (debug)
|
||||
|
||||
- Drain on the receiver leg is a caller error.
|
||||
- Attaching a policy to the issuer side of a direct payment (where one
|
||||
party IS the issuer) is a caller error — the policy must correspond
|
||||
to the non-issuer party's leg.
|
||||
- Any non-empty `DustSplit` requires `featureLendingProtocolV1_1`
|
||||
enabled; a policy under an older rules set falls back to `nullptr`
|
||||
and asserts in debug.
|
||||
|
||||
## Adding a new consumer
|
||||
|
||||
Vault is the sole consumer today (`xrpl::vault_dust::`). Adding a
|
||||
sibling adopter (AMM, LoanBroker, …) is purely additive:
|
||||
|
||||
1. Add an eligibility gate in a new sibling namespace, analogous to
|
||||
`xrpl::vault_dust::useVaultDust`.
|
||||
2. Construct a `DustSplit` in that consumer's transactor helpers,
|
||||
using the scale implied by that consumer's own accounting.
|
||||
3. Reconcile the consumer's bookkeeping using the reported deltas.
|
||||
|
||||
The trust-line layer needs no per-consumer awareness. Do NOT extend
|
||||
`xrpl::vault_dust::` to serve non-Vault consumers.
|
||||
|
||||
## Amendment-gate policy
|
||||
|
||||
Every code path that reads or writes `sfDust` enforces
|
||||
`rules().enabled(featureLendingProtocolV1_1)`:
|
||||
|
||||
- write side: `directSendNoFeeIOU` (this is the canonical anchor);
|
||||
- read side: `creditBalanceExact`;
|
||||
- deletion guard: `removeEmptyHolding`;
|
||||
- consumer eligibility: `vault_dust::useVaultDust`.
|
||||
|
||||
In normal operation the gate is redundant — the transactor-level
|
||||
eligibility predicates already imply the amendment is on — but a
|
||||
hypothetical replay/testing path that ever presented a dust-aware
|
||||
request under a pre-amendment `rules()` must NOT touch `sfDust`: the
|
||||
field would then be neither `SoeDefault(0)` as expected pre-amendment
|
||||
nor part of the ledger's canonical encoding. The gate asserts in
|
||||
debug and falls back to the pre-dust code path in release.
|
||||
|
||||
## Related tests
|
||||
|
||||
- `src/test/app/lending/VaultRoundingTrustlineDust_test.cpp`
|
||||
covers the end-to-end dust behaviour, including the extended-balance
|
||||
invariant path, sender-leg Override reporting on non-terminal
|
||||
withdraw, and end-to-end Drain via defaulted-loan terminal
|
||||
withdrawal.
|
||||
- `src/test/app/lending/VaultRounding_test.cpp` relaxes the O2
|
||||
dust bound to allow the bounded decade-boundary drift documented
|
||||
above.
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
@@ -223,7 +224,8 @@ doWithdraw(
|
||||
AccountID const& sourceAcct,
|
||||
XRPAmount priorBalance,
|
||||
STAmount const& amount,
|
||||
beast::Journal j);
|
||||
beast::Journal j,
|
||||
DustSplit* dust = nullptr);
|
||||
|
||||
/**
|
||||
* Deleter function prototype. Returns the status of the entry deletion
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
@@ -216,14 +217,6 @@ adjustImpreciseNumber(
|
||||
value = 0;
|
||||
}
|
||||
|
||||
inline int
|
||||
getAssetsTotalScale(SLE::const_ref vaultSle)
|
||||
{
|
||||
if (!vaultSle)
|
||||
return Number::kMinExponent - 1; // LCOV_EXCL_LINE
|
||||
return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset));
|
||||
}
|
||||
|
||||
// Compute the minimum required broker cover, rounded consistently.
|
||||
// DebtTotal is a broker-level aggregate maintained at vault scale, so the
|
||||
// rounding must also use vault scale — never an individual loan's scale.
|
||||
@@ -236,7 +229,7 @@ minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::c
|
||||
return roundToAsset(
|
||||
vaultSle->at(sfAsset),
|
||||
tenthBipsOfValue(debtTotal, coverRateMinimum),
|
||||
getAssetsTotalScale(vaultSle));
|
||||
getVaultScale(vaultSle));
|
||||
}
|
||||
|
||||
TER
|
||||
@@ -610,9 +603,6 @@ computeLoanProperties(
|
||||
TenthBips32 managementFeeRate,
|
||||
std::int32_t minimumScale);
|
||||
|
||||
bool
|
||||
isRounded(Asset const& asset, Number const& value, std::int32_t scale);
|
||||
|
||||
// Indicates what type of payment is being made.
|
||||
// regular, late, and full are mutually exclusive.
|
||||
// overpayment is an "add on" to a regular payment, and follows that path with
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
@@ -67,6 +68,43 @@ creditBalance(
|
||||
Currency const& currency);
|
||||
/** @} */
|
||||
|
||||
/**
|
||||
* Read the trust-line balance from @p account's perspective at exact
|
||||
* precision, folding in the trust line's sfDust reservoir so the returned
|
||||
* Number represents the true unrounded holding.
|
||||
*
|
||||
* The scale of the returned Number is generally finer than sfBalance's
|
||||
* canonical precision — callers who want the STAmount-representable part
|
||||
* should keep using @ref creditBalance. This helper is the read side that
|
||||
* complements @ref DustSplit's write side: together they let a caller keep
|
||||
* an off-line total of a trust line without losing sub-quantum drift.
|
||||
*
|
||||
* Returns Number{0} when the trust line does not exist.
|
||||
*
|
||||
* Gated on featureLendingProtocolV1_1; see directSendNoFeeIOU for the
|
||||
* canonical amendment-gate rationale.
|
||||
*
|
||||
* @param view the ledger to check against.
|
||||
* @param account the account whose perspective determines the sign.
|
||||
* @param issuer the counterparty of the trust line.
|
||||
* @param currency the IOU to check.
|
||||
*/
|
||||
Number
|
||||
creditBalanceExact(
|
||||
ReadView const& view,
|
||||
AccountID const& account,
|
||||
AccountID const& issuer,
|
||||
Currency const& currency);
|
||||
|
||||
/**
|
||||
* @overload
|
||||
*/
|
||||
inline Number
|
||||
creditBalanceExact(ReadView const& view, AccountID const& account, Issue const& issue)
|
||||
{
|
||||
return creditBalanceExact(view, account, issue.account, issue.currency);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
//
|
||||
// Freeze checking (IOU-specific)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -60,6 +61,56 @@ enum class AllowMPTOverflow : bool { No = false, Yes };
|
||||
*/
|
||||
enum class WaiveMPTCanTransfer : bool { No = false, Yes };
|
||||
|
||||
/**
|
||||
* Feature-agnostic trust-line dust primitive.
|
||||
*
|
||||
* One struct with two optional per-leg sub-policies (`sender`,
|
||||
* `receiver`); `accountSend` and its callees route each sub-policy to
|
||||
* the corresponding trust-line touch. A leg without a sub-policy runs
|
||||
* the classic pre-dust path byte-identically.
|
||||
*
|
||||
* Modes:
|
||||
* - `Override`: keep `sfBalance` representable at `overrideScale`; park
|
||||
* any sub-quantum remainder in `sfDust`. Previously deferred `sfDust`
|
||||
* is automatically promoted when the combined `sfDust + credit`
|
||||
* clears a whole-quantum boundary.
|
||||
* - `Drain` (sender-leg only): fold all of the sender-line's `sfDust`
|
||||
* into the outgoing transfer, then zero `sfDust`. Used for terminal
|
||||
* removals; there is no receiver-side counterpart.
|
||||
*
|
||||
* Out-fields (`balanceDelta`, `dustDelta`) report from THAT LEG'S
|
||||
* NON-ISSUER PARTY'S perspective (party-positive): sender-leg is
|
||||
* sender-positive, receiver-leg is receiver-positive.
|
||||
*
|
||||
* Contract asserts (debug):
|
||||
* - `Drain` on the receiver leg is a caller error.
|
||||
* - The policy must correspond to the non-issuer party's leg (issuer
|
||||
* side must be null).
|
||||
* - Any non-empty `DustSplit` requires `featureLendingProtocolV1_1`.
|
||||
*
|
||||
* See docs/dust-mechanism.md for the full design rationale (motivation,
|
||||
* two-legged trust-line touch model, sign convention, amendment gate
|
||||
* policy, consumer-adopter pattern).
|
||||
*/
|
||||
struct DustSplit
|
||||
{
|
||||
struct LegPolicy
|
||||
{
|
||||
enum class Mode { Override, Drain };
|
||||
|
||||
Mode mode = Mode::Override;
|
||||
int overrideScale = 0; // used only when mode == Override; exponent
|
||||
// sfBalance must remain representable at
|
||||
|
||||
// out (from this leg's non-issuer party's perspective):
|
||||
Number balanceDelta{};
|
||||
Number dustDelta{};
|
||||
};
|
||||
|
||||
std::optional<LegPolicy> sender;
|
||||
std::optional<LegPolicy> receiver;
|
||||
};
|
||||
|
||||
/* Check if MPToken (for MPT) or trust line (for IOU) exists:
|
||||
* - StrongAuth - before checking if authorization is required
|
||||
* - WeakAuth
|
||||
@@ -386,7 +437,8 @@ accountSend(
|
||||
beast::Journal j,
|
||||
SLE::ref sponsorSle = {},
|
||||
WaiveTransferFee waiveFee = WaiveTransferFee::No,
|
||||
AllowMPTOverflow allowOverflow = AllowMPTOverflow::No);
|
||||
AllowMPTOverflow allowOverflow = AllowMPTOverflow::No,
|
||||
DustSplit* dust = nullptr);
|
||||
|
||||
using MultiplePaymentDestinations = std::vector<std::pair<AccountID, Number>>;
|
||||
/**
|
||||
@@ -395,6 +447,10 @@ using MultiplePaymentDestinations = std::vector<std::pair<AccountID, Number>>;
|
||||
*
|
||||
* Calls static accountSendMultiIOU if saAmount represents Issue.
|
||||
* Calls static accountSendMultiMPT if saAmount represents MPTIssue.
|
||||
*
|
||||
* The optional `dust` split applies only to the sender's leg (the single
|
||||
* shared sender trust line across all destinations). Only `dust->sender`
|
||||
* is consulted; `dust->receiver` must be nullopt.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
accountSendMulti(
|
||||
@@ -403,7 +459,8 @@ accountSendMulti(
|
||||
Asset const& asset,
|
||||
MultiplePaymentDestinations const& receivers,
|
||||
beast::Journal j,
|
||||
WaiveTransferFee waiveFee = WaiveTransferFee::No);
|
||||
WaiveTransferFee waiveFee = WaiveTransferFee::No,
|
||||
DustSplit* dust = nullptr);
|
||||
|
||||
[[nodiscard]] TER
|
||||
transferXRP(
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
|
||||
#include <optional>
|
||||
|
||||
@@ -123,4 +128,313 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref
|
||||
[[nodiscard]] VaultVersion
|
||||
getVaultVersion(SLE::const_ref vault);
|
||||
|
||||
/**
|
||||
* Returns the scale (number of decimal places) at which a vault's
|
||||
* sfAssetsTotal is maintained, derived from the vault's asset and its
|
||||
* current sfAssetsTotal value.
|
||||
*
|
||||
* @param vault The vault SLE.
|
||||
*
|
||||
* @return The vault's scale, or `Number::kMinExponent - 1` if `vault` is
|
||||
* null.
|
||||
*/
|
||||
[[nodiscard]] int
|
||||
getVaultScale(SLE::const_ref vault);
|
||||
|
||||
/**
|
||||
* The single point through which assets are added to a Vault: updates the
|
||||
* Vault's sfAssetsTotal and sfAssetsAvailable and transfers `amount` of the
|
||||
* Vault's asset from `sender` to the Vault's pseudo-account.
|
||||
*
|
||||
* Callers are responsible for rounding `amount` and `valueDelta` to whatever
|
||||
* scale is appropriate for their own accounting (e.g. current vs. posterior
|
||||
* Vault scale); this helper does not perform any additional rounding.
|
||||
*
|
||||
* @param view The ledger view to apply changes to.
|
||||
* @param vault The vault SLE. Must not be null.
|
||||
* @param sender The account to transfer `amount` from.
|
||||
* @param amount The amount to add to sfAssetsAvailable, and to transfer from
|
||||
* `sender` to the Vault's pseudo-account.
|
||||
* @param valueDelta The amount to add to sfAssetsTotal. May differ from
|
||||
* `amount`, e.g. when recognizing a value change that is
|
||||
* not fully backed by a matching cash transfer. May be
|
||||
* negative (e.g. a default write-off, or a small rounding
|
||||
* correction), unlike `amount`.
|
||||
* @param j Journal for logging.
|
||||
*
|
||||
* @return TER on success or failure.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
addVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& sender,
|
||||
STAmount const& amount,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j);
|
||||
|
||||
/**
|
||||
* Signals that a removal is the last one possible for a Vault — i.e. it
|
||||
* burns every outstanding share. removeVaultAssets uses this to hard-reset
|
||||
* sfAssetsTotal and sfAssetsAvailable to exactly zero, rather than
|
||||
* subtracting `amount`/`valueDelta` from them.
|
||||
*
|
||||
* This matters because the discounted exchange-rate formula used to compute
|
||||
* a withdrawal's `amount` can produce a value with more decimal precision
|
||||
* than the Vault's asset can canonically represent. Subtracting such a
|
||||
* value from the field would leave a non-canonical residual instead of an
|
||||
* exact zero, corrupting the ledger entry. A final removal is defined to
|
||||
* exhaust the Vault's exposure entirely, so hard-resetting to zero is both
|
||||
* simpler and correct — no residual dust is possible or desired.
|
||||
*/
|
||||
enum class FinalRemoval : bool { No = false, Yes = true };
|
||||
|
||||
/**
|
||||
* The single point through which assets are clawed back from a Vault entirely:
|
||||
* decreases the Vault's sfAssetsTotal and sfAssetsAvailable
|
||||
* and transfers @p amount from the Vault's pseudo-account to @p recipient via
|
||||
* a plain accountSend.
|
||||
*
|
||||
* Callers are responsible for rounding @p amount to whatever
|
||||
* scale is appropriate for their own accounting; this helper does not
|
||||
* perform any additional rounding.
|
||||
*
|
||||
* @param view The ledger view to apply changes to.
|
||||
* @param vault The vault SLE. Must not be null.
|
||||
* @param recipient The account to transfer `amount` to. Must already be
|
||||
* able to hold the Vault's asset without further setup.
|
||||
* @param amount The amount to clawback from the vault and transfer from the
|
||||
* Vault's pseudo-account to `recipient`. Must be positive;
|
||||
* callers must skip calling this helper entirely for a
|
||||
* zero-amount clawback (unlike addVaultAssets/removeVaultAssets,
|
||||
* which tolerate a zero `amount`).
|
||||
* @param j Journal for logging.
|
||||
*
|
||||
* @return TER code.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
clawbackVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& recipient,
|
||||
STAmount const& amount,
|
||||
beast::Journal j);
|
||||
|
||||
/**
|
||||
* The single point through which assets are removed from a Vault entirely
|
||||
* and withdrawn to a recipient that may not yet have a holding for the
|
||||
* Vault's asset: decreases the Vault's sfAssetsTotal and sfAssetsAvailable
|
||||
* and calls doWithdraw to transfer @p amount from the
|
||||
* Vault's pseudo-account to @p dstAcct.
|
||||
*
|
||||
* Unlike clawbackVaultAssets, this relies solely on doWithdraw's own
|
||||
* pre-transfer balance check rather than an additional post-transfer sanity
|
||||
* check.
|
||||
*
|
||||
* Callers are responsible for rounding @p amount; this helper does not
|
||||
* perform any additional rounding, except when `finalRemoval` is Yes (see
|
||||
* FinalRemoval).
|
||||
*
|
||||
* @param ctx The apply-view context to apply changes to.
|
||||
* @param vault The vault SLE. Must not be null.
|
||||
* @param senderAcct The account that submitted the withdrawal transaction.
|
||||
* @param dstAcct The account to transfer `amount` to; may equal `senderAcct`.
|
||||
* @param priorBalance The XRP reserve base, passed through to doWithdraw for
|
||||
* creating a holding for `dstAcct` when required.
|
||||
* @param amount The amount to subtract from sfAssetsAvailable, and to
|
||||
* transfer from the Vault's pseudo-account to `dstAcct`.
|
||||
* Ignored (other than for the transfer) when `finalRemoval` is
|
||||
* Yes.
|
||||
* @param j Journal for logging.
|
||||
* @param finalRemoval Whether this is the Vault's final removal (see
|
||||
* FinalRemoval).
|
||||
*
|
||||
* @return TER from doWithdraw.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
removeVaultAssets(
|
||||
ApplyViewContext ctx,
|
||||
SLE::ref vault,
|
||||
AccountID const& senderAcct,
|
||||
AccountID const& dstAcct,
|
||||
XRPAmount priorBalance,
|
||||
STAmount const& amount,
|
||||
beast::Journal j,
|
||||
FinalRemoval finalRemoval = FinalRemoval::No);
|
||||
|
||||
/**
|
||||
* The single point through which cash is moved out of a Vault's
|
||||
* sfAssetsAvailable to multiple recipients in a single atomic payment, e.g.
|
||||
* a loan's principal and origination fee, without necessarily shrinking the
|
||||
* Vault's total exposure: updates sfAssetsAvailable (decreases by the sum of
|
||||
* `recipients`' amounts) and sfAssetsTotal (changes by `valueDelta`, same
|
||||
* sign convention as addVaultAssets — typically an increase, since
|
||||
* disbursing a loan recognizes accrued interest into sfAssetsTotal even as
|
||||
* cash leaves the Vault), then transfers the Vault's asset from the Vault's
|
||||
* pseudo-account to each of `recipients`, via accountSendMulti.
|
||||
*
|
||||
* Unlike removeVaultAssets, this is not a removal — the Vault's receivables
|
||||
* grow to match the cash that leaves sfAssetsAvailable, so there is no
|
||||
* "final" edge case to handle here.
|
||||
*
|
||||
* Recipients must already be able to hold the Vault's asset (e.g. via
|
||||
* addEmptyHolding and requireAuth performed by the caller beforehand); this
|
||||
* helper does not create holdings or check authorization.
|
||||
*
|
||||
* sfAssetsAvailable is decreased by an STAmount built from the sum of the
|
||||
* recipients' Numbers, so for a very large recipient list whose sum exceeds
|
||||
* STAmount's ~16 significant digits, this could round differently than
|
||||
* summing the underlying Numbers directly. Not a concern for the current
|
||||
* caller (LoanSet, two recipients).
|
||||
*
|
||||
* @param view The ledger view to apply changes to.
|
||||
* @param vault The vault SLE. Must not be null.
|
||||
* @param recipients The accounts and amounts to transfer from the Vault's
|
||||
* pseudo-account. Must contain more than one entry.
|
||||
* @param valueDelta The amount to add to sfAssetsTotal (same convention as
|
||||
* addVaultAssets). May be negative, and may differ from
|
||||
* the sum of `recipients`' amounts.
|
||||
* @param j Journal for logging.
|
||||
*
|
||||
* @return TER from accountSendMulti.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
moveVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
MultiplePaymentDestinations const& recipients,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j);
|
||||
|
||||
/**
|
||||
* @namespace vault_dust
|
||||
*
|
||||
* Vault-side adopter of the trust-line dust mechanism (see
|
||||
* `xrpl::DustSplit` in TokenHelpers.h, and docs/dust-mechanism.md).
|
||||
* Owns the Vault-level orchestration only: an eligibility gate
|
||||
* (`useVaultDust`) and dust-aware overloads of the four base Vault
|
||||
* helpers, with identical signatures so the transactor call sites can
|
||||
* stay agnostic.
|
||||
*
|
||||
* Non-Vault features that want the same primitives should add a sibling
|
||||
* namespace with their own gate and helper overloads — do not extend
|
||||
* this namespace.
|
||||
*
|
||||
* The four helper overloads here are the ONLY code in the tree that
|
||||
* constructs a `xrpl::DustSplit`; every other caller uses the base
|
||||
* helpers verbatim.
|
||||
*/
|
||||
namespace vault_dust {
|
||||
|
||||
/**
|
||||
* Whether this Vault's custody trust line participates in the sfDust
|
||||
* mechanism. True only when featureLendingProtocolV1_1 is enabled AND the
|
||||
* Vault is cash-basis (sfLEVersion == VaultVersion::CashBasis) AND its
|
||||
* asset is an IOU. Every other case (Legacy vault, integral asset,
|
||||
* amendment disabled) skips every dust-aware code path.
|
||||
*
|
||||
* See directSendNoFeeIOU for the canonical amendment-gate rationale.
|
||||
*
|
||||
* @param view The ledger view (for amendment lookup).
|
||||
* @param vault The vault SLE.
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
useVaultDust(ReadView const& view, SLE::const_ref vault);
|
||||
|
||||
/**
|
||||
* Dust-aware overload of `xrpl::addVaultAssets`. Same signature as the
|
||||
* base version. The dispatcher in `xrpl::addVaultAssets` forwards here
|
||||
* when `useVaultDust(view, vault)` returns true.
|
||||
*
|
||||
* The credit path uses a `DustSplit` targeting the Vault's posterior
|
||||
* scale (the scale implied by `sfAssetsTotal + valueDelta`, an upper
|
||||
* bound on the Vault's post-op scale), so any sub-quantum remainder in
|
||||
* `amount` lands in the custody line's `sfDust` rather than being lost.
|
||||
* Both `sfAssetsTotal` and `sfAssetsAvailable` are updated with the split
|
||||
* outputs so the receivable (`sfAssetsTotal - sfAssetsAvailable`) is
|
||||
* identical to what a dust-unaware call would produce.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
addVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& sender,
|
||||
STAmount const& amount,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j);
|
||||
|
||||
/**
|
||||
* Dust-aware overload of `xrpl::clawbackVaultAssets`. Same signature as
|
||||
* the base version. The dispatcher forwards here when
|
||||
* `useVaultDust(view, vault)` returns true.
|
||||
*
|
||||
* A clawback shrinks `sfAssetsTotal`, which can refine the Vault's
|
||||
* scale. This overload drives the transfer through a sender-leg
|
||||
* `DustSplit::LegPolicy::Mode::Override` at the Vault's posterior
|
||||
* scale; the trust-line layer re-splits `sfBalance`/`sfDust` on the
|
||||
* custody line and reports any promoted (or newly-deferred) sub-quantum
|
||||
* residual so the Vault fields stay aligned with the extended balance.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
clawbackVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& recipient,
|
||||
STAmount const& amount,
|
||||
beast::Journal j);
|
||||
|
||||
/**
|
||||
* Dust-aware overload of `xrpl::removeVaultAssets`. Same signature as the
|
||||
* base version. The dispatcher forwards here when
|
||||
* `useVaultDust(ctx.view, vault)` returns true.
|
||||
*
|
||||
* Non-terminal (`FinalRemoval::No`): drives the withdrawal through a
|
||||
* sender-leg `DustSplit::LegPolicy::Mode::Override` at the Vault's
|
||||
* posterior scale, so any dust stranded on the custody line by a
|
||||
* scale-refining update is renormalised inside the trust-line layer
|
||||
* (no separate promotion pass in Vault code).
|
||||
*
|
||||
* Terminal (`FinalRemoval::Yes`): drives the withdrawal through a
|
||||
* sender-leg `DustSplit::LegPolicy::Mode::Drain`. The trust-line layer
|
||||
* folds the custody line's `sfDust` reservoir into `sfBalance`,
|
||||
* inflates the outgoing transfer by the reservoir so the destination
|
||||
* receives `amount + drainedDust`, and zeroes `sfDust` — leaving the
|
||||
* line with `sfBalance == 0` and `sfDust == 0`, a precondition for
|
||||
* downstream Vault-cleanup deletion guards.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
removeVaultAssets(
|
||||
ApplyViewContext ctx,
|
||||
SLE::ref vault,
|
||||
AccountID const& senderAcct,
|
||||
AccountID const& dstAcct,
|
||||
XRPAmount priorBalance,
|
||||
STAmount const& amount,
|
||||
beast::Journal j,
|
||||
FinalRemoval finalRemoval = FinalRemoval::No);
|
||||
|
||||
/**
|
||||
* Dust-aware overload of `xrpl::moveVaultAssets`. Same signature as the
|
||||
* base version. The dispatcher forwards here when
|
||||
* `useVaultDust(view, vault)` returns true.
|
||||
*
|
||||
* A multi-recipient move (typically a loan disbursement) is a
|
||||
* cash-out-plus-recognition on the Vault side; it shrinks
|
||||
* `sfAssetsAvailable` and may change `sfAssetsTotal` via `valueDelta`.
|
||||
* Both changes can refine the Vault's scale, so this overload attaches
|
||||
* a sender-leg `DustSplit::LegPolicy::Mode::Override` at the Vault's
|
||||
* posterior scale to `accountSendMulti`; the trust-line layer
|
||||
* renormalises any newly-representable dust on the custody line during
|
||||
* the bulk sender-line debit.
|
||||
*/
|
||||
[[nodiscard]] TER
|
||||
moveVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
MultiplePaymentDestinations const& recipients,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j);
|
||||
|
||||
} // namespace vault_dust
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -772,6 +772,17 @@ roundToAsset(
|
||||
return roundToScale(ret, scale);
|
||||
}
|
||||
|
||||
/* Checks if a value is already rounded to the specified scale.
|
||||
* Returns true if rounding down and rounding up produce the same result,
|
||||
* indicating no further precision exists beyond the scale.
|
||||
*/
|
||||
[[nodiscard]] inline bool
|
||||
isRounded(Asset const& asset, Number const& value, std::int32_t scale)
|
||||
{
|
||||
return roundToAsset(asset, value, scale, Number::RoundingMode::Downward) ==
|
||||
roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
inline bool
|
||||
|
||||
@@ -291,6 +291,7 @@ LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({
|
||||
{sfHighQualityOut, SoeOptional},
|
||||
{sfHighSponsor, SoeOptional},
|
||||
{sfLowSponsor, SoeOptional},
|
||||
{sfDust, SoeDefault},
|
||||
}))
|
||||
|
||||
/** The ledger object which lists the network's fee settings.
|
||||
|
||||
@@ -236,6 +236,14 @@ TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14)
|
||||
TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15, SField::kSmdNeedsAsset | SField::kSmdDefault)
|
||||
TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16)
|
||||
TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset | SField::kSmdDefault)
|
||||
// sfDust is a sub-quantum residual on a trust line: the fraction of a credit
|
||||
// that could not be represented in sfBalance at the caller-chosen accounting
|
||||
// scale, parked here so the extended quantity (sfBalance + sfDust) preserves
|
||||
// its exact value across successive rounded credits. Deliberately WITHOUT
|
||||
// kSmdNeedsAsset: that flag rounds the field to the asset's representable
|
||||
// precision via associateAsset(), which is the opposite of this field's
|
||||
// purpose (it exists to hold value finer than that precision).
|
||||
TYPED_SFIELD(sfDust, NUMBER, 18, SField::kSmdDefault)
|
||||
|
||||
// int32
|
||||
TYPED_SFIELD(sfLoanScale, INT32, 1)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/STParsedJSON.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
#include <xrpl/protocol_autogen/LedgerEntryBase.h>
|
||||
@@ -291,6 +292,30 @@ public:
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfLowSponsor);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get sfDust (SoeDefault)
|
||||
* @return The field value, or std::nullopt if not present.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
protocol_autogen::Optional<SF_NUMBER::type::value_type>
|
||||
getDust() const
|
||||
{
|
||||
if (hasDust())
|
||||
return this->sle_->at(sfDust);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if sfDust is present.
|
||||
* @return True if the field is present, false otherwise.
|
||||
*/
|
||||
[[nodiscard]]
|
||||
bool
|
||||
hasDust() const
|
||||
{
|
||||
return this->sle_->isFieldPresent(sfDust);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -482,6 +507,17 @@ public:
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set sfDust (SoeDefault)
|
||||
* @return Reference to this builder for method chaining.
|
||||
*/
|
||||
RippleStateBuilder&
|
||||
setDust(std::decay_t<typename SF_NUMBER::type::value_type> const& value)
|
||||
{
|
||||
object_[sfDust] = value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Build and return the completed RippleState wrapper.
|
||||
* @param index The ledger entry index.
|
||||
|
||||
@@ -73,6 +73,17 @@ public:
|
||||
{
|
||||
Number delta = kNumZero;
|
||||
std::optional<int> scale;
|
||||
// For @c ltRIPPLE_STATE entries under @c featureLendingProtocolV1_1,
|
||||
// this captures the trust line's @c sfDust delta in the same
|
||||
// low/high convention as @c delta. Used by the withdrawal
|
||||
// destination check to compare against the EXTENDED balance
|
||||
// (@c sfBalance + @c sfDust) on the vault's pseudo-account
|
||||
// custody line, so a dust reshuffle (e.g. Override promoting
|
||||
// sub-quantum residual into @c sfBalance, or Drain folding
|
||||
// @c sfDust in) does not spuriously fail the "vault and
|
||||
// destination balance change by equal amount" invariant. Left
|
||||
// at zero for entries that do not carry @c sfDust.
|
||||
Number dustDelta = kNumZero;
|
||||
|
||||
// Compute the delta between two Numbers, taking the coarsest scale
|
||||
[[nodiscard]] static DeltaInfo
|
||||
|
||||
@@ -438,7 +438,8 @@ doWithdraw(
|
||||
AccountID const& sourceAcct,
|
||||
XRPAmount priorBalance,
|
||||
STAmount const& amount,
|
||||
beast::Journal j)
|
||||
beast::Journal j,
|
||||
DustSplit* dust)
|
||||
{
|
||||
auto const dstSle = ctx.view.read(keylet::account(dstAcct));
|
||||
|
||||
@@ -479,9 +480,18 @@ doWithdraw(
|
||||
return sponsorSle.error(); // LCOV_EXCL_LINE
|
||||
|
||||
// Move the funds directly from the broker's pseudo-account to the
|
||||
// dstAcct
|
||||
// dstAcct. Forward any dust policy through accountSend so per-leg
|
||||
// renormalisation / drain semantics apply on the sourceAcct's line.
|
||||
return accountSend(
|
||||
ctx.view, sourceAcct, dstAcct, amount, j, *sponsorSle, WaiveTransferFee::Yes);
|
||||
ctx.view,
|
||||
sourceAcct,
|
||||
dstAcct,
|
||||
amount,
|
||||
j,
|
||||
*sponsorSle,
|
||||
WaiveTransferFee::Yes,
|
||||
AllowMPTOverflow::No,
|
||||
dust);
|
||||
}
|
||||
|
||||
TER
|
||||
|
||||
@@ -120,17 +120,6 @@ loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
|
||||
return tenthBipsOfValue(Number(paymentInterval), interestRate) / kSecondsInYear;
|
||||
}
|
||||
|
||||
/* Checks if a value is already rounded to the specified scale.
|
||||
* Returns true if rounding down and rounding up produce the same result,
|
||||
* indicating no further precision exists beyond the scale.
|
||||
*/
|
||||
bool
|
||||
isRounded(Asset const& asset, Number const& value, std::int32_t scale)
|
||||
{
|
||||
return roundToAsset(asset, value, scale, Number::RoundingMode::Downward) ==
|
||||
roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
|
||||
}
|
||||
|
||||
namespace accrual {
|
||||
|
||||
AccountingDeltas
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
@@ -97,6 +98,37 @@ creditBalance(
|
||||
return result;
|
||||
}
|
||||
|
||||
Number
|
||||
creditBalanceExact(
|
||||
ReadView const& view,
|
||||
AccountID const& account,
|
||||
AccountID const& issuer,
|
||||
Currency const& currency)
|
||||
{
|
||||
auto const sleRippleState = view.read(keylet::trustLine(account, issuer, currency));
|
||||
if (!sleRippleState)
|
||||
return Number{0};
|
||||
|
||||
STAmount balance = sleRippleState->getFieldAmount(sfBalance);
|
||||
// Put balance in @p account's terms (sfBalance is stored in the line's
|
||||
// own low/high convention).
|
||||
if (account < issuer)
|
||||
balance.negate();
|
||||
|
||||
// Amendment gate is defense-in-depth; see directSendNoFeeIOU
|
||||
// (TokenHelpers.cpp) for the canonical rationale.
|
||||
if (!view.rules().enabled(featureLendingProtocolV1_1))
|
||||
return Number{balance};
|
||||
|
||||
// sfDust follows sfBalance's sign convention, so the same negation
|
||||
// applies.
|
||||
Number dust = sleRippleState->at(sfDust);
|
||||
if (account < issuer)
|
||||
dust = -dust;
|
||||
|
||||
return Number{balance} + dust;
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
//
|
||||
// Freeze checking (IOU-specific)
|
||||
@@ -729,8 +761,23 @@ removeEmptyHolding(
|
||||
auto const line = ctx.view.peek(keylet::trustLine(accountID, issue));
|
||||
if (!line)
|
||||
return accountIsIssuer ? (TER)tesSUCCESS : (TER)tecOBJECT_NOT_FOUND;
|
||||
if (!accountIsIssuer && line->at(sfBalance)->iou() != beast::kZero)
|
||||
return tecHAS_OBLIGATIONS;
|
||||
|
||||
// Obligation checks apply only to non-issuer holdings: the issuer's
|
||||
// own trust-line is bookkeeping for the counterparty, not a holding
|
||||
// that carries an obligation to the issuer.
|
||||
if (!accountIsIssuer)
|
||||
{
|
||||
if (line->at(sfBalance)->iou() != beast::kZero)
|
||||
return tecHAS_OBLIGATIONS;
|
||||
// A line can be balance-zero yet hold non-zero sfDust: a debit
|
||||
// can legally consume all representable balance and leave only
|
||||
// dust. Refuse to delete such a line — dust is invisible to
|
||||
// accountHolds, so deletion would silently destroy that value.
|
||||
// Amendment gate is defense-in-depth; see directSendNoFeeIOU.
|
||||
if (ctx.view.rules().enabled(featureLendingProtocolV1_1) &&
|
||||
Number{line->at(sfDust)} != beast::kZero)
|
||||
return tecHAS_OBLIGATIONS;
|
||||
}
|
||||
|
||||
// Adjust the owner count(s)
|
||||
if (line->isFlag(lsfLowReserve))
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
@@ -639,6 +640,14 @@ canTransfer(
|
||||
// - Redeeming IOUs and/or sending sender's own IOUs.
|
||||
// - Create trust line if needed.
|
||||
// --> bCheckIssuer : normally require issuer to be involved.
|
||||
//
|
||||
// The optional `legPolicy` argument corresponds to the NON-ISSUER PARTY on
|
||||
// this specific trust line touch. For a direct payment (one party IS the
|
||||
// issuer) it is the non-issuer party's leg policy; for a transit leg it
|
||||
// is either the sender-leg or receiver-leg policy of the enclosing
|
||||
// two-legged send. `legPolicy` reports its `balanceDelta`/`dustDelta`
|
||||
// from THAT PARTY's perspective (party-positive: positive means the
|
||||
// non-issuer party's holdings grew).
|
||||
static TER
|
||||
directSendNoFeeIOU(
|
||||
ApplyView& view,
|
||||
@@ -647,7 +656,9 @@ directSendNoFeeIOU(
|
||||
STAmount const& saAmount,
|
||||
bool bCheckIssuer,
|
||||
SLE::ref sponsorSle,
|
||||
beast::Journal j)
|
||||
beast::Journal j,
|
||||
DustSplit::LegPolicy* legPolicy = nullptr,
|
||||
bool legIsSender = false)
|
||||
{
|
||||
AccountID const& issuer = saAmount.getIssuer();
|
||||
Currency const& currency = saAmount.get<Issue>().currency;
|
||||
@@ -671,6 +682,34 @@ directSendNoFeeIOU(
|
||||
!isXRP(uReceiverID) && uReceiverID != noAccount(),
|
||||
"xrpl::directSendNoFeeIOU : receiver is not XRP");
|
||||
|
||||
// Canonical amendment gate for the sfDust mechanism.
|
||||
//
|
||||
// sfDust is introduced by featureLendingProtocolV1_1. Every code path
|
||||
// that reads or writes sfDust (vault_dust::useVaultDust,
|
||||
// creditBalanceExact, the removeEmptyHolding dust guard, and this
|
||||
// write-side check) enforces the same rules().enabled(...) gate.
|
||||
// In normal operation the gate is redundant — the transactor-level
|
||||
// eligibility predicates already imply the amendment is on — but a
|
||||
// hypothetical replay/testing path that ever presented a dust-aware
|
||||
// request under a pre-amendment rules() must NOT touch sfDust: the
|
||||
// field would then be neither SoeDefault(0) as expected pre-amendment
|
||||
// nor part of the ledger's canonical encoding. Assert in debug, fall
|
||||
// back to the pre-dust code path in release.
|
||||
if (legPolicy != nullptr && !view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
false,
|
||||
"xrpl::directSendNoFeeIOU : DustSplit::LegPolicy requires "
|
||||
"featureLendingProtocolV1_1");
|
||||
legPolicy = nullptr;
|
||||
}
|
||||
|
||||
// Drain mode is defined only for the sender's leg. There is no
|
||||
// receiver-side reservoir to drain.
|
||||
XRPL_ASSERT(
|
||||
legPolicy == nullptr || legPolicy->mode != DustSplit::LegPolicy::Mode::Drain || legIsSender,
|
||||
"xrpl::directSendNoFeeIOU : Drain mode is sender-leg only");
|
||||
|
||||
// If the line exists, modify it accordingly.
|
||||
if (auto const sleRippleState = view.peek(index))
|
||||
{
|
||||
@@ -683,7 +722,119 @@ directSendNoFeeIOU(
|
||||
|
||||
STAmount const saBefore = saBalance;
|
||||
|
||||
saBalance -= saAmount;
|
||||
// dustLedgerAfter holds the value that must be written to sfDust once
|
||||
// the branch below decides bDelete, in the LINE'S OWN sign
|
||||
// convention (i.e. not yet negated back to sender terms). Left
|
||||
// unset (nullopt) when this call must not touch sfDust at all —
|
||||
// which is every existing caller (legPolicy == nullptr) — so a
|
||||
// line that never carries dust is byte-identical to before.
|
||||
std::optional<Number> dustLedgerAfter;
|
||||
|
||||
if (legPolicy != nullptr)
|
||||
{
|
||||
// sfBalance and sfDust together are one signed extended
|
||||
// quantity, expressed in the trust line's own low/high sign
|
||||
// convention. Convert sfDust to sender terms so it lines up
|
||||
// with saBefore (which was negated above when the sender is
|
||||
// the high account).
|
||||
Number const lineDustLedger = Number{sleRippleState->at(sfDust)};
|
||||
Number const lineDustSender = bSenderHigh ? -lineDustLedger : lineDustLedger;
|
||||
|
||||
// The Vault-side write sites only ever park non-negative
|
||||
// dust on the pseudo-account's custody line. The rest of the
|
||||
// math below tolerates negative dust, but under Drain we
|
||||
// assert non-negativity because folding a negative reservoir
|
||||
// into the outgoing transfer would silently INCREASE the
|
||||
// amount sent — this must never happen in practice.
|
||||
XRPL_ASSERT(
|
||||
lineDustSender >= beast::kZero || !legIsSender ||
|
||||
legPolicy->mode != DustSplit::LegPolicy::Mode::Drain,
|
||||
"xrpl::directSendNoFeeIOU : sender-line dust is non-negative under Drain");
|
||||
|
||||
Number newBalance{0};
|
||||
Number newDust{0};
|
||||
|
||||
if (legPolicy->mode == DustSplit::LegPolicy::Mode::Drain)
|
||||
{
|
||||
// Drain: fold all of sfDust into sfBalance in place,
|
||||
// then debit `saAmount` — which the caller
|
||||
// (directSendNoLimitIOU) has already inflated by
|
||||
// `lineDustSender` so the outgoing transfer reaches the
|
||||
// receiver as `amount + D_sender`. The end state on the
|
||||
// sender's line is:
|
||||
// sfBalance_new = (saBefore + lineDustSender) - saAmount
|
||||
// = saBefore - (saAmount - lineDustSender)
|
||||
// = saBefore - amount_originally_requested
|
||||
// sfDust_new = 0
|
||||
// i.e. the sender's line loses exactly the caller's
|
||||
// originally-requested `amount` in whole-quanta terms,
|
||||
// and the sub-quantum reservoir has been forwarded.
|
||||
newBalance = (Number{saBefore} + lineDustSender) - Number{saAmount};
|
||||
newDust = Number{0};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Override mode: keep sfBalance representable at
|
||||
// `overrideScale`; park any sub-quantum remainder in
|
||||
// sfDust. Truncate the SUM toward zero — never round
|
||||
// the increment separately. This is what makes
|
||||
// promotion of previously-deferred dust automatic: if
|
||||
// the combined lineDustSender + credit clears a whole
|
||||
// quantum, that quantum lands in newBalance with no
|
||||
// separate fold step.
|
||||
Number const exactBefore = Number{saBefore} + lineDustSender;
|
||||
Number const exactAfter = exactBefore - Number{saAmount};
|
||||
|
||||
newBalance = roundToAsset(
|
||||
saAmount.asset(),
|
||||
exactAfter,
|
||||
legPolicy->overrideScale,
|
||||
Number::RoundingMode::TowardsZero);
|
||||
newDust = exactAfter - newBalance;
|
||||
|
||||
XRPL_ASSERT(
|
||||
newDust == beast::kZero || abs(newDust) < Number(1, legPolicy->overrideScale),
|
||||
"xrpl::directSendNoFeeIOU : dust remainder bounded by one quantum");
|
||||
XRPL_ASSERT(
|
||||
newDust == beast::kZero || exactAfter == beast::kZero ||
|
||||
(newDust < beast::kZero) == (exactAfter < beast::kZero),
|
||||
"xrpl::directSendNoFeeIOU : dust does not change sign of the extended "
|
||||
"quantity");
|
||||
}
|
||||
|
||||
Number const balanceDeltaSender = newBalance - Number{saBefore};
|
||||
Number const dustDeltaSender = newDust - lineDustSender;
|
||||
|
||||
// Report the deltas from the leg's non-issuer party's
|
||||
// perspective. Sender-leg reports sender-positive; the
|
||||
// internal computation is already in sender terms, so on
|
||||
// the sender-leg pass we report the sender-space deltas as
|
||||
// is. Receiver-leg reports receiver-positive, which is the
|
||||
// sign-flip of sender-space deltas (since balance changes
|
||||
// net to -saAmount).
|
||||
if (legIsSender)
|
||||
{
|
||||
legPolicy->balanceDelta = balanceDeltaSender;
|
||||
legPolicy->dustDelta = dustDeltaSender;
|
||||
}
|
||||
else
|
||||
{
|
||||
legPolicy->balanceDelta = -balanceDeltaSender;
|
||||
legPolicy->dustDelta = -dustDeltaSender;
|
||||
}
|
||||
|
||||
// newBalance is in SENDER terms (built from saBefore, which
|
||||
// was put there by the negation above); the existing
|
||||
// negate-back-to-ledger-terms code below applies to it
|
||||
// exactly as it would to the non-dust subtraction result.
|
||||
saBalance = STAmount{saAmount.asset(), newBalance};
|
||||
|
||||
dustLedgerAfter = bSenderHigh ? -newDust : newDust;
|
||||
}
|
||||
else
|
||||
{
|
||||
saBalance -= saAmount;
|
||||
}
|
||||
|
||||
JLOG(j.trace()) << "directSendNoFeeIOU: " << to_string(uSenderID) << " -> "
|
||||
<< to_string(uReceiverID) << " : before=" << saBefore.getFullText()
|
||||
@@ -733,6 +884,25 @@ directSendNoFeeIOU(
|
||||
// Receiver reserve is clear.
|
||||
}
|
||||
|
||||
// Trust-line-level protocol invariant: never delete a line whose
|
||||
// sfDust is non-zero — a deleted RIPPLE_STATE silently destroys
|
||||
// any value still parked there. The guard applies to the
|
||||
// dust-unaware branch too (legPolicy == nullptr), because the
|
||||
// line may carry sfDust left by an earlier dust-aware credit.
|
||||
// Skip the field-read entirely pre-amendment so the hot path
|
||||
// stays byte-identical to the base branch.
|
||||
if (dustLedgerAfter)
|
||||
{
|
||||
if (*dustLedgerAfter != beast::kZero)
|
||||
bDelete = false;
|
||||
}
|
||||
else if (
|
||||
view.rules().enabled(featureLendingProtocolV1_1) &&
|
||||
Number{sleRippleState->at(sfDust)} != beast::kZero)
|
||||
{
|
||||
bDelete = false;
|
||||
}
|
||||
|
||||
if (bSenderHigh)
|
||||
saBalance.negate();
|
||||
|
||||
@@ -740,6 +910,9 @@ directSendNoFeeIOU(
|
||||
sleRippleState->setFieldAmount(sfBalance, saBalance);
|
||||
// ONLY: Adjust balance.
|
||||
|
||||
if (dustLedgerAfter)
|
||||
sleRippleState->at(sfDust) = *dustLedgerAfter;
|
||||
|
||||
if (bDelete)
|
||||
{
|
||||
return trustDelete(
|
||||
@@ -754,6 +927,22 @@ directSendNoFeeIOU(
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
// A LegPolicy implies the trust line already exists — the caller is
|
||||
// supposed to arrange this (e.g. via addEmptyHolding) before any
|
||||
// dust-aware credit reaches it. Treat reaching here with a policy
|
||||
// as a caller error: assert in debug, and in release fall back to a
|
||||
// plain no-split credit so no value is silently dropped.
|
||||
XRPL_ASSERT(
|
||||
legPolicy == nullptr,
|
||||
"xrpl::directSendNoFeeIOU : dust split requires an existing trust line");
|
||||
if (legPolicy != nullptr)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
legPolicy->balanceDelta = legIsSender ? -Number{saAmount} : Number{saAmount};
|
||||
legPolicy->dustDelta = Number{0};
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
STAmount const saReceiverLimit(Issue{currency, uReceiverID});
|
||||
STAmount saBalance{saAmount};
|
||||
|
||||
@@ -801,7 +990,8 @@ directSendNoLimitIOU(
|
||||
STAmount& saActual,
|
||||
beast::Journal j,
|
||||
SLE::ref sponsorSle,
|
||||
WaiveTransferFee waiveFee)
|
||||
WaiveTransferFee waiveFee,
|
||||
DustSplit* dust = nullptr)
|
||||
{
|
||||
auto const& issuer = saAmount.getIssuer();
|
||||
|
||||
@@ -810,14 +1000,96 @@ directSendNoLimitIOU(
|
||||
"xrpl::directSendNoLimitIOU : neither sender nor receiver is XRP");
|
||||
XRPL_ASSERT(uSenderID != uReceiverID, "xrpl::directSendNoLimitIOU : sender is not receiver");
|
||||
|
||||
DustSplit::LegPolicy* receiverPolicy =
|
||||
(dust != nullptr && dust->receiver.has_value()) ? &*dust->receiver : nullptr;
|
||||
DustSplit::LegPolicy* senderPolicy =
|
||||
(dust != nullptr && dust->sender.has_value()) ? &*dust->sender : nullptr;
|
||||
|
||||
// Drain sender-leg: inflate the outgoing amount by the sender-line's
|
||||
// sfDust (in sender terms) so the receiver receives `amount + D_sender`.
|
||||
// The sender's line ends with `sfBalance` reduced by the caller's
|
||||
// originally-requested `amount` (whole-quanta portion) and `sfDust`
|
||||
// zeroed. Drain is meaningless when the sender IS the issuer (no
|
||||
// sender-side line exists on the issuer's side).
|
||||
STAmount saAmountEffective = saAmount;
|
||||
if (senderPolicy != nullptr && senderPolicy->mode == DustSplit::LegPolicy::Mode::Drain &&
|
||||
uSenderID != issuer && view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
Currency const& currency = saAmount.get<Issue>().currency;
|
||||
auto const senderLine = view.peek(keylet::trustLine(uSenderID, issuer, currency));
|
||||
if (senderLine)
|
||||
{
|
||||
bool const senderIsHigh = uSenderID > issuer;
|
||||
Number const dustLineTerms = Number{senderLine->at(sfDust)};
|
||||
Number const dustSenderTerms = senderIsHigh ? -dustLineTerms : dustLineTerms;
|
||||
XRPL_ASSERT(
|
||||
dustSenderTerms >= beast::kZero,
|
||||
"xrpl::directSendNoLimitIOU : sender-line dust non-negative under Drain");
|
||||
if (dustSenderTerms != beast::kZero)
|
||||
{
|
||||
saAmountEffective = saAmount + STAmount{saAmount.asset(), dustSenderTerms};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uSenderID == issuer || uReceiverID == issuer || issuer == noAccount())
|
||||
{
|
||||
// Direct send: redeeming IOUs and/or sending own IOUs.
|
||||
auto const ter =
|
||||
directSendNoFeeIOU(view, uSenderID, uReceiverID, saAmount, false, sponsorSle, j);
|
||||
// Direct send: redeeming IOUs and/or sending own IOUs. Only one
|
||||
// trust line is touched; the corresponding non-issuer party's
|
||||
// LegPolicy applies. The issuer-side policy must be absent —
|
||||
// there is no line to keep aligned on the issuer's side.
|
||||
DustSplit::LegPolicy* legPolicy = nullptr;
|
||||
bool legIsSender = false;
|
||||
if (dust != nullptr)
|
||||
{
|
||||
if (uSenderID == issuer)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
!dust->sender.has_value(),
|
||||
"xrpl::directSendNoLimitIOU : issuer-side sender policy must be absent");
|
||||
if (dust->receiver.has_value())
|
||||
{
|
||||
legPolicy = &*dust->receiver;
|
||||
legIsSender = false;
|
||||
}
|
||||
}
|
||||
else if (uReceiverID == issuer)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
!dust->receiver.has_value(),
|
||||
"xrpl::directSendNoLimitIOU : issuer-side receiver policy must be absent");
|
||||
if (dust->sender.has_value())
|
||||
{
|
||||
legPolicy = &*dust->sender;
|
||||
legIsSender = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// issuer == noAccount(): treat as a receiver-leg-only
|
||||
// credit (matches historical behaviour of the flat
|
||||
// DustSplit passed through here).
|
||||
if (dust->receiver.has_value())
|
||||
{
|
||||
legPolicy = &*dust->receiver;
|
||||
legIsSender = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto const ter = directSendNoFeeIOU(
|
||||
view,
|
||||
uSenderID,
|
||||
uReceiverID,
|
||||
saAmountEffective,
|
||||
false,
|
||||
sponsorSle,
|
||||
j,
|
||||
legPolicy,
|
||||
legIsSender);
|
||||
if (!isTesSuccess(ter))
|
||||
return ter;
|
||||
saActual = saAmount;
|
||||
saActual = saAmountEffective;
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
@@ -825,18 +1097,43 @@ directSendNoLimitIOU(
|
||||
|
||||
// Calculate the amount to transfer accounting
|
||||
// for any transfer fees if the fee is not waived:
|
||||
saActual = (waiveFee == WaiveTransferFee::Yes) ? saAmount
|
||||
: multiply(saAmount, transferRate(view, issuer));
|
||||
saActual = (waiveFee == WaiveTransferFee::Yes)
|
||||
? saAmountEffective
|
||||
: multiply(saAmountEffective, transferRate(view, issuer));
|
||||
|
||||
JLOG(j.debug()) << "directSendNoLimitIOU> " << to_string(uSenderID) << " - > "
|
||||
<< to_string(uReceiverID) << " : deliver=" << saAmount.getFullText()
|
||||
<< to_string(uReceiverID) << " : deliver=" << saAmountEffective.getFullText()
|
||||
<< " cost=" << saActual.getFullText();
|
||||
|
||||
TER terResult = directSendNoFeeIOU(view, issuer, uReceiverID, saAmount, true, sponsorSle, j);
|
||||
// Transit path: two trust-line touches. The receiver-leg policy
|
||||
// applies to the (issuer -> receiver) credit; the sender-leg
|
||||
// policy applies to the (sender -> issuer) debit. The effective
|
||||
// `saAmountEffective` reaches both legs — inflated when Drain is
|
||||
// active — so the receiver's inflow and the sender's outflow line
|
||||
// up in extended-balance terms.
|
||||
TER terResult = directSendNoFeeIOU(
|
||||
view,
|
||||
issuer,
|
||||
uReceiverID,
|
||||
saAmountEffective,
|
||||
true,
|
||||
sponsorSle,
|
||||
j,
|
||||
receiverPolicy,
|
||||
/*legIsSender=*/false);
|
||||
|
||||
if (tesSUCCESS == terResult)
|
||||
{
|
||||
terResult = directSendNoFeeIOU(view, uSenderID, issuer, saActual, true, sponsorSle, j);
|
||||
terResult = directSendNoFeeIOU(
|
||||
view,
|
||||
uSenderID,
|
||||
issuer,
|
||||
saActual,
|
||||
true,
|
||||
sponsorSle,
|
||||
j,
|
||||
senderPolicy,
|
||||
/*legIsSender=*/true);
|
||||
}
|
||||
|
||||
return terResult;
|
||||
@@ -845,6 +1142,10 @@ directSendNoLimitIOU(
|
||||
// Send regardless of limits.
|
||||
// --> receivers: Amount/currency/issuer to deliver to receivers.
|
||||
// <-- saActual: Amount actually cost to sender. Sender pays fees.
|
||||
//
|
||||
// The optional `dust` split applies only to the sender's leg (a single
|
||||
// trust line shared across all receivers). Only `dust->sender` is
|
||||
// consulted; `dust->receiver` must be nullopt.
|
||||
static TER
|
||||
directSendNoLimitMultiIOU(
|
||||
ApplyView& view,
|
||||
@@ -853,11 +1154,18 @@ directSendNoLimitMultiIOU(
|
||||
MultiplePaymentDestinations const& receivers,
|
||||
STAmount& actual,
|
||||
beast::Journal j,
|
||||
WaiveTransferFee waiveFee)
|
||||
WaiveTransferFee waiveFee,
|
||||
DustSplit* dust = nullptr)
|
||||
{
|
||||
auto const& issuer = issue.getIssuer();
|
||||
|
||||
XRPL_ASSERT(!isXRP(senderID), "xrpl::directSendNoLimitMultiIOU : sender is not XRP");
|
||||
XRPL_ASSERT(
|
||||
dust == nullptr || !dust->receiver.has_value(),
|
||||
"xrpl::directSendNoLimitMultiIOU : receiver-leg policy has no meaning in multi-send");
|
||||
|
||||
DustSplit::LegPolicy* senderPolicy =
|
||||
(dust != nullptr && dust->sender.has_value()) ? &*dust->sender : nullptr;
|
||||
|
||||
// These may diverge
|
||||
STAmount takeFromSender{issue};
|
||||
@@ -880,8 +1188,15 @@ directSendNoLimitMultiIOU(
|
||||
if (senderID == issuer || receiverID == issuer || issuer == noAccount())
|
||||
{
|
||||
// Direct send: redeeming IOUs and/or sending own IOUs.
|
||||
if (auto const ter =
|
||||
directSendNoFeeIOU(view, senderID, receiverID, amount, false, {}, j);
|
||||
// The sender-leg policy is applied only on the final bulk
|
||||
// sender-line debit below, not on any direct-to-issuer
|
||||
// intermediate credits — a sender-leg policy that touched
|
||||
// the sender's line multiple times would overwrite its own
|
||||
// out fields. In the current Vault-only use of this path
|
||||
// (loan disbursement), no recipient is the asset issuer, so
|
||||
// this branch never runs alongside a non-null senderPolicy.
|
||||
if (auto const ter = directSendNoFeeIOU(
|
||||
view, senderID, receiverID, amount, false, {}, j, nullptr, false);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
actual += amount;
|
||||
@@ -905,14 +1220,29 @@ directSendNoLimitMultiIOU(
|
||||
<< to_string(receiverID) << " : deliver=" << amount.getFullText()
|
||||
<< " cost=" << actual.getFullText();
|
||||
|
||||
if (TER const terResult = directSendNoFeeIOU(view, issuer, receiverID, amount, true, {}, j))
|
||||
// Receiver-leg policy is not defined for multi-send (there
|
||||
// are multiple receiver lines). Pass nullptr here.
|
||||
if (TER const terResult = directSendNoFeeIOU(
|
||||
view, issuer, receiverID, amount, true, {}, j, nullptr, /*legIsSender=*/false))
|
||||
return terResult;
|
||||
}
|
||||
|
||||
if (senderID != issuer && takeFromSender)
|
||||
{
|
||||
if (TER const terResult =
|
||||
directSendNoFeeIOU(view, senderID, issuer, takeFromSender, true, {}, j))
|
||||
// Bulk sender-line debit for all transit legs — this is the
|
||||
// single point where the sender's line changes for this
|
||||
// multi-send, so it's the only place the sender-leg policy
|
||||
// can apply.
|
||||
if (TER const terResult = directSendNoFeeIOU(
|
||||
view,
|
||||
senderID,
|
||||
issuer,
|
||||
takeFromSender,
|
||||
true,
|
||||
{},
|
||||
j,
|
||||
senderPolicy,
|
||||
/*legIsSender=*/true))
|
||||
return terResult;
|
||||
}
|
||||
|
||||
@@ -927,7 +1257,8 @@ accountSendIOU(
|
||||
STAmount const& saAmount,
|
||||
beast::Journal j,
|
||||
SLE::ref sponsorSle,
|
||||
WaiveTransferFee waiveFee)
|
||||
WaiveTransferFee waiveFee,
|
||||
DustSplit* dust = nullptr)
|
||||
{
|
||||
if (view.rules().enabled(fixAMMv1_1))
|
||||
{
|
||||
@@ -945,10 +1276,15 @@ accountSendIOU(
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
/* If we aren't sending anything or if the sender is the same as the
|
||||
* receiver then we don't need to do anything.
|
||||
*/
|
||||
if (!saAmount || (uSenderID == uReceiverID))
|
||||
// Drain sender-leg can legitimately request a zero-amount call: the
|
||||
// caller (e.g. terminal Vault removal) may have amount==0 but still
|
||||
// want to drain any residual sfDust on the sender's line. Route
|
||||
// through in that case so directSendNoLimitIOU can peek the sender's
|
||||
// line and inflate saAmount by the reservoir. All other zero-amount
|
||||
// or self-sends short-circuit as before.
|
||||
bool const drainRequested = dust != nullptr && dust->sender.has_value() &&
|
||||
dust->sender->mode == DustSplit::LegPolicy::Mode::Drain;
|
||||
if ((!saAmount && !drainRequested) || (uSenderID == uReceiverID))
|
||||
return tesSUCCESS;
|
||||
|
||||
if (!saAmount.native())
|
||||
@@ -959,9 +1295,11 @@ accountSendIOU(
|
||||
<< to_string(uReceiverID) << " : " << saAmount.getFullText();
|
||||
|
||||
return directSendNoLimitIOU(
|
||||
view, uSenderID, uReceiverID, saAmount, saActual, j, sponsorSle, waiveFee);
|
||||
view, uSenderID, uReceiverID, saAmount, saActual, j, sponsorSle, waiveFee, dust);
|
||||
}
|
||||
|
||||
XRPL_ASSERT(dust == nullptr, "xrpl::accountSendIOU : dust split is IOU-only");
|
||||
|
||||
/* XRP send which does not check reserve and can do pure adjustment.
|
||||
* Note that sender or receiver may be null and this not a mistake; this
|
||||
* setup is used during pathfinding and it is carefully controlled to
|
||||
@@ -1045,7 +1383,8 @@ accountSendMultiIOU(
|
||||
Issue const& issue,
|
||||
MultiplePaymentDestinations const& receivers,
|
||||
beast::Journal j,
|
||||
WaiveTransferFee waiveFee)
|
||||
WaiveTransferFee waiveFee,
|
||||
DustSplit* dust = nullptr)
|
||||
{
|
||||
XRPL_ASSERT_PARTS(
|
||||
receivers.size() > 1, "xrpl::accountSendMultiIOU", "multiple recipients provided");
|
||||
@@ -1056,9 +1395,12 @@ accountSendMultiIOU(
|
||||
JLOG(j.trace()) << "accountSendMultiIOU: " << to_string(senderID) << " sending "
|
||||
<< receivers.size() << " IOUs";
|
||||
|
||||
return directSendNoLimitMultiIOU(view, senderID, issue, receivers, actual, j, waiveFee);
|
||||
return directSendNoLimitMultiIOU(
|
||||
view, senderID, issue, receivers, actual, j, waiveFee, dust);
|
||||
}
|
||||
|
||||
XRPL_ASSERT(dust == nullptr, "xrpl::accountSendMultiIOU : dust split is IOU-only");
|
||||
|
||||
/* XRP send which does not check reserve and can do pure adjustment.
|
||||
* Note that sender or receiver may be null and this not a mistake; this
|
||||
* setup could be used during pathfinding and it is carefully controlled to
|
||||
@@ -1488,7 +1830,16 @@ directSendNoFee(
|
||||
{
|
||||
return saAmount.asset().visit(
|
||||
[&](Issue const&) {
|
||||
return directSendNoFeeIOU(view, uSenderID, uReceiverID, saAmount, bCheckIssuer, {}, j);
|
||||
return directSendNoFeeIOU(
|
||||
view,
|
||||
uSenderID,
|
||||
uReceiverID,
|
||||
saAmount,
|
||||
bCheckIssuer,
|
||||
{},
|
||||
j,
|
||||
/*legPolicy=*/nullptr,
|
||||
/*legIsSender=*/false);
|
||||
},
|
||||
[&](MPTIssue const&) {
|
||||
XRPL_ASSERT(!bCheckIssuer, "xrpl::directSendNoFee : not checking issuer");
|
||||
@@ -1505,13 +1856,16 @@ accountSend(
|
||||
beast::Journal j,
|
||||
SLE::ref sponsorSle,
|
||||
WaiveTransferFee waiveFee,
|
||||
AllowMPTOverflow allowOverflow)
|
||||
AllowMPTOverflow allowOverflow,
|
||||
DustSplit* dust)
|
||||
{
|
||||
return saAmount.asset().visit(
|
||||
[&](Issue const&) {
|
||||
return accountSendIOU(view, uSenderID, uReceiverID, saAmount, j, sponsorSle, waiveFee);
|
||||
return accountSendIOU(
|
||||
view, uSenderID, uReceiverID, saAmount, j, sponsorSle, waiveFee, dust);
|
||||
},
|
||||
[&](MPTIssue const&) {
|
||||
XRPL_ASSERT(dust == nullptr, "xrpl::accountSend : dust split is IOU-only");
|
||||
return accountSendMPT(
|
||||
view, uSenderID, uReceiverID, saAmount, j, waiveFee, allowOverflow);
|
||||
});
|
||||
@@ -1524,15 +1878,17 @@ accountSendMulti(
|
||||
Asset const& asset,
|
||||
MultiplePaymentDestinations const& receivers,
|
||||
beast::Journal j,
|
||||
WaiveTransferFee waiveFee)
|
||||
WaiveTransferFee waiveFee,
|
||||
DustSplit* dust)
|
||||
{
|
||||
XRPL_ASSERT_PARTS(
|
||||
receivers.size() > 1, "xrpl::accountSendMulti", "multiple recipients provided");
|
||||
return asset.visit(
|
||||
[&](Issue const& issue) {
|
||||
return accountSendMultiIOU(view, senderID, issue, receivers, j, waiveFee);
|
||||
return accountSendMultiIOU(view, senderID, issue, receivers, j, waiveFee, dust);
|
||||
},
|
||||
[&](MPTIssue const& issue) {
|
||||
XRPL_ASSERT(dust == nullptr, "xrpl::accountSendMulti : dust split is IOU-only");
|
||||
return accountSendMultiMPT(view, senderID, issue, receivers, j, waiveFee);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
@@ -157,4 +169,556 @@ getVaultVersion(SLE::const_ref vault)
|
||||
return static_cast<VaultVersion>(version);
|
||||
}
|
||||
|
||||
[[nodiscard]] int
|
||||
getVaultScale(SLE::const_ref vault)
|
||||
{
|
||||
if (!vault)
|
||||
return Number::kMinExponent - 1; // LCOV_EXCL_LINE
|
||||
return scale(vault->at(sfAssetsTotal), vault->at(sfAsset));
|
||||
}
|
||||
|
||||
[[nodiscard]] TER
|
||||
addVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& sender,
|
||||
STAmount const& amount,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::addVaultAssets : valid Vault sle");
|
||||
|
||||
// Forward to the dust-aware overlay for eligible Vaults (cash-basis +
|
||||
// IOU asset). Every other Vault runs the base body verbatim below,
|
||||
// byte-identical to a call with no overlay in the tree.
|
||||
if (vault_dust::useVaultDust(view, vault))
|
||||
return vault_dust::addVaultAssets(view, vault, sender, amount, valueDelta, j);
|
||||
|
||||
[[maybe_unused]] Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(amount.asset() == asset, "xrpl::addVaultAssets : amount matches vault asset");
|
||||
XRPL_ASSERT(
|
||||
valueDelta.asset() == asset, "xrpl::addVaultAssets : valueDelta matches vault asset");
|
||||
|
||||
XRPL_ASSERT(amount >= beast::kZero, "xrpl::addVaultAssets : amount is non-negative");
|
||||
|
||||
// Callers are responsible for rounding amount/valueDelta to whatever
|
||||
// scale their own accounting requires; this helper does not re-round.
|
||||
// valueDelta and amount are independent (e.g. a loan default written off
|
||||
// entirely by the vault, with no first-loss capital cover, has a nonzero
|
||||
// (and possibly negative) valueDelta but a zero amount; late/regular loan
|
||||
// payments can also carry a small negative valueDelta from untracked
|
||||
// interest rounding corrections), so both fields are always updated even
|
||||
// when there is nothing to transfer.
|
||||
vault->at(sfAssetsTotal) += valueDelta;
|
||||
vault->at(sfAssetsAvailable) += amount;
|
||||
view.update(vault);
|
||||
|
||||
if (auto const ter =
|
||||
accountSend(view, sender, vault->at(sfAccount), amount, j, {}, WaiveTransferFee::Yes);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Applies a full-removal mutation to the Vault's ledger fields: both
|
||||
// callers (clawbackVaultAssets and removeVaultAssets) apply `amount` to
|
||||
// sfAssetsTotal and sfAssetsAvailable equally (unlike
|
||||
// addVaultAssets/moveVaultAssets, a full removal always shrinks both fields
|
||||
// by the same amount). On a final removal, both fields are hard-reset to
|
||||
// exactly zero rather than computed via subtraction: see FinalRemoval's
|
||||
// doc comment for why an arithmetic subtraction cannot be trusted to land
|
||||
// on exactly zero here.
|
||||
void
|
||||
applyRemoveVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
STAmount const& amount,
|
||||
FinalRemoval finalRemoval)
|
||||
{
|
||||
if (finalRemoval == FinalRemoval::Yes)
|
||||
{
|
||||
vault->at(sfAssetsTotal) = 0;
|
||||
vault->at(sfAssetsAvailable) = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
vault->at(sfAssetsTotal) -= amount;
|
||||
vault->at(sfAssetsAvailable) -= amount;
|
||||
}
|
||||
view.update(vault);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
[[nodiscard]] TER
|
||||
clawbackVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& recipient,
|
||||
STAmount const& amount,
|
||||
beast::Journal j)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
vault && vault->getType() == ltVAULT, "xrpl::clawbackVaultAssets : valid Vault sle");
|
||||
|
||||
if (vault_dust::useVaultDust(view, vault))
|
||||
return vault_dust::clawbackVaultAssets(view, vault, recipient, amount, j);
|
||||
|
||||
Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(amount.asset() == asset, "xrpl::clawbackVaultAssets : amount matches vault asset");
|
||||
XRPL_ASSERT(amount > beast::kZero, "xrpl::clawbackVaultAssets : amount is positive");
|
||||
|
||||
if (amount > *vault->at(sfAssetsAvailable))
|
||||
return tefINTERNAL;
|
||||
|
||||
applyRemoveVaultAssets(view, vault, amount, FinalRemoval::No);
|
||||
|
||||
if (auto const ter = accountSend(
|
||||
view, vault->at(sfAccount), recipient, amount, j, {}, WaiveTransferFee::Yes);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
// Sanity check
|
||||
if (accountHolds(
|
||||
view,
|
||||
vault->at(sfAccount),
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j) < beast::kZero)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j.error()) << "clawbackVaultAssets: negative balance of vault assets.";
|
||||
return tefINTERNAL;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] TER
|
||||
removeVaultAssets(
|
||||
ApplyViewContext ctx,
|
||||
SLE::ref vault,
|
||||
AccountID const& senderAcct,
|
||||
AccountID const& dstAcct,
|
||||
XRPAmount priorBalance,
|
||||
STAmount const& amount,
|
||||
beast::Journal j,
|
||||
FinalRemoval finalRemoval)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::removeVaultAssets : valid Vault sle");
|
||||
|
||||
if (vault_dust::useVaultDust(ctx.view, vault))
|
||||
return vault_dust::removeVaultAssets(
|
||||
ctx, vault, senderAcct, dstAcct, priorBalance, amount, j, finalRemoval);
|
||||
|
||||
[[maybe_unused]] Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(amount.asset() == asset, "xrpl::removeVaultAssets : amount matches vault asset");
|
||||
XRPL_ASSERT(amount >= beast::kZero, "xrpl::removeVaultAssets : amount is non-negative");
|
||||
|
||||
applyRemoveVaultAssets(ctx.view, vault, amount, finalRemoval);
|
||||
|
||||
if (amount == beast::kZero)
|
||||
return tesSUCCESS; // LCOV_EXCL_LINE
|
||||
|
||||
return doWithdraw(ctx, senderAcct, dstAcct, vault->at(sfAccount), priorBalance, amount, j);
|
||||
}
|
||||
|
||||
[[nodiscard]] TER
|
||||
moveVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
MultiplePaymentDestinations const& recipients,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j)
|
||||
{
|
||||
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::moveVaultAssets : valid Vault sle");
|
||||
|
||||
if (vault_dust::useVaultDust(view, vault))
|
||||
return vault_dust::moveVaultAssets(view, vault, recipients, valueDelta, j);
|
||||
|
||||
XRPL_ASSERT(recipients.size() > 1, "xrpl::moveVaultAssets : multiple recipients provided");
|
||||
|
||||
Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(
|
||||
valueDelta.asset() == asset, "xrpl::moveVaultAssets : valueDelta matches vault asset");
|
||||
XRPL_ASSERT(
|
||||
valueDelta == beast::kZero || getVaultVersion(vault) == VaultVersion::Legacy,
|
||||
"xrpl::moveVaultAssets : nonzero valueDelta requires Legacy vault version");
|
||||
|
||||
Number amountTotal{};
|
||||
for (auto const& [recipient, recipientAmount] : recipients)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
recipientAmount >= beast::kZero,
|
||||
"xrpl::moveVaultAssets : recipientAmount is non-negative");
|
||||
amountTotal += recipientAmount;
|
||||
}
|
||||
STAmount const amount{asset, amountTotal};
|
||||
|
||||
// valueDelta follows addVaultAssets's convention (added to sfAssetsTotal):
|
||||
// disbursing a loan typically increases sfAssetsTotal via accrued
|
||||
// interest even as cash leaves sfAssetsAvailable.
|
||||
|
||||
vault->at(sfAssetsTotal) += valueDelta;
|
||||
vault->at(sfAssetsAvailable) -= amount;
|
||||
view.update(vault);
|
||||
|
||||
if (amount == beast::kZero)
|
||||
return tesSUCCESS;
|
||||
|
||||
return accountSendMulti(
|
||||
view, vault->at(sfAccount), asset, recipients, j, WaiveTransferFee::Yes);
|
||||
}
|
||||
|
||||
namespace vault_dust {
|
||||
|
||||
namespace {
|
||||
|
||||
// Compute the exponent (scale) sfBalance on the Vault's custody line must
|
||||
// remain representable at after this operation. This is the scale of
|
||||
// sfAssetsTotal *after* applying `deltaToAssetsTotal`, using nearest-
|
||||
// rounding so a same-magnitude jump does not oscillate the scale
|
||||
// arbitrarily by a single ulp.
|
||||
int
|
||||
posteriorScale(SLE::const_ref vault, Number const& deltaToAssetsTotal)
|
||||
{
|
||||
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
|
||||
Number const posterior = Number{vault->at(sfAssetsTotal)} + deltaToAssetsTotal;
|
||||
return scale(posterior, vault->at(sfAsset));
|
||||
}
|
||||
|
||||
// Build a sender-leg Override DustSplit targeting the Vault's current
|
||||
// (post-mutation) sfAssetsTotal scale. Shared by clawback, non-terminal
|
||||
// withdraw, and multi-recipient move — every sender-leg dust-aware call
|
||||
// path in vault_dust uses the same shape.
|
||||
DustSplit
|
||||
makeSenderOverride(SLE::const_ref vault, Asset const& asset)
|
||||
{
|
||||
DustSplit split;
|
||||
split.sender = DustSplit::LegPolicy{
|
||||
.mode = DustSplit::LegPolicy::Mode::Override,
|
||||
.overrideScale = scale(Number{vault->at(sfAssetsTotal)}, asset)};
|
||||
return split;
|
||||
}
|
||||
|
||||
// Reconcile Vault fields against a sender-leg dust report. `dustDelta` is
|
||||
// the change in the custody line's sfDust (sender-positive: positive when
|
||||
// dust was newly deferred, negative when previously-deferred dust was
|
||||
// promoted into sfBalance). Both Vault fields shift by that delta so the
|
||||
// receivable (sfAssetsTotal - sfAssetsAvailable) stays aligned with the
|
||||
// line's newDust exactly. No-op when no sender-leg policy ran.
|
||||
void
|
||||
reconcileSenderDust(ApplyView& view, SLE::ref vault, DustSplit const& split)
|
||||
{
|
||||
if (!split.sender)
|
||||
return;
|
||||
vault->at(sfAssetsAvailable) -= split.sender->dustDelta;
|
||||
vault->at(sfAssetsTotal) -= split.sender->dustDelta;
|
||||
view.update(vault);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
[[nodiscard]] bool
|
||||
useVaultDust(ReadView const& view, SLE::const_ref vault)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
vault && vault->getType() == ltVAULT, "xrpl::vault_dust::useVaultDust : valid Vault sle");
|
||||
// Amendment gate is defense-in-depth; see directSendNoFeeIOU
|
||||
// (TokenHelpers.cpp) for the canonical rationale.
|
||||
if (!view.rules().enabled(featureLendingProtocolV1_1))
|
||||
return false;
|
||||
Asset const asset = vault->at(sfAsset);
|
||||
return getVaultVersion(vault) == VaultVersion::CashBasis && !asset.integral();
|
||||
}
|
||||
|
||||
[[nodiscard]] TER
|
||||
addVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& sender,
|
||||
STAmount const& amount,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
vault && vault->getType() == ltVAULT, "xrpl::vault_dust::addVaultAssets : valid Vault sle");
|
||||
XRPL_ASSERT(
|
||||
useVaultDust(view, vault), "xrpl::vault_dust::addVaultAssets : useVaultDust precondition");
|
||||
|
||||
Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(
|
||||
amount.asset() == asset, "xrpl::vault_dust::addVaultAssets : amount matches vault asset");
|
||||
XRPL_ASSERT(
|
||||
valueDelta.asset() == asset,
|
||||
"xrpl::vault_dust::addVaultAssets : valueDelta matches vault asset");
|
||||
XRPL_ASSERT(
|
||||
amount >= beast::kZero, "xrpl::vault_dust::addVaultAssets : amount is non-negative");
|
||||
|
||||
// Route the credit through a DustSplit's receiver-leg policy
|
||||
// targeting the Vault's posterior scale (the scale implied by
|
||||
// sfAssetsTotal + valueDelta): any sub-quantum remainder on the
|
||||
// Vault's custody line lands in sfDust rather than being lost. The
|
||||
// sender's line is written by the pre-credit debit leg and does not
|
||||
// participate in the split — a depositor's own trust line is
|
||||
// dust-unaware.
|
||||
DustSplit split;
|
||||
split.receiver = DustSplit::LegPolicy{
|
||||
.mode = DustSplit::LegPolicy::Mode::Override,
|
||||
.overrideScale = posteriorScale(vault, Number{valueDelta})};
|
||||
if (auto const ter = accountSend(
|
||||
view,
|
||||
sender,
|
||||
vault->at(sfAccount),
|
||||
amount,
|
||||
j,
|
||||
{},
|
||||
WaiveTransferFee::Yes,
|
||||
AllowMPTOverflow::No,
|
||||
&split);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
// Apply the accounting correction so the receivable
|
||||
// (sfAssetsTotal - sfAssetsAvailable) matches what a dust-unaware
|
||||
// call would produce: sfAssetsAvailable moves by the aligned
|
||||
// balanceDelta, and sfAssetsTotal absorbs the sub-quantum residual so
|
||||
// (valueDelta - dustDelta) - balanceDelta == valueDelta - amount.
|
||||
// receiver-leg deltas are receiver-positive, so we ADD them
|
||||
// directly to the Vault's fields (the Vault IS the receiver here).
|
||||
//
|
||||
// Any dust stranded on the custody line by a scale-refining prior
|
||||
// operation is renormalised by the credit-path re-split itself
|
||||
// (`directSendNoFeeIOU` truncates the extended balance +
|
||||
// credit at the Override target scale, so a decade-boundary crossing
|
||||
// automatically promotes freed whole-quanta into sfBalance). No
|
||||
// separate renormaliseStrandedDust pass is needed here.
|
||||
vault->at(sfAssetsAvailable) += split.receiver->balanceDelta;
|
||||
vault->at(sfAssetsTotal) += Number{valueDelta} - split.receiver->dustDelta;
|
||||
view.update(vault);
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] TER
|
||||
clawbackVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
AccountID const& recipient,
|
||||
STAmount const& amount,
|
||||
beast::Journal j)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
vault && vault->getType() == ltVAULT,
|
||||
"xrpl::vault_dust::clawbackVaultAssets : valid Vault sle");
|
||||
XRPL_ASSERT(
|
||||
useVaultDust(view, vault),
|
||||
"xrpl::vault_dust::clawbackVaultAssets : useVaultDust precondition");
|
||||
|
||||
Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(
|
||||
amount.asset() == asset,
|
||||
"xrpl::vault_dust::clawbackVaultAssets : amount matches vault asset");
|
||||
XRPL_ASSERT(
|
||||
amount > beast::kZero, "xrpl::vault_dust::clawbackVaultAssets : amount is positive");
|
||||
|
||||
if (amount > *vault->at(sfAssetsAvailable))
|
||||
return tefINTERNAL;
|
||||
|
||||
// Same field mutation as the base clawback: a full removal shrinks
|
||||
// sfAssetsTotal and sfAssetsAvailable equally.
|
||||
vault->at(sfAssetsTotal) -= amount;
|
||||
vault->at(sfAssetsAvailable) -= amount;
|
||||
view.update(vault);
|
||||
|
||||
// A clawback shrinks sfAssetsTotal, refining the Vault's scale, so
|
||||
// any stranded whole quanta on the custody line's sfDust need to
|
||||
// be promoted back into sfBalance. Drive this through a sender-leg
|
||||
// Override policy targeting the Vault's posterior scale; the trust
|
||||
// -line layer re-splits (sfBalance, sfDust) at that scale and
|
||||
// reports any dust promotion or newly-deferred residual.
|
||||
DustSplit split = makeSenderOverride(vault, asset);
|
||||
|
||||
if (auto const ter = accountSend(
|
||||
view,
|
||||
vault->at(sfAccount),
|
||||
recipient,
|
||||
amount,
|
||||
j,
|
||||
{},
|
||||
WaiveTransferFee::Yes,
|
||||
AllowMPTOverflow::No,
|
||||
&split);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
if (accountHolds(
|
||||
view,
|
||||
vault->at(sfAccount),
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j) < beast::kZero)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j.error()) << "vault_dust::clawbackVaultAssets: negative balance of vault assets.";
|
||||
return tefINTERNAL;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
reconcileSenderDust(view, vault, split);
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] TER
|
||||
removeVaultAssets(
|
||||
ApplyViewContext ctx,
|
||||
SLE::ref vault,
|
||||
AccountID const& senderAcct,
|
||||
AccountID const& dstAcct,
|
||||
XRPAmount priorBalance,
|
||||
STAmount const& amount,
|
||||
beast::Journal j,
|
||||
FinalRemoval finalRemoval)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
vault && vault->getType() == ltVAULT,
|
||||
"xrpl::vault_dust::removeVaultAssets : valid Vault sle");
|
||||
XRPL_ASSERT(
|
||||
useVaultDust(ctx.view, vault),
|
||||
"xrpl::vault_dust::removeVaultAssets : useVaultDust precondition");
|
||||
|
||||
Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(
|
||||
amount.asset() == asset,
|
||||
"xrpl::vault_dust::removeVaultAssets : amount matches vault asset");
|
||||
XRPL_ASSERT(
|
||||
amount >= beast::kZero, "xrpl::vault_dust::removeVaultAssets : amount is non-negative");
|
||||
|
||||
if (finalRemoval == FinalRemoval::Yes)
|
||||
{
|
||||
// Terminal branch: drive the drain through a sender-leg Drain
|
||||
// policy on the outgoing withdrawal. The trust-line layer folds
|
||||
// sfDust into the sender's sfBalance in place, inflates the
|
||||
// outgoing amount so the destination receives `amount + dust`,
|
||||
// and zeroes sfDust — leaving the custody line ready for
|
||||
// downstream deletion guards.
|
||||
AccountID const vaultAccount = vault->at(sfAccount);
|
||||
|
||||
DustSplit split;
|
||||
split.sender =
|
||||
DustSplit::LegPolicy{.mode = DustSplit::LegPolicy::Mode::Drain, .overrideScale = 0};
|
||||
|
||||
// Hard-reset both accounting fields to exactly zero — the same
|
||||
// contract as the base helper's FinalRemoval::Yes branch.
|
||||
vault->at(sfAssetsTotal) = 0;
|
||||
vault->at(sfAssetsAvailable) = 0;
|
||||
ctx.view.update(vault);
|
||||
|
||||
// Short-circuit only when both the whole-quanta amount and the
|
||||
// sub-quantum reservoir on the custody line are zero. The
|
||||
// dust-inclusive read helper collapses sfBalance + sfDust; when
|
||||
// amount==0 the sfBalance component is also zero, so a zero
|
||||
// extended balance means "nothing to drain".
|
||||
if (amount == beast::kZero &&
|
||||
creditBalanceExact(ctx.view, vaultAccount, asset.get<Issue>()) == beast::kZero)
|
||||
return tesSUCCESS;
|
||||
|
||||
return doWithdraw(ctx, senderAcct, dstAcct, vaultAccount, priorBalance, amount, j, &split);
|
||||
}
|
||||
|
||||
// Non-terminal: same field mutation as the base helper (both fields
|
||||
// drop by `amount`), then a dust-aware doWithdraw driven by a
|
||||
// sender-leg Override policy targeting the Vault's posterior
|
||||
// scale. The trust-line layer re-splits (sfBalance, sfDust) on the
|
||||
// custody line at the new scale and reports back any promoted /
|
||||
// newly-deferred sub-quantum residual so the Vault's fields stay
|
||||
// aligned.
|
||||
vault->at(sfAssetsTotal) -= amount;
|
||||
vault->at(sfAssetsAvailable) -= amount;
|
||||
ctx.view.update(vault);
|
||||
|
||||
if (amount != beast::kZero)
|
||||
{
|
||||
DustSplit split = makeSenderOverride(vault, asset);
|
||||
|
||||
if (auto const ter = doWithdraw(
|
||||
ctx, senderAcct, dstAcct, vault->at(sfAccount), priorBalance, amount, j, &split);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
reconcileSenderDust(ctx.view, vault, split);
|
||||
}
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] TER
|
||||
moveVaultAssets(
|
||||
ApplyView& view,
|
||||
SLE::ref vault,
|
||||
MultiplePaymentDestinations const& recipients,
|
||||
STAmount const& valueDelta,
|
||||
beast::Journal j)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
vault && vault->getType() == ltVAULT,
|
||||
"xrpl::vault_dust::moveVaultAssets : valid Vault sle");
|
||||
XRPL_ASSERT(
|
||||
useVaultDust(view, vault), "xrpl::vault_dust::moveVaultAssets : useVaultDust precondition");
|
||||
XRPL_ASSERT(
|
||||
recipients.size() > 1, "xrpl::vault_dust::moveVaultAssets : multiple recipients provided");
|
||||
|
||||
Asset const asset = vault->at(sfAsset);
|
||||
XRPL_ASSERT(
|
||||
valueDelta.asset() == asset,
|
||||
"xrpl::vault_dust::moveVaultAssets : valueDelta matches vault asset");
|
||||
XRPL_ASSERT(
|
||||
valueDelta == beast::kZero || getVaultVersion(vault) == VaultVersion::Legacy,
|
||||
"xrpl::vault_dust::moveVaultAssets : nonzero valueDelta requires Legacy vault version");
|
||||
|
||||
Number amountTotal{};
|
||||
for (auto const& [recipient, recipientAmount] : recipients)
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
recipientAmount >= beast::kZero,
|
||||
"xrpl::vault_dust::moveVaultAssets : recipientAmount is non-negative");
|
||||
amountTotal += recipientAmount;
|
||||
}
|
||||
STAmount const amount{asset, amountTotal};
|
||||
|
||||
// Field mutations first (same as the base helper), then the multi-
|
||||
// send with a sender-leg Override policy so any dust freed by the
|
||||
// scale refinement (from valueDelta / amount) surfaces via the
|
||||
// trust-line layer. accountSendMulti applies the sender-leg
|
||||
// policy on the single shared sender-line debit; the multiple
|
||||
// receiver-line credits are dust-unaware (the trust-line layer
|
||||
// has no receiver-leg policy plumbed through the multi path).
|
||||
vault->at(sfAssetsTotal) += valueDelta;
|
||||
vault->at(sfAssetsAvailable) -= amount;
|
||||
view.update(vault);
|
||||
|
||||
if (amount != beast::kZero)
|
||||
{
|
||||
DustSplit split = makeSenderOverride(vault, asset);
|
||||
|
||||
if (auto const ter = accountSendMulti(
|
||||
view, vault->at(sfAccount), asset, recipients, j, WaiveTransferFee::Yes, &split);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
reconcileSenderDust(view, vault, split);
|
||||
}
|
||||
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
} // namespace vault_dust
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -116,6 +116,10 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
|
||||
// Trust Line balances are STAmounts, so we can use the exponent
|
||||
// directly to get the scale.
|
||||
balanceDelta.scale = amount.exponent();
|
||||
// sfDust follows the same low/high convention as sfBalance.
|
||||
// Pre-amendment sfDust is SoeDefault (0), so this is a no-
|
||||
// op then.
|
||||
balanceDelta.dustDelta = Number{before->at(sfDust)};
|
||||
sign = -1;
|
||||
break;
|
||||
}
|
||||
@@ -161,6 +165,9 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
|
||||
// directly to get the scale.
|
||||
if (amount.exponent() > balanceDelta.scale)
|
||||
balanceDelta.scale = amount.exponent();
|
||||
// Mirror the sfBalance accumulation so dustDelta ends up as
|
||||
// "before - after" (later sign-flipped to "after - before").
|
||||
balanceDelta.dustDelta -= Number{after->at(sfDust)};
|
||||
sign = -1;
|
||||
break;
|
||||
}
|
||||
@@ -178,6 +185,10 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
|
||||
{
|
||||
XRPL_ASSERT_PARTS(balanceDelta.scale, "xrpl::ValidVault::visitEntry", "scale initialized");
|
||||
balanceDelta.delta *= sign;
|
||||
// dustDelta was accumulated with the same "before - after" idiom;
|
||||
// apply the same sign flip so it represents "after - before" in
|
||||
// the trust line's low/high convention.
|
||||
balanceDelta.dustDelta *= sign;
|
||||
deltas_[key] = balanceDelta;
|
||||
}
|
||||
}
|
||||
@@ -202,8 +213,12 @@ ValidVault::deltaAssets(AccountID const& id) const
|
||||
auto result = lookup(keylet::trustLine(id, issue).key);
|
||||
// Trust-line balance is stored from the low-account's perspective;
|
||||
// negate if id is the high account so the delta is in id's terms.
|
||||
// dustDelta shares the same convention, so flip it in lockstep.
|
||||
if (result && id > issue.getIssuer())
|
||||
{
|
||||
result->delta = -result->delta;
|
||||
result->dustDelta = -result->dustDelta;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
else if constexpr (std::is_same_v<TIss, MPTIssue>)
|
||||
@@ -878,8 +893,26 @@ ValidVault::finalize(
|
||||
result = false;
|
||||
}
|
||||
|
||||
// Compare EXTENDED balances (sfBalance + sfDust) on
|
||||
// both sides for the cash-flow parity check. Under
|
||||
// featureLendingProtocolV1_1 a dust-aware withdrawal
|
||||
// may reshape the vault custody line's sfBalance /
|
||||
// sfDust split (Override promotes / defers; Drain
|
||||
// folds sfDust into the outgoing transfer) — all
|
||||
// internal recognition moves preserved by the
|
||||
// extended total. For non-dust callers dustDelta is
|
||||
// zero, so this reduces to the base sfBalance
|
||||
// comparison. See VaultRoundingTrustlineDust_test::
|
||||
// testNonTerminalWithdrawAfterDust for the case this
|
||||
// addresses.
|
||||
Number const extendedPseudoDelta =
|
||||
maybeVaultDeltaAssets->delta + maybeVaultDeltaAssets->dustDelta;
|
||||
Number const extendedDestinationDelta =
|
||||
destinationDelta.delta + destinationDelta.dustDelta;
|
||||
auto const localPseudoDeltaAssets =
|
||||
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
|
||||
roundToAsset(vaultAsset, extendedPseudoDelta, localMinScale);
|
||||
auto const localDestinationDelta =
|
||||
roundToAsset(vaultAsset, extendedDestinationDelta, localMinScale);
|
||||
// For IOU assets near a precision boundary the destination's STAmount
|
||||
// exponent can shift, making part of the sent value unrepresentable at the
|
||||
// receiver's new scale — that portion is irreversibly absorbed by the IOU
|
||||
@@ -891,11 +924,10 @@ ValidVault::finalize(
|
||||
auto const destroyedIsSubUlp = tolerateZeroDelta &&
|
||||
roundToAsset(
|
||||
vaultAsset,
|
||||
maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
|
||||
extendedPseudoDelta * -1 - extendedDestinationDelta,
|
||||
destinationScale,
|
||||
Number::RoundingMode::Downward) == kZero;
|
||||
if (!destroyedIsSubUlp &&
|
||||
localPseudoDeltaAssets * -1 != roundedDestinationDelta)
|
||||
if (!destroyedIsSubUlp && localPseudoDeltaAssets * -1 != localDestinationDelta)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: " << //
|
||||
"withdrawal must change vault and destination balance by equal "
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
@@ -78,7 +79,7 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx)
|
||||
{
|
||||
// Any remaining debt should have been wiped out by the last Loan
|
||||
// Delete. This check is purely defensive.
|
||||
auto const scale = getAssetsTotalScale(vault);
|
||||
auto const scale = getVaultScale(vault);
|
||||
|
||||
auto const rounded =
|
||||
roundToAsset(asset, debtTotal, scale, Number::RoundingMode::TowardsZero);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h> // IWYU pragma: keep
|
||||
@@ -120,7 +121,7 @@ LoanDelete::doApply()
|
||||
roundToAsset(
|
||||
vaultSle->at(sfAsset),
|
||||
debtTotalProxy,
|
||||
getAssetsTotalScale(vaultSle),
|
||||
getVaultScale(vaultSle),
|
||||
Number::RoundingMode::TowardsZero) == beast::kZero,
|
||||
"xrpl::LoanDelete::doApply",
|
||||
"last loan, remaining debt rounds to zero");
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
@@ -172,14 +172,17 @@ LoanManage::defaultLoan(
|
||||
// The vault may be at a different scale than the loan. Reduce rounding
|
||||
// errors during the accounting by rounding some of the values to that
|
||||
// scale.
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const vaultScale = getVaultScale(vaultSle);
|
||||
|
||||
Number vaultTotalDelta;
|
||||
{
|
||||
// Decrease the Total Value of the Vault:
|
||||
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
|
||||
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
|
||||
// Decrease the Total Value of the Vault. Compute using local values
|
||||
// (rather than mutating the Vault's proxies directly) so the actual
|
||||
// field update can be applied once, via addVaultAssets below.
|
||||
Number const vaultTotalBefore = vaultSle->at(sfAssetsTotal);
|
||||
Number const vaultAvailableBefore = vaultSle->at(sfAssetsAvailable);
|
||||
|
||||
if (vaultTotalProxy < vaultDefaultAmount)
|
||||
if (vaultTotalBefore < vaultDefaultAmount)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j.warn()) << "Vault total assets is less than the vault default amount";
|
||||
@@ -189,33 +192,32 @@ LoanManage::defaultLoan(
|
||||
|
||||
auto const vaultDefaultRounded = roundToAsset(
|
||||
vaultAsset, vaultDefaultAmount, vaultScale, Number::RoundingMode::Downward);
|
||||
vaultTotalProxy -= vaultDefaultRounded;
|
||||
Number vaultTotalAfter = vaultTotalBefore - vaultDefaultRounded;
|
||||
// Increase the Asset Available of the Vault by liquidated First-Loss
|
||||
// Capital and any unclaimed funds amount:
|
||||
vaultAvailableProxy += defaultCovered;
|
||||
if (*vaultAvailableProxy > *vaultTotalProxy && !vaultAsset.integral())
|
||||
Number const vaultAvailableAfter = vaultAvailableBefore + defaultCovered;
|
||||
if (vaultAvailableAfter > vaultTotalAfter && !vaultAsset.integral())
|
||||
{
|
||||
auto const difference = vaultAvailableProxy - vaultTotalProxy;
|
||||
JLOG(j.debug()) << "Vault assets available: " << *vaultAvailableProxy << "("
|
||||
<< vaultAvailableProxy.value().exponent()
|
||||
<< "), Total: " << *vaultTotalProxy << "("
|
||||
<< vaultTotalProxy.value().exponent() << "), Difference: " << difference
|
||||
auto const difference = vaultAvailableAfter - vaultTotalAfter;
|
||||
JLOG(j.debug()) << "Vault assets available: " << vaultAvailableAfter << "("
|
||||
<< vaultAvailableAfter.exponent() << "), Total: " << vaultTotalAfter
|
||||
<< "(" << vaultTotalAfter.exponent() << "), Difference: " << difference
|
||||
<< "(" << difference.exponent() << ")";
|
||||
if (vaultAvailableProxy.value().exponent() - difference.exponent() > 13)
|
||||
if (vaultAvailableAfter.exponent() - difference.exponent() > 13)
|
||||
{
|
||||
// If the difference is dust, bring the total up to match
|
||||
// the available
|
||||
JLOG(j.debug()) << "Difference between vault assets available and total is "
|
||||
"dust. Set both to the larger value.";
|
||||
vaultTotalProxy = vaultAvailableProxy;
|
||||
vaultTotalAfter = vaultAvailableAfter;
|
||||
}
|
||||
}
|
||||
if (*vaultAvailableProxy > *vaultTotalProxy)
|
||||
if (vaultAvailableAfter > vaultTotalAfter)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j.fatal()) << "Vault assets available must not be greater "
|
||||
"than assets outstanding. Available: "
|
||||
<< *vaultAvailableProxy << ", Total: " << *vaultTotalProxy;
|
||||
<< vaultAvailableAfter << ", Total: " << vaultTotalAfter;
|
||||
return tecINTERNAL;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
@@ -234,7 +236,7 @@ LoanManage::defaultLoan(
|
||||
adjustImpreciseNumber(
|
||||
vaultLossUnrealizedProxy, -totalDefaultAmount, vaultAsset, vaultScale);
|
||||
}
|
||||
view.update(vaultSle);
|
||||
vaultTotalDelta = vaultTotalAfter - vaultTotalBefore;
|
||||
}
|
||||
|
||||
// Update the LoanBroker object:
|
||||
@@ -267,16 +269,15 @@ LoanManage::defaultLoan(
|
||||
loanSle->at(sfNextPaymentDueDate) = 0;
|
||||
view.update(loanSle);
|
||||
|
||||
// Return funds from the LoanBroker pseudo-account to the
|
||||
// Vault pseudo-account:
|
||||
return accountSend(
|
||||
// Update the Vault's assets, and return funds from the LoanBroker
|
||||
// pseudo-account to the Vault pseudo-account:
|
||||
return addVaultAssets(
|
||||
view,
|
||||
vaultSle,
|
||||
brokerSle->at(sfAccount),
|
||||
vaultSle->at(sfAccount),
|
||||
STAmount{vaultAsset, defaultCovered},
|
||||
j,
|
||||
{},
|
||||
WaiveTransferFee::Yes);
|
||||
STAmount{vaultAsset, vaultTotalDelta},
|
||||
j);
|
||||
}
|
||||
|
||||
TER
|
||||
@@ -292,7 +293,7 @@ LoanManage::impairLoan(
|
||||
// The vault may be at a different scale than the loan. Reduce rounding
|
||||
// errors during the accounting by rounding some of the values to that
|
||||
// scale.
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const vaultScale = getVaultScale(vaultSle);
|
||||
|
||||
// Update the Vault object(set "paper loss")
|
||||
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
|
||||
@@ -332,7 +333,7 @@ LoanManage::unimpairLoan(
|
||||
// The vault may be at a different scale than the loan. Reduce rounding
|
||||
// errors during the accounting by rounding some of the values to that
|
||||
// scale.
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const vaultScale = getVaultScale(vaultSle);
|
||||
|
||||
// Update the Vault object(clear "paper loss")
|
||||
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
@@ -310,7 +311,7 @@ LoanPay::doApply()
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
auto debtTotalProxy = brokerSle->at(sfDebtTotal);
|
||||
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const vaultScale = getVaultScale(vaultSle);
|
||||
|
||||
// Send the broker fee to the owner if they have sufficient cover available,
|
||||
// _and_ if the owner can receive funds
|
||||
@@ -432,12 +433,53 @@ LoanPay::doApply()
|
||||
// LoanBroker object state changes
|
||||
view.update(brokerSle);
|
||||
|
||||
auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable);
|
||||
auto assetsTotalProxy = vaultSle->at(sfAssetsTotal);
|
||||
Number const assetsAvailableBefore = vaultSle->at(sfAssetsAvailable);
|
||||
Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal);
|
||||
|
||||
// These three values are used to check that funds are conserved after the transfers
|
||||
auto const accountBalanceBefore = accountHolds(
|
||||
view,
|
||||
accountID_,
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_,
|
||||
SpendableHandling::FullBalance);
|
||||
auto const vaultBalanceBefore = accountID_ == vaultPseudoAccount
|
||||
? STAmount{asset, 0}
|
||||
: accountHolds(
|
||||
view,
|
||||
vaultPseudoAccount,
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_,
|
||||
SpendableHandling::FullBalance);
|
||||
auto const brokerBalanceBefore = accountID_ == brokerPayee
|
||||
? STAmount{asset, 0}
|
||||
: accountHolds(
|
||||
view,
|
||||
brokerPayee,
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_,
|
||||
SpendableHandling::FullBalance);
|
||||
|
||||
auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
|
||||
auto const totalPaidToVaultRounded =
|
||||
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
|
||||
|
||||
// Cash-basis IOU Vaults route the credit through the dust-aware
|
||||
// addVaultAssets overload (via the xrpl:: dispatcher), which needs the
|
||||
// raw pre-rounding total so its DustSplit can park the sub-quantum
|
||||
// remainder in the custody line's sfDust. Legacy and integral-asset
|
||||
// Vaults still pre-round to the Vault's anterior scale here — that is
|
||||
// byte-identical to the base branch. Delegating to the overlay's own
|
||||
// eligibility gate avoids a manual-sync surface between the two.
|
||||
bool const useDust = vault_dust::useVaultDust(view, vaultSle);
|
||||
|
||||
auto const totalPaidToVaultRounded = useDust
|
||||
? totalPaidToVaultRaw
|
||||
: roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
|
||||
XRPL_ASSERT_PARTS(
|
||||
!asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
|
||||
"xrpl::LoanPay::doApply",
|
||||
@@ -463,11 +505,8 @@ LoanPay::doApply()
|
||||
adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
|
||||
|
||||
//------------------------------------------------------
|
||||
// Vault object state changes
|
||||
view.update(vaultSle);
|
||||
|
||||
Number const assetsAvailableBefore = *assetsAvailableProxy;
|
||||
Number const assetsTotalBefore = *assetsTotalProxy;
|
||||
// Vault object state changes (applied further below, via addVaultAssets,
|
||||
// together with the transfer of funds to the Vault pseudo-account).
|
||||
#if !NDEBUG
|
||||
{
|
||||
Number const pseudoAccountBalanceBefore = accountHolds(
|
||||
@@ -485,11 +524,16 @@ LoanPay::doApply()
|
||||
}
|
||||
#endif
|
||||
|
||||
assetsAvailableProxy += totalPaidToVaultRounded;
|
||||
assetsTotalProxy += assetsTotalDelta;
|
||||
// Raw (pre-rounding) projection, used only for this internal consistency
|
||||
// check; the real post-rounding values are read back from the Vault SLE
|
||||
// further below, once addVaultAssets/associateAsset have actually
|
||||
// mutated and rounded it.
|
||||
[[maybe_unused]] Number const assetsAvailableAfterRaw =
|
||||
assetsAvailableBefore + totalPaidToVaultRounded;
|
||||
[[maybe_unused]] Number const assetsTotalAfterRaw = assetsTotalBefore + assetsTotalDelta;
|
||||
|
||||
XRPL_ASSERT_PARTS(
|
||||
*assetsAvailableProxy <= *assetsTotalProxy,
|
||||
assetsAvailableAfterRaw <= assetsTotalAfterRaw,
|
||||
"xrpl::LoanPay::doApply",
|
||||
"assets available must not be greater than assets outstanding");
|
||||
|
||||
@@ -515,11 +559,70 @@ LoanPay::doApply()
|
||||
|
||||
associateAsset(*loanSle, asset);
|
||||
associateAsset(*brokerSle, asset);
|
||||
associateAsset(*vaultSle, asset);
|
||||
|
||||
// Duplicate some checks after rounding
|
||||
Number const assetsAvailableAfter = *assetsAvailableProxy;
|
||||
Number const assetsTotalAfter = *assetsTotalProxy;
|
||||
if (totalPaidToVaultRounded != beast::kZero)
|
||||
{
|
||||
if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
|
||||
return ter;
|
||||
}
|
||||
|
||||
if (totalPaidToBroker != beast::kZero)
|
||||
{
|
||||
if (brokerPayee == accountID_)
|
||||
{
|
||||
// The broker may have deleted their holding. Recreate it if needed
|
||||
if (auto const ter = addEmptyHolding(
|
||||
ctx_.getApplyViewContext(),
|
||||
brokerPayee,
|
||||
brokerPayeeSle->at(sfBalance).value().xrp(),
|
||||
asset,
|
||||
j_);
|
||||
ter && ter != tecDUPLICATE)
|
||||
{
|
||||
// ignore tecDUPLICATE. That means the holding already exists,
|
||||
// and is fine here
|
||||
return ter;
|
||||
}
|
||||
}
|
||||
if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
|
||||
return ter;
|
||||
}
|
||||
|
||||
// Update the Vault's assets, and transfer the Vault's share of the
|
||||
// payment from the payer to the Vault pseudo-account.
|
||||
//
|
||||
// totalPaidToVaultRounded is either the pre-rounded amount (Legacy /
|
||||
// integral asset path — same as base branch) or the raw pre-rounding
|
||||
// amount (dust path — the dust-aware addVaultAssets overload consumes
|
||||
// the raw digits to compute the sfDust residual).
|
||||
if (auto const ret = addVaultAssets(
|
||||
view,
|
||||
vaultSle,
|
||||
accountID_,
|
||||
STAmount{asset, totalPaidToVaultRounded},
|
||||
STAmount{asset, assetsTotalDelta},
|
||||
j_);
|
||||
!isTesSuccess(ret))
|
||||
return ret;
|
||||
|
||||
// associateAsset snaps every asset-typed STNumber on the Vault SLE to
|
||||
// STAmount's 16-significant-digit precision (via roundToAsset on the
|
||||
// field's current value). For non-dust paths that is the intended
|
||||
// canonicalisation and matches base-branch behaviour verbatim. For the
|
||||
// dust path it would silently erase the sub-quantum recognition
|
||||
// adjustment that the dust-aware addVaultAssets just applied to
|
||||
// sfAssetsTotal (Number carries 19 digits; STAmount only 16), which
|
||||
// is what keeps the receivable (sfAssetsTotal - sfAssetsAvailable)
|
||||
// aligned with principalOutstanding across the repayment. Skip it
|
||||
// in that path.
|
||||
if (!useDust)
|
||||
associateAsset(*vaultSle, asset);
|
||||
|
||||
// Duplicate some checks after rounding. These re-read the Vault's fields
|
||||
// rather than reusing assetsAvailableAfterRaw/assetsTotalAfterRaw, since
|
||||
// rounding above may have moved them off the raw-arithmetic projection.
|
||||
Number const assetsAvailableAfter = vaultSle->at(sfAssetsAvailable);
|
||||
Number const assetsTotalAfter = vaultSle->at(sfAssetsTotal);
|
||||
|
||||
XRPL_ASSERT_PARTS(
|
||||
assetsAvailableAfter <= assetsTotalAfter,
|
||||
@@ -528,9 +631,22 @@ LoanPay::doApply()
|
||||
if (assetsAvailableAfter == assetsAvailableBefore)
|
||||
{
|
||||
// An unchanged assetsAvailable indicates that the amount paid to the
|
||||
// vault was zero, or rounded to zero. That should be impossible, but I
|
||||
// can't rule it out for extreme edge cases, so fail gracefully if it
|
||||
// happens.
|
||||
// vault was zero, or rounded to zero.
|
||||
//
|
||||
// Non-dust path (useDust == false): assetsAvailable is incremented by
|
||||
// the pre-rounded amount, so a non-zero repayment always moves it —
|
||||
// this branch should be unreachable. Fail gracefully if it happens.
|
||||
//
|
||||
// Dust path (useDust == true): assetsAvailable is incremented by
|
||||
// split.balanceDelta, which is the whole-quanta portion of the raw
|
||||
// repayment at the Vault's posterior scale. A repayment strictly
|
||||
// below one quantum lands entirely in sfDust and produces
|
||||
// split.balanceDelta == 0. That is a legitimate (if unusual) outcome
|
||||
// — refuse it here because the invariants downstream and the
|
||||
// subsequent conservation checks all assume assetsAvailable moved.
|
||||
// Callers wanting to permit sub-quantum-only repayments would need to
|
||||
// relax those follow-on checks first; today no such caller exists in
|
||||
// the tree, and the current fixture never reaches this branch.
|
||||
//
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j_.warn()) << "LoanPay: Vault assets available unchanged after rounding: " //
|
||||
@@ -580,73 +696,21 @@ LoanPay::doApply()
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
// These three values are used to check that funds are conserved after the transfers
|
||||
auto const accountBalanceBefore = accountHolds(
|
||||
view,
|
||||
accountID_,
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_,
|
||||
SpendableHandling::FullBalance);
|
||||
auto const vaultBalanceBefore = accountID_ == vaultPseudoAccount
|
||||
? STAmount{asset, 0}
|
||||
: accountHolds(
|
||||
view,
|
||||
vaultPseudoAccount,
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_,
|
||||
SpendableHandling::FullBalance);
|
||||
auto const brokerBalanceBefore = accountID_ == brokerPayee
|
||||
? STAmount{asset, 0}
|
||||
: accountHolds(
|
||||
view,
|
||||
brokerPayee,
|
||||
asset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_,
|
||||
SpendableHandling::FullBalance);
|
||||
|
||||
if (totalPaidToVaultRounded != beast::kZero)
|
||||
{
|
||||
if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
|
||||
return ter;
|
||||
}
|
||||
|
||||
// Transfer the LoanBroker's share of the payment (fee) separately.
|
||||
if (totalPaidToBroker != beast::kZero)
|
||||
{
|
||||
if (brokerPayee == accountID_)
|
||||
{
|
||||
// The broker may have deleted their holding. Recreate it if needed
|
||||
if (auto const ter = addEmptyHolding(
|
||||
ctx_.getApplyViewContext(),
|
||||
brokerPayee,
|
||||
brokerPayeeSle->at(sfBalance).value().xrp(),
|
||||
asset,
|
||||
j_);
|
||||
ter && ter != tecDUPLICATE)
|
||||
{
|
||||
// ignore tecDUPLICATE. That means the holding already exists,
|
||||
// and is fine here
|
||||
return ter;
|
||||
}
|
||||
}
|
||||
if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
|
||||
if (auto const ter = accountSend(
|
||||
view,
|
||||
accountID_,
|
||||
brokerPayee,
|
||||
STAmount{asset, totalPaidToBroker},
|
||||
j_,
|
||||
{},
|
||||
WaiveTransferFee::Yes);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
}
|
||||
|
||||
if (auto const ter = accountSendMulti(
|
||||
view,
|
||||
accountID_,
|
||||
asset,
|
||||
{{vaultPseudoAccount, totalPaidToVaultRounded}, {brokerPayee, totalPaidToBroker}},
|
||||
j_,
|
||||
WaiveTransferFee::Yes))
|
||||
return ter;
|
||||
|
||||
#if !NDEBUG
|
||||
{
|
||||
Number const pseudoAccountBalanceAfter = accountHolds(
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/SponsorHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
@@ -17,6 +18,7 @@
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
@@ -392,7 +394,6 @@ LoanSet::doApply()
|
||||
auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID)));
|
||||
if (!vaultSle)
|
||||
return tefBAD_LEDGER; // LCOV_EXCL_LINE
|
||||
auto const vaultPseudo = vaultSle->at(sfAccount);
|
||||
Asset const vaultAsset = vaultSle->at(sfAsset);
|
||||
|
||||
auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner);
|
||||
@@ -411,10 +412,10 @@ LoanSet::doApply()
|
||||
}
|
||||
auto const principalRequested = tx[sfPrincipalRequested];
|
||||
|
||||
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
|
||||
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
if (vaultAvailableProxy < principalRequested)
|
||||
Number const vaultAvailable = vaultSle->at(sfAssetsAvailable);
|
||||
Number const vaultTotal = vaultSle->at(sfAssetsTotal);
|
||||
auto const vaultScale = getVaultScale(vaultSle);
|
||||
if (vaultAvailable < principalRequested)
|
||||
{
|
||||
JLOG(j_.warn()) << "Insufficient assets available in the Vault to fund the loan.";
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
@@ -441,11 +442,11 @@ LoanSet::doApply()
|
||||
properties.loanState.managementFeeDue);
|
||||
|
||||
XRPL_ASSERT_PARTS(
|
||||
*vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy,
|
||||
*vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > vaultTotal,
|
||||
"xrpl::LoanSet::doApply",
|
||||
"Vault is below maximum limit");
|
||||
|
||||
if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue))
|
||||
if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotal, state.interestDue))
|
||||
{
|
||||
JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault";
|
||||
return tecLIMIT_EXCEEDED;
|
||||
@@ -581,13 +582,12 @@ LoanSet::doApply()
|
||||
if (auto const ter = requireAuth(view, vaultAsset, brokerOwner, AuthType::StrongAuth))
|
||||
return ter;
|
||||
|
||||
if (auto const ter = accountSendMulti(
|
||||
if (auto const ter = moveVaultAssets(
|
||||
view,
|
||||
vaultPseudo,
|
||||
vaultAsset,
|
||||
vaultSle,
|
||||
{{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}},
|
||||
j_,
|
||||
WaiveTransferFee::Yes))
|
||||
STAmount{vaultAsset, assetsTotalDelta},
|
||||
j_))
|
||||
return ter;
|
||||
|
||||
// Get shortcuts to the loan property values
|
||||
@@ -635,14 +635,10 @@ LoanSet::doApply()
|
||||
loan->at(sfPaymentRemaining) = paymentTotal;
|
||||
view.insert(loan);
|
||||
|
||||
// Update the balances in the vault
|
||||
vaultAvailableProxy -= principalRequested;
|
||||
vaultTotalProxy += assetsTotalDelta;
|
||||
XRPL_ASSERT_PARTS(
|
||||
*vaultAvailableProxy <= *vaultTotalProxy,
|
||||
*vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal),
|
||||
"xrpl::LoanSet::doApply",
|
||||
"assets available must not be greater than assets outstanding");
|
||||
view.update(vaultSle);
|
||||
|
||||
// Update the balances in the loan broker
|
||||
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale);
|
||||
|
||||
@@ -350,8 +350,8 @@ VaultClawback::doApply()
|
||||
Asset const vaultAsset = vault->at(sfAsset);
|
||||
STAmount const amount = clawbackAmount(vault, tx[~sfAmount], accountID_);
|
||||
|
||||
auto assetsAvailable = vault->at(sfAssetsAvailable);
|
||||
auto assetsTotal = vault->at(sfAssetsTotal);
|
||||
[[maybe_unused]] Number const assetsAvailable = vault->at(sfAssetsAvailable);
|
||||
[[maybe_unused]] Number const assetsTotal = vault->at(sfAssetsTotal);
|
||||
|
||||
[[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized);
|
||||
XRPL_ASSERT(
|
||||
@@ -383,10 +383,6 @@ VaultClawback::doApply()
|
||||
if (sharesDestroyed == beast::kZero)
|
||||
return tecPRECISION_LOSS;
|
||||
|
||||
assetsTotal -= assetsRecovered;
|
||||
assetsAvailable -= assetsRecovered;
|
||||
view().update(vault);
|
||||
|
||||
auto const& vaultAccount = vault->at(sfAccount);
|
||||
// Transfer shares from holder to vault.
|
||||
if (auto const ter = accountSend(
|
||||
@@ -422,28 +418,22 @@ VaultClawback::doApply()
|
||||
// else quietly ignore, holder balance is not zero
|
||||
}
|
||||
|
||||
if (assetsRecovered > beast::kZero)
|
||||
// Transfer assets from vault to issuer, and update the Vault's balances.
|
||||
// clawbackVaultAssets requires a positive amount, so skip it entirely
|
||||
// when assetsRecovered is zero (e.g. the Owner burning shares in an
|
||||
// empty vault). The issuer always already holds its own asset, so the
|
||||
// clawbackVaultAssets helper is used here rather than removeVaultAssets,
|
||||
// whose doWithdraw-based self-holding creation would incorrectly reject
|
||||
// this while the asset is locked.
|
||||
// Mark the vault modified for the invariant checker, even without a
|
||||
// field change, since it always sees the shares-burn above as a
|
||||
// Vault-affecting change.
|
||||
view().update(vault);
|
||||
if (assetsRecovered != beast::kZero)
|
||||
{
|
||||
// Transfer assets from vault to issuer.
|
||||
if (auto const ter = accountSend(
|
||||
view(), vaultAccount, accountID_, assetsRecovered, j_, {}, WaiveTransferFee::Yes);
|
||||
if (auto const ter = clawbackVaultAssets(view(), vault, accountID_, assetsRecovered, j_);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
// Sanity check
|
||||
if (accountHolds(
|
||||
view(),
|
||||
vaultAccount,
|
||||
assetsRecovered.asset(),
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_) < beast::kZero)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j_.error()) << "VaultClawback: negative balance of vault assets.";
|
||||
return tefINTERNAL;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
}
|
||||
|
||||
associateAsset(*vault, vaultAsset);
|
||||
|
||||
@@ -314,18 +314,27 @@ VaultDeposit::doApply()
|
||||
sharesCreated.asset() != assetsDeposited.asset(),
|
||||
"xrpl::VaultDeposit::doApply : assets are not shares");
|
||||
|
||||
vault->at(sfAssetsTotal) += assetsDeposited;
|
||||
vault->at(sfAssetsAvailable) += assetsDeposited;
|
||||
view().update(vault);
|
||||
|
||||
// A deposit must not push the vault over its limit.
|
||||
//
|
||||
// Note: the limit is checked against the pre-mutation `sfAssetsTotal`
|
||||
// plus the full `assetsDeposited`, which is very slightly conservative
|
||||
// on the dust-aware path. A dust-aware credit routes any sub-quantum
|
||||
// remainder into the custody line's sfDust, and only the whole-quanta
|
||||
// portion moves into sfBalance / sfAssetsAvailable. sfAssetsTotal in
|
||||
// that case grows by `assetsDeposited - split.dustDelta`, i.e. by up
|
||||
// to one quantum less than what this pre-check assumes, so a deposit
|
||||
// that would sit exactly on the limit after dust deferral is rejected
|
||||
// here. Reordering to check after addVaultAssets would require
|
||||
// rolling back the credit on failure (a non-trivial refund path) and
|
||||
// is not worth the extra complexity for a boundary of at most one
|
||||
// quantum.
|
||||
auto const maximum = *vault->at(sfAssetsMaximum);
|
||||
if (maximum != 0 && *vault->at(sfAssetsTotal) > maximum)
|
||||
if (maximum != 0 && *vault->at(sfAssetsTotal) + assetsDeposited > maximum)
|
||||
return tecLIMIT_EXCEEDED;
|
||||
|
||||
// Transfer assets from depositor to vault.
|
||||
if (auto const ter = accountSend(
|
||||
view(), accountID_, vaultAccount, assetsDeposited, j_, {}, WaiveTransferFee::Yes);
|
||||
// Update the vault's assets, and transfer assets from depositor to vault.
|
||||
if (auto const ter =
|
||||
addVaultAssets(view(), vault, accountID_, assetsDeposited, assetsDeposited, j_);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
@@ -357,6 +366,18 @@ VaultDeposit::doApply()
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
// Deposit is safe to associate even on the dust path (unlike LoanPay).
|
||||
// A deposit passes amount == valueDelta to addVaultAssets, so the
|
||||
// dust-aware overload writes back
|
||||
// sfAssetsAvailable += split.balanceDelta,
|
||||
// sfAssetsTotal += valueDelta - split.dustDelta
|
||||
// = amount - split.dustDelta
|
||||
// = split.balanceDelta,
|
||||
// i.e. both fields land at the same posterior scale. Their STAmount
|
||||
// representation is already exact, so associateAsset's roundToAsset
|
||||
// is a no-op here. See LoanPay.cpp's `!useDust` branch for the case
|
||||
// that IS unsafe (amount != valueDelta, so the sub-quantum residual
|
||||
// lives on sfAssetsTotal alone).
|
||||
associateAsset(*vault, vaultAsset);
|
||||
|
||||
return tesSUCCESS;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <xrpl/tx/transactors/vault/VaultWithdraw.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/beast/utility/instrumentation.h>
|
||||
@@ -287,15 +288,15 @@ VaultWithdraw::doApply()
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
}
|
||||
|
||||
auto assetsAvailable = vault->at(sfAssetsAvailable);
|
||||
auto assetsTotal = vault->at(sfAssetsTotal);
|
||||
Number const assetsAvailableBefore = vault->at(sfAssetsAvailable);
|
||||
[[maybe_unused]] Number const assetsTotalBefore = vault->at(sfAssetsTotal);
|
||||
auto const lossUnrealized = vault->at(sfLossUnrealized);
|
||||
XRPL_ASSERT(
|
||||
lossUnrealized <= (assetsTotal - assetsAvailable),
|
||||
lossUnrealized <= (assetsTotalBefore - assetsAvailableBefore),
|
||||
"xrpl::VaultWithdraw::doApply : loss and assets do balance");
|
||||
|
||||
// The vault must have enough assets on hand.
|
||||
if (*assetsAvailable < assetsWithdrawn)
|
||||
if (assetsAvailableBefore < assetsWithdrawn)
|
||||
{
|
||||
JLOG(j_.debug()) << "VaultWithdraw: vault doesn't hold enough assets";
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
@@ -311,6 +312,7 @@ VaultWithdraw::doApply()
|
||||
// worth logging.
|
||||
bool const isFinalWithdrawal =
|
||||
sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
|
||||
FinalRemoval finalRemoval = FinalRemoval::No;
|
||||
if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
|
||||
{
|
||||
// Unreachable: a final withdrawal with lossUnrealized > 0 has
|
||||
@@ -328,7 +330,7 @@ VaultWithdraw::doApply()
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
STAmount const allAvailable{vaultAsset, *assetsAvailable};
|
||||
STAmount const allAvailable{vaultAsset, assetsAvailableBefore};
|
||||
if (assetsWithdrawn != allAvailable)
|
||||
{
|
||||
JLOG(j_.error()) //
|
||||
@@ -337,17 +339,8 @@ VaultWithdraw::doApply()
|
||||
<< " assetsAvailable=" << allAvailable.getText();
|
||||
}
|
||||
assetsWithdrawn = allAvailable;
|
||||
|
||||
// Do not let dust accumulate in the Vault.
|
||||
assetsTotal = 0;
|
||||
assetsAvailable = 0;
|
||||
finalRemoval = FinalRemoval::Yes;
|
||||
}
|
||||
else
|
||||
{
|
||||
assetsTotal -= assetsWithdrawn;
|
||||
assetsAvailable -= assetsWithdrawn;
|
||||
}
|
||||
view().update(vault);
|
||||
|
||||
auto const& vaultAccount = vault->at(sfAccount);
|
||||
|
||||
@@ -385,11 +378,37 @@ VaultWithdraw::doApply()
|
||||
// else quietly ignore, account balance is not zero
|
||||
}
|
||||
|
||||
auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
|
||||
if (auto const ter = removeVaultAssets(
|
||||
applyViewContext,
|
||||
vault,
|
||||
accountID_,
|
||||
dstAcct,
|
||||
preFeeBalance_,
|
||||
assetsWithdrawn,
|
||||
j_,
|
||||
finalRemoval);
|
||||
!isTesSuccess(ter))
|
||||
return ter;
|
||||
|
||||
// Must run after the Vault's sfAssetsTotal/sfAssetsAvailable are mutated
|
||||
// (above): it rounds every asset-typed field on the Vault SLE to the
|
||||
// asset's canonical precision, which the mutation above does not do
|
||||
// itself.
|
||||
//
|
||||
// Withdraw is safe to associate even on the dust path (unlike LoanPay).
|
||||
// The dust-aware removeVaultAssets subtracts `amount` from BOTH
|
||||
// sfAssetsTotal and sfAssetsAvailable in the non-terminal branch, and
|
||||
// hard-resets both to zero in the FinalRemoval::Yes branch, so neither
|
||||
// field ends up with sub-STAmount precision. Any post-removal
|
||||
// renormaliseStrandedDust adds an identical whole-quantum delta to
|
||||
// both fields at the Vault's posterior scale — still STAmount-
|
||||
// representable. See LoanPay.cpp's `!useDust` branch for the case
|
||||
// that IS unsafe (amount != valueDelta, sub-quantum residual on
|
||||
// sfAssetsTotal alone).
|
||||
associateAsset(*vault, vaultAsset);
|
||||
|
||||
auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_);
|
||||
return doWithdraw(
|
||||
applyViewContext, accountID_, dstAcct, vaultAccount, preFeeBalance_, assetsWithdrawn, j_);
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
void
|
||||
|
||||
496
src/test/app/VaultHelpers_test.cpp
Normal file
496
src/test/app/VaultHelpers_test.cpp
Normal file
@@ -0,0 +1,496 @@
|
||||
#include <test/jtx/Account.h>
|
||||
#include <test/jtx/Env.h>
|
||||
#include <test/jtx/amount.h>
|
||||
#include <test/jtx/noop.h>
|
||||
#include <test/jtx/pay.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/ApplyViewImpl.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/TER.h>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// Exercises the addVaultAssets/clawbackVaultAssets/removeVaultAssets/
|
||||
// moveVaultAssets helpers directly, against a real Vault SLE and real
|
||||
// account/trust-line state built via jtx, but bypassing the VaultDeposit/
|
||||
// VaultWithdraw/VaultClawback/LoanSet transactors entirely. This lets each
|
||||
// helper's contract (field deltas, transfer amounts, FinalRemoval behavior)
|
||||
// be checked in isolation, in addition to the transactor-level coverage in
|
||||
// Vault_test.cpp, LoanSet_test.cpp, etc.
|
||||
class VaultHelpers_test : public beast::unit_test::Suite
|
||||
{
|
||||
// Builds a public IOU-backed vault owned by `owner`. Leaves the Env
|
||||
// with no pending (un-applied) transactions, so callers can safely
|
||||
// follow up with raw ApplyViewImpl mutations.
|
||||
static Keylet
|
||||
setupVault(jtx::Env& env, jtx::PrettyAsset const& asset, jtx::Account const& owner)
|
||||
{
|
||||
jtx::Vault const v{env};
|
||||
auto const [createTx, vaultKeylet] = v.create({.owner = owner, .asset = asset});
|
||||
env(createTx);
|
||||
env.close();
|
||||
return vaultKeylet;
|
||||
}
|
||||
|
||||
void
|
||||
testAddVaultAssets()
|
||||
{
|
||||
testcase("addVaultAssets");
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this);
|
||||
Account const issuer{"issuer"};
|
||||
Account const owner{"owner"};
|
||||
Account const depositor{"depositor"};
|
||||
env.fund(XRP(10'000), issuer, owner, depositor);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset = issuer["USD"];
|
||||
env(trust(owner, asset(1'000'000)));
|
||||
env(trust(depositor, asset(1'000'000)));
|
||||
env(pay(issuer, depositor, asset(10'000)));
|
||||
env.close();
|
||||
|
||||
auto const vaultKeylet = setupVault(env, asset, owner);
|
||||
|
||||
Vault const v{env};
|
||||
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
|
||||
env.close();
|
||||
|
||||
auto const open = env.current();
|
||||
ApplyViewImpl view(&*open, TapNone);
|
||||
auto const vault = view.peek(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vault))
|
||||
return;
|
||||
Asset const vaultAsset = vault->at(sfAsset);
|
||||
|
||||
Number const totalBefore = vault->at(sfAssetsTotal);
|
||||
Number const availableBefore = vault->at(sfAssetsAvailable);
|
||||
auto const depositorBalanceBefore = accountHolds(
|
||||
view,
|
||||
depositor,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
|
||||
// valueDelta and amount are independent: a nonzero valueDelta with a
|
||||
// zero amount recognizes a value change (e.g. a covered default)
|
||||
// without transferring any cash.
|
||||
{
|
||||
STAmount const zero{vaultAsset, 0};
|
||||
STAmount const fifty{vaultAsset, 50};
|
||||
auto const ter = addVaultAssets(view, vault, depositor, zero, fifty, env.journal);
|
||||
Number const totalAfter = vault->at(sfAssetsTotal);
|
||||
Number const availableAfter = vault->at(sfAssetsAvailable);
|
||||
auto const depositorBalanceAfter = accountHolds(
|
||||
view,
|
||||
depositor,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
BEAST_EXPECT(isTesSuccess(ter));
|
||||
BEAST_EXPECT(totalAfter == totalBefore + 50);
|
||||
BEAST_EXPECT(availableAfter == availableBefore);
|
||||
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore);
|
||||
}
|
||||
|
||||
// The common case: amount == valueDelta, and cash actually moves
|
||||
// from `sender` to the Vault's pseudo-account.
|
||||
{
|
||||
STAmount const hundred{vaultAsset, 100};
|
||||
auto const ter = addVaultAssets(view, vault, depositor, hundred, hundred, env.journal);
|
||||
Number const totalAfter = vault->at(sfAssetsTotal);
|
||||
Number const availableAfter = vault->at(sfAssetsAvailable);
|
||||
auto const depositorBalanceAfter = accountHolds(
|
||||
view,
|
||||
depositor,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
BEAST_EXPECT(isTesSuccess(ter));
|
||||
BEAST_EXPECT(totalAfter == totalBefore + 150);
|
||||
BEAST_EXPECT(availableAfter == availableBefore + 100);
|
||||
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore - hundred);
|
||||
}
|
||||
|
||||
// valueDelta may be negative (e.g. a small rounding correction on a
|
||||
// loan payment), independently of amount again.
|
||||
{
|
||||
Number const totalBeforeNegative = vault->at(sfAssetsTotal);
|
||||
Number const availableBeforeNegative = vault->at(sfAssetsAvailable);
|
||||
STAmount const zero{vaultAsset, 0};
|
||||
STAmount const minusTen{vaultAsset, -10};
|
||||
auto const ter = addVaultAssets(view, vault, depositor, zero, minusTen, env.journal);
|
||||
Number const totalAfter = vault->at(sfAssetsTotal);
|
||||
Number const availableAfter = vault->at(sfAssetsAvailable);
|
||||
BEAST_EXPECT(isTesSuccess(ter));
|
||||
BEAST_EXPECT(totalAfter == totalBeforeNegative - 10);
|
||||
BEAST_EXPECT(availableAfter == availableBeforeNegative);
|
||||
}
|
||||
|
||||
// If the underlying accountSend fails (here: sender has no trust
|
||||
// line for the vault asset and is not its issuer, so cannot source
|
||||
// the IOU), the helper propagates the non-tes error rather than
|
||||
// silently swallowing it.
|
||||
{
|
||||
Account const stranger{"stranger"};
|
||||
env.fund(XRP(10'000), stranger);
|
||||
env.close();
|
||||
STAmount const ten{vaultAsset, 10};
|
||||
auto const ter = addVaultAssets(view, vault, stranger, ten, ten, env.journal);
|
||||
BEAST_EXPECT(!isTesSuccess(ter));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testClawbackVaultAssets()
|
||||
{
|
||||
testcase("clawbackVaultAssets");
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this);
|
||||
Account const issuer{"issuer"};
|
||||
Account const owner{"owner"};
|
||||
Account const depositor{"depositor"};
|
||||
env.fund(XRP(10'000), issuer, owner, depositor);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset = issuer["USD"];
|
||||
env(trust(owner, asset(1'000'000)));
|
||||
env(trust(depositor, asset(1'000'000)));
|
||||
env(pay(issuer, depositor, asset(10'000)));
|
||||
env.close();
|
||||
|
||||
auto const vaultKeylet = setupVault(env, asset, owner);
|
||||
|
||||
Vault const v{env};
|
||||
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
|
||||
env.close();
|
||||
|
||||
auto const open = env.current();
|
||||
ApplyViewImpl view(&*open, TapNone);
|
||||
auto const vault = view.peek(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vault))
|
||||
return;
|
||||
Asset const vaultAsset = vault->at(sfAsset);
|
||||
|
||||
Number const totalBefore = vault->at(sfAssetsTotal);
|
||||
Number const availableBefore = vault->at(sfAssetsAvailable);
|
||||
AccountID const vaultAccount = vault->at(sfAccount);
|
||||
// The issuer of an IOU always implicitly holds it (no trust line
|
||||
// required), matching the only real caller (VaultClawback, where
|
||||
// the recipient is always the asset's issuer); accountHolds for an
|
||||
// issuer's own currency doesn't track a normal incrementable
|
||||
// balance, so check the Vault's own (real, decrementable) balance
|
||||
// instead.
|
||||
auto const vaultBalanceBefore = accountHolds(
|
||||
view,
|
||||
vaultAccount,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
|
||||
STAmount const hundred{vaultAsset, 100};
|
||||
auto const ter = clawbackVaultAssets(view, vault, issuer, hundred, env.journal);
|
||||
Number const totalAfter = vault->at(sfAssetsTotal);
|
||||
Number const availableAfter = vault->at(sfAssetsAvailable);
|
||||
auto const vaultBalanceAfter = accountHolds(
|
||||
view,
|
||||
vaultAccount,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
|
||||
BEAST_EXPECT(isTesSuccess(ter));
|
||||
BEAST_EXPECT(totalAfter == totalBefore - 100);
|
||||
BEAST_EXPECT(availableAfter == availableBefore - 100);
|
||||
BEAST_EXPECT(vaultBalanceAfter == vaultBalanceBefore - hundred);
|
||||
|
||||
// Clawing back more than sfAssetsAvailable fails outright, leaving
|
||||
// the Vault's fields untouched.
|
||||
{
|
||||
Number const totalBeforeFail = vault->at(sfAssetsTotal);
|
||||
Number const availableBeforeFail = vault->at(sfAssetsAvailable);
|
||||
STAmount const tooMuch{vaultAsset, availableBeforeFail + 1};
|
||||
auto const failTer = clawbackVaultAssets(view, vault, issuer, tooMuch, env.journal);
|
||||
BEAST_EXPECT(failTer == tefINTERNAL);
|
||||
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeFail);
|
||||
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeFail);
|
||||
}
|
||||
|
||||
// If the underlying accountSend fails (here: recipient has no trust
|
||||
// line for the vault asset and cannot receive it), the helper
|
||||
// propagates the non-tes error rather than silently swallowing it.
|
||||
// In production the recipient is always the asset issuer, which
|
||||
// implicitly holds its own asset; this synthetic third-party
|
||||
// recipient stands in only to exercise the failure branch.
|
||||
{
|
||||
Account const stranger{"stranger"};
|
||||
env.fund(XRP(10'000), stranger);
|
||||
env.close();
|
||||
STAmount const ten{vaultAsset, 10};
|
||||
auto const failTer = clawbackVaultAssets(view, vault, stranger, ten, env.journal);
|
||||
BEAST_EXPECT(!isTesSuccess(failTer));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testRemoveVaultAssets()
|
||||
{
|
||||
testcase("removeVaultAssets");
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this);
|
||||
Account const issuer{"issuer"};
|
||||
Account const owner{"owner"};
|
||||
Account const depositor{"depositor"};
|
||||
env.fund(XRP(10'000), issuer, owner, depositor);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset = issuer["USD"];
|
||||
env(trust(owner, asset(1'000'000)));
|
||||
env(trust(depositor, asset(1'000'000)));
|
||||
env(pay(issuer, depositor, asset(10'000)));
|
||||
env.close();
|
||||
|
||||
auto const vaultKeylet = setupVault(env, asset, owner);
|
||||
|
||||
Vault const v{env};
|
||||
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
|
||||
env.close();
|
||||
|
||||
// doWithdraw only reads ctx.tx on the third-party-destination path;
|
||||
// for a self-withdrawal (senderAcct == dstAcct, exercised below) it
|
||||
// is unused, so a trivial signed noop stands in for a real
|
||||
// VaultWithdraw transaction.
|
||||
auto const dummyTx = env.jt(noop(depositor)).stx;
|
||||
if (!BEAST_EXPECT(dummyTx))
|
||||
return;
|
||||
|
||||
auto const open = env.current();
|
||||
ApplyViewImpl view(&*open, TapNone);
|
||||
auto const vault = view.peek(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vault))
|
||||
return;
|
||||
Asset const vaultAsset = vault->at(sfAsset);
|
||||
ApplyViewContext const ctx{.view = view, .tx = *dummyTx};
|
||||
|
||||
Number const totalBefore = vault->at(sfAssetsTotal);
|
||||
Number const availableBefore = vault->at(sfAssetsAvailable);
|
||||
auto const depositorBalanceBefore = accountHolds(
|
||||
view,
|
||||
depositor,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
|
||||
// Normal (non-final) removal: both fields decrease by `amount`.
|
||||
{
|
||||
STAmount const hundred{vaultAsset, 100};
|
||||
auto const ter = removeVaultAssets(
|
||||
ctx,
|
||||
vault,
|
||||
depositor,
|
||||
depositor,
|
||||
XRPAmount{0},
|
||||
hundred,
|
||||
env.journal,
|
||||
FinalRemoval::No);
|
||||
Number const totalAfter = vault->at(sfAssetsTotal);
|
||||
Number const availableAfter = vault->at(sfAssetsAvailable);
|
||||
auto const depositorBalanceAfter = accountHolds(
|
||||
view,
|
||||
depositor,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
|
||||
BEAST_EXPECT(isTesSuccess(ter));
|
||||
BEAST_EXPECT(totalAfter == totalBefore - 100);
|
||||
BEAST_EXPECT(availableAfter == availableBefore - 100);
|
||||
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore + hundred);
|
||||
}
|
||||
|
||||
// FinalRemoval::Yes hard-resets both fields to exactly zero,
|
||||
// regardless of the passed-in amount (deliberately an understated
|
||||
// amount here, to prove the fields aren't merely decremented by
|
||||
// it).
|
||||
{
|
||||
Number const totalBeforeFinal = vault->at(sfAssetsTotal);
|
||||
STAmount const one{vaultAsset, 1};
|
||||
auto const ter = removeVaultAssets(
|
||||
ctx,
|
||||
vault,
|
||||
depositor,
|
||||
depositor,
|
||||
XRPAmount{0},
|
||||
one,
|
||||
env.journal,
|
||||
FinalRemoval::Yes);
|
||||
BEAST_EXPECT(isTesSuccess(ter));
|
||||
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == Number{0});
|
||||
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == Number{0});
|
||||
BEAST_EXPECTS(
|
||||
totalBeforeFinal != Number(0),
|
||||
"fixture sanity: total was nonzero before the final removal");
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testMoveVaultAssets()
|
||||
{
|
||||
testcase("moveVaultAssets");
|
||||
using namespace jtx;
|
||||
|
||||
Env env(*this);
|
||||
Account const issuer{"issuer"};
|
||||
Account const owner{"owner"};
|
||||
Account const depositor{"depositor"};
|
||||
Account const borrower{"borrower"};
|
||||
Account const feeRecipient{"feeRecipient"};
|
||||
env.fund(XRP(10'000), issuer, owner, depositor, borrower, feeRecipient);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset = issuer["USD"];
|
||||
env(trust(owner, asset(1'000'000)));
|
||||
env(trust(depositor, asset(1'000'000)));
|
||||
env(trust(borrower, asset(1'000'000)));
|
||||
env(trust(feeRecipient, asset(1'000'000)));
|
||||
env(pay(issuer, depositor, asset(10'000)));
|
||||
env.close();
|
||||
|
||||
auto const vaultKeylet = setupVault(env, asset, owner);
|
||||
|
||||
Vault const v{env};
|
||||
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
|
||||
env.close();
|
||||
|
||||
auto const open = env.current();
|
||||
ApplyViewImpl view(&*open, TapNone);
|
||||
auto const vault = view.peek(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vault))
|
||||
return;
|
||||
Asset const vaultAsset = vault->at(sfAsset);
|
||||
|
||||
Number const totalBefore = vault->at(sfAssetsTotal);
|
||||
Number const availableBefore = vault->at(sfAssetsAvailable);
|
||||
auto const borrowerBalanceBefore = accountHolds(
|
||||
view,
|
||||
borrower,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
auto const feeRecipientBalanceBefore = accountHolds(
|
||||
view,
|
||||
feeRecipient,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
|
||||
// Mimics a loan disbursement: principal to the borrower, a fee to a
|
||||
// separate recipient. valueDelta is zero here since a nonzero value
|
||||
// is only meaningful (and only permitted, see moveVaultAssets's
|
||||
// assertion) for a Legacy-version Vault recognizing accrued
|
||||
// interest into sfAssetsTotal at origination; this fixture's Vault
|
||||
// is not Legacy. sfAssetsAvailable still decreases independently of
|
||||
// sfAssetsTotal, which is the contract under test.
|
||||
MultiplePaymentDestinations const recipients{
|
||||
{borrower, Number{80}},
|
||||
{feeRecipient, Number{20}},
|
||||
};
|
||||
STAmount const zero{vaultAsset, 0};
|
||||
auto const ter = moveVaultAssets(view, vault, recipients, zero, env.journal);
|
||||
Number const totalAfter = vault->at(sfAssetsTotal);
|
||||
Number const availableAfter = vault->at(sfAssetsAvailable);
|
||||
auto const borrowerBalanceAfter = accountHolds(
|
||||
view,
|
||||
borrower,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
auto const feeRecipientBalanceAfter = accountHolds(
|
||||
view,
|
||||
feeRecipient,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
STAmount const eighty{vaultAsset, 80};
|
||||
STAmount const twenty{vaultAsset, 20};
|
||||
|
||||
BEAST_EXPECT(isTesSuccess(ter));
|
||||
BEAST_EXPECT(totalAfter == totalBefore);
|
||||
BEAST_EXPECT(availableAfter == availableBefore - 100);
|
||||
BEAST_EXPECT(borrowerBalanceAfter == borrowerBalanceBefore + eighty);
|
||||
BEAST_EXPECT(feeRecipientBalanceAfter == feeRecipientBalanceBefore + twenty);
|
||||
|
||||
// Zero-amount recipients still count toward the recipients.size() > 1
|
||||
// precondition and drive the sum-of-amounts to zero, which short-
|
||||
// circuits the accountSendMulti call. The Vault's fields are still
|
||||
// updated (both to their pre-call values, since the deltas are all
|
||||
// zero), and no funds move.
|
||||
{
|
||||
Number const totalBeforeZero = vault->at(sfAssetsTotal);
|
||||
Number const availableBeforeZero = vault->at(sfAssetsAvailable);
|
||||
auto const borrowerBalanceBeforeZero = accountHolds(
|
||||
view,
|
||||
borrower,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
MultiplePaymentDestinations const zeroRecipients{
|
||||
{borrower, Number{0}},
|
||||
{feeRecipient, Number{0}},
|
||||
};
|
||||
auto const zeroTer = moveVaultAssets(view, vault, zeroRecipients, zero, env.journal);
|
||||
auto const borrowerBalanceAfterZero = accountHolds(
|
||||
view,
|
||||
borrower,
|
||||
vaultAsset,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
env.journal);
|
||||
BEAST_EXPECT(isTesSuccess(zeroTer));
|
||||
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeZero);
|
||||
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeZero);
|
||||
BEAST_EXPECT(borrowerBalanceAfterZero == borrowerBalanceBeforeZero);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testAddVaultAssets();
|
||||
testClawbackVaultAssets();
|
||||
testRemoveVaultAssets();
|
||||
testMoveVaultAssets();
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(VaultHelpers, app, xrpl);
|
||||
|
||||
} // namespace xrpl::test
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
@@ -584,7 +585,7 @@ private:
|
||||
return std::nullopt;
|
||||
if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) ||
|
||||
!BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) ||
|
||||
!BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11))
|
||||
!BEAST_EXPECT(getVaultScale(vaultSle) == -11))
|
||||
return std::nullopt;
|
||||
|
||||
// Use issuer clawback to reduce cover to the minimum the
|
||||
@@ -711,7 +712,7 @@ private:
|
||||
|
||||
auto const coverAvail = brokerSle->at(sfCoverAvailable);
|
||||
auto const debtTotal = brokerSle->at(sfDebtTotal);
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const vaultScale = getVaultScale(vaultSle);
|
||||
auto const debtScale = scale(debtTotal, asset);
|
||||
|
||||
// Sanity: debt scale differs from vault scale for this setup.
|
||||
@@ -809,7 +810,7 @@ private:
|
||||
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
|
||||
if (!BEAST_EXPECT(vaultSle))
|
||||
return;
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const vaultScale = getVaultScale(vaultSle);
|
||||
BEAST_EXPECT(vaultScale == -11);
|
||||
|
||||
// Now try to create a tiny additional loan. Principal is
|
||||
|
||||
@@ -151,7 +151,7 @@ protected:
|
||||
using namespace jtx;
|
||||
|
||||
auto const vaultSle = env.le(keylet::vault(vaultID));
|
||||
return getAssetsTotalScale(vaultSle);
|
||||
return getVaultScale(vaultSle);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
79
src/test/app/lending/VaultDustProbe.h
Normal file
79
src/test/app/lending/VaultDustProbe.h
Normal file
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
// Vault Dust — the probe seam.
|
||||
//
|
||||
// Where a Vault's un-recognized remainder ("dust") is stored differs per
|
||||
// implementation:
|
||||
// - the base branch has no dust mechanism at all;
|
||||
// - solution A (…-pseudo-account) stores it as the balance of a second
|
||||
// pseudo-account;
|
||||
// - solution B' (…-trustline-dust) stores it as a signed field beside the
|
||||
// balance on the Vault's custody trust line.
|
||||
//
|
||||
// This header is the ONLY place the shared test suite
|
||||
// (src/test/app/lending/VaultRounding_test.cpp) is allowed to know about
|
||||
// that difference, and it is the ONLY file that may differ between the two
|
||||
// solution branches. Do not reference sfDustAccount, sfDust, or any other
|
||||
// implementation-specific field anywhere else in a test — route every such
|
||||
// read through readVaultDust() below.
|
||||
//
|
||||
// readVaultDust() below is the whole seam: each solution branch reimplements
|
||||
// its body and changes nothing else. There is deliberately no "does this
|
||||
// build have a reservoir?" flag — the shared suite asserts the post-fix
|
||||
// oracles unconditionally, so on the base branch it fails, and that failure
|
||||
// is the bug's demonstration (see the RED/GREEN CONTRACT note in
|
||||
// VaultRounding_test.cpp).
|
||||
|
||||
#include <test/jtx/Env.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/View.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
// Per-Vault: how much dust does this Vault currently hold, normalized to
|
||||
// Vault-pseudo-account terms (i.e. a positive Number means "the Vault is
|
||||
// carrying this much unrecognized value on the borrower's behalf").
|
||||
//
|
||||
// Returns zero when the build has no reservoir (this branch), and also when
|
||||
// this particular Vault has none.
|
||||
//
|
||||
// `inline` is not optional: this header is included both by the shared
|
||||
// suite and by each branch's own per-solution test file, so a non-inline
|
||||
// definition would be a duplicate-symbol link error. If a solution's
|
||||
// implementation grows past a few lines, move the body to a
|
||||
// VaultDustProbe.cpp beside this header rather than dropping `inline`.
|
||||
[[nodiscard]] inline Number
|
||||
readVaultDust(jtx::Env const& env, Keylet const& vaultKeylet)
|
||||
{
|
||||
auto const vaultSle = env.le(vaultKeylet);
|
||||
if (!vaultSle)
|
||||
return Number{};
|
||||
|
||||
xrpl::Asset const asset = vaultSle->at(sfAsset);
|
||||
if (asset.integral())
|
||||
return Number{};
|
||||
|
||||
auto const vaultAccount = vaultSle->at(sfAccount);
|
||||
auto const line =
|
||||
env.current()->read(xrpl::keylet::trustLine(vaultAccount, asset.get<xrpl::Issue>()));
|
||||
if (!line)
|
||||
return Number{};
|
||||
|
||||
// sfDust follows sfBalance's own low/high sign convention: positive
|
||||
// means the low account holds the high account's IOUs. Undo it here so
|
||||
// the shared suite never has to think about which endpoint of the line
|
||||
// is low — see plan-vault-dust-b-prime-field-accounting-kept.md §4.
|
||||
bool const vaultIsHigh = vaultAccount > asset.getIssuer();
|
||||
Number const dust = line->at(sfDust);
|
||||
return vaultIsHigh ? -dust : dust;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
760
src/test/app/lending/VaultRoundingTrustlineDust_test.cpp
Normal file
760
src/test/app/lending/VaultRoundingTrustlineDust_test.cpp
Normal file
@@ -0,0 +1,760 @@
|
||||
#include <test/app/lending/LoanTestBase.h>
|
||||
#include <test/app/lending/VaultDustProbe.h>
|
||||
#include <test/jtx/Account.h>
|
||||
#include <test/jtx/Env.h>
|
||||
#include <test/jtx/TestHelpers.h>
|
||||
#include <test/jtx/amount.h>
|
||||
#include <test/jtx/fee.h>
|
||||
#include <test/jtx/pay.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
#include <xrpl/protocol/Units.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
// ============================================================================
|
||||
// VaultRoundingTrustlineDust_test.cpp — solution B'-specific tests
|
||||
// (docs/plan-vault-dust-b-prime-field-accounting-kept.md §13). This file is
|
||||
// NOT shared: it is free to reference sfDust, DustSplit, useVaultDust, and
|
||||
// friends directly, unlike src/test/app/lending/VaultRounding_test.cpp.
|
||||
// ============================================================================
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
class VaultRoundingTrustlineDust_test : public LoanTestBase
|
||||
{
|
||||
private:
|
||||
struct DustFixture
|
||||
{
|
||||
jtx::Account issuer;
|
||||
jtx::Account lender;
|
||||
jtx::Account borrower;
|
||||
jtx::PrettyAsset asset;
|
||||
BrokerInfo broker;
|
||||
Keylet tinyLoanKeylet;
|
||||
};
|
||||
|
||||
// Same recipe as VaultRounding_test.cpp's withDustSetup (testsuite doc
|
||||
// §5), parameterized by owner-account NAME so callers can search for
|
||||
// both trust-line sign orientations (plan §13.2): the vault
|
||||
// pseudo-account's address is a hash that depends on the owner and
|
||||
// ledger state, so varying the owner varies which side of the issuer
|
||||
// the pseudo-account lands on.
|
||||
std::optional<DustFixture>
|
||||
makeDustFixture(jtx::Env& env, std::string const& ownerSuffix)
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
Account const issuer{"issuer" + ownerSuffix};
|
||||
Account const lender{"lender" + ownerSuffix};
|
||||
Account const borrower{"borrower" + ownerSuffix};
|
||||
env.fund(XRP(1'000'000'00), issuer, lender, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset = issuer["USD"];
|
||||
env(trust(lender, asset(50'000)));
|
||||
env(trust(borrower, asset(50'000)));
|
||||
env.close();
|
||||
env(pay(issuer, lender, asset(30'000)));
|
||||
env(pay(issuer, borrower, asset(1'000)));
|
||||
env.close();
|
||||
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 1'000,
|
||||
.debtMax = Number{0},
|
||||
.coverRateMin = TenthBips32{13'370},
|
||||
.coverDeposit = 5'000,
|
||||
.managementFeeRate = TenthBips16{0}};
|
||||
|
||||
BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
|
||||
|
||||
auto const brokerSle1 = env.le(keylet::loanBroker(broker.brokerID));
|
||||
if (!BEAST_EXPECT(brokerSle1))
|
||||
return std::nullopt;
|
||||
Keylet const tinyLoanKeylet =
|
||||
keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle1->at(sfLoanSequence)));
|
||||
|
||||
env(set(borrower, broker.brokerID, Number{1, -2}),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
kInterestRate(TenthBips32{1'922}),
|
||||
kPaymentTotal(2),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)));
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
env(vault.deposit(
|
||||
{.depositor = lender, .id = broker.vaultKeylet().key, .amount = asset(9'500)}));
|
||||
env.close();
|
||||
|
||||
auto const tinyLoanSle = env.le(tinyLoanKeylet);
|
||||
auto const vaultSle = env.le(broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(vaultSle))
|
||||
return std::nullopt;
|
||||
if (tinyLoanSle->at(sfLoanScale) != -12 || getVaultScale(vaultSle) != -11)
|
||||
{
|
||||
log << "VaultRoundingTrustlineDust: fixture did not reproduce -12/-11 "
|
||||
"for owner suffix '"
|
||||
<< ownerSuffix << "'" << std::endl;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return DustFixture{
|
||||
.issuer = issuer,
|
||||
.lender = lender,
|
||||
.borrower = borrower,
|
||||
.asset = asset,
|
||||
.broker = broker,
|
||||
.tinyLoanKeylet = tinyLoanKeylet};
|
||||
}
|
||||
|
||||
void
|
||||
payTinyLoanInFull(jtx::Env& env, DustFixture const& fx)
|
||||
{
|
||||
using namespace jtx;
|
||||
auto const loanSle = env.le(fx.tinyLoanKeylet);
|
||||
if (!BEAST_EXPECT(loanSle))
|
||||
return;
|
||||
auto const periodicPayment = loanSle->at(sfPeriodicPayment);
|
||||
auto const serviceFee = loanSle->at(sfLoanServiceFee);
|
||||
std::int32_t const loanScale = loanSle->at(sfLoanScale);
|
||||
auto const payment = roundPeriodicPayment(fx.asset.raw(), periodicPayment, loanScale);
|
||||
auto const payAmt = STAmount{fx.asset.raw(), payment + serviceFee};
|
||||
env(jtx::loan::pay(fx.borrower, fx.tinyLoanKeylet.key, payAmt), Fee(XRP(10)));
|
||||
env.close();
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------
|
||||
// §13.1 The field
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
void
|
||||
testDustAbsentReadsZero(FeatureBitset features)
|
||||
{
|
||||
testcase("sfDust absent on an untouched trust line reads as zero");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features};
|
||||
Account const alice{"alice"};
|
||||
Account const bob{"bob"};
|
||||
env.fund(XRP(1'000), alice, bob);
|
||||
env.close();
|
||||
PrettyAsset const asset = alice["USD"];
|
||||
env(trust(bob, asset(1'000)));
|
||||
env.close();
|
||||
|
||||
auto const line =
|
||||
env.le(keylet::trustLine(alice.id(), bob.id(), asset.raw().get<Issue>().currency));
|
||||
if (!BEAST_EXPECT(line))
|
||||
return;
|
||||
BEAST_EXPECT(!line->isFieldPresent(sfDust));
|
||||
BEAST_EXPECT(Number{line->at(sfDust)} == beast::kZero);
|
||||
}
|
||||
|
||||
void
|
||||
testNoDustForLegacyOrIntegralVaults(FeatureBitset features)
|
||||
{
|
||||
testcase("Legacy and integral-asset vaults never carry sfDust");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features}; // featureLendingProtocolV1_1 excluded => Legacy
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
env.fund(XRP(1'000'000), issuer, lender);
|
||||
env.close();
|
||||
PrettyAsset const asset = issuer["USD"];
|
||||
env(trust(lender, asset(500'000)));
|
||||
env.close();
|
||||
env(pay(issuer, lender, asset(400'000)));
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
|
||||
env(tx);
|
||||
env.close();
|
||||
|
||||
auto const vaultSle = env.le(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vaultSle))
|
||||
return;
|
||||
BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy);
|
||||
BEAST_EXPECT(!vault_dust::useVaultDust(*env.current(), vaultSle));
|
||||
BEAST_EXPECT(readVaultDust(env, vaultKeylet) == beast::kZero);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------
|
||||
// §13.2 Sign convention — both orientations, explicitly forced.
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
void
|
||||
testDustBothSignOrientations(FeatureBitset features)
|
||||
{
|
||||
testcase("Dust lifecycle holds with the Vault as both low and high account");
|
||||
|
||||
using namespace jtx;
|
||||
|
||||
bool sawLow = false, sawHigh = false;
|
||||
for (int attempt = 0; attempt < 12 && !(sawLow && sawHigh); ++attempt)
|
||||
{
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
auto const fx = makeDustFixture(env, std::to_string(attempt));
|
||||
if (!fx)
|
||||
continue;
|
||||
|
||||
auto const vaultSle = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSle))
|
||||
continue;
|
||||
AccountID const vaultAccount = vaultSle->at(sfAccount);
|
||||
AccountID const issuerAccount = fx->issuer.id();
|
||||
bool const vaultIsHigh = vaultAccount > issuerAccount;
|
||||
|
||||
if (vaultIsHigh && sawHigh)
|
||||
continue;
|
||||
if (!vaultIsHigh && sawLow)
|
||||
continue;
|
||||
|
||||
payTinyLoanInFull(env, *fx);
|
||||
|
||||
Number const dust = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
if (dust == beast::kZero)
|
||||
continue; // this attempt didn't generate dust; try another
|
||||
|
||||
// Read the raw ledger field and confirm the probe undid the
|
||||
// low/high convention correctly: the raw sfDust value's sign
|
||||
// (in the line's own convention, positive = low account holds
|
||||
// high account's IOUs) must match vaultIsHigh appropriately.
|
||||
auto const line = env.le(
|
||||
keylet::trustLine(
|
||||
vaultAccount, issuerAccount, fx->asset.raw().get<Issue>().currency));
|
||||
if (!BEAST_EXPECT(line))
|
||||
continue;
|
||||
Number const rawDust = line->at(sfDust);
|
||||
|
||||
if (vaultIsHigh)
|
||||
{
|
||||
sawHigh = true;
|
||||
// Vault-positive dust (dust > 0, meaning the Vault is
|
||||
// carrying unrecognized value) corresponds to a NEGATIVE
|
||||
// raw field when the Vault is the high account, since the
|
||||
// raw convention is "low account's terms".
|
||||
BEAST_EXPECT((dust > beast::kZero) == (rawDust < beast::kZero));
|
||||
}
|
||||
else
|
||||
{
|
||||
sawLow = true;
|
||||
BEAST_EXPECT((dust > beast::kZero) == (rawDust > beast::kZero));
|
||||
}
|
||||
|
||||
// The mirror still holds regardless of orientation (O1).
|
||||
Number const avail = vaultSle->at(sfAssetsAvailable);
|
||||
(void)avail;
|
||||
auto const vaultSleAfter = env.le(fx->broker.vaultKeylet());
|
||||
if (BEAST_EXPECT(vaultSleAfter))
|
||||
{
|
||||
Number const a = vaultSleAfter->at(sfAssetsAvailable);
|
||||
Number const b = accountHolds(
|
||||
*env.current(),
|
||||
vaultSleAfter->at(sfAccount),
|
||||
fx->asset.raw(),
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
beast::Journal{beast::Journal::getNullSink()});
|
||||
BEAST_EXPECT(a == b);
|
||||
}
|
||||
}
|
||||
|
||||
BEAST_EXPECT(sawLow);
|
||||
BEAST_EXPECT(sawHigh);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------
|
||||
// §13.3 The credit path (driven through real transactions)
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
void
|
||||
testDustCreatedAndPromoted(FeatureBitset features)
|
||||
{
|
||||
testcase("Credit path: split with remainder, and promotion on accumulation");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
auto const fx = makeDustFixture(env, "promote");
|
||||
if (!fx)
|
||||
return;
|
||||
|
||||
Number const dustBefore = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
BEAST_EXPECT(dustBefore == beast::kZero);
|
||||
|
||||
payTinyLoanInFull(env, *fx);
|
||||
|
||||
Number const dustAfter = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
// The fixture is specifically built so this repayment carries digits
|
||||
// finer than the vault's scale (testsuite doc §5) — dust MUST be
|
||||
// non-zero, or the whole fixture is vacuous.
|
||||
BEAST_EXPECT(dustAfter != beast::kZero);
|
||||
BEAST_EXPECT(dustAfter > beast::kZero);
|
||||
|
||||
auto const vaultSle = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSle))
|
||||
return;
|
||||
Number const q{1, getVaultScale(vaultSle)};
|
||||
BEAST_EXPECT(dustAfter < q);
|
||||
}
|
||||
|
||||
void
|
||||
testNullptrPathUnchanged(FeatureBitset features)
|
||||
{
|
||||
testcase("Ordinary payments never acquire sfDust (nullptr path)");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features};
|
||||
Account const alice{"alice"};
|
||||
Account const bob{"bob"};
|
||||
Account const carol{"carol"};
|
||||
env.fund(XRP(10'000), alice, bob, carol);
|
||||
env.close();
|
||||
PrettyAsset const asset = alice["USD"];
|
||||
env(trust(bob, asset(10'000)));
|
||||
env(trust(carol, asset(10'000)));
|
||||
env.close();
|
||||
env(pay(alice, bob, asset(1'000)));
|
||||
env.close();
|
||||
env(pay(bob, carol, asset(Number{1, -7})));
|
||||
env.close();
|
||||
|
||||
// carol trusts the ISSUER (alice), not bob directly, so bob's
|
||||
// payment to carol ripples through alice — the two lines actually
|
||||
// touched are (alice,bob) and (alice,carol), not (bob,carol).
|
||||
auto const lineAB =
|
||||
env.le(keylet::trustLine(alice.id(), bob.id(), asset.raw().get<Issue>().currency));
|
||||
auto const lineAC =
|
||||
env.le(keylet::trustLine(alice.id(), carol.id(), asset.raw().get<Issue>().currency));
|
||||
if (BEAST_EXPECT(lineAB))
|
||||
BEAST_EXPECT(
|
||||
!lineAB->isFieldPresent(sfDust) || Number{lineAB->at(sfDust)} == beast::kZero);
|
||||
if (BEAST_EXPECT(lineAC))
|
||||
BEAST_EXPECT(
|
||||
!lineAC->isFieldPresent(sfDust) || Number{lineAC->at(sfDust)} == beast::kZero);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------
|
||||
// §13.5 Lifecycle guards
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
void
|
||||
testAccountHoldsExcludesDust(FeatureBitset features)
|
||||
{
|
||||
testcase("accountHolds returns sfBalance alone, never sfBalance + sfDust");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
auto const fx = makeDustFixture(env, "acctholds");
|
||||
if (!fx)
|
||||
return;
|
||||
|
||||
payTinyLoanInFull(env, *fx);
|
||||
|
||||
Number const dust = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
if (dust == beast::kZero)
|
||||
return; // fixture failed to generate dust this run; nothing to check
|
||||
|
||||
auto const vaultSle = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSle))
|
||||
return;
|
||||
|
||||
Number const holds = accountHolds(
|
||||
*env.current(),
|
||||
vaultSle->at(sfAccount),
|
||||
fx->asset.raw(),
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
beast::Journal{beast::Journal::getNullSink()});
|
||||
Number const assetsAvailable = vaultSle->at(sfAssetsAvailable);
|
||||
|
||||
// If accountHolds leaked sfDust into its result, holds would exceed
|
||||
// assetsAvailable by roughly `dust`. It must not.
|
||||
BEAST_EXPECT(holds == assetsAvailable);
|
||||
}
|
||||
|
||||
void
|
||||
testVaultDeleteRequiresZeroDust(FeatureBitset features)
|
||||
{
|
||||
testcase("VaultDelete is blocked while sfDust is non-zero, and succeeds once it is zero");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
|
||||
Account const issuer{"issuer2"};
|
||||
Account const lender{"lender2"};
|
||||
env.fund(XRP(1'000'000), issuer, lender);
|
||||
env.close();
|
||||
PrettyAsset const asset = issuer["USD"];
|
||||
env(trust(lender, asset(500'000)));
|
||||
env.close();
|
||||
env(pay(issuer, lender, asset(400'000)));
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
|
||||
env(tx);
|
||||
env.close();
|
||||
|
||||
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(1'000)}));
|
||||
env.close();
|
||||
|
||||
auto const vaultSleBefore = env.le(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vaultSleBefore))
|
||||
return;
|
||||
STAmount const allAssets{asset.raw(), vaultSleBefore->at(sfAssetsAvailable)};
|
||||
|
||||
env(vault.withdraw({.depositor = lender, .id = vaultKeylet.key, .amount = allAssets}));
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(readVaultDust(env, vaultKeylet) == beast::kZero);
|
||||
|
||||
env(vault.del({.owner = lender, .id = vaultKeylet.key}));
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(!env.le(vaultKeylet));
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------
|
||||
// §13.6 Re-normalisation
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
// Exercise the interaction between a dust-producing repayment and a
|
||||
// subsequent NON-terminal VaultWithdraw. Historically this combination
|
||||
// tripped
|
||||
// "withdrawal must change vault and destination balance by equal
|
||||
// amount"
|
||||
// in ValidVault (src/libxrpl/tx/invariants/VaultInvariant.cpp) when the
|
||||
// withdrawal drove the Vault's posterior scale finer than the custody
|
||||
// line's, allowing renormaliseStrandedDust to promote whole quanta
|
||||
// from sfDust into sfBalance. That promotion is a pure recognition
|
||||
// move (no external cash flow), so comparing sfBalance-only deltas
|
||||
// between the pseudo (which sees the promotion) and the destination
|
||||
// (which does not) mis-attributed a quantum-worth of drift to the
|
||||
// wrong side. The fix compares each side's EXTENDED balance
|
||||
// (sfBalance + sfDust) — see the "extended balance" branch of the
|
||||
// destination check in @c ttVAULT_WITHDRAW.
|
||||
//
|
||||
// Under the current implementation the non-terminal branch mutates the
|
||||
// Vault SLE and the pseudo-account's custody line by the same amount,
|
||||
// and any renormalisation adds identical whole-quantum deltas to both
|
||||
// T and A. The ValidVault destination check now uses extended balance
|
||||
// and therefore holds even when M > 0.
|
||||
void
|
||||
testNonTerminalWithdrawAfterDust(FeatureBitset features)
|
||||
{
|
||||
testcase(
|
||||
"Non-terminal VaultWithdraw after a dust-producing repayment satisfies ValidVault");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
auto const fx = makeDustFixture(env, "nontermwd");
|
||||
if (!fx)
|
||||
return;
|
||||
|
||||
payTinyLoanInFull(env, *fx);
|
||||
|
||||
Number const dustAfterRepay = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
if (dustAfterRepay == beast::kZero)
|
||||
return; // fixture did not generate dust this run
|
||||
|
||||
auto const vaultSleAfterRepay = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleAfterRepay))
|
||||
return;
|
||||
Number const availAfterRepay = vaultSleAfterRepay->at(sfAssetsAvailable);
|
||||
Number const totalAfterRepay = vaultSleAfterRepay->at(sfAssetsTotal);
|
||||
|
||||
// Partial (non-terminal) withdrawal — take a chunk large enough
|
||||
// to refine the Vault's posterior scale (10500 -> ~2), which is
|
||||
// what triggers renormaliseStrandedDust to promote whole quanta
|
||||
// out of the custody line's sfDust. Values chosen to leave
|
||||
// outstanding shares (avoiding the terminal branch).
|
||||
Vault const vault{env};
|
||||
STAmount const withdrawAmount{fx->asset.raw(), Number{10'498}};
|
||||
env(vault.withdraw(
|
||||
{.depositor = fx->lender,
|
||||
.id = fx->broker.vaultKeylet().key,
|
||||
.amount = withdrawAmount}));
|
||||
env.close();
|
||||
|
||||
// If ValidVault trips, the transaction is not committed — a
|
||||
// successful commit is the primary oracle. But also cross-check
|
||||
// the surviving invariant explicitly.
|
||||
auto const vaultSleAfterWithdraw = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleAfterWithdraw))
|
||||
return;
|
||||
|
||||
Number const availAfterWd = vaultSleAfterWithdraw->at(sfAssetsAvailable);
|
||||
Number const totalAfterWd = vaultSleAfterWithdraw->at(sfAssetsTotal);
|
||||
|
||||
// Delta(sfAssetsTotal) must equal Delta(sfAssetsAvailable) —
|
||||
// non-terminal removal subtracts `amount` from both, and any
|
||||
// renormalisation adds the same movable delta to both.
|
||||
BEAST_EXPECT((totalAfterWd - totalAfterRepay) == (availAfterWd - availAfterRepay));
|
||||
|
||||
// Dust must still be strictly less than one quantum at the new
|
||||
// scale (O2).
|
||||
Number const dustAfterWd = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
Number const q{1, getVaultScale(vaultSleAfterWithdraw)};
|
||||
BEAST_EXPECT(dustAfterWd >= beast::kZero);
|
||||
BEAST_EXPECT(dustAfterWd < q);
|
||||
}
|
||||
|
||||
// Companion: another non-terminal step (a second partial withdraw)
|
||||
// exercises the case where sfDust is non-zero going *into* the
|
||||
// withdraw, potentially getting promoted by renormalisation. Same
|
||||
// invariant contract: the ValidVault check must hold.
|
||||
void
|
||||
testSecondNonTerminalWithdrawAfterDust(FeatureBitset features)
|
||||
{
|
||||
testcase(
|
||||
"Two successive non-terminal VaultWithdraws after dust-producing repayment satisfy "
|
||||
"ValidVault");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
auto const fx = makeDustFixture(env, "twowd");
|
||||
if (!fx)
|
||||
return;
|
||||
|
||||
payTinyLoanInFull(env, *fx);
|
||||
|
||||
if (readVaultDust(env, fx->broker.vaultKeylet()) == beast::kZero)
|
||||
return;
|
||||
|
||||
Vault const vault{env};
|
||||
STAmount const firstWd{fx->asset.raw(), Number{5'000}};
|
||||
env(vault.withdraw(
|
||||
{.depositor = fx->lender, .id = fx->broker.vaultKeylet().key, .amount = firstWd}));
|
||||
env.close();
|
||||
|
||||
auto const vaultSleMid = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleMid))
|
||||
return;
|
||||
Number const availMid = vaultSleMid->at(sfAssetsAvailable);
|
||||
Number const totalMid = vaultSleMid->at(sfAssetsTotal);
|
||||
|
||||
STAmount const secondWd{fx->asset.raw(), Number{5'490}};
|
||||
env(vault.withdraw(
|
||||
{.depositor = fx->lender, .id = fx->broker.vaultKeylet().key, .amount = secondWd}));
|
||||
env.close();
|
||||
|
||||
auto const vaultSleEnd = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleEnd))
|
||||
return;
|
||||
|
||||
Number const availEnd = vaultSleEnd->at(sfAssetsAvailable);
|
||||
Number const totalEnd = vaultSleEnd->at(sfAssetsTotal);
|
||||
|
||||
BEAST_EXPECT((totalEnd - totalMid) == (availEnd - availMid));
|
||||
|
||||
Number const dustEnd = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
Number const q{1, getVaultScale(vaultSleEnd)};
|
||||
BEAST_EXPECT(dustEnd >= beast::kZero);
|
||||
BEAST_EXPECT(dustEnd < q);
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------
|
||||
// Two-leg refactor tests (§ Two-leg DustSplit refactor)
|
||||
//
|
||||
// These exercise the plan's new sender-leg policies (Override on
|
||||
// clawback / non-terminal withdrawal / move, Drain on terminal
|
||||
// withdrawal) end-to-end via real transactors, so they cover both
|
||||
// the trust-line layer's per-leg mechanics and the Vault-side
|
||||
// reconciliation through split.sender->dustDelta.
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
// Sender-leg Override renormalisation: a scale-refining withdrawal
|
||||
// promotes stranded dust from sfDust back into sfBalance, and the
|
||||
// Vault's sfAssetsTotal / sfAssetsAvailable both grow by the
|
||||
// promoted amount so the receivable invariant is preserved.
|
||||
void
|
||||
testSenderLegOverrideNonTerminalPromotes(FeatureBitset features)
|
||||
{
|
||||
testcase("Sender-leg Override renormalises stranded dust on non-terminal withdraw");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
auto const fx = makeDustFixture(env, "senderoverride");
|
||||
if (!fx)
|
||||
return;
|
||||
|
||||
// Generate dust via the tiny loan repayment.
|
||||
payTinyLoanInFull(env, *fx);
|
||||
|
||||
Number const dustAfterRepay = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
if (dustAfterRepay == beast::kZero)
|
||||
return; // fixture didn't generate dust this run
|
||||
|
||||
auto const vaultSleAfterRepay = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleAfterRepay))
|
||||
return;
|
||||
int const scaleBefore = getVaultScale(vaultSleAfterRepay);
|
||||
Number const totalBefore = vaultSleAfterRepay->at(sfAssetsTotal);
|
||||
Number const availBefore = vaultSleAfterRepay->at(sfAssetsAvailable);
|
||||
|
||||
// Large partial withdrawal — refines the posterior scale so
|
||||
// the dust reservoir crosses a decade boundary and gets
|
||||
// promoted by the sender-leg Override re-split.
|
||||
Vault const vault{env};
|
||||
STAmount const withdrawAmount{fx->asset.raw(), Number{10'498}};
|
||||
env(vault.withdraw(
|
||||
{.depositor = fx->lender,
|
||||
.id = fx->broker.vaultKeylet().key,
|
||||
.amount = withdrawAmount}));
|
||||
env.close();
|
||||
|
||||
auto const vaultSleAfterWd = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleAfterWd))
|
||||
return;
|
||||
int const scaleAfter = getVaultScale(vaultSleAfterWd);
|
||||
Number const dustAfterWd = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
|
||||
// The withdrawal was sized to refine the scale.
|
||||
BEAST_EXPECT(scaleAfter < scaleBefore);
|
||||
|
||||
// The Vault's T and A both moved by the SAME extended delta —
|
||||
// exactly the invariant the sender-leg Override reconciliation
|
||||
// preserves (both fields shift by `-amount` plus the promoted
|
||||
// dust). If Override's reconciliation were miscoded, T and A
|
||||
// would diverge.
|
||||
Number const totalAfter = vaultSleAfterWd->at(sfAssetsTotal);
|
||||
Number const availAfter = vaultSleAfterWd->at(sfAssetsAvailable);
|
||||
BEAST_EXPECT((totalAfter - totalBefore) == (availAfter - availBefore));
|
||||
|
||||
// The remaining dust on the custody line is bounded by one
|
||||
// quantum at the new posterior scale (up to a decade of
|
||||
// Override drift, which the O2 relaxation tolerates).
|
||||
Number const bound{10, scaleAfter};
|
||||
BEAST_EXPECT(dustAfterWd >= beast::kZero);
|
||||
BEAST_EXPECT(dustAfterWd < bound);
|
||||
}
|
||||
|
||||
// Sender-leg Drain end-to-end: a terminal removal empties the
|
||||
// custody line's reservoir into the destination. Vault code does
|
||||
// no manual sfDust write; the trust-line layer folds sfDust into
|
||||
// sfBalance, adjusts the outgoing amount, and zeroes sfDust. Post-
|
||||
// condition: line has sfBalance == 0 and sfDust == 0; the Vault SLE
|
||||
// has sfAssetsTotal == 0 and sfAssetsAvailable == 0.
|
||||
void
|
||||
testSenderLegDrainTerminalRemoval(FeatureBitset features)
|
||||
{
|
||||
testcase("Sender-leg Drain drains reservoir end-to-end on terminal removal");
|
||||
|
||||
using namespace jtx;
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
auto const fx = makeDustFixture(env, "senderdrain");
|
||||
if (!fx)
|
||||
return;
|
||||
|
||||
// Seed a non-zero reservoir on the custody line by paying the
|
||||
// first periodic instalment (returns interest with sub-quantum
|
||||
// residual).
|
||||
payTinyLoanInFull(env, *fx);
|
||||
|
||||
Number const dustBeforeTerminal = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
if (dustBeforeTerminal == beast::kZero)
|
||||
return; // fixture didn't produce dust this run
|
||||
BEAST_EXPECT(dustBeforeTerminal > beast::kZero);
|
||||
|
||||
// The remainder of the tiny loan is outstanding, which keeps
|
||||
// sfAssetsAvailable < sfAssetsTotal and blocks a terminal
|
||||
// withdrawal (the lender's shares still back the outstanding
|
||||
// principal). Advance time past the next payment due date +
|
||||
// grace window (loan is 2 payments at 1-year intervals; one
|
||||
// has been made) and default the loan so no principal is
|
||||
// outstanding and the vault SLE's sfAssetsTotal drops to
|
||||
// sfAssetsAvailable.
|
||||
env.close(std::chrono::seconds(86400 * 800));
|
||||
env(jtx::loan::manage(fx->lender, fx->tinyLoanKeylet.key, tfLoanDefault));
|
||||
env.close();
|
||||
|
||||
auto const vaultSleBefore = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleBefore))
|
||||
return;
|
||||
|
||||
Number const dustAfterDefault = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
if (dustAfterDefault == beast::kZero)
|
||||
return; // default consumed the reservoir; nothing left to test
|
||||
|
||||
Number const lenderBalanceBefore = env.balance(fx->lender, fx->asset).number();
|
||||
Number const availBefore = Number{vaultSleBefore->at(sfAssetsAvailable)};
|
||||
|
||||
// Withdraw the full available balance in one shot. With the
|
||||
// loan defaulted, sfAssetsAvailable == sfAssetsTotal and the
|
||||
// lender holds all outstanding shares, so this burns every
|
||||
// share and triggers FinalRemoval::Yes inside VaultWithdraw —
|
||||
// the path that installs the Drain policy on the sender leg.
|
||||
STAmount const allAssets{fx->asset.raw(), vaultSleBefore->at(sfAssetsAvailable)};
|
||||
Vault const vault{env};
|
||||
env(vault.withdraw(
|
||||
{.depositor = fx->lender, .id = fx->broker.vaultKeylet().key, .amount = allAssets}));
|
||||
env.close();
|
||||
|
||||
auto const vaultSleAfter = env.le(fx->broker.vaultKeylet());
|
||||
if (!BEAST_EXPECT(vaultSleAfter))
|
||||
return;
|
||||
|
||||
// Vault SLE ends terminal: both totals reset to zero.
|
||||
BEAST_EXPECT(Number{vaultSleAfter->at(sfAssetsAvailable)} == beast::kZero);
|
||||
BEAST_EXPECT(Number{vaultSleAfter->at(sfAssetsTotal)} == beast::kZero);
|
||||
|
||||
// Custody line's sfDust must be zero — Drain zeroed it inside
|
||||
// the trust-line layer, not via a manual fold in Vault code.
|
||||
Number const dustAfter = readVaultDust(env, fx->broker.vaultKeylet());
|
||||
BEAST_EXPECT(dustAfter == beast::kZero);
|
||||
|
||||
// The destination (lender) received at least the vault's
|
||||
// pre-terminal sfAssetsAvailable plus the reservoir that was
|
||||
// drained — the observable end-to-end effect of Drain mode:
|
||||
// value that used to be stranded in sfDust reaches the
|
||||
// destination in the same transaction.
|
||||
Number const lenderBalanceAfter = env.balance(fx->lender, fx->asset).number();
|
||||
Number const received = lenderBalanceAfter - lenderBalanceBefore;
|
||||
BEAST_EXPECT(received >= availBefore);
|
||||
BEAST_EXPECT(received >= availBefore + dustAfterDefault);
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testDustAbsentReadsZero(all_);
|
||||
testNoDustForLegacyOrIntegralVaults(all_);
|
||||
testDustBothSignOrientations(all_);
|
||||
testDustCreatedAndPromoted(all_);
|
||||
testNullptrPathUnchanged(all_);
|
||||
testAccountHoldsExcludesDust(all_);
|
||||
testVaultDeleteRequiresZeroDust(all_);
|
||||
testNonTerminalWithdrawAfterDust(all_);
|
||||
testSecondNonTerminalWithdrawAfterDust(all_);
|
||||
testSenderLegOverrideNonTerminalPromotes(all_);
|
||||
testSenderLegDrainTerminalRemoval(all_);
|
||||
}
|
||||
};
|
||||
|
||||
BEAST_DEFINE_TESTSUITE(VaultRoundingTrustlineDust, tx, xrpl);
|
||||
|
||||
} // namespace xrpl::test
|
||||
1042
src/test/app/lending/VaultRounding_test.cpp
Normal file
1042
src/test/app/lending/VaultRounding_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ TEST(RippleStateTests, BuilderSettersRoundTrip)
|
||||
auto const highQualityOutValue = canonical_UINT32();
|
||||
auto const highSponsorValue = canonical_ACCOUNT();
|
||||
auto const lowSponsorValue = canonical_ACCOUNT();
|
||||
auto const dustValue = canonical_NUMBER();
|
||||
|
||||
RippleStateBuilder builder{
|
||||
balanceValue,
|
||||
@@ -50,6 +51,7 @@ TEST(RippleStateTests, BuilderSettersRoundTrip)
|
||||
builder.setHighQualityOut(highQualityOutValue);
|
||||
builder.setHighSponsor(highSponsorValue);
|
||||
builder.setLowSponsor(lowSponsorValue);
|
||||
builder.setDust(dustValue);
|
||||
|
||||
builder.setLedgerIndex(index);
|
||||
builder.setFlags(0x1u);
|
||||
@@ -154,6 +156,14 @@ TEST(RippleStateTests, BuilderSettersRoundTrip)
|
||||
EXPECT_TRUE(entry.hasLowSponsor());
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = dustValue;
|
||||
auto const actualOpt = entry.getDust();
|
||||
ASSERT_TRUE(actualOpt.has_value());
|
||||
expectEqualField(expected, *actualOpt, "sfDust");
|
||||
EXPECT_TRUE(entry.hasDust());
|
||||
}
|
||||
|
||||
EXPECT_TRUE(entry.hasLedgerIndex());
|
||||
auto const ledgerIndex = entry.getLedgerIndex();
|
||||
ASSERT_TRUE(ledgerIndex.has_value());
|
||||
@@ -180,6 +190,7 @@ TEST(RippleStateTests, BuilderFromSleRoundTrip)
|
||||
auto const highQualityOutValue = canonical_UINT32();
|
||||
auto const highSponsorValue = canonical_ACCOUNT();
|
||||
auto const lowSponsorValue = canonical_ACCOUNT();
|
||||
auto const dustValue = canonical_NUMBER();
|
||||
|
||||
auto sle = std::make_shared<SLE>(RippleState::entryType, index);
|
||||
|
||||
@@ -196,6 +207,7 @@ TEST(RippleStateTests, BuilderFromSleRoundTrip)
|
||||
sle->at(sfHighQualityOut) = highQualityOutValue;
|
||||
sle->at(sfHighSponsor) = highSponsorValue;
|
||||
sle->at(sfLowSponsor) = lowSponsorValue;
|
||||
sle->at(sfDust) = dustValue;
|
||||
|
||||
RippleStateBuilder builderFromSle{sle};
|
||||
EXPECT_TRUE(builderFromSle.validate());
|
||||
@@ -360,6 +372,19 @@ TEST(RippleStateTests, BuilderFromSleRoundTrip)
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfLowSponsor");
|
||||
}
|
||||
|
||||
{
|
||||
auto const& expected = dustValue;
|
||||
|
||||
auto const fromSleOpt = entryFromSle.getDust();
|
||||
auto const fromBuilderOpt = entryFromBuilder.getDust();
|
||||
|
||||
ASSERT_TRUE(fromSleOpt.has_value());
|
||||
ASSERT_TRUE(fromBuilderOpt.has_value());
|
||||
|
||||
expectEqualField(expected, *fromSleOpt, "sfDust");
|
||||
expectEqualField(expected, *fromBuilderOpt, "sfDust");
|
||||
}
|
||||
|
||||
EXPECT_EQ(entryFromSle.getKey(), index);
|
||||
EXPECT_EQ(entryFromBuilder.getKey(), index);
|
||||
}
|
||||
@@ -438,5 +463,7 @@ TEST(RippleStateTests, OptionalFieldsReturnNullopt)
|
||||
EXPECT_FALSE(entry.getHighSponsor().has_value());
|
||||
EXPECT_FALSE(entry.hasLowSponsor());
|
||||
EXPECT_FALSE(entry.getLowSponsor().has_value());
|
||||
EXPECT_FALSE(entry.hasDust());
|
||||
EXPECT_FALSE(entry.getDust().has_value());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user