Fix issues

This commit is contained in:
JCW
2026-07-14 20:02:10 +01:00
parent 31f1d5ff50
commit d5bb723e04
8 changed files with 454 additions and 45 deletions

View File

@@ -1052,7 +1052,7 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet,
TRANSACTION(ttLOAN_DELETE, 81, LoanDelete,
Delegation::NotDelegable,
featureLendingProtocol,
NoPriv, ({
MayModifyVault, ({
{sfLoanID, SoeRequired},
}))

View File

@@ -96,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.

View File

@@ -24,9 +24,11 @@ class LoanSet : public Transactor
private:
static std::uint32_t
getStartDate(ReadView const& view, STTx const& tx);
static bool
isTwoStepFlowEnabled(Rules const& rules);
/* Returns true if the transaction is using the two-step flow. */
static bool
isTwoStepFlow(STTx const& tx, Rules const& rules);
isTwoStepFlow(STTx const& tx);
/* Returns true if the transaction is using the one-step flow. */
static bool
isOneStepFlow(STTx const& tx);

View File

@@ -87,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;
}
@@ -1110,11 +1111,22 @@ ValidVault::finalize(
!beforeVault_.empty(), "xrpl::ValidVault::finalize : loan set updated a vault");
auto const& beforeVault = beforeVault_[0];
// A loan set must create exactly one loan object; the interest
// it books is the only permitted change to assets outstanding.
if (afterLoan_.size() != 1 || !beforeLoan_.empty())
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must create exactly one loan";
return false; // That's all we can do
}
auto const& loan = afterLoan_[0];
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";
@@ -1132,7 +1144,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 "
@@ -1150,16 +1162,6 @@ ValidVault::finalize(
result = false;
}
// A loan set must create exactly one loan object; the interest
// it books is the only permitted change to assets outstanding.
if (afterLoan_.size() != 1 || !beforeLoan_.empty())
{
JLOG(j.fatal()) << //
"Invariant failed: loan set must create exactly one loan";
return false; // That's all we can do
}
auto const& loan = afterLoan_[0];
// The created loan must record exactly the principal the vault
// released. Otherwise the borrower's claim (and thus the assets
// booked back to the vault on repayment) is decoupled from the
@@ -1536,6 +1538,101 @@ ValidVault::finalize(
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:
UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type");

View File

@@ -45,7 +45,7 @@ currentLedgerCloseTime(ReadView const& view)
std::uint32_t
LoanSet::getStartDate(ReadView const& view, STTx const& tx)
{
if (isTwoStepFlow(tx, view.rules()))
if (isTwoStepFlow(tx) && isTwoStepFlowEnabled(view.rules()))
{
return tx[sfStartDate];
}
@@ -65,11 +65,14 @@ LoanSet::getFlagsMask(PreflightContext const& ctx)
}
bool
LoanSet::isTwoStepFlow(STTx const& tx, Rules const& rules)
LoanSet::isTwoStepFlowEnabled(Rules const& rules)
{
if (!rules.enabled(featureLendingProtocolV1_1))
return false;
return rules.enabled(featureLendingProtocolV1_1);
}
bool
LoanSet::isTwoStepFlow(STTx const& tx)
{
// The two-step (Borrower) flow is started when the LoanSet names a
// Borrower and a StartDate but carries neither a Counterparty nor a
// CounterpartySignature.
@@ -107,41 +110,54 @@ LoanSet::preflight(PreflightContext const& ctx)
return std::nullopt;
}();
bool twoStepFlow = isTwoStepFlow(tx, ctx.rules);
bool oneStepFlow = isOneStepFlow(tx);
bool const twoStepFlowEnabled = isTwoStepFlowEnabled(ctx.rules);
bool const twoStepFlow = isTwoStepFlow(tx);
bool const oneStepFlow = isOneStepFlow(tx);
// In the two-step (Borrower) flow introduced by V1.1, a CounterpartySignature
// is not required even for non-batch transactions. The immediate flow still
// requires one.
if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !twoStepFlow)
if (!tx.isFlag(tfInnerBatchTxn) && !counterPartySig && !twoStepFlowEnabled)
{
JLOG(ctx.j.warn()) << "LoanSet transaction must have a CounterpartySignature.";
return temBAD_SIGNER;
}
if (!twoStepFlow && !oneStepFlow)
if (twoStepFlowEnabled)
{
JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a "
"StartDate or a CounterpartySignature.";
return temINVALID;
}
if (!twoStepFlow && !oneStepFlow)
{
JLOG(ctx.j.warn()) << "LoanSet transaction must specify either a Borrower with a "
"StartDate or a CounterpartySignature.";
return temINVALID;
}
if (oneStepFlow)
if (oneStepFlow)
{
if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate))
{
JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a "
"StartDate and a CounterpartySignature.";
return temINVALID;
}
}
if (twoStepFlow)
{
if (tx.isFieldPresent(sfCounterpartySignature))
{
JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a "
"StartDate and a CounterpartySignature.";
return temINVALID;
}
}
}
else
{
if (tx.isFieldPresent(sfBorrower) || tx.isFieldPresent(sfStartDate))
{
JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a "
"StartDate and a CounterpartySignature.";
return temINVALID;
}
}
if (twoStepFlow)
{
if (tx.isFieldPresent(sfCounterpartySignature))
{
JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify both a Borrower with a "
"StartDate and a CounterpartySignature.";
return temINVALID;
JLOG(ctx.j.warn()) << "LoanSet transaction cannot specify a Borrower with a "
"StartDate without the two-step flow being enabled.";
return temDISABLED;
}
}
@@ -212,7 +228,7 @@ LoanSet::checkSign(PreclaimContext const& ctx)
// In the two-step (Borrower) flow introduced by V1.1 there is no
// counterparty, so there is no CounterpartySignature to check.
if (isTwoStepFlow(ctx.tx, ctx.view.rules()))
if (isTwoStepFlowEnabled(ctx.view.rules()) && isTwoStepFlow(ctx.tx))
return tesSUCCESS;
// Counter signer is optional. If it's not specified, it's assumed to be
@@ -343,7 +359,7 @@ LoanSet::preclaim(PreclaimContext const& ctx)
return tecNO_ENTRY;
}
auto const brokerOwner = brokerSle->at(sfOwner);
bool const twoStepFlow = isTwoStepFlow(tx, ctx.view.rules());
bool const twoStepFlow = isTwoStepFlow(tx);
// Determine the Borrower and validate the submitter's permission. In the
// two-step flow the LoanBroker owner proposes the loan on behalf of the
@@ -422,7 +438,7 @@ LoanSet::preclaim(PreclaimContext const& ctx)
if (tx[sfStartDate] <= currentLedgerCloseTime(ctx.view))
{
JLOG(ctx.j.warn()) << "Start date is in the past.";
return tecTOO_SOON;
return tecEXPIRED;
}
}
@@ -434,7 +450,7 @@ LoanSet::doApply()
{
auto const& tx = ctx_.tx;
auto& view = ctx_.view();
bool const twoStepFlow = isTwoStepFlow(tx, view.rules());
bool const twoStepFlow = isTwoStepFlow(tx);
auto const brokerID = tx[sfLoanBrokerID];

View File

@@ -3461,6 +3461,277 @@ protected:
nullptr);
}
// Exercises the two-step (LendingProtocolV1_1) flow, where the LoanBroker
// owner proposes a pending Loan (LoanSet with a Borrower and StartDate) that
// the Borrower later accepts (LoanAccept) or that either party cancels
// (LoanDelete). Requires the LendingProtocolV1_1 amendment.
void
testTwoStep(FeatureBitset features)
{
using namespace jtx;
using namespace jtx::loan;
using namespace std::chrono_literals;
Account const lender{"lender"}; // Vault + LoanBroker owner
Account const borrower{"borrower"};
Account const evan{"evan"}; // unrelated third party
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
// Loan terms shared across the scenarios.
Number const principal = xrpAsset(200).number();
auto const interest = TenthBips32{50'000};
std::uint32_t const payTotal = 10;
std::uint32_t const payInterval = 200;
// Build a funded environment with a Vault + LoanBroker owned by
// `lender`, and return the broker.
auto const makeBroker = [&](Env& env) -> BrokerInfo {
env.fund(XRP(100'000'000), noripple(lender));
env.fund(XRP(1'000'000), borrower, evan);
env.close();
return createVaultAndBroker(env, xrpAsset, lender);
};
// The keylet of the next loan the broker will create.
auto const nextLoanKeylet = [&](Env& env, BrokerInfo const& broker) -> Keylet {
auto const brokerSle = env.le(broker.brokerKeylet());
return keylet::loan(broker.brokerID, brokerSle->at(sfLoanSequence));
};
// A StartDate comfortably in the future.
auto const futureStart = [&](Env& env) -> std::uint32_t {
return (env.now() + 1h).time_since_epoch().count();
};
// Snapshot of the vault's asset accounting.
struct VaultAmounts
{
Number available;
Number reserved;
Number total;
};
auto const readVault = [&](Env& env, BrokerInfo const& broker) -> VaultAmounts {
auto const v = env.le(broker.vaultKeylet());
return {
.available = v->at(sfAssetsAvailable),
.reserved = v->at(sfAssetsReserved),
.total = v->at(sfAssetsTotal)};
};
// Submit a valid two-step proposal from `proposer` on behalf of
// `theBorrower`, with the supplied StartDate and any extra functors.
auto const propose = [&](Env& env,
BrokerInfo const& broker,
Account const& proposer,
Account const& theBorrower,
std::uint32_t startDate,
auto const&... extra) {
env(set(proposer, broker.brokerID, principal),
kBorrower(theBorrower),
kStartDate(startDate),
kInterestRate(interest),
kPaymentTotal(payTotal),
kPaymentInterval(payInterval),
extra...);
};
auto const featureEnabled = [&]() -> bool {
return (features & featureLendingProtocolV1_1).any();
}();
if (!featureEnabled)
{
testcase("Two-step: rejected as before");
Env env(*this, features);
auto const broker = makeBroker(env);
propose(env, broker, lender, borrower, futureStart(env), Ter(temBAD_SIGNER));
// Rest of the tests are not applicable
return;
}
{
testcase("Two-step: propose then accept");
Env env(*this, features);
auto const broker = makeBroker(env);
auto const vault0 = readVault(env, broker);
auto const lenderOwners0 = env.ownerCount(lender);
auto const borrowerOwners0 = env.ownerCount(borrower);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, futureStart(env));
env.close();
// The proposal creates a pending Loan, linked only into the broker
// pseudo-account's directory.
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->at(sfBorrower) == borrower.id());
BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode));
BEAST_EXPECT(!loan->isFieldPresent(sfOwnerNode));
}
// The owner reserve is charged to the broker owner, not the
// borrower.
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1);
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0);
// Vault bookkeeping: Available -= P, Reserved += P, Total +=
// InterestDue.
auto const vault1 = readVault(env, broker);
BEAST_EXPECT(vault1.available == vault0.available - principal);
BEAST_EXPECT(vault1.reserved == vault0.reserved + principal);
BEAST_EXPECT(vault1.total > vault0.total);
Number const interestDue = vault1.total - vault0.total;
// Capture pre-acceptance balances to verify disbursement.
auto const vaultPseudo = [&]() {
auto const v = env.le(broker.vaultKeylet());
return Account("vault pseudo-account", v->at(sfAccount));
}();
STAmount const pseudoBal0 = env.balance(vaultPseudo).value();
STAmount const borrowerBal0 = env.balance(borrower).value();
env(accept(borrower, loanKeylet.key));
env.close();
// The loan is now active and linked into the borrower's directory.
if (auto const loan = env.le(loanKeylet); BEAST_EXPECT(loan))
{
BEAST_EXPECT(!loan->isFlag(lsfLoanPending));
BEAST_EXPECT(loan->isFieldPresent(sfLoanBrokerNode));
BEAST_EXPECT(loan->isFieldPresent(sfOwnerNode));
}
// The reserve is swapped from the broker owner to the borrower.
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0);
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0 + 1);
// Reserved principal is released; Available and Total are unchanged
// from the proposal.
auto const vault2 = readVault(env, broker);
BEAST_EXPECT(vault2.reserved == vault0.reserved);
BEAST_EXPECT(vault2.available == vault0.available - principal);
BEAST_EXPECT(vault2.total == vault0.total + interestDue);
// The principal is disbursed from the vault pseudo-account to the
// borrower (origination fee is zero, so the borrower receives it
// all, less the transaction fee it paid).
BEAST_EXPECT(env.balance(vaultPseudo).value() == pseudoBal0 - xrpAsset(200).value());
BEAST_EXPECT(env.balance(borrower).value() > borrowerBal0);
}
{
testcase("Two-step: proposal failures");
Env env(*this, features);
auto const epoch = env.now();
auto const broker = makeBroker(env);
// The submitter must be the LoanBroker owner
propose(env, broker, evan, borrower, futureStart(env), Ter(tecNO_PERMISSION));
// The StartDate must be in the future.
std::uint32_t const pastDate = epoch.time_since_epoch().count();
propose(env, broker, lender, borrower, pastDate, Ter(tecEXPIRED));
}
{
testcase("Two-step: LoanAccept validation");
Env env(*this, features);
auto const broker = makeBroker(env);
// Zero LoanID fails preflight.
env(accept(borrower, uint256{}), Ter(temINVALID));
// A LoanID that does not resolve to a Loan object.
env(accept(borrower, keylet::loan(broker.brokerID, 999).key), Ter(tecNO_ENTRY));
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, futureStart(env));
env.close();
// Only the borrower may accept.
env(accept(evan, loanKeylet.key), Ter(tecNO_PERMISSION));
env(accept(lender, loanKeylet.key), Ter(tecNO_PERMISSION));
// The borrower accepts successfully.
env(accept(borrower, loanKeylet.key));
env.close();
// The loan is no longer pending, so it cannot be accepted again.
env(accept(borrower, loanKeylet.key), Ter(tecNO_PERMISSION));
}
{
testcase("Two-step: LoanAccept after expiry");
Env env(*this, features);
auto const broker = makeBroker(env);
auto const loanKeylet = nextLoanKeylet(env, broker);
std::uint32_t const startDate = (env.now() + 1h).time_since_epoch().count();
propose(env, broker, lender, borrower, startDate);
env.close();
// Advance the ledger beyond the StartDate.
env.close(NetClock::time_point{NetClock::duration{startDate}} + 1h);
env(accept(borrower, loanKeylet.key), Ter(tecEXPIRED));
}
// Deleting a pending loan reverses the proposal-time bookkeeping and
// releases the broker owner's reserve. It can be done by either the
// broker owner or the borrower.
auto const testDeletePending = [&](Account const& deleter) {
Env env(*this, features);
auto const broker = makeBroker(env);
auto const vault0 = readVault(env, broker);
auto const lenderOwners0 = env.ownerCount(lender);
auto const borrowerOwners0 = env.ownerCount(borrower);
auto const loanKeylet = nextLoanKeylet(env, broker);
propose(env, broker, lender, borrower, futureStart(env));
env.close();
BEAST_EXPECT(env.le(loanKeylet));
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0 + 1);
// An unrelated account cannot delete the loan.
env(del(evan, loanKeylet.key), Ter(tecNO_PERMISSION));
env(del(deleter, loanKeylet.key));
env.close();
// The loan is gone, the reserve is released, and the vault
// bookkeeping is fully reversed.
BEAST_EXPECT(!env.le(loanKeylet));
BEAST_EXPECT(env.ownerCount(lender) == lenderOwners0);
BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwners0);
auto const vault1 = readVault(env, broker);
BEAST_EXPECT(vault1.available == vault0.available);
BEAST_EXPECT(vault1.reserved == vault0.reserved);
BEAST_EXPECT(vault1.total == vault0.total);
};
{
testcase("Two-step: LoanDelete of pending loan by broker owner");
testDeletePending(lender);
}
{
testcase("Two-step: LoanDelete of pending loan by borrower");
testDeletePending(borrower);
}
}
void
testLifecycle(FeatureBitset features)
{
@@ -8560,6 +8831,7 @@ protected:
// Lifecycle
testLifecycle(features);
testLoanSet(features);
testTwoStep(features);
testDosLoanPay(features);
testSelfLoan(features);
@@ -8608,7 +8880,8 @@ public:
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1},
all_))
runAmendmentSensitive(features);
}
};

View File

@@ -919,6 +919,11 @@ set(AccountID const& account,
auto const kCounterparty = JTxFieldWrapper<AccountIdField>(sfCounterparty);
// Two-step (LendingProtocolV1_1) proposal fields.
auto const kBorrower = JTxFieldWrapper<AccountIdField>(sfBorrower);
auto const kStartDate = simpleField<SF_UINT32>(sfStartDate);
// For `CounterPartySignature`, use `Sig(sfCounterpartySignature, ...)`
auto const kLoanOriginationFee = simpleField<SF_NUMBER>(sfLoanOriginationFee);
@@ -950,6 +955,10 @@ auto const kGracePeriod = simpleField<SF_UINT32>(sfGracePeriod);
json::Value
manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags);
// Two-step (LendingProtocolV1_1) acceptance of a pending loan proposal.
json::Value
accept(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0);
json::Value
del(AccountID const& account, uint256 const& loanID, std::uint32_t flags = 0);

View File

@@ -823,6 +823,17 @@ manage(AccountID const& account, uint256 const& loanID, std::uint32_t flags)
return jv;
}
json::Value
accept(AccountID const& account, uint256 const& loanID, std::uint32_t flags)
{
json::Value jv;
jv[sfTransactionType] = jss::LoanAccept;
jv[sfAccount] = to_string(account);
jv[sfLoanID] = to_string(loanID);
jv[sfFlags] = flags;
return jv;
}
json::Value
del(AccountID const& account, uint256 const& loanID, std::uint32_t flags)
{