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

@@ -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