test: Add unit tests for the Vault balance-mutation helpers

Exercise addVaultAssets, clawbackVaultAssets, removeVaultAssets
(including FinalRemoval::Yes hard-reset and the amount > sfAssetsAvailable
failure path), and moveVaultAssets directly against a real Vault SLE and
ApplyView, built via jtx but bypassing the VaultDeposit/VaultWithdraw/
VaultClawback/LoanSet transactors. Covers zero-amount edges, negative
valueDelta, and the independent amount/valueDelta contract, in addition
to the existing transactor-level coverage in Vault_test.cpp,
LoanSet_test.cpp, etc.
This commit is contained in:
Vito
2026-08-10 12:41:00 +02:00
parent 518edd594c
commit 80ef2659e0

View File

@@ -0,0 +1,435 @@
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/noop.h>
#include <test/jtx/pay.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ApplyViewImpl.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
namespace xrpl::test {
// Exercises the addVaultAssets/clawbackVaultAssets/removeVaultAssets/
// moveVaultAssets helpers directly, against a real Vault SLE and real
// account/trust-line state built via jtx, but bypassing the VaultDeposit/
// VaultWithdraw/VaultClawback/LoanSet transactors entirely. This lets each
// helper's contract (field deltas, transfer amounts, FinalRemoval behavior)
// be checked in isolation, in addition to the transactor-level coverage in
// Vault_test.cpp, LoanSet_test.cpp, etc.
class VaultHelpers_test : public beast::unit_test::Suite
{
// Builds a public IOU-backed vault owned by `owner`. Leaves the Env
// with no pending (un-applied) transactions, so callers can safely
// follow up with raw ApplyViewImpl mutations.
static Keylet
setupVault(jtx::Env& env, jtx::PrettyAsset const& asset, jtx::Account const& owner)
{
jtx::Vault const v{env};
auto const [createTx, vaultKeylet] = v.create({.owner = owner, .asset = asset});
env(createTx);
env.close();
return vaultKeylet;
}
void
testAddVaultAssets()
{
testcase("addVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
auto const depositorBalanceBefore = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
// valueDelta and amount are independent: a nonzero valueDelta with a
// zero amount recognizes a value change (e.g. a covered default)
// without transferring any cash.
{
STAmount const zero{vaultAsset, 0};
STAmount const fifty{vaultAsset, 50};
auto const ter = addVaultAssets(view, vault, depositor, zero, fifty, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const depositorBalanceAfter = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore + 50);
BEAST_EXPECT(availableAfter == availableBefore);
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore);
}
// The common case: amount == valueDelta, and cash actually moves
// from `sender` to the Vault's pseudo-account.
{
STAmount const hundred{vaultAsset, 100};
auto const ter = addVaultAssets(view, vault, depositor, hundred, hundred, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const depositorBalanceAfter = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore + 150);
BEAST_EXPECT(availableAfter == availableBefore + 100);
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore - hundred);
}
// valueDelta may be negative (e.g. a small rounding correction on a
// loan payment), independently of amount again.
{
Number const totalBeforeNegative = vault->at(sfAssetsTotal);
Number const availableBeforeNegative = vault->at(sfAssetsAvailable);
STAmount const zero{vaultAsset, 0};
STAmount const minusTen{vaultAsset, -10};
auto const ter = addVaultAssets(view, vault, depositor, zero, minusTen, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBeforeNegative - 10);
BEAST_EXPECT(availableAfter == availableBeforeNegative);
}
}
void
testClawbackVaultAssets()
{
testcase("clawbackVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
AccountID const vaultAccount = vault->at(sfAccount);
// The issuer of an IOU always implicitly holds it (no trust line
// required), matching the only real caller (VaultClawback, where
// the recipient is always the asset's issuer); accountHolds for an
// issuer's own currency doesn't track a normal incrementable
// balance, so check the Vault's own (real, decrementable) balance
// instead.
auto const vaultBalanceBefore = accountHolds(
view,
vaultAccount,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
STAmount const hundred{vaultAsset, 100};
auto const ter = clawbackVaultAssets(view, vault, issuer, hundred, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const vaultBalanceAfter = accountHolds(
view,
vaultAccount,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore - 100);
BEAST_EXPECT(availableAfter == availableBefore - 100);
BEAST_EXPECT(vaultBalanceAfter == vaultBalanceBefore - hundred);
// Clawing back more than sfAssetsAvailable fails outright, leaving
// the Vault's fields untouched.
{
Number const totalBeforeFail = vault->at(sfAssetsTotal);
Number const availableBeforeFail = vault->at(sfAssetsAvailable);
STAmount const tooMuch{vaultAsset, availableBeforeFail + 1};
auto const failTer = clawbackVaultAssets(view, vault, issuer, tooMuch, env.journal);
BEAST_EXPECT(failTer == tefINTERNAL);
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeFail);
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeFail);
}
}
void
testRemoveVaultAssets()
{
testcase("removeVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
// doWithdraw only reads ctx.tx on the third-party-destination path;
// for a self-withdrawal (senderAcct == dstAcct, exercised below) it
// is unused, so a trivial signed noop stands in for a real
// VaultWithdraw transaction.
auto const dummyTx = env.jt(noop(depositor)).stx;
if (!BEAST_EXPECT(dummyTx))
return;
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
ApplyViewContext const ctx{.view = view, .tx = *dummyTx};
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
auto const depositorBalanceBefore = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
// Normal (non-final) removal: both fields decrease by `amount`.
{
STAmount const hundred{vaultAsset, 100};
auto const ter = removeVaultAssets(
ctx,
vault,
depositor,
depositor,
XRPAmount{0},
hundred,
env.journal,
FinalRemoval::No);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const depositorBalanceAfter = accountHolds(
view,
depositor,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore - 100);
BEAST_EXPECT(availableAfter == availableBefore - 100);
BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore + hundred);
}
// FinalRemoval::Yes hard-resets both fields to exactly zero,
// regardless of the passed-in amount (deliberately an understated
// amount here, to prove the fields aren't merely decremented by
// it).
{
Number const totalBeforeFinal = vault->at(sfAssetsTotal);
STAmount const one{vaultAsset, 1};
auto const ter = removeVaultAssets(
ctx,
vault,
depositor,
depositor,
XRPAmount{0},
one,
env.journal,
FinalRemoval::Yes);
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == Number{0});
BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == Number{0});
BEAST_EXPECTS(
totalBeforeFinal != Number(0),
"fixture sanity: total was nonzero before the final removal");
}
}
void
testMoveVaultAssets()
{
testcase("moveVaultAssets");
using namespace jtx;
Env env(*this);
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
Account const borrower{"borrower"};
Account const feeRecipient{"feeRecipient"};
env.fund(XRP(10'000), issuer, owner, depositor, borrower, feeRecipient);
env.close();
PrettyAsset const asset = issuer["USD"];
env(trust(owner, asset(1'000'000)));
env(trust(depositor, asset(1'000'000)));
env(trust(borrower, asset(1'000'000)));
env(trust(feeRecipient, asset(1'000'000)));
env(pay(issuer, depositor, asset(10'000)));
env.close();
auto const vaultKeylet = setupVault(env, asset, owner);
Vault const v{env};
env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)}));
env.close();
auto const open = env.current();
ApplyViewImpl view(&*open, TapNone);
auto const vault = view.peek(vaultKeylet);
if (!BEAST_EXPECT(vault))
return;
Asset const vaultAsset = vault->at(sfAsset);
Number const totalBefore = vault->at(sfAssetsTotal);
Number const availableBefore = vault->at(sfAssetsAvailable);
auto const borrowerBalanceBefore = accountHolds(
view,
borrower,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
auto const feeRecipientBalanceBefore = accountHolds(
view,
feeRecipient,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
// Mimics a loan disbursement: principal to the borrower, a fee to a
// separate recipient. valueDelta is zero here since a nonzero value
// is only meaningful (and only permitted, see moveVaultAssets's
// assertion) for a Legacy-version Vault recognizing accrued
// interest into sfAssetsTotal at origination; this fixture's Vault
// is not Legacy. sfAssetsAvailable still decreases independently of
// sfAssetsTotal, which is the contract under test.
MultiplePaymentDestinations const recipients{
{borrower, Number{80}},
{feeRecipient, Number{20}},
};
STAmount const zero{vaultAsset, 0};
auto const ter = moveVaultAssets(view, vault, recipients, zero, env.journal);
Number const totalAfter = vault->at(sfAssetsTotal);
Number const availableAfter = vault->at(sfAssetsAvailable);
auto const borrowerBalanceAfter = accountHolds(
view,
borrower,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
auto const feeRecipientBalanceAfter = accountHolds(
view,
feeRecipient,
vaultAsset,
FreezeHandling::IgnoreFreeze,
AuthHandling::IgnoreAuth,
env.journal);
STAmount const eighty{vaultAsset, 80};
STAmount const twenty{vaultAsset, 20};
BEAST_EXPECT(isTesSuccess(ter));
BEAST_EXPECT(totalAfter == totalBefore);
BEAST_EXPECT(availableAfter == availableBefore - 100);
BEAST_EXPECT(borrowerBalanceAfter == borrowerBalanceBefore + eighty);
BEAST_EXPECT(feeRecipientBalanceAfter == feeRecipientBalanceBefore + twenty);
}
public:
void
run() override
{
testAddVaultAssets();
testClawbackVaultAssets();
testRemoveVaultAssets();
testMoveVaultAssets();
}
};
BEAST_DEFINE_TESTSUITE(VaultHelpers, app, xrpl);
} // namespace xrpl::test