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>
This commit is contained in:
Timur Ialymov
2026-08-12 14:33:52 +01:00
parent 26cc683ec1
commit b43df72c63
2 changed files with 101 additions and 3 deletions

View File

@@ -30,6 +30,7 @@
#include <cstdint>
#include <memory>
#include <optional>
#include <set>
namespace xrpl {
@@ -584,9 +585,19 @@ 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.
if (view.rules().enabled(fixCleanup3_4_0) &&
isPseudoAccount(view, account, {&sfVaultID, &sfLoanBrokerID}))
return tesSUCCESS;
return TER{tecNO_AUTH};
}
return TER{tecNO_LINE};
}

View File

@@ -13,9 +13,12 @@
#include <xrpl/beast/unit_test/suite.h>
#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>
@@ -728,10 +731,94 @@ private:
}
}
// 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.
void
testRepayIntoUnauthorizedVault()
{
using namespace jtx;
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
auto runTestCases = [&](FeatureBitset features) {
bool const pseudoExempt = features[fixCleanup3_4_0];
testcase << "LoanPay into a vault whose own trust line is unauthorized: 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());
if (!BEAST_EXPECT(vaultSle))
return;
AccountID const vaultPseudo = vaultSle->at(sfAccount);
auto const vaultLine = env.le(keylet::trustLine(vaultPseudo, asset.raw().get<Issue>()));
if (!BEAST_EXPECT(vaultLine))
return;
BEAST_EXPECT(!vaultLine->isFlag(vaultPseudo > issuer.id() ? lsfLowAuth : lsfHighAuth));
using namespace loan;
auto const loanKeylet = nextLoanKeylet(env, broker);
env(set(borrower, broker.brokerID, asset(1'000).value()),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 2));
env.close();
// Paying the principal out of the vault never needed authorization.
BEAST_EXPECT(env.le(loanKeylet));
auto const state = getCurrentState(env, broker, loanKeylet);
STAmount const payment{
broker.asset,
roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)};
env(pay(borrower, loanKeylet.key, payment),
Ter(pseudoExempt ? TER{tesSUCCESS} : TER{tecNO_AUTH}));
env.close();
};
runTestCases(all_);
runTestCases(all_ - fixCleanup3_4_0);
}
void
runAmendmentIndependent()
{
testLoanSetNearZeroInterestRateSucceeds();
testRepayIntoUnauthorizedVault();
}
// Tests run under each entry in amendmentCombinations().