Add loan and vault invariants

This commit is contained in:
JCW
2026-07-16 23:48:14 +01:00
parent 5ce0b1c2c7
commit 534df4169a
6 changed files with 1210 additions and 8 deletions

View File

@@ -366,3 +366,4 @@ words:
- xxhash
- xxhasher
- CGNAT
- unimpairs

View File

@@ -17,12 +17,20 @@ namespace xrpl {
*
* 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`
*
* A loan may only be deleted once it is fully paid off (no payments
* remaining):
*
* 2. A loan may only be deleted by a `LoanDelete` transaction.
* 3. A loan that is not fully paid off must not be deleted.
*
*/
class ValidLoan
{
// Pair is <before, after>. After is used for most of the checks, except
// those that check changed values.
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> loans_;
// Loans removed from the ledger (final state captured at deletion).
std::vector<SLE::const_pointer> deletedLoans_;
public:
void

View File

@@ -36,6 +36,22 @@ namespace xrpl {
* - vault withdrawal and clawback reduce assets and share issuance, and
* subtracts from: total assets, assets available, shares outstanding
* - vault set must not alter the vault assets or shares balance
* - loan set moves the requested principal out of the vault: it must create
* exactly one loan, decreases assets available (and the vault balance) by the
* principal, grows assets outstanding by exactly the interest due booked on
* the created loan, and does not change shares
* - loan manage never removes assets from the vault: assets available may only
* grow (and the vault balance grows with it, by the returned first-loss
* capital on a default), assets outstanding may only shrink (realized loss),
* loss unrealized stays non-negative, and shares do not change; a loan
* manage with none of the sub-operation flags (impair, unimpair, default)
* is a no-op and must not modify the vault
* - loan pay adds the paid principal and interest to the vault: assets
* available (and the vault balance) increase by the same amount, which is at
* most the amount paid, loss unrealized stays non-negative, and shares do not
* change; and assets outstanding move in lock-step: their change equals the
* cash received plus the change in the paid loan's claim on the vault, which
* verifies the payment was split correctly between principal and interest
* - no vault transaction can change loss unrealized (it's updated by loan
* transactions)
*
@@ -55,6 +71,8 @@ class ValidVault
Number assetsAvailable = 0;
Number assetsMaximum = 0;
Number lossUnrealized = 0;
std::uint8_t withdrawalPolicy = 0;
std::uint8_t scale = 0;
Vault static make(SLE const&);
};
@@ -68,6 +86,26 @@ class ValidVault
Shares static make(SLE const&);
};
struct Loan final
{
uint256 key = beast::kZero;
Number principalOutstanding = 0;
Number totalValueOutstanding = 0;
Number managementFeeOutstanding = 0;
// Interest booked to the vault at loan creation: the portion of the
// total value owed that is neither principal nor broker management fee.
[[nodiscard]] Number
interestDue() const;
// The vault's claim on the loan: the total value owed less the broker's
// management fee (which belongs to the broker, not the vault).
[[nodiscard]] Number
claim() const;
Loan static make(SLE const&);
};
public:
struct DeltaInfo final
{
@@ -82,8 +120,10 @@ public:
private:
std::vector<Vault> afterVault_;
std::vector<Shares> afterMPTs_;
std::vector<Loan> afterLoan_;
std::vector<Vault> beforeVault_;
std::vector<Shares> beforeMPTs_;
std::vector<Loan> beforeLoan_;
std::unordered_map<uint256, DeltaInfo> deltas_;
/**

View File

@@ -10,6 +10,7 @@
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
namespace xrpl {
@@ -17,7 +18,13 @@ namespace xrpl {
void
ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
if (after && after->getType() == ltLOAN)
if (isDelete)
{
// On deletion `after` holds the loan's final state.
if (after && after->getType() == ltLOAN)
deletedLoans_.emplace_back(after);
}
else if (after && after->getType() == ltLOAN)
{
loans_.emplace_back(before, after);
}
@@ -34,6 +41,8 @@ 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();
for (auto const& [before, after] : loans_)
{
// https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants
@@ -77,6 +86,15 @@ ValidLoan::finalize(
return false;
}
}
// Interest due (the total value owed less principal and management fee)
// must never be negative.
if (after->at(sfTotalValueOutstanding) - after->at(sfPrincipalOutstanding) -
after->at(sfManagementFeeOutstanding) <
beast::kZero)
{
JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative";
return false;
}
// Must be positive - STNumber
for (auto const field : {
&sfPeriodicPayment,
@@ -90,6 +108,30 @@ ValidLoan::finalize(
}
}
}
// A loan may only be deleted by a LoanDelete transaction, and only once it
// is fully paid off (no payments remaining). Deleting a loan with
// outstanding obligations is a violation.
for (auto const& loan : deletedLoans_)
{
if (txType != ttLOAN_DELETE)
{
JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction "
"other than LoanDelete";
return false;
}
if (loan->at(sfPaymentRemaining) != 0 ||
loan->at(sfTotalValueOutstanding) != beast::kZero ||
loan->at(sfPrincipalOutstanding) != beast::kZero ||
loan->at(sfManagementFeeOutstanding) != beast::kZero)
{
JLOG(j.fatal()) << "Invariant failed: Loan deleted while not fully "
"paid off";
return false;
}
}
return true;
}

View File

@@ -17,6 +17,7 @@
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
@@ -44,6 +45,8 @@ ValidVault::Vault::make(SLE const& from)
self.assetsAvailable = from.at(sfAssetsAvailable);
self.assetsMaximum = from.at(sfAssetsMaximum);
self.lossUnrealized = from.at(sfLossUnrealized);
self.withdrawalPolicy = from.at(sfWithdrawalPolicy);
self.scale = from.at(sfScale);
return self;
}
@@ -61,6 +64,31 @@ ValidVault::Shares::make(SLE const& from)
return self;
}
Number
ValidVault::Loan::interestDue() const
{
return totalValueOutstanding - principalOutstanding - managementFeeOutstanding;
}
Number
ValidVault::Loan::claim() const
{
return totalValueOutstanding - managementFeeOutstanding;
}
ValidVault::Loan
ValidVault::Loan::make(SLE const& from)
{
XRPL_ASSERT(from.getType() == ltLOAN, "ValidVault::Loan::make : from Loan object");
ValidVault::Loan self;
self.key = from.key();
self.principalOutstanding = from.at(sfPrincipalOutstanding);
self.totalValueOutstanding = from.at(sfTotalValueOutstanding);
self.managementFeeOutstanding = from.at(sfManagementFeeOutstanding);
return self;
}
void
ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
@@ -119,6 +147,12 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
sign = -1;
break;
}
case ltLOAN:
// A loan carries no vault balance of its own; capture its prior
// state so the loan pay invariant can verify the change in the
// vault's claim on the loan.
beforeLoan_.push_back(Loan::make(*before));
break;
default:;
}
}
@@ -164,6 +198,13 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte
sign = -1;
break;
}
case ltLOAN:
// A loan carries no vault balance of its own; capture it so the
// loan set and loan pay invariants can verify the interest
// booked to the vault and the change in the vault's claim on
// the loan.
afterLoan_.push_back(Loan::make(*after));
break;
default:;
}
}
@@ -426,7 +467,10 @@ ValidVault::finalize(
{
auto const& beforeVault = beforeVault_[0];
if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId ||
afterVault.shareMPTID != beforeVault.shareMPTID)
afterVault.shareMPTID != beforeVault.shareMPTID ||
afterVault.owner != beforeVault.owner ||
afterVault.withdrawalPolicy != beforeVault.withdrawalPolicy ||
afterVault.scale != beforeVault.scale)
{
JLOG(j.fatal()) << "Invariant failed: violation of vault immutable data";
result = false;
@@ -1045,10 +1089,337 @@ ValidVault::finalize(
return result;
}
case ttLOAN_SET:
case ttLOAN_MANAGE:
case ttLOAN_PAY:
return true;
case ttLOAN_SET: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(), "xrpl::ValidVault::finalize : loan set updated a vault");
auto const& beforeVault = beforeVault_[0];
// A loan set must create exactly one loan object; the interest
// it books is the only permitted change to assets outstanding.
if (afterLoan_.size() != 1 || !beforeLoan_.empty())
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must create exactly one loan";
return false; // That's all we can do
}
auto const& loan = afterLoan_[0];
// Funding a loan moves the requested principal out of the vault
// pseudo-account to the borrower (and, if any, the origination
// fee to the broker owner).
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must change vault balance";
return false; // That's all we can do
}
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
// The vault only releases the requested principal, which must
// match the reduction of both the vault (pseudo-account) balance
// and the assets available.
auto const principalDelta =
roundToAsset(vaultAsset, -tx[sfPrincipalRequested], minScale);
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
if (vaultDeltaAssets != principalDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must decrease vault balance "
"by the principal requested";
result = false;
}
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != principalDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must decrease assets "
"available by the principal requested";
result = false;
}
// The created loan must record exactly the principal the vault
// released. Otherwise the borrower's claim (and thus the assets
// booked back to the vault on repayment) is decoupled from the
// assets actually lent, which would skew the vault's share price.
if (loan.principalOutstanding != tx[sfPrincipalRequested])
{
JLOG(j.fatal()) << //
"Invariant failed: loan set principal outstanding must "
"equal principal requested";
result = false;
}
// Interest accrues to the vault: assets outstanding must grow by
// exactly the interest due booked on the newly created loan.
auto const assetsTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
auto const interestDue = roundToAsset(vaultAsset, loan.interestDue(), minScale);
if (assetsTotalDelta != interestDue)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must increase assets "
"outstanding by the interest due";
result = false;
}
if (afterVault.assetsMaximum > kZero &&
afterVault.assetsTotal > afterVault.assetsMaximum)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set assets outstanding must not "
"exceed assets maximum";
result = false;
}
// A loan set neither mints nor burns vault shares.
if (beforeShares && updatedShares &&
beforeShares->sharesTotal != updatedShares->sharesTotal)
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must not change shares outstanding";
result = false;
}
return result;
}
case ttLOAN_MANAGE: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(),
"xrpl::ValidVault::finalize : loan manage updated a vault");
auto const& beforeVault = beforeVault_[0];
// Loan management (impair / unimpair / default) never removes
// assets from the vault. Only a default returns first-loss
// capital from the broker to the vault pseudo-account; impair
// and unimpair merely adjust the paper (unrealized) loss and
// touch no balances.
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
auto const vaultDelta = maybeVaultDeltaAssets.value_or(
DeltaInfo{.delta = kZero, .scale = scale(afterVault.assetsTotal, vaultAsset)});
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(vaultDelta, view.rules());
auto const vaultDeltaAssets = roundToAsset(vaultAsset, vaultDelta.delta, minScale);
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
auto const assetTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
// --- Checks common to every loan manage sub-operation ---
// Assets available always tracks the real vault balance: any
// change to one is matched by the other.
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan manage vault balance and assets "
"available must add up";
result = false;
}
// Loan management adjusts the unrealized (paper) loss but must
// never drive it negative.
if (afterVault.lossUnrealized < kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan manage must not make loss "
"unrealized negative";
result = false;
}
// A loan manage neither mints nor burns vault shares.
if (beforeShares && updatedShares &&
beforeShares->sharesTotal != updatedShares->sharesTotal)
{
JLOG(j.fatal()) << //
"Invariant failed: loan manage must not change shares "
"outstanding";
result = false;
}
// --- Checks specific to each loan manage sub-operation ---
if (tx.isFlag(tfLoanImpair) || tx.isFlag(tfLoanUnimpair))
{
// Impair / unimpair only move the paper (unrealized) loss;
// they touch neither balances nor assets.
if (assetAvailableDelta != kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan impair/unimpair must not "
"change assets available";
result = false;
}
if (assetTotalDelta != kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan impair/unimpair must not "
"change assets outstanding";
result = false;
}
}
else if (tx.isFlag(tfLoanDefault))
{
// A default returns first-loss capital to the vault, so
// assets available (and the vault balance) may only grow.
if (assetAvailableDelta < kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan default must not decrease "
"assets available";
result = false;
}
// A default realizes (writes off) the uncovered portion of
// the loan, so assets outstanding may only shrink.
if (assetTotalDelta > kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan default must not increase "
"assets outstanding";
result = false;
}
}
else
{
// A loan manage with none of the sub-operation flags
// (impair, unimpair, default) is a no-op and must not
// modify the vault.
JLOG(j.fatal()) << //
"Invariant failed: loan manage without a sub-operation "
"must not modify the vault";
result = false;
}
return result;
}
case ttLOAN_PAY: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(), "xrpl::ValidVault::finalize : loan pay updated a vault");
auto const& beforeVault = beforeVault_[0];
// A loan payment moves the paid principal and interest into the
// vault pseudo-account (fees go to the broker), so the vault
// balance and the assets available both increase by that amount.
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must change vault balance";
return false; // That's all we can do
}
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
auto const assetAvailableDelta = roundToAsset(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
// Assets available always tracks the real vault balance: any
// change to one is matched by the other.
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay vault balance and assets "
"available must add up";
result = false;
}
// A payment always adds funds to the vault, so assets available
// (and the vault balance) must increase.
if (assetAvailableDelta <= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must increase assets "
"available";
result = false;
}
// The vault only receives the principal and interest portion of
// the borrower's payment (the fee goes to the broker), so it can
// never grow by more than the amount paid.
auto const amountPaid = roundToAsset(vaultAsset, tx[sfAmount], minScale);
if (assetAvailableDelta > amountPaid)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must not increase assets "
"available by more than the amount paid";
result = false;
}
// A payment unimpairs the loan, so it may reduce the unrealized
// (paper) loss, but must never drive it negative.
if (afterVault.lossUnrealized < kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must not make loss "
"unrealized negative";
result = false;
}
// A loan pay neither mints nor burns vault shares.
if (beforeShares && updatedShares &&
beforeShares->sharesTotal != updatedShares->sharesTotal)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must not change shares "
"outstanding";
result = false;
}
// The vault's total assets equal its available cash plus the
// claim it holds on outstanding loans (each loan's total value
// owed, less the broker's management fee, which belongs to the
// broker). A payment only moves value between those two pools,
// so the change in assets outstanding must equal the cash
// received plus the change in the paid loan's claim on the
// vault. This is an independent check that the borrower's
// payment was split correctly between principal and interest.
if (afterLoan_.size() != 1 || beforeLoan_.size() != 1 ||
afterLoan_[0].key != beforeLoan_[0].key)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay must modify exactly one "
"loan";
result = false;
}
else
{
auto const claimDelta = roundToAsset(
vaultAsset, afterLoan_[0].claim() - beforeLoan_[0].claim(), minScale);
auto const assetsTotalDelta = roundToAsset(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
if (assetsTotalDelta != assetAvailableDelta + claimDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: loan pay assets outstanding must "
"match the cash received and the change in the loan "
"claim";
result = false;
}
}
return result;
}
default:
// LCOV_EXCL_START
@@ -1060,8 +1431,8 @@ ValidVault::finalize(
if (!result)
{
// The comment at the top of this file starting with "assert(enforce)"
// explains this assert.
// The comment in InvariantCheckPrivilege.h starting with
// "assert(enforce)" explains this assert.
XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : vault invariants");
return !enforce;
}

View File

@@ -7,6 +7,7 @@
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/permissioned_domains.h>
#include <test/jtx/sig.h>
#include <test/jtx/tags.h>
#include <test/jtx/token.h>
#include <test/jtx/trust.h>
@@ -2767,6 +2768,16 @@ class Invariants_test : public beast::unit_test::Suite
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;
};
struct Adjustments
{
// NOLINTBEGIN(readability-redundant-member-init)
@@ -2778,6 +2789,10 @@ class Invariants_test : public beast::unit_test::Suite
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) {
@@ -2876,6 +2891,28 @@ class Invariants_test : public beast::unit_test::Suite
(*sleMPToken)[sfMPTAmount] = *(*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;
for (std::uint32_t seq = 1; seq <= static_cast<std::uint32_t>(args.loanCount);
++seq)
{
auto sleLoan = std::make_shared<SLE>(keylet::loan(keylet.key, seq));
sleLoan->at(sfPrincipalOutstanding) = Number(lp.principalOutstanding);
sleLoan->at(sfTotalValueOutstanding) = Number(lp.totalValueOutstanding);
sleLoan->at(sfManagementFeeOutstanding) = Number(lp.managementFeeOutstanding);
// ValidLoan requires a positive periodic payment, and that a
// loan with payments remaining is not fully paid off.
sleLoan->at(sfPeriodicPayment) = Number(1);
sleLoan->setFieldU32(sfPaymentRemaining, anyOutstanding ? 1 : 0);
sleLoan->setAccountID(sfBorrower, lp.borrower);
sleLoan->makeFieldPresent(sfOwnerNode);
ac.insert(sleLoan);
}
}
return true;
};
@@ -3461,6 +3498,709 @@ class Invariants_test : public beast::unit_test::Suite
precloseXrp,
TxAccount::A2);
testcase << "Vault loan operations";
// ttLOAN_MANAGE (impair): assets outstanding must not change. Only
// assets outstanding is bumped, so the common checks (vault balance,
// assets available, shares) all pass and only the impair/unimpair
// sub-check fires.
doInvariantCheck(
{"loan impair/unimpair must not change assets outstanding"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(ac.view(), keylet, Adjustments{.assetsTotal = 100});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE with none of the sub-operation flags (impair,
// unimpair, default) is a no-op and must not modify the vault.
doInvariantCheck(
{"loan manage without a sub-operation must not modify the vault"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(ac.view(), keylet, Adjustments{.assetsTotal = 100});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: exactly one loan is created, but the vault
// (pseudo-account) balance does not change, so only the balance check
// trips. A loan must be created so the "exactly one loan" check passes
// first and the balance check is actually reached.
doInvariantCheck(
{"loan set must change vault balance"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.createLoan = LoanParams{
.principalOutstanding = 200,
.totalValueOutstanding = 200,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: the balance decreases, but not by the principal requested
doInvariantCheck(
{"loan set must decrease vault balance by the principal requested",
"loan set must decrease assets available by the principal requested"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -100,
.vaultAssets = -100,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 100},
.createLoan = LoanParams{
.principalOutstanding = 200,
.totalValueOutstanding = 200,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{
ttLOAN_SET,
[](STObject& tx) {
tx.at(sfPrincipalRequested) = Number(200);
tx.makeFieldPresent(sfCounterpartySignature);
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: principal matches, but no loan object is created
doInvariantCheck(
{"loan set must create exactly one loan"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -200,
.vaultAssets = -200,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 200}});
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: principal matches, but more than one loan is created
doInvariantCheck(
{"loan set must create exactly one loan"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -200,
.vaultAssets = -200,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 200},
.createLoan =
LoanParams{
.principalOutstanding = 100,
.totalValueOutstanding = 100,
.borrower = a1.id(),
},
.loanCount = 2});
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: principal matches, but assets outstanding change does not
// equal the interest due booked on the created loan (0 here)
doInvariantCheck(
{"loan set must increase assets outstanding by the interest due"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsTotal = -50,
.assetsAvailable = -200,
.vaultAssets = -200,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 200},
.createLoan = LoanParams{
.principalOutstanding = 200,
.totalValueOutstanding = 200,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{
ttLOAN_SET,
[](STObject& tx) {
tx.at(sfPrincipalRequested) = Number(200);
tx.makeFieldPresent(sfCounterpartySignature);
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: principal matches, but shares outstanding changes
doInvariantCheck(
{"loan set must not change shares outstanding"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -200,
.sharesTotal = 10,
.vaultAssets = -200,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 200},
.accountShares = AccountAmount{.account = a2.id(), .amount = 10},
.createLoan = LoanParams{
.principalOutstanding = 200,
.totalValueOutstanding = 200,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{
ttLOAN_SET,
[](STObject& tx) {
tx.at(sfPrincipalRequested) = Number(200);
tx.makeFieldPresent(sfCounterpartySignature);
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: everything balances (principal released, exactly one loan
// created booking zero interest so assets outstanding is unchanged, and
// shares are untouched), but the vault has a positive assets maximum
// below its assets outstanding, so only the maximum check trips.
doInvariantCheck(
{"loan set assets outstanding must not exceed assets maximum"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -200,
.assetsMaximum = 1,
.vaultAssets = -200,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 200},
.createLoan = LoanParams{
.principalOutstanding = 200,
.totalValueOutstanding = 200,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{
ttLOAN_SET,
[](STObject& tx) {
tx.at(sfPrincipalRequested) = Number(200);
tx.makeFieldPresent(sfCounterpartySignature);
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_SET: everything balances (principal released, exactly one loan
// created booking zero interest, shares untouched, assets outstanding
// unchanged), but the created loan records a principal outstanding that
// differs from the principal requested. The vault only released 200 to
// the borrower, yet the loan claims 300 principal, decoupling the
// borrower's debt from the assets actually lent and enabling share-price
// manipulation on repayment.
doInvariantCheck(
{"loan set principal outstanding must equal principal requested"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), 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); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE: vault balance and assets available do not add up
doInvariantCheck(
{"loan manage vault balance and assets available must add up"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(ac.view(), keylet, Adjustments{.assetsAvailable = -100});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE: loss unrealized driven negative
doInvariantCheck(
{"loan manage must not make loss unrealized negative"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = -1});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE: shares outstanding changes
doInvariantCheck(
{"loan manage must not change shares outstanding"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.sharesTotal = 10,
.accountShares = AccountAmount{.account = a2.id(), .amount = 10}});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE (impair): assets available must not change
doInvariantCheck(
{"loan impair/unimpair must not change assets available"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -100,
.vaultAssets = -100,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 100}});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE (default): assets available must not decrease
doInvariantCheck(
{"loan default must not decrease assets available"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsTotal = -100,
.assetsAvailable = -100,
.vaultAssets = -100,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 100}});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanDefault); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE (default): assets outstanding must not increase
doInvariantCheck(
{"loan default must not increase assets outstanding"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsTotal = 100,
.assetsAvailable = 100,
.vaultAssets = 100,
.accountAssets = AccountAmount{.account = a2.id(), .amount = -100}});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanDefault); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_PAY: the vault (pseudo-account) balance must change
doInvariantCheck(
{"loan pay must change vault balance"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(ac.view(), keylet, Adjustments{});
},
XRPAmount{},
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_PAY: assets available must track the real vault balance. The
// vault balance (pseudo-account) grows by 50 but assets available is
// bumped by 60, so the two no longer add up.
doInvariantCheck(
{"loan pay vault balance and assets available must add up"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = 60,
.vaultAssets = 50,
.accountAssets = AccountAmount{.account = a2.id(), .amount = -50}});
},
XRPAmount{},
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(100)); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_PAY: assets available must increase
doInvariantCheck(
{"loan pay must increase assets available"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -100,
.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: assets available increases by more than the amount paid
doInvariantCheck(
{"loan pay must not increase assets available by more than the amount paid"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsTotal = 300,
.assetsAvailable = 300,
.vaultAssets = 300,
.accountAssets = AccountAmount{.account = a2.id(), .amount = -300}});
},
XRPAmount{},
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(100)); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_PAY: shares outstanding changes
doInvariantCheck(
{"loan pay must not change shares outstanding"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), ac.view().seq());
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsTotal = 100,
.assetsAvailable = 100,
.sharesTotal = 10,
.vaultAssets = 100,
.accountAssets = AccountAmount{.account = a2.id(), .amount = -100},
.accountShares = AccountAmount{.account = a2.id(), .amount = 10}});
},
XRPAmount{},
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_PAY: loss unrealized driven negative. The cash inflow is
// valid, but loss unrealized is set below zero.
doInvariantCheck(
{"loan pay must not make loss unrealized negative"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), 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: assets outstanding must match the cash received and the
// change in the paid loan's claim on the vault. A payment only moves
// value between the vault's available cash and its claim on the loan,
// so bumping assets outstanding by more than that must be caught. This
// needs a loan that already exists in the base ledger (so modifying it
// is seen as a before/after change), which the shared harness cannot
// set up, hence the bespoke view construction below.
{
Env env{*this, defaultAmendments()};
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 vaultKeylet = keylet::vault(a1.id(), ov.seq());
// Insert a pre-existing loan into the base view; modifying it in the
// apply view is then seen as a loan modification (before/after).
auto const loanKeylet = keylet::loan(vaultKeylet.key, 1);
{
auto sleLoan = std::make_shared<SLE>(loanKeylet);
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(150);
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
sleLoan->at(sfPeriodicPayment) = Number(1);
sleLoan->setFieldU32(sfPaymentRemaining, 1);
ov.rawInsert(sleLoan);
}
STTx const tx{
ttLOAN_PAY, [](STObject& t) { t.setFieldAmount(sfAmount, XRPAmount(100)); }};
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());
// Cash received: assets available and the vault balance both grow by
// 60, paid by a2. The paid loan's claim drops by 60 (total value
// 150 -> 90). Conservation requires assets outstanding to be
// unchanged, but we bump it by 10 to violate the identity.
if (!BEAST_EXPECT(kAdjust(
ac.view(),
vaultKeylet,
Adjustments{
.assetsTotal = 10,
.assetsAvailable = 60,
.vaultAssets = 60,
.accountAssets = AccountAmount{.account = a2.id(), .amount = -60}})))
return;
auto sleLoan = ac.view().peek(loanKeylet);
if (!BEAST_EXPECT(sleLoan))
return;
sleLoan->at(sfPrincipalOutstanding) = Number(40);
sleLoan->at(sfTotalValueOutstanding) = Number(90);
ac.view().update(sleLoan);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
return;
TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{});
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(sink.messages().str().contains(
"loan pay assets outstanding must match the cash received and "
"the change in the loan claim"));
}
// 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};
Vault const vault{env};
auto [tx, vaultKeylet] = vault.create({.owner = a1, .asset = xrpAsset});
env(tx);
env.close();
if (!BEAST_EXPECT(env.le(vaultKeylet)))
return false;
env(vault.deposit(
{.depositor = a1, .id = vaultKeylet.key, .amount = xrpAsset(100)}));
env.close();
auto const brokerKeylet = keylet::loanBroker(a1.id(), env.seq(a1));
env(loanBroker::set(a1, vaultKeylet.key), Fee(kIncrement));
env.close();
auto const brokerSle = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerSle))
return false;
loanKeylet = keylet::loan(brokerKeylet.key, brokerSle->at(sfLoanSequence));
env(loan::set(a2, brokerKeylet.key, xrpAsset(50).value()),
Sig(sfCounterpartySignature, a1),
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);
// Deleting the loan via LoanDelete while it still has outstanding
// obligations is a violation: the transaction-type check passes and
// the not-fully-paid-off check fires.
doInvariantCheck(
{"Loan deleted while not fully paid off"},
eraseLoan,
XRPAmount{},
STTx{ttLOAN_DELETE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseLoan);
}
// Loan interest due (total value less principal and management fee)
// must never be negative. A neutral transaction type is used so the
// vault invariants short-circuit and only the loan check fires. The
// loan object is created directly with principal 100, total value 90
// and management fee 0, so interest due = 90 - 100 - 0 = -10 (< 0)
// while every individual field stays non-negative.
doInvariantCheck(
{"Loan interest due is negative"},
[&](Account const& a1, Account const&, ApplyContext& ac) {
auto const vaultKeylet = keylet::vault(a1.id(), ac.view().seq());
auto const loanKeylet = keylet::loan(vaultKeylet.key, 1);
auto sleLoan = std::make_shared<SLE>(loanKeylet);
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(90);
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
sleLoan->at(sfPeriodicPayment) = Number(1);
sleLoan->setFieldU32(sfPaymentRemaining, 1);
ac.view().insert(sleLoan);
return true;
});
// Each of these loan STNumber fields must never be negative. A neutral
// transaction type is used so the vault invariants short-circuit and
// only the loan check fires. 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&, ApplyContext& ac) {
auto const vaultKeylet = keylet::vault(a1.id(), ac.view().seq());
auto const loanKeylet = keylet::loan(vaultKeylet.key, 1);
auto sleLoan = std::make_shared<SLE>(loanKeylet);
sleLoan->at(sfPrincipalOutstanding) = Number(0);
sleLoan->at(sfTotalValueOutstanding) = Number(0);
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
sleLoan->at(sfPeriodicPayment) = Number(1);
sleLoan->at(*field) = Number(-10);
sleLoan->setFieldU32(sfPaymentRemaining, isOutstanding ? 1 : 0);
ac.view().insert(sleLoan);
return true;
});
}
// ttVAULT_SET: owner is immutable
doInvariantCheck(
{"violation of vault immutable data"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), 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, tecINVARIANT_FAILED},
precloseXrp);
// ttVAULT_SET: withdrawal policy is immutable
doInvariantCheck(
{"violation of vault immutable data"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), 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, tecINVARIANT_FAILED},
precloseXrp);
// ttVAULT_SET: scale is immutable
doInvariantCheck(
{"violation of vault immutable data"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), 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, tecINVARIANT_FAILED},
precloseXrp);
testcase << "Vault create";
doInvariantCheck(
{