Compare commits

..

2 Commits

Author SHA1 Message Date
Jingchen
71f5555873 feat: Remove pseudo account field filter (#8042)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-27 13:52:23 +00:00
Ayaz Salikhov
3967ed6d54 build: Update release-info to get better pkg_release (#8131) 2026-08-27 13:34:38 +00:00
15 changed files with 79 additions and 386 deletions

View File

@@ -9,7 +9,7 @@ outputs:
description: "The release channel this build belongs to."
value: ${{ steps.release_info.outputs.channel }}
pkg_release:
description: "The package release number: 1 for a tag, the run number otherwise."
description: "The package release number: 1 for a tag, <run number>.<commit date>git<short commit hash> otherwise."
value: ${{ steps.release_info.outputs.pkg_release }}
runs:
@@ -41,4 +41,4 @@ runs:
- name: Determine release channel and package release
id: release_info
uses: XRPLF/actions/release-info@7f956517847fb9e0b56070f72e1280f4e7404a09
uses: XRPLF/actions/release-info@7cc0e4a8d9d0b838f92c48d312856b190341bbba

View File

@@ -74,7 +74,7 @@
"extra_cmake_args": "-Dvalidator_keys=ON",
"package": {
"type": "deb",
"image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88"
"image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-b6a8995"
}
}
],
@@ -88,7 +88,7 @@
"extra_cmake_args": "-Dvalidator_keys=ON",
"package": {
"type": "rpm",
"image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88"
"image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-b6a8995"
}
}
]

View File

@@ -2,8 +2,8 @@
#
# - one job per config that carries a "package" map in linux.json
# - that map names the container image and the format it builds there
# - with 'publish: true' a job also uploads what it built
# (see package/docker/publish_pkg.py)
# - every job ends with the image's publish_pkg.py, uploading what it built
# with 'publish: true' and doing a --dry-run otherwise
#
# Only linux/amd64 is supported; the runner is hardcoded in the job below.
name: Package
@@ -76,6 +76,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: false
- name: Download pre-built xrpld binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -126,14 +131,15 @@ jobs:
if-no-files-found: error
- name: Publish package
if: ${{ inputs.publish }}
env:
CHANNEL: ${{ steps.release_info.outputs.channel }}
DRY_RUN_OPTION: ${{ !inputs.publish && '--dry-run' || '' }}
NEXUS_URL: ${{ inputs.nexus_url }}
NEXUS_USERNAME: ${{ secrets.remote_username }}
NEXUS_PASSWORD: ${{ secrets.remote_password }}
NEXUS_USERNAME: ${{ inputs.publish && secrets.remote_username || '' }}
NEXUS_PASSWORD: ${{ inputs.publish && secrets.remote_password || '' }}
run: |
./package/docker/publish_pkg.py \
publish_pkg.py \
--channel "${CHANNEL}" \
--package-dir "${BUILD_DIR}" \
--nexus-url "${NEXUS_URL}"
--nexus-url "${NEXUS_URL}" \
${DRY_RUN_OPTION}

View File

@@ -49,6 +49,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Prepare runner
uses: XRPLF/actions/prepare-runner@7bf7ceca5932114abdd0d43493c3c30c5a654e13
with:
enable_ccache: false
- name: Determine release info
id: release_info
uses: ./.github/actions/release-info

View File

@@ -13,7 +13,7 @@ To build from source instead, see [BUILD.md](../BUILD.md).
Packages are published to four channels:
- `stable` - the latest production release
- `stable` - production releases
- `rc` - release candidates
- `beta` - beta builds
- `develop` - every push to the [`develop` branch](https://github.com/XRPLF/rippled/tree/develop)

View File

@@ -15,7 +15,6 @@
#include <cstdint>
#include <expected>
#include <optional>
#include <set>
#include <vector>
namespace xrpl {
@@ -353,14 +352,14 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey);
*
* The list is constructed during initialization and is const after that.
* Pseudo-account designator fields MUST be maintained by including the
* SField::sMD_PseudoAccount flag in the SField definition.
* SField::kSmdPseudoAccount flag in the SField definition.
*/
[[nodiscard]] std::vector<SField const*> const&
getPseudoAccountFields();
/**
* Returns true if and only if sleAcct is a pseudo-account or specific
* pseudo-accounts in pseudoFieldFilter.
* Returns true if and only if sleAcct is a pseudo-account of any kind
* (i.e. carries at least one field flagged with SField::kSmdPseudoAccount).
*
* Returns false if sleAcct is:
* - NOT a pseudo-account OR
@@ -368,18 +367,15 @@ getPseudoAccountFields();
* - null pointer
*/
[[nodiscard]] bool
isPseudoAccount(SLE::const_pointer sleAcct, std::set<SField const*> const& pseudoFieldFilter = {});
isPseudoAccount(SLE::const_pointer sleAcct);
/**
* Convenience overload that reads the account from the view.
*/
[[nodiscard]] inline bool
isPseudoAccount(
ReadView const& view,
AccountID const& accountId,
std::set<SField const*> const& pseudoFieldFilter = {})
isPseudoAccount(ReadView const& view, AccountID const& accountId)
{
return isPseudoAccount(view.read(keylet::account(accountId)), pseudoFieldFilter);
return isPseudoAccount(view.read(keylet::account(accountId)));
}
/**

View File

@@ -149,15 +149,21 @@ Versions sort in row order, so moving to a more mature channel never downgrades.
The action decides the package release number on the same split: a tag's version
is unique, so its packages are release 1, while develop repeats the same version
and takes `github.run_number` so each push supersedes the last. Both reach the
packaging scripts as arguments, so neither script derives anything itself.
and takes `<run number>.<commit date>git<commit hash>`, e.g.
`857.20260826gitb6a8995` — the leading run number keeps each push superseding
the last, and the date and hash say which commit a package on
`packages.xrplf.org` came from. Both reach the packaging scripts as arguments,
so neither script derives anything itself.
Publishing is the last step of each packaging job, uploading from the container
that built the packages. It runs when the caller passes `publish: true`:
`on-trigger.yml` for develop pushes in `XRPLF/rippled`, `on-tag.yml` for tags in
any `XRPLF` repository, `on-pr.yml` never. Both authenticate with the
that built the packages with the `publish_pkg.py` shipped in the image — the
same copy other repositories run. Without `publish: true` the step is a
`--dry-run`, listing the uploads it would make without needing credentials, so
any run that builds packages also exercises the upload routing. `on-trigger.yml`
passes `publish: true` for develop pushes in `XRPLF/rippled` and `on-tag.yml`
for tags in any `XRPLF` repository, both authenticating with the
`NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the
Conan remote.
Conan remote; `on-pr.yml` never publishes.
Nexus owns the repository metadata; nothing here indexes anything. Worth knowing:

View File

@@ -28,7 +28,6 @@
#include <limits>
#include <memory>
#include <optional>
#include <set>
#include <stdexcept>
#include <vector>
@@ -515,8 +514,8 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey)
}
// Pseudo-account designator fields MUST be maintained by including the
// SField::sMD_PseudoAccount flag in the SField definition. (Don't forget to
// "| SField::sMD_Default"!) The fields do NOT need to be amendment-gated,
// SField::kSmdPseudoAccount flag in the SField definition. (Don't forget to
// "| SField::kSmdDefault"!) The fields do NOT need to be amendment-gated,
// since a non-active amendment will not set any field, by definition.
// Specific properties of a pseudo-account are NOT checked here, that's what
// InvariantCheck is for.
@@ -547,18 +546,14 @@ getPseudoAccountFields()
}
[[nodiscard]] bool
isPseudoAccount(SLE::const_pointer sleAcct, std::set<SField const*> const& pseudoFieldFilter)
isPseudoAccount(SLE::const_pointer sleAcct)
{
auto const& fields = getPseudoAccountFields();
// Intentionally use defensive coding here because it's cheap and makes the
// semantics of true return value clean.
return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT &&
std::count_if(
fields.begin(), fields.end(), [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool {
return sleAcct->isFieldPresent(*sf) &&
(pseudoFieldFilter.empty() || pseudoFieldFilter.contains(sf));
}) > 0;
std::ranges::any_of(getPseudoAccountFields(), [&sleAcct](SField const* sf) {
return sleAcct->isFieldPresent(*sf);
});
}
std::expected<SLE::pointer, TER>

View File

@@ -384,8 +384,7 @@ requireAuth(
// They are implicitly authorized for any MPT they hold, including vault shares whose
// underlying asset would otherwise require auth.
auto const isPseudoAccountExempt = [&] {
return (featureSAVEnabled || featureMPTV2Enabled) &&
isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID, &sfAMMID});
return (featureSAVEnabled || featureMPTV2Enabled) && isPseudoAccount(view, account);
};
auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());

View File

@@ -847,7 +847,7 @@ ValidMPTTransfer::isAuthorized(
// auth. Exempt them here rather than relying on requireAuth: the recursive
// share -> underlying descent in requireAuth fails for a pseudo-account
// that holds the share but not the underlying.
if (isPseudoAccount(view, holder, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
if (isPseudoAccount(view, holder))
return true;
auto const key = keylet::mptoken(mptid, holder);

View File

@@ -3,7 +3,6 @@
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/View.h>
@@ -170,6 +169,8 @@ LoanManage::defaultLoan(
return std::min(covered, coverAvailable);
}();
auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered;
// Update the Vault object:
// The vault may be at a different scale than the loan. Reduce rounding
@@ -177,75 +178,7 @@ LoanManage::defaultLoan(
// scale.
auto const vaultScale = getAssetsTotalScale(vaultSle);
// Under fixCleanup3_4_0, both vault-side fields are mutated through a
// single asset-typed STAmount pair. `amount = STAmount{vaultAsset,
// defaultCovered}` is applied to both sfAssetsTotal and
// sfAssetsAvailable, and `writeOff = STAmount{vaultAsset,
// totalDefaultAmount}` is applied to sfAssetsTotal only. Because the
// shared `amount` is normalized to STAmount precision exactly once and
// absorbed by both fields symmetrically, sfAssetsAvailable cannot
// overshoot sfAssetsTotal from arithmetic alone -- the residual is
// exactly `-writeOff` on Total. Pre-amendment, the two fields were
// adjusted via values obtained through different Number->STAmount
// paths (vaultDefaultAmount rounded down to vaultScale on one side,
// defaultCovered kept at the finer loan scale on the other), so the
// normalization was asymmetric and a dust-reconciliation snap was
// required to paper over the resulting cross-side mismatch -- a snap
// that itself minted phantom assets on sfAssetsTotal. See the
// sibling change in LoanPay for the same fix on the payment path.
//
// The Number-precision guard `T - A >= totalDefaultAmount` below is
// exactly sufficient: loanVaultExposure returns a difference of
// STNumber fields that are whole multiples of the same immutable
// `10^sfLoanScale` and never outgrow its 16 digits, so its STAmount
// promotion round-trips losslessly with no writeOff slack.
bool const useUnifiedAssetArithmetic = view.rules().enabled(fixCleanup3_4_0);
if (useUnifiedAssetArithmetic)
{
// Prior to realizing the default, the vault must already carry
// at least this loan's exposure. A violation here is a corrupt
// ledger, not a transaction-input error.
//
// This guard is strictly stronger than the pre-amendment
// `vaultTotalBefore < vaultDefaultAmount` form, but activation is
// safe: the ValidVault invariant continuously enforces Total >=
// Available (so Total - Available >= 0 on every ledger), and the
// pre-amendment cross-scale rounding could only inflate the
// difference Total - Available, never deflate it. Any ledger
// reaching this point post-activation will therefore already
// satisfy the stronger form.
Number const vaultTotalBefore = *vaultSle->at(sfAssetsTotal);
Number const vaultAvailableBefore = *vaultSle->at(sfAssetsAvailable);
if (vaultTotalBefore - vaultAvailableBefore < totalDefaultAmount)
{
// LCOV_EXCL_START
JLOG(j.warn()) << "Vault exposure is less than the loan default amount";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
// Mutate the two vault fields through a single asset-typed pair
// (see the block comment on useUnifiedAssetArithmetic above for
// why this is safe):
// (1) write off totalDefaultAmount from Total only;
// (2) add defaultCovered symmetrically to both fields.
STAmount const amount{vaultAsset, defaultCovered};
STAmount const writeOff{vaultAsset, totalDefaultAmount};
vaultSle->at(sfAssetsTotal) += amount - writeOff;
vaultSle->at(sfAssetsAvailable) += amount;
XRPL_ASSERT_PARTS(
*vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal),
"xrpl::LoanManage::defaultLoan",
"assets available must not be greater than assets outstanding");
}
else
{
// Pre-amendment behavior.
auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered;
// Decrease the Total Value of the Vault:
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
@@ -261,8 +194,8 @@ LoanManage::defaultLoan(
auto const vaultDefaultRounded = roundToAsset(
vaultAsset, vaultDefaultAmount, vaultScale, Number::RoundingMode::Downward);
vaultTotalProxy -= vaultDefaultRounded;
// Increase the Asset Available of the Vault by liquidated
// First-Loss Capital and any unclaimed funds amount:
// Increase the Asset Available of the Vault by liquidated First-Loss
// Capital and any unclaimed funds amount:
vaultAvailableProxy += defaultCovered;
if (*vaultAvailableProxy > *vaultTotalProxy && !vaultAsset.integral())
{
@@ -274,8 +207,8 @@ LoanManage::defaultLoan(
<< "(" << difference.exponent() << ")";
if (vaultAvailableProxy.value().exponent() - difference.exponent() > 13)
{
// If the difference is dust, bring the total up to
// match the available
// 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;
@@ -290,23 +223,23 @@ LoanManage::defaultLoan(
return tecINTERNAL;
// LCOV_EXCL_STOP
}
}
// The loss has been realized
if (loanSle->isFlag(lsfLoanImpaired))
{
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
if (vaultLossUnrealizedProxy < totalDefaultAmount)
// The loss has been realized
if (loanSle->isFlag(lsfLoanImpaired))
{
// LCOV_EXCL_START
JLOG(j.warn()) << "Vault unrealized loss is less than the default amount";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
if (vaultLossUnrealizedProxy < totalDefaultAmount)
{
// LCOV_EXCL_START
JLOG(j.warn()) << "Vault unrealized loss is less than the default amount";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
adjustImpreciseNumber(
vaultLossUnrealizedProxy, -totalDefaultAmount, vaultAsset, vaultScale);
}
adjustImpreciseNumber(
vaultLossUnrealizedProxy, -totalDefaultAmount, vaultAsset, vaultScale);
view.update(vaultSle);
}
view.update(vaultSle);
// Update the LoanBroker object:

View File

@@ -469,28 +469,10 @@ LoanPay::doApply()
auto assetsTotalProxy = vaultSle->at(sfAssetsTotal);
auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
// Under fixCleanup3_4_0, feed the same raw payment Number into both
// sfAssetsAvailable and the accountSendMulti transfer to the vault
// pseudo-account. Both sinks store the value as an asset-typed
// STAmount (the ledger field is normalized through STNumber via
// associateAsset(*vaultSle, asset) further below, and the pseudo
// account trust line stores an STAmount directly), so the IOU
// normalization is applied symmetrically and the two balances land on
// the same STAmount value -- which is what makes the debug invariant
// sfAssetsAvailable == pseudo-account balance hold. Pre-amendment,
// sfAssetsAvailable and the pseudo-account both received the
// vaultScale-rounded value (accountSendMulti was called with
// `totalPaidToVault`, i.e. the rounded amount), but sfAssetsTotal
// was mutated with the unrounded `assetsTotalDelta` on the very
// next line. That vault-side asymmetry between sfAssetsAvailable
// (rounded) and sfAssetsTotal (unrounded) is the counterpart of the
// cross-side mismatch `LoanManage::defaultLoan` addresses on the
// default path.
auto const totalPaidToVault = view.rules().enabled(fixCleanup3_4_0)
? totalPaidToVaultRaw
: roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
auto const totalPaidToVaultRounded =
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
XRPL_ASSERT_PARTS(
!asset.integral() || totalPaidToVaultRaw == totalPaidToVault,
!asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
"xrpl::LoanPay::doApply",
"rounding does nothing for integral asset");
auto const totalPaidToBroker = paymentParts->feePaid;
@@ -536,7 +518,7 @@ LoanPay::doApply()
}
#endif
assetsAvailableProxy += totalPaidToVault;
assetsAvailableProxy += totalPaidToVaultRounded;
assetsTotalProxy += assetsTotalDelta;
XRPL_ASSERT_PARTS(
@@ -545,13 +527,13 @@ LoanPay::doApply()
"assets available must not be greater than assets outstanding");
JLOG(j_.debug()) << "total paid to vault raw: " << totalPaidToVaultRaw
<< ", total paid to vault: " << totalPaidToVault
<< ", total paid to vault rounded: " << totalPaidToVaultRounded
<< ", total paid to broker: " << totalPaidToBroker
<< ", amount from transaction: " << amount;
// Move funds
XRPL_ASSERT_PARTS(
totalPaidToVault + totalPaidToBroker <= amount,
totalPaidToVaultRounded + totalPaidToBroker <= amount,
"xrpl::LoanPay::doApply",
"amount is sufficient");
@@ -645,7 +627,7 @@ LoanPay::doApply()
// gate.
bool const skipPayeeAuth = view.rules().enabled(fixCleanup3_4_0);
if (!skipPayeeAuth && totalPaidToVault != beast::kZero)
if (!skipPayeeAuth && totalPaidToVaultRounded != beast::kZero)
{
if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
return ter;
@@ -680,7 +662,7 @@ LoanPay::doApply()
view,
accountID_,
asset,
{{vaultPseudoAccount, totalPaidToVault}, {brokerPayee, totalPaidToBroker}},
{{vaultPseudoAccount, totalPaidToVaultRounded}, {brokerPayee, totalPaidToBroker}},
j_,
WaiveTransferFee::Yes))
return ter;

View File

@@ -150,7 +150,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
// always authorized. No need to amendment gate since Vault and LoanBroker
// can only be created if the Vault amendment is enabled; AMM with MPToken asset
// can only be created if MPTokensV2 is enabled.
if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
if (isPseudoAccount(ctx.view, *holderID))
return tecNO_PERMISSION;
return tesSUCCESS;

View File

@@ -1450,7 +1450,7 @@ public:
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, fixCleanup3_4_0, featureMPTokensV2}, all_))
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};

View File

@@ -38,7 +38,6 @@
#include <ostream>
#include <string>
#include <tuple>
#include <vector>
namespace xrpl::test {
@@ -185,233 +184,6 @@ private:
}
}
// Regression sensor: multi-loan vault with payment history +
// impairment exercises the accrual-based accounting on both amendment
// branches, then defaults one of the loans. The point is to run the
// same drift-inducing scenario under `!fixCleanup3_4_0` and
// `fixCleanup3_4_0` and confirm that both branches leave the vault in
// the same, invariant-holding state.
//
// Semantically the two paths do different things:
// * `!fixCleanup3_4_0` in `LoanManage::defaultLoan` computes
// `roundToAsset(totalDefaultAmount - defaultCovered, vaultScale,
// Downward)` and subtracts that from `sfAssetsTotal`, while adding
// the (unrounded) `defaultCovered` to `sfAssetsAvailable`. A
// downstream dust-reconciliation branch pins Total up to Available
// if the arithmetic drifted them apart. Similarly `LoanPay` on
// this branch pre-rounds `totalPaidToVault` down to vaultScale.
// * `fixCleanup3_4_0` reformulates both call sites to feed each
// ledger field through the same asset-typed STAmount, so both
// sides absorb identical IOU/Number-round-trip normalization
// and the two fields cannot diverge from arithmetic alone -- no
// dust snap is needed.
//
// The scenario intentionally combines: large vault-vs-loan scale gap,
// multiple loans with different interest schedules, multiple partial
// payments, and impairment of the loan to be defaulted.
//
// Note: in practice the two branches produce bit-identical output for
// legitimate transaction inputs, because `computeLoanProperties`
// enforces `loanScale >= vaultScale` (see the `std::max(minimumScale,
// amount.exponent())` clamp on the loan-value STAmount exponent).
// That constraint makes `roundToAsset(vaultDefaultAmount, vaultScale,
// Down)` a no-op for well-formed loans, so `sfAssetsAvailable` cannot
// overshoot `sfAssetsTotal` from arithmetic alone. The dust-snap
// branch this test guards against is therefore doubly-defensive code
// — reachable only from a corrupted ledger state, not a valid
// transaction sequence. If either path ever leaves the invariant
// violated (or the paths diverge on the stored ledger state) this
// test will surface it.
void
testMultiLoanDefaultDriftFixVsLegacy(FeatureBitset features)
{
testcase("Multi-loan drift: default is invariant-safe on both branches");
using namespace jtx;
using namespace std::chrono_literals;
struct RunResult
{
Number totalBefore;
Number availableBefore;
Number totalAfter;
Number availableAfter;
TER defaultTer;
};
auto runScenario = [this](FeatureBitset scenarioFeatures) -> std::optional<RunResult> {
using namespace loan;
Env env{*this, scenarioFeatures};
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000'00), issuer, lender, borrower);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(lender, asset(10'000'000'000)));
env(trust(borrower, asset(10'000'000'000)));
env(pay(issuer, lender, asset(5'000'000'000)));
env(pay(issuer, borrower, asset(5'000'000'000)));
env.close();
// Vault magnitude ~1e7 gives a coarse vaultScale; the loans
// below use principals sized so that
// `computeLoanProperties(..., minimumScale=vaultScale)` clamps
// `loanScale = std::max(vaultScale, amount.exponent())` down
// to `vaultScale`. That equal-scale regime is precisely the
// one where the pre-amendment paths still round vault-side
// mutations asymmetrically (rounded on sfAssetsAvailable and
// in the pseudo-account transfer, unrounded on sfAssetsTotal
// via assetsTotalDelta / defaultCovered), so the dust-snap
// and post-rounding invariant branches this test guards
// against get maximum exposure.
BrokerParameters const brokerParams{
.vaultDeposit = Number{10'000'000},
.debtMax = Number{0},
.coverRateMin = TenthBips32{1000},
.coverRateLiquidation = TenthBips32{2500}};
auto broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const& vaultKeylet = broker.vaultKeylet();
auto nextLoanKey = [&]() -> std::optional<Keylet> {
auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(brokerSle))
return std::nullopt;
auto const seq = brokerSle->at(sfLoanSequence);
return keylet::loan(broker.brokerID, SeqProxy::rawSequence(seq));
};
// Three loans with different principals and interest schedules,
// so each contributes drift-shaped rounding to the vault.
struct LoanSpec
{
Number principal;
std::uint32_t rateBps{};
std::uint32_t payTotal{};
std::uint32_t payInterval{};
};
std::array const specs{
LoanSpec{
.principal = Number{100},
.rateBps = 1922,
.payTotal = 5816,
.payInterval = 86400 * 6},
LoanSpec{
.principal = Number{237},
.rateBps = 1234,
.payTotal = 4321,
.payInterval = 86400 * 5},
LoanSpec{
.principal = Number{389},
.rateBps = 977,
.payTotal = 3333,
.payInterval = 86400 * 4},
};
std::vector<Keylet> loans;
for (auto const& spec : specs)
{
auto const keyOpt = nextLoanKey();
if (!keyOpt)
return std::nullopt;
env(set(borrower, broker.brokerID, spec.principal),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{spec.rateBps}),
kPaymentTotal(spec.payTotal),
kPaymentInterval(spec.payInterval),
kGracePeriod(spec.payInterval),
Fee(env.current()->fees().base * 100));
env.close();
if (env.ter() != tesSUCCESS)
return std::nullopt;
loans.push_back(*keyOpt);
}
// Accrue interest for a while, then make one small partial
// payment on each loan to force `LoanPay::doApply` to touch
// Total/Available with loan-scale-precision deltas.
STAmount const smallPay{asset.raw(), Number{1}};
auto const payFee = Fee(env.current()->fees().base * 10);
env.close(std::chrono::seconds(86400 * 3));
for (auto const& loanKey : loans)
env(pay(borrower, loanKey.key, smallPay, 0u), payFee);
env.close();
// Accrue more interest, another small payment round.
env.close(std::chrono::seconds(86400 * 3));
for (auto const& loanKey : loans)
env(pay(borrower, loanKey.key, smallPay, 0u), payFee);
env.close();
// Impair the first loan (charges `sfLossUnrealized`), then let
// it become defaultable.
env(jtx::loan::manage(lender, loans.front().key, tfLoanImpair), payFee);
env.close();
env.close(std::chrono::seconds(86400 * 40));
auto const vaultSleBefore = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSleBefore))
return std::nullopt;
Number const totalBefore = vaultSleBefore->at(sfAssetsTotal);
Number const availableBefore = vaultSleBefore->at(sfAssetsAvailable);
env(jtx::loan::manage(lender, loans.front().key, tfLoanDefault));
auto const defaultTer = env.ter();
env.close();
auto const vaultSleAfter = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSleAfter))
return std::nullopt;
Number const totalAfter = vaultSleAfter->at(sfAssetsTotal);
Number const availableAfter = vaultSleAfter->at(sfAssetsAvailable);
log << (env.current()->rules().enabled(fixCleanup3_4_0) ? "[fix=YES] " : "[fix=NO ] ")
<< "before: T=" << totalBefore << " A=" << availableBefore
<< " (T-A)=" << (totalBefore - availableBefore)
<< " | default ter=" << transToken(defaultTer) << " | after: T=" << totalAfter
<< " A=" << availableAfter << " (T-A)=" << (totalAfter - availableAfter)
<< std::endl;
return RunResult{
.totalBefore = totalBefore,
.availableBefore = availableBefore,
.totalAfter = totalAfter,
.availableAfter = availableAfter,
.defaultTer = defaultTer};
};
auto const legacyOpt = runScenario(features - fixCleanup3_4_0);
auto const fixedOpt = runScenario(features | fixCleanup3_4_0);
if (!legacyOpt || !fixedOpt)
{
BEAST_EXPECT(legacyOpt && fixedOpt);
return;
}
auto const& legacy = *legacyOpt;
auto const& fixed = *fixedOpt;
// Invariant must hold on both branches.
BEAST_EXPECT(legacy.availableAfter <= legacy.totalAfter);
BEAST_EXPECT(fixed.availableAfter <= fixed.totalAfter);
// Default must succeed on both branches (the fix removes the
// possibility of `tecINTERNAL` from arithmetic drift, and the
// legacy path handles this scenario without needing the dust snap).
BEAST_EXPECT(legacy.defaultTer == tesSUCCESS);
BEAST_EXPECT(fixed.defaultTer == tesSUCCESS);
// For legitimate transaction inputs the two branches must land
// on the identical post-default vault state (see the note on
// `loanScale >= vaultScale` above). If a future change makes the
// paths diverge on well-formed loans -- e.g. by altering how
// sfAssetsTotal is rounded on one side but not the other -- this
// is the assertion that will fire.
BEAST_EXPECT(legacy.totalAfter == fixed.totalAfter);
BEAST_EXPECT(legacy.availableAfter == fixed.availableAfter);
}
void
testRoundingAllowsUndercoverage(FeatureBitset features)
{
@@ -1454,7 +1226,6 @@ private:
testBugVaultWithdrawDustVsAssetsTotal(all_ - fixCleanup3_4_0);
testBugVaultWithdrawDustVsAssetsTotal(all_);
testBugInterestDueDeltaCrash();
testMultiLoanDefaultDriftFixVsLegacy(all_);
}
// Tests run under each entry in amendmentCombinations().
@@ -1475,7 +1246,7 @@ public:
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, fixCleanup3_4_0, featureMPTokensV2}, all_))
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};