mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-27 17:20:54 +00:00
Compare commits
2 Commits
tapanito/f
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71f5555873 | ||
|
|
3967ed6d54 |
4
.github/actions/release-info/action.yml
vendored
4
.github/actions/release-info/action.yml
vendored
@@ -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
|
||||
|
||||
4
.github/scripts/strategy-matrix/linux.json
vendored
4
.github/scripts/strategy-matrix/linux.json
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
20
.github/workflows/reusable-package.yml
vendored
20
.github/workflows/reusable-package.yml
vendored
@@ -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}
|
||||
|
||||
5
.github/workflows/reusable-upload-recipe.yml
vendored
5
.github/workflows/reusable-upload-recipe.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user