Compare commits

...

23 Commits

Author SHA1 Message Date
Vito
07a6f77ed2 adds missing inclde 2026-03-04 12:08:27 +01:00
Vito
e4a716f260 Merge branch 'tapanito/lending-fix-amendment' into tapanito/vault-donation 2026-03-04 11:52:57 +01:00
Vito Tumas
ed4330a7d6 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-04 11:26:33 +01:00
Vito Tumas
feba605998 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-03 15:38:14 +01:00
Vito
b322097529 fixes formatting errors 2026-03-03 13:51:15 +01:00
Vito Tumas
e159d27373 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-03-03 13:48:37 +01:00
Vito Tumas
ba53026006 adds sfMemoData field to VaultDelete transaction (#6356)
* adds sfMemoData field to VaultDelete transaction
2026-02-26 14:13:29 +01:00
Vito Tumas
34773080df Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-25 13:44:20 +01:00
Vito Tumas
3029d10102 Merge branch 'tapanito/lending-fix-amendment' into tapanito/vault-donation 2026-02-24 14:41:07 +01:00
Vito Tumas
3c3bd75991 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-24 14:40:31 +01:00
Vito
c89dd9f0a3 Merge branch 'tapanito/lending-fix-amendment' into tapanito/vault-donation 2026-02-24 14:36:51 +01:00
Vito Tumas
7459fe454d Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-23 12:17:17 +01:00
Vito
3a0cd45f51 Merge branch 'tapanito/lending-fix-amendment' into tapanito/vault-donation 2026-02-19 12:47:57 +01:00
Vito
106bf48725 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-18 18:29:08 +01:00
Vito
79be4717f5 adds vault donation feature 2026-02-18 17:59:08 +01:00
Vito Tumas
74c968d4e3 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-17 13:51:08 +01:00
Vito
167147281c Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-12 15:22:30 +01:00
Vito Tumas
ba60306610 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-11 17:46:20 +01:00
Vito Tumas
6674500896 Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-10 11:48:23 +01:00
Vito
c5d7ebe93d restores missing linebreak 2026-02-05 10:24:14 +01:00
Ed Hennis
d0b5ca9dab Merge branch 'develop' into tapanito/lending-fix-amendment 2026-02-04 18:21:55 -04:00
Vito
5e51893e9b fixes a typo 2026-02-04 11:31:58 +01:00
Vito
3422c11d02 adds lending v1.1 fix amendment 2026-02-04 11:30:41 +01:00
12 changed files with 413 additions and 67 deletions

View File

@@ -291,6 +291,8 @@ constexpr std::uint32_t const tfLoanImpair = 0x00020000;
constexpr std::uint32_t const tfLoanUnimpair = 0x00040000;
constexpr std::uint32_t const tfLoanManageMask = ~(tfUniversal | tfLoanDefault | tfLoanImpair | tfLoanUnimpair);
constexpr std::uint32_t const tfVaultDonate = 0x00010000;
constexpr std::uint32_t const tfVaultDepositMask = ~(tfUniversal | tfVaultDonate);
// clang-format on
} // namespace xrpl

View File

@@ -16,6 +16,7 @@
// Add new amendments to the top of this list.
// Keep it sorted in reverse chronological order.
XRPL_FIX (LendingProtocolV1_1, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (PermissionedDomainInvariant, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (ExpiredNFTokenOfferRemoval, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (BatchInnerSigs, Supported::no, VoteBehavior::DefaultNo)

View File

@@ -868,6 +868,7 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete,
mustDeleteAcct | destroyMPTIssuance | mustModifyVault,
({
{sfVaultID, soeREQUIRED},
{sfMemoData, soeOPTIONAL},
}))
/** This transaction trades assets for shares with a vault. */

View File

@@ -13,6 +13,9 @@ public:
{
}
static std::uint32_t
getFlagsMask(PreflightContext const& ctx);
static NotTEC
preflight(PreflightContext const& ctx);

View File

@@ -9,6 +9,7 @@
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
@@ -378,10 +379,14 @@ ValidVault::finalize(
return std::nullopt;
}();
if (!beforeShares &&
(tx.getTxnType() == ttVAULT_DEPOSIT || //
tx.getTxnType() == ttVAULT_WITHDRAW || //
tx.getTxnType() == ttVAULT_CLAWBACK))
bool const isDonate = !view.rules().enabled(fixLendingProtocolV1_1) || tx.isFlag(tfVaultDonate);
bool const shouldUpdateShares =
// Vault Asset donation is the only operation that can succeed without updating shares
((tx.getTxnType() == ttVAULT_DEPOSIT && !isDonate) || //
tx.getTxnType() == ttVAULT_WITHDRAW || //
tx.getTxnType() == ttVAULT_CLAWBACK);
if (!beforeShares && shouldUpdateShares)
{
JLOG(j.fatal()) << "Invariant failed: vault operation succeeded "
"without updating shares";
@@ -635,37 +640,57 @@ ValidVault::finalize(
result = false;
}
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
if (!accountDeltaShares)
// If assets are not donated, check share invariants
if (view.rules().enabled(fixLendingProtocolV1_1) && tx.isFlag(tfVaultDonate))
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor "
"shares";
return false; // That's all we can do
}
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
if (accountDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: donation must not change depositor shares";
return false; // That's all we can do
}
if (*accountDeltaShares <= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must increase depositor "
"shares";
result = false;
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (vaultDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: donation must not change vault shares";
return false; // That's all we can do
}
}
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || *vaultDeltaShares == zero)
else
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change vault shares";
return false; // That's all we can do
}
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
if (!accountDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor shares";
return false; // That's all we can do
}
if (*vaultDeltaShares * -1 != *accountDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor and "
"vault shares by equal amount";
result = false;
if (*accountDeltaShares <= zero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must increase depositor shares";
result = false;
}
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || *vaultDeltaShares == zero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change vault shares";
return false; // That's all we can do
}
if (*vaultDeltaShares * -1 != *accountDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor and vault shares by "
"equal amount";
result = false;
}
}
if (beforeVault.assetsTotal + *vaultDeltaAssets != afterVault.assetsTotal)

View File

@@ -18,6 +18,13 @@ VaultDelete::preflight(PreflightContext const& ctx)
return temMALFORMED;
}
if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(fixLendingProtocolV1_1))
return temDISABLED;
// The sfMemoData field is an optional field used to record the deletion reason.
if (auto const data = ctx.tx[~sfMemoData]; data && !validDataLength(data, maxDataPayloadLength))
return temMALFORMED;
return tesSUCCESS;
}

View File

@@ -14,6 +14,15 @@
namespace xrpl {
std::uint32_t
VaultDeposit::getFlagsMask(PreflightContext const& ctx)
{
if (ctx.rules.enabled(fixLendingProtocolV1_1))
return tfVaultDepositMask;
return tfVaultDepositMask | tfVaultDonate;
}
NotTEC
VaultDeposit::preflight(PreflightContext const& ctx)
{
@@ -68,6 +77,22 @@ VaultDeposit::preclaim(PreclaimContext const& ctx)
// LCOV_EXCL_STOP
}
if (ctx.view.rules().enabled(fixLendingProtocolV1_1) && ctx.tx.isFlag(tfVaultDonate))
{
if (account != vault->at(sfOwner))
{
JLOG(ctx.j.debug()) << "VaultDeposit: only owner can donate to vault.";
return tecNO_PERMISSION;
}
// Cannot donate to a vault with no shares
if (sleIssuance->at(sfOutstandingAmount) == 0)
{
JLOG(ctx.j.debug()) << "VaultDeposit: empty vault cannot receive donations.";
return tecNO_PERMISSION;
}
}
if (sleIssuance->isFlag(lsfMPTLocked))
{
// LCOV_EXCL_START
@@ -180,42 +205,51 @@ VaultDeposit::doApply()
return err;
}
}
STAmount sharesCreated = {vault->at(sfShareMPTID)}, assetsDeposited;
try
if (view().rules().enabled(fixLendingProtocolV1_1) && ctx_.tx.isFlag(tfVaultDonate))
{
// Compute exchange before transferring any amounts.
{
auto const maybeShares = assetsToSharesDeposit(vault, sleIssuance, amount);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesCreated = *maybeShares;
}
if (sharesCreated == beast::zero)
return tecPRECISION_LOSS;
auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
if (!maybeAssets)
return tecINTERNAL; // LCOV_EXCL_LINE
else if (*maybeAssets > amount)
{
// LCOV_EXCL_START
JLOG(j_.error()) << "VaultDeposit: would take more than offered.";
return tecINTERNAL;
// LCOV_EXCL_STOP
}
assetsDeposited = *maybeAssets;
XRPL_ASSERT(
account_ == vault->at(sfOwner), "xrpl::VaultDeposit::doApply : account is owner");
assetsDeposited = amount;
}
catch (std::overflow_error const&)
else
{
// It's easy to hit this exception from Number with large enough Scale
// so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultDeposit: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) << ", amount=" << amount;
return tecPATH_DRY;
try
{
// Compute exchange before transferring any amounts.
{
auto const maybeShares = assetsToSharesDeposit(vault, sleIssuance, amount);
if (!maybeShares)
return tecINTERNAL; // LCOV_EXCL_LINE
sharesCreated = *maybeShares;
}
if (sharesCreated == beast::zero)
return tecPRECISION_LOSS;
auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated);
if (!maybeAssets)
return tecINTERNAL; // LCOV_EXCL_LINE
else if (*maybeAssets > amount)
{
// LCOV_EXCL_START
JLOG(j_.error()) << "VaultDeposit: would take more than offered.";
return tecINTERNAL;
// LCOV_EXCL_STOP
}
assetsDeposited = *maybeAssets;
}
catch (std::overflow_error const&)
{
// It's easy to hit this exception from Number with large enough Scale
// so we avoid spamming the log and only use debug here.
JLOG(j_.debug()) //
<< "VaultDeposit: overflow error with"
<< " scale=" << (int)vault->at(sfScale).value() //
<< ", assetsTotal=" << vault->at(sfAssetsTotal).value()
<< ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount)
<< ", amount=" << amount;
return tecPATH_DRY;
}
}
XRPL_ASSERT(
@@ -252,11 +286,19 @@ VaultDeposit::doApply()
// LCOV_EXCL_STOP
}
// Transfer shares from vault to depositor.
if (auto const ter =
accountSend(view(), vaultAccount, account_, sharesCreated, j_, WaiveTransferFee::Yes);
!isTesSuccess(ter))
return ter;
if (view().rules().enabled(fixLendingProtocolV1_1) && ctx_.tx.isFlag(tfVaultDonate))
{
XRPL_ASSERT(
sharesCreated == beast::zero, "xrpl::VaultDeposit::doApply: donation issued shares");
}
else
{
// Transfer shares from vault to depositor.
if (auto const ter = accountSend(
view(), vaultAccount, account_, sharesCreated, j_, WaiveTransferFee::Yes);
!isTesSuccess(ter))
return ter;
}
associateAsset(*vault, vaultAsset);

View File

@@ -12,6 +12,7 @@
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
@@ -3452,6 +3453,45 @@ class Invariants_test : public beast::unit_test::suite
precloseXrp,
TxAccount::A2);
doInvariantCheck(
{"donation must not change depositor shares"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
return adjust(ac.view(), keylet, args(A2.id(), 10, [&](Adjustments& sample) {
sample.accountShares->amount = 10;
}));
},
XRPAmount{},
STTx{
ttVAULT_DEPOSIT,
[](STObject& tx) {
tx[sfAmount] = XRPAmount(10);
tx[sfFlags] = tfVaultDonate;
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
doInvariantCheck(
{"donation must not change vault shares"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
return adjust(ac.view(), keylet, args(A2.id(), 10, [&](Adjustments& sample) {
sample.sharesTotal = 10;
sample.accountShares = std::nullopt;
}));
},
XRPAmount{},
STTx{
ttVAULT_DEPOSIT,
[](STObject& tx) {
tx[sfAmount] = XRPAmount(10);
tx[sfFlags] = tfVaultDonate;
}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
testcase << "Vault withdrawal";
doInvariantCheck(
{"withdrawal must change vault balance"},

View File

@@ -1787,10 +1787,21 @@ class LoanBroker_test : public beast::unit_test::suite
testRIPD4274MPT();
}
void
testFixAmendmentEnabled()
{
using namespace jtx;
testcase("testFixAmendmentEnabled");
Env env{*this};
BEAST_EXPECT(env.enabled(fixLendingProtocolV1_1));
}
public:
void
run() override
{
testFixAmendmentEnabled();
testLoanBrokerSetDebtMaximum();
testLoanBrokerCoverDepositNullVault();

View File

@@ -1073,6 +1073,7 @@ class Vault_test : public beast::unit_test::suite
Asset const& asset,
Vault& vault)> test) {
Env env{*this, testable_amendments() | featureSingleAssetVault};
Account issuer{"issuer"};
Account owner{"owner"};
Account depositor{"depositor"};
@@ -5357,6 +5358,213 @@ class Vault_test : public beast::unit_test::suite
}
}
void
testVaultDeleteData()
{
using namespace test::jtx;
Env env{*this};
Account const owner{"owner"};
env.fund(XRP(1'000'000), owner);
env.close();
Vault vault{env};
auto const keylet = keylet::vault(owner.id(), 1);
auto delTx = vault.del({.owner = owner, .id = keylet.key});
// Test VaultDelete with fixLendingProtocolV1_1 disabled
// Transaction fails if the data field is provided
{
testcase("VaultDelete data fixLendingProtocolV1_1 disabled");
env.disableFeature(fixLendingProtocolV1_1);
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength, 'A'));
env(delTx, ter(temDISABLED), THISLINE);
env.close();
env.enableFeature(fixLendingProtocolV1_1);
}
// Transaction fails if the data field is too large
{
testcase("VaultDelete data fixLendingProtocolV1_1 enabled data too large");
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength + 1, 'A'));
env(delTx, ter(temMALFORMED), THISLINE);
env.close();
}
// Transaction fails if the data field is set, but is empty
{
testcase("VaultDelete data fixLendingProtocolV1_1 enabled data empty");
delTx[sfMemoData] = strHex(std::string(0, 'A'));
env(delTx, ter(temMALFORMED), THISLINE);
env.close();
}
{
testcase("VaultDelete data fixLendingProtocolV1_1 enabled data valid");
PrettyAsset const xrpAsset = xrpIssue();
auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
env(tx, ter(tesSUCCESS), THISLINE);
env.close();
// Recreate the transaction as the vault keylet changed
auto delTx = vault.del({.owner = owner, .id = keylet.key});
delTx[sfMemoData] = strHex(std::string(maxDataPayloadLength, 'A'));
env(delTx, ter(tesSUCCESS), THISLINE);
env.close();
}
}
void
testVaultDepositDonate()
{
using namespace test::jtx;
std::string const prefix = "VaultDeposit donate";
Env env{*this};
Vault vault{env};
auto const vaultShareBalance = [&](Keylet const& vaultKeylet) {
auto const sleVault = env.le(vaultKeylet);
BEAST_EXPECT(sleVault != nullptr);
auto const sleIssuance = env.le(keylet::mptIssuance(sleVault->at(sfShareMPTID)));
BEAST_EXPECT(sleIssuance != nullptr);
return sleIssuance->at(sfOutstandingAmount);
};
auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) {
auto const sleVault = env.le(vaultKeylet);
BEAST_EXPECT(sleVault != nullptr);
return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal));
};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(1'000'000), owner, depositor);
env.close();
auto const depositAmount = XRP(10);
auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
env(tx, ter(tesSUCCESS), THISLINE);
env.close();
// With fixLendingProtocolV1_1 disabled, donations fail
{
testcase(prefix + " fails with fixLendingProtocolV1_1 disabled");
env.disableFeature(fixLendingProtocolV1_1);
auto const tx = vault.deposit({
.depositor = owner,
.id = keylet.key,
.amount = depositAmount,
.flags = tfVaultDonate,
});
env(tx, ter{temINVALID_FLAG}, THISLINE);
env.enableFeature(fixLendingProtocolV1_1);
env.close();
}
// Donation is not allowed to an empty vault
{
testcase(prefix + " fails to an empty vault");
auto const tx = vault.deposit({
.depositor = owner,
.id = keylet.key,
.amount = depositAmount,
.flags = tfVaultDonate,
});
env(tx, ter{tecNO_PERMISSION}, THISLINE);
env.close();
}
// Further unit tests require assets in the Vault
env(vault.deposit({
.depositor = depositor,
.id = keylet.key,
.amount = depositAmount,
}),
ter{tesSUCCESS},
THISLINE);
env.close();
// Donation is not allowed by a non-owner
{
testcase(prefix + " fails by a non-owner");
auto const tx = vault.deposit({
.depositor = depositor,
.id = keylet.key,
.amount = depositAmount,
.flags = tfVaultDonate,
});
env(tx, ter{tecNO_PERMISSION}, THISLINE);
env.close();
}
// Donation cannot exceed assets maximum
{
testcase(prefix + " cannot exceed assets maximum");
auto tx = vault.set({
.owner = owner,
.id = keylet.key,
});
tx[sfAssetsMaximum] = XRP(30).number();
env(tx, ter{tesSUCCESS}, THISLINE);
tx = vault.deposit({
.depositor = owner,
.id = keylet.key,
.amount = depositAmount + XRP(30),
.flags = tfVaultDonate,
});
env(tx, ter{tecLIMIT_EXCEEDED}, THISLINE);
env.close();
}
{
testcase(prefix + " succeeds");
auto const shareBalance = vaultShareBalance(keylet);
auto const [assetsAvailable, assetsTotal] = vaultAssetBalance(keylet);
auto tx = vault.deposit({
.depositor = owner,
.id = keylet.key,
.amount = depositAmount,
.flags = tfVaultDonate,
});
env(tx, ter{tesSUCCESS}, THISLINE);
env.close();
auto const shareBalanceAfterDeposit = vaultShareBalance(keylet);
auto const [assetsAvailableAfterDeposit, assetsTotalAfterDeposit] =
vaultAssetBalance(keylet);
BEAST_EXPECT(shareBalance == shareBalanceAfterDeposit);
BEAST_EXPECT(assetsAvailable + depositAmount.number() == assetsAvailableAfterDeposit);
BEAST_EXPECT(assetsTotal + depositAmount.number() == assetsTotalAfterDeposit);
auto const sleVault = env.le(keylet);
if (!BEAST_EXPECT(sleVault))
return;
// The depositor can withdraw their assets and the donated amount
Asset shareAsset(sleVault->at(sfShareMPTID));
tx = vault.withdraw(
{.depositor = depositor, .id = keylet.key, .amount = shareAsset(shareBalance)});
env(tx, ter{tesSUCCESS}, THISLINE);
auto const shareBalanceAfterWithdraw = vaultShareBalance(keylet);
auto const [assetsAvailableAfterWithdraw, assetsTotalAfterWithdraw] =
vaultAssetBalance(keylet);
BEAST_EXPECT(shareBalanceAfterWithdraw == 0);
BEAST_EXPECT(assetsAvailableAfterWithdraw == 0);
BEAST_EXPECT(assetsTotalAfterWithdraw == 0);
}
}
public:
void
run() override
@@ -5378,6 +5586,8 @@ public:
testVaultClawbackBurnShares();
testVaultClawbackAssets();
testAssetsMaximum();
testVaultDepositDonate();
testVaultDeleteData();
}
};

View File

@@ -52,6 +52,9 @@ Vault::deposit(DepositArgs const& args)
jv[jss::Account] = args.depositor.human();
jv[sfVaultID] = to_string(args.id);
jv[jss::Amount] = to_json(args.amount);
if (args.flags)
jv[jss::Flags] = *args.flags;
return jv;
}

View File

@@ -55,6 +55,7 @@ struct Vault
Account depositor;
uint256 id;
STAmount amount;
std::optional<std::uint32_t> flags{};
};
Json::Value