Skip to content

feat: Split LoanSet and LoanAccept#7820

Open
a1q123456 wants to merge 1 commit into
developfrom
a1q123456/split-loan-set-and-loan-accept-implementation
Open

feat: Split LoanSet and LoanAccept#7820
a1q123456 wants to merge 1 commit into
developfrom
a1q123456/split-loan-set-and-loan-accept-implementation

Conversation

@a1q123456

Copy link
Copy Markdown
Contributor

High Level Overview of Change

This PR adds LoanAccept, and modifies LoanDelete and LoanSet to support the two step flow introduced by XRPLF/XRPL-Standards#570.

  1. Extracts some logic in LoanSet into functions
  2. Introduced LoanAccept
  3. Modifies LoanSet and LoanDelete with the changes amendment gated
  4. Tests

Context of Change

XRPLF/XRPL-Standards#570

API Impact

  • Public API: New feature (new methods and/or new fields)
  • Public API: Breaking change (in general, breaking changes should only impact the next api_version)
  • libxrpl change (any change that may affect libxrpl or dependents of libxrpl)
  • Peer protocol change (must be backward compatible or bump the peer protocol version)

@a1q123456 a1q123456 changed the title feat: Add pending loan fields and LoanAccept for LendingProtocolV1_1 feat: Split LoanSet and LoanAccept Jul 16, 2026

@xrplf-ai-reviewer xrplf-ai-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues flagged inline: a debug test narrowing that skips most amendment combinations (high — must revert before merge), and an auth bypass in LoanSet::checkSign where counterparty signature validation can be skipped by crafting transaction fields to satisfy isTwoStepFlow regardless of the on-ledger loan's origination flow.

Review by Claude Sonnet 4.6 · Prompt: V15

Comment thread src/test/app/Loan_test.cpp Outdated
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
// runAmendmentIndependent();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug narrowing left in — runAmendmentIndependent() is commented out and the amendment combination matrix was reduced to {featureLendingProtocolV1_1} only, skipping all existing fixCleanup3_1_3/fixCleanup3_2_0/featureMPTokensV2 combinations. Restore before merging:

        runAmendmentIndependent();
        for (auto const& features : jtx::amendmentCombinations(
                 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2, featureLendingProtocolV1_1}, all_))
            runAmendmentSensitive(features);


// In the two-step (Borrower) flow introduced by V1.1 there is no
// counterparty, so there is no CounterpartySignature to check.
if (isTwoStepFlowEnabled(ctx.view.rules()) && isTwoStepFlow(ctx.tx))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth bypass: isTwoStepFlow() checks only tx fields the submitter controls, not the on-ledger loan object. A malicious actor can craft a tx with sfBorrower+sfStartDate targeting a one-step loan and skip counterparty signature validation. Verify the origination flow against the ledger object, not solely the incoming transaction fields.

Comment thread src/libxrpl/tx/transactors/lending/LoanSet.cpp
loan->at(sfNextPaymentDueDate) = startDate + paymentInterval;
loan->at(sfPaymentRemaining) = paymentTotal;
if (twoStepFlow)
loan->setFlag(lsfLoanPending);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Severity: HIGH

LoanManage::preclaim does not check lsfLoanPending, allowing the broker owner to default a pending loan via LoanManage. This zeroes PrincipalOutstanding and liquidates cover. Subsequent LoanAccept or pending-path LoanDelete then double-reverses vault/broker accounting, corrupting ledger state.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: Add a guard in LoanManage::preclaim (in LoanManage.cpp) to reject transactions when the loan has the lsfLoanPending flag set. Insert the following check after the lsfLoanDefault check (around line 88 in LoanManage.cpp):

if (loanSle->isFlag(lsfLoanPending))
{
    JLOG(ctx.j.warn()) << "Loan is pending acceptance. A pending loan can not be managed.";
    return tecNO_PERMISSION;
}

This prevents a broker owner from calling LoanManage with tfLoanDefault (or tfLoanImpair/tfLoanUnimpair) on a loan that has not yet been accepted by the borrower via LoanAccept. Without this guard, a pending loan can be defaulted, which improperly liquidates first-loss capital (cover) for a loan where principal was never disbursed, permanently inflates sfAssetsReserved, and corrupts vault state.

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.28065% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.5%. Comparing base (5ce0b1c) to head (e32b303).

Files with missing lines Patch % Lines
src/libxrpl/tx/transactors/lending/LoanSet.cpp 85.7% 14 Missing ⚠️
src/libxrpl/ledger/helpers/LendingHelpers.cpp 89.2% 10 Missing ⚠️
src/libxrpl/tx/transactors/lending/LoanAccept.cpp 90.4% 8 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##           develop   #7820    +/-   ##
========================================
  Coverage     82.5%   82.5%            
========================================
  Files         1039    1042     +3     
  Lines        80446   80713   +267     
  Branches      9080    9086     +6     
========================================
+ Hits         66332   66573   +241     
- Misses       14105   14131    +26     
  Partials         9       9            
Files with missing lines Coverage Δ
include/xrpl/ledger/helpers/LendingHelpers.h 96.6% <ø> (ø)
include/xrpl/protocol/LedgerFormats.h 100.0% <ø> (ø)
include/xrpl/protocol/detail/ledger_entries.macro 100.0% <ø> (ø)
include/xrpl/protocol/detail/transactions.macro 100.0% <100.0%> (ø)
...nclude/xrpl/protocol_autogen/ledger_entries/Loan.h 100.0% <100.0%> (ø)
...clude/xrpl/protocol_autogen/ledger_entries/Vault.h 100.0% <100.0%> (ø)
...de/xrpl/protocol_autogen/transactions/LoanAccept.h 100.0% <100.0%> (ø)
...de/xrpl/protocol_autogen/transactions/LoanDelete.h 100.0% <ø> (ø)
...clude/xrpl/protocol_autogen/transactions/LoanSet.h 100.0% <100.0%> (ø)
include/xrpl/tx/transactors/lending/LoanAccept.h 100.0% <100.0%> (ø)
... and 7 more

... and 2 files with indirect coverage changes

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@a1q123456
a1q123456 force-pushed the a1q123456/split-loan-set-and-loan-accept-implementation branch from 7849e48 to 14a1bb2 Compare July 16, 2026 23:19

@xrplf-ai-reviewer xrplf-ai-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing view.update(brokerSle) would leave broker with inflated debt/loan count after pending loan cancellation — see inline.

Review by Claude Sonnet 4.6 · Prompt: V15

-(principalOutstanding + state.interestDue),
vaultAsset,
vaultScale);
adjustLoanBrokerOwnerCount(view, brokerSle, -1, j_);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing view.update(brokerSle) — changes to sfDebtTotal and owner count are silently dropped without it:

        adjustLoanBrokerOwnerCount(view, brokerSle, -1, j_);
        view.update(brokerSle);

@a1q123456
a1q123456 force-pushed the a1q123456/split-loan-set-and-loan-accept-implementation branch from 14a1bb2 to e32b303 Compare July 16, 2026 23:55

@xrplf-ai-reviewer xrplf-ai-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ship it

Review by Claude Sonnet 4.6 · Prompt: V15

}

TER
checkLoanLimits(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this method is a nice refactoring, at the moment it's called only in LoanSet. I suggest to make this refactoring in a separate PR.

There are a few things that should be improved with this:

  1. The method is read-only, thus all parameters should be read only. I.e. ReadView instead of ApplyView, SLE::const_ref instead of SLE::ref.
  2. The method requires a bunch of redundant arguments. LoanProperites already contain LoanState, which in-turn already contains principalRequested.

Since the function requires so many parameters, it is probably doing too many things at once. I'd recommend to split it up further:

  1. A method to validate valueFieldsare rounded at Loan scale.
  2. Method to validate that the Loan would not exceed Vault and Broker asset limits.
  3. Method to ensure there are sufficient funds for the FLC.
  4. Method to check LoanGuards and that the computed loan properties are valid.

{
if (auto const ter = canAddHolding(view, asset))
return ter;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are redundant checks in this method:

checkFrozen checks whether an asset is globally frozen, and then checks if a recursion is needed of the asset is an MPT.

I suggest to refactor this code as

  1. isGlobalFrozen
  2. checkIndividualFrozen(vaultPseudo)
  3. `checkDeepFrozen(brokerPseudo)
  4. checkIndividualFrozen(borrower)
  5. checkDeepFrozen(brokerOwner)

if (auto const ter =
reserveLoanOwner(view, brokerOwner, brokerOwnerSle, accountID_, preFeeBalance_, j_))
return ter;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the immediate flow, LoanSet::doApply's twoStepFlow branch never calls requireAuth for the named borrower against vaultAsset — that check only happens later, inside disburseLoan during LoanAccept. So a broker owner can propose a two-step loan to a borrower who isn't (or never becomes) authorized to hold the vault asset; the loan sits pending, holding the broker's reserve and the vault's committed principal, until it either expires or someone runs LoanDelete.

}

TER
LoanSet::doApply()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

separate one-step / two-step in LoanSet::doApply

doApply interleaves the two flows as four separate if (twoStepFlow) branches — borrower resolution (479-487), reserve/disburse (555-586), pending-flag/vault bookkeeping (630, 639-640), and directory link (656-660). Reading any one flow means filtering out the other inline.

Proposal: extract only the two branches with real behavior — borrower resolution and reserve/disburse — into anonymous-namespace helpers:

LoanParticipants resolveParticipants(tx, twoStepFlow, brokerOwner, accountID); // {borrower, counterparty}
TER createPendingLoan(view, brokerOwner, brokerOwnerSle, accountID, preFeeBalance, j);
TER createImmediateLoan(viewContext, participants, borrowerSle, brokerOwner, brokerOwnerSle,
vaultPseudo, vaultAsset, loanAssetsToBorrower, originationFee,
accountID, preFeeBalance, j);

doApply becomes: common setup → resolveParticipants → twoStepFlow ? createPendingLoan(...) : createImmediateLoan(...) → unchanged common loan/vault construction.

Leave as-is: the 3 single-line if (twoStepFlow) after loan construction (pending flag, reserved bucket, directory link). Splitting those into two parallel builder functions would duplicate ~40 lines of identical field-setting to save 3 lines of branching — worse trade.

Why free functions, not private methods: none touch this; keeping them anonymous-namespace matches the existing currentLedgerCloseTime helper and avoids header churn.

}

NotTEC
LoanSet::preflight(PreflightContext const& ctx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

borrower resolution + authorization interleaved in one lambda (377-395)

std::expected<AccountID, TER> const maybeBorrower = & -> std::expected<AccountID, TER> {
if (twoStepFlow) { ... return tx[sfBorrower]; }
auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner);
if (account != brokerOwner && counterparty != brokerOwner) { ... }
return counterparty == brokerOwner ? account : counterparty;
}();

Same shape as the doApply borrower-resolution lambda — worth extracting as a matching pair of named helpers for consistency and symmetry with the doApply refactor:

std::expected<AccountID, TER> resolveTwoStepBorrower(tx, account, brokerOwner, j);
std::expected<AccountID, TER> resolveOneStepBorrower(tx, account, brokerOwner, j);

called as twoStepFlow ? resolveTwoStepBorrower(...) : resolveOneStepBorrower(...). This is the one piece of branching logic in preclaim; naming it removes the need to read into the lambda body to know which flow's permission rule applies.

}

TER
updateLoanBroker(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as checkLoanLimits I suggest to move this refactoring to a separate PR to keep this PR smaller.

{
// Put the loan into the pseudo-account's directory
return dirLink(view, brokerPseudo, loan, sfLoanBrokerNode);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only called from the LoanSet transaction, it's an encapsulation for a single call, perhaps it's not needed?

Comment on lines +124 to +130
auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable);
auto vaultReservedProxy = vaultSle->at(sfAssetsReserved);
auto vaultTotalProxy = vaultSle->at(sfAssetsTotal);
vaultAvailableProxy += principalOutstanding;
vaultReservedProxy -= principalOutstanding;
vaultTotalProxy -= state.interestDue;
view.update(vaultSle);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proxy values aren't needed, they're good if we're modifying SLE somewhere in a separate method, but in this case, you can simplify the code by directly modifying the values in place:

e.g.

vaultSle->at(sfAssetsTotal) -= state.interestDue;

// time and releases the owner reserve charged to the LoanBroker owner. It is
// only linked into the broker pseudo-account's directory, and the borrower
// was never charged a reserve.
if (loanSle->isFlag(lsfLoanPending))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about splitting the LoanDelete::doApply into two distinct methods deletePendingLoan and deleteActiveLoan?

namespace {

// Two-step (pending) loan: reverse the vault bookkeeping and broker-owner
// reserve charged at proposal time; the loan was never linked to the Borrower.
TER
deletePendingLoan(
    ApplyView& view,
    SLE::ref loanSle,
    SLE::ref brokerSle,
    SLE::ref vaultSle,
    AccountID const& brokerPseudoAccount,
    Asset const& vaultAsset,
    uint256 const& loanID,
    beast::Journal j);

// Active (fully-paid) loan: unlink from both directories, forgive any
// dust debt if this was the broker's last loan, release the Borrower's
// reserve.
TER
deleteActiveLoan(
    ApplyView& view,
    SLE::ref loanSle,
    SLE::ref brokerSle,
    SLE::ref vaultSle,
    AccountID const& brokerPseudoAccount,
    Asset const& vaultAsset,
    uint256 const& loanID,
    beast::Journal j);
}

Comment on lines +27 to +34
static bool
isTwoStepFlowEnabled(Rules const& rules);
/* Returns true if the transaction is using the two-step flow. */
static bool
isTwoStepFlow(STTx const& tx);
/* Returns true if the transaction is using the one-step flow. */
static bool
isOneStepFlow(STTx const& tx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods are only used in the LoanSet.cpp implementation, what about moving them to an anonymous namespace in LoanSet? That way the header won't have to be modified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants