mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-16 04:18:33 +00:00
Compare commits
97 Commits
dangell7/b
...
ripple/len
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e0841de4 | ||
|
|
f3a14b9953 | ||
|
|
97b6ff78ee | ||
|
|
a1bda4d560 | ||
|
|
aa40107606 | ||
|
|
9a49fa8cfe | ||
|
|
2df03ba770 | ||
|
|
25d0d0d138 | ||
|
|
966ce3bed8 | ||
|
|
6cd4b76603 | ||
|
|
8f95125d59 | ||
|
|
b3e8f927f6 | ||
|
|
e118f4cc30 | ||
|
|
b3facefc70 | ||
|
|
eb082ef43b | ||
|
|
b974e14f25 | ||
|
|
91d0fc5f7f | ||
|
|
efadaf6aa7 | ||
|
|
290b1a6d68 | ||
|
|
3b332f7838 | ||
|
|
5538aec034 | ||
|
|
442ed8dbce | ||
|
|
5a4c7b120f | ||
|
|
2daaacbbeb | ||
|
|
cccfd49c73 | ||
|
|
5c58f5ba76 | ||
|
|
641afdc56d | ||
|
|
94ccbfc705 | ||
|
|
9b94732836 | ||
|
|
f39fc07c0b | ||
|
|
5b6202db41 | ||
|
|
a72ca0761a | ||
|
|
03236cc07e | ||
|
|
9b8e5d6672 | ||
|
|
b7d5715c90 | ||
|
|
053bbf3004 | ||
|
|
f3aad20331 | ||
|
|
113427f4bb | ||
|
|
2f8443b578 | ||
|
|
11d4b614b7 | ||
|
|
ea9c8c0d1d | ||
|
|
6884d5181f | ||
|
|
ad745adaa2 | ||
|
|
79a0e98050 | ||
|
|
f048b2aa5c | ||
|
|
3b550b720e | ||
|
|
1d0332525c | ||
|
|
2d1af3dc2a | ||
|
|
37dbfc0447 | ||
|
|
67b0991fbc | ||
|
|
79ef10c529 | ||
|
|
039198e527 | ||
|
|
5fe8a38f1d | ||
|
|
31e4cfe213 | ||
|
|
c5a7ed2753 | ||
|
|
4cb015922a | ||
|
|
30890b8e19 | ||
|
|
8e453e8aad | ||
|
|
bf11ffec7a | ||
|
|
022f6e7414 | ||
|
|
33dbbc7a7e | ||
|
|
888f5be162 | ||
|
|
228e8fe560 | ||
|
|
e27db94891 | ||
|
|
019ae5b9cb | ||
|
|
c571eff3a8 | ||
|
|
f66ce70256 | ||
|
|
c7672d5ef7 | ||
|
|
fbda53aa8b | ||
|
|
59d4aff3b2 | ||
|
|
248076d387 | ||
|
|
ffa095d7fa | ||
|
|
6d3ed3b8a8 | ||
|
|
b77d5b8f4c | ||
|
|
2f2e306fcd | ||
|
|
38e7a6dd21 | ||
|
|
2417a911ac | ||
|
|
c95a1329ea | ||
|
|
1583772faf | ||
|
|
0db000bc97 | ||
|
|
ec8a9cdbf8 | ||
|
|
fa60adb9a6 | ||
|
|
7f687b49be | ||
|
|
f9bd3dabf5 | ||
|
|
398e504b58 | ||
|
|
8336f0473f | ||
|
|
7d02e36cb5 | ||
|
|
5895a7a6c8 | ||
|
|
1b2c77ab57 | ||
|
|
943933dd3b | ||
|
|
f8eef48483 | ||
|
|
cc7513a8ee | ||
|
|
0367116e44 | ||
|
|
f4cf782ded | ||
|
|
9101dd9fd4 | ||
|
|
f68f5c200a | ||
|
|
534df4169a |
@@ -387,3 +387,4 @@ words:
|
||||
- xxhasher
|
||||
- zstdio
|
||||
- CGNAT
|
||||
- ungated
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
namespace xrpl {
|
||||
@@ -353,14 +352,14 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey);
|
||||
*
|
||||
* The list is constructed during initialization and is const after that.
|
||||
* Pseudo-account designator fields MUST be maintained by including the
|
||||
* SField::sMD_PseudoAccount flag in the SField definition.
|
||||
* SField::kSmdPseudoAccount flag in the SField definition.
|
||||
*/
|
||||
[[nodiscard]] std::vector<SField const*> const&
|
||||
getPseudoAccountFields();
|
||||
|
||||
/**
|
||||
* Returns true if and only if sleAcct is a pseudo-account or specific
|
||||
* pseudo-accounts in pseudoFieldFilter.
|
||||
* Returns true if and only if sleAcct is a pseudo-account of any kind
|
||||
* (i.e. carries at least one field flagged with SField::kSmdPseudoAccount).
|
||||
*
|
||||
* Returns false if sleAcct is:
|
||||
* - NOT a pseudo-account OR
|
||||
@@ -368,18 +367,15 @@ getPseudoAccountFields();
|
||||
* - null pointer
|
||||
*/
|
||||
[[nodiscard]] bool
|
||||
isPseudoAccount(SLE::const_pointer sleAcct, std::set<SField const*> const& pseudoFieldFilter = {});
|
||||
isPseudoAccount(SLE::const_pointer sleAcct);
|
||||
|
||||
/**
|
||||
* Convenience overload that reads the account from the view.
|
||||
*/
|
||||
[[nodiscard]] inline bool
|
||||
isPseudoAccount(
|
||||
ReadView const& view,
|
||||
AccountID const& accountId,
|
||||
std::set<SField const*> const& pseudoFieldFilter = {})
|
||||
isPseudoAccount(ReadView const& view, AccountID const& accountId)
|
||||
{
|
||||
return isPseudoAccount(view.read(keylet::account(accountId)), pseudoFieldFilter);
|
||||
return isPseudoAccount(view.read(keylet::account(accountId)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,10 @@ 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
|
||||
* `DebtTotal` and `OwnerCount` are both zero.
|
||||
* 3. At most one `ltLOAN_BROKER` may be deleted in a single transaction.
|
||||
*
|
||||
*/
|
||||
class ValidLoanBroker
|
||||
@@ -36,6 +40,14 @@ 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;
|
||||
// the final pre-deletion state is captured so the deletion invariants can
|
||||
// inspect DebtTotal and OwnerCount.
|
||||
SLE::const_pointer deletedBroker_ = nullptr;
|
||||
// Set if visitEntry observes more than one ltLOAN_BROKER deletion in the
|
||||
// same transaction. Enforced as its own invariant in finalize.
|
||||
bool multipleBrokerDeletions_ = false;
|
||||
// Collect all the modified trust lines. Their high and low accounts will be
|
||||
// loaded to look for LoanBroker pseudo-accounts.
|
||||
std::vector<SLE::const_pointer> lines_;
|
||||
|
||||
@@ -15,9 +15,25 @@ 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 this check
|
||||
* is enforced in `InvariantChecks.cpp`.
|
||||
* 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.
|
||||
* 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`.
|
||||
*
|
||||
*/
|
||||
class ValidLoan
|
||||
@@ -25,6 +41,8 @@ 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 (final state captured at deletion).
|
||||
std::vector<SLE::const_pointer> deletedLoans_;
|
||||
|
||||
public:
|
||||
void
|
||||
|
||||
@@ -48,7 +48,10 @@ namespace xrpl {
|
||||
* vault phase is Investment
|
||||
*
|
||||
* Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced
|
||||
* by NoModifiedUnmodifiableFields (see InvariantCheck.cpp).
|
||||
* by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). From
|
||||
* featureLendingProtocolV1_1 onwards, immutability of the vault's Asset,
|
||||
* pseudo-account and ShareMPTID is likewise enforced by
|
||||
* NoModifiedUnmodifiableFields; prior to that amendment it is checked here.
|
||||
*/
|
||||
class ValidVault
|
||||
{
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
@@ -515,8 +514,8 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey)
|
||||
}
|
||||
|
||||
// Pseudo-account designator fields MUST be maintained by including the
|
||||
// SField::sMD_PseudoAccount flag in the SField definition. (Don't forget to
|
||||
// "| SField::sMD_Default"!) The fields do NOT need to be amendment-gated,
|
||||
// SField::kSmdPseudoAccount flag in the SField definition. (Don't forget to
|
||||
// "| SField::kSmdDefault"!) The fields do NOT need to be amendment-gated,
|
||||
// since a non-active amendment will not set any field, by definition.
|
||||
// Specific properties of a pseudo-account are NOT checked here, that's what
|
||||
// InvariantCheck is for.
|
||||
@@ -547,18 +546,14 @@ getPseudoAccountFields()
|
||||
}
|
||||
|
||||
[[nodiscard]] bool
|
||||
isPseudoAccount(SLE::const_pointer sleAcct, std::set<SField const*> const& pseudoFieldFilter)
|
||||
isPseudoAccount(SLE::const_pointer sleAcct)
|
||||
{
|
||||
auto const& fields = getPseudoAccountFields();
|
||||
|
||||
// Intentionally use defensive coding here because it's cheap and makes the
|
||||
// semantics of true return value clean.
|
||||
return sleAcct && sleAcct->getType() == ltACCOUNT_ROOT &&
|
||||
std::count_if(
|
||||
fields.begin(), fields.end(), [&sleAcct, &pseudoFieldFilter](SField const* sf) -> bool {
|
||||
return sleAcct->isFieldPresent(*sf) &&
|
||||
(pseudoFieldFilter.empty() || pseudoFieldFilter.contains(sf));
|
||||
}) > 0;
|
||||
std::ranges::any_of(getPseudoAccountFields(), [&sleAcct](SField const* sf) {
|
||||
return sleAcct->isFieldPresent(*sf);
|
||||
});
|
||||
}
|
||||
|
||||
std::expected<SLE::pointer, TER>
|
||||
|
||||
@@ -384,8 +384,7 @@ requireAuth(
|
||||
// They are implicitly authorized for any MPT they hold, including vault shares whose
|
||||
// underlying asset would otherwise require auth.
|
||||
auto const isPseudoAccountExempt = [&] {
|
||||
return (featureSAVEnabled || featureMPTV2Enabled) &&
|
||||
isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID, &sfAMMID});
|
||||
return (featureSAVEnabled || featureMPTV2Enabled) && isPseudoAccount(view, account);
|
||||
};
|
||||
|
||||
auto const mptID = keylet::mptokenIssuance(mptIssue.getMptID());
|
||||
|
||||
@@ -1098,6 +1098,7 @@ ValidPseudoAccounts::finalize(
|
||||
if (enforce)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1123,10 +1124,21 @@ NoModifiedUnmodifiableFields::finalize(
|
||||
ReadView const& view,
|
||||
beast::Journal const& j)
|
||||
{
|
||||
static auto const kFieldChanged = [](auto const& before, auto const& after, auto const& field) {
|
||||
// Non-static so it can capture `j` and `tx` and emit the specific field
|
||||
// name on detection. The outer `bad || ...` chain short-circuits after the
|
||||
// first change is seen, so only the first offending field is logged per
|
||||
// entry - enough for diagnosis, and cheaper than accumulating all names.
|
||||
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 +1184,44 @@ NoModifiedUnmodifiableFields::finalize(
|
||||
kFieldChanged(before, after, sfPaymentInterval) ||
|
||||
kFieldChanged(before, after, sfGracePeriod) ||
|
||||
kFieldChanged(before, after, sfLoanScale);
|
||||
|
||||
// lsfLoanOverpayment is immutable after creation. Check it ungated, as
|
||||
// LoanInvariant did before V1_1; the enclosing featureLendingProtocol gate suffices
|
||||
// because only that amendment creates ltLOAN entries. lsfLoanDefault may change
|
||||
// only from unset to set through tfLoanDefault. Under V1_1, reject attempts to
|
||||
// clear it, matching LoanInvariant's previous gate.
|
||||
{
|
||||
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.
|
||||
* Immutability of sfAccount, sfAsset and sfShareMPTID used to be enforced by
|
||||
* VaultInvariant, but is now checked here since InvariantCheck.cpp is where
|
||||
* immutability checks live. The additional fields below are introduced by
|
||||
* featureLendingProtocolV1_1 and only exist on V1_1 vaults.
|
||||
*/
|
||||
if (view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
@@ -1190,7 +1233,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:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <xrpl/basics/Log.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/TokenHelpers.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
@@ -22,6 +23,38 @@ 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` holds the entry's state prior to erasure.
|
||||
//
|
||||
// Deleted trust lines/MPTokens are recorded (from `before`) so finalize() can find the broker
|
||||
// via its pseudo-account and check CoverAvailable still matches the pseudo-account balance.
|
||||
if (isDelete && before)
|
||||
{
|
||||
switch (before->getType())
|
||||
{
|
||||
case ltLOAN_BROKER:
|
||||
if (deletedBroker_)
|
||||
{
|
||||
multipleBrokerDeletions_ = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
deletedBroker_ = before;
|
||||
}
|
||||
break;
|
||||
case ltRIPPLE_STATE:
|
||||
lines_.emplace_back(before);
|
||||
break;
|
||||
case ltMPTOKEN:
|
||||
mpts_.emplace_back(before);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (after)
|
||||
{
|
||||
if (after->getType() == ltLOAN_BROKER)
|
||||
@@ -99,6 +132,44 @@ 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 DebtTotal and
|
||||
// OwnerCount are both zero. 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;
|
||||
}
|
||||
if (deletedBroker_->at(sfDebtTotal) != 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 +213,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.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/ReadView.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Protocol.h>
|
||||
@@ -13,6 +14,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 +24,13 @@ namespace xrpl {
|
||||
void
|
||||
ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
|
||||
{
|
||||
if (after && after->getType() == ltLOAN)
|
||||
if (isDelete)
|
||||
{
|
||||
// `before` holds the loan's state before deletion.
|
||||
if (before && before->getType() == ltLOAN)
|
||||
deletedLoans_.emplace_back(before);
|
||||
}
|
||||
else if (after && after->getType() == ltLOAN)
|
||||
{
|
||||
loans_.emplace_back(before, after);
|
||||
}
|
||||
@@ -39,6 +47,10 @@ 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);
|
||||
|
||||
// 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 +103,10 @@ 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)))
|
||||
|
||||
// The flag immutability check has been moved to InvariantChecks.cpp
|
||||
if (!lpV11Enabled && before &&
|
||||
(before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment)))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed";
|
||||
return false;
|
||||
@@ -123,6 +138,79 @@ ValidLoan::finalize(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (lpV11Enabled)
|
||||
{
|
||||
// Only LoanSet may create a loan. This is an object-existence rule, not
|
||||
// a transaction post-condition, so it applies even when apply failed.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Interest due (the total value owed less principal and management fee)
|
||||
// must never be negative.
|
||||
if (after->at(sfTotalValueOutstanding) - after->at(sfPrincipalOutstanding) -
|
||||
after->at(sfManagementFeeOutstanding) <
|
||||
beast::kZero)
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative";
|
||||
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;
|
||||
}
|
||||
if (!view.read(keylet::vault(brokerSle->at(sfVaultID))))
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan broker vault does not exist";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deletion by the wrong transaction is an invalid object transition even
|
||||
// when apply failed, so check it before the success-only post-conditions.
|
||||
if (lpV11Enabled && txType != ttLOAN_DELETE && !deletedLoans_.empty())
|
||||
{
|
||||
JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction "
|
||||
"other than LoanDelete";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -847,7 +847,7 @@ ValidMPTTransfer::isAuthorized(
|
||||
// auth. Exempt them here rather than relying on requireAuth: the recursive
|
||||
// share -> underlying descent in requireAuth fails for a pseudo-account
|
||||
// that holds the share but not the underlying.
|
||||
if (isPseudoAccount(view, holder, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
|
||||
if (isPseudoAccount(view, holder))
|
||||
return true;
|
||||
|
||||
auto const key = keylet::mptoken(mptid, holder);
|
||||
|
||||
@@ -515,7 +515,8 @@ ValidVault::finalize(
|
||||
bool result = true;
|
||||
|
||||
// Universal transaction checks
|
||||
if (!beforeVault_.empty())
|
||||
// From LendingProtocolV1_1 onwards, vault immutability check is moved to InvariantCheck.cpp
|
||||
if (!beforeVault_.empty() && !view.rules().enabled(featureLendingProtocolV1_1))
|
||||
{
|
||||
auto const& beforeVault = beforeVault_[0];
|
||||
if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId ||
|
||||
|
||||
@@ -150,7 +150,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx)
|
||||
// always authorized. No need to amendment gate since Vault and LoanBroker
|
||||
// can only be created if the Vault amendment is enabled; AMM with MPToken asset
|
||||
// can only be created if MPTokensV2 is enabled.
|
||||
if (isPseudoAccount(ctx.view, *holderID, {&sfVaultID, &sfLoanBrokerID, &sfAMMID}))
|
||||
if (isPseudoAccount(ctx.view, *holderID))
|
||||
return tecNO_PERMISSION;
|
||||
|
||||
return tesSUCCESS;
|
||||
|
||||
@@ -237,6 +237,7 @@ VaultClawback::assetsToClawback(
|
||||
AccountID const& holder,
|
||||
STAmount const& clawbackAmount)
|
||||
{
|
||||
bool const fix340Enabled = ctx_.view().rules().enabled(fixCleanup3_4_0);
|
||||
if (clawbackAmount.asset() != vault->at(sfAsset))
|
||||
{
|
||||
// preclaim should have blocked this , now it's an internal error
|
||||
@@ -271,14 +272,32 @@ VaultClawback::assetsToClawback(
|
||||
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
|
||||
try
|
||||
{
|
||||
// Do not discount a sole holder's shares: clawing back AssetsAvailable
|
||||
// at the discounted rate can burn every share while loan assets remain.
|
||||
auto const waiveUnrealizedLoss =
|
||||
fix340Enabled && isSoleShareholder(view(), holder, sleShareIssuance)
|
||||
? WaiveUnrealizedLoss::Yes
|
||||
: WaiveUnrealizedLoss::No;
|
||||
|
||||
if (clawbackAmount == beast::kZero)
|
||||
{
|
||||
// Zero amount means clawback all shares the holder has; derive the corresponding asset
|
||||
// amount from the share balance.
|
||||
sharesDestroyed = accountHolds(
|
||||
view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
// isSoleShareholder already established that the holder owns the
|
||||
// entire outstanding share supply whenever the waiver applies, so
|
||||
// sfOutstandingAmount gives sharesDestroyed directly, avoiding a
|
||||
// redundant MPToken read via accountHolds.
|
||||
sharesDestroyed = waiveUnrealizedLoss == WaiveUnrealizedLoss::Yes
|
||||
? STAmount{share, sleShareIssuance->at(sfOutstandingAmount)}
|
||||
: accountHolds(
|
||||
view(),
|
||||
holder,
|
||||
share,
|
||||
FreezeHandling::IgnoreFreeze,
|
||||
AuthHandling::IgnoreAuth,
|
||||
j_);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
|
||||
@@ -291,16 +310,15 @@ VaultClawback::assetsToClawback(
|
||||
// Post-amendment: truncate shares so assetsRecovered <=
|
||||
// clawbackAmount by construction (matches the clamp branch
|
||||
// below).
|
||||
auto const truncate = ctx_.view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes
|
||||
: TruncateShares::No;
|
||||
auto const maybeShares =
|
||||
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount, truncate);
|
||||
auto const truncate = fix340Enabled ? TruncateShares::Yes : TruncateShares::No;
|
||||
auto const maybeShares = assetsToSharesWithdraw(
|
||||
vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss);
|
||||
if (!maybeShares)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
sharesDestroyed = *maybeShares;
|
||||
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
assetsRecovered = *maybeAssets;
|
||||
@@ -312,14 +330,18 @@ VaultClawback::assetsToClawback(
|
||||
assetsRecovered = *assetsAvailable;
|
||||
{
|
||||
auto const maybeShares = assetsToSharesWithdraw(
|
||||
vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes);
|
||||
vault,
|
||||
sleShareIssuance,
|
||||
assetsRecovered,
|
||||
TruncateShares::Yes,
|
||||
waiveUnrealizedLoss);
|
||||
if (!maybeShares)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
sharesDestroyed = *maybeShares;
|
||||
}
|
||||
|
||||
auto const maybeAssets =
|
||||
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
|
||||
auto const maybeAssets = sharesToAssetsWithdraw(
|
||||
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
|
||||
if (!maybeAssets)
|
||||
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
|
||||
assetsRecovered = *maybeAssets;
|
||||
|
||||
@@ -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,32 @@ 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 read unconditionally by ValidLoan::finalize.
|
||||
sleLoan->at(sfLoanServiceFee) = Number(0);
|
||||
sleLoan->at(sfLatePaymentFee) = Number(0);
|
||||
sleLoan->at(sfClosePaymentFee) = Number(0);
|
||||
sleLoan->at(sfPrincipalOutstanding) = Number(0);
|
||||
sleLoan->at(sfTotalValueOutstanding) = Number(0);
|
||||
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 0);
|
||||
sleLoan->makeFieldPresent(sfOwnerNode);
|
||||
sleLoan->makeFieldPresent(sfLoanBrokerNode);
|
||||
return sleLoan;
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
#include <test/jtx/Env.h>
|
||||
#include <test/jtx/amount.h>
|
||||
|
||||
#include <xrpld/app/main/Application.h>
|
||||
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
@@ -14,6 +18,7 @@
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
@@ -117,6 +122,13 @@ protected:
|
||||
|
||||
Keylet
|
||||
createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset);
|
||||
|
||||
// Build an ltLOAN SLE with every SoeRequired field explicitly set and
|
||||
// every SoeDefault field the invariants read via `at()` materialized, so
|
||||
// rawInsert-based tests don't accidentally trip an unrelated invariant
|
||||
// or throw from a missing SoeDefault field.
|
||||
static SLE::pointer
|
||||
makeLoanSle(uint256 const& loanBrokerID, std::uint32_t loanSeq, AccountID const& borrower);
|
||||
};
|
||||
|
||||
} // namespace xrpl::test
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#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 +44,7 @@
|
||||
#include <xrpl/tx/invariants/InvariantRunner.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
#include <memory>
|
||||
@@ -757,7 +759,229 @@ 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 the apply-view modification is
|
||||
// seen as a before/after change; the shared harness cannot seed one,
|
||||
// hence the bespoke view construction below.
|
||||
{
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
// ValidLoan::finalize enforces (under featureLendingProtocolV1_1) that
|
||||
// interest due - TotalValueOutstanding minus PrincipalOutstanding minus
|
||||
// ManagementFeeOutstanding - is never negative. Any rounding path in
|
||||
// LoanPay / LoanManage that rounds Principal or ManagementFee up while
|
||||
// rounding TotalValue down (or vice-versa) by a single ULP flips this
|
||||
// negative and would halt the ledger. Exercise each of the three
|
||||
// components at a one-ULP overshoot to cover the boundary explicitly,
|
||||
// plus the exact-zero case to confirm the boundary itself is
|
||||
// accepted.
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
Number totalValue;
|
||||
Number principal;
|
||||
Number managementFee;
|
||||
bool expectFire;
|
||||
};
|
||||
// Baseline: Principal=100, TotalValue=100, MgmtFee=0
|
||||
// (interest due = 0, exactly at the boundary). Each firing case
|
||||
// perturbs one component by -1 or +1 so interest_due = -1.
|
||||
auto const cases = std::to_array<Case>({
|
||||
{.totalValue = Number(100),
|
||||
.principal = Number(100),
|
||||
.managementFee = Number(0),
|
||||
.expectFire = false},
|
||||
{.totalValue = Number(99),
|
||||
.principal = Number(100),
|
||||
.managementFee = Number(0),
|
||||
.expectFire = true},
|
||||
{.totalValue = Number(100),
|
||||
.principal = Number(101),
|
||||
.managementFee = Number(0),
|
||||
.expectFire = true},
|
||||
{.totalValue = Number(100),
|
||||
.principal = Number(100),
|
||||
.managementFee = Number(1),
|
||||
.expectFire = true},
|
||||
});
|
||||
|
||||
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));
|
||||
// Seed a loan whose interest due sits at the boundary
|
||||
// (100 - 100 - 0 = 0). 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->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 (c.expectFire)
|
||||
{
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(messages.contains("Loan interest due is negative"));
|
||||
}
|
||||
else
|
||||
{
|
||||
// The boundary case (interest due == 0) must not trip the
|
||||
// interest-due check. Other invariants may still fire
|
||||
// (e.g. the broker-existence check on this raw-inserted
|
||||
// loan), so only assert the specific message is absent.
|
||||
BEAST_EXPECT(!messages.contains("Loan interest due is negative"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
|
||||
// Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.
|
||||
|
||||
@@ -7,18 +7,23 @@
|
||||
#include <test/jtx/pay.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
#include <test/unit_test/SuiteJournal.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/OpenView.h>
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/Keylet.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
#include <xrpl/protocol/Rules.h>
|
||||
#include <xrpl/protocol/SField.h>
|
||||
#include <xrpl/protocol/STLedgerEntry.h>
|
||||
#include <xrpl/protocol/STObject.h>
|
||||
@@ -28,6 +33,7 @@
|
||||
#include <xrpl/protocol/TxFormats.h>
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/Transactor.h>
|
||||
#include <xrpl/tx/applySteps.h>
|
||||
|
||||
#include <array>
|
||||
@@ -43,6 +49,8 @@ namespace xrpl::test {
|
||||
|
||||
class InvariantsPseudoAccount_test : public InvariantsBase
|
||||
{
|
||||
FeatureBitset const all_{test::jtx::testableAmendments()};
|
||||
|
||||
void
|
||||
testValidPseudoAccounts()
|
||||
{
|
||||
@@ -445,6 +453,245 @@ class InvariantsPseudoAccount_test : public InvariantsBase
|
||||
STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
createLoanBroker);
|
||||
|
||||
// A holding (trust line) removed while the broker still reports
|
||||
// non-zero CoverAvailable must not bypass the cover invariant. The
|
||||
// broker is left untouched, so it is only reachable through the
|
||||
// deleted holding's before-state; erasing the holding drops the
|
||||
// pseudo-account balance to zero while CoverAvailable stays
|
||||
// positive.
|
||||
//
|
||||
// Only the IOU (trust line) case is exercised here. XRP cover has
|
||||
// no holding SLE to delete, and an MPToken cannot be erased in
|
||||
// isolation: the ungated ValidMPTIssuance "a MPToken was deleted"
|
||||
// check fires for any transaction lacking MayDeleteMpt, and every
|
||||
// transaction that carries that privilege also carries a Must*
|
||||
// privilege (delete an account / modify a vault) that trips a
|
||||
// different invariant. A trust line has no such generic deletion
|
||||
// invariant, so it isolates the cover check cleanly. The RippleState
|
||||
// and MPToken discovery paths are otherwise symmetric in finalize.
|
||||
//
|
||||
// The cover-greater-than-balance invariant is gated behind
|
||||
// fixCleanup3_1_3, so the same forced deletion is run under both
|
||||
// rule sets: with the amendment the invariant fires, without it the
|
||||
// state is silently accepted.
|
||||
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"));
|
||||
}
|
||||
|
||||
// A LoanBrokerDelete must not remove a broker whose pre-transaction
|
||||
// OwnerCount is non-zero. DebtTotal is left at zero so the earlier
|
||||
// check passes and the OwnerCount check is what fires.
|
||||
{
|
||||
Env env{*this};
|
||||
Account const a1{"A1"};
|
||||
Account const a2{"A2"};
|
||||
env.fund(XRP(1000), a1, a2);
|
||||
env.close();
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
|
||||
if (!BEAST_EXPECT(env.le(brokerKeylet)))
|
||||
return;
|
||||
env.close();
|
||||
|
||||
OpenView ov{*env.current()};
|
||||
|
||||
{
|
||||
auto const sleBrokerRead = ov.read(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBrokerRead))
|
||||
return;
|
||||
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
|
||||
sleBroker->at(sfOwnerCount) = 1;
|
||||
ov.rawReplace(sleBroker);
|
||||
}
|
||||
|
||||
STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
|
||||
test::StreamSink sink{beast::Severity::Warning};
|
||||
beast::Journal const jlog{sink};
|
||||
ApplyContext ac{
|
||||
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
|
||||
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
|
||||
|
||||
auto sleBroker = ac.view().peek(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return;
|
||||
ac.view().erase(sleBroker);
|
||||
|
||||
auto transactor = makeTransactor(ac);
|
||||
if (!BEAST_EXPECT(transactor))
|
||||
return;
|
||||
TER const result = transactor->checkInvariants(
|
||||
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
|
||||
BEAST_EXPECT(result == tecINVARIANT_FAILED);
|
||||
BEAST_EXPECT(
|
||||
sink.messages().str().contains("Loan Broker deleted with non-zero owner count"));
|
||||
}
|
||||
|
||||
// Only one LoanBroker may be deleted per transaction. Create two
|
||||
// brokers under different owners, then erase both in the apply view
|
||||
// and expect the multi-deletion invariant to fire.
|
||||
{
|
||||
Keylet loanBrokerKeylet1 = keylet::amendments();
|
||||
Keylet loanBrokerKeylet2 = keylet::amendments();
|
||||
Preclose const createTwoBrokers = [&, this](
|
||||
Account const& a1, Account const& a2, Env& env) {
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
loanBrokerKeylet1 = this->createLoanBroker(a1, env, xrpAsset);
|
||||
loanBrokerKeylet2 = this->createLoanBroker(a2, env, xrpAsset);
|
||||
return BEAST_EXPECT(env.le(loanBrokerKeylet1) && env.le(loanBrokerKeylet2));
|
||||
};
|
||||
|
||||
doInvariantCheck(
|
||||
{{"more than one Loan Broker deleted in a single transaction"}},
|
||||
[&](Account const&, Account const&, ApplyContext& ac) {
|
||||
auto sle1 = ac.view().peek(loanBrokerKeylet1);
|
||||
auto sle2 = ac.view().peek(loanBrokerKeylet2);
|
||||
if (!BEAST_EXPECT(sle1 && sle2))
|
||||
return false;
|
||||
ac.view().erase(sle1);
|
||||
ac.view().erase(sle2);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
|
||||
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
|
||||
createTwoBrokers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
#include <test/jtx/sig.h>
|
||||
#include <test/jtx/trust.h>
|
||||
#include <test/jtx/vault.h>
|
||||
#include <test/unit_test/SuiteJournal.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/base_uint.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/beast/utility/Zero.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/OpenView.h>
|
||||
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
|
||||
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
@@ -38,10 +42,12 @@
|
||||
#include <xrpl/protocol/XRPAmount.h>
|
||||
#include <xrpl/protocol/jss.h>
|
||||
#include <xrpl/tx/ApplyContext.h>
|
||||
#include <xrpl/tx/Transactor.h>
|
||||
#include <xrpl/tx/applySteps.h>
|
||||
#include <xrpl/tx/invariants/VaultInvariant.h>
|
||||
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <initializer_list>
|
||||
@@ -68,6 +74,19 @@ class InvariantsVault_test : public InvariantsBase
|
||||
AccountID account;
|
||||
int amount;
|
||||
};
|
||||
// Parameters for a synthetic loan object created alongside a vault
|
||||
// adjustment. The interest due booked to the vault is
|
||||
// totalValueOutstanding - principalOutstanding - managementFeeOutstanding.
|
||||
struct LoanParams
|
||||
{
|
||||
int principalOutstanding = 0;
|
||||
int totalValueOutstanding = 0;
|
||||
int managementFeeOutstanding = 0;
|
||||
AccountID borrower = beast::kZero;
|
||||
// Real broker key the created loan should reference. If unset,
|
||||
// the vault key is used as a fallback for legacy callers.
|
||||
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;
|
||||
// If the caller supplied a broker key, use it; otherwise fall
|
||||
// back to the vault key so pre-existing callers keep working.
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -217,6 +260,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 +653,67 @@ class InvariantsVault_test : public InvariantsBase
|
||||
precloseXrp,
|
||||
TxAccount::A2);
|
||||
|
||||
// Under featureLendingProtocolV1_1 vault immutability is enforced by
|
||||
// NoModifiedUnmodifiableFields (class-1, both passes), which reports
|
||||
// "changed an unchangeable field" and escalates to tef on pass 2.
|
||||
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);
|
||||
|
||||
// Pre-featureLendingProtocolV1_1 the immutability of sfAsset, sfAccount
|
||||
// and sfShareMPTID is enforced by ValidVault directly, which reports
|
||||
// "violation of vault immutable data" on the first pass. ValidVault
|
||||
// returns early on the second pass (result already tec), so the check
|
||||
// does not escalate to tef. Once featureLendingProtocolV1_1 activates, the same fields are
|
||||
// covered by NoModifiedUnmodifiableFields (see the three cases above);
|
||||
// the two paths are mutually exclusive so both need coverage.
|
||||
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 +730,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 +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()));
|
||||
@@ -698,9 +815,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 +928,650 @@ 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_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 object is created directly with
|
||||
// principal 100, total value 90 and management fee 0, so interest due
|
||||
// = 90 - 100 - 0 = -10 (< 0)
|
||||
// while every individual field stays non-negative.
|
||||
doInvariantCheck(
|
||||
{"Loan interest due is negative"},
|
||||
[&](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(90);
|
||||
sleLoan->setFieldU32(sfPaymentRemaining, 1);
|
||||
ac.view().insert(sleLoan);
|
||||
return true;
|
||||
},
|
||||
XRPAmount{},
|
||||
loanSetTx);
|
||||
|
||||
// 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 +2671,7 @@ class InvariantsVault_test : public InvariantsBase
|
||||
// Synthesize a Loan whose final scheduled payment lands
|
||||
// exactly at RedemptionDate: StartDate = red, interval = 60,
|
||||
// remaining = 1 => red + 60 >= red.
|
||||
auto sleLoan = std::make_shared<SLE>(
|
||||
keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq)));
|
||||
auto sleLoan = makeLoanSle(closedEndedBrokerKeylet.key, loanSeq, a1.id());
|
||||
sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
|
||||
sleLoan->at(sfLoanSequence) = loanSeq;
|
||||
sleLoan->at(sfBorrower) = a1.id();
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
#include <xrpl/protocol/Units.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
@@ -383,6 +385,90 @@ private:
|
||||
isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale));
|
||||
}
|
||||
|
||||
// non-full-payment invariant asserts that a successful LoanPay strictly
|
||||
// decreases PaymentRemaining and advances NextPaymentDueDate. doPayment
|
||||
// deliberately leaves those schedule fields unchanged for
|
||||
// PaymentSpecialCase::Extra (an overpayment), so the concern is that an
|
||||
// "extra-only" overpayment - one that does not also cover a scheduled
|
||||
// payment - would reach that branch and trip the invariant.
|
||||
//
|
||||
// The invariant is gated behind featureLendingProtocolV1_1, which
|
||||
// LoanTestBase::all_ excludes, so it is opted back in here.
|
||||
void
|
||||
testLoanPayOverpaymentScheduleInvariant(FeatureBitset features)
|
||||
{
|
||||
testcase("LoanPay overpayment vs non-full-payment invariant");
|
||||
|
||||
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. The invariant
|
||||
// is therefore never reached. Were the payment to succeed while touching only principal,
|
||||
// the invariant would fire instead.
|
||||
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);
|
||||
|
||||
// This reaches the 3.11.5 invariant with tesSUCCESS: PaymentRemaining drops by one,
|
||||
// NextPaymentDueDate advances by one interval, and PrincipalOutstanding strictly decreases
|
||||
// (by more than a plain payment thanks to the extra). The invariant must accept it.
|
||||
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 +937,95 @@ 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);
|
||||
}
|
||||
|
||||
// Tests run under each entry in amendmentCombinations().
|
||||
void
|
||||
runAmendmentSensitive(FeatureBitset features)
|
||||
{
|
||||
testLoanPayComputePeriodicPaymentInvariants(features);
|
||||
testLoanPayDebtDecreaseInvariant(features);
|
||||
testLoanPayOverpaymentScheduleInvariant(features);
|
||||
testAccountSendMptMinAmountInvariant(features);
|
||||
testMinimumBrokerCoverConsistency(features);
|
||||
}
|
||||
@@ -865,6 +1034,7 @@ public:
|
||||
void
|
||||
run() override
|
||||
{
|
||||
testLoanSetRecipientScaleInvariant();
|
||||
for (auto const& features : jtx::amendmentCombinations(
|
||||
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
|
||||
runAmendmentSensitive(features);
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
#include <test/jtx/vault.h>
|
||||
|
||||
#include <xrpl/basics/Number.h>
|
||||
#include <xrpl/basics/chrono.h>
|
||||
#include <xrpl/beast/unit_test/suite.h>
|
||||
#include <xrpl/json/json_forwards.h>
|
||||
#include <xrpl/json/json_value.h>
|
||||
#include <xrpl/ledger/helpers/VaultHelpers.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
@@ -30,6 +32,7 @@
|
||||
#include <xrpl/protocol/SeqProxy.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/protocol/TxFlags.h>
|
||||
#include <xrpl/protocol/UintTypes.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
@@ -1460,6 +1463,217 @@ private:
|
||||
BEAST_EXPECT(ownerCount(env, attacker) == 0);
|
||||
}
|
||||
|
||||
struct ImpairedLoanVault
|
||||
{
|
||||
test::jtx::Account issuer;
|
||||
test::jtx::Account holder;
|
||||
PrettyAsset usd;
|
||||
test::jtx::Vault vault;
|
||||
Keylet vaultKeylet;
|
||||
MPTID shareId;
|
||||
};
|
||||
|
||||
// Impairing a 1,000 loan in a 10,000 vault leaves AssetsAvailable=9,000
|
||||
// and AssetsTotal=10,000. otherDeposit > 0 splits the shares, 0 leaves
|
||||
// holder as the sole shareholder.
|
||||
std::optional<ImpairedLoanVault>
|
||||
makeImpairedLoanVault(test::jtx::Env& env, int otherDeposit)
|
||||
{
|
||||
using namespace test::jtx;
|
||||
using namespace loan_broker;
|
||||
using namespace loan;
|
||||
|
||||
Account const issuer{"issuer"};
|
||||
Account const owner{"owner"};
|
||||
Account const holder{"holder"};
|
||||
Account const other{"other"};
|
||||
Account const borrower{"borrower"};
|
||||
|
||||
env.fund(XRP(100'000), issuer, owner, holder, other, borrower);
|
||||
env.close();
|
||||
|
||||
env(fset(issuer, asfAllowTrustLineClawback));
|
||||
env(fset(issuer, asfDefaultRipple));
|
||||
env.close();
|
||||
|
||||
PrettyAsset const usd = issuer["USD"];
|
||||
env.trust(usd(100'000), owner);
|
||||
env.trust(usd(100'000), holder);
|
||||
env.trust(usd(100'000), other);
|
||||
env.trust(usd(100'000), borrower);
|
||||
env.close();
|
||||
|
||||
int const holderDeposit = 10'000 - otherDeposit;
|
||||
env(pay(issuer, holder, usd(holderDeposit)));
|
||||
if (otherDeposit != 0)
|
||||
{
|
||||
env(pay(issuer, other, usd(otherDeposit)));
|
||||
}
|
||||
env.close();
|
||||
|
||||
Vault const vault{env};
|
||||
auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
|
||||
{.owner = owner, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}});
|
||||
env(createTx);
|
||||
env.close();
|
||||
|
||||
auto const vaultSle = env.le(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vaultSle))
|
||||
return std::nullopt;
|
||||
MPTID const shareId = vaultSle->at(sfShareMPTID);
|
||||
|
||||
env(vault.deposit(
|
||||
{.depositor = holder, .id = vaultKeylet.key, .amount = usd(holderDeposit)}));
|
||||
if (otherDeposit != 0)
|
||||
{
|
||||
env(vault.deposit(
|
||||
{.depositor = other, .id = vaultKeylet.key, .amount = usd(otherDeposit)}));
|
||||
}
|
||||
env.close();
|
||||
|
||||
vault.closePastSubscription(subscriptionDate);
|
||||
|
||||
auto const brokerKeylet =
|
||||
keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
|
||||
env(set(owner, vaultKeylet.key));
|
||||
env.close();
|
||||
|
||||
auto const sleBroker = env.le(brokerKeylet);
|
||||
if (!BEAST_EXPECT(sleBroker))
|
||||
return std::nullopt;
|
||||
auto const loanKeylet =
|
||||
keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
|
||||
|
||||
env(set(borrower, brokerKeylet.key, usd(1'000).value()),
|
||||
loan::kInterestRate(percentageToTenthBips(0)),
|
||||
kGracePeriod(60),
|
||||
kPaymentInterval(120),
|
||||
kPaymentTotal(10),
|
||||
Sig(sfCounterpartySignature, owner),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
// Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with
|
||||
// tecTOO_SOON unless the payment is already late; advance the ledger
|
||||
// past sfNextPaymentDueDate so impairment succeeds. No-op otherwise.
|
||||
if (env.current()->rules().enabled(fixCleanup3_4_0))
|
||||
{
|
||||
auto const loanBefore = env.le(loanKeylet);
|
||||
if (!BEAST_EXPECT(loanBefore))
|
||||
return std::nullopt;
|
||||
std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate);
|
||||
env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
|
||||
}
|
||||
|
||||
env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultAfter = env.le(vaultKeylet);
|
||||
if (!BEAST_EXPECT(vaultAfter))
|
||||
return std::nullopt;
|
||||
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == usd(9'000).value());
|
||||
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == usd(1'000).value());
|
||||
|
||||
return ImpairedLoanVault{
|
||||
.issuer = issuer,
|
||||
.holder = holder,
|
||||
.usd = usd,
|
||||
.vault = vault,
|
||||
.vaultKeylet = vaultKeylet,
|
||||
.shareId = shareId};
|
||||
}
|
||||
|
||||
// Legacy clawback pricing burns every share; fixCleanup3_4_0 leaves 10%
|
||||
// outstanding, backed by the impaired receivable.
|
||||
void
|
||||
testBugClawbackAfterLoanImpair()
|
||||
{
|
||||
using namespace test::jtx;
|
||||
|
||||
auto clawbackHolder = [](ImpairedLoanVault const& setup, STAmount const& amount) {
|
||||
return setup.vault.clawback(
|
||||
{.issuer = setup.issuer,
|
||||
.id = setup.vaultKeylet.key,
|
||||
.holder = setup.holder,
|
||||
.amount = amount});
|
||||
};
|
||||
|
||||
auto runSole = [this, &clawbackHolder](FeatureBitset features, TER expected) {
|
||||
testcase(
|
||||
features[fixCleanup3_4_0]
|
||||
? "VaultClawback after impaired loan (post-fixCleanup3_4_0)"
|
||||
: "VaultClawback after impaired loan (pre-fixCleanup3_4_0)");
|
||||
|
||||
Env env(*this, features);
|
||||
auto const maybeSetup = makeImpairedLoanVault(env, 0);
|
||||
if (!maybeSetup)
|
||||
{
|
||||
BEAST_EXPECT(false);
|
||||
return;
|
||||
}
|
||||
ImpairedLoanVault const& setup = *maybeSetup;
|
||||
|
||||
auto const tokenBefore = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
|
||||
auto const vaultBefore = env.le(setup.vaultKeylet);
|
||||
auto const issuanceBefore = env.le(keylet::mptokenIssuance(setup.shareId));
|
||||
if (!BEAST_EXPECT(tokenBefore) || !BEAST_EXPECT(vaultBefore) ||
|
||||
!BEAST_EXPECT(issuanceBefore))
|
||||
return;
|
||||
std::uint64_t const sharesBefore = tokenBefore->getFieldU64(sfMPTAmount);
|
||||
|
||||
// The clawback of 19,000 exceeds AssetsAvailable (9,000), so
|
||||
// VaultClawback clamps sharesDestroyed to whatever redeems
|
||||
// exactly AssetsAvailable; compute that expected value using the
|
||||
// same conversion helper VaultClawback itself uses, rather than
|
||||
// assuming an exact 90/10 split holds under truncation.
|
||||
auto const maybeSharesDestroyed = assetsToSharesWithdraw(
|
||||
vaultBefore,
|
||||
issuanceBefore,
|
||||
setup.usd(9'000).value(),
|
||||
TruncateShares::Yes,
|
||||
WaiveUnrealizedLoss::Yes);
|
||||
if (!BEAST_EXPECT(maybeSharesDestroyed))
|
||||
return;
|
||||
std::uint64_t const expectedSharesAfter =
|
||||
sharesBefore - maybeSharesDestroyed->mpt().value();
|
||||
|
||||
env(clawbackHolder(setup, setup.usd(19'000).value()), Ter(expected));
|
||||
env.close();
|
||||
if (expected != tesSUCCESS)
|
||||
return;
|
||||
|
||||
auto const vaultAfter = env.le(setup.vaultKeylet);
|
||||
if (!BEAST_EXPECT(vaultAfter))
|
||||
return;
|
||||
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == setup.usd(0).value());
|
||||
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == setup.usd(1'000).value());
|
||||
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == setup.usd(1'000).value());
|
||||
auto const tokenAfter = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
|
||||
if (!BEAST_EXPECT(tokenAfter))
|
||||
return;
|
||||
BEAST_EXPECT(tokenAfter->getFieldU64(sfMPTAmount) == expectedSharesAfter);
|
||||
};
|
||||
|
||||
runSole(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
|
||||
runSole(all_, tesSUCCESS);
|
||||
|
||||
testcase("VaultClawback after impaired loan, non-sole holder");
|
||||
{
|
||||
Env env(*this, all_);
|
||||
auto const maybeSetup = makeImpairedLoanVault(env, 1'000);
|
||||
if (!maybeSetup)
|
||||
{
|
||||
BEAST_EXPECT(false);
|
||||
return;
|
||||
}
|
||||
ImpairedLoanVault const& setup = *maybeSetup;
|
||||
// The waiver does not apply, so the holder's 9,000 shares are
|
||||
// still priced at the discounted rate and cannot cover 9,000.
|
||||
env(clawbackHolder(setup, setup.usd(9'000).value()), Ter(tecINSUFFICIENT_FUNDS));
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
@@ -1480,6 +1694,7 @@ public:
|
||||
testBug6LimitBypassWithShares();
|
||||
testBugClawbackRoundTripOvershoot();
|
||||
testBugWithdrawRoundTripOvershoot();
|
||||
testBugClawbackAfterLoanImpair();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user