Add vault invariants

This commit is contained in:
JCW
2026-07-06 15:35:51 +01:00
parent d07d2aba2e
commit e538aeb59a
4 changed files with 827 additions and 5 deletions

View File

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

View File

@@ -36,6 +36,20 @@ 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
* - 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 +69,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 +84,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 +118,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

@@ -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,11 @@ 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 invariant can verify the interest booked to the vault.
afterLoan_.push_back(Loan::make(*after));
break;
default:;
}
}
@@ -423,7 +462,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;
@@ -1042,11 +1084,314 @@ ValidVault::finalize(
return result;
}
case ttLOAN_SET:
case ttLOAN_MANAGE:
case ttLOAN_SET: {
bool result = true;
XRPL_ASSERT(
!beforeVault_.empty(), "xrpl::ValidVault::finalize : loan set updated a vault");
auto const& beforeVault = beforeVault_[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;
}
// 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)
{
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];
// 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;
}
}
return result;
}
case ttLOAN_PAY: {
// TBD
return true;
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:

View File

@@ -2667,6 +2667,15 @@ 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;
};
struct Adjustments
{
// NOLINTBEGIN(readability-redundant-member-init)
@@ -2678,6 +2687,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) {
@@ -2776,6 +2789,26 @@ 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);
ac.insert(sleLoan);
}
}
return true;
};
@@ -3361,6 +3394,411 @@ class Invariants_test : public beast::unit_test::Suite
precloseXrp,
TxAccount::A2);
testcase << "Vault loan operations";
// ttLOAN_SET: the vault (pseudo-account) balance must change
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{});
},
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{}});
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
{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{},
.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{}});
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
{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{}});
},
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 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: 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"));
}
// 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(
{