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