diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 5ac511a84a6..31ffefdfe00 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -17,11 +17,51 @@ namespace xrpl { * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` * - * A loan may only be deleted once it is fully paid off (no payments - * remaining): + * The following invariants only apply once the `LendingProtocolV1_1` + * amendment is enabled (the two-step "pending loan" flow): * - * 2. A loan may only be deleted by a `LoanDelete` transaction. - * 3. A loan that is not fully paid off must not be deleted. + * 2. A loan's `OwnerNode` may only be added, removed, or changed on an + * existing loan by `LoanAccept`. + * 3. A loan's `lsfLoanPending` flag may only be cleared (never set) on an + * existing loan, and only by `LoanAccept`. + * 4. A `LoanAccept` may only modify an existing loan when: + * a. the loan was pending (`lsfLoanPending` set); + * b. the submitting account (`Account`) is the loan's `Borrower`; + * c. the loan's `StartDate` is still in the future. + * 5. A `LoanSet` that creates a loan must use exactly one of two mutually + * exclusive creation paths: it either names a `Borrower`, or it carries a + * a `CounterpartySignature`. Specifically: + * a. If `Borrower` is present, `Counterparty` and `CounterpartySignature` + * must be absent. + * b. If `CounterpartySignature` is present, `Borrower` must be absent. + * c. Either `Borrower`, or `CounterpartySignature`, + * must be present. + * d. If `Borrower` is present, it must differ from the submitting + * `Account`. + * 6. A `LoanSet` that creates a loan must set `lsfLoanPending` if and only if it + * starts the two-step flow, i.e. `Borrower` and `StartDate` are present while + * `Counterparty` and `CounterpartySignature` are absent. + * 7. A `LoanSet` that creates a loan must set the loan's `Borrower`. + * Additionally, a pending loan's `StartDate` must be in the future (so it is + * still in the future when `LoanAccept` finalizes it). + * 8. A pending loan (`lsfLoanPending` set) must not be linked into the + * borrower's directory (`OwnerNode` absent), and a non-pending loan must + * be linked (`OwnerNode` present). + * + * While the `LendingProtocolV1_1` amendment is not enabled, a `LoanSet` that + * creates a loan must not use any of the two-step flow's inputs: + * + * 9. It must not create a pending loan (`lsfLoanPending` must be clear). + * 10. It must not be given a `Borrower`. + * 11. It must always carry a `CounterpartySignature`. + * + * A loan may only be deleted once it is fully paid off (no payments remaining) + * or, once the `LendingProtocolV1_1` amendment is enabled, while it is still + * pending: + * + * 12. A loan may only be deleted by a `LoanDelete` transaction. + * 13. A pending loan must not be deleted while the amendment is not enabled. + * 14. A loan that is neither fully paid off nor pending must not be deleted. * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 8936ce8b4d9..be292ab8118 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -54,6 +54,9 @@ namespace xrpl { * verifies the payment was split correctly between principal and interest * - no vault transaction can change loss unrealized (it's updated by loan * transactions) + * - AssetsReserved (principal held back for pending loans) may only be changed + * by LoanDelete, LoanAccept, or a LoanSet that creates a pending loan + * (Borrower present, CounterpartySignature absent) * */ class ValidVault @@ -71,6 +74,7 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + Number assetsReserved = 0; std::uint8_t withdrawalPolicy = 0; std::uint8_t scale = 0; @@ -92,6 +96,7 @@ class ValidVault Number principalOutstanding = 0; Number totalValueOutstanding = 0; Number managementFeeOutstanding = 0; + uint32_t flags = 0; // Interest booked to the vault at loan creation: the portion of the // total value owed that is neither principal nor broker management fee. diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 9d25cebf2b2..41edb531af9 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -33,7 +34,7 @@ ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after bool ValidLoan::finalize( STTx const& tx, - TER const, + TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) @@ -42,6 +43,8 @@ ValidLoan::finalize( // is not enabled, so there's no need to check it. auto const txType = tx.getTxnType(); + // The pending-loan checks below only apply once the two-step flow exists. + bool const lendingV11Enabled = view.rules().enabled(featureLendingProtocolV1_1); for (auto const& [before, after] : loans_) { @@ -71,6 +74,192 @@ ValidLoan::finalize( JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed"; return false; } + if (before) + { + // The lsfLoanPending flag may only be cleared (finalising the + // loan), and only by LoanAccept. It must never be set on an + // existing loan. + bool const wasPending = before->isFlag(lsfLoanPending); + bool const isPending = after->isFlag(lsfLoanPending); + + if (!lendingV11Enabled && (wasPending || isPending)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Pending flag changed " + "when the amendment is not enabled"; + return false; + } + + if (wasPending != isPending && + (!lendingV11Enabled || txType != ttLOAN_ACCEPT || (!wasPending && isPending))) + { + JLOG(j.fatal()) << "Invariant failed: Loan Pending flag changed " + "by an unauthorized transaction"; + return false; + } + + // LoanAccept may only process a loan that was pending. + if (txType == ttLOAN_ACCEPT && !wasPending) + { + JLOG(j.fatal()) << "Invariant failed: LoanAccept modified a " + "Loan that was not pending"; + return false; + } + + // The OwnerNode may only be added to an existing loan, and only by + // LoanAccept (which links the loan into the borrower's directory + // once accepted). It must never be removed or changed. + bool const beforeHasNode = before->isFieldPresent(sfOwnerNode); + bool const afterHasNode = after->isFieldPresent(sfOwnerNode); + if (beforeHasNode && + (!afterHasNode || + before->getFieldU64(sfOwnerNode) != after->getFieldU64(sfOwnerNode))) + { + JLOG(j.fatal()) << "Invariant failed: Loan OwnerNode removed " + "or changed"; + return false; + } + if (!beforeHasNode && afterHasNode && (!lendingV11Enabled || txType != ttLOAN_ACCEPT)) + { + JLOG(j.fatal()) << "Invariant failed: Loan OwnerNode added " + "by an unauthorized transaction"; + return false; + } + + // LoanAccept must be submitted by the loan's Borrower, and may only + // finalise a loan whose StartDate is still in the future. + if (lendingV11Enabled && txType == ttLOAN_ACCEPT) + { + if (after->getAccountID(sfBorrower) != tx.getAccountID(sfAccount)) + { + JLOG(j.fatal()) << "Invariant failed: LoanAccept submitted " + "by an account other than the Borrower"; + return false; + } + if (after->getFieldU32(sfStartDate) <= + view.parentCloseTime().time_since_epoch().count()) + { + JLOG(j.fatal()) << "Invariant failed: LoanAccept processed a " + "Loan whose StartDate is not in the future"; + return false; + } + } + } + // On creation, LoanSet must use exactly one of two mutually exclusive + // paths: it either names a Borrower with a StartDate (starting the + // two-step flow) or carries a CounterpartySignature. A Borrower with a + // StartDate and no CounterpartySignature or Counterparty starts the + // two-step flow and must create a pending loan; any other LoanSet must + // create an active (non-pending) loan. + if (isTesSuccess(result)) + { + if (lendingV11Enabled && !before && txType == ttLOAN_SET) + { + bool const hasBorrower = tx.isFieldPresent(sfBorrower); + bool const hasCounterpartySig = tx.isFieldPresent(sfCounterpartySignature); + bool const hasStartDate = tx.isFieldPresent(sfStartDate); + bool const hasCounterparty = tx.isFieldPresent(sfCounterparty); + bool isTwoStepFlow = hasBorrower && hasStartDate; + bool isOneStepFlow = hasCounterpartySig; + + if (!isTwoStepFlow && !isOneStepFlow) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet specified neither " + "a Borrower with a StartDate nor a CounterpartySignature"; + return false; + } + + if (isTwoStepFlow && isOneStepFlow) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet specified both " + "a Borrower with a StartDate and a CounterpartySignature"; + return false; + } + + if (isOneStepFlow && hasBorrower) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet specified a " + "Borrower with a CounterpartySignature"; + return false; + } + + if (isTwoStepFlow && hasCounterparty) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet specified a " + "Borrower with a StartDate and a Counterparty"; + return false; + } + + // In the two-step flow the named Borrower must be a different + // account from the one submitting the LoanSet. + if (hasBorrower && tx.getAccountID(sfBorrower) == tx.getAccountID(sfAccount)) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet Borrower is the " + "submitting account"; + return false; + } + + // The two-step flow is started (and the loan created pending) when + // the LoanSet names a Borrower and a StartDate but carries neither a + // Counterparty nor a CounterpartySignature. + bool const shouldPend = + hasBorrower && hasStartDate && !hasCounterpartySig && !hasCounterparty; + if (shouldPend != after->isFlag(lsfLoanPending)) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet pending flag does " + "not match the two-step flow inputs"; + return false; + } + + // A pending loan (the two-step flow) is accepted in a later ledger, + // so its StartDate must be strictly in the future at creation to + // remain in the future when LoanAccept finalizes it. + if (shouldPend && + after->getFieldU32(sfStartDate) <= + view.parentCloseTime().time_since_epoch().count()) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet created a pending " + "Loan whose StartDate is not in the future"; + return false; + } + + // A created loan must always record its Borrower. As sfBorrower is a + // required field it is always present (defaulting to the zero + // account), so a loan whose Borrower was never set carries the zero + // account. + if (!after->isFieldPresent(sfBorrower) || + after->getAccountID(sfBorrower) == beast::kZero) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet did not set the " + "Loan Borrower"; + return false; + } + } + // Without the two-step flow amendment, LoanSet must not make use of any + // of its inputs: it must not create a pending loan, must not be given a + // Borrower, and must always carry a CounterpartySignature. + if (!lendingV11Enabled && !before && txType == ttLOAN_SET) + { + if (after->isFlag(lsfLoanPending)) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet set the Loan " + "Pending flag when the amendment is not enabled"; + return false; + } + if (tx.isFieldPresent(sfBorrower)) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet specified a " + "Borrower when the amendment is not enabled"; + return false; + } + if (!tx.isFieldPresent(sfCounterpartySignature)) + { + JLOG(j.fatal()) << "Invariant failed: LoanSet omitted the " + "CounterpartySignature when the amendment is " + "not enabled"; + return false; + } + } + } // Must not be negative - STNumber for (auto const field : {&sfLoanServiceFee, @@ -107,10 +296,30 @@ ValidLoan::finalize( return false; } } + // A pending loan must not be linked into the borrower's directory, and + // a non-pending loan must be linked. + if (lendingV11Enabled) + { + bool const isPending = after->isFlag(lsfLoanPending); + bool const hasNode = after->isFieldPresent(sfOwnerNode); + if (isPending && hasNode) + { + JLOG(j.fatal()) << "Invariant failed: pending Loan is linked " + "into the borrower's directory"; + return false; + } + if (!isPending && !hasNode) + { + JLOG(j.fatal()) << "Invariant failed: active Loan is not linked " + "into the borrower's directory"; + return false; + } + } } // A loan may only be deleted by a LoanDelete transaction, and only once it - // is fully paid off (no payments remaining). Deleting a loan with + // is fully paid off (no payments remaining) or, once the two-step flow + // exists, while it is still pending acceptance. Deleting an active loan with // outstanding obligations is a violation. for (auto const& loan : deletedLoans_) { @@ -121,13 +330,24 @@ ValidLoan::finalize( return false; } - if (loan->at(sfPaymentRemaining) != 0 || - loan->at(sfTotalValueOutstanding) != beast::kZero || - loan->at(sfPrincipalOutstanding) != beast::kZero || - loan->at(sfManagementFeeOutstanding) != beast::kZero) + bool const wasPending = loan->isFlag(lsfLoanPending); + // A pending loan cannot exist while the amendment is not enabled. + if (wasPending && !lendingV11Enabled) + { + JLOG(j.fatal()) << "Invariant failed: pending Loan deleted when the " + "amendment is not enabled"; + return false; + } + + bool const paidOff = loan->at(sfPaymentRemaining) == 0 && + loan->at(sfTotalValueOutstanding) == beast::kZero && + loan->at(sfPrincipalOutstanding) == beast::kZero && + loan->at(sfManagementFeeOutstanding) == beast::kZero; + bool const pending = lendingV11Enabled && wasPending; + if (!paidOff && !pending) { JLOG(j.fatal()) << "Invariant failed: Loan deleted while not fully " - "paid off"; + "paid off and not pending"; return false; } } diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 9e2c2a52231..b253716d7d2 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -45,6 +45,7 @@ ValidVault::Vault::make(SLE const& from) self.assetsAvailable = from.at(sfAssetsAvailable); self.assetsMaximum = from.at(sfAssetsMaximum); self.lossUnrealized = from.at(sfLossUnrealized); + self.assetsReserved = from.at(sfAssetsReserved); self.withdrawalPolicy = from.at(sfWithdrawalPolicy); self.scale = from.at(sfScale); return self; @@ -86,6 +87,7 @@ ValidVault::Loan::make(SLE const& from) self.principalOutstanding = from.at(sfPrincipalOutstanding); self.totalValueOutstanding = from.at(sfTotalValueOutstanding); self.managementFeeOutstanding = from.at(sfManagementFeeOutstanding); + self.flags = from.getFlags(); return self; } @@ -475,6 +477,22 @@ ValidVault::finalize( JLOG(j.fatal()) << "Invariant failed: violation of vault immutable data"; result = false; } + + // AssetsReserved (principal held back for pending loans) may only be + // changed by LoanDelete, LoanAccept, or a LoanSet that creates a + // pending loan (Borrower present, CounterpartySignature absent). + if (afterVault.assetsReserved != beforeVault.assetsReserved) + { + bool const pendingLoanSet = txnType == ttLOAN_SET && tx.isFieldPresent(sfBorrower) && + tx.isFieldPresent(sfStartDate) && !tx.isFieldPresent(sfCounterpartySignature) && + !tx.isFieldPresent(sfCounterparty); + if (txnType != ttLOAN_DELETE && txnType != ttLOAN_ACCEPT && !pendingLoanSet) + { + JLOG(j.fatal()) << "Invariant failed: vault AssetsReserved changed " + "by an unauthorized transaction"; + result = false; + } + } } if (!updatedShares) @@ -1105,12 +1123,13 @@ ValidVault::finalize( return false; // That's all we can do } auto const& loan = afterLoan_[0]; + auto const isPendingLoan = (loan.flags & lsfLoanPending) != 0; // Funding a loan moves the requested principal out of the vault // pseudo-account to the borrower (and, if any, the origination // fee to the broker owner). auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); - if (!maybeVaultDeltaAssets) + if (!maybeVaultDeltaAssets && !isPendingLoan) { JLOG(j.fatal()) << // "Invariant failed: loan set must change vault balance"; @@ -1128,7 +1147,7 @@ ValidVault::finalize( auto const vaultDeltaAssets = roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); - if (vaultDeltaAssets != principalDelta) + if (vaultDeltaAssets != principalDelta && !isPendingLoan) { JLOG(j.fatal()) << // "Invariant failed: loan set must decrease vault balance " @@ -1421,8 +1440,204 @@ ValidVault::finalize( return result; } + case ttLOAN_ACCEPT: { + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), + "xrpl::ValidVault::finalize : loan accept updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // Accepting a pending loan disburses the reserved principal + // from the vault pseudo-account to the borrower (and the + // origination fee, if any, to the broker owner) and releases it + // from the reserved bucket. The assets available and assets + // outstanding were already settled when the pending loan was + // created, so they must not move now. + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + if (!maybeVaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must change vault balance"; + return false; // That's all we can do + } + + // A loan accept modifies exactly the loan being accepted; its + // principal outstanding is the amount disbursed and released. + if (afterLoan_.size() != 1 || beforeLoan_.size() != 1 || + afterLoan_[0].key != beforeLoan_[0].key) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must modify exactly one " + "loan"; + return false; // That's all we can do + } + auto const& loan = beforeLoan_[0]; + + // Get the posterior scale to round calculations to + auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + + auto const principalDelta = + roundToAsset(vaultAsset, -loan.principalOutstanding, minScale); + + // The vault (pseudo-account) balance must fall by exactly the + // disbursed principal. + auto const vaultDeltaAssets = + roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); + if (vaultDeltaAssets != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must decrease vault " + "balance by the principal outstanding"; + result = false; + } + + // The reserved principal (held back when the pending loan was + // created) must be released by exactly the disbursed principal. + auto const assetsReservedDelta = roundToAsset( + vaultAsset, afterVault.assetsReserved - beforeVault.assetsReserved, minScale); + if (assetsReservedDelta != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must decrease assets " + "reserved by the principal outstanding"; + result = false; + } + + // Accepting a loan neither adds to nor removes from the pool + // tracked by assets available. + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + if (assetAvailableDelta != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must not change assets " + "available"; + result = false; + } + + // Likewise the interest booked at loan creation stands: assets + // outstanding must not move on accept. + auto const assetsTotalDelta = roundToAsset( + vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + if (assetsTotalDelta != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must not change assets " + "outstanding"; + result = false; + } + + // A loan accept neither mints nor burns vault shares. + if (beforeShares && updatedShares && + beforeShares->sharesTotal != updatedShares->sharesTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must not change shares " + "outstanding"; + result = false; + } + + return result; + } + + case ttLOAN_DELETE: { + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), + "xrpl::ValidVault::finalize : loan delete updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // Only the deletion of a pending loan touches the vault: it + // reverses the bookkeeping LoanSet performed at proposal time. + // The reserved principal returns to the available pool and the + // booked interest is removed from assets outstanding. No funds + // move, so the vault (pseudo-account) balance must not change. + // (Deleting an active loan never modifies the vault, so it does + // not reach this switch.) + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + auto const vaultDelta = maybeVaultDeltaAssets.value_or( + DeltaInfo{.delta = kZero, .scale = scale(afterVault.assetsTotal, vaultAsset)}); + + // Get the posterior scale to round calculations to + auto const minScale = computeVaultMinScale(vaultDelta, view.rules()); + + auto const vaultDeltaAssets = roundToAsset(vaultAsset, vaultDelta.delta, minScale); + if (vaultDeltaAssets != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must not change vault " + "balance"; + result = false; + } + + // A pending loan delete removes exactly the loan being deleted; + // its principal outstanding is returned to the available pool. + if (beforeLoan_.size() != 1 || !afterLoan_.empty()) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must delete exactly one " + "loan"; + return false; // That's all we can do + } + auto const& loan = beforeLoan_[0]; + + auto const principalDelta = + roundToAsset(vaultAsset, loan.principalOutstanding, minScale); + + // The reserved principal (held back when the pending loan was + // created) must be released by exactly the loan's principal. + auto const assetsReservedDelta = roundToAsset( + vaultAsset, afterVault.assetsReserved - beforeVault.assetsReserved, minScale); + if (assetsReservedDelta != -principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must decrease assets " + "reserved by the principal outstanding"; + result = false; + } + + // That same principal returns to the available pool. + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + if (assetAvailableDelta != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must increase assets " + "available by the principal outstanding"; + result = false; + } + + // The interest booked at loan creation is reversed: assets + // outstanding fall by exactly the interest due removed with the + // loan. + auto const assetsTotalDelta = roundToAsset( + vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + auto const interestDue = roundToAsset(vaultAsset, loan.interestDue(), minScale); + if (assetsTotalDelta != -interestDue) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must decrease assets " + "outstanding by the interest due"; + result = false; + } + + // A loan delete neither mints nor burns vault shares. + if (beforeShares && updatedShares && + beforeShares->sharesTotal != updatedShares->sharesTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: loan delete must not change shares " + "outstanding"; + result = false; + } + + return result; + } + + // LCOV_EXCL_START default: - // LCOV_EXCL_START UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type"); return false; // LCOV_EXCL_STOP diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index b56ac2a7969..cef46496df2 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4090,6 +4090,447 @@ class Invariants_test : public beast::unit_test::Suite precloseLoan); } + // LoanAccept restrictions: LoanAccept may only modify a loan that is + // pending acceptance, and the OwnerNode of an existing loan may only + // be added (never removed or changed), and only by LoanAccept. These + // need a loan that already exists in the base ledger, hence the same + // bespoke view construction as above. + { + // The loan's Borrower is the account that a valid LoanAccept must be + // submitted by; stranger stands in for any other account. + Account const borrower{"borrower"}; + Account const stranger{"stranger"}; + + // startDate defaults to the far future so the "StartDate must be in + // the future" check passes unless a test overrides it. + auto const testLoanUpdate = [&, this]( + STTx const& tx, + std::uint32_t baseFlags, + std::optional baseNode, + auto&& mutate, + std::optional const& expected, + std::uint32_t startDate = 0xFFFFFFFFu) { + Env env{*this, defaultAmendments() | featureLendingProtocolV1_1}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + // Create a real vault so a LoanAccept - which must modify the + // vault it lends from - can apply a self-consistent vault delta + // alongside the loan mutation under test. + Vault const vault{env}; + auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(createTx); + env(vault.deposit({.depositor = a1, .id = vaultKeylet.key, .amount = XRP(10)})); + env.close(); + + // The disbursed principal shared by the loan and the vault + // delta for every LoanAccept case below. + Number const principal{100}; + + OpenView ov{*env.current()}; + auto const loanKeylet = keylet::loan(vaultKeylet.key, 1); + + // Seed the vault with reserved principal (as if a pending loan + // had already set it aside) so LoanAccept can release it. + AccountID vaultPseudo; + { + auto const sleVaultBase = ov.read(vaultKeylet); + if (!BEAST_EXPECT(sleVaultBase)) + return; + vaultPseudo = sleVaultBase->getAccountID(sfAccount); + auto sleVault = std::make_shared(*sleVaultBase); + sleVault->at(sfAssetsReserved) = principal; + ov.rawReplace(sleVault); + } + { + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfPrincipalOutstanding) = principal; + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, 2); + sleLoan->setAccountID(sfBorrower, borrower.id()); + sleLoan->setFieldU32(sfStartDate, startDate); + if (baseFlags != 0) + sleLoan->setFieldU32(sfFlags, baseFlags); + if (baseNode) + sleLoan->setFieldU64(sfOwnerNode, *baseNode); + ov.rawInsert(sleLoan); + } + + 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; + mutate(sleLoan); + ac.view().update(sleLoan); + + // A LoanAccept must modify the vault it lends from: release the + // reserved principal and disburse it from the vault + // pseudo-account. The disbursed XRP is credited to a2 so the + // XRP-conservation invariant is satisfied. + if (tx.getTxnType() == ttLOAN_ACCEPT) + { + auto sleVault = ac.view().peek(vaultKeylet); + auto slePseudo = ac.view().peek(keylet::account(vaultPseudo)); + auto sleDest = ac.view().peek(keylet::account(a2.id())); + if (!BEAST_EXPECT(sleVault && slePseudo && sleDest)) + return; + + auto reservedProxy = sleVault->at(sfAssetsReserved); + reservedProxy -= principal; + ac.view().update(sleVault); + + STAmount const disbursed{100}; + slePseudo->setFieldAmount( + sfBalance, slePseudo->getFieldAmount(sfBalance) - disbursed); + ac.view().update(slePseudo); + sleDest->setFieldAmount( + sfBalance, sleDest->getFieldAmount(sfBalance) + disbursed); + ac.view().update(sleDest); + } + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{}); + if (expected) + { + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECTS(sink.messages().str().contains(*expected), *expected); + } + else + { + BEAST_EXPECTS( + result == tesSUCCESS, + sink.messages().str() + " expected logs: " + expected.value_or("")); + } + }; + + STTx const acceptTx{ + ttLOAN_ACCEPT, [&](STObject& tx) { tx.setAccountID(sfAccount, borrower.id()); }}; + + // ttLOAN_ACCEPT: modifying an active (non-pending) loan fails, + // even if the modification is otherwise harmless. + testLoanUpdate( + acceptTx, + 0, + 0, + [](SLE::pointer const& sle) { sle->setFieldU32(sfPaymentRemaining, 1); }, + "LoanAccept modified a Loan that was not pending"); + + // ttLOAN_ACCEPT: removing the OwnerNode while accepting fails. + testLoanUpdate( + acceptTx, + lsfLoanPending, + 0, + [](SLE::pointer const& sle) { + sle->clearFlag(lsfLoanPending); + sle->makeFieldAbsent(sfOwnerNode); + }, + "Loan OwnerNode removed or changed"); + + // ttLOAN_ACCEPT: changing the OwnerNode while accepting fails. + testLoanUpdate( + acceptTx, + lsfLoanPending, + 0, + [](SLE::pointer const& sle) { + sle->clearFlag(lsfLoanPending); + sle->setFieldU64(sfOwnerNode, 1); + }, + "Loan OwnerNode removed or changed"); + + // Only LoanAccept may add the OwnerNode to an existing loan. + testLoanUpdate( + STTx{ttACCOUNT_SET, [](STObject&) {}}, + 0, + std::nullopt, + [](SLE::pointer const& sle) { sle->setFieldU64(sfOwnerNode, 0); }, + "Loan OwnerNode added by an unauthorized transaction"); + + // ttLOAN_ACCEPT: the legitimate transition passes: clear the + // pending flag and link the loan into the borrower's directory. + testLoanUpdate( + acceptTx, + lsfLoanPending, + std::nullopt, + [](SLE::pointer const& sle) { + sle->clearFlag(lsfLoanPending); + sle->setFieldU64(sfOwnerNode, 0); + }, + std::nullopt); + + // ttLOAN_ACCEPT: an account other than the loan's Borrower must not + // accept the loan, even for an otherwise legitimate transition. + testLoanUpdate( + STTx{ + ttLOAN_ACCEPT, + [&](STObject& tx) { tx.setAccountID(sfAccount, stranger.id()); }}, + lsfLoanPending, + std::nullopt, + [](SLE::pointer const& sle) { + sle->clearFlag(lsfLoanPending); + sle->setFieldU64(sfOwnerNode, 0); + }, + "LoanAccept submitted by an account other than the Borrower"); + + // ttLOAN_ACCEPT: the loan's StartDate must still be in the future; + // accepting a loan whose StartDate has passed is a violation. + testLoanUpdate( + acceptTx, + lsfLoanPending, + std::nullopt, + [](SLE::pointer const& sle) { + sle->clearFlag(lsfLoanPending); + sle->setFieldU64(sfOwnerNode, 0); + }, + "LoanAccept processed a Loan whose StartDate is not in the future", + 1u); + + // The pending flag may only be cleared by LoanAccept: any other + // transaction clearing it is a violation. + testLoanUpdate( + STTx{ttACCOUNT_SET, [](STObject&) {}}, + lsfLoanPending, + std::nullopt, + [](SLE::pointer const& sle) { sle->clearFlag(lsfLoanPending); }, + "Loan Pending flag changed by an unauthorized transaction"); + + // The pending flag may never be set on an existing loan (it is only + // set at creation by LoanSet), not even by LoanAccept. + testLoanUpdate( + acceptTx, + 0, + 0, + [](SLE::pointer const& sle) { sle->setFlag(lsfLoanPending); }, + "Loan Pending flag changed by an unauthorized transaction"); + } + + // LoanSet malformedness: with the two-step flow enabled a LoanSet that + // creates a loan must use exactly one of two mutually exclusive paths - + // it either names a Borrower, or it carries a Counterparty together with + // a CounterpartySignature. A LoanSet that breaks these rules must never + // be applied. Each case creates a fully paid-off loan directly (so the + // earlier loan checks pass) under a ttLOAN_SET whose fields break one of + // the rules. + { + Account const borrower{"borrower"}; + Account const counterparty{"counterparty"}; + + // Principal released by the synthetic LoanSet. The vault is + // funded up front and its pseudo-account balance and + // AssetsAvailable are both reduced by this amount, matching the + // sfPrincipalRequested carried on the transaction. + constexpr int kPrincipal = 200; + + Preclose const createVault = [](Account const& a1, Account const&, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + return true; + }; + + // Creates a fully paid-off loan, optionally flagged pending, so the + // earlier loan checks pass and only the LoanSet creation checks + // fire. Moves kPrincipal drops from the vault pseudo-account to a2 + // and reduces AssetsAvailable accordingly, mirroring a real LoanSet + // that releases principal to the borrower. + auto const createLoan = [](bool pending, + std::optional startDate = std::nullopt, + std::optional borrower = std::nullopt) { + return [pending, startDate, borrower]( + Account const& a1, Account const& a2, ApplyContext& ac) { + auto const vaultKeylet = keylet::vault(a1.id(), ac.view().seq()); + auto sleVault = ac.view().peek(vaultKeylet); + if (!sleVault) + return false; + (*sleVault)[sfAssetsAvailable] = *(*sleVault)[sfAssetsAvailable] - kPrincipal; + ac.view().update(sleVault); + + auto sleVaultAccount = ac.view().peek(keylet::account(sleVault->at(sfAccount))); + if (!sleVaultAccount) + return false; + sleVaultAccount->at(sfBalance) -= XRPAmount(kPrincipal); + ac.view().update(sleVaultAccount); + + auto sleA2 = ac.view().peek(keylet::account(a2.id())); + if (!sleA2) + return false; + sleA2->at(sfBalance) += XRPAmount(kPrincipal); + ac.view().update(sleA2); + + auto const loanKeylet = keylet::loan(vaultKeylet.key, 1); + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfPrincipalOutstanding) = Number(kPrincipal); + sleLoan->at(sfTotalValueOutstanding) = Number(kPrincipal); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + if (borrower) + sleLoan->at(sfBorrower) = *borrower; + sleLoan->setFieldU32(sfPaymentRemaining, 1); + if (pending) + { + sleLoan->setFlag(lsfLoanPending); + } + else + { + sleLoan->makeFieldPresent(sfOwnerNode); + } + if (startDate) + sleLoan->setFieldU32(sfStartDate, *startDate); + ac.view().insert(sleLoan); + return true; + }; + }; + + // One-step flow must not be accompanied by a Borrower. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"Invariant failed: LoanSet specified a Borrower with a CounterpartySignature"}, + createLoan(false, std::nullopt, borrower.id()), + XRPAmount{}, + STTx{ + ttLOAN_SET, + [&](STObject& tx) { + tx.makeFieldPresent(sfCounterpartySignature); + tx.makeFieldPresent(sfBorrower); + tx.at(sfPrincipalRequested) = kPrincipal; + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + + // Two-step flow must not be accompanied by a Counterparty. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"Invariant failed: LoanSet specified a Borrower with a StartDate and a " + "Counterparty"}, + createLoan(false, std::nullopt, borrower.id()), + XRPAmount{}, + STTx{ + ttLOAN_SET, + [&](STObject& tx) { + tx.setAccountID(sfBorrower, borrower.id()); + tx.at(sfStartDate) = 0; + tx.setAccountID(sfCounterparty, counterparty.id()); + tx.at(sfPrincipalRequested) = kPrincipal; + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + + // A LoanSet must use one of the two creation paths. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"Invariant failed: LoanSet specified neither a Borrower with a StartDate nor a " + "CounterpartySignature"}, + createLoan(false, std::nullopt, borrower.id()), + XRPAmount{}, + STTx{ttLOAN_SET, [&](STObject& tx) { tx.at(sfPrincipalRequested) = kPrincipal; }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + + // A two-step LoanSet (Borrower and StartDate, no Counterparty or + // CounterpartySignature) must create a pending loan; creating a + // non-pending loan instead is a violation. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"LoanSet pending flag does not match the two-step flow inputs"}, + createLoan(false, std::nullopt, borrower.id()), + XRPAmount{}, + STTx{ + ttLOAN_SET, + [&](STObject& tx) { + tx.setAccountID(sfBorrower, borrower.id()); + tx.setFieldU32(sfStartDate, 0xFFFF'FFFF); + tx.at(sfPrincipalRequested) = kPrincipal; + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + + // A Counterparty + CounterpartySignature LoanSet must create an + // active (non-pending) loan; creating a pending loan instead is a + // violation. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"LoanSet pending flag does not match the two-step flow inputs"}, + createLoan(true, std::nullopt, borrower.id()), + XRPAmount{}, + STTx{ + ttLOAN_SET, + [&](STObject& tx) { + tx.setAccountID(sfCounterparty, counterparty.id()); + tx.makeFieldPresent(sfCounterpartySignature); + tx.at(sfPrincipalRequested) = kPrincipal; + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + + // A created loan must record its Borrower: an otherwise well-formed + // LoanSet that leaves the loan without a Borrower is a violation. + // createLoan never sets sfBorrower, so a Counterparty + + // CounterpartySignature LoanSet (which passes the mutual-exclusion + // and pending-flag checks) reaches and trips this check. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"LoanSet did not set the Loan Borrower"}, + createLoan(false, std::nullopt, std::nullopt), + XRPAmount{}, + STTx{ + ttLOAN_SET, + [&](STObject& tx) { + tx.setAccountID(sfCounterparty, counterparty.id()); + tx.makeFieldPresent(sfCounterpartySignature); + tx.at(sfPrincipalRequested) = kPrincipal; + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + + // In the two-step flow the named Borrower must differ from the + // submitting account; a LoanSet where they are equal is a violation. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"LoanSet Borrower is the submitting account"}, + createLoan(false, std::nullopt, borrower.id()), + XRPAmount{}, + STTx{ + ttLOAN_SET, + [&](STObject& tx) { + tx.setAccountID(sfAccount, borrower.id()); + tx.setAccountID(sfBorrower, borrower.id()); + tx.setFieldU32(sfStartDate, 0xFFFF'FFFF); + tx.at(sfPrincipalRequested) = kPrincipal; + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + + // A pending loan created by the two-step flow must have a StartDate + // in the future; creating one with a past StartDate is a violation. + // createLoan(true, 1) makes a pending loan whose StartDate (1) is in + // the past, and the Borrower + StartDate LoanSet keeps the pending + // flag consistent so this check is reached. + doInvariantCheck( + Env{*this, defaultAmendments() | featureLendingProtocolV1_1}, + {"LoanSet created a pending Loan whose StartDate is not in the " + "future"}, + createLoan(true, 1u), + XRPAmount{}, + STTx{ + ttLOAN_SET, + [&](STObject& tx) { + tx.setAccountID(sfBorrower, borrower.id()); + tx.setFieldU32(sfStartDate, 0xFFFF'FFFF); + tx.at(sfPrincipalRequested) = kPrincipal; + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createVault); + } + // Loan interest due (total value less principal and management fee) // must never be negative. A neutral transaction type is used so the // vault invariants short-circuit and only the loan check fires. The