Compare commits

...

97 Commits

Author SHA1 Message Date
Vito
71e0841de4 Merge remote-tracking branch 'origin/a1q123456/isPseudoAccount-remove-filter' into ripple/lending-stage
# Conflicts:
#	src/libxrpl/tx/transactors/vault/VaultClawback.cpp
2026-08-26 20:43:12 +02:00
Jingchen
f3a14b9953 Merge branch 'develop' into a1q123456/isPseudoAccount-remove-filter 2026-08-26 19:14:27 +01:00
Jingchen
97b6ff78ee Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-26 19:14:13 +01:00
Vito
a1bda4d560 Merge remote-tracking branch 'origin/tapanito/vault-clawback-bug' into ripple/lending-stage 2026-08-26 18:53:19 +02:00
Vito
aa40107606 fix: Advance past due date before impairing loan in VaultBugs test
Develop gained #6557, which makes LoanManage reject tfLoanImpair with
tecTOO_SOON unless the loan payment is already late. Advance the
ledger past sfNextPaymentDueDate before impairing, matching the
pattern already used elsewhere (VaultSoleShareholder_test.cpp).
2026-08-26 18:52:13 +02:00
Vito
9a49fa8cfe merge: Bring in develop (incl. #6557 loan-impair due-date gate) 2026-08-26 18:42:57 +02:00
Vito
2df03ba770 Merge remote-tracking branch 'origin/tapanito/enable-lp-1-1' into ripple/lending-stage 2026-08-26 18:30:45 +02:00
Vito
25d0d0d138 Merge remote-tracking branch 'origin/tapanito/vault-clawback-bug' into ripple/lending-stage
# Conflicts:
#	src/libxrpl/tx/transactors/vault/VaultClawback.cpp
2026-08-26 18:26:28 +02:00
Vito
966ce3bed8 Merge remote-tracking branch 'origin/tapanito/vault-precision-transactor' into ripple/lending-stage 2026-08-26 18:25:52 +02:00
Vito
6cd4b76603 Merge remote-tracking branch 'origin/a1q123456/add-loan-invariants' into ripple/lending-stage 2026-08-26 18:25:50 +02:00
JCW
8f95125d59 Address PR comments 2026-08-26 17:23:54 +01:00
JCW
b3e8f927f6 Address PR comments 2026-08-26 17:22:10 +01:00
Vito
e118f4cc30 feat: Enable LendingProtocolV1_1 amendment 2026-08-26 18:17:12 +02:00
JCW
b3facefc70 Self review: change the amendment gate for vault immutability checks to v1_1 because I don't think it's necessary to use fixCleanup3_4_0 2026-08-26 17:10:04 +01:00
Vito
eb082ef43b fix: Address second round of Copilot review feedback 2026-08-26 17:57:35 +02:00
Vito
b974e14f25 fix: Address PR review feedback on VaultClawback waiver 2026-08-26 17:42:37 +02:00
JCW
91d0fc5f7f pre-commit hooks 2026-08-26 16:01:06 +01:00
JCW
efadaf6aa7 Self review 2026-08-26 15:58:15 +01:00
JCW
290b1a6d68 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-26 15:25:16 +01:00
JCW
3b332f7838 Strip out some checks 2026-08-26 15:25:00 +01:00
Vito
5538aec034 Exclude the unreachable VaultWithdraw clamp error from coverage.
debitIsNonZeroDust already rejects sub-ULP withdrawals, so the post-clamp tecPRECISION_LOSS return is defense-in-depth only.
2026-08-26 15:22:22 +02:00
JCW
442ed8dbce WIP 2026-08-26 13:54:14 +01:00
JCW
5a4c7b120f Address PR comments 2026-08-26 12:14:13 +01:00
Vito Tumas
2daaacbbeb Fix comment typo
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-26 12:59:11 +02:00
Vito
cccfd49c73 chore: Fix clang-tidy findings in VaultBugs impair tests
Include UintTypes.h, brace the otherDeposit guards, and unwrap
the ImpairedLoanVault optional before use.
2026-08-26 12:40:30 +02:00
Vito
5c58f5ba76 test: Consolidate vault precision tests and share scale fixtures 2026-08-26 12:38:00 +02:00
Vito
641afdc56d test: Add clampToAssetsTotalScale table test 2026-08-26 12:37:57 +02:00
Vito
94ccbfc705 fix: Prefer insufficient funds over precision loss in VaultWithdraw 2026-08-26 12:37:55 +02:00
Vito
9b94732836 fix: Re-check depositor rounding after clamp in VaultDeposit 2026-08-26 12:37:53 +02:00
Vito
f39fc07c0b fix: Guard clamp credit sum and skip rounding for integral assets 2026-08-26 12:37:51 +02:00
Vito
5b6202db41 fix: Waive unrealized-loss discount on sole-holder VaultClawback
After LoanManage(tfLoanImpair), clawing back available cash at the
discounted rate can burn every outstanding share while AssetsTotal
still holds the receivable, which ValidVault rejects.
2026-08-26 12:19:57 +02:00
JCW
a72ca0761a Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-26 10:57:03 +01:00
Vito
03236cc07e fix: Snap vault deltas to ValidVault's posterior assetsTotal scale
Derive credits from the Downward-rounded posterior total so an off-grid
sfAssetsTotal cannot over-credit the depositor's payment, and round
debits down at that same posterior scale so decade-boundary amounts
that are representable after the tx succeed in full.
2026-08-25 21:12:14 +02:00
Vito
9b8e5d6672 Merge remote-tracking branch 'origin/develop' into tapanito/vault-precision-transactor 2026-08-25 20:39:29 +02:00
JCW
b7d5715c90 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-25 16:05:47 +01:00
JCW
053bbf3004 WIP: Address PR comments 2026-08-25 16:05:41 +01:00
JCW
f3aad20331 Merge remote-tracking branch 'origin/a1q123456/add-loan-invariants' into a1q123456/add-loan-invariants 2026-08-25 15:23:45 +01:00
JCW
113427f4bb WIP: address PR comments 2026-08-25 15:23:27 +01:00
Jingchen
2f8443b578 Update src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
2026-08-25 14:46:43 +01:00
Jingchen
11d4b614b7 Update src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
2026-08-25 14:46:05 +01:00
Jingchen
ea9c8c0d1d Update src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp
Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
2026-08-25 14:45:19 +01:00
Vito
6884d5181f fix: Re-clamp clawback recovery after mid-grid scale amplification 2026-08-25 15:38:24 +02:00
JCW
ad745adaa2 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-25 14:19:20 +01:00
Vito
79a0e98050 fix: Return tecPRECISION_LOSS instead of asserting on clawback dust 2026-08-25 14:41:54 +02:00
JCW
f048b2aa5c Address PR comments 2026-08-25 11:19:38 +01:00
Vito
3b550b720e Merge remote-tracking branch 'origin/develop' into tapanito/vault-precision-transactor 2026-08-25 11:20:12 +02:00
JCW
1d0332525c Address comments 2026-08-24 22:07:43 +01:00
JCW
2d1af3dc2a Fix build error 2026-08-24 17:18:27 +01:00
Vito
37dbfc0447 Merge remote-tracking branch 'origin/develop' into tapanito/vault-precision-transactor 2026-08-24 16:12:06 +02:00
Vito
67b0991fbc fix: Mark untestable vault clamp overflow and zero-collapse paths
Exclude defense-in-depth precision-loss and overflow_error handlers from
coverage and assert they are unreachable so codecov/patch can pass.
2026-08-24 16:11:42 +02:00
Vito
79ef10c529 Merge remote-tracking branch 'origin/develop' into tapanito/vault-precision-transactor
# Conflicts:
#	src/test/app/vault/VaultBugs_test.cpp
2026-08-24 11:14:03 +02:00
JCW
039198e527 Address PR comments 2026-08-22 00:27:48 +01:00
JCW
5fe8a38f1d Add a new test 2026-08-21 22:51:14 +01:00
JCW
31e4cfe213 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-21 22:22:58 +01:00
JCW
c5a7ed2753 Fix test failures 2026-08-21 18:44:10 +01:00
JCW
4cb015922a pre-commit hooks 2026-08-21 18:00:27 +01:00
JCW
30890b8e19 Address Vito's comments I left 2026-08-21 17:56:23 +01:00
JCW
8e453e8aad Fix some comments 2026-08-21 16:02:48 +01:00
JCW
bf11ffec7a pre-commit hooks 2026-08-21 15:43:29 +01:00
JCW
022f6e7414 Improve test coverage 2026-08-21 15:40:49 +01:00
JCW
33dbbc7a7e Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-21 15:37:38 +01:00
JCW
888f5be162 clean up 2026-08-21 13:19:37 +01:00
JCW
228e8fe560 WIP 2026-08-21 13:19:16 +01:00
JCW
e27db94891 Address PR comments 2026-08-21 12:51:22 +01:00
JCW
019ae5b9cb Update comments 2026-08-21 12:18:34 +01:00
JCW
c571eff3a8 WIP 2026-08-21 11:57:07 +01:00
JCW
f66ce70256 WIP 2026-08-21 11:45:05 +01:00
JCW
c7672d5ef7 WIP 2026-08-21 10:41:40 +01:00
Vito
fbda53aa8b fix: Guard debitIsNonZeroDust against overflow_error in VaultClawback
Mirrors the equivalent guard already applied to VaultWithdraw: wrap
the dust check in try/catch so a sufficiently abused sfScale pushing
assetsTotal/assetsAvailable out of STAmount's representable range
returns tecPATH_DRY instead of surfacing as a raw overflow_error.

Also address other outstanding review nits: fix
VaultTransactorPrecision_test's suite module (tx -> app, matching
every other Vault suite), replace the "PR 2" comment in
VaultPrecisionFixture.h with a stable description, and tighten the
clampToAssetsTotalScale docstring in VaultHelpers.h.
2026-08-21 11:14:31 +02:00
Vito
59d4aff3b2 test: Add release regression tests for FN-80/FN-81 vault bugs
Add pre-fix/post-fix VaultBugs_test.cpp coverage for the two
fixCleanup3_4_0 fixes not yet exercised by a dedicated regression: a
VaultDeposit overcrediting the vault across an IOU 16-digit scale
boundary, and a partial VaultWithdraw permanently locking a large IOU
vault by draining sfAssetsTotal to zero while shares remain
outstanding.
2026-08-21 11:14:18 +02:00
Vito
248076d387 fix: Guard debitIsNonZeroDust against overflow_error in VaultWithdraw
debitIsNonZeroDust converts assetsTotal/assetsAvailable to STAmount,
which a sufficiently abused sfScale can push out of STAmount's
representable range -- the same exposure already guarded around
clampToAssetsTotalScale a few lines below. Wrap it the same way,
returning tecPATH_DRY instead of letting the exception reach the
generic tefEXCEPTION handler in applySteps.cpp.

Also marks the clampToAssetsTotalScale catch as LCOV_EXCL: with the
debitIsNonZeroDust check now guarded first, that catch is only
reachable via RoundingMode::Upward carrying a value that was in range
under the ambient rounding mode just past the max representable
exponent -- not realistically triggerable from a test.
2026-08-21 10:36:55 +02:00
JCW
ffa095d7fa WIP 2026-08-21 09:28:54 +01:00
JCW
6d3ed3b8a8 WIP 2026-08-21 08:30:40 +01:00
JCW
b77d5b8f4c Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-21 08:29:44 +01:00
JCW
2f2e306fcd WIP 2026-08-21 08:27:29 +01:00
Vito
38e7a6dd21 fix: Catch overflow_error from clampToAssetsTotalScale in VaultWithdraw
Mirrors the try/catch already used by VaultDeposit and VaultClawback
around the same helper, returning tecPATH_DRY instead of letting the
exception propagate.
2026-08-20 14:55:52 +02:00
Vito
2417a911ac fix: clang-tidy pt2 2026-08-20 13:17:32 +02:00
Vito
c95a1329ea fix: clang-tidy 2026-08-20 12:44:48 +02:00
Vito
1583772faf fix: Guard clamp for zero-payout withdrawals; address review feedback
The clamp block added in VaultWithdraw::doApply unconditionally returned
tecPRECISION_LOSS when the clamped payout was zero. That undid the
develop-side guard that deliberately permits fixed-share zero-asset
withdrawals in a fully-impaired vault (assetsTotalForWithdrawal == 0),
causing xrpl.tx.LoanRounding to fail on all three build-test configs
(linux-clang, linux-gcc-coverage, windows). Skip the clamp when
assetsWithdrawn is already zero, and also on the final-withdrawal path
where the value is overwritten with sfAssetsAvailable a few lines later.

Also addresses review feedback:
- Correct clampToAssetsTotalScale docstring to reflect that it returns a
  non-negative magnitude; the caller applies the sign.
- Add XRPL_ASSERT precondition matching sibling helpers, and canonicalize
  totalBefore/totalAfter under a single NumberRoundModeGuard so the
  returned delta reflects only `delta`, not a rounding difference between
  the two endpoints.
- Move VaultPrecisionFixture.h and VaultTransactorPrecision_test.cpp
  from src/test/app/lending/ to src/test/app/vault/ so vault tests live
  alongside the other vault-specific suites.
- Simplify inline commentary across VaultDeposit/VaultWithdraw/
  VaultClawback.
2026-08-20 12:12:25 +02:00
Vito
0db000bc97 Merge remote-tracking branch 'origin/develop' into tapanito/vault-precision-transactor
# Conflicts:
#	src/libxrpl/ledger/helpers/VaultHelpers.cpp
#	src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
2026-08-20 11:00:36 +02:00
Vito
ec8a9cdbf8 fix: Clamp Vault Deposit, Withdraw, and Clawback to assetsTotal grid
sfAssetsTotal is stored on a coarser STAmount grid than sfAssetsAvailable
and the vault's trust line, so adding the same amount to all three
quantizes differently on each and leaves the vault's books disagreeing
with its actual holdings by a sub-ULP amount. Fix by clamping the
credited/withdrawn amount to what sfAssetsTotal can represent before
applying it to the other rails, via a shared clampToAssetsTotalScale
helper used by all three transactors.

- Deposit: clamp assetsDeposited downward to the assetsTotal grid, then
  re-derive shares from the clamped amount so the depositor cannot
  receive shares worth more than they paid. Return tecPRECISION_LOSS if
  the clamp rounds the deposit to zero.
- Withdraw: clamp assetsWithdrawn upward (i.e. the vault pays out
  slightly less) so it never pays out more than it can account for.
  Shares are not re-derived, so the withdrawer receives slightly less
  per share, favouring remaining holders.
- Clawback: same pattern as withdraw, guarded on assetsRecovered > 0 and
  placed after the existing clamp-to-available.

Gated on fixCleanup3_4_0.
2026-08-19 19:54:50 +02:00
JCW
fa60adb9a6 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-19 17:28:00 +01:00
JCW
7f687b49be Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-19 15:48:27 +01:00
Jingchen
f9bd3dabf5 Merge branch 'develop' into a1q123456/isPseudoAccount-remove-filter 2026-08-19 14:31:20 +01:00
JCW
398e504b58 Address PR comments 2026-08-19 14:31:05 +01:00
JCW
8336f0473f Stop checking pseudo account fields 2026-08-17 13:29:55 +01:00
JCW
7d02e36cb5 WIP 2026-08-12 14:08:15 +01:00
JCW
5895a7a6c8 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-11 15:27:33 +01:00
JCW
1b2c77ab57 Fix clang-tidy error 2026-08-11 15:18:34 +01:00
JCW
943933dd3b Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-11 14:54:40 +01:00
JCW
f8eef48483 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-08-11 14:54:22 +01:00
JCW
cc7513a8ee Update loan invariants 2026-08-10 17:25:48 +01:00
JCW
0367116e44 Remove some checks in favour of cash-basis vaults 2026-07-27 22:47:23 +01:00
JCW
f4cf782ded Address PR comments 2026-07-27 22:38:08 +01:00
JCW
9101dd9fd4 Merge remote-tracking branch 'origin/develop' into a1q123456/add-loan-invariants 2026-07-27 21:53:46 +01:00
JCW
f68f5c200a Update 2026-07-27 21:53:41 +01:00
JCW
534df4169a Add loan and vault invariants 2026-07-17 00:39:10 +01:00
21 changed files with 1969 additions and 56 deletions

View File

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

View File

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

View File

@@ -19,6 +19,10 @@ namespace xrpl {
* 1. If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most one
* node (the root), which will only hold entries for `RippleState` or
* `MPToken` objects.
* 2. Under featureLendingProtocolV1_1, an `ltLOAN_BROKER` may only be deleted
* by a `ttLOAN_BROKER_DELETE` transaction, and only when its pre-state
* `DebtTotal` and `OwnerCount` are both zero.
* 3. At most one `ltLOAN_BROKER` may be deleted in a single transaction.
*
*/
class ValidLoanBroker
@@ -36,6 +40,14 @@ class ValidLoanBroker
// pseudo-accounts. Key is the brokerID / index. It will be used to find the
// LoanBroker object if brokerBefore and brokerAfter are nullptr
std::map<uint256, BrokerInfo> brokers_;
// The broker whose ledger entry was deleted by this transaction, if any.
// Only ttLOAN_BROKER_DELETE removes a broker, and it removes exactly one;
// the final pre-deletion state is captured so the deletion invariants can
// inspect DebtTotal and OwnerCount.
SLE::const_pointer deletedBroker_ = nullptr;
// Set if visitEntry observes more than one ltLOAN_BROKER deletion in the
// same transaction. Enforced as its own invariant in finalize.
bool multipleBrokerDeletions_ = false;
// Collect all the modified trust lines. Their high and low accounts will be
// loaded to look for LoanBroker pseudo-accounts.
std::vector<SLE::const_pointer> lines_;

View File

@@ -15,9 +15,25 @@ namespace xrpl {
/**
* @brief Invariants: Loans are internally consistent
*
* 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`
* 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0`.
* 2. A newly-created Loan against a closed-ended vault must satisfy
* `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`.
* 3. An `ltLOAN` may only be created by a `ttLOAN_SET` transaction.
* 4. Prior to `featureLendingProtocolV1_1`, the `lsfLoanOverpayment` flag on a
* Loan must not change. From `featureLendingProtocolV1_1` onward this check
* is enforced in `InvariantChecks.cpp`.
* 5. Under `featureLendingProtocolV1_1`:
* a. An `ltLOAN` may only be deleted by a `ttLOAN_DELETE` transaction.
* b. If `Loan.PaymentRemaining = 0` then `Loan.NextPaymentDueDate = 0`.
* c. The `lsfLoanImpaired` flag may only change through a `ttLOAN_MANAGE`
* or `ttLOAN_PAY` transaction.
* d. The `lsfLoanDefault` flag may only change through a `ttLOAN_MANAGE`
* transaction.
* e. Interest due, computed as `TotalValueOutstanding -
* PrincipalOutstanding - ManagementFeeOutstanding`, must not be
* negative.
* f. A Loan must reference a live `ltLOAN_BROKER`, and that broker must
* reference a live `ltVAULT`.
*
*/
class ValidLoan
@@ -25,6 +41,8 @@ class ValidLoan
// Pair is <before, after>. After is used for most of the checks, except
// those that check changed values.
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> loans_;
// Loans removed from the ledger (final state captured at deletion).
std::vector<SLE::const_pointer> deletedLoans_;
public:
void

View File

@@ -48,7 +48,10 @@ namespace xrpl {
* vault phase is Investment
*
* Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced
* by NoModifiedUnmodifiableFields (see InvariantCheck.cpp).
* by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). From
* featureLendingProtocolV1_1 onwards, immutability of the vault's Asset,
* pseudo-account and ShareMPTID is likewise enforced by
* NoModifiedUnmodifiableFields; prior to that amendment it is checked here.
*/
class ValidVault
{

View File

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

View File

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

View File

@@ -1098,6 +1098,7 @@ ValidPseudoAccounts::finalize(
if (enforce)
return false;
}
return true;
}
@@ -1123,10 +1124,21 @@ NoModifiedUnmodifiableFields::finalize(
ReadView const& view,
beast::Journal const& j)
{
static auto const kFieldChanged = [](auto const& before, auto const& after, auto const& field) {
// Non-static so it can capture `j` and `tx` and emit the specific field
// name on detection. The outer `bad || ...` chain short-circuits after the
// first change is seen, so only the first offending field is logged per
// entry - enough for diagnosis, and cheaper than accumulating all names.
auto const kFieldChanged = [&j, &tx](auto const& before, auto const& after, auto const& field) {
bool const beforeField = before->isFieldPresent(field);
bool const afterField = after->isFieldPresent(field);
return beforeField != afterField || (afterField && before->at(field) != after->at(field));
bool const changed =
beforeField != afterField || (afterField && before->at(field) != after->at(field));
if (changed)
{
JLOG(j.fatal()) << "Invariant failed: " << field.getName()
<< " changed on immutable ledger entry in " << tx.getTransactionID();
}
return changed;
};
for (auto const& slePair : changedEntries_)
{
@@ -1172,13 +1184,44 @@ NoModifiedUnmodifiableFields::finalize(
kFieldChanged(before, after, sfPaymentInterval) ||
kFieldChanged(before, after, sfGracePeriod) ||
kFieldChanged(before, after, sfLoanScale);
// lsfLoanOverpayment is immutable after creation. Check it ungated, as
// LoanInvariant did before V1_1; the enclosing featureLendingProtocol gate suffices
// because only that amendment creates ltLOAN entries. lsfLoanDefault may change
// only from unset to set through tfLoanDefault. Under V1_1, reject attempts to
// clear it, matching LoanInvariant's previous gate.
{
if (view.rules().enabled(featureLendingProtocolV1_1))
{
std::uint32_t const beforeFlags = before->getFlags();
std::uint32_t const afterFlags = after->getFlags();
bool const overpaymentChanged =
(beforeFlags & lsfLoanOverpayment) != (afterFlags & lsfLoanOverpayment);
if (overpaymentChanged)
{
JLOG(j.fatal()) << "Invariant failed: lsfLoanOverpayment flag "
"toggled on immutable ledger entry in "
<< tx.getTransactionID();
}
bad = bad || overpaymentChanged;
bool const defaultCleared = (beforeFlags & lsfLoanDefault) != 0 &&
(afterFlags & lsfLoanDefault) == 0;
if (defaultCleared)
{
JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault flag "
"cleared on immutable ledger entry in "
<< tx.getTransactionID();
}
bad = bad || defaultCleared;
}
}
break;
case ltVAULT:
/*
* sfAccount, sfAsset and sfShareMPTID are already
* captured by VaultInvariant. The additional fields
* below are introduced by featureLendingProtocolV1_1
* and only exist on V1_1 vaults.
* Immutability of sfAccount, sfAsset and sfShareMPTID used to be enforced by
* VaultInvariant, but is now checked here since InvariantCheck.cpp is where
* immutability checks live. The additional fields below are introduced by
* featureLendingProtocolV1_1 and only exist on V1_1 vaults.
*/
if (view.rules().enabled(featureLendingProtocolV1_1))
{
@@ -1190,7 +1233,10 @@ NoModifiedUnmodifiableFields::finalize(
kFieldChanged(before, after, sfOwner) ||
kFieldChanged(before, after, sfWithdrawalPolicy) ||
kFieldChanged(before, after, sfScale) ||
kFieldChanged(before, after, sfLEVersion);
kFieldChanged(before, after, sfLEVersion) ||
kFieldChanged(before, after, sfAsset) ||
kFieldChanged(before, after, sfAccount) ||
kFieldChanged(before, after, sfShareMPTID);
}
break;
default:

View File

@@ -2,6 +2,7 @@
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/Feature.h>
@@ -22,6 +23,38 @@ namespace xrpl {
void
ValidLoanBroker::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
// Track LoanBroker deletions so finalize() can enforce:
// (a) only ttLOAN_BROKER_DELETE removes a broker
// (b) at most one broker is removed per transaction
// (c) DebtTotal and OwnerCount were zero before deletion
// `before` holds the entry's state prior to erasure.
//
// Deleted trust lines/MPTokens are recorded (from `before`) so finalize() can find the broker
// via its pseudo-account and check CoverAvailable still matches the pseudo-account balance.
if (isDelete && before)
{
switch (before->getType())
{
case ltLOAN_BROKER:
if (deletedBroker_)
{
multipleBrokerDeletions_ = true;
}
else
{
deletedBroker_ = before;
}
break;
case ltRIPPLE_STATE:
lines_.emplace_back(before);
break;
case ltMPTOKEN:
mpts_.emplace_back(before);
break;
default:
break;
}
}
if (after)
{
if (after->getType() == ltLOAN_BROKER)
@@ -99,6 +132,44 @@ ValidLoanBroker::finalize(
// Loan Brokers will not exist on ledger if the Lending Protocol amendment
// is not enabled, so there's no need to check it.
// Deletion invariants (featureLendingProtocolV1_1). At most one
// LoanBroker may be removed per transaction, and only by
// ttLOAN_BROKER_DELETE, and only when its pre-state DebtTotal and
// OwnerCount are both zero. The DebtTotal check complements ValidLoan's
// LoanBrokerDelete-must-not-touch-any-loan rule: even a broker that has
// finished paying off every loan may still hold non-zero exposure until
// its LoanBrokerCoverWithdraw settles, and neither state is safe to
// delete.
if (view.rules().enabled(featureLendingProtocolV1_1))
{
if (multipleBrokerDeletions_)
{
JLOG(j.fatal())
<< "Invariant failed: more than one Loan Broker deleted in a single transaction";
return false;
}
if (deletedBroker_)
{
if (tx.getTxnType() != ttLOAN_BROKER_DELETE)
{
JLOG(j.fatal()) << "Invariant failed: " << //
"Loan Broker deleted by a transaction other than LoanBrokerDelete";
return false;
}
if (deletedBroker_->at(sfDebtTotal) != beast::kZero)
{
JLOG(j.fatal()) << "Invariant failed: Loan Broker deleted with non-zero debt total";
return false;
}
if (deletedBroker_->at(sfOwnerCount) != 0)
{
JLOG(j.fatal())
<< "Invariant failed: Loan Broker deleted with non-zero owner count";
return false;
}
}
}
for (auto const& line : lines_)
{
for (auto const& field : {&sfLowLimit, &sfHighLimit})
@@ -142,7 +213,6 @@ ValidLoanBroker::finalize(
auto const& before = broker.brokerBefore;
// https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3123-invariants
// If `LoanBroker.OwnerCount = 0` the `DirectoryNode` will have at most
// one node (the root), which will only hold entries for `RippleState`
// or `MPToken` objects.

View File

@@ -5,6 +5,7 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
@@ -13,6 +14,7 @@
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstdint>
@@ -22,7 +24,13 @@ namespace xrpl {
void
ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
if (after && after->getType() == ltLOAN)
if (isDelete)
{
// `before` holds the loan's state before deletion.
if (before && before->getType() == ltLOAN)
deletedLoans_.emplace_back(before);
}
else if (after && after->getType() == ltLOAN)
{
loans_.emplace_back(before, after);
}
@@ -39,6 +47,10 @@ ValidLoan::finalize(
// Loans will not exist on ledger if the Lending Protocol amendment
// is not enabled, so there's no need to check it.
auto const txType = tx.getTxnType();
bool const lpV11Enabled = view.rules().enabled(featureLendingProtocolV1_1);
// Ledger entry validation checks.
for (auto const& [before, after] : loans_)
{
// A closed-ended vault must not accept a loan whose final scheduled payment falls on or
@@ -91,7 +103,10 @@ ValidLoan::finalize(
JLOG(j.fatal()) << "Invariant failed: Fully paid off Loan still has payments remaining";
return false;
}
if (before && (before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment)))
// The flag immutability check has been moved to InvariantChecks.cpp
if (!lpV11Enabled && before &&
(before->isFlag(lsfLoanOverpayment) != after->isFlag(lsfLoanOverpayment)))
{
JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed";
return false;
@@ -123,6 +138,79 @@ ValidLoan::finalize(
return false;
}
}
if (lpV11Enabled)
{
// Only LoanSet may create a loan. This is an object-existence rule, not
// a transaction post-condition, so it applies even when apply failed.
if (!before && txType != ttLOAN_SET)
{
JLOG(j.fatal()) << "Invariant failed: Loan created by a transaction "
"other than LoanSet";
return false;
}
if (after->at(sfPaymentRemaining) == 0 &&
after->at(~sfNextPaymentDueDate).value_or(0) != 0)
{
JLOG(j.fatal()) << "Invariant failed: Loan with zero payments must have zero next "
"payment due date";
return false;
}
if (before)
{
bool const wasImpaired = before->isFlag(lsfLoanImpaired);
bool const isImpaired = after->isFlag(lsfLoanImpaired);
bool const wasDefaulted = before->isFlag(lsfLoanDefault);
bool const isDefaulted = after->isFlag(lsfLoanDefault);
if (wasImpaired != isImpaired && txType != ttLOAN_MANAGE && txType != ttLOAN_PAY)
{
JLOG(j.fatal()) << "Invariant failed: lsfLoanImpaired changed "
"outside LoanManage or LoanPay";
return false;
}
if (wasDefaulted != isDefaulted && txType != ttLOAN_MANAGE)
{
JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault changed "
"outside LoanManage";
return false;
}
}
// Interest due (the total value owed less principal and management fee)
// must never be negative.
if (after->at(sfTotalValueOutstanding) - after->at(sfPrincipalOutstanding) -
after->at(sfManagementFeeOutstanding) <
beast::kZero)
{
JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative";
return false;
}
// A loan must reference a live loan broker, and that broker must
// reference a live vault; otherwise the loan is orphaned and its
// balances have no counterparty on the ledger.
auto const brokerSle = view.read(keylet::loanBroker(after->at(sfLoanBrokerID)));
if (!brokerSle)
{
JLOG(j.fatal()) << "Invariant failed: Loan broker does not exist";
return false;
}
if (!view.read(keylet::vault(brokerSle->at(sfVaultID))))
{
JLOG(j.fatal()) << "Invariant failed: Loan broker vault does not exist";
return false;
}
}
}
// Deletion by the wrong transaction is an invalid object transition even
// when apply failed, so check it before the success-only post-conditions.
if (lpV11Enabled && txType != ttLOAN_DELETE && !deletedLoans_.empty())
{
JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction "
"other than LoanDelete";
return false;
}
return true;
}

View File

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

View File

@@ -515,7 +515,8 @@ ValidVault::finalize(
bool result = true;
// Universal transaction checks
if (!beforeVault_.empty())
// From LendingProtocolV1_1 onwards, vault immutability check is moved to InvariantCheck.cpp
if (!beforeVault_.empty() && !view.rules().enabled(featureLendingProtocolV1_1))
{
auto const& beforeVault = beforeVault_[0];
if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId ||

View File

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

View File

@@ -237,6 +237,7 @@ VaultClawback::assetsToClawback(
AccountID const& holder,
STAmount const& clawbackAmount)
{
bool const fix340Enabled = ctx_.view().rules().enabled(fixCleanup3_4_0);
if (clawbackAmount.asset() != vault->at(sfAsset))
{
// preclaim should have blocked this , now it's an internal error
@@ -271,14 +272,32 @@ VaultClawback::assetsToClawback(
// Number arithmetic can throw overflow_error when Scale and totals are large. Caught below.
try
{
// Do not discount a sole holder's shares: clawing back AssetsAvailable
// at the discounted rate can burn every share while loan assets remain.
auto const waiveUnrealizedLoss =
fix340Enabled && isSoleShareholder(view(), holder, sleShareIssuance)
? WaiveUnrealizedLoss::Yes
: WaiveUnrealizedLoss::No;
if (clawbackAmount == beast::kZero)
{
// Zero amount means clawback all shares the holder has; derive the corresponding asset
// amount from the share balance.
sharesDestroyed = accountHolds(
view(), holder, share, FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, j_);
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
// isSoleShareholder already established that the holder owns the
// entire outstanding share supply whenever the waiver applies, so
// sfOutstandingAmount gives sharesDestroyed directly, avoiding a
// redundant MPToken read via accountHolds.
sharesDestroyed = waiveUnrealizedLoss == WaiveUnrealizedLoss::Yes
? STAmount{share, sleShareIssuance->at(sfOutstandingAmount)}
: accountHolds(
view(),
holder,
share,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
j_);
auto const maybeAssets = sharesToAssetsWithdraw(
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
if (!maybeAssets)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
@@ -291,16 +310,15 @@ VaultClawback::assetsToClawback(
// Post-amendment: truncate shares so assetsRecovered <=
// clawbackAmount by construction (matches the clamp branch
// below).
auto const truncate = ctx_.view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes
: TruncateShares::No;
auto const maybeShares =
assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount, truncate);
auto const truncate = fix340Enabled ? TruncateShares::Yes : TruncateShares::No;
auto const maybeShares = assetsToSharesWithdraw(
vault, sleShareIssuance, clawbackAmount, truncate, waiveUnrealizedLoss);
if (!maybeShares)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
sharesDestroyed = *maybeShares;
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
auto const maybeAssets = sharesToAssetsWithdraw(
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
if (!maybeAssets)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
assetsRecovered = *maybeAssets;
@@ -312,14 +330,18 @@ VaultClawback::assetsToClawback(
assetsRecovered = *assetsAvailable;
{
auto const maybeShares = assetsToSharesWithdraw(
vault, sleShareIssuance, assetsRecovered, TruncateShares::Yes);
vault,
sleShareIssuance,
assetsRecovered,
TruncateShares::Yes,
waiveUnrealizedLoss);
if (!maybeShares)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
sharesDestroyed = *maybeShares;
}
auto const maybeAssets =
sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed);
auto const maybeAssets = sharesToAssetsWithdraw(
vault, sleShareIssuance, sharesDestroyed, waiveUnrealizedLoss);
if (!maybeAssets)
return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE
assetsRecovered = *maybeAssets;

View File

@@ -10,11 +10,13 @@
#include <test/jtx/vault.h>
#include <test/unit_test/SuiteJournal.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
@@ -31,6 +33,7 @@
#include <xrpl/tx/applySteps.h>
#include <chrono>
#include <cstdint>
#include <initializer_list>
#include <memory>
#include <source_location>
@@ -206,4 +209,32 @@ InvariantsBase::createLoanBroker(
return loanBrokerKeylet;
}
SLE::pointer
InvariantsBase::makeLoanSle(
uint256 const& loanBrokerID,
std::uint32_t loanSeq,
AccountID const& borrower)
{
auto sleLoan =
std::make_shared<SLE>(keylet::loan(loanBrokerID, SeqProxy::rawSequence(loanSeq)));
// SoeRequired fields.
sleLoan->at(sfLoanBrokerID) = loanBrokerID;
sleLoan->at(sfLoanSequence) = loanSeq;
sleLoan->at(sfBorrower) = borrower;
sleLoan->at(sfStartDate) = 0u;
sleLoan->at(sfPaymentInterval) = 1u;
sleLoan->at(sfPeriodicPayment) = Number(1);
// SoeDefault fields read unconditionally by ValidLoan::finalize.
sleLoan->at(sfLoanServiceFee) = Number(0);
sleLoan->at(sfLatePaymentFee) = Number(0);
sleLoan->at(sfClosePaymentFee) = Number(0);
sleLoan->at(sfPrincipalOutstanding) = Number(0);
sleLoan->at(sfTotalValueOutstanding) = Number(0);
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
sleLoan->setFieldU32(sfPaymentRemaining, 0);
sleLoan->makeFieldPresent(sfOwnerNode);
sleLoan->makeFieldPresent(sfLoanBrokerNode);
return sleLoan;
}
} // namespace xrpl::test

View File

@@ -4,7 +4,11 @@
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <xrpld/app/main/Application.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/STObject.h>
@@ -14,6 +18,7 @@
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <cstdint>
#include <functional>
#include <initializer_list>
#include <memory>
@@ -117,6 +122,13 @@ protected:
Keylet
createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset);
// Build an ltLOAN SLE with every SoeRequired field explicitly set and
// every SoeDefault field the invariants read via `at()` materialized, so
// rawInsert-based tests don't accidentally trip an unrelated invariant
// or throw from a missing SoeDefault field.
static SLE::pointer
makeLoanSle(uint256 const& loanBrokerID, std::uint32_t loanSeq, AccountID const& borrower);
};
} // namespace xrpl::test

View File

@@ -8,6 +8,7 @@
#include <test/jtx/vault.h>
#include <test/unit_test/SuiteJournal.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
@@ -43,6 +44,7 @@
#include <xrpl/tx/invariants/InvariantRunner.h>
#include <array>
#include <cstdint>
#include <functional>
#include <initializer_list>
#include <memory>
@@ -757,7 +759,229 @@ class InvariantsMisc_test : public InvariantsBase
}
}
// TODO: Loan Object
// Loan flag immutability lives in NoModifiedUnmodifiableFields's
// ltLOAN case: lsfLoanOverpayment must never toggle in either
// direction, and lsfLoanDefault (gated on featureLendingProtocolV1_1)
// may only transition from unset to set. Each case needs a loan that
// already exists in the base ledger so the apply-view modification is
// seen as a before/after change; the shared harness cannot seed one,
// hence the bespoke view construction below.
{
struct Case
{
std::uint32_t before;
std::uint32_t after;
std::string expected;
};
auto const cases = std::to_array<Case>({
{.before = lsfLoanOverpayment,
.after = 0,
.expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"},
{.before = 0,
.after = lsfLoanOverpayment,
.expected = "lsfLoanOverpayment flag toggled on immutable ledger entry"},
{.before = lsfLoanDefault,
.after = 0,
.expected = "lsfLoanDefault flag cleared on immutable ledger entry"},
});
for (auto const& c : cases)
{
Env env{*this, all_};
Account const a1{"A1"};
env.fund(XRP(1000), a1);
env.close();
OpenView ov{*env.current()};
auto const brokerKeylet =
keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq()));
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
{
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(150);
sleLoan->setFieldU32(sfPaymentRemaining, 1);
sleLoan->setFieldU32(sfFlags, c.before);
ov.rawInsert(sleLoan);
}
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
auto sleLoan = ac.view().peek(loanKeylet);
if (!BEAST_EXPECT(sleLoan))
continue;
sleLoan->setFieldU32(sfFlags, c.after);
ac.view().update(sleLoan);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
continue;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(sink.messages().str().contains(c.expected));
}
}
// Pre-featureLendingProtocolV1_1 sibling of the lsfLoanOverpayment
// cases above: the same set-once immutability was originally enforced
// by ValidLoan::finalize, so with V1_1 disabled toggling the flag
// must trip that legacy check instead. lsfLoanDefault immutability
// did not exist pre-V1_1 and is not tested here.
{
auto const cases = std::to_array<std::pair<std::uint32_t, std::uint32_t>>({
{lsfLoanOverpayment, 0},
{0, lsfLoanOverpayment},
});
for (auto const& [before, after] : cases)
{
Env env{*this, all_ - featureLendingProtocolV1_1};
Account const a1{"A1"};
env.fund(XRP(1000), a1);
env.close();
OpenView ov{*env.current()};
auto const brokerKeylet =
keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq()));
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
{
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(150);
sleLoan->setFieldU32(sfPaymentRemaining, 1);
sleLoan->setFieldU32(sfFlags, before);
ov.rawInsert(sleLoan);
}
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
auto sleLoan = ac.view().peek(loanKeylet);
if (!BEAST_EXPECT(sleLoan))
continue;
sleLoan->setFieldU32(sfFlags, after);
ac.view().update(sleLoan);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
continue;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(sink.messages().str().contains("Loan Overpayment flag changed"));
}
}
// ValidLoan::finalize enforces (under featureLendingProtocolV1_1) that
// interest due - TotalValueOutstanding minus PrincipalOutstanding minus
// ManagementFeeOutstanding - is never negative. Any rounding path in
// LoanPay / LoanManage that rounds Principal or ManagementFee up while
// rounding TotalValue down (or vice-versa) by a single ULP flips this
// negative and would halt the ledger. Exercise each of the three
// components at a one-ULP overshoot to cover the boundary explicitly,
// plus the exact-zero case to confirm the boundary itself is
// accepted.
{
struct Case
{
Number totalValue;
Number principal;
Number managementFee;
bool expectFire;
};
// Baseline: Principal=100, TotalValue=100, MgmtFee=0
// (interest due = 0, exactly at the boundary). Each firing case
// perturbs one component by -1 or +1 so interest_due = -1.
auto const cases = std::to_array<Case>({
{.totalValue = Number(100),
.principal = Number(100),
.managementFee = Number(0),
.expectFire = false},
{.totalValue = Number(99),
.principal = Number(100),
.managementFee = Number(0),
.expectFire = true},
{.totalValue = Number(100),
.principal = Number(101),
.managementFee = Number(0),
.expectFire = true},
{.totalValue = Number(100),
.principal = Number(100),
.managementFee = Number(1),
.expectFire = true},
});
for (auto const& c : cases)
{
Env env{*this, all_};
Account const a1{"A1"};
env.fund(XRP(1000), a1);
env.close();
OpenView ov{*env.current()};
auto const brokerKeylet =
keylet::loanBroker(a1.id(), SeqProxy::rawSequence(ov.seq()));
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
// Seed a loan whose interest due sits at the boundary
// (100 - 100 - 0 = 0). The apply-view update below moves it.
{
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(100);
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
sleLoan->setFieldU32(sfPaymentRemaining, 1);
ov.rawInsert(sleLoan);
}
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
auto sleLoan = ac.view().peek(loanKeylet);
if (!BEAST_EXPECT(sleLoan))
continue;
sleLoan->at(sfTotalValueOutstanding) = c.totalValue;
sleLoan->at(sfPrincipalOutstanding) = c.principal;
sleLoan->at(sfManagementFeeOutstanding) = c.managementFee;
ac.view().update(sleLoan);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
continue;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
auto const messages = sink.messages().str();
if (c.expectFire)
{
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(messages.contains("Loan interest due is negative"));
}
else
{
// The boundary case (interest due == 0) must not trip the
// interest-due check. Other invariants may still fire
// (e.g. the broker-existence check on this raw-inserted
// loan), so only assert the specific message is absent.
BEAST_EXPECT(!messages.contains("Loan interest due is negative"));
}
}
}
// VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation.
// Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged.

View File

@@ -7,18 +7,23 @@
#include <test/jtx/pay.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <test/unit_test/SuiteJournal.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Rules.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STObject.h>
@@ -28,6 +33,7 @@
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/applySteps.h>
#include <array>
@@ -43,6 +49,8 @@ namespace xrpl::test {
class InvariantsPseudoAccount_test : public InvariantsBase
{
FeatureBitset const all_{test::jtx::testableAmendments()};
void
testValidPseudoAccounts()
{
@@ -445,6 +453,245 @@ class InvariantsPseudoAccount_test : public InvariantsBase
STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
createLoanBroker);
// A holding (trust line) removed while the broker still reports
// non-zero CoverAvailable must not bypass the cover invariant. The
// broker is left untouched, so it is only reachable through the
// deleted holding's before-state; erasing the holding drops the
// pseudo-account balance to zero while CoverAvailable stays
// positive.
//
// Only the IOU (trust line) case is exercised here. XRP cover has
// no holding SLE to delete, and an MPToken cannot be erased in
// isolation: the ungated ValidMPTIssuance "a MPToken was deleted"
// check fires for any transaction lacking MayDeleteMpt, and every
// transaction that carries that privilege also carries a Must*
// privilege (delete an account / modify a vault) that trips a
// different invariant. A trust line has no such generic deletion
// invariant, so it isolates the cover check cleanly. The RippleState
// and MPToken discovery paths are otherwise symmetric in finalize.
//
// The cover-greater-than-balance invariant is gated behind
// fixCleanup3_1_3, so the same forced deletion is run under both
// rule sets: with the amendment the invariant fires, without it the
// state is silently accepted.
if (assetType == Asset::IOU)
{
Keylet brokerKeylet = keylet::amendments();
Preclose const createBrokerWithCover =
[&, this](Account const& alice, Account const& issuer, Env& env) {
auto const asset = setupAsset(alice, issuer, env);
brokerKeylet = this->createLoanBroker(alice, env, asset);
if (!BEAST_EXPECT(env.le(brokerKeylet)))
return false;
env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10)));
env.close();
return BEAST_EXPECT(env.le(brokerKeylet));
};
Precheck const deleteHolding =
[&](Account const&, Account const&, ApplyContext& ac) {
if (brokerKeylet.type != ltLOAN_BROKER)
return false;
// Read (don't touch) the broker so it is only found via
// the deleted holding, not as a modified entry.
auto const sleBroker = ac.view().read(brokerKeylet);
if (!BEAST_EXPECT(sleBroker))
return false;
auto const pseudoAccountID = sleBroker->at(sfAccount);
// Erase every holding in the pseudo-account directory
// and the directory root itself, mirroring a bug that
// removed the cover holding without zeroing
// CoverAvailable. Removing the root also keeps the
// zero-OwnerCount directory check from firing first.
auto sleDir = ac.view().peek(keylet::ownerDir(pseudoAccountID));
if (!BEAST_EXPECT(sleDir))
return false;
for (auto const& index : sleDir->getFieldV256(sfIndexes))
{
if (auto holding = ac.view().peek(keylet::unchecked(index)))
{
ac.view().erase(holding);
}
}
ac.view().erase(sleDir);
return true;
};
// With fixCleanup3_1_3: the invariant fires.
doInvariantCheck(
makeEnv(all_),
{{"Loan Broker cover available is greater than pseudo-account asset balance"}},
deleteHolding,
XRPAmount{},
STTx{ttACCOUNT_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
createBrokerWithCover);
// Without fixCleanup3_1_3: the same state is silently accepted.
doInvariantCheck(
makeEnv(all_ - fixCleanup3_1_3),
{},
deleteHolding,
XRPAmount{},
STTx{ttACCOUNT_SET, [](STObject&) {}},
{tesSUCCESS, tesSUCCESS},
createBrokerWithCover);
}
// A LoanBroker may only be removed by ttLOAN_BROKER_DELETE. Erase
// the broker in the apply view under a non-delete tx type and
// expect the deletion-tx invariant to fire.
doInvariantCheck(
{{"Loan Broker deleted by a transaction other than LoanBrokerDelete"}},
[&](Account const&, Account const&, ApplyContext& ac) {
if (loanBrokerKeylet.type != ltLOAN_BROKER)
return false;
auto sleBroker = ac.view().peek(loanBrokerKeylet);
if (!BEAST_EXPECT(sleBroker))
return false;
ac.view().erase(sleBroker);
return true;
},
XRPAmount{},
STTx{ttACCOUNT_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
createLoanBroker);
}
// A LoanBrokerDelete must not remove a broker whose pre-transaction
// DebtTotal is non-zero. visitEntry captures `before` from the parent
// view, so the DebtTotal must be seeded in the OpenView before the
// ApplyContext is constructed; a Precheck modification would only
// land in the applyView (visible as `after`) and would leave `before`
// at the createLoanBroker-produced zero.
{
Env env{*this};
Account const a1{"A1"};
Account const a2{"A2"};
env.fund(XRP(1000), a1, a2);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
if (!BEAST_EXPECT(env.le(brokerKeylet)))
return;
env.close();
OpenView ov{*env.current()};
// Seed a non-zero DebtTotal in the base view so `before` at
// visitEntry time reports it.
{
auto const sleBrokerRead = ov.read(brokerKeylet);
if (!BEAST_EXPECT(sleBrokerRead))
return;
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
sleBroker->at(sfDebtTotal) = Number(1);
ov.rawReplace(sleBroker);
}
STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
auto sleBroker = ac.view().peek(brokerKeylet);
if (!BEAST_EXPECT(sleBroker))
return;
ac.view().erase(sleBroker);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
return;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(
sink.messages().str().contains("Loan Broker deleted with non-zero debt total"));
}
// A LoanBrokerDelete must not remove a broker whose pre-transaction
// OwnerCount is non-zero. DebtTotal is left at zero so the earlier
// check passes and the OwnerCount check is what fires.
{
Env env{*this};
Account const a1{"A1"};
Account const a2{"A2"};
env.fund(XRP(1000), a1, a2);
env.close();
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
if (!BEAST_EXPECT(env.le(brokerKeylet)))
return;
env.close();
OpenView ov{*env.current()};
{
auto const sleBrokerRead = ov.read(brokerKeylet);
if (!BEAST_EXPECT(sleBrokerRead))
return;
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
sleBroker->at(sfOwnerCount) = 1;
ov.rawReplace(sleBroker);
}
STTx const tx{ttLOAN_BROKER_DELETE, [](STObject&) {}};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
auto sleBroker = ac.view().peek(brokerKeylet);
if (!BEAST_EXPECT(sleBroker))
return;
ac.view().erase(sleBroker);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
return;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(
sink.messages().str().contains("Loan Broker deleted with non-zero owner count"));
}
// Only one LoanBroker may be deleted per transaction. Create two
// brokers under different owners, then erase both in the apply view
// and expect the multi-deletion invariant to fire.
{
Keylet loanBrokerKeylet1 = keylet::amendments();
Keylet loanBrokerKeylet2 = keylet::amendments();
Preclose const createTwoBrokers = [&, this](
Account const& a1, Account const& a2, Env& env) {
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
loanBrokerKeylet1 = this->createLoanBroker(a1, env, xrpAsset);
loanBrokerKeylet2 = this->createLoanBroker(a2, env, xrpAsset);
return BEAST_EXPECT(env.le(loanBrokerKeylet1) && env.le(loanBrokerKeylet2));
};
doInvariantCheck(
{{"more than one Loan Broker deleted in a single transaction"}},
[&](Account const&, Account const&, ApplyContext& ac) {
auto sle1 = ac.view().peek(loanBrokerKeylet1);
auto sle2 = ac.view().peek(loanBrokerKeylet2);
if (!BEAST_EXPECT(sle1 && sle2))
return false;
ac.view().erase(sle1);
ac.view().erase(sle2);
return true;
},
XRPAmount{},
STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
createTwoBrokers);
}
}

View File

@@ -8,12 +8,16 @@
#include <test/jtx/sig.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <test/unit_test/SuiteJournal.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/protocol/AccountID.h>
@@ -38,10 +42,12 @@
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/applySteps.h>
#include <xrpl/tx/invariants/VaultInvariant.h>
#include <array>
#include <chrono>
#include <cstdint>
#include <functional>
#include <initializer_list>
@@ -68,6 +74,19 @@ class InvariantsVault_test : public InvariantsBase
AccountID account;
int amount;
};
// Parameters for a synthetic loan object created alongside a vault
// adjustment. The interest due booked to the vault is
// totalValueOutstanding - principalOutstanding - managementFeeOutstanding.
struct LoanParams
{
int principalOutstanding = 0;
int totalValueOutstanding = 0;
int managementFeeOutstanding = 0;
AccountID borrower = beast::kZero;
// Real broker key the created loan should reference. If unset,
// the vault key is used as a fallback for legacy callers.
uint256 brokerKey = beast::kZero;
};
struct Adjustments
{
// NOLINTBEGIN(readability-redundant-member-init)
@@ -79,6 +98,10 @@ class InvariantsVault_test : public InvariantsBase
std::optional<int> vaultAssets = std::nullopt;
std::optional<AccountAmount> accountAssets = std::nullopt;
std::optional<AccountAmount> accountShares = std::nullopt;
std::optional<LoanParams> createLoan = std::nullopt;
// Number of loan objects to create (only used when createLoan is
// set); a valid loan set creates exactly one.
int loanCount = 1;
// NOLINTEND(readability-redundant-member-init)
};
constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) {
@@ -186,6 +209,26 @@ class InvariantsVault_test : public InvariantsBase
(*sleMPToken)[sfMPTAmount] = addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount);
ac.update(sleMPToken);
}
if (args.createLoan)
{
auto const& lp = *args.createLoan;
bool const anyOutstanding = lp.principalOutstanding != 0 ||
lp.totalValueOutstanding != 0 || lp.managementFeeOutstanding != 0;
// If the caller supplied a broker key, use it; otherwise fall
// back to the vault key so pre-existing callers keep working.
uint256 const brokerKey = lp.brokerKey != beast::kZero ? lp.brokerKey : keylet.key;
for (std::uint32_t seq = 1; seq <= static_cast<std::uint32_t>(args.loanCount);
++seq)
{
auto sleLoan = makeLoanSle(brokerKey, seq, lp.borrower);
sleLoan->at(sfPrincipalOutstanding) = Number(lp.principalOutstanding);
sleLoan->at(sfTotalValueOutstanding) = Number(lp.totalValueOutstanding);
sleLoan->at(sfManagementFeeOutstanding) = Number(lp.managementFeeOutstanding);
sleLoan->setFieldU32(sfPaymentRemaining, anyOutstanding ? 1 : 0);
ac.insert(sleLoan);
}
}
return true;
};
@@ -217,6 +260,18 @@ class InvariantsVault_test : public InvariantsBase
return true;
};
auto const createClosedXrpBroker =
[&](Account const& owner, Env& env) -> std::optional<std::pair<Keylet, Keylet>> {
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const brokerKeylet = createLoanBroker(owner, env, xrpAsset);
auto const sleBroker = env.le(brokerKeylet);
if (!BEAST_EXPECT(sleBroker))
return std::nullopt;
auto const vaultKeylet = keylet::vault(sleBroker->at(sfVaultID));
env.close(std::chrono::seconds{61});
return std::pair{vaultKeylet, brokerKeylet};
};
testcase << "Vault general checks";
doInvariantCheck(
{"vault deletion succeeded without deleting a vault"},
@@ -598,7 +653,67 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp,
TxAccount::A2);
// Under featureLendingProtocolV1_1 vault immutability is enforced by
// NoModifiedUnmodifiableFields (class-1, both passes), which reports
// "changed an unchangeable field" and escalates to tef on pass 2.
doInvariantCheck(
{"changed an unchangeable field"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))});
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttVAULT_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
doInvariantCheck(
{"changed an unchangeable field"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
sleVault->setAccountID(sfAccount, a2.id());
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttVAULT_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
doInvariantCheck(
{"changed an unchangeable field"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
(*sleVault)[sfShareMPTID] = MPTID(42);
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttVAULT_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// Pre-featureLendingProtocolV1_1 the immutability of sfAsset, sfAccount
// and sfShareMPTID is enforced by ValidVault directly, which reports
// "violation of vault immutable data" on the first pass. ValidVault
// returns early on the second pass (result already tec), so the check
// does not escalate to tef. Once featureLendingProtocolV1_1 activates, the same fields are
// covered by NoModifiedUnmodifiableFields (see the three cases above);
// the two paths are mutually exclusive so both need coverage.
auto const preLendingV11Amendments = all_ - featureLendingProtocolV1_1;
doInvariantCheck(
makeEnv(preLendingV11Amendments),
{"violation of vault immutable data"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
@@ -615,6 +730,7 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp);
doInvariantCheck(
makeEnv(preLendingV11Amendments),
{"violation of vault immutable data"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
@@ -631,6 +747,7 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp);
doInvariantCheck(
makeEnv(preLendingV11Amendments),
{"violation of vault immutable data"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
@@ -698,9 +815,12 @@ class InvariantsVault_test : public InvariantsBase
TxAccount::A2);
// Without fixCleanup3_4_0 the same state must NOT trip the invariant,
// preserving pre-amendment behavior (no fork risk).
// preserving pre-amendment behavior (no fork risk). Also remove
// featureLendingProtocolV1_1 so finalizeLoanManage's stricter checks
// (exactly one loan touched) do not fire from a bare vault mutation
// that does not touch a loan.
doInvariantCheck(
makeEnv(all_ - fixCleanup3_4_0),
makeEnv(all_ - fixCleanup3_4_0 - featureLendingProtocolV1_1),
{},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
@@ -808,6 +928,650 @@ class InvariantsVault_test : public InvariantsBase
precloseXrp,
TxAccount::A2);
// ttLOAN_SET pre-featureLendingProtocolV1_1: finalizeLoanSet short-
// circuits and returns success without inspecting the loan or the
// vault. The same state that trips the principal-outstanding check
// under V1_1 must be silently accepted here.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_1),
{},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsAvailable = -200,
.vaultAssets = -200,
.accountAssets = AccountAmount{.account = a2.id(), .amount = 200},
.createLoan = LoanParams{
.principalOutstanding = 300,
.totalValueOutstanding = 300,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }},
{tesSUCCESS, tesSUCCESS},
precloseXrp);
// ttLOAN_MANAGE: a loan is created rather than modified. This object-
// existence rule applies on both invariant passes.
doInvariantCheck(
{"Loan created by a transaction other than LoanSet"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
return kAdjust(
ac.view(),
keylet,
Adjustments{
.createLoan = LoanParams{
.principalOutstanding = 100,
.totalValueOutstanding = 100,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE: loss unrealized driven negative
doInvariantCheck(
{"loss unrealized must not be negative"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = -1});
},
XRPAmount{},
STTx{ttLOAN_MANAGE, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// Loan flags may only change under the transaction types that own
// those transitions.
{
struct Case
{
std::uint32_t before;
std::uint32_t after;
std::string expected;
};
auto const cases = std::to_array<Case>({
{.before = 0,
.after = lsfLoanImpaired,
.expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"},
{.before = lsfLoanImpaired,
.after = 0,
.expected = "lsfLoanImpaired changed outside LoanManage or LoanPay"},
{.before = 0,
.after = lsfLoanDefault,
.expected = "lsfLoanDefault changed outside LoanManage"},
});
for (auto const& c : cases)
{
Env env{*this, all_};
Account const a1{"A1"};
Account const a2{"A2"};
env.fund(XRP(1000), a1, a2);
auto const keys = createClosedXrpBroker(a1, env);
if (!keys)
continue;
auto const& brokerKeylet = keys->second;
OpenView ov{*env.current()};
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
{
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a1.id());
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(150);
sleLoan->setFieldU32(sfPaymentRemaining, 1);
sleLoan->setFieldU32(sfFlags, c.before);
ov.rawInsert(sleLoan);
}
STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
auto sleLoan = ac.view().peek(loanKeylet);
if (!BEAST_EXPECT(sleLoan))
continue;
sleLoan->setFieldU32(sfFlags, c.after);
ac.view().update(sleLoan);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
continue;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(sink.messages().str().contains(c.expected));
}
}
// ttLOAN_MANAGE (default): a defaulted loan atomically enters a
// terminal state, which drops sfNextPaymentDueDate from the ledger
// entry. Seed a loan that already carries lsfLoanDefault so the
// "must newly set" check passes, then leave sfNextPaymentDueDate
// present and non-zero on the after-image; the residual due-date
// check must then fire.
{
Env env{*this, all_};
Account const a1{"A1"};
Account const a2{"A2"};
env.fund(XRP(1000), a1, a2);
BEAST_EXPECT(precloseXrp(a1, a2, env));
env.close();
OpenView ov{*env.current()};
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
// Pre-insert a loan that is not yet defaulted but has a
// NextPaymentDueDate set; the apply-view mutation below flips
// lsfLoanDefault (so the "must newly set" check passes) while
// leaving the due date behind.
{
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
sleLoan->setFieldU32(sfNextPaymentDueDate, 123);
ov.rawInsert(sleLoan);
}
STTx const tx{
ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
auto sleLoan = ac.view().peek(loanKeylet);
if (!BEAST_EXPECT(sleLoan))
return;
sleLoan->setFieldU32(sfFlags, lsfLoanDefault);
ac.view().update(sleLoan);
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
return;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
BEAST_EXPECT(result == tecINVARIANT_FAILED);
BEAST_EXPECT(sink.messages().str().contains(
"Loan with zero payments must have zero next payment due date"));
}
// ttLOAN_PAY pre-featureLendingProtocolV1_1: finalizeLoanPay short-
// circuits and returns success. The same "no vault balance change"
// state that trips the check under V1_1 must be silently accepted
// here.
doInvariantCheck(
makeEnv(all_ - featureLendingProtocolV1_1),
{},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
return kAdjust(ac.view(), keylet, Adjustments{});
},
XRPAmount{},
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
{tesSUCCESS, tesSUCCESS},
precloseXrp);
// ttLOAN_PAY: cash is credited to the vault and a loan is created
// rather than modified. This object-existence rule applies on both
// invariant passes.
doInvariantCheck(
{"Loan created by a transaction other than LoanSet"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsTotal = 50,
.assetsAvailable = 50,
.vaultAssets = 50,
.accountAssets = AccountAmount{.account = a2.id(), .amount = -50},
.createLoan = LoanParams{
.principalOutstanding = 100,
.totalValueOutstanding = 100,
.borrower = a1.id(),
}});
},
XRPAmount{},
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(50)); }},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// ttLOAN_PAY: loss unrealized driven negative. The cash inflow is
// valid, but loss unrealized is set below zero.
doInvariantCheck(
{"loss unrealized must not be negative"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
return kAdjust(
ac.view(),
keylet,
Adjustments{
.assetsTotal = 100,
.assetsAvailable = 100,
.lossUnrealized = -1,
.vaultAssets = 100,
.accountAssets = AccountAmount{.account = a2.id(), .amount = -100}});
},
XRPAmount{},
STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp);
// ttLOAN_MANAGE (default): the write-off is rounded downward at the
// pre-default AssetsTotal scale. A near-total IOU default can leave
// valid positive dust while moving the posterior AssetsTotal to a much
// finer scale. The dust must be bounded by the former scale rather than
// compared with one unit at the posterior scale.
{
Env env{*this, all_ | featureLendingProtocolV1_1};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const borrower{"borrower"};
env.fund(XRP(1000), issuer, owner, borrower);
env.close();
PrettyAsset const iouAsset{issuer["IOU"]};
auto const brokerKeylet = createLoanBroker(owner, env, iouAsset);
auto const sleBrokerBase = env.le(brokerKeylet);
if (!BEAST_EXPECT(sleBrokerBase))
return;
auto const vaultKeylet = keylet::vault(sleBrokerBase->at(sfVaultID));
env.close();
Number const assetsTotalBefore{1, 1};
Number const loanOwed{9'999'999'999'999'999LL, -15};
Number const assetsTotalAfter{1, -14};
auto const beforeScale = scale(assetsTotalBefore, iouAsset);
auto const afterScale = scale(assetsTotalAfter, iouAsset);
Number const residual = (assetsTotalAfter - assetsTotalBefore) - (-loanOwed);
Number const beforeTolerance{1, beforeScale};
Number const afterTolerance{1, afterScale};
BEAST_EXPECT(afterScale < beforeScale);
BEAST_EXPECT(residual > beast::kZero && residual < beforeTolerance);
BEAST_EXPECT(residual > afterTolerance);
OpenView ov{*env.current()};
{
auto const sleVaultRead = ov.read(vaultKeylet);
if (!BEAST_EXPECT(sleVaultRead))
return;
auto sleVault = std::make_shared<SLE>(*sleVaultRead);
sleVault->at(sfAssetsTotal) = assetsTotalBefore;
sleVault->at(sfAssetsAvailable) = Number(0);
ov.rawReplace(sleVault);
auto const sharesKeylet = keylet::mptokenIssuance(sleVaultRead->at(sfShareMPTID));
auto const sleSharesRead = ov.read(sharesKeylet);
if (!BEAST_EXPECT(sleSharesRead))
return;
auto sleShares = std::make_shared<SLE>(*sleSharesRead);
sleShares->at(sfOutstandingAmount) = 1;
ov.rawReplace(sleShares);
}
{
auto const sleBrokerRead = ov.read(brokerKeylet);
if (!BEAST_EXPECT(sleBrokerRead))
return;
auto sleBroker = std::make_shared<SLE>(*sleBrokerRead);
sleBroker->at(sfDebtTotal) = loanOwed;
ov.rawReplace(sleBroker);
}
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
{
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, borrower.id());
sleLoan->at(sfPrincipalOutstanding) = loanOwed;
sleLoan->at(sfTotalValueOutstanding) = loanOwed;
sleLoan->setFieldU32(sfPaymentRemaining, 1);
ov.rawInsert(sleLoan);
}
STTx const tx{
ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }};
test::StreamSink sink{beast::Severity::Warning};
beast::Journal const jlog{sink};
ApplyContext ac{
env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
CurrentTransactionRulesGuard const rulesGuard(ov.rules());
{
auto sleVault = ac.view().peek(vaultKeylet);
if (!BEAST_EXPECT(sleVault))
return;
sleVault->at(sfAssetsTotal) = assetsTotalAfter;
ac.view().update(sleVault);
}
{
auto sleBroker = ac.view().peek(brokerKeylet);
if (!BEAST_EXPECT(sleBroker))
return;
sleBroker->at(sfDebtTotal) = Number(0);
ac.view().update(sleBroker);
}
{
auto sleLoan = ac.view().peek(loanKeylet);
if (!BEAST_EXPECT(sleLoan))
return;
sleLoan->at(sfPrincipalOutstanding) = Number(0);
sleLoan->at(sfTotalValueOutstanding) = Number(0);
sleLoan->setFieldU32(sfPaymentRemaining, 0);
sleLoan->setFieldU32(sfFlags, lsfLoanDefault);
ac.view().update(sleLoan);
}
auto transactor = makeTransactor(ac);
if (!BEAST_EXPECT(transactor))
return;
TER const result = transactor->checkInvariants(
tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
BEAST_EXPECT(result == tesSUCCESS);
}
// A loan may only be deleted by a LoanDelete transaction, and only once
// it is fully paid off. Both branches are exercised by creating a real
// loan in the Preclose (so it exists in the base ledger with outstanding
// principal) and then erasing it in the Precheck.
{
Keylet loanKeylet = keylet::amendments();
auto const precloseLoan = [&loanKeylet, this](
Account const& a1, Account const& a2, Env& env) -> bool {
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset);
auto const brokerSle = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerSle))
return false;
auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
Vault const vault{env};
env(vault.deposit(
{.depositor = a1, .id = vaultKeylet.key, .amount = xrpAsset(100)}));
env.close(std::chrono::seconds{61});
loanKeylet = keylet::loan(
brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
env(loan::set(a2, brokerKeylet.key, xrpAsset(50).value()),
loan::kCounterparty(a1),
Sig(sfCounterpartySignature, a1),
loan::kPaymentInterval(60),
loan::kPaymentTotal(1),
Fee(env.current()->fees().base * 2));
env.close();
return BEAST_EXPECT(env.le(loanKeylet));
};
auto const eraseLoan = [&loanKeylet](Account const&, Account const&, ApplyContext& ac) {
auto sle = ac.view().peek(loanKeylet);
if (!sle)
return false;
ac.view().erase(sle);
return true;
};
// Deleting the loan under any transaction type other than LoanDelete
// (here the neutral ttACCOUNT_SET) is a violation, even while the
// loan still has outstanding obligations: the transaction-type check
// fires before the not-fully-paid-off check.
doInvariantCheck(
{"Loan deleted by a transaction other than LoanDelete"},
eraseLoan,
XRPAmount{},
STTx{ttACCOUNT_SET, [](STObject&) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseLoan);
}
STTx const loanSetTx{
ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(0); }};
// Loan interest due (total value less principal and management fee)
// must never be negative. The loan object is created directly with
// principal 100, total value 90 and management fee 0, so interest due
// = 90 - 100 - 0 = -10 (< 0)
// while every individual field stays non-negative.
doInvariantCheck(
{"Loan interest due is negative"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(90);
sleLoan->setFieldU32(sfPaymentRemaining, 1);
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
loanSetTx);
// Each of these loan STNumber fields must never be negative. The loan
// is created directly with a single field set negative while the
// paid-off bookkeeping is kept consistent, so that only the "<field>
// is negative" check trips.
for (auto const field : {
&sfLoanServiceFee,
&sfLatePaymentFee,
&sfClosePaymentFee,
&sfPrincipalOutstanding,
&sfTotalValueOutstanding,
&sfManagementFeeOutstanding,
})
{
// The outstanding-balance fields also feed the paid-off checks, so
// a loan carrying one must still have payments remaining; a loan
// with only a negative fee stays fully paid off (zero remaining).
bool const isOutstanding = *field == sfPrincipalOutstanding ||
*field == sfTotalValueOutstanding || *field == sfManagementFeeOutstanding;
doInvariantCheck(
{field->getName() + " is negative"},
[&, field](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
sleLoan->at(*field) = Number(-10);
sleLoan->setFieldU32(sfPaymentRemaining, isOutstanding ? 1 : 0);
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
loanSetTx);
}
// Mirror of the loop above for the strictly-positive constraint: a
// loan's sfPeriodicPayment must always be > 0. Cover both boundary
// failure modes (zero and negative).
for (Number const& badValue : {Number(0), Number(-1)})
{
doInvariantCheck(
{std::string{sfPeriodicPayment.getName()} + " is zero or negative"},
[&, badValue](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
sleLoan->at(sfPeriodicPayment) = badValue;
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
loanSetTx);
}
// A loan with sfPaymentRemaining == 0 must be fully paid off in every
// outstanding-balance dimension. Insert a bare loan that reports zero
// payments remaining but still carries a non-zero principal owed; the
// paid-off invariant must reject it before the later broker-existence
// check has a chance to run.
doInvariantCheck(
{"Loan with zero payments remaining has not been paid off"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
sleLoan->at(sfPrincipalOutstanding) = Number(100);
sleLoan->at(sfTotalValueOutstanding) = Number(100);
sleLoan->at(sfPeriodicPayment) = Number(1);
sleLoan->setFieldU32(sfPaymentRemaining, 0);
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
loanSetTx);
// Converse: a loan whose outstanding balances are all zero has been
// fully paid off and must carry zero payments remaining. Insert a
// fully-zeroed loan with sfPaymentRemaining = 1 to trip the check.
doInvariantCheck(
{"Fully paid off Loan still has payments remaining"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const brokerKeylet = keylet::loanBroker(a1.id(), SeqProxy::rawSequence(1));
auto sleLoan = makeLoanSle(brokerKeylet.key, 1, a2.id());
sleLoan->setFieldU32(sfPaymentRemaining, 1);
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
loanSetTx);
// A loan must reference a live loan broker. A bare loan SLE is
// inserted with every other loan-level field kept consistent so the
// earlier ValidLoan checks pass; sfLoanBrokerID defaults to zero,
// which resolves to no broker, and the broker-existence check trips.
doInvariantCheck(
{"Loan broker does not exist"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto sleLoan = makeLoanSle(uint256{}, 1, a2.id());
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
loanSetTx);
// A loan's broker must in turn reference a live vault. A real broker
// is created in the preclose so its sfVaultID points at an existing
// vault; the precheck then erases that vault and inserts a loan
// referencing the broker, so the broker-existence check passes and
// the broker-vault-existence check trips.
{
Keylet brokerKeylet = keylet::amendments();
auto const precloseBroker = [&brokerKeylet, this](
Account const& a1, Account const&, Env& env) -> bool {
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
brokerKeylet = this->createLoanBroker(a1, env, xrpAsset);
env.close();
return BEAST_EXPECT(env.le(brokerKeylet));
};
doInvariantCheck(
{"Loan broker vault does not exist"},
[&brokerKeylet](Account const&, Account const&, ApplyContext& ac) {
auto sleBroker = ac.view().peek(brokerKeylet);
if (!sleBroker)
return false;
auto sleVault = ac.view().peek(keylet::vault(sleBroker->at(sfVaultID)));
if (!sleVault)
return false;
ac.view().erase(sleVault);
auto const loanKeylet =
keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
auto sleLoan = std::make_shared<SLE>(loanKeylet);
sleLoan->at(sfLoanBrokerID) = brokerKeylet.key;
sleLoan->at(sfPrincipalOutstanding) = Number(0);
sleLoan->at(sfTotalValueOutstanding) = Number(0);
sleLoan->at(sfManagementFeeOutstanding) = Number(0);
sleLoan->at(sfPeriodicPayment) = Number(1);
sleLoan->setFieldU32(sfPaymentRemaining, 0);
ac.view().insert(sleLoan);
return true;
},
XRPAmount{},
loanSetTx,
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseBroker);
}
// ttVAULT_SET: owner is immutable (enforced by
// NoModifiedUnmodifiableFields under featureLendingProtocolV1_1.
doInvariantCheck(
{"changed an unchangeable field"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
sleVault->setAccountID(sfOwner, a2.id());
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttVAULT_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// ttVAULT_SET: withdrawal policy is immutable
doInvariantCheck(
{"changed an unchangeable field"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
sleVault->setFieldU8(
sfWithdrawalPolicy,
static_cast<std::uint8_t>(sleVault->getFieldU8(sfWithdrawalPolicy) + 1));
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttVAULT_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// ttVAULT_SET: scale is immutable
doInvariantCheck(
{"changed an unchangeable field"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
sleVault->setFieldU8(
sfScale, static_cast<std::uint8_t>(sleVault->getFieldU8(sfScale) + 1));
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttVAULT_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
// featureLendingProtocolV1_1 moves the vault immutability checks from VaultInvariant to
// InvariantCheck.
doInvariantCheck(
makeEnv(all_),
{"changed an unchangeable field"},
[&](Account const& a1, Account const& a2, ApplyContext& ac) {
auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq()));
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
sleVault->setFieldU8(
sfWithdrawalPolicy,
static_cast<std::uint8_t>(sleVault->getFieldU8(sfWithdrawalPolicy) + 1));
ac.view().update(sleVault);
return true;
},
XRPAmount{},
STTx{ttVAULT_SET, [](STObject& tx) {}},
{tecINVARIANT_FAILED, tefINVARIANT_FAILED},
precloseXrp);
testcase << "Vault create";
doInvariantCheck(
{
@@ -1907,8 +2671,7 @@ class InvariantsVault_test : public InvariantsBase
// Synthesize a Loan whose final scheduled payment lands
// exactly at RedemptionDate: StartDate = red, interval = 60,
// remaining = 1 => red + 60 >= red.
auto sleLoan = std::make_shared<SLE>(
keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq)));
auto sleLoan = makeLoanSle(closedEndedBrokerKeylet.key, loanSeq, a1.id());
sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key;
sleLoan->at(sfLoanSequence) = loanSeq;
sleLoan->at(sfBorrower) = a1.id();

View File

@@ -25,7 +25,9 @@
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <algorithm>
#include <cstdint>
#include <optional>
@@ -383,6 +385,90 @@ private:
isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale));
}
// non-full-payment invariant asserts that a successful LoanPay strictly
// decreases PaymentRemaining and advances NextPaymentDueDate. doPayment
// deliberately leaves those schedule fields unchanged for
// PaymentSpecialCase::Extra (an overpayment), so the concern is that an
// "extra-only" overpayment - one that does not also cover a scheduled
// payment - would reach that branch and trip the invariant.
//
// The invariant is gated behind featureLendingProtocolV1_1, which
// LoanTestBase::all_ excludes, so it is opted back in here.
void
testLoanPayOverpaymentScheduleInvariant(FeatureBitset features)
{
testcase("LoanPay overpayment vs non-full-payment invariant");
using namespace jtx;
using namespace loan;
Env env{*this, features | featureLendingProtocolV1_1};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(10'000'000), lender, borrower);
env.close();
PrettyAsset const asset{xrpIssue(), 1000};
BrokerInfo const broker = createVaultAndBroker(
env,
asset,
lender,
{
.vaultDeposit = asset(100'000).value(),
.managementFeeRate = TenthBips16(10'000),
});
auto const loanSetFee = Fee(env.current()->fees().base * 2);
// Principal 10,000 over 3 payments, overpayment enabled. One scheduled
// payment is ~3,333, so an amount well below that cannot cover one.
auto const loanKeylet = nextLoanKeylet(env, broker);
env(loan::set(borrower, broker.brokerID, asset(10'000).value(), tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
loan::kPaymentInterval(86400 * 30),
loan::kPaymentTotal(3),
loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))),
loanSetFee);
env.close();
auto const before = getCurrentState(env, broker, loanKeylet);
BEAST_EXPECT(before.paymentRemaining == 3);
STAmount const belowOnePayment = asset(1'000).value();
BEAST_EXPECT((belowOnePayment < STAmount{asset, before.periodicPayment}));
auto const payFee = Fee(env.current()->fees().base * 2);
// The amount does not cover a scheduled payment, so makeRegularPayment makes zero scheduled
// payments and returns tecINSUFFICIENT_PAYMENT before the Extra branch runs. The invariant
// is therefore never reached. Were the payment to succeed while touching only principal,
// the invariant would fire instead.
env(pay(borrower, loanKeylet.key, belowOnePayment, tfLoanOverpayment),
payFee,
Ter(tecINSUFFICIENT_PAYMENT));
env.close();
auto const afterReject = getCurrentState(env, broker, loanKeylet);
BEAST_EXPECT(afterReject.paymentRemaining == before.paymentRemaining);
BEAST_EXPECT(afterReject.principalOutstanding == before.principalOutstanding);
BEAST_EXPECT(afterReject.nextPaymentDate == before.nextPaymentDate);
// This reaches the 3.11.5 invariant with tesSUCCESS: PaymentRemaining drops by one,
// NextPaymentDueDate advances by one interval, and PrincipalOutstanding strictly decreases
// (by more than a plain payment thanks to the extra). The invariant must accept it.
STAmount const onePaymentPlusExtra = asset(5'000).value();
env(pay(borrower, loanKeylet.key, onePaymentPlusExtra, tfLoanOverpayment), payFee);
env.close();
auto const afterPay = getCurrentState(env, broker, loanKeylet);
BEAST_EXPECT(afterPay.paymentRemaining == before.paymentRemaining - 1);
BEAST_EXPECT(afterPay.principalOutstanding < before.principalOutstanding);
BEAST_EXPECT(afterPay.nextPaymentDate == before.nextPaymentDate + before.paymentInterval);
}
void
testAccountSendMptMinAmountInvariant(FeatureBitset features)
{
@@ -851,12 +937,95 @@ private:
});
}
void
testLoanSetRecipientScaleInvariant()
{
using namespace jtx;
using namespace loan;
auto const runCase = [&](bool coarseBorrower) {
testcase(
coarseBorrower ? "LoanSet borrower balance uses coarsest scale"
: "LoanSet broker owner balance uses coarsest scale");
Env env(*this, all_ | featureLendingProtocolV1_1);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
Number const coarseBalance{100'000'000'000LL};
Number const regularBalance{100'000'000};
PrettyAsset const asset = createFundedRippleIouAsset(
env,
issuer,
lender,
borrower,
coarseBorrower ? regularBalance : coarseBalance,
coarseBorrower ? coarseBalance : regularBalance);
BrokerParameters const brokerParams{
.vaultDeposit = 1'000'000,
.debtMax = 0,
.coverRateMin = TenthBips32{0},
.coverDeposit = 0,
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams);
Number const principal{1'012'345, -5};
Number const originationFee{123'456, -6};
Account const& recipient = coarseBorrower ? borrower : lender;
Number const expected = coarseBorrower ? principal : originationFee;
auto const before = env.balance(recipient, asset);
if (coarseBorrower)
{
env(set(borrower, broker.brokerID, principal),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
}
else
{
env(set(borrower, broker.brokerID, principal),
kCounterparty(lender),
Sig(sfCounterpartySignature, lender),
kLoanOriginationFee(originationFee),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
}
env.close();
auto const after = env.balance(recipient, asset);
Number const received = after.number() - before.number();
auto const recipientScale =
std::max(before.value().exponent(), after.value().exponent());
auto const vaultScale = broker.vaultScale(env);
Number const tolerance{1, recipientScale};
BEAST_EXPECT(recipientScale > vaultScale);
BEAST_EXPECT(received != expected);
BEAST_EXPECT(
abs(roundToAsset(asset, received, recipientScale) -
roundToAsset(asset, expected, recipientScale)) <= tolerance);
};
runCase(/*coarseBorrower=*/true);
runCase(/*coarseBorrower=*/false);
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testLoanPayComputePeriodicPaymentInvariants(features);
testLoanPayDebtDecreaseInvariant(features);
testLoanPayOverpaymentScheduleInvariant(features);
testAccountSendMptMinAmountInvariant(features);
testMinimumBrokerCoverConsistency(features);
}
@@ -865,6 +1034,7 @@ public:
void
run() override
{
testLoanSetRecipientScaleInvariant();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);

View File

@@ -14,9 +14,11 @@
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -30,6 +32,7 @@
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
#include <chrono>
#include <cstdint>
@@ -1460,6 +1463,217 @@ private:
BEAST_EXPECT(ownerCount(env, attacker) == 0);
}
struct ImpairedLoanVault
{
test::jtx::Account issuer;
test::jtx::Account holder;
PrettyAsset usd;
test::jtx::Vault vault;
Keylet vaultKeylet;
MPTID shareId;
};
// Impairing a 1,000 loan in a 10,000 vault leaves AssetsAvailable=9,000
// and AssetsTotal=10,000. otherDeposit > 0 splits the shares, 0 leaves
// holder as the sole shareholder.
std::optional<ImpairedLoanVault>
makeImpairedLoanVault(test::jtx::Env& env, int otherDeposit)
{
using namespace test::jtx;
using namespace loan_broker;
using namespace loan;
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const holder{"holder"};
Account const other{"other"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, owner, holder, other, borrower);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const usd = issuer["USD"];
env.trust(usd(100'000), owner);
env.trust(usd(100'000), holder);
env.trust(usd(100'000), other);
env.trust(usd(100'000), borrower);
env.close();
int const holderDeposit = 10'000 - otherDeposit;
env(pay(issuer, holder, usd(holderDeposit)));
if (otherDeposit != 0)
{
env(pay(issuer, other, usd(otherDeposit)));
}
env.close();
Vault const vault{env};
auto const [createTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
{.owner = owner, .asset = usd, .subscriptionOffset = std::chrono::seconds{60}});
env(createTx);
env.close();
auto const vaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return std::nullopt;
MPTID const shareId = vaultSle->at(sfShareMPTID);
env(vault.deposit(
{.depositor = holder, .id = vaultKeylet.key, .amount = usd(holderDeposit)}));
if (otherDeposit != 0)
{
env(vault.deposit(
{.depositor = other, .id = vaultKeylet.key, .amount = usd(otherDeposit)}));
}
env.close();
vault.closePastSubscription(subscriptionDate);
auto const brokerKeylet =
keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
env(set(owner, vaultKeylet.key));
env.close();
auto const sleBroker = env.le(brokerKeylet);
if (!BEAST_EXPECT(sleBroker))
return std::nullopt;
auto const loanKeylet =
keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
env(set(borrower, brokerKeylet.key, usd(1'000).value()),
loan::kInterestRate(percentageToTenthBips(0)),
kGracePeriod(60),
kPaymentInterval(120),
kPaymentTotal(10),
Sig(sfCounterpartySignature, owner),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with
// tecTOO_SOON unless the payment is already late; advance the ledger
// past sfNextPaymentDueDate so impairment succeeds. No-op otherwise.
if (env.current()->rules().enabled(fixCleanup3_4_0))
{
auto const loanBefore = env.le(loanKeylet);
if (!BEAST_EXPECT(loanBefore))
return std::nullopt;
std::uint32_t const dueDate = loanBefore->at(sfNextPaymentDueDate);
env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
}
env(manage(owner, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
auto const vaultAfter = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultAfter))
return std::nullopt;
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == usd(9'000).value());
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == usd(1'000).value());
return ImpairedLoanVault{
.issuer = issuer,
.holder = holder,
.usd = usd,
.vault = vault,
.vaultKeylet = vaultKeylet,
.shareId = shareId};
}
// Legacy clawback pricing burns every share; fixCleanup3_4_0 leaves 10%
// outstanding, backed by the impaired receivable.
void
testBugClawbackAfterLoanImpair()
{
using namespace test::jtx;
auto clawbackHolder = [](ImpairedLoanVault const& setup, STAmount const& amount) {
return setup.vault.clawback(
{.issuer = setup.issuer,
.id = setup.vaultKeylet.key,
.holder = setup.holder,
.amount = amount});
};
auto runSole = [this, &clawbackHolder](FeatureBitset features, TER expected) {
testcase(
features[fixCleanup3_4_0]
? "VaultClawback after impaired loan (post-fixCleanup3_4_0)"
: "VaultClawback after impaired loan (pre-fixCleanup3_4_0)");
Env env(*this, features);
auto const maybeSetup = makeImpairedLoanVault(env, 0);
if (!maybeSetup)
{
BEAST_EXPECT(false);
return;
}
ImpairedLoanVault const& setup = *maybeSetup;
auto const tokenBefore = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
auto const vaultBefore = env.le(setup.vaultKeylet);
auto const issuanceBefore = env.le(keylet::mptokenIssuance(setup.shareId));
if (!BEAST_EXPECT(tokenBefore) || !BEAST_EXPECT(vaultBefore) ||
!BEAST_EXPECT(issuanceBefore))
return;
std::uint64_t const sharesBefore = tokenBefore->getFieldU64(sfMPTAmount);
// The clawback of 19,000 exceeds AssetsAvailable (9,000), so
// VaultClawback clamps sharesDestroyed to whatever redeems
// exactly AssetsAvailable; compute that expected value using the
// same conversion helper VaultClawback itself uses, rather than
// assuming an exact 90/10 split holds under truncation.
auto const maybeSharesDestroyed = assetsToSharesWithdraw(
vaultBefore,
issuanceBefore,
setup.usd(9'000).value(),
TruncateShares::Yes,
WaiveUnrealizedLoss::Yes);
if (!BEAST_EXPECT(maybeSharesDestroyed))
return;
std::uint64_t const expectedSharesAfter =
sharesBefore - maybeSharesDestroyed->mpt().value();
env(clawbackHolder(setup, setup.usd(19'000).value()), Ter(expected));
env.close();
if (expected != tesSUCCESS)
return;
auto const vaultAfter = env.le(setup.vaultKeylet);
if (!BEAST_EXPECT(vaultAfter))
return;
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == setup.usd(0).value());
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == setup.usd(1'000).value());
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == setup.usd(1'000).value());
auto const tokenAfter = env.le(keylet::mptoken(setup.shareId, setup.holder.id()));
if (!BEAST_EXPECT(tokenAfter))
return;
BEAST_EXPECT(tokenAfter->getFieldU64(sfMPTAmount) == expectedSharesAfter);
};
runSole(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
runSole(all_, tesSUCCESS);
testcase("VaultClawback after impaired loan, non-sole holder");
{
Env env(*this, all_);
auto const maybeSetup = makeImpairedLoanVault(env, 1'000);
if (!maybeSetup)
{
BEAST_EXPECT(false);
return;
}
ImpairedLoanVault const& setup = *maybeSetup;
// The waiver does not apply, so the holder's 9,000 shares are
// still priced at the discounted rate and cannot cover 9,000.
env(clawbackHolder(setup, setup.usd(9'000).value()), Ter(tecINSUFFICIENT_FUNDS));
}
}
public:
void
run() override
@@ -1480,6 +1694,7 @@ public:
testBug6LimitBypassWithShares();
testBugClawbackRoundTripOvershoot();
testBugWithdrawRoundTripOvershoot();
testBugClawbackAfterLoanImpair();
}
};