mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-18 21:10:02 +00:00
test: Split Vault_test into topical suites under src/test/app/vault/ (#8041)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
716
src/test/app/vault/VaultBugs_test.cpp
Normal file
716
src/test/app/vault/VaultBugs_test.cpp
Normal 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
|
||||
1122
src/test/app/vault/VaultClawback_test.cpp
Normal file
1122
src/test/app/vault/VaultClawback_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
1008
src/test/app/vault/VaultClosedEnded_test.cpp
Normal file
1008
src/test/app/vault/VaultClosedEnded_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
586
src/test/app/vault/VaultDomain_test.cpp
Normal file
586
src/test/app/vault/VaultDomain_test.cpp
Normal 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
|
||||
691
src/test/app/vault/VaultFreeze_test.cpp
Normal file
691
src/test/app/vault/VaultFreeze_test.cpp
Normal 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
|
||||
1776
src/test/app/vault/VaultLifecycle_test.cpp
Normal file
1776
src/test/app/vault/VaultLifecycle_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
543
src/test/app/vault/VaultRPC_test.cpp
Normal file
543
src/test/app/vault/VaultRPC_test.cpp
Normal 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
|
||||
1228
src/test/app/vault/VaultScale_test.cpp
Normal file
1228
src/test/app/vault/VaultScale_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
736
src/test/app/vault/VaultShares_test.cpp
Normal file
736
src/test/app/vault/VaultShares_test.cpp
Normal 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
|
||||
655
src/test/app/vault/VaultSoleShareholder_test.cpp
Normal file
655
src/test/app/vault/VaultSoleShareholder_test.cpp
Normal 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
|
||||
120
src/test/app/vault/VaultTestBase.h
Normal file
120
src/test/app/vault/VaultTestBase.h
Normal 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
|
||||
1086
src/test/app/vault/VaultValidation_test.cpp
Normal file
1086
src/test/app/vault/VaultValidation_test.cpp
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user