Skip to content
2 changes: 1 addition & 1 deletion include/xrpl/protocol/detail/transactions.macro
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,7 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet,
Delegation::NotDelegable,
featureLendingProtocol,
CreatePseudoAcct | MayAuthorizeMpt, ({
{sfVaultID, SoeRequired},
{sfVaultID, SoeOptional},
{sfLoanBrokerID, SoeOptional},
{sfData, SoeOptional},
{sfManagementFeeRate, SoeOptional},
Expand Down
29 changes: 21 additions & 8 deletions include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,29 @@ class LoanBrokerSet : public TransactionBase
// Transaction-specific field getters

/**
* @brief Get sfVaultID (SoeRequired)
* @return The field value.
* @brief Get sfVaultID (SoeOptional)
* @return The field value, or std::nullopt if not present.
*/
[[nodiscard]]
SF_UINT256::type::value_type
protocol_autogen::Optional<SF_UINT256::type::value_type>
getVaultID() const
{
return this->tx_->at(sfVaultID);
if (hasVaultID())
{
return this->tx_->at(sfVaultID);
}
return std::nullopt;
}

/**
* @brief Check if sfVaultID is present.
* @return True if the field is present, false otherwise.
*/
[[nodiscard]]
bool
hasVaultID() const
{
return this->tx_->isFieldPresent(sfVaultID);
}

/**
Expand Down Expand Up @@ -228,17 +243,15 @@ class LoanBrokerSetBuilder : public TransactionBuilderBase<LoanBrokerSetBuilder>
/**
* @brief Construct a new LoanBrokerSetBuilder with required fields.
* @param account The account initiating the transaction.
* @param vaultID The sfVaultID field value.
* @param sequence Optional sequence number for the transaction.
* @param fee Optional fee for the transaction.
*/
LoanBrokerSetBuilder(SF_ACCOUNT::type::value_type account,
std::decay_t<typename SF_UINT256::type::value_type> const& vaultID, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<LoanBrokerSetBuilder>(ttLOAN_BROKER_SET, account, sequence, fee)
{
setVaultID(vaultID);
}

/**
Expand All @@ -258,7 +271,7 @@ class LoanBrokerSetBuilder : public TransactionBuilderBase<LoanBrokerSetBuilder>
/** @brief Transaction-specific field setters */

/**
* @brief Set sfVaultID (SoeRequired)
* @brief Set sfVaultID (SoeOptional)
* @return Reference to this builder for method chaining.
*/
LoanBrokerSetBuilder&
Expand Down
199 changes: 152 additions & 47 deletions src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
#include <xrpl/tx/transactors/lending/LoanBrokerSet.h>

#include <xrpl/basics/Expected.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
Expand Down Expand Up @@ -49,7 +54,9 @@
if (!validNumericRange(tx[~sfDebtMaximum], Number(kMaxMpTokenAmount), Number(0)))
return temINVALID;

if (tx.isFieldPresent(sfLoanBrokerID))
auto const isLoanBrokerUpdate = tx.isFieldPresent(sfLoanBrokerID);

if (isLoanBrokerUpdate)
{
// Fixed fields can not be specified if we're modifying an existing
// LoanBroker Object
Expand All @@ -61,9 +68,27 @@
return temINVALID;

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.

No explicit zero-value guard for sfLoanBrokerID in the update path — confirm this is covered elsewhere.

The isLoanBrokerUpdate flag is set when sfLoanBrokerID is present, but a zero uint256 value would pass this check and route into preclaimUpdate, calling ctx.view.read(keylet::loanbroker(uint256{})) on an unintended ledger key. Line 59 guards sfVaultID against beast::zero explicitly — sfLoanBrokerID deserves the same treatment. If STTx deserialization already rejects a zero field value, add a comment documenting that invariant; otherwise add an explicit guard:

Consider adding above this line:

        if (tx[sfLoanBrokerID] == beast::zero)
            return temINVALID;

}

if (auto const vaultID = tx.at(~sfVaultID))
// Amendment-specific field presence rules
if (ctx.rules.enabled(featureLendingProtocolV1_1))
{
if (isLoanBrokerUpdate)
{
if (tx.isFieldPresent(sfVaultID))
return temINVALID;
}
else
{
if (!tx.isFieldPresent(sfVaultID) || tx[sfVaultID] == beast::kZero)
return temINVALID;
}
}
else
{
if (*vaultID == beast::kZero)
// Pre-amendment: VaultID was soeREQUIRED, must always be present
if (!tx.isFieldPresent(sfVaultID))
return temINVALID;

if (tx[sfVaultID] == beast::kZero)
return temINVALID;
}

Expand All @@ -88,77 +113,157 @@
return kValueFields;
}

TER
LoanBrokerSet::preclaim(PreclaimContext const& ctx)
/** Read and validate a vault, checking existence and ownership.
*
* @param ctx The preclaim context.
* @param account The expected vault owner.
* @param id The vault ID to look up.
* @return The vault SLE on success, or a TER error.
*/
[[nodiscard]] static Expected<std::shared_ptr<SLE const>, TER>
readVault(PreclaimContext const& ctx, AccountID const& account, uint256 const& id)
{
auto const& tx = ctx.tx;

auto const account = tx[sfAccount];
auto const vaultID = tx[sfVaultID];

auto const sleVault = ctx.view.read(keylet::vault(vaultID));
if (!sleVault)
auto const sle = ctx.view.read(keylet::vault(id));
if (!sle)
{
JLOG(ctx.j.warn()) << "Vault does not exist.";
return tecNO_ENTRY;
return Unexpected(tecNO_ENTRY);
}
Asset const asset = sleVault->at(sfAsset);

if (account != sleVault->at(sfOwner))
if (account != sle->at(sfOwner))
{
JLOG(ctx.j.warn()) << "Account is not the owner of the Vault.";
return tecNO_PERMISSION;
return Unexpected(tecNO_PERMISSION);
}
return sle;
}

if (auto const brokerID = tx[~sfLoanBrokerID])
{
// Updating an existing Broker
/** Preclaim validation for updating an existing LoanBroker.
*
* @param ctx The preclaim context.
* @param account The transaction submitter.
* @param brokerID The LoanBroker ID to update.
* @return The vault SLE on success, or a TER error.
*/
[[nodiscard]] static Expected<std::shared_ptr<SLE const>, TER>
preclaimUpdate(PreclaimContext const& ctx, AccountID const& account, uint256 const& brokerID)
Comment thread
Tapanito marked this conversation as resolved.
{

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.

Inconsistent ownership validation order in post-amendment update path

In preclaimUpdate with fixEnabled = true, the code first validates vault ownership via readVault(ctx, account, sleBroker->at(sfVaultID)) and then separately checks account != sleBroker->at(sfOwner). If vault ownership can be transferred (a vault-transfer feature exists or is added later), a new vault owner could be blocked from modifying a broker they indirectly own via the vault — or conversely, an attacker who gains vault ownership could pass the first check but fail the second. More critically, the two checks test different objects: the first checks the vault's owner field, the second checks the broker's owner field. These could theoretically diverge.

Suggested fix: Add an explicit assertion or comment documenting that the vault owner and broker owner are expected to be identical (invariant established at creation time). Consider also adding a check that sleVault->at(sfOwner) == sleBroker->at(sfOwner) to defensively detect any ledger state corruption where these diverge, returning tefINTERNAL if they differ.

auto const& tx = ctx.tx;
bool const fixEnabled = ctx.view.rules().enabled(featureLendingProtocolV1_1);

auto const sleBroker = ctx.view.read(keylet::loanbroker(*brokerID));
std::shared_ptr<SLE const> sleBroker;
std::shared_ptr<SLE const> sleVault;

if (fixEnabled)
{
// Post-amendment: VaultID is not in the tx, read it from broker
sleBroker = ctx.view.read(keylet::loanbroker(brokerID));
if (!sleBroker)
{
JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
return tecNO_ENTRY;
return Unexpected(tecNO_ENTRY);
}
if (vaultID != sleBroker->at(sfVaultID))
{
JLOG(ctx.j.warn()) << "Can not change VaultID on an existing LoanBroker.";
return tecNO_PERMISSION;
}
if (account != sleBroker->at(sfOwner))

auto const vault = readVault(ctx, account, sleBroker->at(sfVaultID));
if (!vault)
return vault;
sleVault = *vault;
}
else
{
XRPL_ASSERT(
tx.isFieldPresent(sfVaultID),
"xrpl::LoanBrokerSet::preclaimUpdate : VaultID is present in the transaction");
// Pre-amendment: vault is validated before broker to preserve
// the original error ordering for historical transaction replay.
auto const vault = readVault(ctx, account, tx[sfVaultID]);
Comment thread
Tapanito marked this conversation as resolved.
if (!vault)
return vault;
sleVault = *vault;

sleBroker = ctx.view.read(keylet::loanbroker(brokerID));
if (!sleBroker)
{
JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker.";
return tecNO_PERMISSION;
JLOG(ctx.j.warn()) << "LoanBroker does not exist.";
return Unexpected(tecNO_ENTRY);
}

if (auto const debtMax = tx[~sfDebtMaximum])
if (tx[sfVaultID] != sleBroker->at(sfVaultID))
Comment thread
Tapanito marked this conversation as resolved.
{
// Can't reduce the debt maximum below the current total debt
auto const currentDebtTotal = sleBroker->at(sfDebtTotal);
if (*debtMax != 0 && *debtMax < currentDebtTotal)
{
JLOG(ctx.j.warn()) << "Cannot reduce DebtMaximum below current DebtTotal.";
return tecLIMIT_EXCEEDED;
}
JLOG(ctx.j.warn()) << "Can not change VaultID on an existing LoanBroker.";
return Unexpected(tecNO_PERMISSION);
}
}
else

XRPL_ASSERT(sleVault, "xrpl::LoanBrokerSet::preclaimUpdate : sleVault is initialized");

if (account != sleBroker->at(sfOwner))

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.

Vault owner and broker owner are checked independently — add a defensive cross-validation.

In the fixEnabled path, readVault validates that account == vault.sfOwner, and then line 176 separately checks account != sleBroker->at(sfOwner). These two owner fields could theoretically diverge due to ledger state corruption (e.g. if vault ownership is ever transferable). Consider adding an assertion or an explicit cross-check to catch any such divergence early:

// Defensive invariant: vault owner and broker owner must agree
if (sleVault->at(sfOwner) != sleBroker->at(sfOwner))
{
    JLOG(ctx.j.warn()) << "Vault and LoanBroker owner mismatch (ledger corruption?)";
    return Unexpected(tefINTERNAL);
}

Insert this block before the existing account != sleBroker->at(sfOwner) check at line 176.

{
if (auto const ter = canAddHolding(ctx.view, asset))
return ter;
JLOG(ctx.j.warn()) << "Account is not the owner of the LoanBroker.";
return Unexpected(tecNO_PERMISSION);
}

if (auto const ter = checkFrozen(ctx.view, sleVault->at(sfAccount), sleVault->at(sfAsset)))
if (auto const debtMax = tx[~sfDebtMaximum])
{
auto const currentDebtTotal = sleBroker->at(sfDebtTotal);
if (*debtMax != 0 && *debtMax < currentDebtTotal)
{
JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen.";
return ter;
JLOG(ctx.j.warn()) << "Cannot reduce DebtMaximum below current DebtTotal.";
return Unexpected(tecLIMIT_EXCEEDED);
}
}

return sleVault;
}

/** Preclaim validation for creating a new LoanBroker.
*
* @param ctx The preclaim context.
* @param account The transaction submitter (vault owner).
* @return The vault SLE on success, or a TER error.
*/
[[nodiscard]] static Expected<std::shared_ptr<SLE const>, TER>
preclaimCreate(PreclaimContext const& ctx, AccountID const& account)
{
XRPL_ASSERT(
ctx.tx.isFieldPresent(sfVaultID),
"xrpl::LoanBrokerSet::preclaimCreate : VaultID is present in the transaction");
auto const vault = readVault(ctx, account, ctx.tx[sfVaultID]);
Comment thread
Tapanito marked this conversation as resolved.
if (!vault)
return vault;
auto const& sleVault = *vault;

Asset const asset = sleVault->at(sfAsset);
if (auto const ter = canAddHolding(ctx.view, asset))
return Unexpected(ter);

if (auto const ter = checkFrozen(ctx.view, sleVault->at(sfAccount), sleVault->at(sfAsset)))
{
JLOG(ctx.j.warn()) << "Vault pseudo-account is frozen.";
return Unexpected(ter);
}

return sleVault;
}

TER
LoanBrokerSet::preclaim(PreclaimContext const& ctx)
{
auto const account = ctx.tx[sfAccount];

auto const maybeVault = [&]() -> Expected<std::shared_ptr<SLE const>, TER> {

Check warning on line 252 in src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp

View check run for this annotation

Codecov / codecov/patch

src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp#L252

Added line #L252 was not covered by tests
if (auto const brokerID = ctx.tx[~sfLoanBrokerID])
return preclaimUpdate(ctx, account, *brokerID);
return preclaimCreate(ctx, account);
}();

if (!maybeVault)
return maybeVault.error();

// Check that relevant values can be represented as the vault asset
// type. This is mostly only relevant for integral (non-IOU) types
// type. This is mostly only relevant for integral (non-IOU) types.
Asset const asset = (*maybeVault)->at(sfAsset);
for (auto const& field : getValueFields())
{
if (auto const value = tx[field]; value && STAmount{asset, *value} != *value)
if (auto const value = ctx.tx[field]; value && STAmount{asset, *value} != *value)
{
JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value
<< ") can not be represented as a(n) " << to_string(asset) << ".";
Expand Down
Loading
Loading