diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 735fc6ed9e..ae33ce57ef 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -10,6 +10,7 @@ #include #include +#include #include namespace xrpl { @@ -45,30 +46,25 @@ assetsToSharesDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount const& shares); /** - * Returns the effective change to sfAssetsTotal after canonicalizing - * `sfAssetsTotal + delta` under `mode`, as a non-negative magnitude. - * `delta` is positive when crediting the vault and negative when debiting - * it; only its sign is used to select the rounding direction. This is not - * the same as simply rounding `delta`'s magnitude to sfAssetsTotal's - * scale: both the before and after totals are canonicalized under `mode` - * before subtracting, so the result also accounts for sfAssetsTotal - * itself sitting mid-grid. The caller adds the returned magnitude to - * sfAssetsTotal for credits, or subtracts it for debits, and applies it - * the same way to any related field (for example sfAssetsAvailable) so - * all rails stay in sync; the other fields' scale is at least as fine as - * sfAssetsTotal's. + * Returns a non-negative magnitude to apply to sfAssetsTotal and the related + * rails, snapped to the scale of the post-transaction sfAssetsTotal — the same + * scale ValidVault uses. Credits take the Downward-rounded posterior total + * minus the current total so the vault cannot be credited more than `|delta|` + * even when the anterior total sits off that grid. Debits round `|delta|` + * Downward on the posterior scale. A zero result is `tecPRECISION_LOSS`. + * + * The caller adds the returned magnitude for credits or subtracts it for + * debits, and applies it to every related field so all rails stay in sync. + * A zero result is returned as `tecPRECISION_LOSS` so callers reject rather + * than moving shares without assets. * * @param vault The vault SLE. - * @param delta The signed amount by which sfAssetsTotal will change; only - * its sign selects the rounding direction. The magnitude is - * what is quantized and returned. - * @param mode The rounding mode to apply when quantizing to the - * sfAssetsTotal scale. - * - * @return The rounded magnitude, always non-negative. + * @param delta The requested signed change to sfAssetsTotal. + * @return The rounded magnitude, always non-negative, or `tecPRECISION_LOSS` + * if the change is smaller than one ULP. */ -[[nodiscard]] STAmount -clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta, Number::RoundingMode mode); +[[nodiscard]] std::expected +clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta); /** * Controls whether to truncate shares instead of rounding. diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 456fb40e5e..ee001e0dee 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include +#include #include #include @@ -69,20 +71,46 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co return assets; } -[[nodiscard]] STAmount -clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta, Number::RoundingMode mode) +[[nodiscard]] std::expected +clampToAssetsTotalScale(SLE::const_ref vault, STAmount const& delta) { XRPL_ASSERT( delta.asset() == vault->at(sfAsset), "xrpl::clampToAssetsTotalScale : delta and vault asset match"); Asset const asset = vault->at(sfAsset); - // Canonicalize both endpoints under the same rounding mode so the subtraction - // reflects only the effect of `delta`, never a rounding difference between them. - NumberRoundModeGuard const mg(mode); - STAmount const totalBefore{asset, vault->at(sfAssetsTotal)}; - STAmount const totalAfter{asset, vault->at(sfAssetsTotal) + delta}; - return delta.negative() ? totalBefore - totalAfter : totalAfter - totalBefore; + STAmount const magnitude = delta.negative() ? -delta : delta; + Number const assetsTotal = vault->at(sfAssetsTotal); + int const postScale = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); + return scale(assetsTotal + delta, asset); + }(); + + STAmount actualDelta; + if (delta.negative()) + { + // A debit that is a multiple of the posterior ULP lands exactly, so rounding the + // magnitude is sufficient and cannot pay out more than requested. + actualDelta = roundToScale(magnitude, postScale, Number::RoundingMode::Downward); + } + else + { + // A credit must be derived from the posterior total. Rounding only the magnitude + // leaves the anterior total's off-grid residue in the stored sum, which can credit + // the vault by more than the depositor paid. + Number const roundedPosterior = + roundToAsset(asset, assetsTotal + magnitude, postScale, Number::RoundingMode::Downward); + actualDelta = STAmount{asset, roundedPosterior - assetsTotal}; + } + + XRPL_ASSERT( + abs(actualDelta) <= abs(delta), + "xrpl::clampToAssetsTotalScale : actual delta smaller or equal to calculated delta"); + + if (actualDelta <= beast::kZero) + return std::unexpected(tecPRECISION_LOSS); + + return actualDelta; } [[nodiscard]] Number diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index fddaeee697..ef7ac613f8 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -322,26 +322,16 @@ VaultClawback::assetsToClawback( } } - // Post-fixCleanup3_4_0: round the recovery to the sfAssetsTotal scale so all rails change - // by the same representable delta. sharesDestroyed is intentionally NOT re-derived here: - // the holder's shares are burned for their pre-clamp value, so any sub-ULP trimmed off - // stays in the vault for the remaining shareholders. + // Post-fixCleanup3_4_0: round the recovery down at the posterior sfAssetsTotal scale so all + // rails change by the same representable delta. sharesDestroyed is intentionally NOT + // re-derived here: the holder's shares are burned for their pre-clamp value, so any + // sub-ULP trimmed off stays in the vault for the remaining shareholders. if (ctx_.view().rules().enabled(fixCleanup3_4_0) && assetsRecovered > beast::kZero) { - assetsRecovered = - clampToAssetsTotalScale(vault, -assetsRecovered, Number::RoundingMode::Upward); - // Recovery collapsed to zero. Return tecPRECISION_LOSS rather than burning shares for - // no asset return. - if (assetsRecovered <= beast::kZero) - return std::unexpected(tecPRECISION_LOSS); - // clampToAssetsTotalScale canonicalizes both endpoints under RoundingMode::Upward - // before subtracting (see its docstring), so when sfAssetsTotal sits mid-grid the - // returned magnitude can exceed the pre-clamp value by up to one grid step, even - // though assetsRecovered was already clamped to *assetsAvailable above. Clamp again - // so the debit in doApply() can never drive assetsAvailable negative; sharesDestroyed - // is left as-is, consistent with the note above that it is not re-derived here. - if (assetsRecovered > *assetsAvailable) - assetsRecovered = *assetsAvailable; + auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsRecovered); + if (!maybeClamped) + return std::unexpected(maybeClamped.error()); + assetsRecovered = *maybeClamped; } } catch (std::overflow_error const&) @@ -414,28 +404,14 @@ VaultClawback::doApply() if (sharesDestroyed == beast::kZero) return tecPRECISION_LOSS; - // Even a non-zero recovery can be too small to change the stored sfAssetsTotal or - // sfAssetsAvailable at STAmount's precision. Shares would still be burned, so ValidVault - // would fail after apply with "clawback must decrease vault balance"; reject here instead. - // On the issuer-clawback path this check is expected to be redundant once fixCleanup3_4_0 - // is active, since assetsToClawback's own clampToAssetsTotalScale already snapped - // assetsRecovered to the grid under RoundingMode::Upward; however this check runs under the - // ambient rounding mode, so a boundary value could in principle still register as dust here. - // On the owner-burn path assetsRecovered is not put through that clamp at all, so this is the - // only guard against burning shares without moving the vault's stored balance. Genuinely - // reachable (or at least not provably otherwise); must return tecPRECISION_LOSS rather than - // assert, so keep this as a normal, testable branch rather than UNREACHABLE. - // - // Number arithmetic can throw overflow_error when Scale and totals are large. Caught - // below. debitIsNonZeroDust converts assetsTotal/assetsAvailable to STAmount, which is - // exactly what a sufficiently abused sfScale can push out of STAmount's representable - // range. + // Number arithmetic can throw overflow_error when Scale and totals are large. if (view().rules().enabled(fixCleanup3_4_0)) { try { - if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered) || - debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsRecovered)) + // A non-zero recovery can be too small to change the stored sfAssetsTotal at + // STAmount's precision. Shares would still be burned, reject it instead. + if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered)) { // LCOV_EXCL_START JLOG(j_.debug()) @@ -463,8 +439,6 @@ VaultClawback::doApply() // LCOV_EXCL_STOP } - // Debit both rails by the same delta so sfAssetsTotal and sfAssetsAvailable stay in step, - // as required by the ValidVault invariant. assetsTotal -= assetsRecovered; assetsAvailable -= assetsRecovered; view().update(vault); diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 3c731b0620..4470ac95cd 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -352,25 +352,12 @@ VaultDeposit::doApply() // representable delta. if (fix340Enabled) { - // Round Downward so the vault is credited by at most what the depositor paid. - assetsDeposited = - clampToAssetsTotalScale(vault, assetsDeposited, Number::RoundingMode::Downward); - - // Unreachable: sharesCreated == 0 and roundsToZeroForDepositor above already - // rejected deposits that canonicalize to nothing, so clamping a positive credit - // further cannot collapse it (or the re-derived share count) to zero. Kept as - // defense in depth. - if (assetsDeposited <= beast::kZero) - { - // LCOV_EXCL_START - UNREACHABLE( - "xrpl::VaultDeposit::doApply : deposit rounds to zero at assets outstanding " - "scale"); - JLOG(j_.warn()) << "VaultDeposit: deposit rounds to zero at " - "assets outstanding scale."; - return tecPRECISION_LOSS; - // LCOV_EXCL_STOP - } + // Round down at the posterior sfAssetsTotal scale so the vault is credited by no more + // than the depositor paid. + auto const maybeClamped = clampToAssetsTotalScale(vault, assetsDeposited); + if (!maybeClamped) + return maybeClamped.error(); + assetsDeposited = *maybeClamped; // The pre-clamp share count would over-issue by the trimmed ULP and give the depositor // more value than they credited. @@ -381,12 +368,7 @@ VaultDeposit::doApply() sharesCreated = *maybeReShares; if (sharesCreated == beast::kZero) - { - // LCOV_EXCL_START - UNREACHABLE("xrpl::VaultDeposit::doApply : clamped deposit mints zero shares"); return tecPRECISION_LOSS; - // LCOV_EXCL_STOP - } } } catch (std::overflow_error const&) diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 4e1055720a..e28055702b 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -381,9 +381,9 @@ VaultWithdraw::doApply() if (fix340Enabled && !isFinalWithdrawal) { - // Fixed-shares path: a small share count can round to zero assets even though the vault - // still has backing value. Reject rather than burn shares for a zero payout. The - // fixed-assets branch above has already rejected zero via the sharesRedeemed check. + // Fixed-shares path: a small share count can round to zero assets even though the vault has + // backing value. Reject rather than burn shares for a zero payout. The fixed-assets branch + // above has already rejected zero via the sharesRedeemed check. if (amount.asset() == share && assetsWithdrawn == beast::kZero && assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero) { @@ -391,17 +391,12 @@ VaultWithdraw::doApply() return tecPRECISION_LOSS; } - // Number arithmetic can throw overflow_error when Scale and totals are large. Caught - // below. debitIsNonZeroDust converts assetsTotal/assetsAvailable to STAmount, which is - // exactly what a sufficiently abused sfScale can push out of STAmount's representable - // range. + // Number arithmetic can throw overflow_error when Scale and totals are large. try { - // Even a non-zero withdrawal can be too small to change the stored sfAssetsTotal or - // sfAssetsAvailable at STAmount's precision. Shares would still move, so ValidVault - // would fail after apply; reject here instead. - if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn) || - debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsWithdrawn)) + // A non-zero payout can be too small to change the stored sfAssetsTotal at + // STAmount's precision. Shares would still be burned, reject it instead. + if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn)) { JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored" " vault balance"; @@ -448,30 +443,16 @@ VaultWithdraw::doApply() // the final-withdrawal path, which overwrites assetsWithdrawn with sfAssetsAvailable below. if (fix340Enabled && !isFinalWithdrawal && assetsWithdrawn > beast::kZero) { - // Number arithmetic can throw overflow_error when Scale and totals are large. The - // debitIsNonZeroDust check above already performs the same STAmount conversion of - // assetsTotal/assetsAvailable under the ambient rounding mode and would have thrown - // (and been caught) first for any value that overflows under that mode. Only reachable - // if RoundingMode::Upward -- forced below to keep the clamp conservative -- carries a - // value that was in range under the ambient mode just past the max representable - // exponent. Kept for defense in depth; not realistically triggerable from a test. + // Number arithmetic can throw overflow_error when Scale and totals are large. try { - // Round Upward on the negative delta: the stored total is decremented by no more than - // it can represent, so the payout is trimmed downward and the vault never pays out - // more than it can account for. - assetsWithdrawn = - clampToAssetsTotalScale(vault, -assetsWithdrawn, Number::RoundingMode::Upward); - // Unreachable: debitIsNonZeroDust above already rejected amounts too small to - // move sfAssetsTotal, so snapping to that same grid cannot collapse the payout - // to zero. Kept as defense in depth. - if (assetsWithdrawn <= beast::kZero) - { - // LCOV_EXCL_START - UNREACHABLE("xrpl::VaultWithdraw::doApply : clamped withdrawal rounds to zero"); - return tecPRECISION_LOSS; - // LCOV_EXCL_STOP - } + // Round down at the posterior sfAssetsTotal scale so the payout never exceeds the + // value represented by the redeemed shares. sharesRedeemed is intentionally not + // re-derived: any trimmed residue stays with remaining shareholders. + auto const maybeClamped = clampToAssetsTotalScale(vault, -assetsWithdrawn); + if (!maybeClamped) + return maybeClamped.error(); + assetsWithdrawn = *maybeClamped; } // LCOV_EXCL_START catch (std::overflow_error const&) diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp index 04f25e11e7..5052830bed 100644 --- a/src/test/app/vault/VaultScale_test.cpp +++ b/src/test/app/vault/VaultScale_test.cpp @@ -893,22 +893,11 @@ private: } }); - // Regression for a review finding on PR 8057: clampToAssetsTotalScale - // canonicalizes sfAssetsTotal under RoundingMode::Upward, and since - // sfAssetsTotal/sfAssetsAvailable are NUMBER fields (more precise - // than an STAmount's 16 significant digits), that canonicalization - // can *amplify* the magnitude when sfAssetsTotal sits mid-grid, e.g. - // T=10000000000000005, delta=6: round_up(T) - round_up(T - 6) == - // 10000000000000010 - 9999999999999999 == 11 > 6. assetsToClawback - // had already clamped assetsRecovered to sfAssetsAvailable (== 6 - // here) *before* that scale clamp runs, so without a re-check the - // amplified value (11) would flow into doApply()'s - // `assetsAvailable -= assetsRecovered`, driving assetsAvailable - // negative. Force this exact mid-grid state via `peek` (real deposit - // math can't reach a 17-significant-digit total) and confirm the - // clawback clamps back down to sfAssetsAvailable instead. + // The posterior total determines the invariant's comparison scale. Although + // T=10000000000000005 has a 10-asset ULP, T-6=9999999999999999 is exactly representable + // at the finer posterior scale, so the full recovery is valid. testCase(0, [&, this](Env& env, Data d) { - testcase("Scale clawback re-clamped after mid-grid amplification"); + testcase("Scale clawback uses posterior scale across decade boundary"); // depositor's balance is capped to 200 by the shared harness fixture. auto tx = d.vault.deposit( @@ -931,10 +920,8 @@ private: return true; }); - // sfAmount absent means "clawback everything the holder has"; - // the holder's 100 shares would convert to ~100 assets, clamped - // to sfAssetsAvailable (6) before the sfAssetsTotal scale clamp - // amplifies it to 11. + // sfAmount absent means "clawback everything the holder has"; the holder's 100 shares + // convert to ~100 assets and are first clamped to sfAssetsAvailable (6). // Deliberately read the vault before env.close(): the peek() // mutation above lives only in the open ledger (it bypasses the // normal transaction-apply path), so closing the ledger would @@ -947,10 +934,140 @@ private: auto const sle = env.le(d.keylet); BEAST_EXPECT(sle != nullptr); - // Never negative: the fix re-clamps assetsRecovered to - // sfAssetsAvailable after the scale clamp amplifies it. - BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0))); - BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, midGridTotal - available)); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == Number(0)); + BEAST_EXPECT(sle->at(sfAssetsTotal) == midGridTotal - available); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(94)); + }); + + // If the posterior total remains on the same 10-asset grid, a six-asset recovery is + // genuinely unrepresentable and must be rejected rather than burning shares for nothing. + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale clawback rejects amount below posterior scale"); + + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + + Number const midGridTotal{12345678901234567ll}; + Number const available{6}; + d.peek([&](SLE& vault, SLE& shares) -> bool { + vault[sfAssetsTotal] = midGridTotal; + vault[sfAssetsAvailable] = available; + shares[sfOutstandingAmount] = static_cast(12345678901234567ull); + return true; + }); + + tx = d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor}); + env(tx, Ter(tecPRECISION_LOSS)); + + auto const sle = env.le(d.keylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); + BEAST_EXPECT(sle->at(sfAssetsTotal) == midGridTotal); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(100)); + }); + + // The same posterior-scale rule applies to a recovery larger than the anterior ULP. + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale clawback preserves exact posterior amount"); + + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + + Number const midGridTotal{10000000000000005ll}; + Number const available{15}; + d.peek([&](SLE& vault, SLE& shares) -> bool { + vault[sfAssetsTotal] = midGridTotal; + vault[sfAssetsAvailable] = available; + shares[sfOutstandingAmount] = static_cast(10000000000000005ull); + return true; + }); + + tx = d.vault.clawback({.issuer = d.issuer, .id = d.keylet.key, .holder = d.depositor}); + env(tx, Ter(tesSUCCESS)); + + auto const sle = env.le(d.keylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == Number(0)); + BEAST_EXPECT(sle->at(sfAssetsTotal) == midGridTotal - available); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(85)); + }); + + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale deposit rejects amount below posterior scale"); + + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + + Number const midGridTotal{10000000000000005ll}; + Number const available{100}; + d.peek([&](SLE& vault, SLE& shares) -> bool { + vault[sfAssetsTotal] = midGridTotal; + vault[sfAssetsAvailable] = available; + shares[sfOutstandingAmount] = static_cast(10000000000000005ull); + return true; + }); + + auto const assetsBefore = env.balance(d.depositor, d.assets); + tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(6))}); + env(tx, Ter(tecPRECISION_LOSS)); + + auto const sle = env.le(d.keylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); + BEAST_EXPECT(sle->at(sfAssetsTotal) == midGridTotal); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == assetsBefore); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(100)); + }); + + testCase(0, [&, this](Env& env, Data d) { + testcase("Scale withdraw uses posterior scale across decade boundary"); + + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + + Number const midGridTotal{10000000000000005ll}; + Number const available{100}; + d.peek([&](SLE& vault, SLE& shares) -> bool { + vault[sfAssetsTotal] = midGridTotal; + vault[sfAssetsAvailable] = available; + shares[sfOutstandingAmount] = static_cast(10000000000000005ull); + return true; + }); + + auto const assetsBefore = env.balance(d.depositor, d.assets); + tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.share, Number(15))}); + env(tx, Ter(tesSUCCESS)); + + auto const sle = env.le(d.keylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == Number(85)); + BEAST_EXPECT(sle->at(sfAssetsTotal) == midGridTotal - Number(15)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, assetsBefore.number() + Number(15))); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(85)); }); } diff --git a/src/test/app/vault/VaultTransactorPrecision_test.cpp b/src/test/app/vault/VaultTransactorPrecision_test.cpp index f88d1706c1..f7f912967b 100644 --- a/src/test/app/vault/VaultTransactorPrecision_test.cpp +++ b/src/test/app/vault/VaultTransactorPrecision_test.cpp @@ -479,7 +479,7 @@ class VaultTransactorPrecision_test : public VaultPrecisionFixture } // Sub-ULP withdrawal from a ~1e8 vault; post-fix must return - // tecPRECISION_LOSS after the Upward clamp rounds the amount to zero. + // tecPRECISION_LOSS after the posterior-scale clamp rounds the amount to zero. void testWithdrawSubUlpRejected(FeatureBitset features) {