Merge remote-tracking branch 'origin/tapanito/vault-block-deposit' into ripple/lending-protocol-fv

This commit is contained in:
Vito
2026-09-02 11:05:29 +02:00
13 changed files with 412 additions and 17 deletions

View File

@@ -171,6 +171,18 @@ sharesToAssetsWithdraw(
[[nodiscard]] bool
isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance);
/**
* Determine if a vault is insolvent. A vault is considered insolvent when
* the total assets in the vault are zero, and outstanding shares are non-zero.
*
* @param vault The vault SLE.
* @param shareIssuance The MPTokenIssuance SLE for the vault's shares.
*
* @return True if the vault is insolvent, false otherwise.
*/
[[nodiscard]] bool
isVaultInsolvent(SLE::const_ref vault, SLE::const_ref shareIssuance);
/**
* Resolves a Vault's LEVersion, the single point every accounting touch
* point should call to determine which recognition model (accrual vs.

View File

@@ -199,7 +199,9 @@ enum LedgerEntryType : std::uint16_t {
LSF_FLAG(lsfAccepted, 0x00010000)) \
\
LEDGER_OBJECT(Vault, \
LSF_FLAG(lsfVaultPrivate, 0x00010000)) \
LSF_FLAG(lsfVaultPrivate, 0x00010000) \
LSF_FLAG(lsfVaultDepositBlocked, 0x00020000) /* True, vault deposit is blocked */ \
LSF_FLAG(lsfVaultOwnerCanBlockDeposit, 0x00040000)) /* True, vault owner can block deposit */ \
\
LEDGER_OBJECT(Loan, \
LSF_FLAG(lsfLoanDefault, 0x00010000) \

View File

@@ -195,7 +195,13 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal;
\
TRANSACTION(VaultCreate, \
TF_FLAG(tfVaultPrivate, lsfVaultPrivate) \
TF_FLAG(tfVaultShareNonTransferable, 0x00020000), \
TF_FLAG(tfVaultShareNonTransferable, 0x00020000) \
TF_FLAG(tfVaultOwnerCanBlockDeposit, lsfVaultOwnerCanBlockDeposit), \
MASK_ADJ(0)) \
\
TRANSACTION(VaultSet, \
TF_FLAG(tfVaultDepositBlock, 0x00010000) \
TF_FLAG(tfVaultDepositUnblock, 0x00020000), \
MASK_ADJ(0)) \
\
TRANSACTION(VaultDeposit, \
@@ -243,7 +249,6 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal;
TF_FLAG(tfSponsorshipCreate, 0x00020000) \
TF_FLAG(tfSponsorshipReassign, 0x00040000), \
MASK_ADJ(0))
// clang-format on
// Create all the flag values.

View File

@@ -20,6 +20,9 @@ public:
{
}
static std::uint32_t
getFlagsMask(PreflightContext const& ctx);
static bool
checkExtraFeatures(PreflightContext const& ctx);

View File

@@ -2,7 +2,9 @@
PRs merged into the `ripple/lending-protocol-fv` branch.
| PR | Title | Author | Branch | Merged |
| --------------------------------------------------- | ---------------------------------- | ---------- | --------------------------------------------------------- | ---------- |
| [#6383](https://github.com/XRPLF/rippled/pull/6383) | feat: Add tfVaultDonate feature | @Tapanito | `tapanito/vault-donation` | 2026-09-02 |
| [#7820](https://github.com/XRPLF/rippled/pull/7820) | feat: Split LoanSet and LoanAccept | @a1q123456 | `a1q123456/split-loan-set-and-loan-accept-implementation` | 2026-09-02 |
| PR | Title | Author | Branch | Merged |
| --------------------------------------------------- | ----------------------------------------------- | ---------- | --------------------------------------------------------- | ---------- |
| [#6383](https://github.com/XRPLF/rippled/pull/6383) | feat: Add tfVaultDonate feature | @Tapanito | `tapanito/vault-donation` | 2026-09-02 |
| [#7820](https://github.com/XRPLF/rippled/pull/7820) | feat: Split LoanSet and LoanAccept | @a1q123456 | `a1q123456/split-loan-set-and-loan-accept-implementation` | 2026-09-02 |
| [#6528](https://github.com/XRPLF/rippled/pull/6528) | feat: Make VaultID conditional on LoanBrokerSet | @Tapanito | `tapanito/loan-broker-set` | 2026-09-02 |
| [#6361](https://github.com/XRPLF/rippled/pull/6361) | Adds functionality to block vault deposits | @Tapanito | `tapanito/vault-block-deposit` | 2026-09-02 |

View File

@@ -225,6 +225,20 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref
return sleToken->getFieldU64(sfMPTAmount) == outstanding;
}
[[nodiscard]] bool
isVaultInsolvent(SLE::const_ref vault, SLE::const_ref shareIssuance)
{
XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::isVaultInsolvent : Vault sle");
XRPL_ASSERT(
shareIssuance && shareIssuance->getType() == ltMPTOKEN_ISSUANCE,
"xrpl::isVaultInsolvent : MPTokenIssuance sle");
auto const assetsTotal = vault->at(sfAssetsTotal);
auto const sharesOutstanding = shareIssuance->at(sfOutstandingAmount);
return assetsTotal == 0 && sharesOutstanding > 0;
}
[[nodiscard]] VaultVersion
getVaultVersion(SLE::const_ref vault)
{

View File

@@ -55,7 +55,10 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx)
std::uint32_t
VaultCreate::getFlagsMask(PreflightContext const& ctx)
{
return tfVaultCreateMask;
if (ctx.rules.enabled(featureLendingProtocolV1_1))
return tfVaultCreateMask;
return tfVaultCreateMask | tfVaultOwnerCanBlockDeposit;
}
NotTEC
@@ -212,6 +215,7 @@ VaultCreate::doApply()
std::uint32_t mptFlags = 0;
if (!tx.isFlag(tfVaultShareNonTransferable))
mptFlags |= (lsfMPTCanEscrow | lsfMPTCanTrade | lsfMPTCanTransfer);
if (tx.isFlag(tfVaultPrivate))
mptFlags |= lsfMPTRequireAuth;
@@ -249,7 +253,13 @@ VaultCreate::doApply()
auto const& mptIssuanceID = *maybeShare;
vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset});
vault->at(sfFlags) = tx.getFlags() & tfVaultPrivate;
if (tx.isFlag(tfVaultPrivate))
vault->setFlag(lsfVaultPrivate);
if (view().rules().enabled(featureLendingProtocolV1_1) &&
tx.isFlag(tfVaultOwnerCanBlockDeposit))
vault->setFlag(lsfVaultOwnerCanBlockDeposit);
vault->at(sfSequence) = sequence.value();
vault->at(sfOwner) = accountID_;
vault->at(sfAccount) = pseudoId;
@@ -294,7 +304,7 @@ VaultCreate::doApply()
return err;
// If the vault is private, set the authorized flag for the vault owner
if (tx.isFlag(tfVaultPrivate))
if (vault->isFlag(lsfVaultPrivate))
{
if (auto const err = authorizeMPToken(
applyViewContext,

View File

@@ -152,8 +152,8 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
// LCOV_EXCL_STOP
}
auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
if (!sleIssuance)
auto const sleShareIssuance = ctx.view.read(keylet::mptokenIssuance(mptIssuanceID));
if (!sleShareIssuance)
{
// LCOV_EXCL_START
JLOG(ctx.j.error()) << "VaultDeposit: missing issuance of vault shares.";
@@ -170,14 +170,14 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
}
// Cannot donate to a vault with no shares
if (sleIssuance->at(sfOutstandingAmount) == 0)
if (sleShareIssuance->at(sfOutstandingAmount) == 0)
{
JLOG(ctx.j.debug()) << "VaultDeposit: empty vault cannot receive donations.";
return tecNO_PERMISSION;
}
}
if (sleIssuance->isFlag(lsfMPTLocked))
if (sleShareIssuance->isFlag(lsfMPTLocked))
{
// LCOV_EXCL_START
JLOG(ctx.j.error()) << "VaultDeposit: issuance of vault shares is locked.";
@@ -185,6 +185,24 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
// LCOV_EXCL_STOP
}
if (ctx.view.rules().enabled(featureLendingProtocolV1_1))
{
// Perform these checks early to avoid unnecessary processing
// The Vault is insolvent, deposits are not allowed
if (isVaultInsolvent(vault, sleShareIssuance))
{
JLOG(ctx.j.debug()) << "VaultDeposit: Vault is insolvent, deposits are not allowed";
return tecLOCKED;
}
if (vault->isFlag(lsfVaultDepositBlocked))
{
JLOG(ctx.j.debug()) << "VaultDeposit: Vault deposits are blocked";
return tecNO_PERMISSION;
}
}
if (fix330Enabled)
{
if (auto const ret = checkDepositFreeze(ctx.view, account, vaultAccount, vaultAsset))
@@ -205,7 +223,8 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
// credential is tolerated here because doApply deletes it.
if (vault->isFlag(lsfVaultPrivate) && account != vault->at(sfOwner))
{
if (auto const err = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::Yes);
if (auto const err =
checkVaultDomain(ctx.view, sleShareIssuance, account, SuppressExpired::Yes);
!isTesSuccess(err))
return err;
}

View File

@@ -12,9 +12,12 @@
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
#include <cstdint>
namespace xrpl {
bool
@@ -23,6 +26,29 @@ VaultSet::checkExtraFeatures(PreflightContext const& ctx)
return !ctx.tx.isFieldPresent(sfDomainID) || ctx.rules.enabled(featurePermissionedDomains);
}
std::uint32_t
VaultSet::getFlagsMask(PreflightContext const& ctx)
{
if (ctx.rules.enabled(featureLendingProtocolV1_1))
return tfVaultSetMask;
// Add tfVaultDepositBlock and tfVaultDepositUnblock flags to indicate they are disabled
return tfVaultSetMask | tfVaultDepositBlock | tfVaultDepositUnblock;
}
static bool
isValidVaultUpdate(PreflightContext const& ctx)
{
auto const atLeastOneFieldPresent = ctx.tx.isFieldPresent(sfDomainID) ||
ctx.tx.isFieldPresent(sfAssetsMaximum) || ctx.tx.isFieldPresent(sfData);
// Mask of valid, non-universal flags: any bit set here means the
// transaction is requesting a meaningful flag change.
auto const expectedFlags = ~(VaultSet::getFlagsMask(ctx) | tfUniversal);
return atLeastOneFieldPresent || ((ctx.tx.getFlags() & expectedFlags) != 0u);
}
NotTEC
VaultSet::preflight(PreflightContext const& ctx)
{
@@ -50,13 +76,19 @@ VaultSet::preflight(PreflightContext const& ctx)
}
}
if (!ctx.tx.isFieldPresent(sfDomainID) && !ctx.tx.isFieldPresent(sfAssetsMaximum) &&
!ctx.tx.isFieldPresent(sfData))
if (!isValidVaultUpdate(ctx))
{
JLOG(ctx.j.debug()) << "VaultSet: nothing is being updated.";
return temMALFORMED;
}
if (ctx.tx.isFlag(tfVaultDepositBlock) && ctx.tx.isFlag(tfVaultDepositUnblock))
{
JLOG(ctx.j.debug())
<< "VaultSet: cannot set tfVaultDepositBlock and tfVaultDepositUnblock simultaneously.";
return temINVALID_FLAG;
}
return tesSUCCESS;
}
@@ -110,6 +142,29 @@ VaultSet::preclaim(PreclaimContext const& ctx)
}
}
if (ctx.view.rules().enabled(featureLendingProtocolV1_1))
{
// The Vault is not configured to support deposit blocking
if (!vault->isFlag(lsfVaultOwnerCanBlockDeposit) &&
(ctx.tx.isFlag(tfVaultDepositBlock) || ctx.tx.isFlag(tfVaultDepositUnblock)))
{
JLOG(ctx.j.debug()) << "VaultSet: vault does not support blocking deposits";
return tecNO_PERMISSION;
}
if (vault->isFlag(lsfVaultDepositBlocked) && ctx.tx.isFlag(tfVaultDepositBlock))
{
JLOG(ctx.j.debug()) << "VaultSet: vault deposit is already blocked";
return tecNO_PERMISSION;
}
if (!vault->isFlag(lsfVaultDepositBlocked) && ctx.tx.isFlag(tfVaultDepositUnblock))
{
JLOG(ctx.j.debug()) << "VaultSet: vault deposit is already unblocked";
return tecNO_PERMISSION;
}
}
return tesSUCCESS;
}
@@ -167,6 +222,15 @@ VaultSet::doApply()
view().update(sleIssuance);
}
if (view().rules().enabled(featureLendingProtocolV1_1))
{
if (tx.isFlag(tfVaultDepositBlock))
vault->setFlag(lsfVaultDepositBlocked);
if (tx.isFlag(tfVaultDepositUnblock))
vault->clearFlag(lsfVaultDepositBlocked);
}
// Note, we must update Vault object even if only DomainID is being updated
// in Issuance object. Otherwise it's really difficult for Vault invariants
// to verify the operation.

View File

@@ -0,0 +1,253 @@
#include <test/app/vault/VaultTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <string>
namespace xrpl {
class VaultBlockDeposit_test : public VaultTestBase
{
void
testVaultDepositBlockGeneral()
{
using namespace test::jtx;
Env env{*this};
Account const owner{"owner"};
Account const other{"other"};
env.fund(XRP(100'000'000), owner, other);
Vault vault{env};
PrettyAsset const asset = xrpIssue();
std::string const prefix = "VaultDepositBlock: ";
auto const blockVault = [&](TER expectedTer, Keylet const& keylet) {
env(vault.set({.owner = owner, .id = keylet.key, .flags = tfVaultDepositBlock}),
Ter(expectedTer));
};
auto const unblockVault = [&](TER expectedTer, Keylet const& keylet) {
env(vault.set({.owner = owner, .id = keylet.key, .flags = tfVaultDepositUnblock}),
Ter(expectedTer));
};
// Blocking Vault with the amendment disabled fails
{
testcase(prefix + "block/unblock fails when amendment is disabled");
env.disableFeature(featureLendingProtocolV1_1);
auto const [tx, keylet] = vault.create(
{.owner = owner, .asset = asset, .flags = tfVaultOwnerCanBlockDeposit});
env(tx, Ter(temINVALID_FLAG));
env.close();
blockVault(temINVALID_FLAG, keylet);
unblockVault(temINVALID_FLAG, keylet);
env.enableFeature(featureLendingProtocolV1_1);
}
// Block Vault deposits fails if the vault is not configured to allow blocking deposits
{
testcase(prefix + "block/unblock fails when vault is not configured");
auto const [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
blockVault(tecNO_PERMISSION, keylet);
unblockVault(tecNO_PERMISSION, keylet);
env(vault.del({.owner = owner, .id = keylet.key}), Ter(tesSUCCESS));
env.close();
}
auto const [tx, keylet] =
vault.create({.owner = owner, .asset = asset, .flags = tfVaultOwnerCanBlockDeposit});
env(tx);
env.close();
{
testcase(prefix + "block/unblock succeeds");
// deposit assets to show that blocking deposit does not block withdrawals
env(vault.deposit({
.depositor = owner,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
env(vault.deposit({
.depositor = other,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
blockVault(tesSUCCESS, keylet);
// Owner is blocked from depositing to the vault
env(vault.deposit({
.depositor = owner,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tecNO_PERMISSION));
// Other accounts are also blocked from depositing to the vault
env(vault.deposit({
.depositor = other,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tecNO_PERMISSION));
// Block vault withdrawal works as normal
env(vault.withdraw({
.depositor = owner,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
env(vault.withdraw({
.depositor = other,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
unblockVault(tesSUCCESS, keylet);
env(vault.deposit({
.depositor = owner,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
env(vault.deposit({
.depositor = other,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
// Withdraw to keep the vault empty
env(vault.withdraw({
.depositor = owner,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
env(vault.withdraw({
.depositor = other,
.id = keylet.key,
.amount = XRP(10'000),
}),
Ter(tesSUCCESS));
}
{
testcase(prefix + "block/unblock fails when caller is not owner");
env(vault.set({.owner = other, .id = keylet.key, .flags = tfVaultDepositBlock}),
Ter(tecNO_PERMISSION));
blockVault(tesSUCCESS, keylet);
env(vault.set({.owner = other, .id = keylet.key, .flags = tfVaultDepositUnblock}),
Ter(tecNO_PERMISSION));
unblockVault(tesSUCCESS, keylet);
}
{
testcase(prefix + "unblock fails when vault is already unblocked");
unblockVault(tecNO_PERMISSION, keylet);
}
{
testcase(prefix + "block fails when vault is already blocked");
blockVault(tesSUCCESS, keylet);
blockVault(tecNO_PERMISSION, keylet);
unblockVault(tesSUCCESS, keylet);
}
env(vault.del({.owner = owner, .id = keylet.key}));
}
void
testPrivateVaultBlockDoesNotClearPrivate()
{
using namespace test::jtx;
Env env{*this};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(1000), issuer, owner);
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1000), owner);
env(pay(issuer, owner, asset(500)));
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create(
{.owner = owner,
.asset = asset,
.flags = tfVaultPrivate | tfVaultOwnerCanBlockDeposit});
env(tx);
env.close();
{
testcase("blocking a private vault does not change lsfVaultPrivate flag");
auto setTx =
vault.set({.owner = owner, .id = keylet.key, .flags = tfVaultDepositBlock});
env(setTx, Ter(tesSUCCESS));
auto const sleVault = env.le(keylet);
if (!BEAST_EXPECT(sleVault))
return;
BEAST_EXPECT(sleVault->isFlag(lsfVaultDepositBlocked));
BEAST_EXPECT(sleVault->isFlag(lsfVaultPrivate));
}
{
testcase("unblocking a private vault does not change lsfVaultPrivate flag");
auto setTx =
vault.set({.owner = owner, .id = keylet.key, .flags = tfVaultDepositUnblock});
env(setTx, Ter(tesSUCCESS));
auto const sleVault = env.le(keylet);
if (!BEAST_EXPECT(sleVault))
return;
BEAST_EXPECT(!sleVault->isFlag(lsfVaultDepositBlocked));
BEAST_EXPECT(sleVault->isFlag(lsfVaultPrivate));
}
}
public:
void
run() override
{
testVaultDepositBlockGeneral();
testPrivateVaultBlockDoesNotClearPrivate();
}
};
BEAST_DEFINE_TESTSUITE(VaultBlockDeposit, app, xrpl);
} // namespace xrpl

View File

@@ -169,6 +169,14 @@ private:
tx[sfFlags] = tfClearDeepFreeze;
env(tx, Ter{temINVALID_FLAG});
{
env.disableFeature(featureLendingProtocolV1_1);
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
tx[sfFlags] = tfVaultOwnerCanBlockDeposit;
env(tx, Ter(temINVALID_FLAG));
env.enableFeature(featureLendingProtocolV1_1);
}
{
auto tx = vault.set({.owner = owner, .id = keylet.key});
tx[sfFlags] = tfClearDeepFreeze;

View File

@@ -72,6 +72,8 @@ Vault::set(SetArgs const& args)
jv[jss::TransactionType] = jss::VaultSet;
jv[jss::Account] = args.owner.human();
jv[sfVaultID] = to_string(args.id);
if (args.flags)
jv[jss::Flags] = *args.flags;
return jv;
}

View File

@@ -80,6 +80,7 @@ struct Vault
{
Account owner;
uint256 id;
std::optional<std::uint32_t> flags = std::nullopt;
};
static json::Value