-
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 4 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; | ||||||
|
|
@@ -832,7 +849,7 @@ ValidVault::finalize( | |||||
| return destination == vaultAsset.getIssuer(); | ||||||
| }(); | ||||||
|
|
||||||
| if (!issuerWithdrawal) | ||||||
| if (!issuerWithdrawal && !zeroDeltaIsLegitimate) | ||||||
|
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. Account-delta cross-check shouldn't be gated by zeroDeltaIsLegitimate. Use zero fallback:
Suggested change
Contributor
Author
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. Done in 11fd4cc, with one adjustment: with the gate removed, a legitimate zero-value withdrawal has both destination deltas absent, which would trip the "must change one destination balance" check. So that specific case is tolerated explicitly (nothing moved anywhere, nothing to cross-check), while any one-sided balance change now runs the full cross-check against the zero-fallback vault delta. The sub-ULP check also had to switch from
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. ✅ Auto-resolved: the latest review no longer flags this issue, so it appears to have been addressed. If that is not correct, reopen this thread and it will be re-checked on the next review. |
||||||
| { | ||||||
| auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee); | ||||||
| auto const maybeOtherAccDelta = [&]() -> std::optional<DeltaInfo> { | ||||||
|
|
||||||
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