test: Split Vault_test into topical suites under src/test/app/vault/ (#8041)

This commit is contained in:
Vito Tumas
2026-08-18 17:57:12 +00:00
committed by GitHub
parent b21fd86f6e
commit 8c12de6c56
16 changed files with 10278 additions and 9783 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -409,7 +409,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
Env const env{*this};
auto const& rules = env.current()->rules();
// Inputs from the bug reproduction in Loan_test.cpp:
// Inputs from the near-zero-rate LoanPay bug reproduction:
// InterestRate = 1 TenthBips32 (0.001 % per year),
// PaymentInterval = 600 s, principal = 100, 3 payments.
// periodicRate is ~1.9e-10.

View File

@@ -67,6 +67,16 @@
namespace xrpl::test {
/**
* Shared base for the Loan*_test family under src/test/app/lending/.
*
* Run all suites in this family with
* xrpld -u Loan,LendingHelpers
* The "Loan" prefix is matched against every suite name via
* beast::unit_test::Selector::ModeT::Automatch; LendingHelpers is listed
* explicitly because it does not share the "Loan" prefix (and lives in a
* different module: app vs tx).
*/
class LoanTestBase : public beast::unit_test::Suite
{
protected:

View File

@@ -1,46 +0,0 @@
#include <xrpl/beast/unit_test/global_suites.h>
#include <xrpl/beast/unit_test/suite.h>
#include <algorithm>
#include <array>
#include <string_view>
namespace xrpl::test {
/**
* Aggregator: running this suite ("Loan") reruns every topical Loan/Lending
* suite in one invocation. Each member suite below remains independently
* runnable under its own name. Declared manual so an unfiltered full test
* run doesn't execute every case twice.
*/
class Loan_test : public beast::unit_test::Suite
{
void
run() override
{
static constexpr std::array<std::string_view, 12> kMembers{
"LendingHelpers",
"LoanBroker",
"LoanCashBasis",
"LoanCoverFreezeAuth",
"LoanInvariants",
"LoanLifecycle",
"LoanMisc",
"LoanPay",
"LoanRounding",
"LoanSecurity",
"LoanSet",
"LoanValidation",
};
for (auto const& info : beast::unit_test::globalSuites())
{
if (std::ranges::find(kMembers, info.name()) != kMembers.end())
info.run(runner());
}
}
};
BEAST_DEFINE_TESTSUITE_MANUAL(Loan, tx, xrpl);
} // namespace xrpl::test

View File

@@ -0,0 +1,716 @@
#include <test/app/vault/VaultTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/CaptureLogs.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/flags.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.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/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <memory>
#include <string>
#include <tuple>
#include <utility>
namespace xrpl {
class VaultBugs_test : public VaultTestBase
{
private:
// Bug: the equality check (vault outflow == destination inflow) was
// skipped whenever the destination delta rounded to zero at localMinScale,
// including cases where the vault outflow rounded to a non-zero value and
// a representable amount of value was genuinely destroyed.
//
// Scenario: Bob's IOU balance sits 5 units below the 10^16 STAmount
// precision boundary (atEdge2 = 9,999,999,999,999,995). A withdrawal of
// 6 USD shifts his balance across that boundary: the exponent increments
// (0 → 1), so his effective inflow in Number space is only +5 — 1 USD is
// consumed by the precision-boundary rounding and cannot be credited.
//
// The destroyed amount (1 USD) is sub-ULP at destinationScale=1 (step=10),
// so the check treats it as an unavoidable IOU-precision artefact and
// lets the transaction succeed.
//
// Contrast: if 15 USD were destroyed at the same scale (destroyed ≥ step),
// floor(15/10)=1 ≠ 0 and the invariant would fire — that discrepancy IS
// representable and indicates a real accounting bug.
//
// Pre-fixCleanup3_2_0: the "must increase destination balance" check fires
// because roundedDestinationDelta = 0 ≤ 0.
void
testVaultWithdrawEqualityEnforced()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(100'000), issuer, alice, bob);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const usd{issuer["USD"]};
STAmount const aliceLimit{usd.raw(), 2, 16};
STAmount const bobLimit{usd.raw(), 2, 16};
// Bob's balance sits 5 units below the 10^16 STAmount precision
// boundary. Receiving 6 USD shifts his exponent 0 → 1; the
// STAmount records +5, not +6 (1 USD is lost to rounding).
STAmount const atEdge2{usd.raw(), Number{9'999'999'999'999'995LL}};
env(trust(alice, aliceLimit));
env(trust(bob, bobLimit));
env.close();
env(pay(issuer, alice, usd(1'000)));
env(pay(issuer, bob, atEdge2));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
env.close();
// Withdraw 6 USD to Bob: vault loses 6, Bob gains only 5.
// Destroyed amount = 1 USD, which is sub-ULP at destinationScale=1.
auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(6)});
tx[sfDestination] = bob.human();
env(tx, Ter(expected));
env.close();
};
{
testcase(
"bug: VaultWithdraw to destination at IOU precision boundary fires "
"invariant (pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to destination at IOU precision boundary succeeds "
"when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tesSUCCESS);
}
}
// VaultDeposit by issuer with the vault parked at the IOU 16-digit
// edge (9.999e15). Issuer mints 2 more USD; the vault trust line
// goes 9.999e15 → 10^16, gaining 1 unit instead of 2 (canonicalization).
//
// Pre-fixCleanup3_2_0: the proactive check is absent; the deposit
// applies, then VaultInvariant's "deposit must increase vault
// balance" assertion fires at finalize time on the rounded vault
// delta of zero, returning tecINVARIANT_FAILED.
// Post-amendment: reject deposit that is not representable at Vault scale.
void
testBugIssuerVaultDepositAtEdge()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(100'000), issuer, owner);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const usd{issuer["USD"]};
STAmount const trustLimit{usd.raw(), 2, 16};
STAmount const ownerFund{usd.raw(), Number{9'999'999'999'999'999LL}};
env(trust(owner, trustLimit));
env.close();
env(pay(issuer, owner, ownerFund));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = ownerFund}));
env.close();
// Vault pseudo-account is now at 9.999e15. Issuer mints 2
// more USD. Pre: tecINVARIANT_FAILED at finalize. Post:
// tecPRECISION_LOSS proactively. Either way, no value moves.
env(vault.deposit({.depositor = issuer, .id = vaultKeylet.key, .amount = usd(2)}),
Ter(expected));
env.close();
};
{
testcase(
"bug: VaultDeposit by issuer at IOU edge fires "
"tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit by issuer at IOU edge rejects with "
"tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tecPRECISION_LOSS);
}
}
// Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for
// sfAssetsTotal/Available deltas. This is symmetric to
// testBugMakeDeltaAnteriorScale but in the opposite direction: a deposit
// pushes assetsTotal from just below 1e16 (IOU exponent 0, ULP = 1) to just
// above it (exponent 1, ULP = 10). makeDelta picks the coarser *posterior*
// scale 1. The trust line balance rounds from atEdge + 2 = 10,000,000,000,000,001
// → 1e16, so the pseudo-account delta is only +1 in IOU space.
// roundToAsset(+1, scale=1) = 0 fires "deposit must increase vault balance"
// even though the state change is consistent at every precision boundary.
//
// Fix (fixCleanup3_2_0): computeVaultMinScale uses the posterior Number-space
// scale of sfAssetsTotal (which retains the full value 10,000,000,000,000,001,
// exponent 0), giving minScale = 0. roundToAsset(+1, scale=0) = 1 > 0 and
// the invariant passes. However the transactor's own precision guard fires
// first (bob pays 2 USD, vault receives only 1 due to IOU rounding), so the
// post-amendment result is tecPRECISION_LOSS rather than tesSUCCESS —
// the depositor is protected from silently losing 1 USD to rounding.
void
testBugMakeDeltaPosteriorScale()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(100'000), issuer, alice, bob);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const usd{issuer["USD"]};
// atEdge is the largest IOU value with exponent 0 (ULP = 1).
// A deposit of 2 USD brings assetsTotal to 10,000,000,000,000,001
// in Number space, crossing the 1e16 boundary in IOU space.
STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
env(trust(alice, STAmount{usd.raw(), 2, 16}));
env(trust(bob, usd(100)));
env.close();
env(pay(issuer, alice, atEdge));
env(pay(issuer, bob, usd(2)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
// sfAssetsTotal = sfAssetsAvailable = atEdge (exponent 0, ULP = 1)
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = atEdge}));
env.close();
// Deposit 2 USD: +2 is sub-ULP at the posterior IOU scale (ULP = 10)
// but exact at the Number scale retained by sfAssetsTotal.
env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
Ter(expected));
env.close();
};
{
testcase(
"bug: VaultDeposit across IOU scale boundary fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit across IOU scale boundary succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tecPRECISION_LOSS);
}
}
// Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
// sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
// same max() for the vault pseudo-account RippleState. When
// sfAssetsTotal sits exactly at 1e16 (IOU exponent 1, ULP = 10) and a
// withdrawal of 5 USD brings it to 9.999...995e15 (IOU exponent 0,
// ULP = 1), all three computations pick the anterior coarser scale 1.
// roundToAsset(-5, scale=1) collapses to 0, so the invariant check
// vaultPseudoDeltaAssets >= kZero fires even though the state change is
// valid and fully consistent at IOU precision.
//
// Fix (fixCleanup3_2_0): finalize compares the vault pseudo-account and
// sfAssetsTotal/Available deltas directly in Number space, bypassing
// scale-coarsened rounding.
void
testBugMakeDeltaAnteriorScale()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
env.fund(XRP(100'000), issuer, alice);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const usd{issuer["USD"]};
// Trust limit of 2e16, fund exactly 1e16 so deposit lands at the
// IOU scale-1 boundary (exponent 1, ULP = 10).
STAmount const fundAndDeposit{usd.raw(), Number{1, 16}};
env(trust(alice, STAmount{usd.raw(), 2, 16}));
env.close();
env(pay(issuer, alice, fundAndDeposit));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
// sfAssetsTotal = sfAssetsAvailable = 1e16 (exponent 1, ULP = 10).
env(vault.deposit(
{.depositor = alice, .id = vaultKeylet.key, .amount = fundAndDeposit}));
env.close();
// Withdraw 5 USD: -5 is sub-ULP at the anterior scale (ULP = 10)
// but exact at the posterior scale (ULP = 1). The state change is
// consistent; only the invariant's scale selection is wrong.
env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(5)}),
Ter(expected));
env.close();
};
{
testcase(
"bug: VaultWithdraw across IOU scale boundary fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw across IOU scale boundary succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tesSUCCESS);
}
}
// Bug: when a depositor's IOU trustline balance is very large (e.g.
// ~1e17), adding a small deposit (e.g. 1 USD) leaves sfAssetsTotal
// unchanged at IOU precision because the increment is sub-ULP at the
// vault's current asset scale. The vault records the deposit, mints
// shares, and decrements the depositor's trustline, but sfAssetsTotal
// does not change — the conservation invariant fires because the rail
// delta is zero.
//
// Two sub-cases are exercised:
// 1. First-ever deposit into an empty vault: the depositor's own
// trustline has a large balance so 1 USD canonicalizes to zero
// when written back through the IOU rail.
// 2. Subsequent deposit after the vault already holds a large
// sfAssetsTotal: a different depositor (bob, with a small balance)
// sends 1 USD, which again rounds to zero at the vault's coarse
// asset scale.
//
// Fix (fixCleanup3_2_0): the deposit transactor checks whether
// roundToAsset(amount, vault_scale) == 0 and rejects early with
// tecPRECISION_LOSS before any state is modified.
void
testVaultDepositCanonicalizeToZero()
{
using namespace test::jtx;
auto runScenario = [this](FeatureBitset features, TER expected) {
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(100'000), issuer, alice, bob);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const usd{issuer["USD"]};
STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
STAmount const aliceFund{usd.raw(), Number{99'999'999'999'999'999LL}};
env(trust(alice, trustLimit));
env(trust(bob, trustLimit));
env.close();
env(pay(issuer, alice, aliceFund));
env(pay(issuer, bob, usd(1000)));
env.close();
Vault const vault{env};
// Scale=0 so sfAssetsTotal stores whole USD
auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
// Alice's deposit canonicalizes to zero at her own trustline scale
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1)}),
Ter(expected));
// Increase vault-scale
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
env.close();
env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(1)}),
Ter(expected));
env.close();
};
{
testcase(
"bug: VaultDeposit below Vault precision canonicalized to zero "
"(pre-fixCleanup3_2_0)");
runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultDeposit below Vault precision canonicalized to zero "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), tecPRECISION_LOSS);
}
}
// Bug: ValidVault::visitEntry computes destinationDelta.scale as
// max(before_exponent, after_exponent) for RippleState entries. When a
// withdrawal credits a destination whose IOU balance sits just below a
// power-of-10 boundary (atEdge = 9'999'999'999'999'999), the post-credit
// STAmount rounds up one exponent (exponent 0 → 1), making
// destinationDelta.scale = 1. The invariant then calls
// roundToAsset(+2 USD, scale=1) = 0 and incorrectly fires
// "withdrawal must increase destination balance".
//
// Fix (fixCleanup3_2_0): finalize compares destination delta directly in
// Number space, bypassing scale-coarsened rounding. The transaction
// itself succeeds because the effective IOU credit is non-trivial at
// Number precision even though the STAmount exponent shifted.
void
testVaultWithdrawCanonicalizeToZero()
{
using namespace test::jtx;
enum class DestKind : bool { ThirdParty = false, Self = true };
auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(100'000), issuer, alice, bob);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const usd{issuer["USD"]};
STAmount const aliceLimit{usd.raw(), 2, 16};
STAmount const bobLimit{usd.raw(), 2, 16};
STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
env(trust(alice, aliceLimit));
if (destKind == DestKind::ThirdParty)
env(trust(bob, bobLimit));
env.close();
env(pay(issuer, alice, usd(1'000)));
if (destKind == DestKind::ThirdParty)
env(pay(issuer, bob, atEdge));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
env.close();
// For the self-destination case, push alice's own trust line to
// the IOU edge so the next withdraw inflow crosses the boundary.
if (destKind == DestKind::Self)
{
env(pay(issuer, alice, atEdge));
env.close();
}
auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(2)});
if (destKind == DestKind::ThirdParty)
tx[sfDestination] = bob.human();
env(tx, Ter(expected));
env.close();
};
{
testcase(
"bug: VaultWithdraw to third-party at IOU edge fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(
testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to third-party at IOU edge succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
}
{
testcase(
"bug: VaultWithdraw to self at IOU edge fires invariant "
"(pre-fixCleanup3_2_0)");
runScenario(
testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
}
{
testcase(
"bug: VaultWithdraw to self at IOU edge succeeds "
"(post-fixCleanup3_2_0)");
runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
}
}
// VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
// shFULL_BALANCE), which for an IOU asset adds the counterparty's
// LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
// getTrustLineBalance with includeOppositeLimit=true). When the
// depositor's raw balance < deposit amount but raw + opposite limit >=
// amount, preclaim is satisfied. doApply then calls
// directSendNoFeeIOU, which unconditionally subtracts saAmount from
// saBalance — driving the trust line negative — and returns tesSUCCESS.
// The post-send sanity check uses the default shSIMPLE_BALANCE (no
// opposite-limit add), sees a negative balance, and returns tefINTERNAL.
void
testVaultDepositNegativeBalanceFromOppositeLimit()
{
auto runTest = [&](FeatureBitset f, TER expected) {
using namespace test::jtx;
using namespace std::literals;
Env env{*this, f};
Account const gw{"gateway"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10000), gw, owner, depositor);
env.close();
// Gateway with DefaultRipple so vault creation on its IOU works.
env(fset(gw, asfDefaultRipple));
env.close();
// Depositor opens a trust line to gateway and receives a small
// balance.
PrettyAsset const usd = gw["USD"];
env.trust(usd(1000), depositor);
env(pay(gw, depositor, usd(100))); // raw trust-line balance: 100
env.close();
// Key precondition: gateway sets a non-zero limit on the same
// RippleState — the "opposite field" from depositor's perspective.
// This is what inflates shFULL_BALANCE in preclaim above the raw
// balance.
env(trust(gw, depositor["USD"](1000)));
env.close();
// Create the IOU vault.
Vault const vault{env};
auto [vaultTx, keylet] = vault.create({.owner = owner, .asset = usd});
env(vaultTx);
env.close();
// Submit a deposit of 500 USD:
// - raw balance: 100 USD
// - opposite limit (gw's side): 1000 USD
// - preclaim sees 100 + 1000 = 1100, passes (>= 500)
// - doApply transfers 500, depositor's trust-line balance
// becomes -400
// - sanity check at VaultDeposit.cpp:256 fires
// - tx returns tefINTERNAL (BUG — should be tesSUCCESS.
auto depositTx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(500)});
env(depositTx, Ter(expected));
env.close();
};
{
testcase(
"IOU vault deposit exceeding depositor's balance but "
"within counterparty's trust limit, pre-fixCleanup3_2_0 "
"(tefINTERNAL)");
runTest(test::jtx::testableAmendments() - fixCleanup3_2_0, tefINTERNAL);
}
{
testcase(
"IOU vault deposit exceeding depositor's balance but "
"within counterparty's trust limit, post-fixCleanup3_2_0 "
"(tesSUCCESS)");
runTest(test::jtx::testableAmendments(), tesSUCCESS);
}
}
// Reproduction: canWithdraw IOU limit check bypassed when
// withdrawal amount is specified in shares (MPT) rather than in assets.
void
testBug6LimitBypassWithShares()
{
using namespace test::jtx;
testcase("Bug6 - limit bypass with share-denominated withdrawal");
auto const allAmendments = testableAmendments() | featureSingleAssetVault;
for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
{
bool const withFix = features[fixCleanup3_1_3];
Env env{*this, features};
Account const owner{"owner"};
Account const issuer{"issuer"};
Account const depositor{"depositor"};
Account const charlie{"charlie"};
Vault const vault{env};
env.fund(XRP(1000), issuer, owner, depositor, charlie);
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1000), owner);
env.trust(asset(1000), depositor);
env(pay(issuer, owner, asset(200)));
env(pay(issuer, depositor, asset(200)));
env.close();
// Charlie gets a LOW trustline limit of 5
env.trust(asset(5), charlie);
env.close();
auto const [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
auto const depositTx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
env(depositTx);
env.close();
// Get the share MPT info
auto const vaultSle = env.le(keylet);
if (!BEAST_EXPECT(vaultSle))
return;
auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
MPTIssue const shares(mptIssuanceID);
PrettyAsset const share(shares);
// CONTROL: Withdraw 10 IOU (asset-denominated) to charlie.
// Charlie's limit is 5, so this should be rejected with tecNO_LINE
// regardless of the amendment.
{
auto withdrawTx =
vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
withdrawTx[sfDestination] = charlie.human();
env(withdrawTx, Ter{tecNO_LINE});
env.close();
}
auto const charlieBalanceBefore = env.balance(charlie, asset.raw().get<Issue>());
// Withdraw the equivalent amount in shares to charlie.
// Post-fix: rejected (tecNO_LINE) because the share amount is
// converted to assets and the trustline limit is checked.
// Pre-fix: succeeds (tesSUCCESS) because the limit check was
// skipped for share-denominated withdrawals.
{
auto withdrawTx = vault.withdraw(
{.depositor = depositor,
.id = keylet.key,
.amount = STAmount(share, 10'000'000)});
withdrawTx[sfDestination] = charlie.human();
env(withdrawTx, Ter{withFix ? TER{tecNO_LINE} : TER{tesSUCCESS}});
env.close();
auto const charlieBalanceAfter = env.balance(charlie, asset.raw().get<Issue>());
if (withFix)
{
// Post-fix: charlie's balance is unchanged — the withdrawal
// was correctly rejected despite being share-denominated.
BEAST_EXPECT(charlieBalanceAfter == charlieBalanceBefore);
}
else
{
// Pre-fix: charlie received the assets, bypassing the
// trustline limit.
BEAST_EXPECT(charlieBalanceAfter > charlieBalanceBefore);
}
}
}
}
public:
void
run() override
{
testVaultWithdrawEqualityEnforced();
testBugIssuerVaultDepositAtEdge();
testBugMakeDeltaPosteriorScale();
testBugMakeDeltaAnteriorScale();
testVaultDepositCanonicalizeToZero();
testVaultWithdrawCanonicalizeToZero();
testVaultDepositNegativeBalanceFromOppositeLimit();
testBug6LimitBypassWithShares();
}
};
BEAST_DEFINE_TESTSUITE(VaultBugs, app, xrpl);
} // namespace xrpl

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,586 @@
#include <test/app/vault/VaultTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/credentials.h>
#include <test/jtx/flags.h>
#include <test/jtx/offer.h>
#include <test/jtx/pay.h>
#include <test/jtx/permissioned_domains.h>
#include <test/jtx/ter.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
#include <chrono>
#include <cstdint>
#include <string>
#include <tuple>
#include <utility>
namespace xrpl {
class VaultDomain_test : public VaultTestBase
{
private:
void
testWithDomainCheck()
{
using namespace test::jtx;
testcase("private vault");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
Account const charlie{"charlie"};
Account const pdOwner{"pdOwner"};
Account const credIssuer1{"credIssuer1"};
Account const credIssuer2{"credIssuer2"};
std::string const credType = "credential";
Vault const vault{env};
env.fund(XRP(1000), issuer, owner, depositor, charlie, pdOwner, credIssuer1, credIssuer2);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
env.require(Flags(issuer, asfAllowTrustLineClawback));
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1000), owner);
env(pay(issuer, owner, asset(500)));
env.trust(asset(1000), depositor);
env(pay(issuer, depositor, asset(500)));
env.trust(asset(1000), charlie);
env(pay(issuer, charlie, asset(5)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
env(tx);
env.close();
BEAST_EXPECT(env.le(keylet));
{
testcase("private vault owner can deposit");
auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
env(tx);
}
{
testcase("private vault depositor not authorized yet");
auto tx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx, Ter{tecNO_AUTH});
}
{
testcase("private vault cannot set non-existing domain");
auto tx = vault.set({.owner = owner, .id = keylet.key});
tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
env(tx, Ter{tecOBJECT_NOT_FOUND});
}
{
testcase("private vault set domainId");
{
pdomain::Credentials const credentials1{
{.issuer = credIssuer1, .credType = credType}};
env(pdomain::setTx(pdOwner, credentials1));
auto const domainId1 = [&]() {
auto tx = env.tx()->getJson(JsonOptions::Values::None);
return pdomain::getNewDomain(env.meta());
}();
auto tx = vault.set({.owner = owner, .id = keylet.key});
tx[sfDomainID] = to_string(domainId1);
env(tx);
env.close();
// Update domain second time, should be harmless
env(tx);
env.close();
}
{
pdomain::Credentials const credentials{
{.issuer = credIssuer1, .credType = credType},
{.issuer = credIssuer2, .credType = credType}};
env(pdomain::setTx(pdOwner, credentials));
auto const domainId = [&]() {
auto tx = env.tx()->getJson(JsonOptions::Values::None);
return pdomain::getNewDomain(env.meta());
}();
auto tx = vault.set({.owner = owner, .id = keylet.key});
tx[sfDomainID] = to_string(domainId);
env(tx);
env.close();
// Should be idempotent
tx = vault.set({.owner = owner, .id = keylet.key});
tx[sfDomainID] = to_string(domainId);
env(tx);
env.close();
}
}
{
testcase("private vault depositor still not authorized");
auto tx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx, Ter{tecNO_AUTH});
env.close();
}
auto const credKeylet = credentials::keylet(depositor, credIssuer1, credType);
{
testcase("private vault depositor now authorized");
env(credentials::create(depositor, credIssuer1, credType));
env(credentials::accept(depositor, credIssuer1, credType));
env(credentials::create(charlie, credIssuer1, credType));
// charlie's credential not accepted
env.close();
auto credSle = env.le(credKeylet);
BEAST_EXPECT(credSle != nullptr);
auto tx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx);
env.close();
tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(50)});
env(tx, Ter{tecNO_AUTH});
env.close();
}
{
testcase("private vault depositor lost authorization");
env(credentials::deleteCred(credIssuer1, depositor, credIssuer1, credType));
env(credentials::deleteCred(credIssuer1, charlie, credIssuer1, credType));
env.close();
auto credSle = env.le(credKeylet);
BEAST_EXPECT(credSle == nullptr);
auto tx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx, Ter{tecNO_AUTH});
env.close();
}
auto const shares = [&env, keylet = keylet, this]() -> Asset {
auto const vault = env.le(keylet);
BEAST_EXPECT(vault != nullptr);
return MPTIssue(vault->at(sfShareMPTID));
}();
{
testcase("private vault expired authorization");
uint32_t const closeTime =
env.current()->header().parentCloseTime.time_since_epoch().count();
{
auto tx0 = credentials::create(depositor, credIssuer2, credType);
tx0[sfExpiration] = closeTime + 20;
env(tx0);
tx0 = credentials::create(charlie, credIssuer2, credType);
tx0[sfExpiration] = closeTime + 20;
env(tx0);
env.close();
env(credentials::accept(depositor, credIssuer2, credType));
env(credentials::accept(charlie, credIssuer2, credType));
env.close();
}
{
auto tx1 =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx1);
env.close();
auto const tokenKeylet =
keylet::mptoken(shares.get<MPTIssue>().getMptID(), depositor.id());
BEAST_EXPECT(env.le(tokenKeylet) != nullptr);
}
{
// time advance
env.close();
env.close();
env.close();
auto const credsKeylet = credentials::keylet(depositor, credIssuer2, credType);
BEAST_EXPECT(env.le(credsKeylet) != nullptr);
auto tx2 =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
env(tx2, Ter{tecEXPIRED});
env.close();
BEAST_EXPECT(env.le(credsKeylet) == nullptr);
}
{
auto const credsKeylet = credentials::keylet(charlie, credIssuer2, credType);
BEAST_EXPECT(env.le(credsKeylet) != nullptr);
auto const tokenKeylet =
keylet::mptoken(shares.get<MPTIssue>().getMptID(), charlie.id());
BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
auto tx3 =
vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(2)});
env(tx3, Ter{tecEXPIRED});
env.close();
BEAST_EXPECT(env.le(credsKeylet) == nullptr);
BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
}
}
{
testcase("private vault reset domainId");
auto tx = vault.set({.owner = owner, .id = keylet.key});
tx[sfDomainID] = "0";
env(tx);
env.close();
tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx, Ter{tecNO_AUTH});
env.close();
tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx);
env.close();
tx = vault.clawback(
{.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
env(tx);
tx = vault.clawback(
{.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
env(tx);
env.close();
tx = vault.del({
.owner = owner,
.id = keylet.key,
});
env(tx);
}
}
void
testDomainLossAfterAcquisition()
{
using namespace test::jtx;
testcase("private vault share transfer after depositor loses domain");
// The "Private Vault - Access Control Rules" spec requires that a holder who
// loses Layer 2 (Permissioned Domain membership) after acquiring shares be
// blocked from sending them onward, by P2P transfer or DEX offer, the same
// way a brand-new never-authorized holder is blocked. Only withdrawal to
// self is meant to stay open.
//
// For a domain-gated share MPToken, requireAuth()'s escape hatch for
// holders who already have an MPToken (MPTokenHelpers.cpp) only applies to
// the classic explicit-issuer-authorization flag, which
// enforceMPTokenAuthorization documents as "meaningless" for
// domain-authorized holders and never sets. So a stale MPToken does not
// carry authorization forward once the account's domain credential is
// gone, and both actions below are correctly blocked.
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
Account const bob{"bob"};
Account const pdOwner{"pdOwner"};
Account const credIssuer{"credIssuer"};
std::string const credType = "credential";
Vault const vault{env};
env.fund(XRP(1000), issuer, owner, depositor, bob, pdOwner, credIssuer);
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1000), owner);
env(pay(issuer, owner, asset(500)));
env.trust(asset(1000), depositor);
env(pay(issuer, depositor, asset(500)));
env.trust(asset(1000), bob);
env(pay(issuer, bob, asset(500)));
env.close();
// Transferable shares (no tfVaultShareNonTransferable): sections 3.3/3.4 of
// the spec (DEX trading / P2P transfer) only apply to transferable shares.
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
env(tx);
env.close();
pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
env(pdomain::setTx(pdOwner, credentials));
auto const domainId = [&]() {
auto tx = env.tx()->getJson(JsonOptions::Values::None);
return pdomain::getNewDomain(env.meta());
}();
{
auto domainTx = vault.set({.owner = owner, .id = keylet.key});
domainTx[sfDomainID] = to_string(domainId);
env(domainTx);
env.close();
}
// Both depositor and bob acquire domain membership and deposit, so each
// ends up with an authorized share MPToken.
env(credentials::create(depositor, credIssuer, credType));
env(credentials::accept(depositor, credIssuer, credType));
env(credentials::create(bob, credIssuer, credType));
env(credentials::accept(bob, credIssuer, credType));
env.close();
env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
env.close();
auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
auto const sle = env.le(keylet);
BEAST_EXPECT(sle != nullptr);
return MPTIssue(sle->at(sfShareMPTID));
}();
// Depositor loses Layer 2: their Permissioned Domain credential is revoked.
auto const credKeylet = credentials::keylet(depositor, credIssuer, credType);
env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType));
env.close();
BEAST_EXPECT(env.le(credKeylet) == nullptr);
// Sanity check, mirrors testWithDomainCheck's "not authorized yet" case: a
// brand-new depositor with no MPToken yet is still correctly blocked. The
// gap below is specific to holders who already hold shares.
{
Account const charlie{"charlie"};
env.fund(XRP(1000), charlie);
env.close();
auto depTx =
vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(1)});
env(depTx, Ter{tecNO_AUTH});
}
// P2P transfer: spec section 3.4 requires this blocked once Layer 2 is
// lost, and it is.
env(pay(depositor, bob, shares(1)), Ter{tecNO_AUTH});
env.close();
// DEX/CLOB: spec section 3.3 requires the seller leg blocked the same way.
// The offer can't even be created: preclaim treats the seller as
// unfunded once their share balance reads as zero for auth purposes.
env(offer(depositor, XRP(1), shares(1)), Ter{tecUNFUNDED_OFFER});
env.close();
BEAST_EXPECT(expectOffers(env, depositor, 0));
}
void
testDomainCheckBuyerSideOffer()
{
using namespace test::jtx;
testcase("private vault share purchase via DEX requires buyer domain membership");
// The "Private Vault - Access Control Rules" spec requires the buyer leg
// of a DEX trade in private-vault shares to hold Layer 1 and Layer 2 as
// well, not just the seller.
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const bob{"bob"};
Account const charlie{"charlie"};
Account const pdOwner{"pdOwner"};
Account const credIssuer{"credIssuer"};
std::string const credType = "credential";
Vault const vault{env};
env.fund(XRP(1000), issuer, owner, bob, charlie, pdOwner, credIssuer);
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1000), owner);
env(pay(issuer, owner, asset(500)));
env.trust(asset(1000), bob);
env(pay(issuer, bob, asset(500)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
env(tx);
env.close();
pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
env(pdomain::setTx(pdOwner, credentials));
auto const domainId = [&]() {
auto tx = env.tx()->getJson(JsonOptions::Values::None);
return pdomain::getNewDomain(env.meta());
}();
{
auto domainTx = vault.set({.owner = owner, .id = keylet.key});
domainTx[sfDomainID] = to_string(domainId);
env(domainTx);
env.close();
}
// Only bob joins the domain and deposits; charlie never does.
env(credentials::create(bob, credIssuer, credType));
env(credentials::accept(bob, credIssuer, credType));
env.close();
env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
env.close();
auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
auto const sle = env.le(keylet);
BEAST_EXPECT(sle != nullptr);
return MPTIssue(sle->at(sfShareMPTID));
}();
// Bob (domain member, holds shares) rests a sell offer.
env(offer(bob, XRP(1), shares(1)));
env.close();
BEAST_EXPECT(expectOffers(env, bob, 1));
// Charlie never held the domain credential. Buying shares via a
// crossing offer must be blocked the same way a direct MPTokenAuthorize
// + pay attempt already is (see testWithDomainChecXRP's "cannot pay
// shares to 3rd party"): checkAcceptAsset() rejects the offer outright
// in preclaim, before any funding check is even reached.
env(offer(charlie, shares(1), XRP(1)), Ter{tecNO_AUTH});
env.close();
BEAST_EXPECT(expectOffers(env, bob, 1));
BEAST_EXPECT(expectOffers(env, charlie, 0));
}
void
testWithDomainChecXRP()
{
using namespace test::jtx;
testcase("private XRP vault");
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Account const depositor{"depositor"};
Account const alice{"charlie"};
std::string const credType = "credential";
Vault const vault{env};
env.fund(XRP(100000), owner, depositor, alice);
env.close();
PrettyAsset const asset = xrpIssue();
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
env(tx);
env.close();
auto const [vaultAccount, issuanceId] =
[&env, keylet = keylet, this]() -> std::tuple<AccountID, uint192> {
auto const vault = env.le(keylet);
BEAST_EXPECT(vault != nullptr);
return {vault->at(sfAccount), vault->at(sfShareMPTID)};
}();
BEAST_EXPECT(env.le(keylet::account(vaultAccount)));
BEAST_EXPECT(env.le(keylet::mptokenIssuance(issuanceId)));
PrettyAsset const shares{issuanceId};
{
testcase("private XRP vault owner can deposit");
auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
env(tx);
env.close();
}
{
testcase("private XRP vault cannot pay shares to depositor yet");
env(pay(owner, depositor, shares(1)), Ter{tecNO_AUTH});
}
{
testcase("private XRP vault depositor not authorized yet");
auto tx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx, Ter{tecNO_AUTH});
}
{
testcase("private XRP vault set DomainID");
pdomain::Credentials const credentials{{.issuer = owner, .credType = credType}};
env(pdomain::setTx(owner, credentials));
auto const domainId = [&]() {
auto tx = env.tx()->getJson(JsonOptions::Values::None);
return pdomain::getNewDomain(env.meta());
}();
auto tx = vault.set({.owner = owner, .id = keylet.key});
tx[sfDomainID] = to_string(domainId);
env(tx);
env.close();
}
auto const credKeylet = credentials::keylet(depositor, owner, credType);
{
testcase("private XRP vault depositor now authorized");
env(credentials::create(depositor, owner, credType));
env(credentials::accept(depositor, owner, credType));
env.close();
BEAST_EXPECT(env.le(credKeylet));
auto tx =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
env(tx);
env.close();
}
{
testcase("private XRP vault can pay shares to depositor");
env(pay(owner, depositor, shares(1)));
}
{
testcase("private XRP vault cannot pay shares to 3rd party");
json::Value jv;
jv[sfAccount] = alice.human();
jv[sfTransactionType] = jss::MPTokenAuthorize;
jv[sfMPTokenIssuanceID] = to_string(issuanceId);
env(jv);
env.close();
env(pay(owner, alice, shares(1)), Ter{tecNO_AUTH});
}
}
public:
void
run() override
{
testWithDomainCheck();
testDomainLossAfterAcquisition();
testDomainCheckBuyerSideOffer();
testWithDomainChecXRP();
}
};
BEAST_DEFINE_TESTSUITE(VaultDomain, app, xrpl);
} // namespace xrpl

View File

@@ -0,0 +1,691 @@
#include <test/app/vault/VaultTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/jss.h>
#include <string>
#include <tuple>
#include <utility>
namespace xrpl {
class VaultFreeze_test : public VaultTestBase
{
private:
void
testVaultDepositFreezeIOU()
{
using namespace test::jtx;
testcase("VaultDeposit IOU freeze checks");
Account const issuer{"issuer"};
Account const owner{"owner"};
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, owner);
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1'000'000), owner);
env(pay(issuer, owner, asset(100'000)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
// Initial deposit so the vault pseudo-account has a trustline
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
env.close();
auto runTests = [&]() {
auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
// Global freeze
{
testcase("VaultDeposit IOU global freeze");
env(fset(issuer, asfGlobalFreeze));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(tecFROZEN));
env(fclear(issuer, asfGlobalFreeze));
}
// Depositor freeze
{
testcase("VaultDeposit IOU depositor freeze");
env(trust(issuer, asset(0), owner, tfSetFreeze));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(tecFROZEN));
env(trust(issuer, asset(0), owner, tfClearFreeze));
}
// Depositor deep freeze
{
testcase("VaultDeposit IOU depositor deep freeze");
env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(tecFROZEN));
env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
}
// Vault-account freeze
// Post-fix: checkDepositFreeze catches it → tecFROZEN
// Pre-fix: not checked directly, but the transitive share
// check triggers → tecLOCKED
{
testcase("VaultDeposit IOU pseudo-account freeze");
auto trustSet = [&]() {
json::Value jv;
jv[jss::Account] = issuer.human();
{
auto& ja = jv[jss::LimitAmount] =
asset(0).value().getJson(JsonOptions::Values::None);
ja[jss::issuer] = toBase58(vaultAcct.id());
}
jv[jss::TransactionType] = jss::TrustSet;
return jv;
}();
trustSet[jss::Flags] = tfSetFreeze;
env(trustSet);
env.close();
TER const expected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(expected));
trustSet[jss::Flags] = tfClearFreeze;
env(trustSet);
env.close();
}
// Vault-account deep freeze
{
testcase("VaultDeposit IOU pseudo-account deep freeze");
auto trustSet = [&]() {
json::Value jv;
jv[jss::Account] = issuer.human();
{
auto& ja = jv[jss::LimitAmount] =
asset(0).value().getJson(JsonOptions::Values::None);
ja[jss::issuer] = toBase58(vaultAcct.id());
}
jv[jss::TransactionType] = jss::TrustSet;
return jv;
}();
trustSet[jss::Flags] = tfSetFreeze | tfSetDeepFreeze;
env(trustSet);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
trustSet[jss::Flags] = tfClearFreeze | tfClearDeepFreeze;
env(trustSet);
env.close();
}
// Clawback works while frozen
{
testcase("VaultDeposit IOU freeze clawback unaffected");
env(fset(issuer, asfGlobalFreeze));
env(vault.clawback(
{.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
env(fclear(issuer, asfGlobalFreeze));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
env.close();
}
};
runTests();
env.disableFeature(fixCleanup3_3_0);
runTests();
env.enableFeature(fixCleanup3_3_0);
}
void
testVaultDepositFreezeMPT()
{
using namespace test::jtx;
testcase("VaultDeposit MPT lock checks");
Account const issuer{"issuer"};
Account const owner{"owner"};
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, owner);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create(
{.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
PrettyAsset const mpt{mptt.issuanceID()};
mptt.authorize({.account = owner});
mptt.authorize({.account = issuer, .holder = owner});
env.close();
env(pay(issuer, owner, mpt(100'000)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
env(tx);
env.close();
auto const vaultAcctID = env.le(keylet)->at(sfAccount);
Account const vaultAcct("vault", vaultAcctID);
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
env.close();
// For MPT isDeepFrozen == isFrozen, so all locks block in
// both pre- and post-fix.
auto runTests = [&]() {
// Global lock
{
testcase("VaultDeposit MPT global lock");
mptt.set({.flags = tfMPTLock});
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
Ter(tecLOCKED));
mptt.set({.flags = tfMPTUnlock});
env.close();
}
// Depositor individual lock
{
testcase("VaultDeposit MPT depositor lock");
mptt.set({.holder = owner, .flags = tfMPTLock});
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
Ter(tecLOCKED));
mptt.set({.holder = owner, .flags = tfMPTUnlock});
env.close();
}
// Vault pseudo-account individual lock
{
testcase("VaultDeposit MPT pseudo-account lock");
mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
Ter(tecLOCKED));
mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
env.close();
}
// Clawback works while locked
{
testcase("VaultDeposit MPT lock clawback unaffected");
mptt.set({.flags = tfMPTLock});
env.close();
env(vault.clawback(
{.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
mptt.set({.flags = tfMPTUnlock});
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
env.close();
}
};
runTests();
env.disableFeature(fixCleanup3_3_0);
runTests();
env.enableFeature(fixCleanup3_3_0);
}
void
testVaultWithdrawFreezeIOU()
{
using namespace test::jtx;
testcase("VaultWithdraw IOU freeze checks");
Account const issuer{"issuer"};
Account const owner{"owner"};
Env env{*this};
Vault const vault{env};
env.fund(XRP(100'000), issuer, owner);
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1'000'000), owner);
env(pay(issuer, owner, asset(100'000)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
env.close();
Account const charlie{"charlie"};
env.fund(XRP(10'000), charlie);
env.trust(asset(1'000'000), charlie);
env.close();
auto runTests = [&]() {
auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
// Global freeze → self-withdraw
{
testcase("VaultWithdraw IOU global freeze");
env(fset(issuer, asfGlobalFreeze));
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(tecFROZEN));
// Global freeze → withdraw to 3rd party
auto withdrawToCharlie =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
withdrawToCharlie[sfDestination] = charlie.human();
env(withdrawToCharlie, Ter(tecFROZEN));
env(fclear(issuer, asfGlobalFreeze));
}
// Vault-account freeze
{
testcase("VaultWithdraw IOU pseudo-account freeze");
auto trustSet = [&]() {
json::Value jv;
jv[jss::Account] = issuer.human();
{
auto& ja = jv[jss::LimitAmount] =
asset(0).value().getJson(JsonOptions::Values::None);
ja[jss::issuer] = toBase58(vaultAcct.id());
}
jv[jss::TransactionType] = jss::TrustSet;
return jv;
}();
trustSet[jss::Flags] = tfSetFreeze;
env(trustSet);
env.close();
TER const terExpected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
// Self-withdraw
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(terExpected));
// Withdraw to 3rd party
auto withdrawToCharlie =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
withdrawToCharlie[sfDestination] = charlie.human();
env(withdrawToCharlie, Ter(terExpected));
trustSet[jss::Flags] = tfClearFreeze;
env(trustSet);
env.close();
}
// Depositor freeze, self-withdraw
{
testcase("VaultWithdraw IOU self-withdraw freeze check");
env(trust(issuer, asset(0), owner, tfSetFreeze));
// Post-fix: self-withdraw allowed (submitter==dst skip)
// Pre-fix: isFrozen(depositor, iou) catches it
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
// Depositor freeze withdraw to 3rd party
auto withdrawTo3rd =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
withdrawTo3rd[sfDestination] = charlie.human();
// Post-fix: submitter freeze blocks withdraw to 3rd party
// Pre-fix: submitter's IOU freeze not checked, but checkFrozen(depositor,
// share) triggers tecLOCKED
env(withdrawTo3rd, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
env(trust(issuer, asset(0), owner, tfClearFreeze));
// Replenish what was withdrawn
if (fix330Enabled)
{
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
}
env.close();
}
// Depositor deep freeze → self-withdraw blocked
{
testcase("VaultWithdraw IOU depositor deep freeze");
env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(tecFROZEN));
env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
}
// Destination freeze → withdraw to 3rd party
{
testcase("VaultWithdraw IOU freeze withdraw to 3rd party");
env(trust(issuer, asset(0), charlie, tfSetFreeze));
// Self-withdraw unaffected by charlie's freeze
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
auto withdrawToCharlie =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
withdrawToCharlie[sfDestination] = charlie.human();
// Post-fix: freeze on dst allowed
// Pre-fix: checkFrozen(dst, iou) catches it
env(withdrawToCharlie, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
env(trust(issuer, asset(0), charlie, tfClearFreeze));
// Replenish: 1 for self-withdraw + 1 if charlie withdraw succeeded
env(vault.deposit(
{.depositor = owner,
.id = keylet.key,
.amount = asset(fix330Enabled ? 2 : 1)}));
env.close();
}
// Destination deep freeze → withdraw to 3rd party blocked
{
testcase("VaultWithdraw IOU deep freeze withdraw to 3rd party");
env(trust(issuer, asset(0), charlie, tfSetFreeze | tfSetDeepFreeze));
auto withdrawToCharlie =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
withdrawToCharlie[sfDestination] = charlie.human();
env(withdrawToCharlie, Ter(tecFROZEN));
// Destination deep freeze → self-withdraw unaffected
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
env(trust(issuer, asset(0), charlie, tfClearFreeze | tfClearDeepFreeze));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
env.close();
}
// Clawback works while frozen
{
testcase("VaultWithdraw IOU freeze clawback unaffected");
env(fset(issuer, asfGlobalFreeze));
env(vault.clawback(
{.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
env(fclear(issuer, asfGlobalFreeze));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
env.close();
}
};
runTests();
env.disableFeature(fixCleanup3_3_0);
runTests();
env.enableFeature(fixCleanup3_3_0);
}
void
testVaultWithdrawFreezeMPT()
{
using namespace test::jtx;
testcase("VaultWithdraw MPT lock checks");
Account const issuer{"issuer"};
Account const owner{"owner"};
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, owner);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create(
{.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
PrettyAsset const mpt{mptt.issuanceID()};
mptt.authorize({.account = owner});
mptt.authorize({.account = issuer, .holder = owner});
env.close();
env(pay(issuer, owner, mpt(100'000)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
env(tx);
env.close();
Account const vaultAcct("vault", env.le(keylet)->at(sfAccount));
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
env.close();
Account const charlie{"charlie"};
env.fund(XRP(10'000), charlie);
env.close();
mptt.authorize({.account = charlie});
mptt.authorize({.account = issuer, .holder = charlie});
env.close();
auto runTests = [&]() {
auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
// Global lock
{
testcase("VaultWithdraw MPT global lock");
mptt.set({.flags = tfMPTLock});
env.close();
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
Ter(tecLOCKED));
// Global lock → withdraw to issuer
// Post-fix: bypasses freeze checks, but accountHolds
// on the pseudo returns 0 under global lock
// Pre-fix: checkFrozen(dst=issuer) catches global lock
{
auto withdrawToIssuer =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
withdrawToIssuer[sfDestination] = issuer.human();
env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
}
mptt.set({.flags = tfMPTUnlock});
env.close();
if (fix330Enabled)
{
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
}
env.close();
}
// Vault pseudo-account individual lock
{
testcase("VaultWithdraw MPT pseudo-account lock");
mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
env.close();
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
Ter(tecLOCKED));
mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
env.close();
}
// Depositor individual lock → self-withdraw blocked
// (isDeepFrozen == isFrozen for MPT)
{
testcase("VaultWithdraw MPT depositor lock");
mptt.set({.holder = owner, .flags = tfMPTLock});
env.close();
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
Ter(tecLOCKED));
// Depositor lock → withdraw to 3rd party also blocked
{
auto withdrawToCharlie =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
withdrawToCharlie[sfDestination] = charlie.human();
env(withdrawToCharlie, Ter(tecLOCKED));
}
// Depositor lock → withdraw to issuer
// Post-fix: issuer bypass in checkWithdrawFreezes
// Pre-fix: checkFrozen(depositor, share) blocks transitively
{
auto withdrawToIssuer =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
withdrawToIssuer[sfDestination] = issuer.human();
env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
}
mptt.set({.holder = owner, .flags = tfMPTUnlock});
env.close();
if (fix330Enabled)
{
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
}
env.close();
}
// 3rd party destination lock → withdraw to 3rd party blocked
{
testcase("VaultWithdraw MPT 3rd party destination lock");
mptt.set({.holder = charlie, .flags = tfMPTLock});
env.close();
{
auto withdrawToCharlie =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
withdrawToCharlie[sfDestination] = charlie.human();
env(withdrawToCharlie, Ter{tecLOCKED});
}
// 3rd party lock → self-withdraw unaffected
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
mptt.set({.holder = charlie, .flags = tfMPTUnlock});
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
env.close();
}
// Clawback works while locked
{
testcase("VaultWithdraw MPT lock clawback unaffected");
mptt.set({.flags = tfMPTLock});
env.close();
env(vault.clawback(
{.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
mptt.set({.flags = tfMPTUnlock});
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
env.close();
}
};
runTests();
env.disableFeature(fixCleanup3_3_0);
runTests();
env.enableFeature(fixCleanup3_3_0);
}
// Focused demonstration: a depositor under an individual IOU freeze
// can still withdraw to themselves (self-withdrawal), but is blocked from
// withdrawing to a third party.
//
// Pre-fixCleanup3_3_0: both the self-withdrawal AND the third-party
// withdrawal were blocked because the old code checked checkFrozen on the
// destination regardless of whether it was the submitter.
// Post-fixCleanup3_3_0: checkWithdrawFreeze skips the submitter freeze
// check when submitter == destination, so self-withdrawal succeeds.
void
testVaultSelfWithdrawWhileFrozen()
{
testcase("VaultWithdraw IOU self-withdrawal while individually frozen");
using namespace test::jtx;
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const charlie{"charlie"};
Env env{*this};
Vault vault{env};
env.fund(XRP(100'000), issuer, owner, charlie);
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1'000'000), owner);
env.trust(asset(1'000'000), charlie);
env(pay(issuer, owner, asset(100'000)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}));
env.close();
auto runTests = [&]() {
auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
// Set an individual freeze on the owner's IOU trustline.
env(trust(issuer, asset(0), owner, tfSetFreeze));
env.close();
// Self-withdrawal: submitter == destination, so the submitter
// freeze check is skipped.
// Post-fix: tesSUCCESS. Pre-fix: tecFROZEN.
env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
// Withdrawal to a third party is blocked: submitter != destination
// so the submitter freeze check applies.
{
auto withdrawToCharlie =
vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
withdrawToCharlie[sfDestination] = charlie.human();
// Post-fix: tecFROZEN (checkIndividualFrozen on submitter).
// Pre-fix: tecLOCKED (isFrozen on the vault share).
env(withdrawToCharlie, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
}
env(trust(issuer, asset(0), owner, tfClearFreeze));
env.close();
};
runTests();
env.disableFeature(fixCleanup3_3_0);
runTests();
env.enableFeature(fixCleanup3_3_0);
}
public:
void
run() override
{
testVaultDepositFreezeIOU();
testVaultDepositFreezeMPT();
testVaultWithdrawFreezeIOU();
testVaultWithdrawFreezeMPT();
testVaultSelfWithdrawWhileFrozen();
}
};
BEAST_DEFINE_TESTSUITE(VaultFreeze, app, xrpl);
} // namespace xrpl

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,543 @@
#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/vault.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/jss.h>
#include <chrono>
#include <memory>
#include <string>
#include <tuple>
#include <utility>
namespace xrpl {
class VaultRPC_test : public VaultTestBase
{
private:
void
testRPC()
{
using namespace test::jtx;
testcase("RPC");
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Account const issuer{"issuer"};
Vault const vault{env};
env.fund(XRP(1000), issuer, owner);
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1000), owner);
env(pay(issuer, owner, asset(200)));
env.close();
auto const sequence = env.seq(owner);
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
// Set some fields
{
auto tx1 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
env(tx1);
auto tx2 = vault.set({.owner = owner, .id = keylet.key});
tx2[sfAssetsMaximum] = asset(1000).number();
env(tx2);
env.close();
}
auto const sleVault = [&env, keylet = keylet, this]() {
auto const vault = env.le(keylet);
BEAST_EXPECT(vault != nullptr);
return vault;
}();
auto const check = [&, keylet = keylet, sle = sleVault, this](
json::Value const& vault,
json::Value const& issuance = json::ValueType::Null) {
BEAST_EXPECT(vault.isObject());
static constexpr auto kCheckString =
[](auto& node, SField const& field, std::string v) -> bool {
return node.isMember(field.fieldName) && node[field.fieldName].isString() &&
node[field.fieldName] == v;
};
static constexpr auto kCheckObject =
[](auto& node, SField const& field, json::Value v) -> bool {
return node.isMember(field.fieldName) && node[field.fieldName].isObject() &&
node[field.fieldName] == v;
};
static constexpr auto kCheckInt = [](auto& node, SField const& field, int v) -> bool {
return node.isMember(field.fieldName) &&
((node[field.fieldName].isInt() && node[field.fieldName] == json::Int(v)) ||
(node[field.fieldName].isUInt() && node[field.fieldName] == json::UInt(v)));
};
BEAST_EXPECT(vault["LedgerEntryType"].asString() == "Vault");
BEAST_EXPECT(vault[jss::index].asString() == strHex(keylet.key));
BEAST_EXPECT(kCheckInt(vault, sfFlags, 0));
// Ignore all other standard fields, this test doesn't care
BEAST_EXPECT(kCheckString(vault, sfAccount, toBase58(sle->at(sfAccount))));
BEAST_EXPECT(kCheckObject(vault, sfAsset, toJson(sle->at(sfAsset))));
BEAST_EXPECT(kCheckString(vault, sfAssetsAvailable, "50"));
BEAST_EXPECT(kCheckString(vault, sfAssetsMaximum, "1000"));
BEAST_EXPECT(kCheckString(vault, sfAssetsTotal, "50"));
BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName()));
auto const strShareID = strHex(sle->at(sfShareMPTID));
BEAST_EXPECT(kCheckString(vault, sfShareMPTID, strShareID));
BEAST_EXPECT(kCheckString(vault, sfOwner, toBase58(owner.id())));
BEAST_EXPECT(kCheckInt(vault, sfSequence, sequence));
BEAST_EXPECT(kCheckInt(vault, sfWithdrawalPolicy, kVaultStrategyFirstComeFirstServe));
if (issuance.isObject())
{
BEAST_EXPECT(issuance["LedgerEntryType"].asString() == "MPTokenIssuance");
BEAST_EXPECT(issuance[jss::mpt_issuance_id].asString() == strShareID);
BEAST_EXPECT(kCheckInt(issuance, sfSequence, 1));
BEAST_EXPECT(kCheckInt(
issuance, sfFlags, int(lsfMPTCanEscrow | lsfMPTCanTrade | lsfMPTCanTransfer)));
BEAST_EXPECT(kCheckString(issuance, sfOutstandingAmount, "50000000"));
}
};
{
testcase("RPC ledger_entry selected by key");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault] = strHex(keylet.key);
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
check(jvVault[jss::result][jss::node]);
}
{
testcase("RPC ledger_entry selected by owner and seq");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault][jss::owner] = owner.human();
jvParams[jss::vault][jss::seq] = sequence;
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
check(jvVault[jss::result][jss::node]);
}
{
testcase("RPC ledger_entry cannot find vault by key");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault] = to_string(uint256(42));
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
}
{
testcase("RPC ledger_entry cannot find vault by owner and seq");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault][jss::owner] = issuer.human();
jvParams[jss::vault][jss::seq] = 1'000'000;
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
}
{
testcase("RPC ledger_entry malformed key");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault] = 42;
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC ledger_entry malformed owner");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault][jss::owner] = 42;
jvParams[jss::vault][jss::seq] = sequence;
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedOwner");
}
{
testcase("RPC ledger_entry malformed seq");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault][jss::owner] = issuer.human();
jvParams[jss::vault][jss::seq] = "foo";
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC ledger_entry negative seq");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault][jss::owner] = issuer.human();
jvParams[jss::vault][jss::seq] = -1;
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC ledger_entry oversized seq");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault][jss::owner] = issuer.human();
jvParams[jss::vault][jss::seq] = 1e20;
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC ledger_entry bool seq");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault][jss::owner] = issuer.human();
jvParams[jss::vault][jss::seq] = true;
auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC account_objects");
json::Value jvParams;
jvParams[jss::account] = owner.human();
jvParams[jss::type] = jss::vault;
auto jv = env.rpc("json", "account_objects", to_string(jvParams))[jss::result];
BEAST_EXPECT(jv[jss::account_objects].size() == 1);
check(jv[jss::account_objects][0u]);
}
{
testcase("RPC ledger_data");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::binary] = false;
jvParams[jss::type] = jss::vault;
json::Value jv = env.rpc("json", "ledger_data", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::state].size() == 1);
check(jv[jss::result][jss::state][0u]);
}
{
testcase("RPC vault_info command line");
json::Value jv = env.rpc("vault_info", strHex(keylet.key), "validated");
BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
}
{
testcase("RPC vault_info json");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault_id] = strHex(keylet.key);
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
}
{
testcase("RPC vault_info invalid vault_id");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault_id] = "foobar";
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json invalid index");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault_id] = 0;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json by owner and sequence");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = owner.human();
jvParams[jss::seq] = sequence;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
}
{
testcase("RPC vault_info json malformed sequence");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = owner.human();
jvParams[jss::seq] = "foobar";
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json invalid sequence");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = owner.human();
jvParams[jss::seq] = 0;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json negative sequence");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = owner.human();
jvParams[jss::seq] = -1;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json oversized sequence");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = owner.human();
jvParams[jss::seq] = 1e20;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json bool sequence");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = owner.human();
jvParams[jss::seq] = true;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json malformed owner");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = "foobar";
jvParams[jss::seq] = sequence;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json invalid combination only owner");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::owner] = owner.human();
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json invalid combination only seq");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::seq] = sequence;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json invalid combination seq vault_id");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault_id] = strHex(keylet.key);
jvParams[jss::seq] = sequence;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json invalid combination owner vault_id");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault_id] = strHex(keylet.key);
jvParams[jss::owner] = owner.human();
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase(
"RPC vault_info json invalid combination owner seq "
"vault_id");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault_id] = strHex(keylet.key);
jvParams[jss::seq] = sequence;
jvParams[jss::owner] = owner.human();
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info json no input");
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info command line invalid index");
json::Value jv = env.rpc("vault_info", "foobar", "validated");
BEAST_EXPECT(jv[jss::error].asString() == "invalidParams");
}
{
testcase("RPC vault_info command line invalid index");
json::Value jv = env.rpc("vault_info", "0", "validated");
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
}
{
testcase("RPC vault_info command line invalid index");
json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated");
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound");
}
{
testcase("RPC vault_info command line invalid ledger");
json::Value jv = env.rpc("vault_info", strHex(keylet.key), "0");
BEAST_EXPECT(jv[jss::result][jss::error].asString() == "lgrNotFound");
}
}
// RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate
// in both vault_info and ledger_entry responses. Open-ended vaults must not.
void
testRPCClosedEnded()
{
using namespace test::jtx;
testcase("RPC closed-ended vault fields");
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Account const owner2{"owner2"};
env.fund(XRP(1000), owner, owner2);
env.close();
auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
Asset const asset = xrpIssue();
auto const sub = env.now().time_since_epoch().count() + 60;
auto const red = sub + kMinInvestmentPeriod;
Vault const vault{env};
auto [tx, keylet] = vault.create(
{.owner = owner,
.asset = asset,
.vaultKind = closedEnded,
.subscriptionDate = sub,
.redemptionDate = red});
env(tx);
env.close();
auto [tx2, keylet2] = vault.create({.owner = owner2, .asset = asset});
env(tx2);
env.close();
auto const asUInt = [](json::Value const& jv) -> json::UInt {
return jv.isUInt() ? jv.asUInt() : json::UInt(jv.asInt());
};
auto const checkClosedEnded = [&](json::Value const& v) {
BEAST_EXPECT(v.isObject());
BEAST_EXPECT(v.isMember(sfVaultKind.fieldName));
BEAST_EXPECT(asUInt(v[sfVaultKind.fieldName]) == json::UInt(closedEnded));
BEAST_EXPECT(v.isMember(sfSubscriptionDate.fieldName));
BEAST_EXPECT(asUInt(v[sfSubscriptionDate.fieldName]) == json::UInt(sub));
BEAST_EXPECT(v.isMember(sfRedemptionDate.fieldName));
BEAST_EXPECT(asUInt(v[sfRedemptionDate.fieldName]) == json::UInt(red));
};
auto const checkOpenEnded = [&](json::Value const& v) {
BEAST_EXPECT(v.isObject());
BEAST_EXPECT(!v.isMember(sfVaultKind.fieldName));
BEAST_EXPECT(!v.isMember(sfSubscriptionDate.fieldName));
BEAST_EXPECT(!v.isMember(sfRedemptionDate.fieldName));
};
{
json::Value jvParams;
jvParams[jss::vault_id] = strHex(keylet.key);
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
checkClosedEnded(jv[jss::result][jss::vault]);
}
{
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault] = strHex(keylet.key);
auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
checkClosedEnded(jv[jss::result][jss::node]);
}
{
json::Value jvParams;
jvParams[jss::vault_id] = strHex(keylet2.key);
auto jv = env.rpc("json", "vault_info", to_string(jvParams));
BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
checkOpenEnded(jv[jss::result][jss::vault]);
}
{
json::Value jvParams;
jvParams[jss::ledger_index] = jss::validated;
jvParams[jss::vault] = strHex(keylet2.key);
auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
checkOpenEnded(jv[jss::result][jss::node]);
}
}
public:
void
run() override
{
testRPC();
testRPCClosedEnded();
}
};
BEAST_DEFINE_TESTSUITE(VaultRPC, app, xrpl);
} // namespace xrpl

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,736 @@
#include <test/app/vault/VaultTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/escrow.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
#include <test/jtx/noop.h>
#include <test/jtx/pay.h>
#include <test/jtx/seq.h>
#include <test/jtx/sig.h>
#include <test/jtx/tags.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/protocol/jss.h>
#include <optional>
#include <string>
#include <tuple>
#include <utility>
namespace xrpl {
class VaultShares_test : public VaultTestBase
{
private:
void
testNonTransferableShares()
{
using namespace test::jtx;
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(1000), issuer, owner, depositor);
env.close();
Vault const vault{env};
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1000), owner);
env(pay(issuer, owner, asset(100)));
env.trust(asset(1000), depositor);
env(pay(issuer, depositor, asset(100)));
env.close();
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
tx[sfFlags] = tfVaultShareNonTransferable;
env(tx);
env.close();
{
testcase("nontransferable deposits");
auto tx1 =
vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(40)});
env(tx1);
auto tx2 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(60)});
env(tx2);
env.close();
}
auto const vaultAccount = //
[&env, key = keylet.key, this]() -> AccountID {
auto jvVault = env.rpc("vault_info", strHex(key));
BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "100");
BEAST_EXPECT(
jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "100000000");
// Vault pseudo-account
return parseBase58<AccountID>(jvVault[jss::result][jss::vault][jss::Account].asString())
.value();
}();
auto const mptId = makeMptID(1, vaultAccount);
Asset const shares = mptId;
{
testcase("nontransferable shares cannot be moved");
env(pay(owner, depositor, shares(10)), Ter{tecNO_AUTH});
env(pay(depositor, owner, shares(10)), Ter{tecNO_AUTH});
}
{
testcase("nontransferable shares can be used to withdraw");
auto tx1 =
vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
env(tx1);
auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
env(tx2);
env.close();
}
{
testcase("nontransferable shares balance check");
auto jvVault = env.rpc("vault_info", strHex(keylet.key));
BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "50");
BEAST_EXPECT(
jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "50000000");
}
{
testcase("nontransferable shares withdraw rest");
auto tx1 =
vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
env(tx1);
auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
env(tx2);
env.close();
}
{
testcase("nontransferable shares delete empty vault");
auto tx = vault.del({.owner = owner, .id = keylet.key});
env(tx);
BEAST_EXPECT(!env.le(keylet));
}
}
void
testFailedPseudoAccount()
{
using namespace test::jtx;
testcase("fail pseudo-account allocation");
Env env{*this, testableAmendments()};
Account const owner{"owner"};
Vault const vault{env};
env.fund(XRP(1000), owner);
auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
for (int i = 0; i < 256; ++i)
{
AccountID const accountId = xrpl::pseudoAccountAddress(*env.current(), keylet.key);
env(pay(env.master.id(), accountId, XRP(1000)),
Seq(kAutofill),
Fee(kAutofill),
Sig(kAutofill));
}
auto [tx, keylet1] = vault.create({.owner = owner, .asset = xrpIssue()});
BEAST_EXPECT(keylet.key == keylet1.key);
env(tx, Ter{terADDRESS_COLLISION});
}
void
testRemoveEmptyHoldingLockedAmount()
{
testcase("removeEmptyHolding deletes MPToken with sfLockedAmount");
using namespace test::jtx;
using namespace std::literals;
auto const amendments = testableAmendments();
auto runTest = [&](FeatureBitset f) {
Env env{*this, f};
auto const baseFee = env.current()->fees().base;
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
Account const bob{"bob"};
env.fund(XRP(100000), issuer, owner, depositor, bob);
env.close();
Vault const vault{env};
// Create an MPT asset for the vault
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
mptt.authorize({.account = depositor});
env(pay(issuer, depositor, asset(1000)));
env.close();
// Create vault
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
auto const vaultSle = env.le(keylet);
BEAST_EXPECT(vaultSle != nullptr);
auto const shareMptID = vaultSle->at(sfShareMPTID);
MPTIssue const shareIssue{shareMptID};
// Depositor deposits 1000 asset units into vault, receiving shares
env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}));
env.close();
// Check depositor has shares
{
auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
BEAST_EXPECT(sleMpt != nullptr);
BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 1000);
}
// Escrow 500 of those shares
env(escrow::create(depositor, bob, STAmount{shareIssue, 500}),
escrow::kCondition(escrow::kCb1),
escrow::kFinishTime(env.now() + 1s),
Fee(baseFee * 150),
Ter(tesSUCCESS));
env.close();
// Verify: sfMPTAmount=500, sfLockedAmount=500
{
auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
BEAST_EXPECT(sleMpt != nullptr);
BEAST_EXPECT(sleMpt->at(sfLockedAmount) == 500);
BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 500);
}
// Withdraw remaining spendable shares — triggers removeEmptyHolding
env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(500)}),
Ter(tesSUCCESS));
env.close();
auto const sleMptAfter = env.le(keylet::mptoken(shareMptID, depositor));
if (!f[fixCleanup3_1_3])
{
// Without the fix, removeEmptyHolding deletes the MPToken
// even though sfLockedAmount > 0, leaving the escrow's locked
// amount untracked.
BEAST_EXPECT(sleMptAfter == nullptr);
}
else
{
// With the fix, MPToken must still exist with sfLockedAmount > 0
// and sfMPTAmount == 0 (all spendable shares withdrawn).
BEAST_EXPECT(sleMptAfter != nullptr);
if (sleMptAfter)
{
BEAST_EXPECT(sleMptAfter->at(sfLockedAmount) == 500);
BEAST_EXPECT(sleMptAfter->at(sfMPTAmount) == 0);
}
}
};
runTest(amendments - fixCleanup3_1_3);
runTest(amendments);
}
void
testRemoveEmptyHoldingConfidentialBalances()
{
testcase("removeEmptyHolding keeps MPToken with confidential balances");
using namespace test::jtx;
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const holder{"holder"};
MPTTester mpt{env, issuer, {.holders = {holder}}};
mpt.create({.authorize = MPTCreate::allHolders});
auto const tokenKeylet = keylet::mptoken(mpt.issuanceID(), holder.id());
auto const encryptedBalanceFields = {
&sfConfidentialBalanceInbox,
&sfConfidentialBalanceSpending,
&sfIssuerEncryptedBalance,
&sfAuditorEncryptedBalance};
env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
for (auto const field : encryptedBalanceFields)
{
Sandbox sb(&view, TapNone);
auto const token = sb.peek(tokenKeylet);
if (!BEAST_EXPECT(token))
return false;
token->setFieldVL(*field, gMakeZeroBuffer(kEcGamalEncryptedTotalLength));
sb.update(token);
auto const dummyTx = *env.jt(noop(holder)).stx;
BEAST_EXPECT(
removeEmptyHolding({sb, dummyTx}, holder.id(), MPTIssue(mpt.issuanceID()), j) ==
tecHAS_OBLIGATIONS);
BEAST_EXPECT(sb.peek(tokenKeylet) != nullptr);
}
return true;
});
}
void
testReferenceHolding()
{
using namespace test::jtx;
auto readReferenceHolding = [&](Env const& env,
Keylet const& vaultKeylet) -> std::optional<uint256> {
auto const sleVault = env.le(vaultKeylet);
if (!sleVault)
return std::nullopt;
auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
return std::nullopt;
return sleIssuance->getFieldH256(sfReferenceHolding);
};
// Post-fixCleanup3_2_0: vault share carries sfReferenceHolding
// pointing to the vault pseudo's MPToken (for MPT-backed vaults)
// or RippleState (for IOU-backed vaults).
{
testcase("sfReferenceHolding: MPT-backed vault, post-amendment");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(10'000), issuer, owner);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
auto const sleVault = env.le(keylet);
BEAST_EXPECT(sleVault != nullptr);
auto const pseudoId = sleVault->at(sfAccount);
auto const expected = keylet::mptoken(mptt.issuanceID(), pseudoId).key;
auto const stored = readReferenceHolding(env, keylet);
BEAST_EXPECT(stored.has_value());
BEAST_EXPECT(stored && *stored == expected);
// The pointed-to MPToken must actually exist.
BEAST_EXPECT(env.le(keylet::mptoken(mptt.issuanceID(), pseudoId)) != nullptr);
}
{
testcase("sfReferenceHolding: IOU-backed vault, post-amendment");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(10'000), issuer, owner);
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1'000'000), owner);
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
auto const sleVault = env.le(keylet);
BEAST_EXPECT(sleVault != nullptr);
auto const pseudoId = sleVault->at(sfAccount);
auto const expected = keylet::trustLine(pseudoId, asset.raw().get<Issue>()).key;
auto const stored = readReferenceHolding(env, keylet);
BEAST_EXPECT(stored.has_value());
BEAST_EXPECT(stored && *stored == expected);
// The pointed-to RippleState must actually exist.
BEAST_EXPECT(env.le(keylet::trustLine(pseudoId, asset.raw().get<Issue>())) != nullptr);
}
// XRP-backed vaults leave the field absent: XRP has no separate
// holding ledger entry and no transferability concept to inherit.
{
testcase("sfReferenceHolding: XRP-backed vault, field absent");
Env env{*this, testableAmendments()};
Account const owner{"owner"};
env.fund(XRP(10'000), owner);
env.close();
PrettyAsset const asset{xrpIssue(), 1'000'000};
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
}
// Pre-fixCleanup3_2_0: vault share has the field absent regardless
// of underlying type.
{
testcase("sfReferenceHolding: vault share, pre-amendment");
Env env{*this, testableAmendments() - fixCleanup3_2_0};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(10'000), issuer, owner);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
}
// Plain MPTokenIssuanceCreate (not a vault share) must never
// populate the field. Only the post-amendment case is
// interesting; pre-amendment nothing writes the field at all.
{
testcase("sfReferenceHolding: plain MPT issuance never set");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
env.fund(XRP(10'000), issuer);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
env.close();
auto const sleIssuance = env.le(keylet::mptokenIssuance(mptt.issuanceID()));
if (BEAST_EXPECT(sleIssuance))
BEAST_EXPECT(!sleIssuance->isFieldPresent(sfReferenceHolding));
}
}
// Probe every transactor surface that might delete the vault pseudo-
// account's underlying holding (the MPToken or RippleState pointed to
// by sfReferenceHolding). Each scenario asserts either that the
// existing pseudo-account guards stop the deletion at preclaim, or
// that the ledger leaves the holding intact afterwards. This is a
// regression guard: if any of these guards regresses, the share's
// sfReferenceHolding pointer would dangle and the new ValidMPTIssuance
// invariant would catch it - but we want to fail much earlier, at
// the transactor's preclaim / doApply, not at invariant time.
void
testHoldingDeletionBlocked()
{
using namespace test::jtx;
// Helper: read the share's referenced holding and confirm the
// pointed-to SLE still exists after the probe.
auto referencedHoldingExists = [&](Env const& env, Keylet const& vaultKeylet) -> bool {
auto const sleVault = env.le(vaultKeylet);
if (!sleVault)
return false;
auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
return false;
auto const holdingKey = sleIssuance->getFieldH256(sfReferenceHolding);
return env.le(keylet::unchecked(holdingKey)) != nullptr;
};
// ---- MPT-backed vault ----------------------------------------
{
testcase("vault pseudo MPToken: Clawback blocked by tecPSEUDO_ACCOUNT");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
mptt.authorize({.account = depositor});
env(pay(issuer, depositor, asset(1'000)));
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
// Issuer attempts to claw back the FULL underlying balance
// (500) directly from the vault pseudo-account. With the
// full amount, the doApply path would drain the pseudo's
// MPToken to zero and removeEmptyHolding would erase it -
// if doApply ever ran. SAV's pseudo-account guard at
// Clawback.cpp:201 refuses at preclaim with
// tecPSEUDO_ACCOUNT before any state change.
env(claw(issuer, asset(500), pseudoAccount), Ter{tecPSEUDO_ACCOUNT});
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
// Sanity: pseudo's full balance is intact.
BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
}
{
testcase("vault pseudo MPToken: Issuer cannot Unauthorize pseudo");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(10'000), issuer, owner);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
mptt.authorize({.account = issuer, .holder = owner});
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
auto const pseudoId = env.le(keylet)->at(sfAccount);
// Issuer attempts MPTokenAuthorize against the pseudo with
// tfMPTUnauthorize. MPTokenAuthorize.cpp blocks pseudo
// accounts via isPseudoAccount; the pseudo's MPToken is
// preserved. Construct the tx manually since the pseudo
// lacks a signing key, and the issuer-driven flavour is
// expressed via sfHolder.
json::Value jv;
jv[sfAccount] = issuer.human();
jv[sfHolder] = toBase58(pseudoId);
jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
jv[sfFlags] = tfMPTUnauthorize;
jv[sfTransactionType] = jss::MPTokenAuthorize;
env(jv, Ter{tecNO_PERMISSION});
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
}
{
testcase("vault pseudo MPToken: MPTokenIssuanceDestroy blocked while vault holds");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const depositor{"depositor"};
env.fund(XRP(10'000), issuer, owner, depositor);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
mptt.authorize({.account = depositor});
env(pay(issuer, depositor, asset(1'000)));
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
// While the vault holds outstanding underlying, the issuer
// cannot destroy the issuance. tecHAS_OBLIGATIONS confirms
// the protection - and as a side effect, the share's
// sfReferenceHolding pointer cannot be left pointing at a
// ghost issuance.
mptt.destroy({.id = mptt.issuanceID(), .err = tecHAS_OBLIGATIONS});
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
}
// ---- IOU-backed vault ----------------------------------------
{
testcase("vault pseudo trust line: Clawback blocked by tecPSEUDO_ACCOUNT");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(10'000), issuer, owner);
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1'000'000), owner);
env(pay(issuer, owner, asset(1'000)));
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
// Issuer attempts to claw back the FULL IOU balance (500)
// directly from the vault pseudo. With the full amount, the
// doApply path would drain the trust line to zero and (if
// both reserve flags clear) trustDelete would erase it - if
// doApply ever ran. The same SAV pseudo-account guard
// refuses at preclaim with tecPSEUDO_ACCOUNT. The amount's
// STAmount issuer field is the holder, per IOU clawback
// convention.
env(claw(issuer, pseudoAccount["IOU"](500)), Ter{tecPSEUDO_ACCOUNT});
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
// Sanity: pseudo's full balance is intact.
BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
}
{
testcase("vault pseudo trust line: TrustSet limit=0 from issuer preserves line");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(10'000), issuer, owner);
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(1'000'000), owner);
env(pay(issuer, owner, asset(1'000)));
env.close();
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
// Issuer submits TrustSet with limit=0 against the vault
// pseudo. The pseudo's side of the line still has the
// original (non-zero) limit and a non-zero balance, so the
// line is preserved - even though the issuer cleared its
// own side. trustDelete only fires when both limits clear
// and the balance is zero.
Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
env(trust(issuer, pseudoAccount["IOU"](0)));
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
}
// ---- Positive control: VaultDelete is the only legitimate path
{
testcase("vault pseudo holding: VaultDelete is the legitimate cleanup path");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
env.fund(XRP(10'000), issuer, owner);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const asset = mptt.issuanceID();
mptt.authorize({.account = owner});
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
BEAST_EXPECT(referencedHoldingExists(env, keylet));
auto const pseudoId = env.le(keylet)->at(sfAccount);
auto const sharedMptId = env.le(keylet)->at(sfShareMPTID);
auto const holdingKeylet = keylet::mptoken(mptt.issuanceID(), pseudoId);
// VaultDelete tears down the vault pseudo's holding, the
// share issuance, and the pseudo-account itself. Invariant
// permits this because the tx is ttVAULT_DELETE.
env(vault.del({.owner = owner, .id = keylet.key}));
env.close();
BEAST_EXPECT(env.le(keylet) == nullptr);
BEAST_EXPECT(env.le(holdingKeylet) == nullptr);
BEAST_EXPECT(env.le(keylet::mptokenIssuance(sharedMptId)) == nullptr);
}
}
public:
void
run() override
{
testNonTransferableShares();
testFailedPseudoAccount();
testRemoveEmptyHoldingLockedAmount();
testRemoveEmptyHoldingConfidentialBalances();
testReferenceHolding();
testHoldingDeletionBlocked();
}
};
BEAST_DEFINE_TESTSUITE(VaultShares, app, xrpl);
} // namespace xrpl

View File

@@ -0,0 +1,655 @@
#include <test/app/vault/VaultTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/CaptureLogs.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/pay.h>
#include <test/jtx/sig.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_forwards.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <tuple>
#include <utility>
namespace xrpl {
class VaultSoleShareholder_test : public VaultTestBase
{
private:
// design doc:
// AssetsAvailable ≈ 3,333.50
// AssetsTotal ≈ 6,666.50 (3,333.50 cash + 3,333 receivable)
// LossUnrealized = 3,333
// OutstandingShares = sharesLender (5e9 at IOU scale 1e6)
struct StuckDepositorFixture
{
test::jtx::Account issuer{"issuer"};
test::jtx::Account lender{"lender"};
test::jtx::Account bob{"bob"};
test::jtx::Account borrower{"borrower"};
std::optional<PrettyAsset> asset;
std::optional<Keylet> vaultKeylet;
uint256 brokerID;
std::optional<Keylet> loanKeylet;
MPTID shareAsset;
std::uint64_t sharesLender = 0;
};
static constexpr std::int64_t kStuckFunding = 1'000'000;
static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
static constexpr std::int64_t kStuckDeposit = 5'000;
static constexpr std::int64_t kStuckPrincipal = 3'333;
static constexpr std::uint32_t kStuckPayInterval = 600;
static constexpr std::uint32_t kStuckPayTotal = 2;
[[nodiscard]] StuckDepositorFixture
setupStuckDepositor(test::jtx::Env& env)
{
using namespace test::jtx;
StuckDepositorFixture f;
f.asset = f.issuer[iouCurrency_];
env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
env.close();
env(trust(f.lender, (*f.asset)(10'000'000)));
env(trust(f.bob, (*f.asset)(10'000'000)));
env(trust(f.borrower, (*f.asset)(10'000'000)));
env.close();
env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
env.close();
// Vault: Lender creates and seeds it; Bob matches the deposit for a
// clean 50/50 split.
Vault const v{env};
auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
env(createTx);
env.close();
if (!BEAST_EXPECT(env.le(vaultKeylet)))
return f;
f.vaultKeylet = vaultKeylet;
env(v.deposit({
.depositor = f.lender,
.id = vaultKeylet.key,
.amount = (*f.asset)(kStuckDeposit),
}),
Ter(tesSUCCESS));
env(v.deposit({
.depositor = f.bob,
.id = vaultKeylet.key,
.amount = (*f.asset)(kStuckDeposit),
}),
Ter(tesSUCCESS));
env.close();
// Loan broker: no cover, no management fee, debt cap 10x principal.
f.brokerID =
keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))).key;
{
using namespace loan_broker;
env(set(f.lender, vaultKeylet.key),
kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
env.close();
}
// Loan: 3,333 USD principal, impaired immediately.
auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
if (!BEAST_EXPECT(sleBroker))
return f;
f.loanKeylet =
keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
{
using namespace loan;
env(set(f.borrower, f.brokerID, kStuckPrincipal),
Sig(sfCounterpartySignature, f.lender),
kPaymentTotal(kStuckPayTotal),
kPaymentInterval(kStuckPayInterval),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
env.close();
}
auto const vaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultSle))
return f;
BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
f.shareAsset = vaultSle->at(sfShareMPTID);
auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
if (!BEAST_EXPECT(tokenBob))
return f;
std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
// Bob (non-sole) exits at the discounted rate. Always succeeds.
STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
env(v.withdraw({
.depositor = f.bob,
.id = vaultKeylet.key,
.amount = bobShareAmt,
}),
Ter(tesSUCCESS));
env.close();
auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
if (!BEAST_EXPECT(tokenLender))
return f;
f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
if (!BEAST_EXPECT(sleIssuance))
return f;
BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
auto const vaultAfterBob = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultAfterBob))
return f;
// After Bob's exit: loss is unchanged (3,333 receivable), and the
// gap between assetsTotal and assetsAvailable equals exactly that
// receivable.
BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
BEAST_EXPECT(
vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
vaultAfterBob->at(sfLossUnrealized));
return f;
}
// Reproduces the worked example from the XLS-0065 design doc. The sole
// remaining shareholder asks (via fixed-asset input) for the vault's
// entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
// invariant violation. Post-fix the full-price exchange rate burns
// only a portion of the shares, the depositor receives all of
// AssetsAvailable, and the residual shares remain backed by the
// impaired-loan receivable.
void
testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
{
using namespace test::jtx;
bool const withFix = features[fixCleanup3_2_0];
testcase(
std::string{"Vault withdraw: sole shareholder exits via "
"fixed-asset amount with impaired loan"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
PrettyAsset const& asset = *f.asset;
auto const vaultBefore = env.le(vaultKey);
if (!BEAST_EXPECT(vaultBefore))
return;
Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
Number const totalBefore = vaultBefore->at(sfAssetsTotal);
Number const lossBefore = vaultBefore->at(sfLossUnrealized);
STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
// The requested amount differs between feature regimes because
// the two regimes are testing different behaviors:
//
// - Pre-fix: request the full AssetsAvailable (3,333.50). Under
// the discounted formula this would burn every outstanding
// share, hitting the zero-sized-vault invariant. The
// transaction is rejected with tecINVARIANT_FAILED — the
// stuck-depositor bug.
//
// - Post-fix: request a strictly smaller amount (1,000 USD).
// The full-price formula burns only ~30% of the outstanding
// shares; the vault retains the rest, backed by the impaired
// receivable. Requesting *exactly* AssetsAvailable post-fix
// would currently fail with tecINSUFFICIENT_FUNDS due to the
// round-to-nearest used by assetsToSharesWithdraw (the
// recomputed payout can overshoot the request by a few ULPs).
// The "force payout to AssetsAvailable" branch in doApply
// only triggers when every share is burned, which is covered
// by the loan-repayment test.
STAmount const requestAssets =
withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
Vault const v{env};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = requestAssets,
}),
Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
env.close();
auto const vaultAfter = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfter))
return;
auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceAfter))
return;
std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
Number const totalAfter = vaultAfter->at(sfAssetsTotal);
Number const lossAfter = vaultAfter->at(sfLossUnrealized);
if (!withFix)
{
// Pre-fix: rejected — vault state unchanged.
BEAST_EXPECT(sharesAfter == f.sharesLender);
BEAST_EXPECT(availableAfter == availableBefore);
BEAST_EXPECT(totalAfter == totalBefore);
BEAST_EXPECT(lossAfter == lossBefore);
return;
}
// Post-fix exact-value derivation (fixture: sharesLender=5e9,
// totalBefore=6666.5, request=1000):
// sharesRedeemed = round(sharesLender * request / totalBefore)
// = round(750,018,750.469) = 750,018,750
// received = totalBefore * sharesRedeemed / sharesLender
// = 999.999999375 (slightly under 1,000 due to
// integer-share rounding)
constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
Number const expectedReceived =
totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
// LossUnrealized is unchanged: the loan-protocol side is untouched.
BEAST_EXPECT(lossAfter == lossBefore);
// The entire (total - available) gap is the impaired receivable,
// i.e. equal to lossUnrealized.
BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
Number const received{lenderBalanceAfter - lenderBalanceBefore};
BEAST_EXPECT(received == expectedReceived);
// Conservation: assets removed from the vault equal what the
// depositor received.
BEAST_EXPECT(totalBefore - totalAfter == received);
BEAST_EXPECT(availableBefore - availableAfter == received);
}
// Sole shareholder attempts to burn ALL outstanding shares via
// fixed-shares input while the vault still holds an impaired
// receivable. Pre-fix this fails with the zero-sized-vault invariant
// violation. Post-fix the full-price rate causes assetsWithdrawn to
// equal assetsTotal, which exceeds assetsAvailable, so the transaction
// is rejected with tecINSUFFICIENT_FUNDS.
void
testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
{
using namespace test::jtx;
bool const withFix = features[fixCleanup3_2_0];
testcase(
std::string{"Vault withdraw: sole shareholder full-shares "
"burn is rejected while loss outstanding"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
std::string logs;
Env env(*this, features, std::make_unique<test::CaptureLogs>(&logs));
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
auto const vaultBefore = env.le(vaultKey);
if (!BEAST_EXPECT(vaultBefore))
return;
Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
Number const totalBefore = vaultBefore->at(sfAssetsTotal);
Number const lossBefore = vaultBefore->at(sfLossUnrealized);
// Fixed-shares input: ask for ALL outstanding shares.
STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
Vault const v{env};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = shareAmt,
}),
Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
env.close();
// Either way the transaction was rejected; vault state unchanged.
auto const vaultAfter = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfter))
return;
auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceAfter))
return;
BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
}
// Clean-state regression: with no impaired loan, a sole shareholder
// burning all their shares fully empties the vault under both the
// pre-fix and post-fix code paths. Confirms the new logic doesn't
// break the existing happy-path close-out.
void
testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
{
using namespace test::jtx;
bool const withFix = features[fixCleanup3_2_0];
testcase(
std::string{"Vault withdraw: sole shareholder clean-state "
"close-out unchanged"} +
(withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
env.fund(XRP(kStuckFunding), issuer, lender);
env.close();
PrettyAsset const asset = issuer[iouCurrency_];
env(trust(lender, asset(10'000'000)));
env.close();
env(pay(issuer, lender, asset(kStuckDepositorIOU)));
env.close();
// Sole shareholder of a clean vault — no loan broker needed.
Vault const v{env};
auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
env(createTx);
env.close();
env(v.deposit({
.depositor = lender,
.id = vaultKeylet.key,
.amount = asset(kStuckDeposit),
}),
Ter(tesSUCCESS));
env.close();
auto const vaultBefore = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultBefore))
return;
auto const shareAsset = vaultBefore->at(sfShareMPTID);
auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
if (!BEAST_EXPECT(tokenLender))
return;
std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
// Sole shareholder, no loans, no loss. Burn everything.
STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
env(v.withdraw({
.depositor = lender,
.id = vaultKeylet.key,
.amount = allShares,
}),
Ter(tesSUCCESS));
env.close();
auto const vaultFinal = env.le(vaultKeylet);
if (!BEAST_EXPECT(vaultFinal))
return;
auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
if (!BEAST_EXPECT(issuanceFinal))
return;
BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
// (Pre-fix path takes the regular code path; post-fix path enters
// the new final-withdrawal guard, which forces payout to exactly
// assetsAvailable. Either way the result is identical for a clean
// vault.)
(void)withFix;
}
// Sole shareholder in an impaired vault redeems a *partial* count of
// shares via fixed-shares input. Pre-fix the discounted formula is
// used; post-fix the full-price formula is used (waiveUnrealizedLoss
// = Yes). The relative payout therefore differs, and post-fix the
// depositor recovers proportionally more of the residual cash for
// the shares burned. In both cases the vault is left in a valid
// (non-empty) state.
void
testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
{
using namespace test::jtx;
testcase(
"Vault withdraw: sole-shareholder partial fixed-shares uses "
"full-price rate (fixCleanup3_2_0)");
Env env(*this, all_ | fixCleanup3_2_0);
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
PrettyAsset const& asset = *f.asset;
auto const vaultBefore = env.le(vaultKey);
if (!BEAST_EXPECT(vaultBefore))
return;
Number const totalBefore = vaultBefore->at(sfAssetsTotal);
Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
Number const lossBefore = vaultBefore->at(sfLossUnrealized);
// Burn exactly half of the outstanding shares.
std::uint64_t const halfShares = f.sharesLender / 2;
STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
Vault const v{env};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = halfAmt,
}),
Ter(tesSUCCESS));
env.close();
// Expected payout under the full-price formula:
// assets = totalBefore * halfShares / sharesLender
// which (with halfShares == sharesLender/2) is roughly
// totalBefore / 2.
STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
Number const received{lenderBalanceAfter - lenderBalanceBefore};
Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
BEAST_EXPECT(received == expected);
// The full-price payout exceeds the discounted formula by exactly
// lossBefore * halfShares / sharesLender — that's the whole point
// of the waive.
Number const discounted =
(totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
BEAST_EXPECT(received - discounted == expectedDelta);
auto const vaultAfter = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfter))
return;
auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceAfter))
return;
// Vault remains valid: half the shares remain, lossUnrealized
// is untouched, and the entire (total - available) gap is still
// the impaired receivable.
BEAST_EXPECT(
issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
BEAST_EXPECT(
vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
vaultAfter->at(sfLossUnrealized));
// Conservation: vault delta matches the depositor's gain.
BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
}
// Post-fix end-to-end resolution: after the sole-shareholder partial
// exit, the loan is repaid in full. With unrealized loss cleared and
// all assets back as cash, the depositor can burn all remaining
// shares and fully exit the vault. The final withdrawal hits the
// "force payout to assetsAvailable" branch in doApply.
void
testWithdrawSoleShareholderLoanRepaymentExit()
{
using namespace test::jtx;
using namespace loan;
testcase(
"Vault withdraw: sole shareholder fully exits after impaired "
"loan is repaid (fixCleanup3_2_0)");
Env env(*this, all_ | fixCleanup3_2_0);
auto const f = setupStuckDepositor(env);
if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
{
BEAST_EXPECT(false);
return;
}
Keylet const& vaultKey = *f.vaultKeylet;
Keylet const& loanKey = *f.loanKeylet;
PrettyAsset const& asset = *f.asset;
Vault const v{env};
// Sole-shareholder partial exit (see comment in
// testWithdrawSoleShareholderFixedAssetExit for why we request
// less than full AssetsAvailable).
{
STAmount const requestAssets = asset(1000).value();
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = requestAssets,
}),
Ter(tesSUCCESS));
env.close();
}
// Confirm the "dormant-but-alive" state from the design doc. The
// partial exit burned exactly 750,018,750 shares (see derivation
// in testWithdrawSoleShareholderFixedAssetExit).
auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
if (!BEAST_EXPECT(tokenAfterExit))
return;
std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
// Borrower repays the loan in full (pays more than the outstanding
// total; the loan transactor caps the receivable).
env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
env.close();
auto const vaultAfterRepay = env.le(vaultKey);
if (!BEAST_EXPECT(vaultAfterRepay))
return;
// Repayment converts the 3,333 receivable back to cash; assetsTotal
// is unchanged but assetsAvailable jumps by exactly the same amount,
// and lossUnrealized clears to zero.
BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
// Burn all remaining shares — the clean-state preconditions of
// the "final withdrawal" guard are now satisfied.
STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
env(v.withdraw({
.depositor = f.lender,
.id = vaultKey.key,
.amount = allShares,
}),
Ter(tesSUCCESS));
env.close();
auto const vaultFinal = env.le(vaultKey);
if (!BEAST_EXPECT(vaultFinal))
return;
auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
if (!BEAST_EXPECT(issuanceFinal))
return;
// Zero-sized vault invariant satisfied: 0 shares, 0 assets.
BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
// The final payout equals exactly the AssetsAvailable that
// existed before the call (the "force payout" branch).
STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
BEAST_EXPECT(finalReceived == availableBeforeFinal);
}
public:
void
run() override
{
testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
testWithdrawSoleShareholderFixedAssetExit(all_);
testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
testWithdrawSoleShareholderFullSharesRejected(all_);
testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
testWithdrawSoleShareholderCleanVaultUnaffected(all_);
testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
testWithdrawSoleShareholderLoanRepaymentExit();
}
};
BEAST_DEFINE_TESTSUITE(VaultSoleShareholder, app, xrpl);
} // namespace xrpl

View File

@@ -0,0 +1,120 @@
#pragma once
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAmount.h>
#include <chrono>
#include <cstdint>
#include <format>
#include <source_location>
#include <string>
#include <utility>
namespace xrpl {
/**
* Shared base for the Vault*_test family under src/test/app/vault/.
*
* Owns the class-level helpers (type aliases, closed-ended vault
* scaffolding, standard feature bitset, IOU currency string) that every
* topical Vault*_test suite depends on. Mirrors
* src/test/app/lending/LoanTestBase.h.
*
* Run all suites in this family with `xrpld -u Vault` (the "Vault" prefix
* is matched against every suite name via
* beast::unit_test::Selector::ModeT::Automatch).
*/
class VaultTestBase : public beast::unit_test::Suite
{
protected:
using PrettyAsset = test::jtx::PrettyAsset;
using PrettyAmount = test::jtx::PrettyAmount;
static constexpr auto kNegativeAmount = [](PrettyAsset const& asset) -> PrettyAmount {
return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""};
};
/**
* Get the current ledger's close time resolution.
* @param env The test environment.
*/
static NetClock::duration
getLedgerTimeResolution(test::jtx::Env& env)
{
return env.current()->header().closeTimeResolution;
}
void
closeToTime(
test::jtx::Env& env,
NetClock::time_point time,
std::source_location const& loc = std::source_location::current())
{
using namespace std::chrono_literals;
env.close(time - env.closed()->header().closeTimeResolution + 1s);
expect(
env.closed()->header().closeTime == time,
std::format(
"current ledger time {} is not equal to the target ledger time {}",
env.closed()->header().closeTime.time_since_epoch(),
time.time_since_epoch()),
loc.file_name(),
loc.line());
}
using d = NetClock::duration;
using tp = NetClock::time_point;
// Vault holds an Env& so no default initializer is possible; the
// struct is always aggregate-initialized by makeClosedEndedVault.
// NOLINTBEGIN(cppcoreguidelines-pro-type-member-init)
struct ClosedEndedSetup
{
test::jtx::Vault vault;
Keylet keylet;
std::uint32_t sub = 0;
std::uint32_t red = 0;
};
// NOLINTEND(cppcoreguidelines-pro-type-member-init)
// Submit a VaultCreate for a closed-ended vault with SubscriptionDate at
// env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then
// close the ledger. Returns the Vault helper, the vault's keylet and the
// resolved sub/red timestamps.
static ClosedEndedSetup
makeClosedEndedVault(
test::jtx::Env& env,
test::jtx::Account const& owner,
Asset const& asset,
std::uint32_t subOffset,
std::uint32_t gap)
{
auto const sub = env.now().time_since_epoch().count() + subOffset;
auto const red = sub + gap;
test::jtx::Vault const vault{env};
auto [tx, keylet] = vault.create(
{.owner = owner,
.asset = asset,
.vaultKind = std::to_underlying(VaultKind::ClosedEnded),
.subscriptionDate = sub,
.redemptionDate = red});
env(tx);
env.close();
return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
}
FeatureBitset const all_{test::jtx::testableAmendments()};
std::string const iouCurrency_{"IOU"};
};
} // namespace xrpl

File diff suppressed because it is too large Load Diff