Compare commits

...

10 Commits

Author SHA1 Message Date
Timur Ialymov
debdd961fc Merge remote-tracking branch 'origin/develop' into tialymov/FN-85-vault_iou_require_auth 2026-08-19 15:11:50 +01:00
Timur Ialymov
6434427bcb chore: Drop the comment above the payee authorization gate
The variable name and the rules check next to it already say what the gate
does. The note about giving up the missing-line guard belongs in the commit
that introduced the change, not in the source.
2026-08-19 15:11:48 +01:00
Timur Ialymov
7668ee65d3 Merge remote-tracking branch 'origin/develop' into tialymov/FN-85-vault_iou_require_auth 2026-08-19 14:16:37 +01:00
Timur Ialymov
f96a96a880 fix: Drop the payee authorization checks from loan repayment
Now that a vault or broker pseudo-account counts as authorized to hold an
IOU, the two StrongAuth checks in the payout path can no longer reject
anyone. A pseudo-account payee is exempt, and a broker owner only becomes
the payee by passing the identical check where the payee is chosen.

Both calls are skipped behind the same rule that grants the exemption
rather than deleted, so a ledger without it keeps rejecting an
unauthorized payee exactly as before. Skipping them also gives up the
missing-line guard that StrongAuth provided: a payout into a holding that
no longer exists now creates it instead of returning tecNO_LINE.
2026-08-19 14:16:35 +01:00
Timur Ialymov
feea4f7651 chore: Drop a redundant comment in the IOU authorization check
The note about why the filter is function-local was noise sitting next to
the declaration it described.
2026-08-18 19:17:12 +01:00
Timur Ialymov
ecca139447 test: Cover the broker fee leg and ledger state on repayment
The exemption covers vault and loan broker pseudo-accounts, but the test
only exercised the vault. LoanPay pays the broker fee into the broker's own
pseudo-account whenever the owner cannot take it, and that reaches the same
authorization check on the other pseudo-account, so drive that path with a
deep-frozen owner.

Two more things the test was silent about: the issuer can repair either line
by hand with TrustSet, because the line already exists, and a repayment that
is refused has to leave every balance where it was. Both are asserted now,
along with the balances and AssetsAvailable moving on the runs that succeed.
2026-08-18 18:58:33 +01:00
Timur Ialymov
bea11fad42 refactor: Hoist the pseudo-account field filter out of the auth check
The filter is constant, but building it inline meant a fresh std::set on
every IOU authorization check that reached the unauthorized-line branch,
including the ones on the trading paths. Build it once instead.

It is a function-local static rather than a namespace-scope one because the
SFields it points at are defined in another translation unit.
2026-08-18 18:58:28 +01:00
Timur Ialymov
a83944428f Merge remote-tracking branch 'origin/develop' into tialymov/FN-85-vault_iou_require_auth 2026-08-18 18:05:46 +01:00
Timur Ialymov
d5cd5d5727 fix: Include the jtx flags header directly in LoanPay_test.cpp
The new repayment test calls fset, which was reaching the file only
transitively through LoanTestBase.h, so misc-include-cleaner rejected it.
2026-08-12 14:56:10 +01:00
Timur Ialymov
b43df72c63 fix: Exempt vault and loan broker accounts from IOU authorization
A vault whose asset is an IOU from an issuer with RequireAuth owns a trust
line that nobody can authorize. VaultCreate opens it without the auth flag,
and the pseudo-account has no signing key to authorize itself. Deposits and
loan origination never look at that line, so the vault works right up to the
first repayment, the one step that has to credit the vault back. LoanPay
checks authorization there and fails with tecNO_AUTH.

Treat a trust line that a vault or loan broker pseudo-account already owns as
authorized, which is the rule MPT applies today. The exemption covers only
the authorization flag, so a missing line still fails, and it leaves AMM
accounts alone so trading paths keep enforcing RequireAuth unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:33:52 +01:00
3 changed files with 214 additions and 6 deletions

View File

@@ -30,6 +30,7 @@
#include <cstdint>
#include <memory>
#include <optional>
#include <set>
namespace xrpl {
@@ -584,9 +585,20 @@ requireAuth(ReadView const& view, Issue const& issue, AccountID const& account,
{
if (trustLine)
{
return trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth)
? tesSUCCESS
: TER{tecNO_AUTH};
if (trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth))
return tesSUCCESS;
// A Vault or LoanBroker holds the asset on behalf of its
// participants, and its pseudo-account has no signing key, so it
// can never authorize its own line and no transaction offers the
// issuer a chance to do it either. Treat a line it already owns as
// authorized, the same way MPT does.
static std::set<SField const*> const kPseudoAccountFilter{&sfVaultID, &sfLoanBrokerID};
if (view.rules().enabled(fixCleanup3_4_0) &&
isPseudoAccount(view, account, kPseudoAccountFilter))
return tesSUCCESS;
return TER{tecNO_AUTH};
}
return TER{tecNO_LINE};
}

View File

@@ -620,7 +620,9 @@ LoanPay::doApply()
? STAmount{asset, 0}
: conservationBalance(view, brokerPayee, asset, j_);
if (totalPaidToVaultRounded != beast::kZero)
bool const skipPayeeAuth = view.rules().enabled(fixCleanup3_4_0);
if (!skipPayeeAuth && totalPaidToVaultRounded != beast::kZero)
{
if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth))
return ter;
@@ -644,8 +646,11 @@ LoanPay::doApply()
return ter;
}
}
if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
return ter;
if (!skipPayeeAuth)
{
if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth))
return ter;
}
}
if (auto const ter = accountSendMulti(

View File

@@ -4,6 +4,7 @@
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/jtx_json.h>
#include <test/jtx/noop.h>
#include <test/jtx/pay.h>
@@ -15,9 +16,11 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
@@ -730,6 +733,193 @@ private:
}
}
// Which pseudo-account is left holding an unauthorized trust line when the
// repayment lands.
enum class UnauthorizedPayee {
// The vault's own line, as VaultCreate leaves it.
Vault,
// Same vault, but the issuer authorized the line by hand first.
VaultAuthorized,
// Vault line authorized, broker owner unable to take the fee, so the
// fee goes to the loan broker's pseudo-account instead.
Broker,
};
// A vault holding an IOU whose issuer requires authorization ends up with
// its own trust line unauthorized: VaultCreate opens the line without the
// auth flag, and the pseudo-account has no key to sign a TrustSet for
// itself. Neither deposits nor loan origination look at that line, so the
// vault appears to work right up to the first repayment, which is the only
// step that has to credit the vault back.
//
// The loan broker's pseudo-account has the same defect for the same reason,
// and LoanPay reaches it whenever the broker owner cannot take the fee.
//
// The issuer can still repair either line by hand, because TrustSet accepts
// a line that already exists even when its owner is a pseudo-account.
void
testRepayIntoUnauthorizedVault()
{
using namespace jtx;
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
auto runTestCases = [&](FeatureBitset features, UnauthorizedPayee payee) {
bool const pseudoExempt = features[fixCleanup3_4_0];
// With the vault's line repaired by the issuer, the only remaining
// unauthorized payee is the broker's pseudo-account.
bool const expectSuccess = pseudoExempt || payee == UnauthorizedPayee::VaultAuthorized;
auto const payeeLabel = [payee]() -> char const* {
switch (payee)
{
case UnauthorizedPayee::Vault:
return "vault";
case UnauthorizedPayee::VaultAuthorized:
return "vault authorized by the issuer";
case UnauthorizedPayee::Broker:
return "loan broker";
}
return ""; // LCOV_EXCL_LINE
}();
testcase << "LoanPay crediting an unauthorized " << payeeLabel << ": pseudo-account "
<< (pseudoExempt ? "exempt" : "not exempt");
Env env{*this, features};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
env(fset(issuer, asfRequireAuth));
env.close();
PrettyAsset const asset = issuer[iouCurrency_];
env(trust(lender, asset(100'000'000)));
env(trust(borrower, asset(100'000'000)));
env.close();
// Authorize the two participants. Nothing asks the issuer to also
// authorize the vault, which is the whole point of this test.
env(trust(issuer, asset(0), lender, tfSetfAuth));
env(trust(issuer, asset(0), borrower, tfSetfAuth));
env.close();
env(pay(issuer, lender, asset(10'000'000)));
env(pay(issuer, borrower, asset(10'000)));
env.close();
// Creating the vault and funding it with deposits succeeds even
// though the vault cannot be authorized to hold the asset.
BrokerInfo const broker{createVaultAndBroker(env, asset, lender)};
auto const vaultSle = env.le(broker.vaultKeylet());
auto const brokerSle = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(vaultSle && brokerSle))
return;
Account const vaultPseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
Account const brokerPseudo{"broker pseudo-account", brokerSle->at(sfAccount)};
auto const lineIsAuthorized = [&](Account const& holder) -> bool {
auto const line = env.le(keylet::trustLine(holder, asset.raw().get<Issue>()));
if (!BEAST_EXPECT(line))
return false;
return line->isFlag(holder.id() > issuer.id() ? lsfLowAuth : lsfHighAuth);
};
BEAST_EXPECT(!lineIsAuthorized(vaultPseudo));
BEAST_EXPECT(!lineIsAuthorized(brokerPseudo));
if (payee != UnauthorizedPayee::Vault)
{
env(trust(issuer, asset(0), vaultPseudo, tfSetfAuth));
env.close();
BEAST_EXPECT(lineIsAuthorized(vaultPseudo));
}
using namespace loan;
// The service fee guarantees the broker is owed something on the
// first payment, so the broker leg of the transfer is exercised.
Number const serviceFee = asset(2).value();
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(1'000).value()),
Sig(sfCounterpartySignature, lender),
kLoanServiceFee(serviceFee),
kInterestRate(percentageToTenthBips(12)),
kPaymentTotal(12),
kPaymentInterval(600),
Fee(env.current()->fees().base * 2));
env.close();
// Paying the principal out of the vault never needed authorization.
BEAST_EXPECT(env.le(loanKeylet));
if (payee == UnauthorizedPayee::Broker)
{
// A deep-frozen owner cannot take the fee, so LoanPay pays it
// into the broker's pseudo-account instead.
env(trust(issuer, asset(0), lender, tfSetFreeze | tfSetDeepFreeze));
env.close();
}
auto const state = getCurrentState(env, broker, loanKeylet);
STAmount const payment{
broker.asset,
roundPeriodicPayment(
broker.asset, state.periodicPayment + serviceFee, state.loanScale)};
// Repayment turns an outstanding loan back into cash the vault can
// lend again, so AssetsAvailable is what moves. AssetsTotal already
// counted the loan.
auto const assetsAvailable = [&]() -> Number {
auto const sle = env.le(broker.vaultKeylet());
if (!BEAST_EXPECT(sle))
return Number{};
return sle->at(sfAssetsAvailable);
};
auto const borrowerBefore = env.balance(borrower, asset).number();
auto const vaultBefore = env.balance(vaultPseudo, asset).number();
auto const brokerBefore = env.balance(brokerPseudo, asset).number();
auto const assetsAvailableBefore = assetsAvailable();
env(pay(borrower, loanKeylet.key, payment),
Ter(expectSuccess ? TER{tesSUCCESS} : TER{tecNO_AUTH}));
env.close();
if (expectSuccess)
{
BEAST_EXPECT(env.balance(borrower, asset).number() < borrowerBefore);
BEAST_EXPECT(env.balance(vaultPseudo, asset).number() > vaultBefore);
BEAST_EXPECT(assetsAvailable() > assetsAvailableBefore);
// Confirms the broker variant really did route the fee to the
// pseudo-account rather than to the owner.
BEAST_EXPECT(
(env.balance(brokerPseudo, asset).number() > brokerBefore) ==
(payee == UnauthorizedPayee::Broker));
}
else
{
// A rejected repayment must leave every balance untouched.
BEAST_EXPECT(env.balance(borrower, asset).number() == borrowerBefore);
BEAST_EXPECT(env.balance(vaultPseudo, asset).number() == vaultBefore);
BEAST_EXPECT(env.balance(brokerPseudo, asset).number() == brokerBefore);
BEAST_EXPECT(assetsAvailable() == assetsAvailableBefore);
}
};
for (auto const& features : {all_, all_ - fixCleanup3_4_0})
{
runTestCases(features, UnauthorizedPayee::Vault);
runTestCases(features, UnauthorizedPayee::VaultAuthorized);
runTestCases(features, UnauthorizedPayee::Broker);
}
}
void
testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features)
{
@@ -838,6 +1028,7 @@ private:
runAmendmentIndependent()
{
testLoanSetNearZeroInterestRateSucceeds();
testRepayIntoUnauthorizedVault();
}
// Tests run under each entry in amendmentCombinations().