Compare commits

...

4 Commits

Author SHA1 Message Date
Ayaz Salikhov
9a7c5ea593 chore: Bump version to 3.4.0-b3 (#8132) 2026-08-27 17:26:55 +00:00
Jingchen
7281e0606a feat: Add vault invariants (#7732)
Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-27 17:15:17 +00:00
Jingchen
71f5555873 feat: Remove pseudo account field filter (#8042)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-27 13:52:23 +00:00
Ayaz Salikhov
3967ed6d54 build: Update release-info to get better pkg_release (#8131) 2026-08-27 13:34:38 +00:00
28 changed files with 2083 additions and 63 deletions

View File

@@ -387,3 +387,4 @@ words:
- xxhasher
- zstdio
- CGNAT
- ungated

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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_;

View File

@@ -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

View File

@@ -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
{

View File

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

View File

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

View File

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

View File

@@ -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
;

View File

@@ -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:

View File

@@ -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.

View File

@@ -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;
}

View File

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

View File

@@ -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 ||

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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};
}

View File

@@ -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)
};
/**