mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-28 17:50:55 +00:00
Compare commits
4 Commits
tapanito/i
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7c5ea593 | ||
|
|
7281e0606a | ||
|
|
71f5555873 | ||
|
|
3967ed6d54 |
@@ -387,3 +387,4 @@ words:
|
||||
- xxhasher
|
||||
- zstdio
|
||||
- CGNAT
|
||||
- ungated
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,11 @@ namespace xrpl {
|
||||
* 1. If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most one
|
||||
* node (the root), which will only hold entries for `RippleState` or
|
||||
* `MPToken` objects.
|
||||
* 2. Under featureLendingProtocolV1_1, an `ltLOAN_BROKER` may only be deleted
|
||||
* by a `ttLOAN_BROKER_DELETE` transaction, and only when its pre-state
|
||||
* `OwnerCount` is zero and its pre-state `DebtTotal` rounds to zero at the
|
||||
* vault's `AssetsTotal` scale, as `LoanBrokerDelete::preclaim` requires.
|
||||
* 3. At most one `ltLOAN_BROKER` may be deleted in a single transaction.
|
||||
*
|
||||
*/
|
||||
class ValidLoanBroker
|
||||
@@ -36,6 +41,15 @@ class ValidLoanBroker
|
||||
// pseudo-accounts. Key is the brokerID / index. It will be used to find the
|
||||
// LoanBroker object if brokerBefore and brokerAfter are nullptr
|
||||
std::map<uint256, BrokerInfo> brokers_;
|
||||
// The broker whose ledger entry was deleted by this transaction, if any.
|
||||
// Only ttLOAN_BROKER_DELETE removes a broker, and it removes exactly one.
|
||||
// This is the pre-transaction state, which is what LoanBrokerDelete::preclaim
|
||||
// reads when it decides whether the broker may be deleted, so the deletion invariants inspect
|
||||
// the same DebtTotal and OwnerCount that the transactor did.
|
||||
SLE::const_pointer deletedBroker_ = nullptr;
|
||||
// Set if visitEntry observes more than one ltLOAN_BROKER deletion in the
|
||||
// same transaction. Enforced as its own invariant in finalize.
|
||||
bool multipleBrokerDeletions_ = false;
|
||||
// Collect all the modified trust lines. Their high and low accounts will be
|
||||
// loaded to look for LoanBroker pseudo-accounts.
|
||||
std::vector<SLE::const_pointer> lines_;
|
||||
|
||||
@@ -15,9 +15,33 @@ namespace xrpl {
|
||||
/**
|
||||
* @brief Invariants: Loans are internally consistent
|
||||
*
|
||||
* 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`
|
||||
* 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`.
|
||||
* 2. A newly-created Loan against a closed-ended vault must satisfy
|
||||
* `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`.
|
||||
* 3. An `ltLOAN` may only be created by a `ttLOAN_SET` transaction.
|
||||
* 4. Prior to `featureLendingProtocolV1_1`, the `lsfLoanOverpayment` flag on a
|
||||
* Loan must not change. From `featureLendingProtocolV1_1` onward the same
|
||||
* rule is enforced by `NoModifiedUnmodifiableFields`.
|
||||
* 5. Under `featureLendingProtocolV1_1`:
|
||||
* a. An `ltLOAN` may only be deleted by a `ttLOAN_DELETE` transaction.
|
||||
* b. If `Loan.PaymentRemaining = 0` then `Loan.NextPaymentDueDate = 0`.
|
||||
* c. The `lsfLoanImpaired` flag may only change through a `ttLOAN_MANAGE`
|
||||
* or `ttLOAN_PAY` transaction.
|
||||
* d. The `lsfLoanDefault` flag may only change through a `ttLOAN_MANAGE`
|
||||
* transaction. Combined with `NoModifiedUnmodifiableFields`, which
|
||||
* rejects any clearing of `lsfLoanDefault`, this makes the flag
|
||||
* write-once: `ttLOAN_MANAGE` may set it, and no transaction may
|
||||
* clear it.
|
||||
* e. Interest due, computed as `TotalValueOutstanding -
|
||||
* PrincipalOutstanding - ManagementFeeOutstanding`, must not be
|
||||
* negative.
|
||||
* f. A Loan must reference a live `ltLOAN_BROKER`, and that broker must
|
||||
* reference a live `ltVAULT`.
|
||||
* g. Post-conditions for the Loan paid down by a successful `ttLOAN_PAY`:
|
||||
* `PaymentRemaining > 0` after: `PrincipalOutstanding` and
|
||||
* `PaymentRemaining` strictly decrease; `NextPaymentDueDate`
|
||||
* advances by N * `PaymentInterval`, N > 0.
|
||||
* `PaymentRemaining == 0` after: pinned by checks 1 and 5b.
|
||||
*
|
||||
*/
|
||||
class ValidLoan
|
||||
@@ -25,6 +49,9 @@ class ValidLoan
|
||||
// Pair is <before, after>. After is used for most of the checks, except
|
||||
// those that check changed values.
|
||||
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> loans_;
|
||||
// Loans removed from the ledger, in the same <before, after> form as loans_.
|
||||
// Note that `after` holds the erased entry, so it is not null.
|
||||
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> deletedLoans_;
|
||||
|
||||
public:
|
||||
void
|
||||
|
||||
@@ -48,7 +48,10 @@ namespace xrpl {
|
||||
* vault phase is Investment
|
||||
*
|
||||
* Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced
|
||||
* by NoModifiedUnmodifiableFields (see InvariantCheck.cpp).
|
||||
* by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). From
|
||||
* featureLendingProtocolV1_1 onwards, immutability of the vault's Asset,
|
||||
* pseudo-account and ShareMPTID is likewise enforced by
|
||||
* NoModifiedUnmodifiableFields; prior to that amendment it is checked here.
|
||||
*/
|
||||
class ValidVault
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace {
|
||||
//------------------------------------------------------------------------------
|
||||
// clang-format off
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
char const* const versionString = "3.4.0-b2"
|
||||
char const* const versionString = "3.4.0-b3"
|
||||
// clang-format on
|
||||
;
|
||||
|
||||
|
||||
@@ -1123,10 +1123,17 @@ NoModifiedUnmodifiableFields::finalize(
|
||||
ReadView const& view,
|
||||
beast::Journal const& j)
|
||||
{
|
||||
static auto const kFieldChanged = [](auto const& before, auto const& after, auto const& field) {
|
||||
auto const kFieldChanged = [&j, &tx](auto const& before, auto const& after, auto const& field) {
|
||||
bool const beforeField = before->isFieldPresent(field);
|
||||
bool const afterField = after->isFieldPresent(field);
|
||||
return beforeField != afterField || (afterField && before->at(field) != after->at(field));
|
||||
bool const changed =
|
||||
beforeField != afterField || (afterField && before->at(field) != after->at(field));
|
||||
if (changed)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: " << field.getName()
|
||||
<< " changed on immutable ledger entry in " << tx.getTransactionID();
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
for (auto const& slePair : changedEntries_)
|
||||
{
|
||||
@@ -1172,13 +1179,40 @@ NoModifiedUnmodifiableFields::finalize(
|
||||
kFieldChanged(before, after, sfPaymentInterval) ||
|
||||
kFieldChanged(before, after, sfGracePeriod) ||
|
||||
kFieldChanged(before, after, sfLoanScale);
|
||||
|
||||
// lsfLoanOverpayment must never toggle. lsfLoanDefault may only
|
||||
// transition from unset to set, which combined with ValidLoan's rule that
|
||||
// only LoanManage may change it makes the flag write-once.
|
||||
if (view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
std::uint32_t const beforeFlags = before->getFlags();
|
||||
std::uint32_t const afterFlags = after->getFlags();
|
||||
bool const overpaymentChanged =
|
||||
(beforeFlags & lsfLoanOverpayment) != (afterFlags & lsfLoanOverpayment);
|
||||
if (overpaymentChanged)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: lsfLoanOverpayment flag "
|
||||
"toggled on immutable ledger entry in "
|
||||
<< tx.getTransactionID();
|
||||
}
|
||||
bad = bad || overpaymentChanged;
|
||||
bool const defaultCleared =
|
||||
(beforeFlags & lsfLoanDefault) != 0 && (afterFlags & lsfLoanDefault) == 0;
|
||||
if (defaultCleared)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault flag "
|
||||
"cleared on immutable ledger entry in "
|
||||
<< tx.getTransactionID();
|
||||
}
|
||||
bad = bad || defaultCleared;
|
||||
}
|
||||
break;
|
||||
case ltVAULT:
|
||||
/*
|
||||
* sfAccount, sfAsset and sfShareMPTID are already
|
||||
* captured by VaultInvariant. The additional fields
|
||||
* below are introduced by featureLendingProtocolV1_1
|
||||
* and only exist on V1_1 vaults.
|
||||
* All the fields below are only immutable from
|
||||
* featureLendingProtocolV1_1 onwards; some of them only exist on
|
||||
* V1_1 vaults. Before that amendment, sfAsset, sfAccount and
|
||||
* sfShareMPTID are checked by VaultInvariant instead.
|
||||
*/
|
||||
if (view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
@@ -1190,7 +1224,10 @@ NoModifiedUnmodifiableFields::finalize(
|
||||
kFieldChanged(before, after, sfOwner) ||
|
||||
kFieldChanged(before, after, sfWithdrawalPolicy) ||
|
||||
kFieldChanged(before, after, sfScale) ||
|
||||
kFieldChanged(before, after, sfLEVersion);
|
||||
kFieldChanged(before, after, sfLEVersion) ||
|
||||
kFieldChanged(before, after, sfAsset) ||
|
||||
kFieldChanged(before, after, sfAccount) ||
|
||||
kFieldChanged(before, after, sfShareMPTID);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
#include <xrpl/tx/invariants/LoanBrokerInvariant.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STAmount.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
@@ -22,6 +27,24 @@ namespace xrpl {
|
||||
void
|
||||
ValidLoanBroker::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
|
||||
{
|
||||
// Track LoanBroker deletions so finalize() can enforce:
|
||||
// (a) only ttLOAN_BROKER_DELETE removes a broker
|
||||
// (b) at most one broker is removed per transaction
|
||||
// (c) DebtTotal and OwnerCount were zero before deletion
|
||||
// `before` is the pre-transaction state, which is what
|
||||
// LoanBrokerDelete::preclaim reads. Erased trust lines and MPTokens need no
|
||||
// special handling here: the `if (after)` branch below already records them.
|
||||
if (isDelete && before && before->getType() == ltLOAN_BROKER)
|
||||
{
|
||||
if (deletedBroker_)
|
||||
{
|
||||
multipleBrokerDeletions_ = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
deletedBroker_ = before;
|
||||
}
|
||||
}
|
||||
if (after)
|
||||
{
|
||||
if (after->getType() == ltLOAN_BROKER)
|
||||
@@ -99,6 +122,64 @@ ValidLoanBroker::finalize(
|
||||
// Loan Brokers will not exist on ledger if the Lending Protocol amendment
|
||||
// is not enabled, so there's no need to check it.
|
||||
|
||||
// Deletion invariants (featureLendingProtocolV1_1). At most one
|
||||
// LoanBroker may be removed per transaction, and only by
|
||||
// ttLOAN_BROKER_DELETE, and only when its pre-state OwnerCount is zero and
|
||||
// its pre-state DebtTotal is zero to the precision of the vault asset. The
|
||||
// DebtTotal check complements ValidLoan's
|
||||
// LoanBrokerDelete-must-not-touch-any-loan rule: even a broker that has
|
||||
// finished paying off every loan may still hold non-zero exposure until
|
||||
// its LoanBrokerCoverWithdraw settles, and neither state is safe to
|
||||
// delete.
|
||||
if (view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
if (multipleBrokerDeletions_)
|
||||
{
|
||||
JLOG(j.fatal())
|
||||
<< "Invariant failed: more than one Loan Broker deleted in a single transaction";
|
||||
return false;
|
||||
}
|
||||
if (deletedBroker_)
|
||||
{
|
||||
if (tx.getTxnType() != ttLOAN_BROKER_DELETE)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: " << //
|
||||
"Loan Broker deleted by a transaction other than LoanBrokerDelete";
|
||||
return false;
|
||||
}
|
||||
// Mirror LoanBrokerDelete::preclaim, which accepts a DebtTotal
|
||||
// that rounds to zero at the vault's AssetsTotal scale rather than
|
||||
// requiring an exact zero. Requiring more here would turn a
|
||||
// transaction the transactor deliberately permits into an
|
||||
// invariant failure.
|
||||
if (auto const debtTotal = deletedBroker_->at(sfDebtTotal); debtTotal != beast::kZero)
|
||||
{
|
||||
// The erased broker is also collected in brokers_, and that
|
||||
// loop reports a missing vault, so no separate diagnostic is
|
||||
// needed here. Without a vault there is no scale to round at,
|
||||
// so the residue cannot be excused as dust.
|
||||
auto const vault = view.read(keylet::vault(deletedBroker_->at(sfVaultID)));
|
||||
if (!vault ||
|
||||
roundToAsset(
|
||||
Asset{vault->at(sfAsset)},
|
||||
debtTotal,
|
||||
getAssetsTotalScale(vault),
|
||||
Number::RoundingMode::TowardsZero) != beast::kZero)
|
||||
{
|
||||
JLOG(j.fatal())
|
||||
<< "Invariant failed: Loan Broker deleted with non-zero debt total";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (deletedBroker_->at(sfOwnerCount) != 0)
|
||||
{
|
||||
JLOG(j.fatal())
|
||||
<< "Invariant failed: Loan Broker deleted with non-zero owner count";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto const& line : lines_)
|
||||
{
|
||||
for (auto const& field : {&sfLowLimit, &sfHighLimit})
|
||||
@@ -142,7 +223,6 @@ ValidLoanBroker::finalize(
|
||||
|
||||
auto const& before = broker.brokerBefore;
|
||||
|
||||
// https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants
|
||||
// If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most
|
||||
// one node (the root), which will only hold entries for `RippleState`
|
||||
// or `MPToken` objects.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#include <xrpl/tx/invariants/LoanInvariant.h>
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
@@ -13,6 +16,7 @@
|
||||
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
|
||||
#include <xrpl/protocol/STTx.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
|
||||
#include <cstdint>
|
||||
@@ -22,7 +26,14 @@ namespace xrpl {
|
||||
void
|
||||
ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
|
||||
{
|
||||
if (after && after->getType() == ltLOAN)
|
||||
// Classify here, but leave the decision about which checks apply to
|
||||
// finalize(), which is the only place that can see the Rules.
|
||||
if (isDelete)
|
||||
{
|
||||
if (before && before->getType() == ltLOAN)
|
||||
deletedLoans_.emplace_back(before, after);
|
||||
}
|
||||
else if (after && after->getType() == ltLOAN)
|
||||
{
|
||||
loans_.emplace_back(before, after);
|
||||
}
|
||||
@@ -39,6 +50,16 @@ ValidLoan::finalize(
|
||||
// Loans will not exist on ledger if the Lending Protocol amendment
|
||||
// is not enabled, so there's no need to check it.
|
||||
|
||||
auto const txType = tx.getTxnType();
|
||||
bool const lpV11Enabled = view.rules().enabled(featureLendingProtocolV1_1);
|
||||
|
||||
// Without featureLendingProtocolV1_1 an erased Loan is subject to the same
|
||||
// per-entry checks as any modified Loan. From V1_1 onward it is only subject
|
||||
// to the ttLOAN_DELETE check below.
|
||||
if (!lpV11Enabled)
|
||||
loans_.insert(loans_.end(), deletedLoans_.begin(), deletedLoans_.end());
|
||||
|
||||
// Ledger entry validation checks.
|
||||
for (auto const& [before, after] : loans_)
|
||||
{
|
||||
// A closed-ended vault must not accept a loan whose final scheduled payment falls on or
|
||||
@@ -91,7 +112,11 @@ ValidLoan::finalize(
|
||||
JLOG(j.fatal()) << "Invariant failed: Fully paid off Loan still has payments remaining";
|
||||
return false;
|
||||
}
|
||||
if (before && (before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment)))
|
||||
|
||||
// From featureLendingProtocolV1_1 onwards this flag is immutable by way of
|
||||
// NoModifiedUnmodifiableFields.
|
||||
if (!lpV11Enabled && before &&
|
||||
(before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment)))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed";
|
||||
return false;
|
||||
@@ -123,6 +148,125 @@ ValidLoan::finalize(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (lpV11Enabled)
|
||||
{
|
||||
// Only LoanSet may create a loan.
|
||||
if (!before && txType != ttLOAN_SET)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan created by a transaction "
|
||||
"other than LoanSet";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (after->at(sfPaymentRemaining) == 0 &&
|
||||
after->at(~sfNextPaymentDueDate).value_or(0) != 0)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan with zero payments must have zero next "
|
||||
"payment due date";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (before)
|
||||
{
|
||||
bool const wasImpaired = before->isFlag(lsfLoanImpaired);
|
||||
bool const isImpaired = after->isFlag(lsfLoanImpaired);
|
||||
bool const wasDefaulted = before->isFlag(lsfLoanDefault);
|
||||
bool const isDefaulted = after->isFlag(lsfLoanDefault);
|
||||
|
||||
if (wasImpaired != isImpaired && txType != ttLOAN_MANAGE && txType != ttLOAN_PAY)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: lsfLoanImpaired changed "
|
||||
"outside LoanManage or LoanPay";
|
||||
return false;
|
||||
}
|
||||
if (wasDefaulted != isDefaulted && txType != ttLOAN_MANAGE)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault changed "
|
||||
"outside LoanManage";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A loan must reference a live loan broker, and that broker must
|
||||
// reference a live vault; otherwise the loan is orphaned and its
|
||||
// balances have no counterparty on the ledger.
|
||||
auto const brokerSle = view.read(keylet::loanBroker(after->at(sfLoanBrokerID)));
|
||||
if (!brokerSle)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan broker does not exist";
|
||||
return false;
|
||||
}
|
||||
auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID)));
|
||||
if (!vaultSle)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan broker vault does not exist";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Interest due (the total value owed less principal and management fee)
|
||||
// must never be negative. TotalValueOutstanding, PrincipalOutstanding and
|
||||
// ManagementFeeOutstanding are each independently rounded to sfLoanScale
|
||||
// by the accounting code, so their difference can carry one unit of
|
||||
// quantization noise even when the underlying flow is correct. Absorb
|
||||
// one unit at that scale, matching the pattern used in ValidVault.
|
||||
auto const interestDue = after->at(sfTotalValueOutstanding) -
|
||||
after->at(sfPrincipalOutstanding) - after->at(sfManagementFeeOutstanding);
|
||||
|
||||
// Only IOU amounts can accumulate STAmount quantization noise. For integral-domain
|
||||
// assets (XRP/MPT) enforce the boundary strictly.
|
||||
bool const integral = Asset{vaultSle->at(sfAsset)}.integral();
|
||||
|
||||
Number const tolerance = integral ? Number{} : Number{-1, after->at(sfLoanScale)};
|
||||
if (interestDue < tolerance)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Transaction success post-conditions. A successful loan pay makes at least
|
||||
// one scheduled payment, so a loan left with payments still outstanding
|
||||
// must show that payment in its balance and schedule. A payment that clears
|
||||
// the loan outright instead drives PaymentRemaining to zero, which the
|
||||
// fully-paid-off and zero due-date checks above pin.
|
||||
if (isTesSuccess(result) && txType == ttLOAN_PAY)
|
||||
{
|
||||
if (before && after->at(sfPaymentRemaining) != 0)
|
||||
{
|
||||
if (!(after->at(sfPrincipalOutstanding) < before->at(sfPrincipalOutstanding)))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: loan pay must strictly decrease "
|
||||
"PrincipalOutstanding on a non-full-repayment";
|
||||
return false;
|
||||
}
|
||||
if (!(after->at(sfPaymentRemaining) < before->at(sfPaymentRemaining)))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: loan pay must decrease "
|
||||
"PaymentRemaining on a non-full-repayment";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::uint32_t const beforeDue = before->at(~sfNextPaymentDueDate).value_or(0);
|
||||
std::uint32_t const afterDue = after->at(~sfNextPaymentDueDate).value_or(0);
|
||||
std::uint32_t const interval = after->at(sfPaymentInterval);
|
||||
if (afterDue <= beforeDue || interval == 0 ||
|
||||
(afterDue - beforeDue) % interval != 0)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: loan pay must advance "
|
||||
"NextPaymentDueDate by a positive multiple of "
|
||||
"PaymentInterval on a non-full-repayment";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only LoanDelete may delete a loan.
|
||||
if (lpV11Enabled && txType != ttLOAN_DELETE && !deletedLoans_.empty())
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction "
|
||||
"other than LoanDelete";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -515,7 +515,8 @@ ValidVault::finalize(
|
||||
bool result = true;
|
||||
|
||||
// Universal transaction checks
|
||||
if (!beforeVault_.empty())
|
||||
// From LendingProtocolV1_1 onwards, vault immutability check is moved to InvariantCheck.cpp
|
||||
if (!beforeVault_.empty() && !view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
auto const& beforeVault = beforeVault_[0];
|
||||
if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
#include <test/jtx/vault.h>
|
||||
#include <test/unit_test/SuiteJournal.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/OpenView.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
@@ -31,6 +33,7 @@
|
||||
#include <xrpl/tx/applySteps.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
#include <source_location>
|
||||
@@ -206,4 +209,33 @@ InvariantsBase::createLoanBroker(
|
||||
return loanBrokerKeylet;
|
||||
}
|
||||
|
||||
SLE::pointer
|
||||
InvariantsBase::makeLoanSle(
|
||||
uint256 const& loanBrokerID,
|
||||
std::uint32_t loanSeq,
|
||||
AccountID const& borrower)
|
||||
{
|
||||
auto sleLoan =
|
||||
std::make_shared<SLE>(keylet::loan(loanBrokerID, SeqProxy::rawSequence(loanSeq)));
|
||||
// SoeRequired fields.
|
||||
sleLoan->at(sfLoanBrokerID) = loanBrokerID;
|
||||
sleLoan->at(sfLoanSequence) = loanSeq;
|
||||
sleLoan->at(sfBorrower) = borrower;
|
||||
sleLoan->at(sfStartDate) = 0u;
|
||||
sleLoan->at(sfPaymentInterval) = 1u;
|
||||
sleLoan->at(sfPeriodicPayment) = Number(1);
|
||||
// SoeDefault fields, materialized so that an invariant reading them through
|
||||
// at() does not throw on this hand-built entry.
|
||||
sleLoan->at(sfLoanServiceFee) = Number(0);
|
||||
sleLoan->at(sfLatePaymentFee) = Number(0);
|
||||
sleLoan->at(sfClosePaymentFee) = Number(0);
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(0);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(0);
|
||||
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 0);
|
||||
sleLoan->makeFieldPresent(sfOwnerNode);
|
||||
sleLoan->makeFieldPresent(sfLoanBrokerNode);
|
||||
return sleLoan;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
#include <test/jtx/Env.h>
|
||||
#include <test/jtx/amount.h>
|
||||
|
||||
#include <xrpld/app/main/Application.h>
|
||||
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
@@ -14,6 +18,7 @@
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
@@ -117,6 +122,13 @@ protected:
|
||||
|
||||
Keylet
|
||||
createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset);
|
||||
|
||||
// Build an ltLOAN SLE with every SoeRequired field explicitly set and
|
||||
// every SoeDefault field the invariants read via `at()` materialized, so
|
||||
// rawInsert-based tests don't accidentally trip an unrelated invariant
|
||||
// or throw from a missing SoeDefault field.
|
||||
static SLE::pointer
|
||||
makeLoanSle(uint256 const& loanBrokerID, std::uint32_t loanSeq, AccountID const& borrower);
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
#include <test/jtx/Env.h>
|
||||
#include <test/jtx/TestHelpers.h>
|
||||
#include <test/jtx/amount.h>
|
||||
#include <test/jtx/pay.h>
|
||||
#include <test/jtx/token.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
#include <test/unit_test/SuiteJournal.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
@@ -43,6 +46,7 @@
|
||||
#include <xrpl/tx/invariants/InvariantRunner.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
@@ -757,7 +761,255 @@ class InvariantsMisc_test : public InvariantsBase
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Loan Object
|
||||
// Loan flag immutability lives in NoModifiedUnmodifiableFields's
|
||||
// ltLOAN case: lsfLoanOverpayment must never toggle in either
|
||||
// direction, and lsfLoanDefault (gated on featureLendingProtocolV1_1)
|
||||
// may only transition from unset to set. Each case needs a loan that
|
||||
// already exists in the base ledger, so that the apply-view modification
|
||||
// is seen as a before/after change rather than an insertion.
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
std::uint32_t before;
|
||||
std::uint32_t after;
|
||||
std::string expected;
|
||||
};
|
||||
auto const cases = std::to_array<Case>({
|
||||
{.before = lsfLoanOverpayment,
|
||||
.after = 0,
|
||||
.expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"},
|
||||
{.before = 0,
|
||||
.after = lsfLoanOverpayment,
|
||||
.expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"},
|
||||
{.before = lsfLoanDefault,
|
||||
.after = 0,
|
||||
.expected = "lsfLoanDefault flag cleared on immutable ledger entry"},
|
||||
});
|
||||
|
||||
for (auto const& c : cases)
|
||||
{
|
||||
Env env{*this, all_};
|
||||
Account const a1{"A1"};
|
||||
env.fund(XRP(1000), a1);
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
auto const brokerKeylet =
|
||||
keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq()));
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(100);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(150);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
sleLoan->setFieldU32(sfFlags, c.before);
|
||||
ov.rawInsert(sleLoan);
|
||||
}
|
||||
|
||||
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleLoan = ac.view().peek(loanKeylet);
|
||||
if (!BEAST_EXPECT(sleLoan))
|
||||
continue;
|
||||
sleLoan->setFieldU32(sfFlags, c.after);
|
||||
ac.view().update(sleLoan);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
continue;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(sink.messages().str().contains(c.expected));
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-featureLendingProtocolV1_1 sibling of the lsfLoanOverpayment
|
||||
// cases above: the same set-once immutability was originally enforced
|
||||
// by ValidLoan::finalize, so with V1_1 disabled toggling the flag
|
||||
// must trip that legacy check instead. lsfLoanDefault immutability
|
||||
// did not exist pre-V1_1 and is not tested here.
|
||||
{
|
||||
auto const cases = std::to_array<std::pair<std::uint32_t, std::uint32_t>>({
|
||||
{lsfLoanOverpayment, 0},
|
||||
{0, lsfLoanOverpayment},
|
||||
});
|
||||
|
||||
for (auto const& [before, after] : cases)
|
||||
{
|
||||
Env env{*this, all_ - featureLendingProtocolV1_1};
|
||||
Account const a1{"A1"};
|
||||
env.fund(XRP(1000), a1);
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
auto const brokerKeylet =
|
||||
keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq()));
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(100);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(150);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
sleLoan->setFieldU32(sfFlags, before);
|
||||
ov.rawInsert(sleLoan);
|
||||
}
|
||||
|
||||
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleLoan = ac.view().peek(loanKeylet);
|
||||
if (!BEAST_EXPECT(sleLoan))
|
||||
continue;
|
||||
sleLoan->setFieldU32(sfFlags, after);
|
||||
ac.view().update(sleLoan);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
continue;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(sink.messages().str().contains("Loan Overpayment flag changed"));
|
||||
}
|
||||
}
|
||||
|
||||
// Under featureLendingProtocolV1_1, ValidLoan::finalize requires
|
||||
// interest due (total value minus principal and management fee) to be
|
||||
// non-negative after each value is rounded to sfLoanScale. Test zero,
|
||||
// each way to produce a one-unit deficit, and a two-unit deficit. At
|
||||
// scale 0, an XRP-backed broker rejects any deficit, while an
|
||||
// IOU-backed one permits one unit of rounding tolerance.
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
Number totalValue;
|
||||
Number principal;
|
||||
Number managementFee;
|
||||
bool expectFireIntegral;
|
||||
bool expectFireTolerant;
|
||||
};
|
||||
// The first case sits exactly at the boundary, the middle three
|
||||
// perturb one component so that interest due is -1, which is within
|
||||
// the tolerance, and the last overshoots it at -2.
|
||||
auto const cases = std::to_array<Case>({
|
||||
{.totalValue = Number(100),
|
||||
.principal = Number(100),
|
||||
.managementFee = Number(0),
|
||||
.expectFireIntegral = false,
|
||||
.expectFireTolerant = false},
|
||||
{.totalValue = Number(99),
|
||||
.principal = Number(100),
|
||||
.managementFee = Number(0),
|
||||
.expectFireIntegral = true,
|
||||
.expectFireTolerant = false},
|
||||
{.totalValue = Number(100),
|
||||
.principal = Number(101),
|
||||
.managementFee = Number(0),
|
||||
.expectFireIntegral = true,
|
||||
.expectFireTolerant = false},
|
||||
{.totalValue = Number(100),
|
||||
.principal = Number(100),
|
||||
.managementFee = Number(1),
|
||||
.expectFireIntegral = true,
|
||||
.expectFireTolerant = false},
|
||||
{.totalValue = Number(98),
|
||||
.principal = Number(100),
|
||||
.managementFee = Number(0),
|
||||
.expectFireIntegral = true,
|
||||
.expectFireTolerant = true},
|
||||
});
|
||||
|
||||
for (bool const integralAsset : {true, false})
|
||||
{
|
||||
for (auto const& c : cases)
|
||||
{
|
||||
Env env{*this, all_};
|
||||
Account const a1{"A1"};
|
||||
Account const issuer{"issuer"};
|
||||
env.fund(XRP(1000), a1, issuer);
|
||||
env.close();
|
||||
|
||||
// The check reads the broker's vault asset to decide
|
||||
// whether the rounding tolerance applies, so both
|
||||
// branches need a real broker over the relevant asset.
|
||||
auto const asset = [&]() -> PrettyAsset {
|
||||
if (integralAsset)
|
||||
return PrettyAsset{xrpIssue(), 1'000'000};
|
||||
PrettyAsset const iouAsset = issuer["IOU"];
|
||||
env(trust(a1, iouAsset(1000)));
|
||||
env(pay(issuer, a1, iouAsset(1000)));
|
||||
env.close();
|
||||
return iouAsset;
|
||||
}();
|
||||
|
||||
auto const brokerKeylet = this->createLoanBroker(a1, env, asset);
|
||||
if (!BEAST_EXPECT(env.le(brokerKeylet)))
|
||||
continue;
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
auto const loanKeylet =
|
||||
keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
// Seed a loan whose interest due sits at the boundary. The
|
||||
// apply-view update below moves it.
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(100);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(100);
|
||||
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
|
||||
sleLoan->at(sfLoanScale) = 0;
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
ov.rawInsert(sleLoan);
|
||||
}
|
||||
|
||||
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleLoan = ac.view().peek(loanKeylet);
|
||||
if (!BEAST_EXPECT(sleLoan))
|
||||
continue;
|
||||
sleLoan->at(sfTotalValueOutstanding) = c.totalValue;
|
||||
sleLoan->at(sfPrincipalOutstanding) = c.principal;
|
||||
sleLoan->at(sfManagementFeeOutstanding) = c.managementFee;
|
||||
ac.view().update(sleLoan);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
continue;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
auto const messages = sink.messages().str();
|
||||
if (integralAsset ? c.expectFireIntegral : c.expectFireTolerant)
|
||||
{
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(messages.contains("Loan interest due is negative"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Other invariants may still fire on this raw-inserted
|
||||
// loan, so only assert the specific message is absent.
|
||||
BEAST_EXPECT(!messages.contains("Loan interest due is negative"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
|
||||
// Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.
|
||||
|
||||
@@ -7,18 +7,23 @@
|
||||
#include <test/jtx/pay.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
#include <test/unit_test/SuiteJournal.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/OpenView.h>
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Rules.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
@@ -28,6 +33,7 @@
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/Transactor.h>
|
||||
#include <xrpl/tx/applySteps.h>
|
||||
|
||||
#include <array>
|
||||
@@ -43,6 +49,8 @@ namespace xrpl::test {
|
||||
|
||||
class InvariantsPseudoAccount_test : public InvariantsBase
|
||||
{
|
||||
FeatureBitset const all_{test::jtx::testableAmendments()};
|
||||
|
||||
void
|
||||
testValidPseudoAccounts()
|
||||
{
|
||||
@@ -445,6 +453,281 @@ class InvariantsPseudoAccount_test : public InvariantsBase
|
||||
STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
createLoanBroker);
|
||||
|
||||
// Deleting the IOU holding while leaving the broker unchanged must
|
||||
// still expose CoverAvailable exceeding the now-zero balance: the
|
||||
// broker is discovered through the deleted trust line. XRP has no
|
||||
// holding SLE, while deleting an MPToken triggers other invariants,
|
||||
// so IOU isolates this check. Verify that fixCleanup3_1_3 gates it
|
||||
// by expecting failure only when the amendment is enabled.
|
||||
if (assetType == Asset::IOU)
|
||||
{
|
||||
Keylet brokerKeylet = keylet::amendments();
|
||||
Preclose const createBrokerWithCover =
|
||||
[&, this](Account const& alice, Account const& issuer, Env& env) {
|
||||
auto const asset = setupAsset(alice, issuer, env);
|
||||
brokerKeylet = this->createLoanBroker(alice, env, asset);
|
||||
if (!BEAST_EXPECT(env.le(brokerKeylet)))
|
||||
return false;
|
||||
env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
|
||||
env.close();
|
||||
return BEAST_EXPECT(env.le(brokerKeylet));
|
||||
};
|
||||
|
||||
Precheck const deleteHolding =
|
||||
[&](Account const&, Account const&, ApplyContext& ac) {
|
||||
if (brokerKeylet.type != ltLOAN_BROKER)
|
||||
return false;
|
||||
// Read (don't touch) the broker so it is only found via
|
||||
// the deleted holding, not as a modified entry.
|
||||
auto const sleBroker = ac.view().read(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return false;
|
||||
auto const pseudoAccountID = sleBroker->at(sfAccount);
|
||||
|
||||
// Erase every holding in the pseudo-account directory
|
||||
// and the directory root itself, mirroring a bug that
|
||||
// removed the cover holding without zeroing
|
||||
// CoverAvailable. Removing the root also keeps the
|
||||
// zero-OwnerCount directory check from firing first.
|
||||
auto sleDir = ac.view().peek(keylet::ownerDir(pseudoAccountID));
|
||||
if (!BEAST_EXPECT(sleDir))
|
||||
return false;
|
||||
for (auto const& index : sleDir->getFieldV256(sfIndexes))
|
||||
{
|
||||
if (auto holding = ac.view().peek(keylet::unchecked(index)))
|
||||
{
|
||||
ac.view().erase(holding);
|
||||
}
|
||||
}
|
||||
ac.view().erase(sleDir);
|
||||
return true;
|
||||
};
|
||||
|
||||
// With fixCleanup3_1_3: the invariant fires.
|
||||
doInvariantCheck(
|
||||
makeEnv(all_),
|
||||
{{"Loan Broker cover available is greater than pseudo-account asset balance"}},
|
||||
deleteHolding,
|
||||
XRPAmount{},
|
||||
STTx{ttACCOUNT_SET, [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
createBrokerWithCover);
|
||||
|
||||
// Without fixCleanup3_1_3: the same state is silently accepted.
|
||||
doInvariantCheck(
|
||||
makeEnv(all_ - fixCleanup3_1_3),
|
||||
{},
|
||||
deleteHolding,
|
||||
XRPAmount{},
|
||||
STTx{ttACCOUNT_SET, [](STObject&) {}},
|
||||
{tesSUCCESS, tesSUCCESS},
|
||||
createBrokerWithCover);
|
||||
}
|
||||
|
||||
// A LoanBroker may only be removed by ttLOAN_BROKER_DELETE. Erase
|
||||
// the broker in the apply view under a non-delete tx type and
|
||||
// expect the deletion-tx invariant to fire.
|
||||
doInvariantCheck(
|
||||
{{"Loan Broker deleted by a transaction other than LoanBrokerDelete"}},
|
||||
[&](Account const&, Account const&, ApplyContext& ac) {
|
||||
if (loanBrokerKeylet.type != ltLOAN_BROKER)
|
||||
return false;
|
||||
auto sleBroker = ac.view().peek(loanBrokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return false;
|
||||
ac.view().erase(sleBroker);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttACCOUNT_SET, [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
createLoanBroker);
|
||||
}
|
||||
|
||||
// A LoanBrokerDelete must not remove a broker whose pre-transaction
|
||||
// DebtTotal is non-zero. visitEntry captures `before` from the parent
|
||||
// view, so the DebtTotal must be seeded in the OpenView before the
|
||||
// ApplyContext is constructed; a Precheck modification would only
|
||||
// land in the applyView (visible as `after`) and would leave `before`
|
||||
// at the createLoanBroker-produced zero.
|
||||
{
|
||||
Env env{*this};
|
||||
Account const a1{"A1"};
|
||||
Account const a2{"A2"};
|
||||
env.fund(XRP(1000), a1, a2);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
|
||||
if (!BEAST_EXPECT(env.le(brokerKeylet)))
|
||||
return;
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
// Seed a non-zero DebtTotal in the base view so `before` at
|
||||
// visitEntry time reports it.
|
||||
{
|
||||
auto const sleBrokerRead = ov.read(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBrokerRead))
|
||||
return;
|
||||
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
|
||||
sleBroker->at(sfDebtTotal) = Number(1);
|
||||
ov.rawReplace(sleBroker);
|
||||
}
|
||||
|
||||
STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleBroker = ac.view().peek(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return;
|
||||
ac.view().erase(sleBroker);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
return;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(
|
||||
sink.messages().str().contains("Loan Broker deleted with non-zero debt total"));
|
||||
}
|
||||
|
||||
// Residual DebtTotal dust that rounds to zero at the vault asset's
|
||||
// scale must not trip the invariant: LoanBrokerDelete::preclaim
|
||||
// deliberately permits it, so the invariant must not be stricter.
|
||||
// Other invariants may still object to a hand-erased broker, so only
|
||||
// the absence of the DebtTotal complaint is asserted.
|
||||
{
|
||||
Env env{*this};
|
||||
Account const a1{"A1"};
|
||||
Account const a2{"A2"};
|
||||
env.fund(XRP(1000), a1, a2);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
|
||||
if (!BEAST_EXPECT(env.le(brokerKeylet)))
|
||||
return;
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
// A thousandth of a drop: non-zero, but zero once quantized to XRP.
|
||||
{
|
||||
auto const sleBrokerRead = ov.read(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBrokerRead))
|
||||
return;
|
||||
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
|
||||
sleBroker->at(sfDebtTotal) = Number(1, -3);
|
||||
ov.rawReplace(sleBroker);
|
||||
}
|
||||
|
||||
STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleBroker = ac.view().peek(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return;
|
||||
ac.view().erase(sleBroker);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
return;
|
||||
[[maybe_unused]] TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(
|
||||
!sink.messages().str().contains("Loan Broker deleted with non-zero debt total"));
|
||||
}
|
||||
|
||||
// A LoanBrokerDelete must not remove a broker whose pre-transaction
|
||||
// OwnerCount is non-zero. DebtTotal is left at zero so the earlier
|
||||
// check passes and the OwnerCount check is what fires.
|
||||
{
|
||||
Env env{*this};
|
||||
Account const a1{"A1"};
|
||||
Account const a2{"A2"};
|
||||
env.fund(XRP(1000), a1, a2);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
|
||||
if (!BEAST_EXPECT(env.le(brokerKeylet)))
|
||||
return;
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
{
|
||||
auto const sleBrokerRead = ov.read(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBrokerRead))
|
||||
return;
|
||||
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
|
||||
sleBroker->at(sfOwnerCount) = 1;
|
||||
ov.rawReplace(sleBroker);
|
||||
}
|
||||
|
||||
STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleBroker = ac.view().peek(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return;
|
||||
ac.view().erase(sleBroker);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
return;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(
|
||||
sink.messages().str().contains("Loan Broker deleted with non-zero owner count"));
|
||||
}
|
||||
|
||||
// Only one LoanBroker may be deleted per transaction. Create two
|
||||
// brokers under different owners, then erase both in the apply view
|
||||
// and expect the multi-deletion invariant to fire.
|
||||
{
|
||||
Keylet loanBrokerKeylet1 = keylet::amendments();
|
||||
Keylet loanBrokerKeylet2 = keylet::amendments();
|
||||
Preclose const createTwoBrokers = [&, this](
|
||||
Account const& a1, Account const& a2, Env& env) {
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
loanBrokerKeylet1 = this->createLoanBroker(a1, env, xrpAsset);
|
||||
loanBrokerKeylet2 = this->createLoanBroker(a2, env, xrpAsset);
|
||||
return BEAST_EXPECT(env.le(loanBrokerKeylet1) && env.le(loanBrokerKeylet2));
|
||||
};
|
||||
|
||||
doInvariantCheck(
|
||||
{{"more than one Loan Broker deleted in a single transaction"}},
|
||||
[&](Account const&, Account const&, ApplyContext& ac) {
|
||||
auto sle1 = ac.view().peek(loanBrokerKeylet1);
|
||||
auto sle2 = ac.view().peek(loanBrokerKeylet2);
|
||||
if (!BEAST_EXPECT(sle1 && sle2))
|
||||
return false;
|
||||
ac.view().erase(sle1);
|
||||
ac.view().erase(sle2);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
createTwoBrokers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
#include <test/jtx/sig.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
#include <test/unit_test/SuiteJournal.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/OpenView.h>
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
@@ -38,10 +42,12 @@
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/Transactor.h>
|
||||
#include <xrpl/tx/applySteps.h>
|
||||
#include <xrpl/tx/invariants/VaultInvariant.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
@@ -68,6 +74,19 @@ class InvariantsVault_test : public InvariantsBase
|
||||
AccountID account;
|
||||
int amount;
|
||||
};
|
||||
// Parameters for a synthetic loan object created alongside a vault
|
||||
// adjustment. The interest due booked to the vault is
|
||||
// totalValueOutstanding - principalOutstanding - managementFeeOutstanding.
|
||||
struct LoanParams
|
||||
{
|
||||
int principalOutstanding = 0;
|
||||
int totalValueOutstanding = 0;
|
||||
int managementFeeOutstanding = 0;
|
||||
AccountID borrower = beast::kZero;
|
||||
// Broker the created loan references. Left unset when the test does
|
||||
// not depend on the broker resolving to a real ledger entry.
|
||||
uint256 brokerKey = beast::kZero;
|
||||
};
|
||||
struct Adjustments
|
||||
{
|
||||
// NOLINTBEGIN(readability-redundant-member-init)
|
||||
@@ -79,6 +98,10 @@ class InvariantsVault_test : public InvariantsBase
|
||||
std::optional<int> vaultAssets = std::nullopt;
|
||||
std::optional<AccountAmount> accountAssets = std::nullopt;
|
||||
std::optional<AccountAmount> accountShares = std::nullopt;
|
||||
std::optional<LoanParams> createLoan = std::nullopt;
|
||||
// Number of loan objects to create (only used when createLoan is
|
||||
// set); a valid loan set creates exactly one.
|
||||
int loanCount = 1;
|
||||
// NOLINTEND(readability-redundant-member-init)
|
||||
};
|
||||
constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) {
|
||||
@@ -186,6 +209,26 @@ class InvariantsVault_test : public InvariantsBase
|
||||
(*sleMPToken)[sfMPTAmount] = addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount);
|
||||
ac.update(sleMPToken);
|
||||
}
|
||||
|
||||
if (args.createLoan)
|
||||
{
|
||||
auto const& lp = *args.createLoan;
|
||||
bool const anyOutstanding = lp.principalOutstanding != 0 ||
|
||||
lp.totalValueOutstanding != 0 || lp.managementFeeOutstanding != 0;
|
||||
// The vault key stands in for an unset broker: it keeps the loan
|
||||
// keylet distinct per vault while resolving to no broker.
|
||||
uint256 const brokerKey = lp.brokerKey != beast::kZero ? lp.brokerKey : keylet.key;
|
||||
for (std::uint32_t seq = 1; seq <= static_cast<std::uint32_t>(args.loanCount);
|
||||
++seq)
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKey, seq, lp.borrower);
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(lp.principalOutstanding);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(lp.totalValueOutstanding);
|
||||
sleLoan->at(sfManagementFeeOutstanding) = Number(lp.managementFeeOutstanding);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, anyOutstanding ? 1 : 0);
|
||||
ac.insert(sleLoan);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -206,7 +249,10 @@ class InvariantsVault_test : public InvariantsBase
|
||||
|
||||
Account const a3{"A3"};
|
||||
Account const a4{"A4"};
|
||||
auto const precloseXrp = [&](Account const& a1, Account const& a2, Env& env) -> bool {
|
||||
auto const precloseXrp = [&](Account const& a1,
|
||||
Account const& a2,
|
||||
Env& env,
|
||||
VaultVersion version = VaultVersion::CashBasis) -> bool {
|
||||
env.fund(XRP(1000), a3, a4);
|
||||
Vault const vault{env};
|
||||
auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()});
|
||||
@@ -217,6 +263,18 @@ class InvariantsVault_test : public InvariantsBase
|
||||
return true;
|
||||
};
|
||||
|
||||
auto const createClosedXrpBroker =
|
||||
[&](Account const& owner, Env& env) -> std::optional<std::pair<Keylet, Keylet>> {
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
auto const brokerKeylet = createLoanBroker(owner, env, xrpAsset);
|
||||
auto const sleBroker = env.le(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return std::nullopt;
|
||||
auto const vaultKeylet = keylet::vault(sleBroker->at(sfVaultID));
|
||||
env.close(std::chrono::seconds{61});
|
||||
return std::pair{vaultKeylet, brokerKeylet};
|
||||
};
|
||||
|
||||
testcase << "Vault general checks";
|
||||
doInvariantCheck(
|
||||
{"vault deletion succeeded without deleting a vault"},
|
||||
@@ -598,7 +656,81 @@ class InvariantsVault_test : public InvariantsBase
|
||||
precloseXrp,
|
||||
TxAccount::A2);
|
||||
|
||||
// Under featureLendingProtocolV1_1 the immutability of sfAsset, sfAccount,
|
||||
// sfShareMPTID and sfLEVersion is enforced by NoModifiedUnmodifiableFields.
|
||||
doInvariantCheck(
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))});
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
doInvariantCheck(
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
sleVault->setAccountID(sfAccount, a2.id());
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
doInvariantCheck(
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
(*sleVault)[sfShareMPTID] = MPTID(42);
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
doInvariantCheck(
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
(*sleVault)[sfLEVersion] = std::to_underlying(VaultVersion::Legacy);
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
[&precloseXrp](Account const& a1, Account const& a2, Env& env) {
|
||||
return precloseXrp(a1, a2, env, VaultVersion::CashBasis);
|
||||
});
|
||||
|
||||
// Pre-featureLendingProtocolV1_1 sfAsset, sfAccount and sfShareMPTID are
|
||||
// guarded by ValidVault instead, so both paths need coverage. ValidVault
|
||||
// returns early once the result is already tec, hence no escalation to
|
||||
// tef on the second pass.
|
||||
auto const preLendingV11Amendments = all_ - featureLendingProtocolV1_1;
|
||||
doInvariantCheck(
|
||||
makeEnv(preLendingV11Amendments),
|
||||
{"violation of vault immutable data"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
@@ -615,6 +747,7 @@ class InvariantsVault_test : public InvariantsBase
|
||||
precloseXrp);
|
||||
|
||||
doInvariantCheck(
|
||||
makeEnv(preLendingV11Amendments),
|
||||
{"violation of vault immutable data"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
@@ -631,6 +764,7 @@ class InvariantsVault_test : public InvariantsBase
|
||||
precloseXrp);
|
||||
|
||||
doInvariantCheck(
|
||||
makeEnv(preLendingV11Amendments),
|
||||
{"violation of vault immutable data"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
@@ -698,9 +832,12 @@ class InvariantsVault_test : public InvariantsBase
|
||||
TxAccount::A2);
|
||||
|
||||
// Without fixCleanup3_4_0 the same state must NOT trip the invariant,
|
||||
// preserving pre-amendment behavior (no fork risk).
|
||||
// preserving pre-amendment behavior (no fork risk). Also remove
|
||||
// featureLendingProtocolV1_1 so finalizeLoanManage's stricter checks
|
||||
// (exactly one loan touched) do not fire from a bare vault mutation
|
||||
// that does not touch a loan.
|
||||
doInvariantCheck(
|
||||
makeEnv(all_ - fixCleanup3_4_0),
|
||||
makeEnv(all_ - fixCleanup3_4_0 - featureLendingProtocolV1_1),
|
||||
{},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
@@ -808,6 +945,746 @@ class InvariantsVault_test : public InvariantsBase
|
||||
precloseXrp,
|
||||
TxAccount::A2);
|
||||
|
||||
// ttLOAN_SET pre-featureLendingProtocolV1_1: finalizeLoanSet short-
|
||||
// circuits and returns success without inspecting the loan or the
|
||||
// vault. The same state that trips the principal-outstanding check
|
||||
// under V1_1 must be silently accepted here.
|
||||
doInvariantCheck(
|
||||
makeEnv(all_ - featureLendingProtocolV1_1),
|
||||
{},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
return kAdjust(
|
||||
ac.view(),
|
||||
keylet,
|
||||
Adjustments{
|
||||
.assetsAvailable = -200,
|
||||
.vaultAssets = -200,
|
||||
.accountAssets = AccountAmount{.account = a2.id(), .amount = 200},
|
||||
.createLoan = LoanParams{
|
||||
.principalOutstanding = 300,
|
||||
.totalValueOutstanding = 300,
|
||||
.borrower = a1.id(),
|
||||
}});
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
|
||||
{tesSUCCESS, tesSUCCESS},
|
||||
precloseXrp);
|
||||
|
||||
// ttLOAN_MANAGE: a loan is created rather than modified. This object-
|
||||
// existence rule applies on both invariant passes.
|
||||
doInvariantCheck(
|
||||
{"Loan created by a transaction other than LoanSet"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
return kAdjust(
|
||||
ac.view(),
|
||||
keylet,
|
||||
Adjustments{
|
||||
.createLoan = LoanParams{
|
||||
.principalOutstanding = 100,
|
||||
.totalValueOutstanding = 100,
|
||||
.borrower = a1.id(),
|
||||
}});
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
// ttLOAN_MANAGE: loss unrealized driven negative
|
||||
doInvariantCheck(
|
||||
{"loss unrealized must not be negative"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = -1});
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_MANAGE, [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
// Loan flags may only change under the transaction types that own
|
||||
// those transitions.
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
std::uint32_t before;
|
||||
std::uint32_t after;
|
||||
std::string expected;
|
||||
};
|
||||
auto const cases = std::to_array<Case>({
|
||||
{.before = 0,
|
||||
.after = lsfLoanImpaired,
|
||||
.expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"},
|
||||
{.before = lsfLoanImpaired,
|
||||
.after = 0,
|
||||
.expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"},
|
||||
{.before = 0,
|
||||
.after = lsfLoanDefault,
|
||||
.expected = "lsfLoanDefault changed outside LoanManage"},
|
||||
});
|
||||
|
||||
for (auto const& c : cases)
|
||||
{
|
||||
Env env{*this, all_};
|
||||
Account const a1{"A1"};
|
||||
Account const a2{"A2"};
|
||||
env.fund(XRP(1000), a1, a2);
|
||||
auto const keys = createClosedXrpBroker(a1, env);
|
||||
if (!keys)
|
||||
continue;
|
||||
auto const& brokerKeylet = keys->second;
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(100);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(150);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
sleLoan->setFieldU32(sfFlags, c.before);
|
||||
ov.rawInsert(sleLoan);
|
||||
}
|
||||
|
||||
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleLoan = ac.view().peek(loanKeylet);
|
||||
if (!BEAST_EXPECT(sleLoan))
|
||||
continue;
|
||||
sleLoan->setFieldU32(sfFlags, c.after);
|
||||
ac.view().update(sleLoan);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
continue;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(sink.messages().str().contains(c.expected));
|
||||
}
|
||||
}
|
||||
|
||||
// ttLOAN_MANAGE (default): a defaulted loan atomically enters a
|
||||
// terminal state, which drops sfNextPaymentDueDate from the ledger
|
||||
// entry. Seed a loan that already carries lsfLoanDefault so the
|
||||
// "must newly set" check passes, then leave sfNextPaymentDueDate
|
||||
// present and non-zero on the after-image; the residual due-date
|
||||
// check must then fire.
|
||||
{
|
||||
Env env{*this, all_};
|
||||
Account const a1{"A1"};
|
||||
Account const a2{"A2"};
|
||||
env.fund(XRP(1000), a1, a2);
|
||||
BEAST_EXPECT(precloseXrp(a1, a2, env));
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
// Pre-insert a loan that is not yet defaulted but has a
|
||||
// NextPaymentDueDate set; the apply-view mutation below flips
|
||||
// lsfLoanDefault (so the "must newly set" check passes) while
|
||||
// leaving the due date behind.
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
|
||||
sleLoan->setFieldU32(sfNextPaymentDueDate, 123);
|
||||
ov.rawInsert(sleLoan);
|
||||
}
|
||||
|
||||
STTx const tx{
|
||||
ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleLoan = ac.view().peek(loanKeylet);
|
||||
if (!BEAST_EXPECT(sleLoan))
|
||||
return;
|
||||
sleLoan->setFieldU32(sfFlags, lsfLoanDefault);
|
||||
ac.view().update(sleLoan);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
return;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(sink.messages().str().contains(
|
||||
"Loan with zero payments must have zero next payment due date"));
|
||||
}
|
||||
|
||||
// ttLOAN_PAY pre-featureLendingProtocolV1_1: finalizeLoanPay short-
|
||||
// circuits and returns success. The same "no vault balance change"
|
||||
// state that trips the check under V1_1 must be silently accepted
|
||||
// here.
|
||||
doInvariantCheck(
|
||||
makeEnv(all_ - featureLendingProtocolV1_1),
|
||||
{},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
return kAdjust(ac.view(), keylet, Adjustments{});
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
|
||||
{tesSUCCESS, tesSUCCESS},
|
||||
precloseXrp);
|
||||
|
||||
// ttLOAN_PAY: cash is credited to the vault and a loan is created
|
||||
// rather than modified. This object-existence rule applies on both
|
||||
// invariant passes.
|
||||
doInvariantCheck(
|
||||
{"Loan created by a transaction other than LoanSet"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
return kAdjust(
|
||||
ac.view(),
|
||||
keylet,
|
||||
Adjustments{
|
||||
.assetsTotal = 50,
|
||||
.assetsAvailable = 50,
|
||||
.vaultAssets = 50,
|
||||
.accountAssets = AccountAmount{.account = a2.id(), .amount = -50},
|
||||
.createLoan = LoanParams{
|
||||
.principalOutstanding = 100,
|
||||
.totalValueOutstanding = 100,
|
||||
.borrower = a1.id(),
|
||||
}});
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(50)); }},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
// ttLOAN_PAY: loss unrealized driven negative. The cash inflow is
|
||||
// valid, but loss unrealized is set below zero.
|
||||
doInvariantCheck(
|
||||
{"loss unrealized must not be negative"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
return kAdjust(
|
||||
ac.view(),
|
||||
keylet,
|
||||
Adjustments{
|
||||
.assetsTotal = 100,
|
||||
.assetsAvailable = 100,
|
||||
.lossUnrealized = -1,
|
||||
.vaultAssets = 100,
|
||||
.accountAssets = AccountAmount{.account = a2.id(), .amount = -100}});
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
|
||||
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
// ttLOAN_PAY success post-conditions. A loan left with payments still
|
||||
// remaining after a successful payment must show that payment in its
|
||||
// balance and schedule: PrincipalOutstanding and PaymentRemaining both
|
||||
// strictly decrease, and NextPaymentDueDate advances by a positive
|
||||
// multiple of PaymentInterval. Each case seeds the same loan, then applies
|
||||
// an after-image that breaks exactly one of those conditions.
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
Number principal;
|
||||
std::uint32_t remaining;
|
||||
std::uint32_t dueDate;
|
||||
std::string expected;
|
||||
};
|
||||
auto const cases = std::to_array<Case>({
|
||||
{.principal = Number(100),
|
||||
.remaining = 1,
|
||||
.dueDate = 110,
|
||||
.expected = "loan pay must strictly decrease PrincipalOutstanding"},
|
||||
{.principal = Number(50),
|
||||
.remaining = 2,
|
||||
.dueDate = 110,
|
||||
.expected = "loan pay must decrease PaymentRemaining"},
|
||||
{.principal = Number(50),
|
||||
.remaining = 1,
|
||||
.dueDate = 100,
|
||||
.expected = "loan pay must advance NextPaymentDueDate"},
|
||||
// Advanced, but not by a whole number of payment intervals.
|
||||
{.principal = Number(50),
|
||||
.remaining = 1,
|
||||
.dueDate = 105,
|
||||
.expected = "loan pay must advance NextPaymentDueDate"},
|
||||
});
|
||||
|
||||
for (auto const& c : cases)
|
||||
{
|
||||
Env env{*this, all_};
|
||||
Account const a1{"A1"};
|
||||
Account const a2{"A2"};
|
||||
env.fund(XRP(1000), a1, a2);
|
||||
auto const keys = createClosedXrpBroker(a1, env);
|
||||
if (!keys)
|
||||
continue;
|
||||
auto const& brokerKeylet = keys->second;
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(100);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(150);
|
||||
sleLoan->at(sfPaymentInterval) = 10u;
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 2);
|
||||
sleLoan->setFieldU32(sfNextPaymentDueDate, 100);
|
||||
ov.rawInsert(sleLoan);
|
||||
}
|
||||
|
||||
STTx const tx{
|
||||
ttLOAN_PAY, [](STObject& t) { t.setFieldAmount(sfAmount, XRPAmount(50)); }};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleLoan = ac.view().peek(loanKeylet);
|
||||
if (!BEAST_EXPECT(sleLoan))
|
||||
continue;
|
||||
sleLoan->at(sfPrincipalOutstanding) = c.principal;
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, c.remaining);
|
||||
sleLoan->setFieldU32(sfNextPaymentDueDate, c.dueDate);
|
||||
ac.view().update(sleLoan);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
continue;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(sink.messages().str().contains(c.expected));
|
||||
}
|
||||
}
|
||||
|
||||
// ttLOAN_MANAGE (default): the write-off is rounded downward at the
|
||||
// pre-default AssetsTotal scale. A near-total IOU default can leave
|
||||
// valid positive dust while moving the posterior AssetsTotal to a much
|
||||
// finer scale. The dust must be bounded by the former scale rather than
|
||||
// compared with one unit at the posterior scale.
|
||||
{
|
||||
Env env{*this, all_ | featureLendingProtocolV1_1};
|
||||
Account const issuer{"issuer"};
|
||||
Account const owner{"owner"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1000), issuer, owner, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const iouAsset{issuer["IOU"]};
|
||||
auto const brokerKeylet = createLoanBroker(owner, env, iouAsset);
|
||||
auto const sleBrokerBase = env.le(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBrokerBase))
|
||||
return;
|
||||
auto const vaultKeylet = keylet::vault(sleBrokerBase->at(sfVaultID));
|
||||
env.close();
|
||||
|
||||
Number const assetsTotalBefore{1, 1};
|
||||
Number const loanOwed{9'999'999'999'999'999LL, -15};
|
||||
Number const assetsTotalAfter{1, -14};
|
||||
auto const beforeScale = scale(assetsTotalBefore, iouAsset);
|
||||
auto const afterScale = scale(assetsTotalAfter, iouAsset);
|
||||
Number const residual = (assetsTotalAfter - assetsTotalBefore) - (-loanOwed);
|
||||
Number const beforeTolerance{1, beforeScale};
|
||||
Number const afterTolerance{1, afterScale};
|
||||
|
||||
BEAST_EXPECT(afterScale < beforeScale);
|
||||
BEAST_EXPECT(residual > beast::kZero && residual < beforeTolerance);
|
||||
BEAST_EXPECT(residual > afterTolerance);
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
{
|
||||
auto const sleVaultRead = ov.read(vaultKeylet);
|
||||
if (!BEAST_EXPECT(sleVaultRead))
|
||||
return;
|
||||
auto sleVault = std::make_shared<SLE>(*sleVaultRead);
|
||||
sleVault->at(sfAssetsTotal) = assetsTotalBefore;
|
||||
sleVault->at(sfAssetsAvailable) = Number(0);
|
||||
ov.rawReplace(sleVault);
|
||||
|
||||
auto const sharesKeylet = keylet::mptokenIssuance(sleVaultRead->at(sfShareMPTID));
|
||||
auto const sleSharesRead = ov.read(sharesKeylet);
|
||||
if (!BEAST_EXPECT(sleSharesRead))
|
||||
return;
|
||||
auto sleShares = std::make_shared<SLE>(*sleSharesRead);
|
||||
sleShares->at(sfOutstandingAmount) = 1;
|
||||
ov.rawReplace(sleShares);
|
||||
}
|
||||
{
|
||||
auto const sleBrokerRead = ov.read(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBrokerRead))
|
||||
return;
|
||||
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
|
||||
sleBroker->at(sfDebtTotal) = loanOwed;
|
||||
ov.rawReplace(sleBroker);
|
||||
}
|
||||
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
{
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, borrower.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = loanOwed;
|
||||
sleLoan->at(sfTotalValueOutstanding) = loanOwed;
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
ov.rawInsert(sleLoan);
|
||||
}
|
||||
|
||||
STTx const tx{
|
||||
ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
{
|
||||
auto sleVault = ac.view().peek(vaultKeylet);
|
||||
if (!BEAST_EXPECT(sleVault))
|
||||
return;
|
||||
sleVault->at(sfAssetsTotal) = assetsTotalAfter;
|
||||
ac.view().update(sleVault);
|
||||
}
|
||||
{
|
||||
auto sleBroker = ac.view().peek(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return;
|
||||
sleBroker->at(sfDebtTotal) = Number(0);
|
||||
ac.view().update(sleBroker);
|
||||
}
|
||||
{
|
||||
auto sleLoan = ac.view().peek(loanKeylet);
|
||||
if (!BEAST_EXPECT(sleLoan))
|
||||
return;
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(0);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(0);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 0);
|
||||
sleLoan->setFieldU32(sfFlags, lsfLoanDefault);
|
||||
ac.view().update(sleLoan);
|
||||
}
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
return;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tesSUCCESS);
|
||||
}
|
||||
|
||||
// A loan may only be deleted by a LoanDelete transaction, and only once
|
||||
// it is fully paid off. Both branches are exercised by creating a real
|
||||
// loan in the Preclose (so it exists in the base ledger with outstanding
|
||||
// principal) and then erasing it in the Precheck.
|
||||
{
|
||||
Keylet loanKeylet = keylet::amendments();
|
||||
auto const precloseLoan = [&loanKeylet, this](
|
||||
Account const& a1, Account const& a2, Env& env) -> bool {
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
|
||||
auto const brokerSle = env.le(brokerKeylet);
|
||||
if (!BEAST_EXPECT(brokerSle))
|
||||
return false;
|
||||
auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
|
||||
Vault const vault{env};
|
||||
env(vault.deposit(
|
||||
{.depositor = a1, .id = vaultKeylet.key, .amount = xrpAsset(100)}));
|
||||
env.close(std::chrono::seconds{61});
|
||||
|
||||
loanKeylet = keylet::loan(
|
||||
brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
|
||||
env(loan::set(a2, brokerKeylet.key, xrpAsset(50).value()),
|
||||
loan::kCounterparty(a1),
|
||||
Sig(sfCounterpartySignature, a1),
|
||||
loan::kPaymentInterval(60),
|
||||
loan::kPaymentTotal(1),
|
||||
Fee(env.current()->fees().base * 2));
|
||||
env.close();
|
||||
return BEAST_EXPECT(env.le(loanKeylet));
|
||||
};
|
||||
|
||||
auto const eraseLoan = [&loanKeylet](Account const&, Account const&, ApplyContext& ac) {
|
||||
auto sle = ac.view().peek(loanKeylet);
|
||||
if (!sle)
|
||||
return false;
|
||||
ac.view().erase(sle);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Deleting the loan under any transaction type other than LoanDelete
|
||||
// (here the neutral ttACCOUNT_SET) is a violation, even while the
|
||||
// loan still has outstanding obligations: the transaction-type check
|
||||
// fires before the not-fully-paid-off check.
|
||||
doInvariantCheck(
|
||||
{"Loan deleted by a transaction other than LoanDelete"},
|
||||
eraseLoan,
|
||||
XRPAmount{},
|
||||
STTx{ttACCOUNT_SET, [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseLoan);
|
||||
}
|
||||
|
||||
STTx const loanSetTx{
|
||||
ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(0); }};
|
||||
|
||||
// Loan interest due (total value less principal and management fee) must
|
||||
// never be negative. The loan below carries a total value short of its
|
||||
// principal, while every individual field stays non-negative. A real
|
||||
// broker over an XRP vault is created in the preclose, both so the
|
||||
// earlier broker-existence checks pass and so the deficit is measured
|
||||
// in an integral asset domain, where no rounding tolerance applies.
|
||||
{
|
||||
Keylet brokerKeylet = keylet::amendments();
|
||||
auto const precloseBroker = [&brokerKeylet, this](
|
||||
Account const& a1, Account const&, Env& env) -> bool {
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
brokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
|
||||
env.close();
|
||||
return BEAST_EXPECT(env.le(brokerKeylet));
|
||||
};
|
||||
|
||||
doInvariantCheck(
|
||||
{"Loan interest due is negative"},
|
||||
[&](Account const&, Account const& a2, ApplyContext& ac) {
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(100);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(90);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx,
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseBroker);
|
||||
}
|
||||
|
||||
// Each of these loan STNumber fields must never be negative. The loan
|
||||
// is created directly with a single field set negative while the
|
||||
// paid-off bookkeeping is kept consistent, so that only the "<field>
|
||||
// is negative" check trips.
|
||||
for (auto const field : {
|
||||
&sfLoanServiceFee,
|
||||
&sfLatePaymentFee,
|
||||
&sfClosePaymentFee,
|
||||
&sfPrincipalOutstanding,
|
||||
&sfTotalValueOutstanding,
|
||||
&sfManagementFeeOutstanding,
|
||||
})
|
||||
{
|
||||
// The outstanding-balance fields also feed the paid-off checks, so
|
||||
// a loan carrying one must still have payments remaining; a loan
|
||||
// with only a negative fee stays fully paid off (zero remaining).
|
||||
bool const isOutstanding = *field == sfPrincipalOutstanding ||
|
||||
*field == sfTotalValueOutstanding || *field == sfManagementFeeOutstanding;
|
||||
doInvariantCheck(
|
||||
{field->getName() + " is negative"},
|
||||
[&, field](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
|
||||
sleLoan->at(*field) = Number(-10);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, isOutstanding ? 1 : 0);
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx);
|
||||
}
|
||||
|
||||
// Mirror of the loop above for the strictly-positive constraint: a
|
||||
// loan's sfPeriodicPayment must always be > 0. Cover both boundary
|
||||
// failure modes (zero and negative).
|
||||
for (Number const& badValue : {Number(0), Number(-1)})
|
||||
{
|
||||
doInvariantCheck(
|
||||
{std::string{sfPeriodicPayment.getName()} + " is zero or negative"},
|
||||
[&, badValue](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
|
||||
sleLoan->at(sfPeriodicPayment) = badValue;
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx);
|
||||
}
|
||||
|
||||
// A loan with sfPaymentRemaining == 0 must be fully paid off in every
|
||||
// outstanding-balance dimension. Insert a bare loan that reports zero
|
||||
// payments remaining but still carries a non-zero principal owed; the
|
||||
// paid-off invariant must reject it before the later broker-existence
|
||||
// check has a chance to run.
|
||||
doInvariantCheck(
|
||||
{"Loan with zero payments remaining has not been paid off"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(100);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(100);
|
||||
sleLoan->at(sfPeriodicPayment) = Number(1);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 0);
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx);
|
||||
|
||||
// Converse: a loan whose outstanding balances are all zero has been
|
||||
// fully paid off and must carry zero payments remaining. Insert a
|
||||
// fully-zeroed loan with sfPaymentRemaining = 1 to trip the check.
|
||||
doInvariantCheck(
|
||||
{"Fully paid off Loan still has payments remaining"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
|
||||
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx);
|
||||
|
||||
// A loan must reference a live loan broker. A bare loan SLE is
|
||||
// inserted with every other loan-level field kept consistent so the
|
||||
// earlier ValidLoan checks pass; sfLoanBrokerID defaults to zero,
|
||||
// which resolves to no broker, and the broker-existence check trips.
|
||||
doInvariantCheck(
|
||||
{"Loan broker does not exist"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto sleLoan = makeLoanSle(uint256{}, 1, a2.id());
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx);
|
||||
|
||||
// A loan's broker must in turn reference a live vault. A real broker
|
||||
// is created in the preclose so its sfVaultID points at an existing
|
||||
// vault; the precheck then erases that vault and inserts a loan
|
||||
// referencing the broker, so the broker-existence check passes and
|
||||
// the broker-vault-existence check trips.
|
||||
{
|
||||
Keylet brokerKeylet = keylet::amendments();
|
||||
auto const precloseBroker = [&brokerKeylet, this](
|
||||
Account const& a1, Account const&, Env& env) -> bool {
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
brokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
|
||||
env.close();
|
||||
return BEAST_EXPECT(env.le(brokerKeylet));
|
||||
};
|
||||
|
||||
doInvariantCheck(
|
||||
{"Loan broker vault does not exist"},
|
||||
[&brokerKeylet](Account const&, Account const&, ApplyContext& ac) {
|
||||
auto sleBroker = ac.view().peek(brokerKeylet);
|
||||
if (!sleBroker)
|
||||
return false;
|
||||
auto sleVault = ac.view().peek(keylet::vault(sleBroker->at(sfVaultID)));
|
||||
if (!sleVault)
|
||||
return false;
|
||||
ac.view().erase(sleVault);
|
||||
|
||||
auto const loanKeylet =
|
||||
keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
|
||||
auto sleLoan = std::make_shared<SLE>(loanKeylet);
|
||||
sleLoan->at(sfLoanBrokerID) = brokerKeylet.key;
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(0);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(0);
|
||||
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
|
||||
sleLoan->at(sfPeriodicPayment) = Number(1);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 0);
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx,
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseBroker);
|
||||
}
|
||||
|
||||
// ttVAULT_SET: owner is immutable (enforced by
|
||||
// NoModifiedUnmodifiableFields under featureLendingProtocolV1_1.
|
||||
doInvariantCheck(
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
sleVault->setAccountID(sfOwner, a2.id());
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
// ttVAULT_SET: withdrawal policy is immutable
|
||||
doInvariantCheck(
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
sleVault->setFieldU8(
|
||||
sfWithdrawalPolicy,
|
||||
static_cast<std::uint8_t>(sleVault->getFieldU8(sfWithdrawalPolicy) + 1));
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
// ttVAULT_SET: scale is immutable
|
||||
doInvariantCheck(
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
sleVault->setFieldU8(
|
||||
sfScale, static_cast<std::uint8_t>(sleVault->getFieldU8(sfScale) + 1));
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
// featureLendingProtocolV1_1 moves the vault immutability checks from VaultInvariant to
|
||||
// InvariantCheck.
|
||||
doInvariantCheck(
|
||||
makeEnv(all_),
|
||||
{"changed an unchangeable field"},
|
||||
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
|
||||
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
|
||||
auto sleVault = ac.view().peek(keylet);
|
||||
if (!sleVault)
|
||||
return false;
|
||||
sleVault->setFieldU8(
|
||||
sfWithdrawalPolicy,
|
||||
static_cast<std::uint8_t>(sleVault->getFieldU8(sfWithdrawalPolicy) + 1));
|
||||
ac.view().update(sleVault);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttVAULT_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
precloseXrp);
|
||||
|
||||
testcase << "Vault create";
|
||||
doInvariantCheck(
|
||||
{
|
||||
@@ -1907,8 +2784,7 @@ class InvariantsVault_test : public InvariantsBase
|
||||
// Synthesize a Loan whose final scheduled payment lands
|
||||
// exactly at RedemptionDate: StartDate = red, interval = 60,
|
||||
// remaining = 1 => red + 60 >= red.
|
||||
auto sleLoan = std::make_shared<SLE>(
|
||||
keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq)));
|
||||
auto sleLoan = makeLoanSle(closedEndedBrokerKeylet.key, loanSeq, a1.id());
|
||||
sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
|
||||
sleLoan->at(sfLoanSequence) = loanSeq;
|
||||
sleLoan->at(sfBorrower) = a1.id();
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
#include <xrpl/protocol/Units.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
@@ -383,6 +385,85 @@ private:
|
||||
isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale));
|
||||
}
|
||||
|
||||
// Verify an overpayment cannot reduce principal without covering and
|
||||
// advancing at least one scheduled instalment: reject an extra-only amount,
|
||||
// but accept an instalment plus extra. Enable V1_1 explicitly because
|
||||
// LoanTestBase::all_ excludes it.
|
||||
void
|
||||
testLoanPayOverpaymentScheduleInvariant(FeatureBitset features)
|
||||
{
|
||||
testcase("LoanPay overpayment schedule advancement");
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
Env env{*this, features | featureLendingProtocolV1_1};
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
|
||||
env.fund(XRP(10'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const asset{xrpIssue(), 1000};
|
||||
|
||||
BrokerInfo const broker = createVaultAndBroker(
|
||||
env,
|
||||
asset,
|
||||
lender,
|
||||
{
|
||||
.vaultDeposit = asset(100'000).value(),
|
||||
.managementFeeRate = TenthBips16(10'000),
|
||||
});
|
||||
|
||||
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
||||
|
||||
// Principal 10,000 over 3 payments, overpayment enabled. One scheduled
|
||||
// payment is ~3,333, so an amount well below that cannot cover one.
|
||||
auto const loanKeylet = nextLoanKeylet(env, broker);
|
||||
env(loan::set(borrower, broker.brokerID, asset(10'000).value(), tfLoanOverpayment),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
loan::kPaymentInterval(86400 * 30),
|
||||
loan::kPaymentTotal(3),
|
||||
loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))),
|
||||
loanSetFee);
|
||||
env.close();
|
||||
|
||||
auto const before = getCurrentState(env, broker, loanKeylet);
|
||||
BEAST_EXPECT(before.paymentRemaining == 3);
|
||||
|
||||
STAmount const belowOnePayment = asset(1'000).value();
|
||||
BEAST_EXPECT((belowOnePayment < STAmount{asset, before.periodicPayment}));
|
||||
|
||||
auto const payFee = Fee(env.current()->fees().base * 2);
|
||||
|
||||
// The amount does not cover a scheduled payment, so makeRegularPayment makes zero scheduled
|
||||
// payments and returns tecINSUFFICIENT_PAYMENT before the Extra branch runs. Were the
|
||||
// payment to succeed while touching only principal, PaymentRemaining and NextPaymentDueDate
|
||||
// would silently fail to advance.
|
||||
env(pay(borrower, loanKeylet.key, belowOnePayment, tfLoanOverpayment),
|
||||
payFee,
|
||||
Ter(tecINSUFFICIENT_PAYMENT));
|
||||
env.close();
|
||||
|
||||
auto const afterReject = getCurrentState(env, broker, loanKeylet);
|
||||
BEAST_EXPECT(afterReject.paymentRemaining == before.paymentRemaining);
|
||||
BEAST_EXPECT(afterReject.principalOutstanding == before.principalOutstanding);
|
||||
BEAST_EXPECT(afterReject.nextPaymentDate == before.nextPaymentDate);
|
||||
|
||||
// PaymentRemaining drops by one, NextPaymentDueDate advances by one interval, and
|
||||
// PrincipalOutstanding strictly decreases (by more than a plain payment thanks to the
|
||||
// extra).
|
||||
STAmount const onePaymentPlusExtra = asset(5'000).value();
|
||||
env(pay(borrower, loanKeylet.key, onePaymentPlusExtra, tfLoanOverpayment), payFee);
|
||||
env.close();
|
||||
|
||||
auto const afterPay = getCurrentState(env, broker, loanKeylet);
|
||||
BEAST_EXPECT(afterPay.paymentRemaining == before.paymentRemaining - 1);
|
||||
BEAST_EXPECT(afterPay.principalOutstanding < before.principalOutstanding);
|
||||
BEAST_EXPECT(afterPay.nextPaymentDate == before.nextPaymentDate + before.paymentInterval);
|
||||
}
|
||||
|
||||
void
|
||||
testAccountSendMptMinAmountInvariant(FeatureBitset features)
|
||||
{
|
||||
@@ -851,12 +932,175 @@ private:
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
testLoanSetRecipientScaleInvariant()
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
auto const runCase = [&](bool coarseBorrower) {
|
||||
testcase(
|
||||
coarseBorrower ? "LoanSet borrower balance uses coarsest scale"
|
||||
: "LoanSet broker owner balance uses coarsest scale");
|
||||
|
||||
Env env(*this, all_ | featureLendingProtocolV1_1);
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
|
||||
Number const coarseBalance{100'000'000'000LL};
|
||||
Number const regularBalance{100'000'000};
|
||||
PrettyAsset const asset = createFundedRippleIouAsset(
|
||||
env,
|
||||
issuer,
|
||||
lender,
|
||||
borrower,
|
||||
coarseBorrower ? regularBalance : coarseBalance,
|
||||
coarseBorrower ? coarseBalance : regularBalance);
|
||||
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 1'000'000,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0}};
|
||||
BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
|
||||
|
||||
Number const principal{1'012'345, -5};
|
||||
Number const originationFee{123'456, -6};
|
||||
Account const& recipient = coarseBorrower ? borrower : lender;
|
||||
Number const expected = coarseBorrower ? principal : originationFee;
|
||||
auto const before = env.balance(recipient, asset);
|
||||
|
||||
if (coarseBorrower)
|
||||
{
|
||||
env(set(borrower, broker.brokerID, principal),
|
||||
kCounterparty(lender),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
kInterestRate(TenthBips32{0}),
|
||||
kPaymentTotal(1),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(tesSUCCESS));
|
||||
}
|
||||
else
|
||||
{
|
||||
env(set(borrower, broker.brokerID, principal),
|
||||
kCounterparty(lender),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
kLoanOriginationFee(originationFee),
|
||||
kInterestRate(TenthBips32{0}),
|
||||
kPaymentTotal(1),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(tesSUCCESS));
|
||||
}
|
||||
env.close();
|
||||
|
||||
auto const after = env.balance(recipient, asset);
|
||||
Number const received = after.number() - before.number();
|
||||
auto const recipientScale =
|
||||
std::max(before.value().exponent(), after.value().exponent());
|
||||
auto const vaultScale = broker.vaultScale(env);
|
||||
Number const tolerance{1, recipientScale};
|
||||
|
||||
BEAST_EXPECT(recipientScale > vaultScale);
|
||||
BEAST_EXPECT(received != expected);
|
||||
BEAST_EXPECT(
|
||||
abs(roundToAsset(asset, received, recipientScale) -
|
||||
roundToAsset(asset, expected, recipientScale)) <= tolerance);
|
||||
};
|
||||
|
||||
runCase(/*coarseBorrower=*/true);
|
||||
runCase(/*coarseBorrower=*/false);
|
||||
}
|
||||
|
||||
// Under featureLendingProtocolV1_1, ValidLoan::finalize enforces
|
||||
// TotalValueOutstanding >= PrincipalOutstanding + ManagementFeeOutstanding
|
||||
// ("interest due is non-negative"). This test drives the transactor
|
||||
// through a multi-payment scenario with a non-zero management fee and
|
||||
// messy IOU-scale rounding; if any rounding path in LoanPay were to
|
||||
// inflate PrincipalOutstanding or ManagementFeeOutstanding relative to
|
||||
// TotalValueOutstanding by even one ULP, the invariant would fire and
|
||||
// the LoanPay would return tecINVARIANT_FAILED instead of tesSUCCESS.
|
||||
void
|
||||
testLoanPayInterestDueNonNegativeInvariant()
|
||||
{
|
||||
testcase("LoanPay interest-due non-negative invariant");
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
Env env(*this, all_ | featureLendingProtocolV1_1);
|
||||
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
|
||||
PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower);
|
||||
|
||||
// Default broker params carry managementFeeRate = 100 tenth-bips
|
||||
// (1%), which is what makes managementFeeOutstanding accumulate
|
||||
// non-trivially through the payment schedule.
|
||||
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)};
|
||||
|
||||
auto const loanSetFee = Fee(env.current()->fees().base * 2);
|
||||
auto const loanKeylet = nextLoanKeylet(env, broker);
|
||||
|
||||
// Messy interest rate, non-trivial payment count. Values chosen so
|
||||
// that periodicPayment and each roundedInterest/managementFee share
|
||||
// are unlikely to be representable exactly at loanScale.
|
||||
env(set(borrower, broker.brokerID, Number{1'000}),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
kInterestRate(TenthBips32{24'346}),
|
||||
kPaymentTotal(24),
|
||||
kPaymentInterval(86400 * 30),
|
||||
loanSetFee);
|
||||
env.close();
|
||||
|
||||
auto const payFee = Fee(env.current()->fees().base * 2);
|
||||
// Boundary check up front on the freshly-created loan.
|
||||
{
|
||||
auto const initial = getCurrentState(env, broker, loanKeylet);
|
||||
BEAST_EXPECT(
|
||||
initial.totalValue >=
|
||||
initial.principalOutstanding + initial.managementFeeOutstanding);
|
||||
}
|
||||
|
||||
// Six regular scheduled payments. If the invariant fires the
|
||||
// Ter(tesSUCCESS) assertion below catches it; the identity check
|
||||
// then re-asserts it in the test for a clearer failure message.
|
||||
std::uint32_t prevPaymentRemaining = 24;
|
||||
for (int i = 0; i < 6; ++i)
|
||||
{
|
||||
auto const loanSle = env.le(loanKeylet);
|
||||
if (!BEAST_EXPECT(loanSle))
|
||||
return;
|
||||
// Match the amount LoanPay expects for a scheduled payment:
|
||||
// periodicPayment rounded at loanScale, plus the flat service
|
||||
// fee (0 here by default, but included for robustness).
|
||||
auto const payAmount = STAmount{
|
||||
iouAsset,
|
||||
roundPeriodicPayment(
|
||||
iouAsset, loanSle->at(sfPeriodicPayment), loanSle->at(sfLoanScale)) +
|
||||
loanSle->at(sfLoanServiceFee)};
|
||||
env(pay(borrower, loanKeylet.key, payAmount), payFee, Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const state = getCurrentState(env, broker, loanKeylet);
|
||||
BEAST_EXPECT(
|
||||
state.totalValue >= state.principalOutstanding + state.managementFeeOutstanding);
|
||||
BEAST_EXPECT(state.paymentRemaining == prevPaymentRemaining - 1);
|
||||
prevPaymentRemaining = state.paymentRemaining;
|
||||
}
|
||||
}
|
||||
|
||||
// Tests run under each entry in amendmentCombinations().
|
||||
void
|
||||
runAmendmentSensitive(FeatureBitset features)
|
||||
{
|
||||
testLoanPayComputePeriodicPaymentInvariants(features);
|
||||
testLoanPayDebtDecreaseInvariant(features);
|
||||
testLoanPayOverpaymentScheduleInvariant(features);
|
||||
testAccountSendMptMinAmountInvariant(features);
|
||||
testMinimumBrokerCoverConsistency(features);
|
||||
}
|
||||
@@ -865,6 +1109,8 @@ public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testLoanSetRecipientScaleInvariant();
|
||||
testLoanPayInterestDueNonNegativeInvariant();
|
||||
for (auto const& features : jtx::amendmentCombinations(
|
||||
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
|
||||
runAmendmentSensitive(features);
|
||||
|
||||
@@ -39,6 +39,8 @@ Vault::create(CreateArgs const& args) const
|
||||
jv[sfSubscriptionDate] = *args.subscriptionDate;
|
||||
if (args.redemptionDate)
|
||||
jv[sfRedemptionDate] = *args.redemptionDate;
|
||||
if (args.leVersion)
|
||||
jv[sfLEVersion] = std::to_underlying(*args.leVersion);
|
||||
return {jv, keylet};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
@@ -33,6 +34,8 @@ struct Vault
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<std::uint32_t> redemptionDate =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
std::optional<VaultVersion> leVersion =
|
||||
std::nullopt; // NOLINT(readability-redundant-member-init)
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user