-
Notifications
You must be signed in to change notification settings - Fork 1.7k
fix: Reject VaultWithdraw fixed-share amounts that round to zero #7950
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
ed6a130
c8e46d5
a404103
fce3847
11fd4cc
84c5035
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| #pragma once | ||
|
|
||
| #include <xrpl/basics/Number.h> | ||
| #include <xrpl/ledger/ReadView.h> | ||
| #include <xrpl/protocol/AccountID.h> | ||
| #include <xrpl/protocol/Asset.h> | ||
| #include <xrpl/protocol/Protocol.h> | ||
| #include <xrpl/protocol/STAmount.h> | ||
| #include <xrpl/protocol/STLedgerEntry.h> | ||
|
|
@@ -52,6 +54,37 @@ enum class TruncateShares : bool { No = false, Yes = true }; | |
| */ | ||
| enum class WaiveUnrealizedLoss : bool { No = false, Yes = true }; | ||
|
|
||
| /** | ||
| * Returns the effective total of assets backing outstanding shares, i.e. | ||
| * sfAssetsTotal, discounted by sfLossUnrealized unless waived. This is the | ||
| * numerator used by both withdraw conversion helpers (assetsToSharesWithdraw | ||
| * and sharesToAssetsWithdraw) to compute the share/asset exchange rate. | ||
| * | ||
| * @param vault The vault SLE. | ||
| * @param waive Whether to waive (i.e. not subtract) the vault's unrealized | ||
| * loss. | ||
| */ | ||
| [[nodiscard]] Number | ||
| effectiveAssetsTotalWithdraw(SLE::const_ref vault, WaiveUnrealizedLoss waive); | ||
|
|
||
| /** | ||
| * Returns whether debiting `amount` from `total` — the current value of a | ||
| * vault's sfAssetsTotal or sfAssetsAvailable field — would canonicalize back | ||
| * to the exact same STAmount value it started at. This happens when a | ||
| * genuinely non-zero debit is dust relative to a `total` large enough to | ||
| * exceed STAmount's significant-digit precision: the shares still move, but | ||
| * the stored total doesn't change, which otherwise trips the ValidVault | ||
| * invariant after the fact instead of failing cleanly upfront. | ||
| * | ||
| * @param asset The vault's underlying asset, used to canonicalize both sides | ||
| * the same way the ledger will when the field is stored. | ||
| * @param total The field's current value. | ||
| * @param amount The amount to debit. A value of zero always returns false; | ||
| * that case is rejected separately and unconditionally. | ||
| */ | ||
| [[nodiscard]] bool | ||
| debitRoundsToNoOp(Asset const& asset, Number const& total, Number const& amount); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the name
I suggest naming it as |
||
|
|
||
| /** | ||
| * From the perspective of a vault, return the number of shares to demand from | ||
| * the depositor when they ask to withdraw a fixed amount of assets. Since | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -805,19 +805,36 @@ ValidVault::finalize( | |
| auto const& beforeVault = beforeVault_[0]; | ||
|
|
||
| auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); | ||
| if (!maybeVaultDeltaAssets) | ||
|
|
||
| // Post-fixCleanup3_4_0: a withdrawal that redeems shares from a | ||
| // pool with no effective value left to back them (e.g. fully | ||
| // impaired/insolvent) legitimately moves zero assets on both | ||
| // sides — VaultWithdraw::doApply does not touch either | ||
| // balance-holding entry for a zero-value transfer, so no delta | ||
| // is recorded. VaultWithdraw::doApply separately rejects | ||
| // (tecPRECISION_LOSS) the case where a *positive* per-share | ||
| // value merely rounds down to zero, so a missing delta while | ||
| // the pool still held positive effective value indicates a | ||
| // real accounting bug, not this exception. | ||
| bool const zeroDeltaIsLegitimate = view.rules().enabled(fixCleanup3_4_0) && | ||
| !maybeVaultDeltaAssets && beforeVault.assetsTotal <= beforeVault.lossUnrealized; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think it's possible to have a legitimate case where |
||
|
|
||
| if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate) | ||
| { | ||
| JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance"; | ||
| return false; // That's all we can do | ||
| } | ||
|
|
||
| DeltaInfo const vaultDeltaAssets = maybeVaultDeltaAssets.value_or( | ||
| DeltaInfo{.delta = kNumZero, .scale = std::nullopt}); | ||
|
|
||
| // Get the posterior scale to round calculations to | ||
| auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); | ||
| auto const minScale = computeVaultMinScale(vaultDeltaAssets, view.rules()); | ||
|
|
||
| auto const vaultPseudoDeltaAssets = | ||
| roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); | ||
| roundToAsset(vaultAsset, vaultDeltaAssets.delta, minScale); | ||
|
|
||
| if (vaultPseudoDeltaAssets >= kZero) | ||
| if (!zeroDeltaIsLegitimate && vaultPseudoDeltaAssets >= kZero) | ||
| { | ||
| JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance"; | ||
| result = false; | ||
|
|
@@ -844,63 +861,76 @@ ValidVault::finalize( | |
|
|
||
| if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value()) | ||
| { | ||
| JLOG(j.fatal()) << // | ||
| "Invariant failed: withdrawal must change one destination balance"; | ||
| return false; | ||
| // Both changed is always a bug. Neither changed is | ||
| // consistent only with a legitimate zero-value | ||
| // withdrawal, which moves nothing on either side — | ||
| // there is nothing left to cross-check. | ||
| if (!zeroDeltaIsLegitimate || maybeAccDelta.has_value()) | ||
| { | ||
| JLOG(j.fatal()) << // | ||
| "Invariant failed: withdrawal must change one destination balance"; | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| auto const destinationDelta = // | ||
| maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta; | ||
|
|
||
| // the scale of destinationDelta can be coarser than | ||
| // minScale, so we take that into account when rounding | ||
| auto const destinationScale = computeCoarsestScale({destinationDelta}); | ||
| auto const localMinScale = std::max(minScale, destinationScale); | ||
|
|
||
| auto const roundedDestinationDelta = | ||
| roundToAsset(vaultAsset, destinationDelta.delta, localMinScale); | ||
|
|
||
| // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only. | ||
| // If the receiver's trust line sits at a coarser scale, the inflow may | ||
| // safely round down to zero. | ||
| // | ||
| // XRP and MPT remain strict. Because they are integer-exact, a zero | ||
| // destination delta indicates a true accounting bug, not a rounding artifact. | ||
| bool const tolerateZeroDelta = | ||
| view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral(); | ||
| auto const invalidBalanceChange = tolerateZeroDelta | ||
| ? roundedDestinationDelta < kZero | ||
| : roundedDestinationDelta <= kZero; | ||
| if (invalidBalanceChange) | ||
| else | ||
| { | ||
| JLOG(j.fatal()) << // | ||
| "Invariant failed: withdrawal must increase destination balance"; | ||
| result = false; | ||
| } | ||
|
|
||
| auto const localPseudoDeltaAssets = | ||
| roundToAsset(vaultAsset, vaultPseudoDeltaAssets, 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 | ||
| // rail. Tolerate the mismatch only when the destroyed amount (vault outflow | ||
| // minus destination inflow, in Number space) is itself sub-ULP at the | ||
| // destination's scale. Floor rounding is used so that values exactly at the | ||
| // step boundary are not mistakenly dismissed. Any representable discrepancy | ||
| // indicates a real accounting bug and must be caught. | ||
| auto const destroyedIsSubUlp = tolerateZeroDelta && | ||
| roundToAsset( | ||
| vaultAsset, | ||
| maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta, | ||
| destinationScale, | ||
| Number::RoundingMode::Downward) == kZero; | ||
| if (!destroyedIsSubUlp && | ||
| localPseudoDeltaAssets * -1 != roundedDestinationDelta) | ||
| { | ||
| JLOG(j.fatal()) << "Invariant failed: " << // | ||
| "withdrawal must change vault and destination balance by equal " | ||
| "amount"; | ||
| result = false; | ||
| // A one-sided change is cross-checked even for a | ||
| // legitimate zero vault delta: the destination must | ||
| // then have moved by (rounded) zero as well. | ||
| auto const destinationDelta = // | ||
| maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can use |
||
|
|
||
| // the scale of destinationDelta can be coarser than | ||
| // minScale, so we take that into account when rounding | ||
| auto const destinationScale = computeCoarsestScale({destinationDelta}); | ||
| auto const localMinScale = std::max(minScale, destinationScale); | ||
|
|
||
| auto const roundedDestinationDelta = | ||
| roundToAsset(vaultAsset, destinationDelta.delta, localMinScale); | ||
|
|
||
| // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs | ||
| // only. If the receiver's trust line sits at a coarser scale, the inflow | ||
| // may safely round down to zero. | ||
| // | ||
| // XRP and MPT remain strict. Because they are integer-exact, a zero | ||
| // destination delta indicates a true accounting bug, not a rounding | ||
| // artifact. | ||
| bool const tolerateZeroDelta = | ||
| view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral(); | ||
| auto const invalidBalanceChange = tolerateZeroDelta | ||
| ? roundedDestinationDelta < kZero | ||
| : roundedDestinationDelta <= kZero; | ||
| if (invalidBalanceChange) | ||
| { | ||
| JLOG(j.fatal()) << // | ||
| "Invariant failed: withdrawal must increase destination balance"; | ||
| result = false; | ||
| } | ||
|
|
||
| auto const localPseudoDeltaAssets = | ||
| roundToAsset(vaultAsset, vaultPseudoDeltaAssets, 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 rail. Tolerate the mismatch only when the destroyed amount (vault | ||
| // outflow minus destination inflow, in Number space) is itself sub-ULP at | ||
| // the destination's scale. Floor rounding is used so that values exactly | ||
| // at the step boundary are not mistakenly dismissed. Any representable | ||
| // discrepancy indicates a real accounting bug and must be caught. | ||
| auto const destroyedIsSubUlp = tolerateZeroDelta && | ||
| roundToAsset( | ||
| vaultAsset, | ||
| vaultDeltaAssets.delta * -1 - destinationDelta.delta, | ||
| destinationScale, | ||
| Number::RoundingMode::Downward) == kZero; | ||
| if (!destroyedIsSubUlp && | ||
| localPseudoDeltaAssets * -1 != roundedDestinationDelta) | ||
| { | ||
| JLOG(j.fatal()) << "Invariant failed: " << // | ||
| "withdrawal must change vault and destination balance by equal " | ||
| "amount"; | ||
| result = false; | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"withdraw" is a verb, which reads as an operation.
I suggest naming it as
assetsTotalNetOfUnrealizedLossorassetsTotalForWithdrawal