From 8c12de6c5624017e866ba78bf41cf3cfc5a722ab Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:57:12 +0000 Subject: [PATCH] test: Split Vault_test into topical suites under src/test/app/vault/ (#8041) --- src/test/app/Vault_test.cpp | 9736 ----------------- src/test/app/lending/LendingHelpers_test.cpp | 2 +- src/test/app/lending/LoanTestBase.h | 10 + src/test/app/lending/Loan_test.cpp | 46 - src/test/app/vault/VaultBugs_test.cpp | 716 ++ src/test/app/vault/VaultClawback_test.cpp | 1122 ++ src/test/app/vault/VaultClosedEnded_test.cpp | 1008 ++ src/test/app/vault/VaultDomain_test.cpp | 586 + src/test/app/vault/VaultFreeze_test.cpp | 691 ++ src/test/app/vault/VaultLifecycle_test.cpp | 1776 +++ src/test/app/vault/VaultRPC_test.cpp | 543 + src/test/app/vault/VaultScale_test.cpp | 1228 +++ src/test/app/vault/VaultShares_test.cpp | 736 ++ .../app/vault/VaultSoleShareholder_test.cpp | 655 ++ src/test/app/vault/VaultTestBase.h | 120 + src/test/app/vault/VaultValidation_test.cpp | 1086 ++ 16 files changed, 10278 insertions(+), 9783 deletions(-) delete mode 100644 src/test/app/Vault_test.cpp delete mode 100644 src/test/app/lending/Loan_test.cpp create mode 100644 src/test/app/vault/VaultBugs_test.cpp create mode 100644 src/test/app/vault/VaultClawback_test.cpp create mode 100644 src/test/app/vault/VaultClosedEnded_test.cpp create mode 100644 src/test/app/vault/VaultDomain_test.cpp create mode 100644 src/test/app/vault/VaultFreeze_test.cpp create mode 100644 src/test/app/vault/VaultLifecycle_test.cpp create mode 100644 src/test/app/vault/VaultRPC_test.cpp create mode 100644 src/test/app/vault/VaultScale_test.cpp create mode 100644 src/test/app/vault/VaultShares_test.cpp create mode 100644 src/test/app/vault/VaultSoleShareholder_test.cpp create mode 100644 src/test/app/vault/VaultTestBase.h create mode 100644 src/test/app/vault/VaultValidation_test.cpp diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp deleted file mode 100644 index 34ac40fb54..0000000000 --- a/src/test/app/Vault_test.cpp +++ /dev/null @@ -1,9736 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace xrpl { - -class Vault_test : public beast::unit_test::Suite -{ - using PrettyAsset = xrpl::test::jtx::PrettyAsset; - using PrettyAmount = xrpl::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}; - } - - void - testSequences() - { - using namespace test::jtx; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - Account const charlie{"charlie"}; // authorized 3rd party - Account const dave{"dave"}; - - auto const testSequence = [&, this]( - std::string const& prefix, - Env& env, - Vault& vault, - PrettyAsset const& asset) { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfData] = "AFEED00E"; - tx[sfAssetsMaximum] = asset(100).number(); - env(tx); - env.close(); - BEAST_EXPECT(env.le(keylet)); - std::uint64_t const scale = asset.raw().holds() ? 1 : 1e6; - - auto const [share, vaultAccount] = - [&env, keylet = keylet, asset, this]() -> std::tuple { - auto const vault = env.le(keylet); - BEAST_EXPECT(vault != nullptr); - if (!asset.integral()) - { - BEAST_EXPECT(vault->at(sfScale) == 6); - } - else - { - BEAST_EXPECT(vault->at(sfScale) == 0); - } - auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID))); - BEAST_EXPECT(shares != nullptr); - if (!asset.integral()) - { - BEAST_EXPECT(shares->at(sfAssetScale) == 6); - } - else - { - BEAST_EXPECT(shares->at(sfAssetScale) == 0); - } - return {MPTIssue(vault->at(sfShareMPTID)), Account("vault", vault->at(sfAccount))}; - }(); - auto const shares = share.raw().get(); - env.memoize(vaultAccount); - - // Several 3rd party accounts which cannot receive funds - Account const alice{"alice"}; - Account const erin{"erin"}; // not authorized by issuer - env.fund(XRP(1000), alice, erin); - env(fset(alice, asfDepositAuth)); - env.close(); - - { - testcase(prefix + " fail to deposit more than assets held"); - auto tx = vault.deposit( - {.depositor = depositor, .id = keylet.key, .amount = asset(10000)}); - env(tx, Ter(tecINSUFFICIENT_FUNDS)); - env.close(); - } - - { - testcase(prefix + " deposit non-zero amount"); - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale)); - } - - { - testcase(prefix + " deposit non-zero amount again"); - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale)); - } - - { - testcase(prefix + " fail to delete non-empty vault"); - auto tx = vault.del({.owner = owner, .id = keylet.key}); - env(tx, Ter(tecHAS_OBLIGATIONS)); - env.close(); - } - - { - testcase(prefix + " fail to update because wrong owner"); - auto tx = vault.set({.owner = issuer, .id = keylet.key}); - tx[sfAssetsMaximum] = asset(50).number(); - env(tx, Ter(tecNO_PERMISSION)); - env.close(); - } - - { - testcase(prefix + " fail to set maximum lower than current amount"); - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfAssetsMaximum] = asset(50).number(); - env(tx, Ter(tecLIMIT_EXCEEDED)); - env.close(); - } - - { - testcase(prefix + " set maximum higher than current amount"); - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfAssetsMaximum] = asset(150).number(); - env(tx); - env.close(); - } - - { - testcase(prefix + " set maximum is idempotent, set it again"); - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfAssetsMaximum] = asset(150).number(); - env(tx); - env.close(); - } - - { - testcase(prefix + " set data"); - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfData] = "0"; - env(tx); - env.close(); - } - - { - testcase(prefix + " fail to set domain on public vault"); - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); - env(tx, Ter{tecNO_PERMISSION}); - env.close(); - } - - { - testcase(prefix + " fail to deposit more than maximum"); - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - env(tx, Ter(tecLIMIT_EXCEEDED)); - env.close(); - } - - { - testcase(prefix + " reset maximum to zero i.e. not enforced"); - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfAssetsMaximum] = asset(0).number(); - env(tx); - env.close(); - } - - { - testcase(prefix + " fail to withdraw more than assets held"); - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); - env(tx, Ter(tecINSUFFICIENT_FUNDS)); - env.close(); - } - - { - testcase(prefix + " deposit some more"); - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale)); - } - - { - testcase(prefix + " clawback some"); - auto code = asset.raw().native() ? Ter(temMALFORMED) : Ter(tesSUCCESS); - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(10)}); - env(tx, code); - env.close(); - if (!asset.raw().native()) - { - BEAST_EXPECT(env.balance(depositor, shares) == share(190 * scale)); - } - } - - { - testcase(prefix + " clawback all"); - auto code = asset.raw().native() ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS); - auto tx = vault.clawback({.issuer = issuer, .id = keylet.key, .holder = depositor}); - env(tx, code); - env.close(); - if (!asset.raw().native()) - { - BEAST_EXPECT(env.balance(depositor, shares) == share(0)); - - { - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet.key, - .holder = depositor, - .amount = asset(10)}); - env(tx, Ter{tecPRECISION_LOSS}); - env.close(); - } - - { - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(10)}); - env(tx, Ter{tecPRECISION_LOSS}); - env.close(); - } - } - } - - if (!asset.raw().native()) - { - testcase(prefix + " deposit again"); - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(200)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale)); - } - else - { - testcase(prefix + " deposit/withdrawal same or less than fee"); - auto const amount = env.current()->fees().base; - - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount}); - env(tx); - env.close(); - - tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}); - env(tx); - env.close(); - - tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount}); - env(tx); - env.close(); - - // Withdraw to 3rd party - tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}); - tx[sfDestination] = charlie.human(); - env(tx); - env.close(); - - tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount - 1}); - env(tx); - env.close(); - - tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = amount - 1}); - env(tx); - env.close(); - } - - { - testcase(prefix + " fail to withdraw to 3rd party lsfDepositAuth"); - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - tx[sfDestination] = alice.human(); - env(tx, Ter{tecNO_PERMISSION}); - env.close(); - } - - { - testcase(prefix + " fail to withdraw to zero destination"); - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); - tx[sfDestination] = "0"; - env(tx, Ter(temMALFORMED)); - env.close(); - } - - if (!asset.raw().native()) - { - testcase(prefix + " fail to withdraw to 3rd party no authorization"); - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - tx[sfDestination] = erin.human(); - env(tx, Ter{asset.raw().holds() ? tecNO_LINE : tecNO_AUTH}); - env.close(); - } - - { - testcase(prefix + " fail to withdraw to 3rd party lsfRequireDestTag"); - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - tx[sfDestination] = dave.human(); - env(tx, Ter{tecDST_TAG_NEEDED}); - env.close(); - } - - { - testcase(prefix + " withdraw to 3rd party lsfRequireDestTag"); - auto tx = - vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); - tx[sfDestination] = dave.human(); - tx[sfDestinationTag] = "0"; - env(tx); - env.close(); - } - - { - testcase(prefix + " deposit again"); - auto tx = vault.deposit({.depositor = dave, .id = keylet.key, .amount = asset(50)}); - env(tx); - env.close(); - } - - { - testcase(prefix + " fail to withdraw lsfRequireDestTag"); - auto tx = - vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)}); - env(tx, Ter{tecDST_TAG_NEEDED}); - env.close(); - } - - { - testcase(prefix + " withdraw with tag"); - auto tx = - vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)}); - tx[sfDestinationTag] = "0"; - env(tx); - env.close(); - } - - { - testcase(prefix + " withdraw to authorized 3rd party"); - auto tx = - vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); - tx[sfDestination] = charlie.human(); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale)); - } - - { - testcase(prefix + " withdraw to issuer"); - auto tx = - vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); - tx[sfDestination] = issuer.human(); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale)); - } - - if (!asset.raw().native()) - { - testcase(prefix + " issuer deposits"); - auto tx = - vault.deposit({.depositor = issuer, .id = keylet.key, .amount = asset(10)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(issuer, shares) == share(10 * scale)); - - testcase(prefix + " issuer withdraws"); - tx = vault.withdraw( - {.depositor = issuer, .id = keylet.key, .amount = share(10 * scale)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(issuer, shares) == share(0 * scale)); - } - - { - testcase(prefix + " withdraw remaining assets"); - auto tx = - vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(depositor, shares) == share(0)); - - if (!asset.raw().native()) - { - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet.key, - .holder = depositor, - .amount = asset(0)}); - env(tx, Ter{tecPRECISION_LOSS}); - env.close(); - } - - { - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = share(10)}); - env(tx, Ter{tecINSUFFICIENT_FUNDS}); - env.close(); - } - } - - if (!asset.integral()) - { - testcase(prefix + " temporary authorization for 3rd party"); - env(trust(erin, asset(1000))); - env(trust(issuer, asset(0), erin, tfSetfAuth)); - env(pay(issuer, erin, asset(10))); - - // Erin deposits all in vault, then sends shares to depositor - auto tx = vault.deposit({.depositor = erin, .id = keylet.key, .amount = asset(10)}); - env(tx); - env.close(); - { - auto tx = pay(erin, depositor, share(10 * scale)); - - // depositor no longer has MPToken for shares - env(tx, Ter{tecNO_AUTH}); - env.close(); - - // depositor will gain MPToken for shares again - env(vault.deposit( - {.depositor = depositor, .id = keylet.key, .amount = asset(1)})); - env.close(); - - env(tx); - env.close(); - } - - testcase(prefix + " withdraw to authorized 3rd party"); - // Depositor withdraws assets, destined to Erin - tx = - vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); - tx[sfDestination] = erin.human(); - env(tx); - env.close(); - - // Erin returns assets to issuer - env(pay(erin, issuer, asset(10))); - env.close(); - - testcase(prefix + " fail to pay to unauthorized 3rd party"); - env(trust(erin, asset(0))); - env.close(); - - // Erin has MPToken but is no longer authorized to hold assets - env(pay(depositor, erin, share(1)), Ter{tecNO_LINE}); - env.close(); - - // Depositor withdraws remaining single asset - tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}); - env(tx); - env.close(); - } - - { - testcase(prefix + " fail to delete because wrong owner"); - auto tx = vault.del({.owner = issuer, .id = keylet.key}); - env(tx, Ter(tecNO_PERMISSION)); - env.close(); - } - - { - testcase(prefix + " delete empty vault"); - auto tx = vault.del({.owner = owner, .id = keylet.key}); - env(tx); - env.close(); - BEAST_EXPECT(!env.le(keylet)); - } - }; - - auto testCases = [&, this]( - std::string prefix, std::function setup) { - Env env{*this, testableAmendments()}; - - Vault vault{env}; - env.fund(XRP(1000), issuer, owner, depositor, charlie, dave); - env.close(); - env(fset(issuer, asfAllowTrustLineClawback)); - env(fset(issuer, asfRequireAuth)); - env(fset(dave, asfRequireDest)); - env.close(); - env.require(Flags(issuer, asfAllowTrustLineClawback)); - env.require(Flags(issuer, asfRequireAuth)); - - PrettyAsset const asset = setup(env); - testSequence(prefix, env, vault, asset); - }; - - testCases("XRP", [&](Env& env) -> PrettyAsset { return {xrpIssue(), 1'000'000}; }); - - testCases("IOU", [&](Env& env) -> Asset { - PrettyAsset const asset = issuer["IOU"]; - env(trust(owner, asset(1000))); - env(trust(depositor, asset(1000))); - env(trust(charlie, asset(1000))); - env(trust(dave, asset(1000))); - env(trust(issuer, asset(0), owner, tfSetfAuth)); - env(trust(issuer, asset(0), depositor, tfSetfAuth)); - env(trust(issuer, asset(0), charlie, tfSetfAuth)); - env(trust(issuer, asset(0), dave, tfSetfAuth)); - env(pay(issuer, depositor, asset(1000))); - env.close(); - return asset; - }); - - testCases("MPT", [&](Env& env) -> Asset { - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const asset = mptt.issuanceID(); - mptt.authorize({.account = depositor}); - mptt.authorize({.account = charlie}); - mptt.authorize({.account = dave}); - env(pay(issuer, depositor, asset(1000))); - env.close(); - return asset; - }); - } - - void - testPreflight() - { - using namespace test::jtx; - - struct CaseArgs - { - FeatureBitset features = testableAmendments(); - }; - - auto testCase = [&, this]( - std::function test, - CaseArgs args = {}) { - Env env{*this, args.features}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - Vault vault{env}; - env.fund(XRP(1000), issuer, owner); - env.close(); - - env(fset(issuer, asfAllowTrustLineClawback)); - env(fset(issuer, asfRequireAuth)); - env.close(); - - PrettyAsset const asset = issuer["IOU"]; - env(trust(owner, asset(1000))); - env(trust(issuer, asset(0), owner, tfSetfAuth)); - env(pay(issuer, owner, asset(1000))); - env.close(); - - test(env, issuer, owner, asset, vault); - }; - - auto testDisabled = [&](TER resultAfterCreate = temDISABLED) { - return [&, resultAfterCreate]( - Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("disabled single asset vault"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter{temDISABLED}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - env(tx, kData("test"), Ter{resultAfterCreate}); - } - - { - auto tx = - vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - env(tx, Ter{resultAfterCreate}); - } - - { - auto tx = - vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - env(tx, Ter{resultAfterCreate}); - } - - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)}); - env(tx, Ter{resultAfterCreate}); - } - - { - auto tx = vault.del({.owner = owner, .id = keylet.key}); - env(tx, Ter{resultAfterCreate}); - } - }; - }; - - testCase(testDisabled(), {.features = testableAmendments() - featureSingleAssetVault}); - - testCase(testDisabled(tecNO_ENTRY), {.features = testableAmendments() - featureMPTokensV1}); - - testCase( - [&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("disabled permissioned domains"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - - tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate; - tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); - env(tx, Ter{temDISABLED}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - env(tx, kData("Test")); - - tx[sfDomainID] = to_string(BaseUInt<256>(13ul)); - env(tx, Ter{temDISABLED}); - } - }, - {.features = testableAmendments() - featurePermissionedDomains}); - - testCase([&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("invalid flags"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfFlags] = tfClearDeepFreeze; - env(tx, Ter{temINVALID_FLAG}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfFlags] = tfClearDeepFreeze; - env(tx, Ter{temINVALID_FLAG}); - } - - { - auto tx = - vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - tx[sfFlags] = tfClearDeepFreeze; - env(tx, Ter{temINVALID_FLAG}); - } - - { - auto tx = - vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - tx[sfFlags] = tfClearDeepFreeze; - env(tx, Ter{temINVALID_FLAG}); - } - - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)}); - tx[sfFlags] = tfClearDeepFreeze; - env(tx, Ter{temINVALID_FLAG}); - } - - { - auto tx = vault.del({.owner = owner, .id = keylet.key}); - tx[sfFlags] = tfClearDeepFreeze; - env(tx, Ter{temINVALID_FLAG}); - } - }); - - testCase([&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("invalid fee"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[jss::Fee] = "-1"; - env(tx, Ter{temBAD_FEE}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[jss::Fee] = "-1"; - env(tx, Ter{temBAD_FEE}); - } - - { - auto tx = - vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - tx[jss::Fee] = "-1"; - env(tx, Ter{temBAD_FEE}); - } - - { - auto tx = - vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - tx[jss::Fee] = "-1"; - env(tx, Ter{temBAD_FEE}); - } - - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)}); - tx[jss::Fee] = "-1"; - env(tx, Ter{temBAD_FEE}); - } - - { - auto tx = vault.del({.owner = owner, .id = keylet.key}); - tx[jss::Fee] = "-1"; - env(tx, Ter{temBAD_FEE}); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const&, Vault& vault) { - testcase("disabled permissioned domain"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()}); - tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); - env(tx, Ter{temDISABLED}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); - env(tx, Ter{temDISABLED}); - } - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfDomainID] = "0"; - env(tx, Ter{temDISABLED}); - } - }, - {.features = (testableAmendments()) - featurePermissionedDomains}); - - testCase([&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("use zero vault"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()}); - - { - auto tx = vault.set({ - .owner = owner, - .id = beast::kZero, - }); - env(tx, Ter{temMALFORMED}); - } - - { - auto tx = - vault.deposit({.depositor = owner, .id = beast::kZero, .amount = asset(10)}); - env(tx, Ter(temMALFORMED)); - } - - { - auto tx = - vault.withdraw({.depositor = owner, .id = beast::kZero, .amount = asset(10)}); - env(tx, Ter{temMALFORMED}); - } - - { - auto tx = vault.clawback( - {.issuer = issuer, .id = beast::kZero, .holder = owner, .amount = asset(10)}); - env(tx, Ter{temMALFORMED}); - } - - { - auto tx = vault.del({ - .owner = owner, - .id = beast::kZero, - }); - env(tx, Ter{temMALFORMED}); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("withdraw to bad destination"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = - vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - tx[jss::Destination] = "0"; - env(tx, Ter{temMALFORMED}); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("create with Scale"); - - { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 255; - env(tx, Ter(temMALFORMED)); - } - - { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 19; - env(tx, Ter(temMALFORMED)); - } - - // accepted range from 0 to 18 - { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 18; - env(tx); - env.close(); - auto const sleVault = env.le(keylet); - BEAST_EXPECT(sleVault); - BEAST_EXPECT((*sleVault)[sfScale] == 18); - } - - { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 0; - env(tx); - env.close(); - auto const sleVault = env.le(keylet); - BEAST_EXPECT(sleVault); - BEAST_EXPECT((*sleVault)[sfScale] == 0); - } - - { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - auto const sleVault = env.le(keylet); - BEAST_EXPECT(sleVault); - BEAST_EXPECT((*sleVault)[sfScale] == 6); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("create or set invalid data"); - - auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = tx1; - tx[sfData] = ""; - env(tx, Ter(temMALFORMED)); - } - - { - auto tx = tx1; - // A hexadecimal string of 257 bytes. - tx[sfData] = std::string(514, 'A'); - env(tx, Ter(temMALFORMED)); - } - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfData] = ""; - env(tx, Ter{temMALFORMED}); - } - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - // A hexadecimal string of 257 bytes. - tx[sfData] = std::string(514, 'A'); - env(tx, Ter{temMALFORMED}); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("set nothing updated"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - env(tx, Ter{temMALFORMED}); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("create with invalid metadata"); - - auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = tx1; - tx[sfMPTokenMetadata] = ""; - env(tx, Ter(temMALFORMED)); - } - - { - auto tx = tx1; - // This metadata is for the share token. - // A hexadecimal string of 1025 bytes. - tx[sfMPTokenMetadata] = std::string(2050, 'B'); - env(tx, Ter(temMALFORMED)); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("set negative maximum"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfAssetsMaximum] = kNegativeAmount(asset).number(); - env(tx, Ter{temMALFORMED}); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("invalid deposit amount"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.deposit( - {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)}); - env(tx, Ter(temBAD_AMOUNT)); - } - - { - auto tx = - vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(0)}); - env(tx, Ter(temBAD_AMOUNT)); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("invalid set immutable flag"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfFlags] = tfVaultPrivate; - env(tx, Ter(temINVALID_FLAG)); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("invalid withdraw amount"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.withdraw( - {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)}); - env(tx, Ter(temBAD_AMOUNT)); - } - - { - auto tx = - vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(0)}); - env(tx, Ter(temBAD_AMOUNT)); - } - }); - - testCase([&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("invalid clawback"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - // Preclaim only checks for native assets. - if (asset.native()) - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)}); - env(tx, Ter(temMALFORMED)); - } - - { - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet.key, - .holder = owner, - .amount = kNegativeAmount(asset)}); - env(tx, Ter(temBAD_AMOUNT)); - } - }); - - testCase( - [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { - testcase("invalid create"); - - auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = tx1; - tx[sfWithdrawalPolicy] = 0; - env(tx, Ter(temMALFORMED)); - } - - { - auto tx = tx1; - tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); - env(tx, Ter{temMALFORMED}); - } - - { - auto tx = tx1; - tx[sfAssetsMaximum] = kNegativeAmount(asset).number(); - env(tx, Ter{temMALFORMED}); - } - - { - auto tx = tx1; - tx[sfFlags] = tfVaultPrivate; - tx[sfDomainID] = "0"; - env(tx, Ter{temMALFORMED}); - } - }); - } - - // VaultCreate malformation and happy paths for closed-ended vaults, plus the - // featureLendingProtocolV1_1 gate. - void - testVaultCreateClosedEnded() - { - testcase("closed-ended VaultCreate"); - using namespace test::jtx; - - auto const withEnv = [this](FeatureBitset features, auto&& body) { - Env env{*this, features}; - Account const owner{"owner"}; - env.fund(XRP(1000), owner); - env.close(); - Vault vault{env}; - body(env, owner, vault); - }; - - Asset const asset = xrpIssue(); - auto const minPeriod = kMinInvestmentPeriod; - auto const maxPeriod = kMaxInvestmentPeriod; - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); - - // Gate: the three new fields require featureLendingProtocolV1_1. - withEnv( - testableAmendments() - featureLendingProtocolV1_1, - [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = sub + minPeriod}); - env(tx, Ter{temDISABLED}); - }); - - /* - * Valid closed-ended creation with a comfortably interior gap (well above - * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). - */ - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + 86400; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - env.close(); - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded); - BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); - BEAST_EXPECT(sle->at(sfRedemptionDate) == red); - } - }); - - // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED. - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .redemptionDate = sub + minPeriod}); - env(tx, Ter{temMALFORMED}); - }); - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub}); - env(tx, Ter{temMALFORMED}); - }); - - /* - * SubscriptionDate not strictly after parent close time (preclaim, state-dependent - - * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see - * the note below the next case. Note: there is no separate "expired RedemptionDate" test - * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past - * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the - * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the - * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause - * of tecEXPIRED. - */ - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const nowSec = env.now().time_since_epoch().count(); - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = nowSec, - .redemptionDate = nowSec + minPeriod}); - env(tx, Ter{tecEXPIRED}); - }); - - /* - * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= - * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < - * sub case, the latter yielding a negative signed int64 gap that is caught by the - * sub-minimum branch of the gap check. - */ - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = sub + minPeriod - 1}); - env(tx, Ter{temMALFORMED}); - }); - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = sub}); - env(tx, Ter{temMALFORMED}); - }); - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = sub - 1}); - env(tx, Ter{temMALFORMED}); - }); - - // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right). - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = sub + maxPeriod}); - env(tx, Ter{temMALFORMED}); - }); - - // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as - // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully. - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = sub + maxPeriod + 1}); - env(tx, Ter{temMALFORMED}); - }); - - // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is - // inclusive). - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + minPeriod; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - env.close(); - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(sle->at(sfRedemptionDate) == red); - } - }); - - // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is - // accepted (upper bound is exclusive). - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + maxPeriod - 1; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - env.close(); - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(sle->at(sfRedemptionDate) == red); - } - }); - - // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present - // => temMALFORMED. - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = - vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub}); - env(tx, Ter{temMALFORMED}); - }); - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto [tx, keylet] = - vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod}); - env(tx, Ter{temMALFORMED}); - }); - - // Unrecognised VaultKind => temMALFORMED. - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = static_cast(closedEnded + 1)}); - env(tx, Ter{temMALFORMED}); - }); - - // Happy path: open-ended vault (no new fields present) is unaffected. - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); - BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); - BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); - } - }); - - // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same - // as absent. Per spec, absent and OpenEnded are equivalent. - withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = std::to_underlying(VaultKind::OpenEnded)}); - env(tx); - env.close(); - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - // OpenEnded is sfVaultKind's default; SoeDefault fields - // aren't serialized when they hold the default value. - BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); - BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); - BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); - } - }); - } - - // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now - // == SubscriptionDate case (which must still resolve to Subscription). - void - testVaultPhaseDerivation() - { - testcase("closed-ended phase derivation"); - using namespace test::jtx; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - env.fund(XRP(1000), owner, depositor); - env.close(); - - Asset const asset = xrpIssue(); - auto const [vault, keylet, sub, red] = - makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); - - // Pre-seed shares during Subscription so the depositor has capital to - // withdraw at the Redemption boundary below. - env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()})); - env.close(); - - auto const deposit = - [&](TER expected, std::source_location const& loc = std::source_location::current()) { - env( - WithSourceLocation{ - vault.deposit( - {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), - loc}, - Ter{expected}); - }; - auto const withdraw = - [&](TER expected, std::source_location const& loc = std::source_location::current()) { - env( - WithSourceLocation{ - vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), - loc}, - Ter{expected}); - }; - - auto const runTest = [&](TER expectedDeposit, - TER expectedWithdraw, - std::source_location const& loc = - std::source_location::current()) { - deposit(expectedDeposit, loc); - withdraw(expectedWithdraw, loc); - }; - - // Assert both deposit and withdraw return codes at each point so the - // active phase is uniquely identified: - // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS - // Investment: deposit tecEXPIRED, withdraw tecTOO_SOON - // Redemption: deposit tecEXPIRED, withdraw tesSUCCESS - - // Ledger time comfortably before SubscriptionDate: Subscription. - runTest(tesSUCCESS, tesSUCCESS); - - // Boundary: parent close time exactly at SubscriptionDate must still - // be Subscription. - closeToTime(env, tp{d{sub}}); - runTest(tesSUCCESS, tesSUCCESS); - - // One second past SubscriptionDate: Investment. - closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); - runTest(tecEXPIRED, tecTOO_SOON); - - // Any point strictly before RedemptionDate remains Investment. - closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env)); - runTest(tecEXPIRED, tecTOO_SOON); - - // Boundary: parent close time == RedemptionDate is Redemption (per - // spec table: now >= RedemptionDate). Deposits are rejected but - // withdrawals succeed. - closeToTime(env, tp{d{red}}); - runTest(tecEXPIRED, tesSUCCESS); - env.close(); - } - - // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any - // dates present on the vault. - void - testVaultPhaseDerivationOpenEnded() - { - testcase("open-ended phase derivation"); - using namespace test::jtx; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - env.fund(XRP(1000), owner); - env.close(); - - Asset const asset = xrpIssue(); - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - auto const checkPhaseAt = [&](NetClock::time_point at) { - closeToTime(env, at); - auto const sle = env.le(keylet); - if (!BEAST_EXPECT(sle)) - return; - BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase); - }; - - // Advance the clock through a wide range of ledger times: an open-ended vault's phase - // must be NoPhase at every one of them, because the derivation short-circuits on - // VaultKind::OpenEnded before it looks at any dates. - auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution; - checkPhaseAt(ledgerTime); - checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod}); - checkPhaseAt( - ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} - - env.closed()->header().closeTimeResolution); - } - - // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and - // Redemption. - void - testVaultDepositClosedEnded() - { - testcase("closed-ended VaultDeposit phase gating"); - using namespace test::jtx; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - env.fund(XRP(1000), owner, depositor); - env.close(); - - Asset const asset = xrpIssue(); - auto const [vault, keylet, sub, red] = - makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); - - auto const deposit = - [&](TER expected, std::source_location const& loc = std::source_location::current()) { - env( - WithSourceLocation{ - vault.deposit( - {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), - loc}, - Ter{expected}); - env.close(); - }; - - // Subscription: allowed. - deposit(tesSUCCESS); - - // Investment: rejected. - env.close(tp{d{sub + 1}}); - deposit(tecEXPIRED); - - // Redemption: rejected. - env.close(tp{d{red}}); - deposit(tecEXPIRED); - } - - // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The - // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with - // capital deployed as an outstanding loan. - void - testVaultWithdrawClosedEnded() - { - testcase("closed-ended VaultWithdraw phase gating"); - using namespace test::jtx; - using namespace loan_broker; - using namespace loan; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000), owner, depositor, borrower); - env.close(); - - Asset const asset = xrpIssue(); - // Widen the Investment window so a single-payment loan (min payment - // interval kMinPaymentInterval = 60s) fits before RedemptionDate. - auto const [vault, keylet, sub, red] = - makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u); - - // Deposit XRP(100) in Subscription so the depositor's shares are - // worth XRP(100). The vault holds XRP(100) with - // AssetsAvailable == AssetsTotal. - env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - - // Create a loan broker backed by this vault. LoanBrokerSet has no - // phase gate, so this is fine to do in Subscription. - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(loan_broker::set(owner, keylet.key)); - env.close(); - - auto const withdraw = [&](STAmount const& amount, - TER expected, - std::source_location const& loc = - std::source_location::current()) { - env( - WithSourceLocation{ - vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}), - loc}, - Ter{expected}); - env.close(); - }; - - // Subscription: allowed (LP cancel). - withdraw(XRP(1).value(), tesSUCCESS); - - // Investment: rejected. - closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); - withdraw(XRP(1).value(), tecTOO_SOON); - - // Deploy capital: borrower takes a loan of XRP(60) against the - // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal - // remains ~XRP(99). - env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(60), - kPaymentTotal(1), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2)); - env.close(); - - // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small - // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share - // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the - // vault-shortage guard (not the insufficient-shares guard). - closeToTime(env, tp{d{red}}); - withdraw(XRP(10).value(), tesSUCCESS); - withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS); - } - - // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with - // multiple depositors and a real loan originated through the Investment leg. Exercises every - // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each - // phase. - void - testVaultClosedEndedLifecycle() - { - testcase("closed-ended vault lifecycle (subscribe → invest → redeem)"); - using namespace test::jtx; - using namespace loan_broker; - using namespace loan; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const alice{"alice"}; - Account const bob{"bob"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000), owner, alice, bob, borrower); - env.close(); - - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); - Asset const asset = xrpIssue(); - // Widen the Investment window so a single-payment loan (min payment interval - // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom. - auto const [vault, keylet, sub, red] = - makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); - - auto const sleCreate = env.le(keylet); - BEAST_EXPECT(sleCreate); - MPTIssue const shares{sleCreate->at(sfShareMPTID)}; - - auto const balancesEq = [&](STAmount const& available, STAmount const& total) { - auto const sle = env.le(keylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); - BEAST_EXPECT(sle->at(sfAssetsTotal) == total); - }; - auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); }; - - // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share - // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the - // MPToken SLE directly to avoid the lookup. - auto const sharesEq = [&](Account const& holder, std::uint64_t expected) { - auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id())); - std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u; - BEAST_EXPECT(actual == expected); - }; - - // ---- Subscription phase ---- - // A legitimate VaultSet succeeds (positive control for 3.7). - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfData] = "AA"; - env(tx); - env.close(); - } - - // alice deposits 100 XRP. - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - sharesEq(alice, 100'000'000); - availableEq(XRP(100).value()); - - // bob deposits 200 XRP. - env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()})); - env.close(); - sharesEq(bob, 200'000'000); - availableEq(XRP(300).value()); - - // alice cancels 25 XRP (LP cancel is permitted in Subscription). - env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()})); - env.close(); - sharesEq(alice, 75'000'000); - availableEq(XRP(275).value()); - - // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is - // fine to do in Subscription. - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(loan_broker::set(owner, keylet.key)); - env.close(); - - // ---- Investment phase (now == sub + 1) ---- - env.close(tp{d{sub + 1}}); - - // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED. - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), - Ter{tecEXPIRED}); - env.close(); - // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON. - env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), - Ter{tecTOO_SOON}); - env.close(); - - // A real loan is originated during Investment (permitted only in this phase). Zero-interest - // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis - // accounting recognise no interest at origination); AssetsAvailable drops by the loan - // principal. - env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(60), - kPaymentTotal(1), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2)); - env.close(); - auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key)); - BEAST_EXPECT(sleBroker); - auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); - BEAST_EXPECT(env.le(loanKeylet)); - balancesEq(XRP(215).value(), XRP(275).value()); - - // Non-immutable VaultSet still works in Investment (positive control). - { - auto tx = vault.set({.owner = owner, .id = keylet.key}); - tx[sfData] = "BB"; - env(tx); - env.close(); - } - - // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved. - sharesEq(alice, 75'000'000); - sharesEq(bob, 200'000'000); - - // ---- Redemption phase (now == red) ---- - env.close(tp{d{red}}); - - // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both - // Investment and Redemption. - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), - Ter{tecEXPIRED}); - env.close(); - - // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215). - env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()})); - env.close(); - sharesEq(alice, 0); - balancesEq(XRP(140).value(), XRP(200).value()); - - // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP - // sits in the outstanding loan). A full 200 XRP withdrawal fails against the - // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed - // by the loan receivable — the realistic outcome when capital is still deployed at - // Redemption. - env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}), - Ter{tecINSUFFICIENT_FUNDS}); - env.close(); - env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()})); - env.close(); - sharesEq(bob, 60'000'000); - balancesEq(XRP(0).value(), XRP(60).value()); - - // Defensive spot-check that the three immutable fields have not changed across the entire - // lifecycle. Direct immutability coverage lives with the invariant tests. - auto const sleFinal = env.le(keylet); - if (BEAST_EXPECT(sleFinal)) - { - BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded); - BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub); - BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red); - } - } - - // SubscriptionDate boundary cases at the top of the UINT32 range. - // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits - // the inclusive lower bound of the kMinInvestmentPeriod gap check. - // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is - // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value - // can satisfy the gap check. - void - testVaultCreateSubscriptionDateBoundary() - { - testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX"); - using namespace test::jtx; - - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); - Asset const asset = xrpIssue(); - - { - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - env.fund(XRP(1000), owner); - env.close(); - - Vault const vault{env}; - auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod; - auto const red = std::numeric_limits::max(); - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - env.close(); - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); - BEAST_EXPECT(sle->at(sfRedemptionDate) == red); - } - } - - // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod - // wraps in a UINT32. Every candidate red must fall to temMALFORMED via - // the gap check in preflight. - auto const rejectAtMax = [&, this](std::uint32_t red) { - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - env.fund(XRP(1000), owner); - env.close(); - - Vault const vault{env}; - auto [tx, keylet] = vault.create( - {.owner = owner, - .asset = asset, - .vaultKind = closedEnded, - .subscriptionDate = std::numeric_limits::max(), - .redemptionDate = red}); - env(tx, Ter{temMALFORMED}); - }; - rejectAtMax(std::numeric_limits::max()); - rejectAtMax(0u); - rejectAtMax(kMinInvestmentPeriod - 1u); - } - - // A loan whose payment is made after the Investment phase has ended - // (well past its next-due-date and grace period, into Redemption) must - // still be repayable. The vault phase must not gate LoanPay. - void - testVaultLoanLatePaymentAfterInvestment() - { - testcase("closed-ended vault: late loan payment during Redemption succeeds"); - using namespace test::jtx; - using namespace loan_broker; - using namespace loan; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const alice{"alice"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000), owner, alice, borrower); - env.close(); - - Asset const asset = xrpIssue(); - auto const [vault, keylet, sub, red] = - makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); - - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(loan_broker::set(owner, keylet.key)); - env.close(); - - // Investment phase: originate a zero-interest, single-payment loan - // with a 300s payment interval and 60s grace. The payment is due - // shortly after origination and well before RedemptionDate. - env.close(tp{d{sub + 1}}); - env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(300), - kPaymentTotal(1), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2)); - env.close(); - auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); - BEAST_EXPECT(env.le(loanKeylet)); - - // Advance to Redemption. The payment is now past its due date and - // grace, and the vault is no longer in Investment. - closeToTime(env, tp{d{red}}); - - env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment)); - env.close(); - - // Loan principal returned to the vault; assetsAvailable == assetsTotal. - auto const sleAfter = env.le(keylet); - if (BEAST_EXPECT(sleAfter)) - { - BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal)); - BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value()); - } - - env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - } - - // Two concurrent loans against the same closed-ended vault in Investment - // must coexist: both loan SLEs are created, AssetsAvailable reflects the - // sum of the two outstanding principals, and each can be repaid - // independently. - void - testVaultClosedEndedMultipleLoans() - { - testcase("closed-ended vault: multiple concurrent loans in Investment"); - using namespace test::jtx; - using namespace loan_broker; - using namespace loan; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const alice{"alice"}; - Account const bob{"bob"}; - Account const borrower1{"borrower1"}; - Account const borrower2{"borrower2"}; - env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2); - env.close(); - - Asset const asset = xrpIssue(); - auto const [vault, keylet, sub, red] = - makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); - - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(loan_broker::set(owner, keylet.key)); - env.close(); - - env.close(tp{d{sub + 1}}); - - auto const originate = [&](Account const& b, STAmount const& principal) { - env(loan::set(b, brokerKeylet.key, principal), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(300), - kPaymentTotal(1), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2)); - env.close(); - }; - originate(borrower1, XRP(50).value()); - originate(borrower2, XRP(70).value()); - - auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); - auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u)); - BEAST_EXPECT(env.le(loan1)); - BEAST_EXPECT(env.le(loan2)); - - // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable - // drops by the sum of the two loan principals. - { - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value()); - } - } - - // Repay the first loan; the second remains outstanding. - env(loan::pay(borrower1, loan1.key, XRP(50).value())); - env.close(); - { - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value()); - } - } - - // Repay the second loan; vault is fully liquid again. - env(loan::pay(borrower2, loan2.key, XRP(70).value())); - env.close(); - { - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - { - BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal)); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value()); - } - } - - // Redemption: both depositors withdraw in full. - env.close(tp{d{red}}); - env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); - env.close(); - } - - // VaultClawback has no phase gate: an issuer must be able to reclaim - // asset from a depositor in Subscription, Investment and Redemption - // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path - // is exercised (XRP clawback with an explicit amount is temMALFORMED). - void - testVaultClawbackClosedEndedPhases() - { - testcase("closed-ended vault: VaultClawback succeeds in each phase"); - using namespace test::jtx; - - Env env{*this, testableAmendments()}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - Account const alice{"alice"}; - env.fund(XRP(10'000), issuer, owner, alice); - env.close(); - - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const iou = issuer["IOU"]; - env.trust(iou(10'000), alice); - env(pay(issuer, alice, iou(1'000))); - env.close(); - - auto const [vault, keylet, sub, red] = - makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u); - - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()})); - env.close(); - - auto const totalsEq = [&](STAmount const& expected) { - auto const sle = env.le(keylet); - if (BEAST_EXPECT(sle)) - BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); - }; - - // Subscription phase clawback. - env(vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); - env.close(); - totalsEq(iou(290).value()); - - // Investment phase clawback. - env.close(tp{d{sub + 1}}); - env(vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); - env.close(); - totalsEq(iou(280).value()); - - // Redemption phase clawback. - env.close(tp{d{red}}); - env(vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); - env.close(); - totalsEq(iou(270).value()); - } - - // Test for non-asset specific behaviors. - void - testCreateFailXRP() - { - using namespace test::jtx; - - auto testCase = [this]( - std::function test) { - 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 vault{env}; - Asset const asset = xrpIssue(); - - test(env, issuer, owner, depositor, asset, vault); - }; - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault) { - testcase("nothing to set"); - auto tx = vault.set({.owner = owner, .id = keylet::skip().key}); - tx[sfAssetsMaximum] = asset(0).number(); - env(tx, Ter(tecNO_ENTRY)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault) { - testcase("nothing to deposit to"); - auto tx = vault.deposit( - {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)}); - env(tx, Ter(tecNO_ENTRY)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault) { - testcase("nothing to withdraw from"); - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)}); - env(tx, Ter(tecNO_ENTRY)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - testcase("nothing to delete"); - auto tx = vault.del({.owner = owner, .id = keylet::skip().key}); - env(tx, Ter(tecNO_ENTRY)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - testcase("transaction is good"); - env(tx); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfWithdrawalPolicy] = 1; - testcase("explicitly select withdrawal policy"); - env(tx); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - testcase("insufficient fee"); - env(tx, Fee(env.current()->fees().base - 1), Ter(telINSUF_FEE_P)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - testcase("insufficient reserve"); - // It is possible to construct a complicated mathematical - // expression for this amount, but it is sadly not easy. - env(pay(owner, issuer, XRP(775))); - env.close(); - env(tx, Ter(tecINSUFFICIENT_RESERVE)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfFlags] = tfVaultPrivate; - tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); - testcase("non-existing domain"); - env(tx, Ter{tecOBJECT_NOT_FOUND}); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - testcase("cannot set Scale=0"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 0; - env(tx, Ter{temMALFORMED}); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - testcase("cannot set Scale=1"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 1; - env(tx, Ter{temMALFORMED}); - }); - } - - void - testCreateFailIOU() - { - using namespace test::jtx; - { - { - testcase("IOU fail because MPT is disabled"); - Env env{*this, (testableAmendments() - featureMPTokensV1)}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - env.fund(XRP(1000), issuer, owner); - env.close(); - - Vault const vault{env}; - Asset const asset = issuer["IOU"].asset(); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - env(tx, Ter(temDISABLED)); - env.close(); - } - - { - testcase("IOU fail create frozen"); - Env env{*this, testableAmendments()}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - env.fund(XRP(1000), issuer, owner); - env.close(); - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - Vault const vault{env}; - Asset const asset = issuer["IOU"].asset(); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - env(tx, Ter(tecFROZEN)); - env.close(); - } - - { - testcase("IOU fail create no ripling"); - Env env{*this, testableAmendments()}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - env.fund(XRP(1000), issuer, owner); - env.close(); - env(fclear(issuer, asfDefaultRipple)); - env.close(); - - Vault const vault{env}; - Asset const asset = issuer["IOU"].asset(); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(terNO_RIPPLE)); - env.close(); - } - - { - testcase("IOU no issuer"); - Env env{*this, testableAmendments()}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - env.fund(XRP(1000), owner); - env.close(); - - Vault const vault{env}; - Asset const asset = issuer["IOU"].asset(); - { - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(terNO_ACCOUNT)); - env.close(); - } - } - } - - { - testcase("IOU fail create vault for AMM LPToken"); - Env env{*this, testableAmendments()}; - Account const gw("gateway"); - Account const alice("alice"); - Account const carol("carol"); - IOU const usd = gw["USD"]; - - auto const [asset1, asset2] = std::pair(XRP(10000), usd(10000)); - auto toFund = [&](STAmount const& a) -> STAmount { - if (a.native()) - { - auto const defXRP = XRP(30000); - if (a <= defXRP) - return defXRP; - return a + XRP(1000); - } - auto defIOU = STAmount{a.asset(), 30000}; - if (a <= defIOU) - return defIOU; - return a + STAmount{a.asset(), 1000}; - }; - auto const toFund1 = toFund(asset1); - auto const toFund2 = toFund(asset2); - BEAST_EXPECT(asset1 <= toFund1 && asset2 <= toFund2); - - if (!asset1.native() && !asset2.native()) - { - fund(env, gw, {alice, carol}, {toFund1, toFund2}, Fund::All); - } - else if (asset1.native()) - { - fund(env, gw, {alice, carol}, toFund1, {toFund2}, Fund::All); - } - else if (asset2.native()) - { - fund(env, gw, {alice, carol}, toFund2, {toFund1}, Fund::All); - } - - AMM const ammAlice(env, alice, asset1, asset2, CreateArg{.log = false, .tfee = 0}); - - Account const owner{"owner"}; - env.fund(XRP(1000000), owner); - - Vault const vault{env}; - auto [tx, k] = vault.create({.owner = owner, .asset = ammAlice.lptIssue()}); - env(tx, Ter{tecWRONG_ASSET}); - env.close(); - } - } - - void - testCreateFailMPT() - { - using namespace test::jtx; - - auto testCase = [this]( - std::function test) { - 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 vault{env}; - MPTTester mptt{env, issuer, kMptInitNoFund}; - // Locked because that is the default flag. - mptt.create(); - Asset const asset = mptt.issuanceID(); - - test(env, issuer, owner, depositor, asset, vault); - }; - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - testcase("MPT no authorization"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(tecNO_AUTH)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - testcase("MPT cannot set Scale=0"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 0; - env(tx, Ter{temMALFORMED}); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault) { - testcase("MPT cannot set Scale=1"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 1; - env(tx, Ter{temMALFORMED}); - }); - } - - 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(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 - testWithMPT() - { - using namespace test::jtx; - - struct CaseArgs - { - bool enableClawback = true; - bool requireAuth = true; - int initialXRP = 1000; - FeatureBitset features = testableAmendments(); - }; - - auto testCase = [this]( - std::function test, - CaseArgs args = {}) { - Env env{*this, args.features}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - env.fund(XRP(args.initialXRP), issuer, owner, depositor); - env.close(); - Vault vault{env}; - - MPTTester mptt{env, issuer, kMptInitNoFund}; - auto const kNone = LedgerSpecificFlags(0); - mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock | - (args.enableClawback ? tfMPTCanClawback : kNone) | - (args.requireAuth ? tfMPTRequireAuth : kNone)}); - PrettyAsset const asset = mptt.issuanceID(); - mptt.authorize({.account = owner}); - mptt.authorize({.account = depositor}); - if (args.requireAuth) - { - mptt.authorize({.account = issuer, .holder = owner}); - mptt.authorize({.account = issuer, .holder = depositor}); - } - - env(pay(issuer, depositor, asset(1000))); - env.close(); - - test(env, issuer, owner, depositor, asset, vault, mptt); - }; - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT nothing to clawback from"); - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet::skip().key, - .holder = depositor, - .amount = asset(10)}); - env(tx, Ter(tecNO_ENTRY)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT global lock blocks create"); - mptt.set({.account = issuer, .flags = tfMPTLock}); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(tecLOCKED)); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT only issuer can clawback"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - env(tx); - env.close(); - - { - auto tx = vault.clawback({ - .issuer = depositor, - .id = keylet.key, - .holder = depositor, - }); - env(tx, Ter(tecNO_PERMISSION)); - } - - { - auto tx = vault.clawback({ - .issuer = owner, - .id = keylet.key, - .holder = depositor, - }); - env(tx, Ter(tecNO_PERMISSION)); - } - }); - - testCase( - [this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT depositor without MPToken, auth required"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - tx = vault.deposit( - {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); - env(tx); - env.close(); - - { - // Remove depositor MPToken and it will not be re-created - mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); - - tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - env(tx, Ter{tecNO_AUTH}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - } - - { - // Set destination to 3rd party without MPToken - Account const charlie{"charlie"}; - env.fund(XRP(1000), charlie); - env.close(); - - tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - tx[sfDestination] = charlie.human(); - env(tx, Ter(tecNO_AUTH)); - } - }, - {.requireAuth = true}); - - testCase( - [this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT depositor without MPToken, no auth required"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - auto v = env.le(keylet); - BEAST_EXPECT(v); - - tx = vault.deposit( - {.depositor = depositor, - .id = keylet.key, - .amount = asset(1000)}); // all assets held by depositor - env(tx); - env.close(); - - { - // Remove depositor's MPToken and it will be re-created - mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); - - tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - env(tx); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 != nullptr); - BEAST_EXPECT(sleMPT2->at(sfMPTAmount) == 100); - } - - { - // Remove 3rd party MPToken and it will not be re-created - mptt.authorize({.account = owner, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); - - tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - tx[sfDestination] = owner.human(); - env(tx, Ter(tecNO_AUTH)); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - } - }, - {.requireAuth = false}); - - auto const [acctReserve, incReserve] = [this]() -> std::pair { - Env const env{*this, testableAmendments()}; - return { - env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), - env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; - }(); - - testCase( - [&, this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT fail reserve to re-create MPToken"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - auto v = env.le(keylet); - BEAST_EXPECT(v); - - env(pay(depositor, owner, asset(1000))); - env.close(); - - tx = vault.deposit( - {.depositor = owner, - .id = keylet.key, - .amount = asset(1000)}); // all assets held by owner - env(tx); - env.close(); - - { - // Remove owners's MPToken and it will not be re-created - mptt.authorize({.account = owner, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner); - auto const sleMPT = env.le(mptoken); - BEAST_EXPECT(sleMPT == nullptr); - - // Use one reserve so the next transaction fails - env(ticket::create(owner, 1)); - env.close(); - - // No reserve to create MPToken for asset in VaultWithdraw - tx = vault.withdraw( - {.depositor = owner, .id = keylet.key, .amount = asset(100)}); - env(tx, Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - env(pay(depositor, owner, XRP(incReserve))); - env.close(); - - // Withdraw can now create asset MPToken, tx will succeed - env(tx); - env.close(); - } - }, - {.requireAuth = false, .initialXRP = acctReserve + (incReserve * 4) + 1}); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT issuance deleted"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); - env(tx); - env.close(); - - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)}); - env(tx); - } - - mptt.destroy({.issuer = issuer, .id = mptt.issuanceID()}); - env.close(); - - { - auto [tx, keylet] = vault.create({.owner = depositor, .asset = asset}); - env(tx, Ter{tecOBJECT_NOT_FOUND}); - } - - { - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); - env(tx, Ter{tecOBJECT_NOT_FOUND}); - } - - { - auto tx = - vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); - env(tx, Ter{tecOBJECT_NOT_FOUND}); - } - - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)}); - env(tx, Ter{tecOBJECT_NOT_FOUND}); - } - - env(vault.del({.owner = owner, .id = keylet.key})); - }); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT vault owner can receive shares unless unauthorized"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); - env(tx); - env.close(); - - auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID { - auto const vault = env.le(keylet); - return vault->at(sfShareMPTID); - }(keylet); - PrettyAsset const shares = MPTIssue(issuanceId); - - { - // owner has MPToken for shares they did not explicitly create - env(pay(depositor, owner, shares(1))); - env.close(); - - tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = shares(1)}); - env(tx); - env.close(); - - // owner's MPToken for vault shares not destroyed by withdraw - env(pay(depositor, owner, shares(1))); - env.close(); - - tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)}); - env(tx); - env.close(); - - // owner's MPToken for vault shares not destroyed by clawback - env(pay(depositor, owner, shares(1))); - env.close(); - - // pay back, so we can destroy owner's MPToken now - env(pay(owner, depositor, shares(1))); - env.close(); - - { - // explicitly destroy vault owners MPToken with zero balance - json::Value jv; - jv[sfAccount] = owner.human(); - jv[sfMPTokenIssuanceID] = to_string(issuanceId); - jv[sfFlags] = tfMPTUnauthorize; - jv[sfTransactionType] = jss::MPTokenAuthorize; - env(jv); - env.close(); - } - - // owner no longer has MPToken for vault shares - tx = pay(depositor, owner, shares(1)); - env(tx, Ter{tecNO_AUTH}); - env.close(); - - // destroy all remaining shares, so we can delete vault - tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)}); - env(tx); - env.close(); - - // will soft fail destroying MPToken for vault owner - env(vault.del({.owner = owner, .id = keylet.key})); - env.close(); - } - }); - - testCase( - [this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - PrettyAsset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT clawback disabled"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - tx = vault.deposit( - {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); - env(tx); - env.close(); - - { - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet.key, - .holder = depositor, - .amount = asset(0)}); - env(tx, Ter{tecNO_PERMISSION}); - } - }, - {.enableClawback = false}); - - testCase([this]( - Env& env, - Account const& issuer, - Account const& owner, - Account const& depositor, - Asset const& asset, - Vault& vault, - MPTTester& mptt) { - testcase("MPT un-authorization"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); - env(tx); - env.close(); - - mptt.authorize({.account = issuer, .holder = depositor, .flags = tfMPTUnauthorize}); - env.close(); - - { - auto tx = vault.withdraw( - {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - env(tx, Ter(tecNO_AUTH)); - - // Withdrawal to other (authorized) accounts works - tx[sfDestination] = issuer.human(); - env(tx); - env.close(); - - tx[sfDestination] = owner.human(); - env(tx); - env.close(); - } - - { - // Cannot deposit some more - auto tx = - vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); - env(tx, Ter(tecNO_AUTH)); - } - - { - // Cannot clawback if issuer is the holder - tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = issuer, .amount = asset(800)}); - env(tx, Ter(tecNO_PERMISSION)); - } - // Clawback works - tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(800)}); - env(tx); - env.close(); - - env(vault.del({.owner = owner, .id = keylet.key})); - }); - - { - testcase("MPT shares to a vault"); - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const issuer{"issuer"}; - env.fund(XRP(1000000), owner, issuer); - env.close(); - Vault const vault{env}; - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock | lsfMPTCanClawback | tfMPTRequireAuth}); - mptt.authorize({.account = owner}); - mptt.authorize({.account = issuer, .holder = owner}); - PrettyAsset const asset = mptt.issuanceID(); - env(pay(issuer, owner, asset(100))); - auto [tx1, k1] = vault.create({.owner = owner, .asset = asset}); - env(tx1); - env.close(); - - auto const shares = [&env, keylet = k1, this]() -> Asset { - auto const vault = env.le(keylet); - BEAST_EXPECT(vault != nullptr); - return MPTIssue(vault->at(sfShareMPTID)); - }(); - - auto [tx2, k2] = vault.create({.owner = owner, .asset = shares}); - env(tx2, Ter{tecWRONG_ASSET}); - env.close(); - } - - { - testcase("MPT locked: vault shares inherit underlying lock"); - - Env env{*this, testableAmendments()}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - Account const alice{"alice"}; - Account const bob{"bob"}; - Account const carol{"carol"}; - env.fund(XRP(10'000), issuer, owner, alice, bob, carol); - env.close(); - Vault const vault{env}; - - MPTTester asset{ - {.env = env, - .issuer = issuer, - .holders = {owner, alice, bob, carol}, - .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}}; - env(pay(issuer, alice, asset(1'000))); - env(pay(issuer, bob, asset(1'000))); - env.close(); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)})); - // Bob also deposits so he has a share MPToken to receive into. - env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)})); - env.close(); - - auto const shares = [&]() -> PrettyAsset { - auto const sle = env.le(keylet); - BEAST_EXPECT(sle != nullptr); - return MPTIssue(sle->at(sfShareMPTID)); - }(); - auto const shareMptID = shares.raw().get().getMptID(); - auto const shareBalance = [&](Account const& account) { - auto const sle = env.le(keylet::mptoken(shareMptID, account)); - return sle ? sle->at(sfMPTAmount) : 0; - }; - - // Sanity: before the underlying lock, peer-to-peer share - // transfers are allowed. - env(pay(alice, bob, shares(1))); - env.close(); - - // Create the offer while shares are spendable, then lock the - // underlying to test whether a stale offer can still be crossed. - env(offer(alice, XRP(1), shares(1))); - env.close(); - - // Lock the underlying after the vault and share balances exist. - asset.set({.account = issuer, .flags = tfMPTLock}); - env.close(); - - // Direct vault share payment inherits the underlying lock via - // sfReferenceHolding. - BEAST_EXPECT(shareBalance(alice) == 499); - BEAST_EXPECT(shareBalance(bob) == 501); - env(pay(alice, bob, shares(1)), Ter{tecLOCKED}); - env.close(); - BEAST_EXPECT(shareBalance(alice) == 499); - BEAST_EXPECT(shareBalance(bob) == 501); - - // The same inherited lock must also block DEX payment paths that - // would consume an offer selling vault shares. - env(pay(carol, bob, shares(1)), - Sendmax(XRP(1)), - Path(BookSpec{shares.raw()}), - Ter{tecPATH_PARTIAL}); - env.close(); - BEAST_EXPECT(shareBalance(alice) == 499); - BEAST_EXPECT(shareBalance(bob) == 501); - BEAST_EXPECT(expectOffers(env, alice, 1)); - } - - { - testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM"); - - Env env{*this, testableAmendments()}; - Account const issuer{"issuer"}; - Account const owner{"owner"}; - Account const alice{"alice"}; - Account const bob{"bob"}; - env.fund(XRP(100'000), issuer, owner, alice, bob); - env.close(); - Vault const vault{env}; - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const asset = mptt.issuanceID(); - mptt.authorize({.account = owner}); - mptt.authorize({.account = alice}); - mptt.authorize({.account = bob}); - env(pay(issuer, alice, asset(10'000))); - env(pay(issuer, bob, asset(10'000))); - env.close(); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - // Seed shares so we can later place them on trading venues. - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)})); - env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)})); - env.close(); - - auto const shares = [&]() -> PrettyAsset { - auto const sle = env.le(keylet); - BEAST_EXPECT(sle != nullptr); - return MPTIssue(sle->at(sfShareMPTID)); - }(); - - // CanTrade is not set on the underlying, both the asset and - // the vault share are blocked on the DEX. - env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); - env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION}); - env.close(); - - // Deposit still works before enabling CanTrade. - env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)})); - env.close(); - - // Peer-to-peer share transfers still work (CanTransfer is set on - // both layers). - env(pay(alice, bob, shares(1))); - env.close(); - - // Withdraw still works before enabling CanTrade. - env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)})); - env.close(); - - // Enable CanTrade on the underlying. - mptt.set({.flags = tfMPTSetCanTrade}); - env.close(); - - env(offer(alice, XRP(1), asset(10))); - env(offer(alice, XRP(1), shares(1))); - env.close(); - - AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000)); - } - - { - testcase("MPT OutstandingAmount > MaximumAmount"); - - Env env{*this, testableAmendments() | featureSingleAssetVault}; - Account const alice{"alice"}; - Account const issuer{"issuer"}; - env.fund(XRP(1'000), alice, issuer); - env.close(); - Vault const vault{env}; - - MPTTester const btc({.env = env, .issuer = issuer, .holders = {alice}, .maxAmt = 100}); - - auto [tx, k] = vault.create({.owner = issuer, .asset = btc}); - env(tx); - env.close(); - - tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(110)}); - // accountHolds is the first check and the issuer has only BTC(100) - // available - env(tx, Ter{tecINSUFFICIENT_FUNDS}); - env.close(); - - // OutstandingAmount == MaximumAmount - env(pay(issuer, alice, btc(100))); - env.close(); - - tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(100)}); - // the issuer has BTC(0) available - env(tx, Ter{tecINSUFFICIENT_FUNDS}); - env.close(); - - tx = vault.deposit({.depositor = alice, .id = k.key, .amount = btc(100)}); - // alice transfers BTC(100), OutstandingAmount is 100 - env(tx); - env.close(); - } - } - - void - testWithIOU() - { - using namespace test::jtx; - - struct CaseArgs - { - int initialXRP = 1000; - Number initialIOU = 200; - double transferRate = 1.0; - bool charlieRipple = true; - FeatureBitset features = testableAmendments(); - }; - - auto testCase = [&, this]( - std::function vaultAccount, - Vault& vault, - PrettyAsset const& asset, - std::function issuanceId)> test, - CaseArgs args = {}) { - Env env{*this, args.features}; - Account const owner{"owner"}; - Account const issuer{"issuer"}; - Account const charlie{"charlie"}; - Vault vault{env}; - env.fund(XRP(args.initialXRP), issuer, owner, charlie); - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const asset = issuer["IOU"]; - env.trust(asset(1000), owner); - env(pay(issuer, owner, asset(args.initialIOU))); - env.close(); - if (!args.charlieRipple) - { - env(fset(issuer, 0, asfDefaultRipple)); - env.close(); - env.trust(asset(1000), charlie); - env.close(); - env(pay(issuer, charlie, asset(args.initialIOU))); - env.close(); - env(fset(issuer, asfDefaultRipple)); - } - else - { - env.trust(asset(1000), charlie); - } - env.close(); - env(rate(issuer, args.transferRate)); - env.close(); - - auto const vaultAccount = [&env](xrpl::Keylet keylet) -> Account { - return Account("vault", env.le(keylet)->at(sfAccount)); - }; - auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID { - return env.le(keylet)->at(sfShareMPTID); - }; - - test(env, owner, issuer, charlie, vaultAccount, vault, asset, issuanceId); - }; - - testCase([&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const&, - auto vaultAccount, - Vault& vault, - PrettyAsset const& asset, - auto&&...) { - testcase("IOU cannot use different asset"); - PrettyAsset const foo = issuer["FOO"]; - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - { - // Cannot create new trustline to a vault - auto tx = [&, account = vaultAccount(keylet)]() { - json::Value jv; - jv[jss::Account] = issuer.human(); - { - auto& ja = jv[jss::LimitAmount] = - foo(0).value().getJson(JsonOptions::Values::None); - ja[jss::issuer] = toBase58(account); - } - jv[jss::TransactionType] = jss::TrustSet; - jv[jss::Flags] = tfSetFreeze; - return jv; - }(); - env(tx, Ter{tecNO_PERMISSION}); - env.close(); - } - - { - auto tx = vault.deposit({.depositor = issuer, .id = keylet.key, .amount = foo(20)}); - env(tx, Ter{tecWRONG_ASSET}); - env.close(); - } - - { - auto tx = - vault.withdraw({.depositor = issuer, .id = keylet.key, .amount = foo(20)}); - env(tx, Ter{tecWRONG_ASSET}); - env.close(); - } - - env(vault.del({.owner = owner, .id = keylet.key})); - env.close(); - }); - - testCase( - [&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const& charlie, - auto vaultAccount, - Vault& vault, - PrettyAsset const& asset, - auto issuanceId) { - testcase("IOU transfer fees not applied"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)})); - env.close(); - - auto const issue = asset.raw().get(); - Asset const share = Asset(issuanceId(keylet)); - - // transfer fees ignored on deposit - BEAST_EXPECT(env.balance(owner, issue) == asset(100)); - BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100)); - - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)}); - env(tx); - env.close(); - } - - // transfer fees ignored on clawback - BEAST_EXPECT(env.balance(owner, issue) == asset(100)); - BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50)); - - env(vault.withdraw( - {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)})); - - // transfer fees ignored on withdraw - BEAST_EXPECT(env.balance(owner, issue) == asset(120)); - BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30)); - - { - auto tx = vault.withdraw( - {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)}); - tx[sfDestination] = charlie.human(); - env(tx); - } - - // transfer fees ignored on withdraw to 3rd party - BEAST_EXPECT(env.balance(owner, issue) == asset(120)); - BEAST_EXPECT(env.balance(charlie, issue) == asset(30)); - BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0)); - - env(vault.del({.owner = owner, .id = keylet.key})); - env.close(); - }, - CaseArgs{.transferRate = 1.25}); - - testCase([&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const& charlie, - auto, - Vault& vault, - PrettyAsset const& asset, - auto&&...) { - testcase("IOU no trust line to 3rd party"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)})); - env.close(); - - Account const erin{"erin"}; - env.fund(XRP(1000), erin); - env.close(); - - // Withdraw to 3rd party without trust line - auto const tx1 = [&](xrpl::Keylet keylet) { - auto tx = - vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - tx[sfDestination] = erin.human(); - return tx; - }(keylet); - env(tx1, Ter{tecNO_LINE}); - }); - - testCase([&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const& charlie, - auto, - Vault& vault, - PrettyAsset const& asset, - auto&&...) { - testcase("IOU no trust line to depositor"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - // reset limit, so deposit of all funds will delete the trust line - env.trust(asset(0), owner); - env.close(); - - env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)})); - env.close(); - - auto trustline = env.le(keylet::trustLine(owner, asset.raw().get())); - BEAST_EXPECT(trustline == nullptr); - - // Withdraw without trust line, will succeed - auto const tx1 = [&](xrpl::Keylet keylet) { - auto tx = - vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - return tx; - }(keylet); - env(tx1); - }); - - testCase( - [&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const& charlie, - auto vaultAccount, - Vault& vault, - PrettyAsset const& asset, - std::function issuanceId) { - testcase("IOU non-transferable"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 0; - env(tx); - env.close(); - - // Turn on noripple on the pseudo account's trust line. - // Charlie's is already set. - env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple)); - - { - // Charlie cannot deposit - auto tx = vault.deposit( - {.depositor = charlie, .id = keylet.key, .amount = asset(100)}); - env(tx, Ter{terNO_RIPPLE}); - env.close(); - } - - { - PrettyAsset const shares = issuanceId(keylet); - auto tx1 = - vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}); - env(tx1); - env.close(); - - // Charlie cannot receive funds - auto tx2 = vault.withdraw( - {.depositor = owner, .id = keylet.key, .amount = shares(100)}); - tx2[sfDestination] = charlie.human(); - env(tx2, Ter{terNO_RIPPLE}); - env.close(); - - { - // Create MPToken for shares held by Charlie - json::Value tx{json::ValueType::Object}; - tx[sfAccount] = charlie.human(); - tx[sfMPTokenIssuanceID] = - to_string(shares.raw().get().getMptID()); - tx[sfTransactionType] = jss::MPTokenAuthorize; - env(tx); - env.close(); - } - // Behavioral shift introduced by share inheritance: - // before fixCleanup3_2_0 this share Payment succeeded - // and the underlying IOU's NoRipple restriction surfaced - // only later on Charlie's withdrawal (terNO_RIPPLE). - // Post-amendment, canTransfer reads the share's - // sfReferenceHolding and dispatches to the underlying IOU; - // rippling is disabled between owner and charlie so the - // share payment itself is now blocked. tecPATH_DRY is - // the path-find layer's translation of the underlying - // terNO_RIPPLE under featureMPTokensV2. - env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY}); - env.close(); - } - - tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)}); - env(tx); - env.close(); - - // Delete vault with zero balance - env(vault.del({.owner = owner, .id = keylet.key})); - }, - {.charlieRipple = false}); - - testCase( - [&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const& charlie, - auto const& vaultAccount, - Vault& vault, - PrettyAsset const& asset, - auto&&...) { - testcase("IOU calculation rounding"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = 1; - env(tx); - env.close(); - - auto const startingOwnerBalance = env.balance(owner, asset); - BEAST_EXPECT((startingOwnerBalance.value() == STAmount{asset, 11875, -2})); - - // This operation (first deposit 100, then 3.75 x 5) is known to - // have triggered calculation rounding errors in Number - // (addition and division), causing the last deposit to be - // blocked by Vault invariants. - env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)})); - - auto const tx1 = vault.deposit( - {.depositor = owner, .id = keylet.key, .amount = asset(Number(375, -2))}); - for (auto i = 0; i < 5; ++i) - { - env(tx1); - } - env.close(); - - { - STAmount const xfer{asset, 1185, -1}; - BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value() - xfer); - BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == xfer); - - auto const vault = env.le(keylet); - BEAST_EXPECT(vault->at(sfAssetsAvailable) == xfer); - BEAST_EXPECT(vault->at(sfAssetsTotal) == xfer); - } - - // Total vault balance should be 118.5 IOU. Withdraw and delete - // the vault to verify this exact amount was deposited and the - // owner has matching shares - env(vault.withdraw( - {.depositor = owner, - .id = keylet.key, - .amount = asset(Number(1000 + (37 * 5), -1))})); - - { - BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value()); - BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == beast::kZero); - auto const vault = env.le(keylet); - BEAST_EXPECT(vault->at(sfAssetsAvailable) == beast::kZero); - BEAST_EXPECT(vault->at(sfAssetsTotal) == beast::kZero); - } - - env(vault.del({.owner = owner, .id = keylet.key})); - env.close(); - }, - {.initialIOU = Number(11875, -2)}); - - auto const [acctReserve, incReserve] = [this]() -> std::pair { - Env const env{*this, testableAmendments()}; - return { - env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), - env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; - }(); - - testCase( - [&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const& charlie, - auto, - Vault& vault, - PrettyAsset const& asset, - auto&&...) { - testcase("IOU no trust line to depositor no reserve"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - // reset limit, so deposit of all funds will delete the trust - // line - env.trust(asset(0), owner); - env.close(); - - env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)})); - env.close(); - - auto trustline = env.le(keylet::trustLine(owner, asset.raw().get())); - BEAST_EXPECT(trustline == nullptr); - - env(ticket::create(owner, 1)); - env.close(); - - // Fail because not enough reserve to create trust line - tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); - env(tx, Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - env(pay(charlie, owner, XRP(incReserve))); - env.close(); - - // Withdraw can now create trust line, will succeed - env(tx); - env.close(); - }, - CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1}); - - testCase( - [&, this]( - Env& env, - Account const& owner, - Account const& issuer, - Account const& charlie, - auto, - Vault& vault, - PrettyAsset const& asset, - auto&&...) { - testcase("IOU no reserve for share MPToken"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - env(tx); - env.close(); - - env(pay(owner, charlie, asset(100))); - env.close(); - - env(ticket::create(charlie, 3)); - env.close(); - - // Fail because not enough reserve to create MPToken for shares - tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(100)}); - env(tx, Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - env(pay(issuer, charlie, XRP(incReserve))); - env.close(); - - // Deposit can now create MPToken, will succeed - env(tx); - env.close(); - }, - CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1}); - } - - 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().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().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 { - 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}); - } - } - - 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 - testScaleIOU() - { - using namespace test::jtx; - - struct Data - { - Account const& owner; - Account const& issuer; - Account const& depositor; - Account const& vaultAccount; - MPTIssue shares; - PrettyAsset const& share; - Vault& vault; - xrpl::Keylet keylet; - Issue assets; - PrettyAsset const& asset; - std::function)> peek; - }; - - auto testCase = [&, this]( - std::uint8_t scale, std::function test) { - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const issuer{"issuer"}; - Account const depositor{"depositor"}; - Vault vault{env}; - env.fund(XRP(1000), issuer, owner, depositor); - 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(); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - tx[sfScale] = scale; - env(tx); - - auto const [vaultAccount, issuanceId] = - [&env](xrpl::Keylet keylet) -> std::tuple { - auto const vault = env.le(keylet); - return {Account("vault", vault->at(sfAccount)), vault->at(sfShareMPTID)}; - }(keylet); - MPTIssue const shares(issuanceId); - env.memoize(vaultAccount); - - auto const peek = [keylet, &env, this](std::function fn) -> bool { - return env.app().getOpenLedger().modify( - [&](OpenView& view, beast::Journal j) -> bool { - Sandbox sb(&view, TapNone); - auto vault = sb.peek(keylet::vault(keylet.key)); - if (!BEAST_EXPECT(vault)) - return false; - auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID))); - if (!BEAST_EXPECT(shares)) - return false; - if (fn(*vault, *shares)) - { - sb.update(vault); - sb.update(shares); - sb.apply(view); - return true; - } - return false; - }); - }; - - test( - env, - {.owner = owner, - .issuer = issuer, - .depositor = depositor, - .vaultAccount = vaultAccount, - .shares = shares, - .share = PrettyAsset(shares), - .vault = vault, - .keylet = keylet, - .assets = asset.raw().get(), - .asset = asset, - .peek = peek}); - }; - - testCase(18, [&, this](Env& env, Data d) { - testcase("Scale deposit overflow on first deposit"); - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)}); - env(tx, Ter{tecPATH_DRY}); - env.close(); - }); - - testCase(18, [&, this](Env& env, Data d) { - testcase("Scale deposit overflow on second deposit"); - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); - env(tx); - env.close(); - } - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)}); - env(tx, Ter{tecPATH_DRY}); - env.close(); - } - }); - - testCase(18, [&, this](Env& env, Data d) { - testcase("Scale deposit overflow on total shares"); - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); - env(tx); - env.close(); - } - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); - env(tx, Ter{tecPATH_DRY}); - env.close(); - } - }); - - testCase(1, [&, this](Env& env, Data d) { - testcase("Scale deposit exact"); - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(1)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(10)); - BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start - 1)); - }); - - testCase(1, [&, this](Env& env, Data d) { - testcase("Scale deposit insignificant amount"); - - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(9, -2))}); - env(tx, Ter{tecPRECISION_LOSS}); - }); - - testCase(1, [&, this](Env& env, Data d) { - testcase("Scale deposit exact, using full precision"); - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(15, -1))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(15)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(15, -1))); - }); - - testCase(1, [&, this](Env& env, Data d) { - testcase("Scale deposit exact, truncating from .5"); - - auto const start = env.balance(d.depositor, d.assets).number(); - // Each of the cases below will transfer exactly 1.2 IOU to the - // vault and receive 12 shares in exchange - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(125, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start - Number(12, -1))); - } - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(1201, -3))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(24)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start - Number(24, -1))); - } - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(1299, -3))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(36)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start - Number(36, -1))); - } - }); - - testCase(1, [&, this](Env& env, Data d) { - testcase("Scale deposit exact, truncating from .01"); - - auto const start = env.balance(d.depositor, d.assets).number(); - // round to 12 - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(1201, -3))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1))); - - { - // round to 6 - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(69, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start - Number(18, -1))); - } - }); - - testCase(1, [&, this](Env& env, Data d) { - testcase("Scale deposit exact, truncating from .99"); - - auto const start = env.balance(d.depositor, d.assets).number(); - // round to 12 - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(1299, -3))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1))); - - { - // round to 6 - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(62, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start - Number(18, -1))); - } - }); - - testCase(1, [&, this](Env& env, Data d) { - // initial setup: deposit 100 IOU, receive 1000 shares - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(100, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0))); - - { - testcase("Scale redeem exact"); - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 100 * 100 / 1000 = 100 * 0.1 = 10 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.share, Number(100, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0))); - } - - { - testcase("Scale redeem with rounding"); - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5 - - auto const start = env.balance(d.depositor, d.assets).number(); - d.peek([](SLE& vault, auto&) -> bool { - vault[sfAssetsAvailable] = Number(1); - return true; - }); - - // Note, this transaction fails first (because of above change - // in the open ledger) but then succeeds when the ledger is - // closed (because a modification like above is not persistent), - // which is why the checks below are expected to pass. - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.share, Number(25, 0))}); - env(tx, Ter{tecINSUFFICIENT_FUNDS}); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start + Number(25, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(900 - 25, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(900 - 25, 0))); - } - - { - testcase("Scale redeem exact"); - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 87.5 * 21 / 875 = 87.5 * 0.024 = 2.1 - - auto const start = env.balance(d.depositor, d.assets).number(); - - tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.share, Number(21, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 21)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start + Number(21, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(875 - 21, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(875 - 21, 0))); - } - - { - testcase("Scale redeem rest"); - auto const rest = env.balance(d.depositor, d.shares).number(); - - tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.share, rest)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0); - BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0); - BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0); - } - }); - - testCase(18, [&, this](Env& env, Data d) { - testcase("Scale withdraw overflow"); - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); - env(tx); - env.close(); - } - - { - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(10, 0))}); - env(tx, Ter{tecPATH_DRY}); - env.close(); - } - }); - - testCase(1, [&, this](Env& env, Data d) { - // initial setup: deposit 100 IOU, receive 1000 shares - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(100, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0))); - - { - testcase("Scale withdraw exact"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 1000 * 10 / 100 = 1000 * 0.1 = 100 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 100 * 100 / 1000 = 100 * 0.1 = 10 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(10, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0))); - } - - { - testcase("Scale withdraw insignificant amount"); - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(4, -2))}); - env(tx, Ter{tecPRECISION_LOSS}); - } - - { - testcase("Scale withdraw with rounding assets"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5 - - auto const start = env.balance(d.depositor, d.assets).number(); - d.peek([](SLE& vault, auto&) -> bool { - vault[sfAssetsAvailable] = Number(1); - return true; - }); - - // Note, this transaction fails first (because of above change - // in the open ledger) but then succeeds when the ledger is - // closed (because a modification like above is not persistent), - // which is why the checks below are expected to pass. - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(25, -1))}); - env(tx, Ter{tecINSUFFICIENT_FUNDS}); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start + Number(25, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(900 - 25, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(900 - 25, 0))); - } - - { - testcase("Scale withdraw with rounding shares up"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(375, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start + Number(38, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(875 - 38, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(875 - 38, 0))); - } - - { - testcase("Scale withdraw with rounding shares down"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(372, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == - STAmount(d.asset, start + Number(37, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(837 - 37, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(837 - 37, 0))); - } - - { - testcase("Scale withdraw tiny amount"); - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(9, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(800 - 1, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(800 - 1, 0))); - } - - { - testcase("Scale withdraw rest"); - auto const rest = env.balance(d.vaultAccount, d.assets).number(); - - tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, rest)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0); - BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0); - BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0); - } - }); - - testCase(18, [&, this](Env& env, Data d) { - testcase("Scale clawback overflow"); - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); - env(tx); - env.close(); - } - - { - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(10, 0))}); - env(tx, Ter{tecPATH_DRY}); - env.close(); - } - }); - - testCase(1, [&, this](Env& env, Data d) { - // initial setup: deposit 100 IOU, receive 1000 shares - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(100, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); - BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(1000, 0))); - { - testcase("Scale clawback exact"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 1000 * 10 / 100 = 1000 * 0.1 = 100 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 100 * 100 / 1000 = 100 * 0.1 = 10 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(10, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900)); - BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(900, 0))); - } - - { - testcase("Scale clawback insignificant amount"); - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(4, -2))}); - env(tx, Ter{tecPRECISION_LOSS}); - } - - { - testcase("Scale clawback with rounding assets"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(25, -1))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25)); - BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(900 - 25, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(900 - 25, 0))); - } - - { - testcase("Scale clawback with rounding shares up"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(375, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); - BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(875 - 38, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(875 - 38, 0))); - } - - { - testcase("Scale clawback with rounding shares down"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(372, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); - BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(837 - 37, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(837 - 37, 0))); - } - - { - testcase("Scale clawback tiny amount"); - - auto const start = env.balance(d.depositor, d.assets).number(); - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(9, -2))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); - BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(800 - 1, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(800 - 1, 0))); - } - - { - testcase("Scale clawback rest"); - auto const rest = env.balance(d.vaultAccount, d.assets).number(); - d.peek([](SLE& vault, auto&) -> bool { - vault[sfAssetsAvailable] = Number(5); - return true; - }); - - // Note, this transaction yields two different results: - // * in the open ledger, with AssetsAvailable = 5 - // * when the ledger is closed with unmodified AssetsAvailable - // because a modification like above is not persistent. - tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, rest)}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0); - BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0); - BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0); - } - }); - - // Non-1:1 ratio (scale=1, 10:1 shares:assets) with an outstanding loan. - // Deposit 100 IOU → 1000 shares. Borrow 40 → assetsAvailable=60. - // Clawback 80 IOU → clamped to 60, then share math uses truncation. - testCase(1, [&, this](Env& env, Data d) { - using namespace loan_broker; - using namespace loan; - - testcase("Scale clawback clamped with outstanding loan"); - - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(100, 0))}); - env(tx); - env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); - - // Create a loan broker backed by this vault - auto const brokerKeylet = - keylet::loanBroker(d.owner.id(), SeqProxy::rawSequence(env.seq(d.owner))); - env(set(d.owner, d.keylet.key)); - env.close(); - - // Borrow 40: assetsAvailable=60, assetsTotal=100 - env(set(d.depositor, brokerKeylet.key, STAmount(d.asset, Number(40, 0))), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, d.owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(d.keylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(60, 0))); - BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(100, 0))); - } - - // Request 80 IOU clawback — clamped to assetsAvailable (60) - // With scale=1 (10:1), 60 assets = 600 shares destroyed - tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(80, 0))}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(d.keylet); - BEAST_EXPECT(sle != nullptr); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0, 0))); - BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(40, 0))); - - // 600 of 1000 shares destroyed, 400 remain - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400)); - } - }); - } - - 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]); - } - } - - void - testVaultClawbackBurnShares() - { - using namespace test::jtx; - using namespace loan_broker; - using namespace loan; - Env env(*this, beast::Severity::Warning); - - auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) { - auto const sleVault = env.le(vaultKeylet); - BEAST_EXPECT(sleVault != nullptr); - - return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal)); - }; - - auto const vaultShareBalance = [&](Keylet const& vaultKeylet) { - auto const sleVault = env.le(vaultKeylet); - BEAST_EXPECT(sleVault != nullptr); - - auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); - BEAST_EXPECT(sleIssuance != nullptr); - - return sleIssuance->at(sfOutstandingAmount); - }; - - auto const setupVault = [&](PrettyAsset const& asset, - Account const& owner, - Account const& depositor) -> std::pair { - Vault const vault{env}; - - auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - auto const& vaultSle = env.le(vaultKeylet); - BEAST_EXPECT(vaultSle != nullptr); - - Asset const share = vaultSle->at(sfShareMPTID); - - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), - Ter(tesSUCCESS)); - env.close(); - - auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet); - BEAST_EXPECT(availablePreDefault == totalPreDefault); - BEAST_EXPECT(availablePreDefault == asset(100).value()); - - // attempt to clawback shares while there are assets fails - env(vault.clawback( - {.issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - .amount = share(0).value()}), - Ter(tecNO_PERMISSION)); - env.close(); - - auto const& sharesAvailable = vaultShareBalance(vaultKeylet); - auto const& brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - - env(set(owner, vaultKeylet.key)); - env.close(); - - auto const& loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); - - // Create a simple Loan for the full amount of Vault assets - env(set(depositor, brokerKeylet.key, asset(100).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // attempt to clawback shares while there assetsAvailable == 0 and - // assetsTotal > 0 fails - env(vault.clawback( - {.issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - .amount = share(0).value()}), - Ter(tecNO_PERMISSION)); - env.close(); - - env.close(std::chrono::seconds{120 + 60}); - - env(manage(owner, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - - auto const& [availablePostDefault, totalPostDefault] = vaultAssetBalance(vaultKeylet); - - BEAST_EXPECT(availablePostDefault == totalPostDefault); - BEAST_EXPECT(availablePostDefault == asset(0).value()); - BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable); - - return std::make_pair(vault, vaultKeylet); - }; - - auto const testCase = [&](PrettyAsset const& asset, - std::string const& prefix, - Account const& owner, - Account const& depositor) { - { - testcase("VaultClawback (share) - " + prefix + " owner asset clawback fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); - // when asset is XRP or owner is not issuer clawback fail - // when owner is issuer precision loss occurs as vault is - // empty - auto const expectedTer = [&]() { - if (asset.native()) - return Ter(temMALFORMED); - if (asset.raw().getIssuer() != owner.id()) - return Ter(tecNO_PERMISSION); - return Ter(tecPRECISION_LOSS); - }(); - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(100).value(), - }), - expectedTer); - env.close(); - } - - { - testcase( - "VaultClawback (share) - " + prefix + " owner incomplete share clawback fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); - auto const& vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - Asset const share = vaultSle->at(sfShareMPTID); - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - .amount = share(1).value(), - }), - Ter(tecLIMIT_EXCEEDED)); - env.close(); - } - - { - testcase( - "VaultClawback (share) - " + prefix + - " owner implicit complete share clawback"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - }), - // when owner is issuer implicit clawback fails - asset.native() || asset.raw().getIssuer() != owner.id() ? Ter(tesSUCCESS) - : Ter(tecWRONG_ASSET)); - env.close(); - } - - { - testcase( - "VaultClawback (share) - " + prefix + - " owner explicit complete share clawback succeeds"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); - auto const& vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - Asset const share = vaultSle->at(sfShareMPTID); - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - .amount = share(vaultShareBalance(vaultKeylet)).value(), - }), - Ter(tesSUCCESS)); - env.close(); - } - { - testcase("VaultClawback (share) - " + prefix + " owner can clawback own shares"); - auto [vault, vaultKeylet] = setupVault(asset, owner, owner); - auto const& vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - Asset const share = vaultSle->at(sfShareMPTID); - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = owner, - .amount = share(vaultShareBalance(vaultKeylet)).value(), - }), - Ter(tesSUCCESS)); - env.close(); - } - - { - testcase("VaultClawback (share) - " + prefix + " empty vault share clawback fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, owner); - auto const& vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - Asset const share = vaultSle->at(sfShareMPTID); - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = owner, - .amount = share(vaultShareBalance(vaultKeylet)).value(), - }), - Ter(tesSUCCESS)); - - // Now the vault is empty, clawback again fails - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = owner, - .amount = share(vaultShareBalance(vaultKeylet)).value(), - }), - Ter(tecNO_PERMISSION)); - env.close(); - } - }; - - Account const owner{"alice"}; - Account const depositor{"bob"}; - Account const issuer{"issuer"}; - - env.fund(XRP(10000), issuer, owner, depositor); - env.close(); - - // Test XRP - PrettyAsset const xrp = xrpIssue(); - testCase(xrp, "XRP", owner, depositor); - testCase(xrp, "XRP (depositor is owner)", owner, owner); - - // Test IOU - PrettyAsset const iou = issuer["IOU"]; - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - env.trust(iou(1000), owner); - env.trust(iou(1000), depositor); - env(pay(issuer, owner, iou(100))); - env(pay(issuer, depositor, iou(100))); - env.close(); - testCase(iou, "IOU", owner, depositor); - testCase(iou, "IOU (owner is issuer)", issuer, depositor); - - // Test MPT - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mpt = mptt.issuanceID(); - mptt.authorize({.account = owner}); - mptt.authorize({.account = depositor}); - env(pay(issuer, owner, mpt(1000))); - env(pay(issuer, depositor, mpt(1000))); - env.close(); - testCase(mpt, "MPT", owner, depositor); - testCase(mpt, "MPT (owner is issuer)", issuer, depositor); - } - - void - testVaultClawbackAssets() - { - using namespace test::jtx; - using namespace loan_broker; - using namespace loan; - Env env(*this); - env.enableFeature(fixCleanup3_1_3); - - auto const setupVault = [&](PrettyAsset const& asset, - Account const& owner, - Account const& depositor, - Account const& issuer) -> std::pair { - Vault const vault{env}; - - auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - auto const& vaultSle = env.le(vaultKeylet); - BEAST_EXPECT(vaultSle != nullptr); - env.memoize(Account("vault", vaultSle->at(sfAccount))); - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), - Ter(tesSUCCESS)); - env.close(); - - return std::make_pair(vault, vaultKeylet); - }; - - auto const testCase = [&](PrettyAsset const& asset, - std::string const& prefix, - Account const& owner, - Account const& depositor, - Account const& issuer) { - if (asset.native()) - { - testcase("VaultClawback (asset) - " + prefix + " issuer XRP clawback fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - // If the asset is XRP, clawback with amount fails as malformed - // when asset is specified. - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = issuer, - .amount = asset(1).value(), - }), - Ter(temMALFORMED)); - // When asset is implicit, clawback fails as no permission. - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = issuer, - }), - Ter(tecNO_PERMISSION)); - return; - } - - { - testcase( - "VaultClawback (asset) - " + prefix + " clawback for different asset fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - Account const issuer2{"issuer2"}; - PrettyAsset const asset2 = issuer2["FOO"]; - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset2(1).value(), - }), - Ter(tecWRONG_ASSET)); - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " ambiguous owner/issuer asset clawback fails"); - auto [vault, vaultKeylet] = setupVault(asset, issuer, depositor, issuer); - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = issuer, - }), - Ter(tecWRONG_ASSET)); - } - - { - testcase("VaultClawback (asset) - " + prefix + " non-issuer asset clawback fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - }), - Ter(tecNO_PERMISSION)); - - env(vault.clawback({ - .issuer = owner, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(1).value(), - }), - Ter(tecNO_PERMISSION)); - } - - { - testcase("VaultClawback (asset) - " + prefix + " issuer clawback from self fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, issuer, issuer); - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = issuer, - }), - Ter(tecNO_PERMISSION)); - } - - { - testcase("VaultClawback (asset) - " + prefix + " issuer share clawback fails"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - auto const& vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - Asset const share = vaultSle->at(sfShareMPTID); - - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = share(1).value(), - }), - Ter(tecNO_PERMISSION)); - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " partial issuer asset clawback succeeds"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(1).value(), - }), - Ter(tesSUCCESS)); - } - - { - testcase( - "VaultClawback (asset) - " + prefix + " full issuer asset clawback succeeds"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(100).value(), - }), - Ter(tesSUCCESS)); - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " implicit full issuer asset clawback succeeds"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - }), - Ter(tesSUCCESS)); - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " zero-amount clawback clamped with outstanding loan"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - auto const vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - // Create a loan broker backed by this vault - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(set(owner, vaultKeylet.key)); - env.close(); - - // Depositor borrows 40 units, reducing assetsAvailable to 60 - // while assetsTotal stays at 100 - env(set(depositor, brokerKeylet.key, asset(40).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); - } - - // Zero-amount clawback (= "clawback all") should succeed, - // clamped to assetsAvailable (60) rather than the full - // share value (100). - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - }), - Ter(tesSUCCESS)); - env.close(); - - // Only 60 assets clawed back; loan's 40 still outstanding - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle != nullptr); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); - - // 60 of 100 shares destroyed (1:1 ratio), 40 remain - auto const sharesAfter = env.balance(depositor, shares); - BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1})); - } - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " non-zero clawback clamped with outstanding loan"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - auto const vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - // Create a loan broker backed by this vault - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(set(owner, vaultKeylet.key)); - env.close(); - - // Depositor borrows 40 units - env(set(depositor, brokerKeylet.key, asset(40).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); - } - - // Request 100 but only 60 available — clamped to 60 - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(100).value(), - }), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle != nullptr); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); - - // 60 of 100 shares destroyed (1:1 ratio), 40 remain - auto const sharesAfter = env.balance(depositor, shares); - BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1})); - } - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " partial clawback below available with outstanding loan"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - auto const vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - // Create a loan broker backed by this vault - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(set(owner, vaultKeylet.key)); - env.close(); - - // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100 - env(set(depositor, brokerKeylet.key, asset(40).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); - } - - // Clawback 30 — well under available (60), no clamping needed - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(30).value(), - }), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle != nullptr); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(30).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(70).value()); - - // 30 of 100 shares destroyed (1:1 ratio), 70 remain - auto const sharesAfter = env.balance(depositor, shares); - BEAST_EXPECT(sharesAfter == shares(Number{7, sle->at(sfScale) + 1})); - } - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " clawback exactly equal to available with outstanding loan"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - auto const vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(set(owner, vaultKeylet.key)); - env.close(); - - // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100 - env(set(depositor, brokerKeylet.key, asset(40).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // Clawback exactly 60 — at the boundary, no clamping needed - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(60).value(), - }), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle != nullptr); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); - - // 60 of 100 shares destroyed (1:1 ratio), 40 remain - auto const sharesAfter = env.balance(depositor, shares); - BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1})); - } - } - - { - testcase( - "VaultClawback (asset) - " + prefix + - " clawback with zero available (fully borrowed)"); - auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); - - auto const vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(set(owner, vaultKeylet.key)); - env.close(); - - // Depositor borrows all 100 units: assetsAvailable=0, assetsTotal=100 - env(set(depositor, brokerKeylet.key, asset(100).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); - } - - auto const sharesBefore = env.balance(depositor, shares); - - // Zero-amount clawback — nothing available, clamped to 0, - // resulting in zero shares destroyed → tecPRECISION_LOSS - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - }), - Ter(tecPRECISION_LOSS)); - env.close(); - - // Explicit amount clawback — also nothing available - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - .amount = asset(50).value(), - }), - Ter(tecPRECISION_LOSS)); - env.close(); - - { - // Nothing changed — vault and shares unchanged - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle != nullptr); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); - auto const sharesAfter = env.balance(depositor, shares); - BEAST_EXPECT(sharesAfter == sharesBefore); - } - } - }; - - Account const owner{"alice"}; - Account const depositor{"bob"}; - Account const issuer{"issuer"}; - - env.fund(XRP(10000), issuer, owner, depositor); - env.close(); - - // Test XRP - PrettyAsset const xrp = xrpIssue(); - testCase(xrp, "XRP", owner, depositor, issuer); - - // Test IOU - PrettyAsset const iou = issuer["IOU"]; - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - env.trust(iou(2000), owner); - env.trust(iou(2000), depositor); - env(pay(issuer, owner, iou(2000))); - env(pay(issuer, depositor, iou(2000))); - env.close(); - testCase(iou, "IOU", owner, depositor, issuer); - - // Test MPT - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - - PrettyAsset const mpt = mptt.issuanceID(); - mptt.authorize({.account = owner}); - mptt.authorize({.account = depositor}); - env(pay(issuer, depositor, mpt(2000))); - env.close(); - testCase(mpt, "MPT", owner, depositor, issuer); - - // Test pre-fixCleanup3_1_3 legacy path: zero-amount clawback - // returns early without clamping to assetsAvailable. - { - testcase( - "VaultClawback (asset) - IOU pre-fixCleanup3_1_3" - " zero-amount clawback unclamped with outstanding loan"); - - env.disableFeature(fixCleanup3_1_3); - - auto [vault, vaultKeylet] = setupVault(iou, owner, depositor, issuer); - - auto const vaultSle = env.le(vaultKeylet); - BEAST_EXPECT(vaultSle != nullptr); - if (!vaultSle) - return; - - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - // Create a loan broker backed by this vault - auto const brokerKeylet = - keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(set(owner, vaultKeylet.key)); - env.close(); - - // Depositor borrows 40 units, reducing assetsAvailable to 60 - // while assetsTotal stays at 100 - env(set(depositor, brokerKeylet.key, iou(40).value()), - loan::kInterestRate(TenthBips32(0)), - kGracePeriod(60), - kPaymentInterval(120), - kPaymentTotal(10), - Sig(sfCounterpartySignature, owner), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value()); - } - - auto const sharesBefore = env.balance(depositor, shares); - - // Legacy: zero-amount clawback tries to recover the full - // share value (100) without clamping to assetsAvailable (60). - // This causes the vault balance to go negative, triggering - // the sanity check in doApply → tefINTERNAL. - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - }), - Ter(tefINTERNAL)); - env.close(); - - { - // Transaction rolled back — vault and shares unchanged - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle != nullptr); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value()); - BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value()); - auto const sharesAfter = env.balance(depositor, shares); - BEAST_EXPECT(sharesAfter == sharesBefore); - } - - env.enableFeature(fixCleanup3_1_3); - } - } - - void - testAssetsMaximum() - { - testcase("Assets Maximum"); - - using namespace test::jtx; - - Env env{*this, testableAmendments()}; - Account const owner{"owner"}; - Account const issuer{"issuer"}; - - Vault const vault{env}; - env.fund(XRP(1'000'000), issuer, owner); - env.close(); - - auto const maxInt64 = std::to_string(std::numeric_limits::max()); - BEAST_EXPECT(maxInt64 == "9223372036854775807"); - - auto const maxInt64Plus1 = std::to_string( - static_cast(std::numeric_limits::max()) + 1); - BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808"); - - // Naming things is hard - auto const maxInt64Plus2 = std::to_string( - static_cast(std::numeric_limits::max()) + 2); - BEAST_EXPECT(maxInt64Plus2 == "9223372036854775809"); - - auto const initialXRP = to_string(kInitialXrp); - BEAST_EXPECT(initialXRP == "100000000000000000"); - - auto const initialXRPPlus1 = to_string(kInitialXrp + 1); - BEAST_EXPECT(initialXRPPlus1 == "100000000000000001"); - - { - testcase("Assets Maximum: XRP"); - - PrettyAsset const xrpAsset = xrpIssue(); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); - tx[sfData] = "4D65746144617461"; - - tx[sfAssetsMaximum] = maxInt64; - env(tx, Ter(tefEXCEPTION)); - env.close(); - - tx[sfAssetsMaximum] = initialXRPPlus1; - env(tx, Ter(tefEXCEPTION)); - env.close(); - - tx[sfAssetsMaximum] = initialXRP; - env(tx); - env.close(); - - // There are several parse failures expected in this function, so just disable it once. - env.setParseFailureExpected(true); - try - { - tx[sfAssetsMaximum] = maxInt64Plus1; - env(tx, Ter(tefEXCEPTION)); - env.close(); - // should throw in parser - fail(); - } - catch (std::exception const& e) - { - BEAST_EXPECT( - std::string(e.what()) == - "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); - } - - try - { - tx[sfAssetsMaximum] = maxInt64Plus2; - env(tx, Ter(tefEXCEPTION)); - // should throw in parser - fail(); - } - catch (std::exception const& e) - { - BEAST_EXPECT( - std::string(e.what()) == - "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); - } - - auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - try - { - auto const insertAt = maxInt64Plus2.size() - 3; - auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." + - maxInt64Plus2.substr(insertAt); // (max int64+2) / 1000 - BEAST_EXPECT(decimalTest == "9223372036854775.809"); - tx[sfAssetsMaximum] = decimalTest; - env(tx); - // should throw in parser - fail(); - } - catch (std::exception const& e) - { - BEAST_EXPECT( - std::string(e.what()) == - "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); - } - - auto const vaultSle = env.le(newKeylet); - BEAST_EXPECT(!vaultSle); - } - - { - testcase("Assets Maximum: MPT"); - - PrettyAsset const mptAsset = [&]() { - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - env.close(); - PrettyAsset const mptAsset = mptt["MPT"]; - mptt.authorize({.account = owner}); - env.close(); - return mptAsset; - }(); - - env(pay(issuer, owner, mptAsset(100'000))); - env.close(); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = mptAsset}); - tx[sfData] = "4D65746144617461"; - - tx[sfAssetsMaximum] = maxInt64; - env(tx); - env.close(); - - tx[sfAssetsMaximum] = initialXRPPlus1; - env(tx); - env.close(); - - tx[sfAssetsMaximum] = initialXRP; - env(tx); - env.close(); - - try - { - tx[sfAssetsMaximum] = maxInt64Plus2; - env(tx, Ter(tefEXCEPTION)); - // should throw in parser - fail(); - } - catch (std::exception const& e) - { - BEAST_EXPECT( - std::string(e.what()) == - "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); - } - - auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - try - { - auto const insertAt = maxInt64Plus2.size() - 1; - auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." + - maxInt64Plus2.substr(insertAt); // (max int64+2) / 10 - BEAST_EXPECT(decimalTest == "922337203685477580.9"); - tx[sfAssetsMaximum] = decimalTest; - env(tx); - // should throw in parser - fail(); - } - catch (std::exception const& e) - { - BEAST_EXPECT( - std::string(e.what()) == - "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); - } - - auto const vaultSle = env.le(newKeylet); - BEAST_EXPECT(!vaultSle); - } - - { - testcase("Assets Maximum: IOU"); - - // Almost anything goes with IOUs - PrettyAsset const iouAsset = issuer["IOU"]; - env.trust(iouAsset(1000), owner); - env(pay(issuer, owner, iouAsset(200))); - env.close(); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = iouAsset}); - tx[sfData] = "4D65746144617461"; - - tx[sfAssetsMaximum] = maxInt64; - env(tx); - env.close(); - - tx[sfAssetsMaximum] = initialXRPPlus1; - env(tx); - env.close(); - - tx[sfAssetsMaximum] = initialXRP; - env(tx); - env.close(); - - // Since several tests are expected to have parser failures, leave this flag set for the - // remainder of this function. - env.setParseFailureExpected(true); - try - { - tx[sfAssetsMaximum] = maxInt64Plus2; - env(tx); - // should throw in parser - fail(); - } - catch (std::exception const& e) - { - BEAST_EXPECT( - std::string(e.what()) == - "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); - } - - tx[sfAssetsMaximum] = "1000000000000000e80"; - env.close(); - - tx[sfAssetsMaximum] = "1000000000000000e-96"; - env.close(); - - // These values will be rounded to 15 significant digits - { - auto const newKeylet = - keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - try - { - auto const insertAt = maxInt64Plus2.size() - 1; - auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." + - maxInt64Plus2.substr(insertAt); // (max int64+2) / 10 - BEAST_EXPECT(decimalTest == "922337203685477580.9"); - tx[sfAssetsMaximum] = decimalTest; - env(tx); - // should throw in parser - fail(); - } - catch (std::exception const& e) - { - BEAST_EXPECT( - std::string(e.what()) == - "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); - } - - auto const vaultSle = env.le(newKeylet); - BEAST_EXPECT(!vaultSle); - } - { - tx[sfAssetsMaximum] = "9223372036854775807e40"; // max int64 * 10^40 - auto const newKeylet = - keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(tx); - env.close(); - - auto const vaultSle = env.le(newKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - - BEAST_EXPECT( - (vaultSle->at(sfAssetsMaximum) == - Number{9223372036854776, 43, Number::Normalized{}})); - } - { - tx[sfAssetsMaximum] = "9223372036854775807e-40"; // max int64 * 10^-40 - auto const newKeylet = - keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(tx); - env.close(); - - auto const vaultSle = env.le(newKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - - BEAST_EXPECT( - (vaultSle->at(sfAssetsMaximum) == - Number{9223372036854776, -37, Number::Normalized{}})); - } - { - tx[sfAssetsMaximum] = "9223372036854775807e-100"; // max int64 * 10^-100 - auto const newKeylet = - keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - env(tx); - env.close(); - - // Field 'AssetsMaximum' may not be explicitly set to default. - auto const vaultSle = env.le(newKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - - BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == kNumZero); - } - - // What _can't_ IOUs do? - // 1. Exceed maximum exponent / offset - tx[sfAssetsMaximum] = "1000000000000000e81"; - env(tx, Ter(tefEXCEPTION)); - env.close(); - - // 2. Mantissa larger than uint64 max - try - { - auto const g = env.getParseFailureGuard(true); - tx[sfAssetsMaximum] = "18446744073709551617e5"; // uint64 max + 1 - env(tx); - BEAST_EXPECTS(false, "Expected parse_error for mantissa larger than uint64 max"); - } - catch (ParseError const& e) - { - using namespace std::string_literals; - BEAST_EXPECT( - e.what() == "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."s); - } - } - } - - void - testVaultEscrowedMPT() - { - using namespace test::jtx; - using namespace std::literals; - - // Verify vault deposit/withdraw/clawback respect sfLockedAmount. - // When MPT tokens are escrowed, sfMPTAmount is reduced and - // sfLockedAmount is increased. Vault operations go through - // accountSend/accountHolds which read sfMPTAmount, so escrowed - // tokens are naturally excluded. - - { - testcase("Vault deposit fails when MPT asset is escrowed"); - - Env env{*this, testableAmendments()}; - auto const baseFee = env.current()->fees().base; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - Account const issuer{"issuer"}; - Account const bob{"bob"}; - - env.fund(XRP(10000), issuer, owner, depositor, bob); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create( - {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow}); - mptt.authorize({.account = owner}); - mptt.authorize({.account = depositor}); - mptt.authorize({.account = bob}); - PrettyAsset const asset = mptt.issuanceID(); - env(pay(issuer, depositor, asset(100))); - env.close(); - - // Escrow 60 of 100 MPT tokens: sfMPTAmount drops to 40 - auto const escrowSeq = env.seq(depositor); - env(escrow::create(depositor, bob, asset(60)), - escrow::kCondition(escrow::kCb1), - escrow::kFinishTime(env.now() + 1s), - Fee(baseFee * 150), - Ter(tesSUCCESS)); - env.close(); - - Vault const vault{env}; - auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - // Deposit 100 should fail — only 40 spendable - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), - Ter(tecINSUFFICIENT_FUNDS)); - env.close(); - - // Deposit 40 (the unlocked balance) should succeed - env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); - } - - // Clean up escrow - env(escrow::finish(bob, depositor, escrowSeq), - escrow::kCondition(escrow::kCb1), - escrow::kFulfillment(escrow::kFb1), - Fee(baseFee * 150), - Ter(tesSUCCESS)); - env.close(); - } - - { - testcase("Vault withdraw respects escrowed shares"); - - Env env{*this, testableAmendments()}; - auto const baseFee = env.current()->fees().base; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - Account const issuer{"issuer"}; - Account const bob{"bob"}; - - env.fund(XRP(10000), issuer, owner, depositor, bob); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create( - {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow}); - mptt.authorize({.account = owner}); - mptt.authorize({.account = depositor}); - PrettyAsset const asset = mptt.issuanceID(); - env(pay(issuer, depositor, asset(100))); - env.close(); - - Vault const vault{env}; - auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - // Deposit 100 → get shares - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), - Ter(tesSUCCESS)); - env.close(); - - auto const vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - env.memoize(Account("vault", vaultSle->at(sfAccount))); - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - // Authorize bob for share MPT so he can receive escrowed shares - auto const shareMPTID = vaultSle->at(sfShareMPTID); - { - json::Value jv; - jv[jss::Account] = bob.human(); - jv[sfMPTokenIssuanceID] = to_string(shareMPTID); - jv[jss::TransactionType] = jss::MPTokenAuthorize; - env(jv, Ter(tesSUCCESS)); - env.close(); - } - - // Escrow 60% of shares - auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1}); - env(escrow::create(depositor, bob, escrowAmount), - escrow::kCondition(escrow::kCb1), - escrow::kFinishTime(env.now() + 1s), - Fee(baseFee * 150), - Ter(tesSUCCESS)); - env.close(); - - // Withdraw all 100 should fail — only 40% of shares are unlocked - env(vault.withdraw( - {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), - Ter(tecINSUFFICIENT_FUNDS)); - env.close(); - - // Withdraw 40 (matching unlocked shares) should succeed - env(vault.withdraw( - {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value()); - } - } - - { - testcase("Vault clawback only recovers unlocked shares"); - - Env env{*this, testableAmendments() | fixCleanup3_1_3}; - auto const baseFee = env.current()->fees().base; - Account const owner{"owner"}; - Account const depositor{"depositor"}; - Account const issuer{"issuer"}; - Account const bob{"bob"}; - - env.fund(XRP(10000), issuer, owner, depositor, bob); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create( - {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow}); - mptt.authorize({.account = owner}); - mptt.authorize({.account = depositor}); - PrettyAsset const asset = mptt.issuanceID(); - env(pay(issuer, depositor, asset(100))); - env.close(); - - Vault const vault{env}; - auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - // Deposit 100 → get shares - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), - Ter(tesSUCCESS)); - env.close(); - - auto const vaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(vaultSle)) - return; - env.memoize(Account("vault", vaultSle->at(sfAccount))); - PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - - // Authorize bob for share MPT so he can receive escrowed shares - auto const shareMPTID = vaultSle->at(sfShareMPTID); - { - json::Value jv; - jv[jss::Account] = bob.human(); - jv[sfMPTokenIssuanceID] = to_string(shareMPTID); - jv[jss::TransactionType] = jss::MPTokenAuthorize; - env(jv, Ter(tesSUCCESS)); - env.close(); - } - - // Escrow 60% of shares - auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1}); - env(escrow::create(depositor, bob, escrowAmount), - escrow::kCondition(escrow::kCb1), - escrow::kFinishTime(env.now() + 1s), - Fee(baseFee * 150), - Ter(tesSUCCESS)); - env.close(); - - // Zero-amount clawback ("all") — should only recover assets - // corresponding to unlocked shares (40%) - env(vault.clawback({ - .issuer = issuer, - .id = vaultKeylet.key, - .holder = depositor, - }), - Ter(tesSUCCESS)); - env.close(); - - { - auto const sle = env.le(vaultKeylet); - BEAST_EXPECT(sle != nullptr); - // Only 40 of 100 assets recovered (matching 40% unlocked shares) - BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value()); - BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); - - // Depositor's unlocked shares are now 0 - auto const sharesAfter = env.balance(depositor, shares); - BEAST_EXPECT(sharesAfter == shares(0)); - } - } - } - - // 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()); - - // 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()); - 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); - } - } - } - } - - 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; - }); - } - - // ----------------------------------------------------------------------- - // Helpers and tests: sole-shareholder / stuck-depositor (XLS-0065 + - // fixCleanup3_2_0). The vault-level withdraw behavior is tested here; - // the loan-protocol setup is incidental. - // ----------------------------------------------------------------------- - - FeatureBitset const all_{test::jtx::testableAmendments()}; - std::string const iouCurrency_{"IOU"}; - - // 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 asset; - std::optional vaultKeylet; - uint256 brokerID; - std::optional 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(&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(&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); - } - - // 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); - } - - // 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); - } - - // 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(&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: 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(&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: 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(&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); - } - } - - // 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(&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); - } - } - - // 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(&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); - } - } - - // 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(&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); - } - } - - void - testReferenceHolding() - { - using namespace test::jtx; - - auto readReferenceHolding = [&](Env const& env, - Keylet const& vaultKeylet) -> std::optional { - 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()).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())) != 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); - } - } - - // 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); - } - } - - void - testVaultDeleteMemoData() - { - using namespace test::jtx; - - Env env{*this}; - - Account const owner{"owner"}; - env.fund(XRP(1'000'000), owner); - env.close(); - - Vault const vault{env}; - - auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(1)); - auto delTx = vault.del({.owner = owner, .id = keylet.key}); - - // Test VaultDelete with featureLendingProtocolV1_1 disabled - // Transaction fails if the data field is provided - { - testcase("VaultDelete memo data featureLendingProtocolV1_1 disabled"); - env.disableFeature(featureLendingProtocolV1_1); - delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A')); - env(delTx, Ter(temDISABLED)); - env.enableFeature(featureLendingProtocolV1_1); - env.close(); - } - - // Transaction fails if the data field is too large - { - testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data too large"); - delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 1, 'A')); - env(delTx, Ter(temMALFORMED)); - env.close(); - } - - // Transaction fails if the data field is set, but is empty - { - testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data empty"); - delTx[sfMemoData] = strHex(std::string()); - env(delTx, Ter(temMALFORMED)); - env.close(); - } - - { - testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault"); - auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); - - // Recreate the transaction as the vault keylet changed - auto delTx = vault.del({.owner = owner, .id = keylet.key}); - delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A')); - env(delTx, Ter(tecNO_ENTRY)); - env.close(); - } - - { - testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data valid"); - PrettyAsset const xrpAsset = xrpIssue(); - auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - // Recreate the transaction as the vault keylet changed - auto delTx = vault.del({.owner = owner, .id = keylet.key}); - delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A')); - env(delTx, Ter(tesSUCCESS)); - env.close(); - } - } - - void - testVaultCreateLEVersion() - { - using namespace test::jtx; - - Account const owner{"owner"}; - PrettyAsset const xrpAsset = xrpIssue(); - - { - testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent"); - Env env{*this}; - env.disableFeature(featureLendingProtocolV1_1); - env.fund(XRP(1'000'000), owner); - env.close(); - - Vault const vault{env}; - auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - auto const sleVault = env.le(keylet); - BEAST_EXPECT(sleVault); - BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion)); - } - - { - testcase( - "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == " - "VaultVersion::CashBasis"); - Env env{*this}; - env.fund(XRP(1'000'000), owner); - env.close(); - - Vault const vault{env}; - auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); - env(tx, Ter(tesSUCCESS)); - env.close(); - - auto const sleVault = env.le(keylet); - BEAST_EXPECT(sleVault); - BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion)); - BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); - } - - { - testcase("VaultCreate rejects LEVersion set in the transaction"); - Env env{*this}; - env.fund(XRP(1'000'000), owner); - env.close(); - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); - tx[sfLEVersion] = 2; - env(tx, Ter(temMALFORMED)); - env.close(); - - BEAST_EXPECT(!env.le(keylet)); - } - - { - testcase("VaultSet rejects LEVersion set in the transaction"); - Env env{*this}; - env.fund(XRP(1'000'000), owner); - env.close(); - - Vault const vault{env}; - auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); - env(createTx, Ter(tesSUCCESS)); - env.close(); - - auto setTx = vault.set({.owner = owner, .id = keylet.key}); - setTx[sfLEVersion] = 2; - env(setTx, Ter(temMALFORMED)); - env.close(); - } - } - - 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); - } - - // 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); - } - - 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); - } - -public: - void - run() override - { - testVaultWithdrawEqualityEnforced(); - testBugIssuerVaultDepositAtEdge(); - testBugMakeDeltaPosteriorScale(); - testBugMakeDeltaAnteriorScale(); - testVaultDepositCanonicalizeToZero(); - testVaultWithdrawCanonicalizeToZero(); - testVaultDepositNegativeBalanceFromOppositeLimit(); - testSequences(); - testPreflight(); - testCreateFailXRP(); - testCreateFailIOU(); - testCreateFailMPT(); - testVaultCreateClosedEnded(); - testVaultCreateSubscriptionDateBoundary(); - testVaultPhaseDerivation(); - testVaultPhaseDerivationOpenEnded(); - testVaultDepositClosedEnded(); - testVaultWithdrawClosedEnded(); - testVaultClosedEndedLifecycle(); - testVaultLoanLatePaymentAfterInvestment(); - testVaultClosedEndedMultipleLoans(); - testVaultClawbackClosedEndedPhases(); - testWithMPT(); - testWithIOU(); - testWithDomainCheck(); - testDomainLossAfterAcquisition(); - testDomainCheckBuyerSideOffer(); - testWithDomainChecXRP(); - testNonTransferableShares(); - testFailedPseudoAccount(); - testScaleIOU(); - testRPC(); - testRPCClosedEnded(); - testVaultClawbackBurnShares(); - testVaultClawbackAssets(); - testVaultEscrowedMPT(); - testAssetsMaximum(); - testVaultDeleteMemoData(); - testVaultCreateLEVersion(); - testBug6LimitBypassWithShares(); - testRemoveEmptyHoldingLockedAmount(); - testRemoveEmptyHoldingConfidentialBalances(); - - testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0); - testWithdrawSoleShareholderFixedAssetExit(all_); - testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0); - testWithdrawSoleShareholderFullSharesRejected(all_); - testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0); - testWithdrawSoleShareholderCleanVaultUnaffected(all_); - testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice(); - testWithdrawSoleShareholderLoanRepaymentExit(); - - testVaultDepositFreezeIOU(); - testVaultDepositFreezeMPT(); - testVaultWithdrawFreezeIOU(); - testVaultWithdrawFreezeMPT(); - testVaultSelfWithdrawWhileFrozen(); - - testReferenceHolding(); - testHoldingDeletionBlocked(); - } -}; - -BEAST_DEFINE_TESTSUITE_PRIO(Vault, app, xrpl, 1); - -} // namespace xrpl diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 5d67cdc3c5..909b617980 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -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. diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 950b196043..b3669742fe 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -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: diff --git a/src/test/app/lending/Loan_test.cpp b/src/test/app/lending/Loan_test.cpp deleted file mode 100644 index 717387665e..0000000000 --- a/src/test/app/lending/Loan_test.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include -#include - -#include -#include -#include - -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 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 diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp new file mode 100644 index 0000000000..a7071f3767 --- /dev/null +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -0,0 +1,716 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +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(&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(&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(&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(&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(&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(&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()); + + // 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()); + 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 diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp new file mode 100644 index 0000000000..2a9fe42b1c --- /dev/null +++ b/src/test/app/vault/VaultClawback_test.cpp @@ -0,0 +1,1122 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl { + +class VaultClawback_test : public VaultTestBase +{ +private: + void + testVaultClawbackBurnShares() + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + Env env(*this, beast::Severity::Warning); + + auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) { + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault != nullptr); + + return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal)); + }; + + auto const vaultShareBalance = [&](Keylet const& vaultKeylet) { + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault != nullptr); + + auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); + BEAST_EXPECT(sleIssuance != nullptr); + + return sleIssuance->at(sfOutstandingAmount); + }; + + auto const setupVault = [&](PrettyAsset const& asset, + Account const& owner, + Account const& depositor) -> std::pair { + Vault const vault{env}; + + auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + + Asset const share = vaultSle->at(sfShareMPTID); + + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), + Ter(tesSUCCESS)); + env.close(); + + auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet); + BEAST_EXPECT(availablePreDefault == totalPreDefault); + BEAST_EXPECT(availablePreDefault == asset(100).value()); + + // attempt to clawback shares while there are assets fails + env(vault.clawback( + {.issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(0).value()}), + Ter(tecNO_PERMISSION)); + env.close(); + + auto const& sharesAvailable = vaultShareBalance(vaultKeylet); + auto const& brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + + env(set(owner, vaultKeylet.key)); + env.close(); + + auto const& loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + + // Create a simple Loan for the full amount of Vault assets + env(set(depositor, brokerKeylet.key, asset(100).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // attempt to clawback shares while there assetsAvailable == 0 and + // assetsTotal > 0 fails + env(vault.clawback( + {.issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(0).value()}), + Ter(tecNO_PERMISSION)); + env.close(); + + env.close(std::chrono::seconds{120 + 60}); + + env(manage(owner, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + + auto const& [availablePostDefault, totalPostDefault] = vaultAssetBalance(vaultKeylet); + + BEAST_EXPECT(availablePostDefault == totalPostDefault); + BEAST_EXPECT(availablePostDefault == asset(0).value()); + BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable); + + return std::make_pair(vault, vaultKeylet); + }; + + auto const testCase = [&](PrettyAsset const& asset, + std::string const& prefix, + Account const& owner, + Account const& depositor) { + { + testcase("VaultClawback (share) - " + prefix + " owner asset clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + // when asset is XRP or owner is not issuer clawback fail + // when owner is issuer precision loss occurs as vault is + // empty + auto const expectedTer = [&]() { + if (asset.native()) + return Ter(temMALFORMED); + if (asset.raw().getIssuer() != owner.id()) + return Ter(tecNO_PERMISSION); + return Ter(tecPRECISION_LOSS); + }(); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + expectedTer); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + " owner incomplete share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + auto const& vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + Asset const share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + Ter(tecLIMIT_EXCEEDED)); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner implicit complete share clawback"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + }), + // when owner is issuer implicit clawback fails + asset.native() || asset.raw().getIssuer() != owner.id() ? Ter(tesSUCCESS) + : Ter(tecWRONG_ASSET)); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner explicit complete share clawback succeeds"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + auto const& vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + Asset const share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + Ter(tesSUCCESS)); + env.close(); + } + { + testcase("VaultClawback (share) - " + prefix + " owner can clawback own shares"); + auto [vault, vaultKeylet] = setupVault(asset, owner, owner); + auto const& vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + Asset const share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + Ter(tesSUCCESS)); + env.close(); + } + + { + testcase("VaultClawback (share) - " + prefix + " empty vault share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, owner); + auto const& vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + Asset const share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + Ter(tesSUCCESS)); + + // Now the vault is empty, clawback again fails + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + Ter(tecNO_PERMISSION)); + env.close(); + } + }; + + Account const owner{"alice"}; + Account const depositor{"bob"}; + Account const issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset const xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor); + testCase(xrp, "XRP (depositor is owner)", owner, owner); + + // Test IOU + PrettyAsset const iou = issuer["IOU"]; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + env.trust(iou(1000), owner); + env.trust(iou(1000), depositor); + env(pay(issuer, owner, iou(100))); + env(pay(issuer, depositor, iou(100))); + env.close(); + testCase(iou, "IOU", owner, depositor); + testCase(iou, "IOU (owner is issuer)", issuer, depositor); + + // Test MPT + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mpt = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, owner, mpt(1000))); + env(pay(issuer, depositor, mpt(1000))); + env.close(); + testCase(mpt, "MPT", owner, depositor); + testCase(mpt, "MPT (owner is issuer)", issuer, depositor); + } + + void + testVaultClawbackAssets() + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + Env env(*this); + env.enableFeature(fixCleanup3_1_3); + + auto const setupVault = [&](PrettyAsset const& asset, + Account const& owner, + Account const& depositor, + Account const& issuer) -> std::pair { + Vault const vault{env}; + + auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + env.memoize(Account("vault", vaultSle->at(sfAccount))); + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), + Ter(tesSUCCESS)); + env.close(); + + return std::make_pair(vault, vaultKeylet); + }; + + auto const testCase = [&](PrettyAsset const& asset, + std::string const& prefix, + Account const& owner, + Account const& depositor, + Account const& issuer) { + if (asset.native()) + { + testcase("VaultClawback (asset) - " + prefix + " issuer XRP clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + // If the asset is XRP, clawback with amount fails as malformed + // when asset is specified. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + .amount = asset(1).value(), + }), + Ter(temMALFORMED)); + // When asset is implicit, clawback fails as no permission. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + Ter(tecNO_PERMISSION)); + return; + } + + { + testcase( + "VaultClawback (asset) - " + prefix + " clawback for different asset fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + Account const issuer2{"issuer2"}; + PrettyAsset const asset2 = issuer2["FOO"]; + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset2(1).value(), + }), + Ter(tecWRONG_ASSET)); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " ambiguous owner/issuer asset clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, issuer, depositor, issuer); + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + Ter(tecWRONG_ASSET)); + } + + { + testcase("VaultClawback (asset) - " + prefix + " non-issuer asset clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + }), + Ter(tecNO_PERMISSION)); + + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(1).value(), + }), + Ter(tecNO_PERMISSION)); + } + + { + testcase("VaultClawback (asset) - " + prefix + " issuer clawback from self fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, issuer, issuer); + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + Ter(tecNO_PERMISSION)); + } + + { + testcase("VaultClawback (asset) - " + prefix + " issuer share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + auto const& vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + Asset const share = vaultSle->at(sfShareMPTID); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + Ter(tecNO_PERMISSION)); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " partial issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(1).value(), + }), + Ter(tesSUCCESS)); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + " full issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + Ter(tesSUCCESS)); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " implicit full issuer asset clawback succeeds"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + }), + Ter(tesSUCCESS)); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " zero-amount clawback clamped with outstanding loan"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + // Create a loan broker backed by this vault + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + // Depositor borrows 40 units, reducing assetsAvailable to 60 + // while assetsTotal stays at 100 + env(set(depositor, brokerKeylet.key, asset(40).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); + } + + // Zero-amount clawback (= "clawback all") should succeed, + // clamped to assetsAvailable (60) rather than the full + // share value (100). + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + }), + Ter(tesSUCCESS)); + env.close(); + + // Only 60 assets clawed back; loan's 40 still outstanding + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); + + // 60 of 100 shares destroyed (1:1 ratio), 40 remain + auto const sharesAfter = env.balance(depositor, shares); + BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1})); + } + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " non-zero clawback clamped with outstanding loan"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + // Create a loan broker backed by this vault + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + // Depositor borrows 40 units + env(set(depositor, brokerKeylet.key, asset(40).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); + } + + // Request 100 but only 60 available — clamped to 60 + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); + + // 60 of 100 shares destroyed (1:1 ratio), 40 remain + auto const sharesAfter = env.balance(depositor, shares); + BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1})); + } + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " partial clawback below available with outstanding loan"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + // Create a loan broker backed by this vault + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100 + env(set(depositor, brokerKeylet.key, asset(40).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); + } + + // Clawback 30 — well under available (60), no clamping needed + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(30).value(), + }), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(30).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(70).value()); + + // 30 of 100 shares destroyed (1:1 ratio), 70 remain + auto const sharesAfter = env.balance(depositor, shares); + BEAST_EXPECT(sharesAfter == shares(Number{7, sle->at(sfScale) + 1})); + } + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " clawback exactly equal to available with outstanding loan"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100 + env(set(depositor, brokerKeylet.key, asset(40).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Clawback exactly 60 — at the boundary, no clamping needed + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(60).value(), + }), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); + + // 60 of 100 shares destroyed (1:1 ratio), 40 remain + auto const sharesAfter = env.balance(depositor, shares); + BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1})); + } + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " clawback with zero available (fully borrowed)"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + // Depositor borrows all 100 units: assetsAvailable=0, assetsTotal=100 + env(set(depositor, brokerKeylet.key, asset(100).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); + } + + auto const sharesBefore = env.balance(depositor, shares); + + // Zero-amount clawback — nothing available, clamped to 0, + // resulting in zero shares destroyed → tecPRECISION_LOSS + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + }), + Ter(tecPRECISION_LOSS)); + env.close(); + + // Explicit amount clawback — also nothing available + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(50).value(), + }), + Ter(tecPRECISION_LOSS)); + env.close(); + + { + // Nothing changed — vault and shares unchanged + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value()); + auto const sharesAfter = env.balance(depositor, shares); + BEAST_EXPECT(sharesAfter == sharesBefore); + } + } + }; + + Account const owner{"alice"}; + Account const depositor{"bob"}; + Account const issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset const xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor, issuer); + + // Test IOU + PrettyAsset const iou = issuer["IOU"]; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + env.trust(iou(2000), owner); + env.trust(iou(2000), depositor); + env(pay(issuer, owner, iou(2000))); + env(pay(issuer, depositor, iou(2000))); + env.close(); + testCase(iou, "IOU", owner, depositor, issuer); + + // Test MPT + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + + PrettyAsset const mpt = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, depositor, mpt(2000))); + env.close(); + testCase(mpt, "MPT", owner, depositor, issuer); + + // Test pre-fixCleanup3_1_3 legacy path: zero-amount clawback + // returns early without clamping to assetsAvailable. + { + testcase( + "VaultClawback (asset) - IOU pre-fixCleanup3_1_3" + " zero-amount clawback unclamped with outstanding loan"); + + env.disableFeature(fixCleanup3_1_3); + + auto [vault, vaultKeylet] = setupVault(iou, owner, depositor, issuer); + + auto const vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + // Create a loan broker backed by this vault + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + // Depositor borrows 40 units, reducing assetsAvailable to 60 + // while assetsTotal stays at 100 + env(set(depositor, brokerKeylet.key, iou(40).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value()); + } + + auto const sharesBefore = env.balance(depositor, shares); + + // Legacy: zero-amount clawback tries to recover the full + // share value (100) without clamping to assetsAvailable (60). + // This causes the vault balance to go negative, triggering + // the sanity check in doApply → tefINTERNAL. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + }), + Ter(tefINTERNAL)); + env.close(); + + { + // Transaction rolled back — vault and shares unchanged + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value()); + BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value()); + auto const sharesAfter = env.balance(depositor, shares); + BEAST_EXPECT(sharesAfter == sharesBefore); + } + + env.enableFeature(fixCleanup3_1_3); + } + } + + void + testVaultEscrowedMPT() + { + using namespace test::jtx; + using namespace std::literals; + + // Verify vault deposit/withdraw/clawback respect sfLockedAmount. + // When MPT tokens are escrowed, sfMPTAmount is reduced and + // sfLockedAmount is increased. Vault operations go through + // accountSend/accountHolds which read sfMPTAmount, so escrowed + // tokens are naturally excluded. + + { + testcase("Vault deposit fails when MPT asset is escrowed"); + + Env env{*this, testableAmendments()}; + auto const baseFee = env.current()->fees().base; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const issuer{"issuer"}; + Account const bob{"bob"}; + + env.fund(XRP(10000), issuer, owner, depositor, bob); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow}); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + mptt.authorize({.account = bob}); + PrettyAsset const asset = mptt.issuanceID(); + env(pay(issuer, depositor, asset(100))); + env.close(); + + // Escrow 60 of 100 MPT tokens: sfMPTAmount drops to 40 + auto const escrowSeq = env.seq(depositor); + env(escrow::create(depositor, bob, asset(60)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + env.close(); + + Vault const vault{env}; + auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + // Deposit 100 should fail — only 40 spendable + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), + Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // Deposit 40 (the unlocked balance) should succeed + env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value()); + } + + // Clean up escrow + env(escrow::finish(bob, depositor, escrowSeq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + env.close(); + } + + { + testcase("Vault withdraw respects escrowed shares"); + + Env env{*this, testableAmendments()}; + auto const baseFee = env.current()->fees().base; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const issuer{"issuer"}; + Account const bob{"bob"}; + + env.fund(XRP(10000), issuer, owner, depositor, bob); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow}); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + PrettyAsset const asset = mptt.issuanceID(); + env(pay(issuer, depositor, asset(100))); + env.close(); + + Vault const vault{env}; + auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + // Deposit 100 → get shares + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), + Ter(tesSUCCESS)); + env.close(); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + env.memoize(Account("vault", vaultSle->at(sfAccount))); + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + // Authorize bob for share MPT so he can receive escrowed shares + auto const shareMPTID = vaultSle->at(sfShareMPTID); + { + json::Value jv; + jv[jss::Account] = bob.human(); + jv[sfMPTokenIssuanceID] = to_string(shareMPTID); + jv[jss::TransactionType] = jss::MPTokenAuthorize; + env(jv, Ter(tesSUCCESS)); + env.close(); + } + + // Escrow 60% of shares + auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1}); + env(escrow::create(depositor, bob, escrowAmount), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + env.close(); + + // Withdraw all 100 should fail — only 40% of shares are unlocked + env(vault.withdraw( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), + Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // Withdraw 40 (matching unlocked shares) should succeed + env(vault.withdraw( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value()); + } + } + + { + testcase("Vault clawback only recovers unlocked shares"); + + Env env{*this, testableAmendments() | fixCleanup3_1_3}; + auto const baseFee = env.current()->fees().base; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const issuer{"issuer"}; + Account const bob{"bob"}; + + env.fund(XRP(10000), issuer, owner, depositor, bob); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow}); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + PrettyAsset const asset = mptt.issuanceID(); + env(pay(issuer, depositor, asset(100))); + env.close(); + + Vault const vault{env}; + auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + // Deposit 100 → get shares + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}), + Ter(tesSUCCESS)); + env.close(); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + env.memoize(Account("vault", vaultSle->at(sfAccount))); + PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); + + // Authorize bob for share MPT so he can receive escrowed shares + auto const shareMPTID = vaultSle->at(sfShareMPTID); + { + json::Value jv; + jv[jss::Account] = bob.human(); + jv[sfMPTokenIssuanceID] = to_string(shareMPTID); + jv[jss::TransactionType] = jss::MPTokenAuthorize; + env(jv, Ter(tesSUCCESS)); + env.close(); + } + + // Escrow 60% of shares + auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1}); + env(escrow::create(depositor, bob, escrowAmount), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + env.close(); + + // Zero-amount clawback ("all") — should only recover assets + // corresponding to unlocked shares (40%) + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + }), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(vaultKeylet); + BEAST_EXPECT(sle != nullptr); + // Only 40 of 100 assets recovered (matching 40% unlocked shares) + BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value()); + + // Depositor's unlocked shares are now 0 + auto const sharesAfter = env.balance(depositor, shares); + BEAST_EXPECT(sharesAfter == shares(0)); + } + } + } + +public: + void + run() override + { + testVaultClawbackBurnShares(); + testVaultClawbackAssets(); + testVaultEscrowedMPT(); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(VaultClawback, app, xrpl, 1); + +} // namespace xrpl diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp new file mode 100644 index 0000000000..252a7f4990 --- /dev/null +++ b/src/test/app/vault/VaultClosedEnded_test.cpp @@ -0,0 +1,1008 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class VaultClosedEnded_test : public VaultTestBase +{ +private: + // VaultCreate malformation and happy paths for closed-ended vaults, plus the + // featureLendingProtocolV1_1 gate. + void + testVaultCreateClosedEnded() + { + testcase("closed-ended VaultCreate"); + using namespace test::jtx; + + auto const withEnv = [this](FeatureBitset features, auto&& body) { + Env env{*this, features}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + Vault vault{env}; + body(env, owner, vault); + }; + + Asset const asset = xrpIssue(); + auto const minPeriod = kMinInvestmentPeriod; + auto const maxPeriod = kMaxInvestmentPeriod; + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Gate: the three new fields require featureLendingProtocolV1_1. + withEnv( + testableAmendments() - featureLendingProtocolV1_1, + [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temDISABLED}); + }); + + /* + * Valid closed-ended creation with a comfortably interior gap (well above + * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + 86400; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + + /* + * SubscriptionDate not strictly after parent close time (preclaim, state-dependent - + * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see + * the note below the next case. Note: there is no separate "expired RedemptionDate" test + * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past + * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the + * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the + * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause + * of tecEXPIRED. + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const nowSec = env.now().time_since_epoch().count(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = nowSec, + .redemptionDate = nowSec + minPeriod}); + env(tx, Ter{tecEXPIRED}); + }); + + /* + * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= + * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < + * sub case, the latter yielding a negative signed int64 gap that is caught by the + * sub-minimum branch of the gap check. + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod - 1}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub - 1}); + env(tx, Ter{temMALFORMED}); + }); + + // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as + // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod + 1}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is + // inclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + minPeriod; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is + // accepted (upper bound is exclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + maxPeriod - 1; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present + // => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Unrecognised VaultKind => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = static_cast(closedEnded + 1)}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: open-ended vault (no new fields present) is unaffected. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + + // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same + // as absent. Per spec, absent and OpenEnded are equivalent. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::OpenEnded)}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + // OpenEnded is sfVaultKind's default; SoeDefault fields + // aren't serialized when they hold the default value. + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + } + + // SubscriptionDate boundary cases at the top of the UINT32 range. + // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits + // the inclusive lower bound of the kMinInvestmentPeriod gap check. + // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is + // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value + // can satisfy the gap check. + void + testVaultCreateSubscriptionDateBoundary() + { + testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX"); + using namespace test::jtx; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + + { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod; + auto const red = std::numeric_limits::max(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + } + + // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod + // wraps in a UINT32. Every candidate red must fall to temMALFORMED via + // the gap check in preflight. + auto const rejectAtMax = [&, this](std::uint32_t red) { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = std::numeric_limits::max(), + .redemptionDate = red}); + env(tx, Ter{temMALFORMED}); + }; + rejectAtMax(std::numeric_limits::max()); + rejectAtMax(0u); + rejectAtMax(kMinInvestmentPeriod - 1u); + } + + // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now + // == SubscriptionDate case (which must still resolve to Subscription). + void + testVaultPhaseDerivation() + { + testcase("closed-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); + + // Pre-seed shares during Subscription so the depositor has capital to + // withdraw at the Redemption boundary below. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()})); + env.close(); + + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + }; + auto const withdraw = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + }; + + auto const runTest = [&](TER expectedDeposit, + TER expectedWithdraw, + std::source_location const& loc = + std::source_location::current()) { + deposit(expectedDeposit, loc); + withdraw(expectedWithdraw, loc); + }; + + // Assert both deposit and withdraw return codes at each point so the + // active phase is uniquely identified: + // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS + // Investment: deposit tecEXPIRED, withdraw tecTOO_SOON + // Redemption: deposit tecEXPIRED, withdraw tesSUCCESS + + // Ledger time comfortably before SubscriptionDate: Subscription. + runTest(tesSUCCESS, tesSUCCESS); + + // Boundary: parent close time exactly at SubscriptionDate must still + // be Subscription. + closeToTime(env, tp{d{sub}}); + runTest(tesSUCCESS, tesSUCCESS); + + // One second past SubscriptionDate: Investment. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); + + // Any point strictly before RedemptionDate remains Investment. + closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); + + // Boundary: parent close time == RedemptionDate is Redemption (per + // spec table: now >= RedemptionDate). Deposits are rejected but + // withdrawals succeed. + closeToTime(env, tp{d{red}}); + runTest(tecEXPIRED, tesSUCCESS); + env.close(); + } + + // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any + // dates present on the vault. + void + testVaultPhaseDerivationOpenEnded() + { + testcase("open-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Asset const asset = xrpIssue(); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + auto const checkPhaseAt = [&](NetClock::time_point at) { + closeToTime(env, at); + auto const sle = env.le(keylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase); + }; + + // Advance the clock through a wide range of ledger times: an open-ended vault's phase + // must be NoPhase at every one of them, because the derivation short-circuits on + // VaultKind::OpenEnded before it looks at any dates. + auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution; + checkPhaseAt(ledgerTime); + checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod}); + checkPhaseAt( + ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} - + env.closed()->header().closeTimeResolution); + } + + // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and + // Redemption. + void + testVaultDepositClosedEnded() + { + testcase("closed-ended VaultDeposit phase gating"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); + + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + env.close(); + }; + + // Subscription: allowed. + deposit(tesSUCCESS); + + // Investment: rejected. + env.close(tp{d{sub + 1}}); + deposit(tecEXPIRED); + + // Redemption: rejected. + env.close(tp{d{red}}); + deposit(tecEXPIRED); + } + + // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The + // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with + // capital deployed as an outstanding loan. + void + testVaultWithdrawClosedEnded() + { + testcase("closed-ended VaultWithdraw phase gating"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, depositor, borrower); + env.close(); + + Asset const asset = xrpIssue(); + // Widen the Investment window so a single-payment loan (min payment + // interval kMinPaymentInterval = 60s) fits before RedemptionDate. + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u); + + // Deposit XRP(100) in Subscription so the depositor's shares are + // worth XRP(100). The vault holds XRP(100) with + // AssetsAvailable == AssetsTotal. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + // Create a loan broker backed by this vault. LoanBrokerSet has no + // phase gate, so this is fine to do in Subscription. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + auto const withdraw = [&](STAmount const& amount, + TER expected, + std::source_location const& loc = + std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}), + loc}, + Ter{expected}); + env.close(); + }; + + // Subscription: allowed (LP cancel). + withdraw(XRP(1).value(), tesSUCCESS); + + // Investment: rejected. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + withdraw(XRP(1).value(), tecTOO_SOON); + + // Deploy capital: borrower takes a loan of XRP(60) against the + // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal + // remains ~XRP(99). + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + + // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small + // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share + // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the + // vault-shortage guard (not the insufficient-shares guard). + closeToTime(env, tp{d{red}}); + withdraw(XRP(10).value(), tesSUCCESS); + withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS); + } + + // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with + // multiple depositors and a real loan originated through the Investment leg. Exercises every + // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each + // phase. + void + testVaultClosedEndedLifecycle() + { + testcase("closed-ended vault lifecycle (subscribe → invest → redeem)"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, bob, borrower); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + // Widen the Investment window so a single-payment loan (min payment interval + // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom. + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + auto const sleCreate = env.le(keylet); + BEAST_EXPECT(sleCreate); + MPTIssue const shares{sleCreate->at(sfShareMPTID)}; + + auto const balancesEq = [&](STAmount const& available, STAmount const& total) { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); + BEAST_EXPECT(sle->at(sfAssetsTotal) == total); + }; + auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); }; + + // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share + // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the + // MPToken SLE directly to avoid the lookup. + auto const sharesEq = [&](Account const& holder, std::uint64_t expected) { + auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id())); + std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u; + BEAST_EXPECT(actual == expected); + }; + + // ---- Subscription phase ---- + // A legitimate VaultSet succeeds (positive control for 3.7). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "AA"; + env(tx); + env.close(); + } + + // alice deposits 100 XRP. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + sharesEq(alice, 100'000'000); + availableEq(XRP(100).value()); + + // bob deposits 200 XRP. + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()})); + env.close(); + sharesEq(bob, 200'000'000); + availableEq(XRP(300).value()); + + // alice cancels 25 XRP (LP cancel is permitted in Subscription). + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()})); + env.close(); + sharesEq(alice, 75'000'000); + availableEq(XRP(275).value()); + + // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is + // fine to do in Subscription. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // ---- Investment phase (now == sub + 1) ---- + env.close(tp{d{sub + 1}}); + + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecEXPIRED}); + env.close(); + // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON. + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecTOO_SOON}); + env.close(); + + // A real loan is originated during Investment (permitted only in this phase). Zero-interest + // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis + // accounting recognise no interest at origination); AssetsAvailable drops by the loan + // principal. + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key)); + BEAST_EXPECT(sleBroker); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + BEAST_EXPECT(env.le(loanKeylet)); + balancesEq(XRP(215).value(), XRP(275).value()); + + // Non-immutable VaultSet still works in Investment (positive control). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "BB"; + env(tx); + env.close(); + } + + // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved. + sharesEq(alice, 75'000'000); + sharesEq(bob, 200'000'000); + + // ---- Redemption phase (now == red) ---- + env.close(tp{d{red}}); + + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both + // Investment and Redemption. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecEXPIRED}); + env.close(); + + // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215). + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()})); + env.close(); + sharesEq(alice, 0); + balancesEq(XRP(140).value(), XRP(200).value()); + + // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP + // sits in the outstanding loan). A full 200 XRP withdrawal fails against the + // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed + // by the loan receivable — the realistic outcome when capital is still deployed at + // Redemption. + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}), + Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()})); + env.close(); + sharesEq(bob, 60'000'000); + balancesEq(XRP(0).value(), XRP(60).value()); + + // Defensive spot-check that the three immutable fields have not changed across the entire + // lifecycle. Direct immutability coverage lives with the invariant tests. + auto const sleFinal = env.le(keylet); + if (BEAST_EXPECT(sleFinal)) + { + BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red); + } + } + + // A loan whose payment is made after the Investment phase has ended + // (well past its next-due-date and grace period, into Redemption) must + // still be repayable. The vault phase must not gate LoanPay. + void + testVaultLoanLatePaymentAfterInvestment() + { + testcase("closed-ended vault: late loan payment during Redemption succeeds"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, borrower); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // Investment phase: originate a zero-interest, single-payment loan + // with a 300s payment interval and 60s grace. The payment is due + // shortly after origination and well before RedemptionDate. + env.close(tp{d{sub + 1}}); + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + BEAST_EXPECT(env.le(loanKeylet)); + + // Advance to Redemption. The payment is now past its due date and + // grace, and the vault is no longer in Investment. + closeToTime(env, tp{d{red}}); + + env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment)); + env.close(); + + // Loan principal returned to the vault; assetsAvailable == assetsTotal. + auto const sleAfter = env.le(keylet); + if (BEAST_EXPECT(sleAfter)) + { + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal)); + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value()); + } + + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // Two concurrent loans against the same closed-ended vault in Investment + // must coexist: both loan SLEs are created, AssetsAvailable reflects the + // sum of the two outstanding principals, and each can be repaid + // independently. + void + testVaultClosedEndedMultipleLoans() + { + testcase("closed-ended vault: multiple concurrent loans in Investment"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower1{"borrower1"}; + Account const borrower2{"borrower2"}; + env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + env.close(tp{d{sub + 1}}); + + auto const originate = [&](Account const& b, STAmount const& principal) { + env(loan::set(b, brokerKeylet.key, principal), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + }; + originate(borrower1, XRP(50).value()); + originate(borrower2, XRP(70).value()); + + auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u)); + BEAST_EXPECT(env.le(loan1)); + BEAST_EXPECT(env.le(loan2)); + + // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable + // drops by the sum of the two loan principals. + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value()); + } + } + + // Repay the first loan; the second remains outstanding. + env(loan::pay(borrower1, loan1.key, XRP(50).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value()); + } + } + + // Repay the second loan; vault is fully liquid again. + env(loan::pay(borrower2, loan2.key, XRP(70).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal)); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value()); + } + } + + // Redemption: both depositors withdraw in full. + env.close(tp{d{red}}); + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // VaultClawback has no phase gate: an issuer must be able to reclaim + // asset from a depositor in Subscription, Investment and Redemption + // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path + // is exercised (XRP clawback with an explicit amount is temMALFORMED). + void + testVaultClawbackClosedEndedPhases() + { + testcase("closed-ended vault: VaultClawback succeeds in each phase"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, owner, alice); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer["IOU"]; + env.trust(iou(10'000), alice); + env(pay(issuer, alice, iou(1'000))); + env.close(); + + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()})); + env.close(); + + auto const totalsEq = [&](STAmount const& expected) { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); + }; + + // Subscription phase clawback. + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(290).value()); + + // Investment phase clawback. + env.close(tp{d{sub + 1}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(280).value()); + + // Redemption phase clawback. + env.close(tp{d{red}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(270).value()); + } + +public: + void + run() override + { + testVaultCreateClosedEnded(); + testVaultCreateSubscriptionDateBoundary(); + testVaultPhaseDerivation(); + testVaultPhaseDerivationOpenEnded(); + testVaultDepositClosedEnded(); + testVaultWithdrawClosedEnded(); + testVaultClosedEndedLifecycle(); + testVaultLoanLatePaymentAfterInvestment(); + testVaultClosedEndedMultipleLoans(); + testVaultClawbackClosedEndedPhases(); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(VaultClosedEnded, app, xrpl, 1); + +} // namespace xrpl diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp new file mode 100644 index 0000000000..db8943921b --- /dev/null +++ b/src/test/app/vault/VaultDomain_test.cpp @@ -0,0 +1,586 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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().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().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 { + 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 diff --git a/src/test/app/vault/VaultFreeze_test.cpp b/src/test/app/vault/VaultFreeze_test.cpp new file mode 100644 index 0000000000..120aabc8f6 --- /dev/null +++ b/src/test/app/vault/VaultFreeze_test.cpp @@ -0,0 +1,691 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +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 diff --git a/src/test/app/vault/VaultLifecycle_test.cpp b/src/test/app/vault/VaultLifecycle_test.cpp new file mode 100644 index 0000000000..ce91ca857a --- /dev/null +++ b/src/test/app/vault/VaultLifecycle_test.cpp @@ -0,0 +1,1776 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl { + +class VaultLifecycle_test : public VaultTestBase +{ +private: + void + testSequences() + { + using namespace test::jtx; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const charlie{"charlie"}; // authorized 3rd party + Account const dave{"dave"}; + + auto const testSequence = [&, this]( + std::string const& prefix, + Env& env, + Vault& vault, + PrettyAsset const& asset) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfData] = "AFEED00E"; + tx[sfAssetsMaximum] = asset(100).number(); + env(tx); + env.close(); + BEAST_EXPECT(env.le(keylet)); + std::uint64_t const scale = asset.raw().holds() ? 1 : 1e6; + + auto const [share, vaultAccount] = + [&env, keylet = keylet, asset, this]() -> std::tuple { + auto const vault = env.le(keylet); + BEAST_EXPECT(vault != nullptr); + if (!asset.integral()) + { + BEAST_EXPECT(vault->at(sfScale) == 6); + } + else + { + BEAST_EXPECT(vault->at(sfScale) == 0); + } + auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID))); + BEAST_EXPECT(shares != nullptr); + if (!asset.integral()) + { + BEAST_EXPECT(shares->at(sfAssetScale) == 6); + } + else + { + BEAST_EXPECT(shares->at(sfAssetScale) == 0); + } + return {MPTIssue(vault->at(sfShareMPTID)), Account("vault", vault->at(sfAccount))}; + }(); + auto const shares = share.raw().get(); + env.memoize(vaultAccount); + + // Several 3rd party accounts which cannot receive funds + Account const alice{"alice"}; + Account const erin{"erin"}; // not authorized by issuer + env.fund(XRP(1000), alice, erin); + env(fset(alice, asfDepositAuth)); + env.close(); + + { + testcase(prefix + " fail to deposit more than assets held"); + auto tx = vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = asset(10000)}); + env(tx, Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + } + + { + testcase(prefix + " deposit non-zero amount"); + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale)); + } + + { + testcase(prefix + " deposit non-zero amount again"); + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale)); + } + + { + testcase(prefix + " fail to delete non-empty vault"); + auto tx = vault.del({.owner = owner, .id = keylet.key}); + env(tx, Ter(tecHAS_OBLIGATIONS)); + env.close(); + } + + { + testcase(prefix + " fail to update because wrong owner"); + auto tx = vault.set({.owner = issuer, .id = keylet.key}); + tx[sfAssetsMaximum] = asset(50).number(); + env(tx, Ter(tecNO_PERMISSION)); + env.close(); + } + + { + testcase(prefix + " fail to set maximum lower than current amount"); + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfAssetsMaximum] = asset(50).number(); + env(tx, Ter(tecLIMIT_EXCEEDED)); + env.close(); + } + + { + testcase(prefix + " set maximum higher than current amount"); + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfAssetsMaximum] = asset(150).number(); + env(tx); + env.close(); + } + + { + testcase(prefix + " set maximum is idempotent, set it again"); + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfAssetsMaximum] = asset(150).number(); + env(tx); + env.close(); + } + + { + testcase(prefix + " set data"); + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "0"; + env(tx); + env.close(); + } + + { + testcase(prefix + " fail to set domain on public vault"); + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); + env(tx, Ter{tecNO_PERMISSION}); + env.close(); + } + + { + testcase(prefix + " fail to deposit more than maximum"); + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + env(tx, Ter(tecLIMIT_EXCEEDED)); + env.close(); + } + + { + testcase(prefix + " reset maximum to zero i.e. not enforced"); + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfAssetsMaximum] = asset(0).number(); + env(tx); + env.close(); + } + + { + testcase(prefix + " fail to withdraw more than assets held"); + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx, Ter(tecINSUFFICIENT_FUNDS)); + env.close(); + } + + { + testcase(prefix + " deposit some more"); + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale)); + } + + { + testcase(prefix + " clawback some"); + auto code = asset.raw().native() ? Ter(temMALFORMED) : Ter(tesSUCCESS); + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(10)}); + env(tx, code); + env.close(); + if (!asset.raw().native()) + { + BEAST_EXPECT(env.balance(depositor, shares) == share(190 * scale)); + } + } + + { + testcase(prefix + " clawback all"); + auto code = asset.raw().native() ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS); + auto tx = vault.clawback({.issuer = issuer, .id = keylet.key, .holder = depositor}); + env(tx, code); + env.close(); + if (!asset.raw().native()) + { + BEAST_EXPECT(env.balance(depositor, shares) == share(0)); + + { + auto tx = vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = depositor, + .amount = asset(10)}); + env(tx, Ter{tecPRECISION_LOSS}); + env.close(); + } + + { + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(10)}); + env(tx, Ter{tecPRECISION_LOSS}); + env.close(); + } + } + } + + if (!asset.raw().native()) + { + testcase(prefix + " deposit again"); + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(200)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale)); + } + else + { + testcase(prefix + " deposit/withdrawal same or less than fee"); + auto const amount = env.current()->fees().base; + + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount}); + env(tx); + env.close(); + + tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}); + env(tx); + env.close(); + + tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount}); + env(tx); + env.close(); + + // Withdraw to 3rd party + tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}); + tx[sfDestination] = charlie.human(); + env(tx); + env.close(); + + tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount - 1}); + env(tx); + env.close(); + + tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = amount - 1}); + env(tx); + env.close(); + } + + { + testcase(prefix + " fail to withdraw to 3rd party lsfDepositAuth"); + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + tx[sfDestination] = alice.human(); + env(tx, Ter{tecNO_PERMISSION}); + env.close(); + } + + { + testcase(prefix + " fail to withdraw to zero destination"); + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + tx[sfDestination] = "0"; + env(tx, Ter(temMALFORMED)); + env.close(); + } + + if (!asset.raw().native()) + { + testcase(prefix + " fail to withdraw to 3rd party no authorization"); + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + tx[sfDestination] = erin.human(); + env(tx, Ter{asset.raw().holds() ? tecNO_LINE : tecNO_AUTH}); + env.close(); + } + + { + testcase(prefix + " fail to withdraw to 3rd party lsfRequireDestTag"); + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + tx[sfDestination] = dave.human(); + env(tx, Ter{tecDST_TAG_NEEDED}); + env.close(); + } + + { + testcase(prefix + " withdraw to 3rd party lsfRequireDestTag"); + auto tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); + tx[sfDestination] = dave.human(); + tx[sfDestinationTag] = "0"; + env(tx); + env.close(); + } + + { + testcase(prefix + " deposit again"); + auto tx = vault.deposit({.depositor = dave, .id = keylet.key, .amount = asset(50)}); + env(tx); + env.close(); + } + + { + testcase(prefix + " fail to withdraw lsfRequireDestTag"); + auto tx = + vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)}); + env(tx, Ter{tecDST_TAG_NEEDED}); + env.close(); + } + + { + testcase(prefix + " withdraw with tag"); + auto tx = + vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)}); + tx[sfDestinationTag] = "0"; + env(tx); + env.close(); + } + + { + testcase(prefix + " withdraw to authorized 3rd party"); + auto tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); + tx[sfDestination] = charlie.human(); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale)); + } + + { + testcase(prefix + " withdraw to issuer"); + auto tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); + tx[sfDestination] = issuer.human(); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale)); + } + + if (!asset.raw().native()) + { + testcase(prefix + " issuer deposits"); + auto tx = + vault.deposit({.depositor = issuer, .id = keylet.key, .amount = asset(10)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(issuer, shares) == share(10 * scale)); + + testcase(prefix + " issuer withdraws"); + tx = vault.withdraw( + {.depositor = issuer, .id = keylet.key, .amount = share(10 * scale)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(issuer, shares) == share(0 * scale)); + } + + { + testcase(prefix + " withdraw remaining assets"); + auto tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(depositor, shares) == share(0)); + + if (!asset.raw().native()) + { + auto tx = vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = depositor, + .amount = asset(0)}); + env(tx, Ter{tecPRECISION_LOSS}); + env.close(); + } + + { + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = share(10)}); + env(tx, Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + } + } + + if (!asset.integral()) + { + testcase(prefix + " temporary authorization for 3rd party"); + env(trust(erin, asset(1000))); + env(trust(issuer, asset(0), erin, tfSetfAuth)); + env(pay(issuer, erin, asset(10))); + + // Erin deposits all in vault, then sends shares to depositor + auto tx = vault.deposit({.depositor = erin, .id = keylet.key, .amount = asset(10)}); + env(tx); + env.close(); + { + auto tx = pay(erin, depositor, share(10 * scale)); + + // depositor no longer has MPToken for shares + env(tx, Ter{tecNO_AUTH}); + env.close(); + + // depositor will gain MPToken for shares again + env(vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + + env(tx); + env.close(); + } + + testcase(prefix + " withdraw to authorized 3rd party"); + // Depositor withdraws assets, destined to Erin + tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); + tx[sfDestination] = erin.human(); + env(tx); + env.close(); + + // Erin returns assets to issuer + env(pay(erin, issuer, asset(10))); + env.close(); + + testcase(prefix + " fail to pay to unauthorized 3rd party"); + env(trust(erin, asset(0))); + env.close(); + + // Erin has MPToken but is no longer authorized to hold assets + env(pay(depositor, erin, share(1)), Ter{tecNO_LINE}); + env.close(); + + // Depositor withdraws remaining single asset + tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}); + env(tx); + env.close(); + } + + { + testcase(prefix + " fail to delete because wrong owner"); + auto tx = vault.del({.owner = issuer, .id = keylet.key}); + env(tx, Ter(tecNO_PERMISSION)); + env.close(); + } + + { + testcase(prefix + " delete empty vault"); + auto tx = vault.del({.owner = owner, .id = keylet.key}); + env(tx); + env.close(); + BEAST_EXPECT(!env.le(keylet)); + } + }; + + auto testCases = [&, this]( + std::string prefix, std::function setup) { + Env env{*this, testableAmendments()}; + + Vault vault{env}; + env.fund(XRP(1000), issuer, owner, depositor, charlie, dave); + env.close(); + env(fset(issuer, asfAllowTrustLineClawback)); + env(fset(issuer, asfRequireAuth)); + env(fset(dave, asfRequireDest)); + env.close(); + env.require(Flags(issuer, asfAllowTrustLineClawback)); + env.require(Flags(issuer, asfRequireAuth)); + + PrettyAsset const asset = setup(env); + testSequence(prefix, env, vault, asset); + }; + + testCases("XRP", [&](Env& env) -> PrettyAsset { return {xrpIssue(), 1'000'000}; }); + + testCases("IOU", [&](Env& env) -> Asset { + PrettyAsset const asset = issuer["IOU"]; + env(trust(owner, asset(1000))); + env(trust(depositor, asset(1000))); + env(trust(charlie, asset(1000))); + env(trust(dave, asset(1000))); + env(trust(issuer, asset(0), owner, tfSetfAuth)); + env(trust(issuer, asset(0), depositor, tfSetfAuth)); + env(trust(issuer, asset(0), charlie, tfSetfAuth)); + env(trust(issuer, asset(0), dave, tfSetfAuth)); + env(pay(issuer, depositor, asset(1000))); + env.close(); + return asset; + }); + + testCases("MPT", [&](Env& env) -> Asset { + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = depositor}); + mptt.authorize({.account = charlie}); + mptt.authorize({.account = dave}); + env(pay(issuer, depositor, asset(1000))); + env.close(); + return asset; + }); + } + + void + testWithMPT() + { + using namespace test::jtx; + + struct CaseArgs + { + bool enableClawback = true; + bool requireAuth = true; + int initialXRP = 1000; + FeatureBitset features = testableAmendments(); + }; + + auto testCase = [this]( + std::function test, + CaseArgs args = {}) { + Env env{*this, args.features}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(args.initialXRP), issuer, owner, depositor); + env.close(); + Vault vault{env}; + + MPTTester mptt{env, issuer, kMptInitNoFund}; + auto const kNone = LedgerSpecificFlags(0); + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | + (args.enableClawback ? tfMPTCanClawback : kNone) | + (args.requireAuth ? tfMPTRequireAuth : kNone)}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + if (args.requireAuth) + { + mptt.authorize({.account = issuer, .holder = owner}); + mptt.authorize({.account = issuer, .holder = depositor}); + } + + env(pay(issuer, depositor, asset(1000))); + env.close(); + + test(env, issuer, owner, depositor, asset, vault, mptt); + }; + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT nothing to clawback from"); + auto tx = vault.clawback( + {.issuer = issuer, + .id = keylet::skip().key, + .holder = depositor, + .amount = asset(10)}); + env(tx, Ter(tecNO_ENTRY)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT global lock blocks create"); + mptt.set({.account = issuer, .flags = tfMPTLock}); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(tecLOCKED)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT only issuer can clawback"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + env(tx); + env.close(); + + { + auto tx = vault.clawback({ + .issuer = depositor, + .id = keylet.key, + .holder = depositor, + }); + env(tx, Ter(tecNO_PERMISSION)); + } + + { + auto tx = vault.clawback({ + .issuer = owner, + .id = keylet.key, + .holder = depositor, + }); + env(tx, Ter(tecNO_PERMISSION)); + } + }); + + testCase( + [this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT depositor without MPToken, auth required"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx); + env.close(); + + { + // Remove depositor MPToken and it will not be re-created + mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); + + tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + env(tx, Ter{tecNO_AUTH}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + } + + { + // Set destination to 3rd party without MPToken + Account const charlie{"charlie"}; + env.fund(XRP(1000), charlie); + env.close(); + + tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + tx[sfDestination] = charlie.human(); + env(tx, Ter(tecNO_AUTH)); + } + }, + {.requireAuth = true}); + + testCase( + [this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT depositor without MPToken, no auth required"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + auto v = env.le(keylet); + BEAST_EXPECT(v); + + tx = vault.deposit( + {.depositor = depositor, + .id = keylet.key, + .amount = asset(1000)}); // all assets held by depositor + env(tx); + env.close(); + + { + // Remove depositor's MPToken and it will be re-created + mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); + + tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + env(tx); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 != nullptr); + BEAST_EXPECT(sleMPT2->at(sfMPTAmount) == 100); + } + + { + // Remove 3rd party MPToken and it will not be re-created + mptt.authorize({.account = owner, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); + + tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + tx[sfDestination] = owner.human(); + env(tx, Ter(tecNO_AUTH)); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + } + }, + {.requireAuth = false}); + + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env const env{*this, testableAmendments()}; + return { + env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), + env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; + }(); + + testCase( + [&, this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT fail reserve to re-create MPToken"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + auto v = env.le(keylet); + BEAST_EXPECT(v); + + env(pay(depositor, owner, asset(1000))); + env.close(); + + tx = vault.deposit( + {.depositor = owner, + .id = keylet.key, + .amount = asset(1000)}); // all assets held by owner + env(tx); + env.close(); + + { + // Remove owners's MPToken and it will not be re-created + mptt.authorize({.account = owner, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner); + auto const sleMPT = env.le(mptoken); + BEAST_EXPECT(sleMPT == nullptr); + + // Use one reserve so the next transaction fails + env(ticket::create(owner, 1)); + env.close(); + + // No reserve to create MPToken for asset in VaultWithdraw + tx = vault.withdraw( + {.depositor = owner, .id = keylet.key, .amount = asset(100)}); + env(tx, Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + env(pay(depositor, owner, XRP(incReserve))); + env.close(); + + // Withdraw can now create asset MPToken, tx will succeed + env(tx); + env.close(); + } + }, + {.requireAuth = false, .initialXRP = acctReserve + (incReserve * 4) + 1}); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT issuance deleted"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx); + env.close(); + + { + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)}); + env(tx); + } + + mptt.destroy({.issuer = issuer, .id = mptt.issuanceID()}); + env.close(); + + { + auto [tx, keylet] = vault.create({.owner = depositor, .asset = asset}); + env(tx, Ter{tecOBJECT_NOT_FOUND}); + } + + { + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); + env(tx, Ter{tecOBJECT_NOT_FOUND}); + } + + { + auto tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); + env(tx, Ter{tecOBJECT_NOT_FOUND}); + } + + { + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)}); + env(tx, Ter{tecOBJECT_NOT_FOUND}); + } + + env(vault.del({.owner = owner, .id = keylet.key})); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT vault owner can receive shares unless unauthorized"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx); + env.close(); + + auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID { + auto const vault = env.le(keylet); + return vault->at(sfShareMPTID); + }(keylet); + PrettyAsset const shares = MPTIssue(issuanceId); + + { + // owner has MPToken for shares they did not explicitly create + env(pay(depositor, owner, shares(1))); + env.close(); + + tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = shares(1)}); + env(tx); + env.close(); + + // owner's MPToken for vault shares not destroyed by withdraw + env(pay(depositor, owner, shares(1))); + env.close(); + + tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)}); + env(tx); + env.close(); + + // owner's MPToken for vault shares not destroyed by clawback + env(pay(depositor, owner, shares(1))); + env.close(); + + // pay back, so we can destroy owner's MPToken now + env(pay(owner, depositor, shares(1))); + env.close(); + + { + // explicitly destroy vault owners MPToken with zero balance + json::Value jv; + jv[sfAccount] = owner.human(); + jv[sfMPTokenIssuanceID] = to_string(issuanceId); + jv[sfFlags] = tfMPTUnauthorize; + jv[sfTransactionType] = jss::MPTokenAuthorize; + env(jv); + env.close(); + } + + // owner no longer has MPToken for vault shares + tx = pay(depositor, owner, shares(1)); + env(tx, Ter{tecNO_AUTH}); + env.close(); + + // destroy all remaining shares, so we can delete vault + tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)}); + env(tx); + env.close(); + + // will soft fail destroying MPToken for vault owner + env(vault.del({.owner = owner, .id = keylet.key})); + env.close(); + } + }); + + testCase( + [this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT clawback disabled"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + tx = vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx); + env.close(); + + { + auto tx = vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = depositor, + .amount = asset(0)}); + env(tx, Ter{tecNO_PERMISSION}); + } + }, + {.enableClawback = false}); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT un-authorization"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}); + env(tx); + env.close(); + + mptt.authorize({.account = issuer, .holder = depositor, .flags = tfMPTUnauthorize}); + env.close(); + + { + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + env(tx, Ter(tecNO_AUTH)); + + // Withdrawal to other (authorized) accounts works + tx[sfDestination] = issuer.human(); + env(tx); + env.close(); + + tx[sfDestination] = owner.human(); + env(tx); + env.close(); + } + + { + // Cannot deposit some more + auto tx = + vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); + env(tx, Ter(tecNO_AUTH)); + } + + { + // Cannot clawback if issuer is the holder + tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = issuer, .amount = asset(800)}); + env(tx, Ter(tecNO_PERMISSION)); + } + // Clawback works + tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(800)}); + env(tx); + env.close(); + + env(vault.del({.owner = owner, .id = keylet.key})); + }); + + { + testcase("MPT shares to a vault"); + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const issuer{"issuer"}; + env.fund(XRP(1000000), owner, issuer); + env.close(); + Vault const vault{env}; + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | lsfMPTCanClawback | tfMPTRequireAuth}); + mptt.authorize({.account = owner}); + mptt.authorize({.account = issuer, .holder = owner}); + PrettyAsset const asset = mptt.issuanceID(); + env(pay(issuer, owner, asset(100))); + auto [tx1, k1] = vault.create({.owner = owner, .asset = asset}); + env(tx1); + env.close(); + + auto const shares = [&env, keylet = k1, this]() -> Asset { + auto const vault = env.le(keylet); + BEAST_EXPECT(vault != nullptr); + return MPTIssue(vault->at(sfShareMPTID)); + }(); + + auto [tx2, k2] = vault.create({.owner = owner, .asset = shares}); + env(tx2, Ter{tecWRONG_ASSET}); + env.close(); + } + + { + testcase("MPT locked: vault shares inherit underlying lock"); + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + env.fund(XRP(10'000), issuer, owner, alice, bob, carol); + env.close(); + Vault const vault{env}; + + MPTTester asset{ + {.env = env, + .issuer = issuer, + .holders = {owner, alice, bob, carol}, + .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}}; + env(pay(issuer, alice, asset(1'000))); + env(pay(issuer, bob, asset(1'000))); + env.close(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)})); + // Bob also deposits so he has a share MPToken to receive into. + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)})); + env.close(); + + auto const shares = [&]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + auto const shareMptID = shares.raw().get().getMptID(); + auto const shareBalance = [&](Account const& account) { + auto const sle = env.le(keylet::mptoken(shareMptID, account)); + return sle ? sle->at(sfMPTAmount) : 0; + }; + + // Sanity: before the underlying lock, peer-to-peer share + // transfers are allowed. + env(pay(alice, bob, shares(1))); + env.close(); + + // Create the offer while shares are spendable, then lock the + // underlying to test whether a stale offer can still be crossed. + env(offer(alice, XRP(1), shares(1))); + env.close(); + + // Lock the underlying after the vault and share balances exist. + asset.set({.account = issuer, .flags = tfMPTLock}); + env.close(); + + // Direct vault share payment inherits the underlying lock via + // sfReferenceHolding. + BEAST_EXPECT(shareBalance(alice) == 499); + BEAST_EXPECT(shareBalance(bob) == 501); + env(pay(alice, bob, shares(1)), Ter{tecLOCKED}); + env.close(); + BEAST_EXPECT(shareBalance(alice) == 499); + BEAST_EXPECT(shareBalance(bob) == 501); + + // The same inherited lock must also block DEX payment paths that + // would consume an offer selling vault shares. + env(pay(carol, bob, shares(1)), + Sendmax(XRP(1)), + Path(BookSpec{shares.raw()}), + Ter{tecPATH_PARTIAL}); + env.close(); + BEAST_EXPECT(shareBalance(alice) == 499); + BEAST_EXPECT(shareBalance(bob) == 501); + BEAST_EXPECT(expectOffers(env, alice, 1)); + } + + { + testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM"); + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(100'000), issuer, owner, alice, bob); + env.close(); + Vault const vault{env}; + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = alice}); + mptt.authorize({.account = bob}); + env(pay(issuer, alice, asset(10'000))); + env(pay(issuer, bob, asset(10'000))); + env.close(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + // Seed shares so we can later place them on trading venues. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)})); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)})); + env.close(); + + auto const shares = [&]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + + // CanTrade is not set on the underlying, both the asset and + // the vault share are blocked on the DEX. + env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); + env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION}); + env.close(); + + // Deposit still works before enabling CanTrade. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)})); + env.close(); + + // Peer-to-peer share transfers still work (CanTransfer is set on + // both layers). + env(pay(alice, bob, shares(1))); + env.close(); + + // Withdraw still works before enabling CanTrade. + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)})); + env.close(); + + // Enable CanTrade on the underlying. + mptt.set({.flags = tfMPTSetCanTrade}); + env.close(); + + env(offer(alice, XRP(1), asset(10))); + env(offer(alice, XRP(1), shares(1))); + env.close(); + + AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000)); + } + + { + testcase("MPT OutstandingAmount > MaximumAmount"); + + Env env{*this, testableAmendments() | featureSingleAssetVault}; + Account const alice{"alice"}; + Account const issuer{"issuer"}; + env.fund(XRP(1'000), alice, issuer); + env.close(); + Vault const vault{env}; + + MPTTester const btc({.env = env, .issuer = issuer, .holders = {alice}, .maxAmt = 100}); + + auto [tx, k] = vault.create({.owner = issuer, .asset = btc}); + env(tx); + env.close(); + + tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(110)}); + // accountHolds is the first check and the issuer has only BTC(100) + // available + env(tx, Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + + // OutstandingAmount == MaximumAmount + env(pay(issuer, alice, btc(100))); + env.close(); + + tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(100)}); + // the issuer has BTC(0) available + env(tx, Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + + tx = vault.deposit({.depositor = alice, .id = k.key, .amount = btc(100)}); + // alice transfers BTC(100), OutstandingAmount is 100 + env(tx); + env.close(); + } + } + + void + testWithIOU() + { + using namespace test::jtx; + + struct CaseArgs + { + int initialXRP = 1000; + Number initialIOU = 200; + double transferRate = 1.0; + bool charlieRipple = true; + FeatureBitset features = testableAmendments(); + }; + + auto testCase = [&, this]( + std::function vaultAccount, + Vault& vault, + PrettyAsset const& asset, + std::function issuanceId)> test, + CaseArgs args = {}) { + Env env{*this, args.features}; + Account const owner{"owner"}; + Account const issuer{"issuer"}; + Account const charlie{"charlie"}; + Vault vault{env}; + env.fund(XRP(args.initialXRP), issuer, owner, charlie); + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1000), owner); + env(pay(issuer, owner, asset(args.initialIOU))); + env.close(); + if (!args.charlieRipple) + { + env(fset(issuer, 0, asfDefaultRipple)); + env.close(); + env.trust(asset(1000), charlie); + env.close(); + env(pay(issuer, charlie, asset(args.initialIOU))); + env.close(); + env(fset(issuer, asfDefaultRipple)); + } + else + { + env.trust(asset(1000), charlie); + } + env.close(); + env(rate(issuer, args.transferRate)); + env.close(); + + auto const vaultAccount = [&env](xrpl::Keylet keylet) -> Account { + return Account("vault", env.le(keylet)->at(sfAccount)); + }; + auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID { + return env.le(keylet)->at(sfShareMPTID); + }; + + test(env, owner, issuer, charlie, vaultAccount, vault, asset, issuanceId); + }; + + testCase([&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const&, + auto vaultAccount, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU cannot use different asset"); + PrettyAsset const foo = issuer["FOO"]; + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + { + // Cannot create new trustline to a vault + auto tx = [&, account = vaultAccount(keylet)]() { + json::Value jv; + jv[jss::Account] = issuer.human(); + { + auto& ja = jv[jss::LimitAmount] = + foo(0).value().getJson(JsonOptions::Values::None); + ja[jss::issuer] = toBase58(account); + } + jv[jss::TransactionType] = jss::TrustSet; + jv[jss::Flags] = tfSetFreeze; + return jv; + }(); + env(tx, Ter{tecNO_PERMISSION}); + env.close(); + } + + { + auto tx = vault.deposit({.depositor = issuer, .id = keylet.key, .amount = foo(20)}); + env(tx, Ter{tecWRONG_ASSET}); + env.close(); + } + + { + auto tx = + vault.withdraw({.depositor = issuer, .id = keylet.key, .amount = foo(20)}); + env(tx, Ter{tecWRONG_ASSET}); + env.close(); + } + + env(vault.del({.owner = owner, .id = keylet.key})); + env.close(); + }); + + testCase( + [&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto vaultAccount, + Vault& vault, + PrettyAsset const& asset, + auto issuanceId) { + testcase("IOU transfer fees not applied"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const issue = asset.raw().get(); + Asset const share = Asset(issuanceId(keylet)); + + // transfer fees ignored on deposit + BEAST_EXPECT(env.balance(owner, issue) == asset(100)); + BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100)); + + { + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)}); + env(tx); + env.close(); + } + + // transfer fees ignored on clawback + BEAST_EXPECT(env.balance(owner, issue) == asset(100)); + BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50)); + + env(vault.withdraw( + {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)})); + + // transfer fees ignored on withdraw + BEAST_EXPECT(env.balance(owner, issue) == asset(120)); + BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30)); + + { + auto tx = vault.withdraw( + {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)}); + tx[sfDestination] = charlie.human(); + env(tx); + } + + // transfer fees ignored on withdraw to 3rd party + BEAST_EXPECT(env.balance(owner, issue) == asset(120)); + BEAST_EXPECT(env.balance(charlie, issue) == asset(30)); + BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0)); + + env(vault.del({.owner = owner, .id = keylet.key})); + env.close(); + }, + CaseArgs{.transferRate = 1.25}); + + testCase([&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU no trust line to 3rd party"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)})); + env.close(); + + Account const erin{"erin"}; + env.fund(XRP(1000), erin); + env.close(); + + // Withdraw to 3rd party without trust line + auto const tx1 = [&](xrpl::Keylet keylet) { + auto tx = + vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + tx[sfDestination] = erin.human(); + return tx; + }(keylet); + env(tx1, Ter{tecNO_LINE}); + }); + + testCase([&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU no trust line to depositor"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + // reset limit, so deposit of all funds will delete the trust line + env.trust(asset(0), owner); + env.close(); + + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)})); + env.close(); + + auto trustline = env.le(keylet::trustLine(owner, asset.raw().get())); + BEAST_EXPECT(trustline == nullptr); + + // Withdraw without trust line, will succeed + auto const tx1 = [&](xrpl::Keylet keylet) { + auto tx = + vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + return tx; + }(keylet); + env(tx1); + }); + + testCase( + [&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto vaultAccount, + Vault& vault, + PrettyAsset const& asset, + std::function issuanceId) { + testcase("IOU non-transferable"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 0; + env(tx); + env.close(); + + // Turn on noripple on the pseudo account's trust line. + // Charlie's is already set. + env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple)); + + { + // Charlie cannot deposit + auto tx = vault.deposit( + {.depositor = charlie, .id = keylet.key, .amount = asset(100)}); + env(tx, Ter{terNO_RIPPLE}); + env.close(); + } + + { + PrettyAsset const shares = issuanceId(keylet); + auto tx1 = + vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}); + env(tx1); + env.close(); + + // Charlie cannot receive funds + auto tx2 = vault.withdraw( + {.depositor = owner, .id = keylet.key, .amount = shares(100)}); + tx2[sfDestination] = charlie.human(); + env(tx2, Ter{terNO_RIPPLE}); + env.close(); + + { + // Create MPToken for shares held by Charlie + json::Value tx{json::ValueType::Object}; + tx[sfAccount] = charlie.human(); + tx[sfMPTokenIssuanceID] = + to_string(shares.raw().get().getMptID()); + tx[sfTransactionType] = jss::MPTokenAuthorize; + env(tx); + env.close(); + } + // Behavioral shift introduced by share inheritance: + // before fixCleanup3_2_0 this share Payment succeeded + // and the underlying IOU's NoRipple restriction surfaced + // only later on Charlie's withdrawal (terNO_RIPPLE). + // Post-amendment, canTransfer reads the share's + // sfReferenceHolding and dispatches to the underlying IOU; + // rippling is disabled between owner and charlie so the + // share payment itself is now blocked. tecPATH_DRY is + // the path-find layer's translation of the underlying + // terNO_RIPPLE under featureMPTokensV2. + env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY}); + env.close(); + } + + tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)}); + env(tx); + env.close(); + + // Delete vault with zero balance + env(vault.del({.owner = owner, .id = keylet.key})); + }, + {.charlieRipple = false}); + + testCase( + [&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto const& vaultAccount, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU calculation rounding"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 1; + env(tx); + env.close(); + + auto const startingOwnerBalance = env.balance(owner, asset); + BEAST_EXPECT((startingOwnerBalance.value() == STAmount{asset, 11875, -2})); + + // This operation (first deposit 100, then 3.75 x 5) is known to + // have triggered calculation rounding errors in Number + // (addition and division), causing the last deposit to be + // blocked by Vault invariants. + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)})); + + auto const tx1 = vault.deposit( + {.depositor = owner, .id = keylet.key, .amount = asset(Number(375, -2))}); + for (auto i = 0; i < 5; ++i) + { + env(tx1); + } + env.close(); + + { + STAmount const xfer{asset, 1185, -1}; + BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value() - xfer); + BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == xfer); + + auto const vault = env.le(keylet); + BEAST_EXPECT(vault->at(sfAssetsAvailable) == xfer); + BEAST_EXPECT(vault->at(sfAssetsTotal) == xfer); + } + + // Total vault balance should be 118.5 IOU. Withdraw and delete + // the vault to verify this exact amount was deposited and the + // owner has matching shares + env(vault.withdraw( + {.depositor = owner, + .id = keylet.key, + .amount = asset(Number(1000 + (37 * 5), -1))})); + + { + BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value()); + BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == beast::kZero); + auto const vault = env.le(keylet); + BEAST_EXPECT(vault->at(sfAssetsAvailable) == beast::kZero); + BEAST_EXPECT(vault->at(sfAssetsTotal) == beast::kZero); + } + + env(vault.del({.owner = owner, .id = keylet.key})); + env.close(); + }, + {.initialIOU = Number(11875, -2)}); + + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env const env{*this, testableAmendments()}; + return { + env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), + env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; + }(); + + testCase( + [&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU no trust line to depositor no reserve"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + // reset limit, so deposit of all funds will delete the trust + // line + env.trust(asset(0), owner); + env.close(); + + env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)})); + env.close(); + + auto trustline = env.le(keylet::trustLine(owner, asset.raw().get())); + BEAST_EXPECT(trustline == nullptr); + + env(ticket::create(owner, 1)); + env.close(); + + // Fail because not enough reserve to create trust line + tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + env(tx, Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + env(pay(charlie, owner, XRP(incReserve))); + env.close(); + + // Withdraw can now create trust line, will succeed + env(tx); + env.close(); + }, + CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1}); + + testCase( + [&, this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU no reserve for share MPToken"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + env(pay(owner, charlie, asset(100))); + env.close(); + + env(ticket::create(charlie, 3)); + env.close(); + + // Fail because not enough reserve to create MPToken for shares + tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(100)}); + env(tx, Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + env(pay(issuer, charlie, XRP(incReserve))); + env.close(); + + // Deposit can now create MPToken, will succeed + env(tx); + env.close(); + }, + CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1}); + } + +public: + void + run() override + { + testSequences(); + testWithMPT(); + testWithIOU(); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(VaultLifecycle, app, xrpl, 1); + +} // namespace xrpl diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp new file mode 100644 index 0000000000..2ac092b5a7 --- /dev/null +++ b/src/test/app/vault/VaultRPC_test.cpp @@ -0,0 +1,543 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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 diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp new file mode 100644 index 0000000000..94c594f674 --- /dev/null +++ b/src/test/app/vault/VaultScale_test.cpp @@ -0,0 +1,1228 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class VaultScale_test : public VaultTestBase +{ +private: + void + testScaleIOU() + { + using namespace test::jtx; + + struct Data + { + Account const& owner; + Account const& issuer; + Account const& depositor; + Account const& vaultAccount; + MPTIssue shares; + PrettyAsset const& share; + Vault& vault; + xrpl::Keylet keylet; + Issue assets; + PrettyAsset const& asset; + std::function)> peek; + }; + + auto testCase = [&, this]( + std::uint8_t scale, std::function test) { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const issuer{"issuer"}; + Account const depositor{"depositor"}; + Vault vault{env}; + env.fund(XRP(1000), issuer, owner, depositor); + 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(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = scale; + env(tx); + + auto const [vaultAccount, issuanceId] = + [&env](xrpl::Keylet keylet) -> std::tuple { + auto const vault = env.le(keylet); + return {Account("vault", vault->at(sfAccount)), vault->at(sfShareMPTID)}; + }(keylet); + MPTIssue const shares(issuanceId); + env.memoize(vaultAccount); + + auto const peek = [keylet, &env, this](std::function fn) -> bool { + return env.app().getOpenLedger().modify( + [&](OpenView& view, beast::Journal j) -> bool { + Sandbox sb(&view, TapNone); + auto vault = sb.peek(keylet::vault(keylet.key)); + if (!BEAST_EXPECT(vault)) + return false; + auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID))); + if (!BEAST_EXPECT(shares)) + return false; + if (fn(*vault, *shares)) + { + sb.update(vault); + sb.update(shares); + sb.apply(view); + return true; + } + return false; + }); + }; + + test( + env, + {.owner = owner, + .issuer = issuer, + .depositor = depositor, + .vaultAccount = vaultAccount, + .shares = shares, + .share = PrettyAsset(shares), + .vault = vault, + .keylet = keylet, + .assets = asset.raw().get(), + .asset = asset, + .peek = peek}); + }; + + testCase(18, [&, this](Env& env, Data d) { + testcase("Scale deposit overflow on first deposit"); + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)}); + env(tx, Ter{tecPATH_DRY}); + env.close(); + }); + + testCase(18, [&, this](Env& env, Data d) { + testcase("Scale deposit overflow on second deposit"); + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); + env(tx); + env.close(); + } + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)}); + env(tx, Ter{tecPATH_DRY}); + env.close(); + } + }); + + testCase(18, [&, this](Env& env, Data d) { + testcase("Scale deposit overflow on total shares"); + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); + env(tx); + env.close(); + } + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); + env(tx, Ter{tecPATH_DRY}); + env.close(); + } + }); + + testCase(1, [&, this](Env& env, Data d) { + testcase("Scale deposit exact"); + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(1)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(10)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start - 1)); + }); + + testCase(1, [&, this](Env& env, Data d) { + testcase("Scale deposit insignificant amount"); + + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(9, -2))}); + env(tx, Ter{tecPRECISION_LOSS}); + }); + + testCase(1, [&, this](Env& env, Data d) { + testcase("Scale deposit exact, using full precision"); + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(15, -1))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(15)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(15, -1))); + }); + + testCase(1, [&, this](Env& env, Data d) { + testcase("Scale deposit exact, truncating from .5"); + + auto const start = env.balance(d.depositor, d.assets).number(); + // Each of the cases below will transfer exactly 1.2 IOU to the + // vault and receive 12 shares in exchange + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(125, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start - Number(12, -1))); + } + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(1201, -3))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(24)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start - Number(24, -1))); + } + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(1299, -3))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(36)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start - Number(36, -1))); + } + }); + + testCase(1, [&, this](Env& env, Data d) { + testcase("Scale deposit exact, truncating from .01"); + + auto const start = env.balance(d.depositor, d.assets).number(); + // round to 12 + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(1201, -3))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1))); + + { + // round to 6 + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(69, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start - Number(18, -1))); + } + }); + + testCase(1, [&, this](Env& env, Data d) { + testcase("Scale deposit exact, truncating from .99"); + + auto const start = env.balance(d.depositor, d.assets).number(); + // round to 12 + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(1299, -3))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1))); + + { + // round to 6 + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(62, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start - Number(18, -1))); + } + }); + + testCase(1, [&, this](Env& env, Data d) { + // initial setup: deposit 100 IOU, receive 1000 shares + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0))); + + { + testcase("Scale redeem exact"); + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 100 * 100 / 1000 = 100 * 0.1 = 10 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.share, Number(100, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0))); + } + + { + testcase("Scale redeem with rounding"); + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5 + + auto const start = env.balance(d.depositor, d.assets).number(); + d.peek([](SLE& vault, auto&) -> bool { + vault[sfAssetsAvailable] = Number(1); + return true; + }); + + // Note, this transaction fails first (because of above change + // in the open ledger) but then succeeds when the ledger is + // closed (because a modification like above is not persistent), + // which is why the checks below are expected to pass. + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.share, Number(25, 0))}); + env(tx, Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start + Number(25, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(900 - 25, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(900 - 25, 0))); + } + + { + testcase("Scale redeem exact"); + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 87.5 * 21 / 875 = 87.5 * 0.024 = 2.1 + + auto const start = env.balance(d.depositor, d.assets).number(); + + tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.share, Number(21, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 21)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start + Number(21, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(875 - 21, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(875 - 21, 0))); + } + + { + testcase("Scale redeem rest"); + auto const rest = env.balance(d.depositor, d.shares).number(); + + tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.share, rest)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0); + BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0); + BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0); + } + }); + + testCase(18, [&, this](Env& env, Data d) { + testcase("Scale withdraw overflow"); + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); + env(tx); + env.close(); + } + + { + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(10, 0))}); + env(tx, Ter{tecPATH_DRY}); + env.close(); + } + }); + + testCase(1, [&, this](Env& env, Data d) { + // initial setup: deposit 100 IOU, receive 1000 shares + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0))); + + { + testcase("Scale withdraw exact"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 1000 * 10 / 100 = 1000 * 0.1 = 100 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 100 * 100 / 1000 = 100 * 0.1 = 10 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(10, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0))); + } + + { + testcase("Scale withdraw insignificant amount"); + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(4, -2))}); + env(tx, Ter{tecPRECISION_LOSS}); + } + + { + testcase("Scale withdraw with rounding assets"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5 + + auto const start = env.balance(d.depositor, d.assets).number(); + d.peek([](SLE& vault, auto&) -> bool { + vault[sfAssetsAvailable] = Number(1); + return true; + }); + + // Note, this transaction fails first (because of above change + // in the open ledger) but then succeeds when the ledger is + // closed (because a modification like above is not persistent), + // which is why the checks below are expected to pass. + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(25, -1))}); + env(tx, Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start + Number(25, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(900 - 25, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(900 - 25, 0))); + } + + { + testcase("Scale withdraw with rounding shares up"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(375, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start + Number(38, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(875 - 38, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(875 - 38, 0))); + } + + { + testcase("Scale withdraw with rounding shares down"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(372, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == + STAmount(d.asset, start + Number(37, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(837 - 37, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(837 - 37, 0))); + } + + { + testcase("Scale withdraw tiny amount"); + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(9, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(800 - 1, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(800 - 1, 0))); + } + + { + testcase("Scale withdraw rest"); + auto const rest = env.balance(d.vaultAccount, d.assets).number(); + + tx = d.vault.withdraw( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, rest)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0); + BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0); + BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0); + } + }); + + testCase(18, [&, this](Env& env, Data d) { + testcase("Scale clawback overflow"); + + { + auto tx = d.vault.deposit( + {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); + env(tx); + env.close(); + } + + { + auto tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(10, 0))}); + env(tx, Ter{tecPATH_DRY}); + env.close(); + } + }); + + testCase(1, [&, this](Env& env, Data d) { + // initial setup: deposit 100 IOU, receive 1000 shares + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); + BEAST_EXPECT( + env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(1000, 0))); + { + testcase("Scale clawback exact"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 1000 * 10 / 100 = 1000 * 0.1 = 100 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 100 * 100 / 1000 = 100 * 0.1 = 10 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(10, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(900, 0))); + } + + { + testcase("Scale clawback insignificant amount"); + auto tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(4, -2))}); + env(tx, Ter{tecPRECISION_LOSS}); + } + + { + testcase("Scale clawback with rounding assets"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(25, -1))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(900 - 25, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(900 - 25, 0))); + } + + { + testcase("Scale clawback with rounding shares up"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(375, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(875 - 38, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(875 - 38, 0))); + } + + { + testcase("Scale clawback with rounding shares down"); + // assetsToSharesWithdraw: + // shares = sharesTotal * (assets / assetsTotal) + // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 + // sharesToAssetsWithdraw: + // assets = assetsTotal * (shares / sharesTotal) + // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(372, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(837 - 37, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(837 - 37, 0))); + } + + { + testcase("Scale clawback tiny amount"); + + auto const start = env.balance(d.depositor, d.assets).number(); + auto tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(9, -2))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.assets) == + STAmount(d.asset, Number(800 - 1, -1))); + BEAST_EXPECT( + env.balance(d.vaultAccount, d.shares) == + STAmount(d.share, -Number(800 - 1, 0))); + } + + { + testcase("Scale clawback rest"); + auto const rest = env.balance(d.vaultAccount, d.assets).number(); + d.peek([](SLE& vault, auto&) -> bool { + vault[sfAssetsAvailable] = Number(5); + return true; + }); + + // Note, this transaction yields two different results: + // * in the open ledger, with AssetsAvailable = 5 + // * when the ledger is closed with unmodified AssetsAvailable + // because a modification like above is not persistent. + tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, rest)}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0); + BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0); + BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0); + } + }); + + // Non-1:1 ratio (scale=1, 10:1 shares:assets) with an outstanding loan. + // Deposit 100 IOU → 1000 shares. Borrow 40 → assetsAvailable=60. + // Clawback 80 IOU → clamped to 60, then share math uses truncation. + testCase(1, [&, this](Env& env, Data d) { + using namespace loan_broker; + using namespace loan; + + testcase("Scale clawback clamped with outstanding loan"); + + auto tx = d.vault.deposit( + {.depositor = d.depositor, + .id = d.keylet.key, + .amount = STAmount(d.asset, Number(100, 0))}); + env(tx); + env.close(); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); + + // Create a loan broker backed by this vault + auto const brokerKeylet = + keylet::loanBroker(d.owner.id(), SeqProxy::rawSequence(env.seq(d.owner))); + env(set(d.owner, d.keylet.key)); + env.close(); + + // Borrow 40: assetsAvailable=60, assetsTotal=100 + env(set(d.depositor, brokerKeylet.key, STAmount(d.asset, Number(40, 0))), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(120), + kPaymentTotal(10), + Sig(sfCounterpartySignature, d.owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(d.keylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(60, 0))); + BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(100, 0))); + } + + // Request 80 IOU clawback — clamped to assetsAvailable (60) + // With scale=1 (10:1), 60 assets = 600 shares destroyed + tx = d.vault.clawback( + {.issuer = d.issuer, + .id = d.keylet.key, + .holder = d.depositor, + .amount = STAmount(d.asset, Number(80, 0))}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + { + auto const sle = env.le(d.keylet); + BEAST_EXPECT(sle != nullptr); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0, 0))); + BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(40, 0))); + + // 600 of 1000 shares destroyed, 400 remain + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400)); + } + }); + } + + void + testAssetsMaximum() + { + testcase("Assets Maximum"); + + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const issuer{"issuer"}; + + Vault const vault{env}; + env.fund(XRP(1'000'000), issuer, owner); + env.close(); + + auto const maxInt64 = std::to_string(std::numeric_limits::max()); + BEAST_EXPECT(maxInt64 == "9223372036854775807"); + + auto const maxInt64Plus1 = std::to_string( + static_cast(std::numeric_limits::max()) + 1); + BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808"); + + // Naming things is hard + auto const maxInt64Plus2 = std::to_string( + static_cast(std::numeric_limits::max()) + 2); + BEAST_EXPECT(maxInt64Plus2 == "9223372036854775809"); + + auto const initialXRP = to_string(kInitialXrp); + BEAST_EXPECT(initialXRP == "100000000000000000"); + + auto const initialXRPPlus1 = to_string(kInitialXrp + 1); + BEAST_EXPECT(initialXRPPlus1 == "100000000000000001"); + + { + testcase("Assets Maximum: XRP"); + + PrettyAsset const xrpAsset = xrpIssue(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + tx[sfData] = "4D65746144617461"; + + tx[sfAssetsMaximum] = maxInt64; + env(tx, Ter(tefEXCEPTION)); + env.close(); + + tx[sfAssetsMaximum] = initialXRPPlus1; + env(tx, Ter(tefEXCEPTION)); + env.close(); + + tx[sfAssetsMaximum] = initialXRP; + env(tx); + env.close(); + + // There are several parse failures expected in this function, so just disable it once. + env.setParseFailureExpected(true); + try + { + tx[sfAssetsMaximum] = maxInt64Plus1; + env(tx, Ter(tefEXCEPTION)); + env.close(); + // should throw in parser + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT( + std::string(e.what()) == + "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); + } + + try + { + tx[sfAssetsMaximum] = maxInt64Plus2; + env(tx, Ter(tefEXCEPTION)); + // should throw in parser + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT( + std::string(e.what()) == + "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); + } + + auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + try + { + auto const insertAt = maxInt64Plus2.size() - 3; + auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." + + maxInt64Plus2.substr(insertAt); // (max int64+2) / 1000 + BEAST_EXPECT(decimalTest == "9223372036854775.809"); + tx[sfAssetsMaximum] = decimalTest; + env(tx); + // should throw in parser + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT( + std::string(e.what()) == + "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); + } + + auto const vaultSle = env.le(newKeylet); + BEAST_EXPECT(!vaultSle); + } + + { + testcase("Assets Maximum: MPT"); + + PrettyAsset const mptAsset = [&]() { + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + env.close(); + PrettyAsset const mptAsset = mptt["MPT"]; + mptt.authorize({.account = owner}); + env.close(); + return mptAsset; + }(); + + env(pay(issuer, owner, mptAsset(100'000))); + env.close(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = mptAsset}); + tx[sfData] = "4D65746144617461"; + + tx[sfAssetsMaximum] = maxInt64; + env(tx); + env.close(); + + tx[sfAssetsMaximum] = initialXRPPlus1; + env(tx); + env.close(); + + tx[sfAssetsMaximum] = initialXRP; + env(tx); + env.close(); + + try + { + tx[sfAssetsMaximum] = maxInt64Plus2; + env(tx, Ter(tefEXCEPTION)); + // should throw in parser + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT( + std::string(e.what()) == + "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); + } + + auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + try + { + auto const insertAt = maxInt64Plus2.size() - 1; + auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." + + maxInt64Plus2.substr(insertAt); // (max int64+2) / 10 + BEAST_EXPECT(decimalTest == "922337203685477580.9"); + tx[sfAssetsMaximum] = decimalTest; + env(tx); + // should throw in parser + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT( + std::string(e.what()) == + "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); + } + + auto const vaultSle = env.le(newKeylet); + BEAST_EXPECT(!vaultSle); + } + + { + testcase("Assets Maximum: IOU"); + + // Almost anything goes with IOUs + PrettyAsset const iouAsset = issuer["IOU"]; + env.trust(iouAsset(1000), owner); + env(pay(issuer, owner, iouAsset(200))); + env.close(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = iouAsset}); + tx[sfData] = "4D65746144617461"; + + tx[sfAssetsMaximum] = maxInt64; + env(tx); + env.close(); + + tx[sfAssetsMaximum] = initialXRPPlus1; + env(tx); + env.close(); + + tx[sfAssetsMaximum] = initialXRP; + env(tx); + env.close(); + + // Since several tests are expected to have parser failures, leave this flag set for the + // remainder of this function. + env.setParseFailureExpected(true); + try + { + tx[sfAssetsMaximum] = maxInt64Plus2; + env(tx); + // should throw in parser + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT( + std::string(e.what()) == + "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); + } + + tx[sfAssetsMaximum] = "1000000000000000e80"; + env.close(); + + tx[sfAssetsMaximum] = "1000000000000000e-96"; + env.close(); + + // These values will be rounded to 15 significant digits + { + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + try + { + auto const insertAt = maxInt64Plus2.size() - 1; + auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." + + maxInt64Plus2.substr(insertAt); // (max int64+2) / 10 + BEAST_EXPECT(decimalTest == "922337203685477580.9"); + tx[sfAssetsMaximum] = decimalTest; + env(tx); + // should throw in parser + fail(); + } + catch (std::exception const& e) + { + BEAST_EXPECT( + std::string(e.what()) == + "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); + } + + auto const vaultSle = env.le(newKeylet); + BEAST_EXPECT(!vaultSle); + } + { + tx[sfAssetsMaximum] = "9223372036854775807e40"; // max int64 * 10^40 + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(tx); + env.close(); + + auto const vaultSle = env.le(newKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + + BEAST_EXPECT( + (vaultSle->at(sfAssetsMaximum) == + Number{9223372036854776, 43, Number::Normalized{}})); + } + { + tx[sfAssetsMaximum] = "9223372036854775807e-40"; // max int64 * 10^-40 + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(tx); + env.close(); + + auto const vaultSle = env.le(newKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + + BEAST_EXPECT( + (vaultSle->at(sfAssetsMaximum) == + Number{9223372036854776, -37, Number::Normalized{}})); + } + { + tx[sfAssetsMaximum] = "9223372036854775807e-100"; // max int64 * 10^-100 + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(tx); + env.close(); + + // Field 'AssetsMaximum' may not be explicitly set to default. + auto const vaultSle = env.le(newKeylet); + if (!BEAST_EXPECT(vaultSle)) + return; + + BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == kNumZero); + } + + // What _can't_ IOUs do? + // 1. Exceed maximum exponent / offset + tx[sfAssetsMaximum] = "1000000000000000e81"; + env(tx, Ter(tefEXCEPTION)); + env.close(); + + // 2. Mantissa larger than uint64 max + try + { + auto const g = env.getParseFailureGuard(true); + tx[sfAssetsMaximum] = "18446744073709551617e5"; // uint64 max + 1 + env(tx); + BEAST_EXPECTS(false, "Expected parse_error for mantissa larger than uint64 max"); + } + catch (ParseError const& e) + { + using namespace std::string_literals; + BEAST_EXPECT( + e.what() == "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."s); + } + } + } + +public: + void + run() override + { + testScaleIOU(); + testAssetsMaximum(); + } +}; + +BEAST_DEFINE_TESTSUITE_PRIO(VaultScale, app, xrpl, 1); + +} // namespace xrpl diff --git a/src/test/app/vault/VaultShares_test.cpp b/src/test/app/vault/VaultShares_test.cpp new file mode 100644 index 0000000000..037ee3e057 --- /dev/null +++ b/src/test/app/vault/VaultShares_test.cpp @@ -0,0 +1,736 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +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(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 { + 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()).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())) != 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 diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp new file mode 100644 index 0000000000..ffaad07112 --- /dev/null +++ b/src/test/app/vault/VaultSoleShareholder_test.cpp @@ -0,0 +1,655 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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 asset; + std::optional vaultKeylet; + uint256 brokerID; + std::optional 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(&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(&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 diff --git a/src/test/app/vault/VaultTestBase.h b/src/test/app/vault/VaultTestBase.h new file mode 100644 index 0000000000..538f3b72d8 --- /dev/null +++ b/src/test/app/vault/VaultTestBase.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp new file mode 100644 index 0000000000..4219ce4661 --- /dev/null +++ b/src/test/app/vault/VaultValidation_test.cpp @@ -0,0 +1,1086 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl { + +class VaultValidation_test : public VaultTestBase +{ +private: + void + testPreflight() + { + using namespace test::jtx; + + struct CaseArgs + { + FeatureBitset features = testableAmendments(); + }; + + auto testCase = [&, this]( + std::function test, + CaseArgs args = {}) { + Env env{*this, args.features}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Vault vault{env}; + env.fund(XRP(1000), issuer, owner); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env(fset(issuer, asfRequireAuth)); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env(trust(owner, asset(1000))); + env(trust(issuer, asset(0), owner, tfSetfAuth)); + env(pay(issuer, owner, asset(1000))); + env.close(); + + test(env, issuer, owner, asset, vault); + }; + + auto testDisabled = [&](TER resultAfterCreate = temDISABLED) { + return [&, resultAfterCreate]( + Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("disabled single asset vault"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter{temDISABLED}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + env(tx, kData("test"), Ter{resultAfterCreate}); + } + + { + auto tx = + vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + env(tx, Ter{resultAfterCreate}); + } + + { + auto tx = + vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + env(tx, Ter{resultAfterCreate}); + } + + { + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)}); + env(tx, Ter{resultAfterCreate}); + } + + { + auto tx = vault.del({.owner = owner, .id = keylet.key}); + env(tx, Ter{resultAfterCreate}); + } + }; + }; + + testCase(testDisabled(), {.features = testableAmendments() - featureSingleAssetVault}); + + testCase(testDisabled(tecNO_ENTRY), {.features = testableAmendments() - featureMPTokensV1}); + + testCase( + [&](Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("disabled permissioned domains"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + + tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate; + tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); + env(tx, Ter{temDISABLED}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + env(tx, kData("Test")); + + tx[sfDomainID] = to_string(BaseUInt<256>(13ul)); + env(tx, Ter{temDISABLED}); + } + }, + {.features = testableAmendments() - featurePermissionedDomains}); + + testCase([&](Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("invalid flags"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfFlags] = tfClearDeepFreeze; + env(tx, Ter{temINVALID_FLAG}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfFlags] = tfClearDeepFreeze; + env(tx, Ter{temINVALID_FLAG}); + } + + { + auto tx = + vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + tx[sfFlags] = tfClearDeepFreeze; + env(tx, Ter{temINVALID_FLAG}); + } + + { + auto tx = + vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + tx[sfFlags] = tfClearDeepFreeze; + env(tx, Ter{temINVALID_FLAG}); + } + + { + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)}); + tx[sfFlags] = tfClearDeepFreeze; + env(tx, Ter{temINVALID_FLAG}); + } + + { + auto tx = vault.del({.owner = owner, .id = keylet.key}); + tx[sfFlags] = tfClearDeepFreeze; + env(tx, Ter{temINVALID_FLAG}); + } + }); + + testCase([&](Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("invalid fee"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[jss::Fee] = "-1"; + env(tx, Ter{temBAD_FEE}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[jss::Fee] = "-1"; + env(tx, Ter{temBAD_FEE}); + } + + { + auto tx = + vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + tx[jss::Fee] = "-1"; + env(tx, Ter{temBAD_FEE}); + } + + { + auto tx = + vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + tx[jss::Fee] = "-1"; + env(tx, Ter{temBAD_FEE}); + } + + { + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)}); + tx[jss::Fee] = "-1"; + env(tx, Ter{temBAD_FEE}); + } + + { + auto tx = vault.del({.owner = owner, .id = keylet.key}); + tx[jss::Fee] = "-1"; + env(tx, Ter{temBAD_FEE}); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const&, Vault& vault) { + testcase("disabled permissioned domain"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()}); + tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); + env(tx, Ter{temDISABLED}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); + env(tx, Ter{temDISABLED}); + } + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfDomainID] = "0"; + env(tx, Ter{temDISABLED}); + } + }, + {.features = (testableAmendments()) - featurePermissionedDomains}); + + testCase([&](Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("use zero vault"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()}); + + { + auto tx = vault.set({ + .owner = owner, + .id = beast::kZero, + }); + env(tx, Ter{temMALFORMED}); + } + + { + auto tx = + vault.deposit({.depositor = owner, .id = beast::kZero, .amount = asset(10)}); + env(tx, Ter(temMALFORMED)); + } + + { + auto tx = + vault.withdraw({.depositor = owner, .id = beast::kZero, .amount = asset(10)}); + env(tx, Ter{temMALFORMED}); + } + + { + auto tx = vault.clawback( + {.issuer = issuer, .id = beast::kZero, .holder = owner, .amount = asset(10)}); + env(tx, Ter{temMALFORMED}); + } + + { + auto tx = vault.del({ + .owner = owner, + .id = beast::kZero, + }); + env(tx, Ter{temMALFORMED}); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("withdraw to bad destination"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = + vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); + tx[jss::Destination] = "0"; + env(tx, Ter{temMALFORMED}); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("create with Scale"); + + { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 255; + env(tx, Ter(temMALFORMED)); + } + + { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 19; + env(tx, Ter(temMALFORMED)); + } + + // accepted range from 0 to 18 + { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 18; + env(tx); + env.close(); + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT((*sleVault)[sfScale] == 18); + } + + { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 0; + env(tx); + env.close(); + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT((*sleVault)[sfScale] == 0); + } + + { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT((*sleVault)[sfScale] == 6); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("create or set invalid data"); + + auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = tx1; + tx[sfData] = ""; + env(tx, Ter(temMALFORMED)); + } + + { + auto tx = tx1; + // A hexadecimal string of 257 bytes. + tx[sfData] = std::string(514, 'A'); + env(tx, Ter(temMALFORMED)); + } + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = ""; + env(tx, Ter{temMALFORMED}); + } + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + // A hexadecimal string of 257 bytes. + tx[sfData] = std::string(514, 'A'); + env(tx, Ter{temMALFORMED}); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("set nothing updated"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + env(tx, Ter{temMALFORMED}); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("create with invalid metadata"); + + auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = tx1; + tx[sfMPTokenMetadata] = ""; + env(tx, Ter(temMALFORMED)); + } + + { + auto tx = tx1; + // This metadata is for the share token. + // A hexadecimal string of 1025 bytes. + tx[sfMPTokenMetadata] = std::string(2050, 'B'); + env(tx, Ter(temMALFORMED)); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("set negative maximum"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfAssetsMaximum] = kNegativeAmount(asset).number(); + env(tx, Ter{temMALFORMED}); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("invalid deposit amount"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = vault.deposit( + {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)}); + env(tx, Ter(temBAD_AMOUNT)); + } + + { + auto tx = + vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(0)}); + env(tx, Ter(temBAD_AMOUNT)); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("invalid set immutable flag"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfFlags] = tfVaultPrivate; + env(tx, Ter(temINVALID_FLAG)); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("invalid withdraw amount"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = vault.withdraw( + {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)}); + env(tx, Ter(temBAD_AMOUNT)); + } + + { + auto tx = + vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(0)}); + env(tx, Ter(temBAD_AMOUNT)); + } + }); + + testCase([&](Env& env, + Account const& issuer, + Account const& owner, + Asset const& asset, + Vault& vault) { + testcase("invalid clawback"); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + // Preclaim only checks for native assets. + if (asset.native()) + { + auto tx = vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)}); + env(tx, Ter(temMALFORMED)); + } + + { + auto tx = vault.clawback( + {.issuer = issuer, + .id = keylet.key, + .holder = owner, + .amount = kNegativeAmount(asset)}); + env(tx, Ter(temBAD_AMOUNT)); + } + }); + + testCase( + [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) { + testcase("invalid create"); + + auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset}); + + { + auto tx = tx1; + tx[sfWithdrawalPolicy] = 0; + env(tx, Ter(temMALFORMED)); + } + + { + auto tx = tx1; + tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); + env(tx, Ter{temMALFORMED}); + } + + { + auto tx = tx1; + tx[sfAssetsMaximum] = kNegativeAmount(asset).number(); + env(tx, Ter{temMALFORMED}); + } + + { + auto tx = tx1; + tx[sfFlags] = tfVaultPrivate; + tx[sfDomainID] = "0"; + env(tx, Ter{temMALFORMED}); + } + }); + } + + // Test for non-asset specific behaviors. + void + testCreateFailXRP() + { + using namespace test::jtx; + + auto testCase = [this]( + std::function test) { + 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 vault{env}; + Asset const asset = xrpIssue(); + + test(env, issuer, owner, depositor, asset, vault); + }; + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault) { + testcase("nothing to set"); + auto tx = vault.set({.owner = owner, .id = keylet::skip().key}); + tx[sfAssetsMaximum] = asset(0).number(); + env(tx, Ter(tecNO_ENTRY)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault) { + testcase("nothing to deposit to"); + auto tx = vault.deposit( + {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)}); + env(tx, Ter(tecNO_ENTRY)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault) { + testcase("nothing to withdraw from"); + auto tx = vault.withdraw( + {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)}); + env(tx, Ter(tecNO_ENTRY)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + testcase("nothing to delete"); + auto tx = vault.del({.owner = owner, .id = keylet::skip().key}); + env(tx, Ter(tecNO_ENTRY)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + testcase("transaction is good"); + env(tx); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfWithdrawalPolicy] = 1; + testcase("explicitly select withdrawal policy"); + env(tx); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + testcase("insufficient fee"); + env(tx, Fee(env.current()->fees().base - 1), Ter(telINSUF_FEE_P)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + testcase("insufficient reserve"); + // It is possible to construct a complicated mathematical + // expression for this amount, but it is sadly not easy. + env(pay(owner, issuer, XRP(775))); + env.close(); + env(tx, Ter(tecINSUFFICIENT_RESERVE)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfFlags] = tfVaultPrivate; + tx[sfDomainID] = to_string(BaseUInt<256>(42ul)); + testcase("non-existing domain"); + env(tx, Ter{tecOBJECT_NOT_FOUND}); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + testcase("cannot set Scale=0"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 0; + env(tx, Ter{temMALFORMED}); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + testcase("cannot set Scale=1"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 1; + env(tx, Ter{temMALFORMED}); + }); + } + + void + testCreateFailIOU() + { + using namespace test::jtx; + { + { + testcase("IOU fail because MPT is disabled"); + Env env{*this, (testableAmendments() - featureMPTokensV1)}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + env.fund(XRP(1000), issuer, owner); + env.close(); + + Vault const vault{env}; + Asset const asset = issuer["IOU"].asset(); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + env(tx, Ter(temDISABLED)); + env.close(); + } + + { + testcase("IOU fail create frozen"); + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + env.fund(XRP(1000), issuer, owner); + env.close(); + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + Vault const vault{env}; + Asset const asset = issuer["IOU"].asset(); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + + env(tx, Ter(tecFROZEN)); + env.close(); + } + + { + testcase("IOU fail create no ripling"); + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + env.fund(XRP(1000), issuer, owner); + env.close(); + env(fclear(issuer, asfDefaultRipple)); + env.close(); + + Vault const vault{env}; + Asset const asset = issuer["IOU"].asset(); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(terNO_RIPPLE)); + env.close(); + } + + { + testcase("IOU no issuer"); + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + Asset const asset = issuer["IOU"].asset(); + { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(terNO_ACCOUNT)); + env.close(); + } + } + } + + { + testcase("IOU fail create vault for AMM LPToken"); + Env env{*this, testableAmendments()}; + Account const gw("gateway"); + Account const alice("alice"); + Account const carol("carol"); + IOU const usd = gw["USD"]; + + auto const [asset1, asset2] = std::pair(XRP(10000), usd(10000)); + auto toFund = [&](STAmount const& a) -> STAmount { + if (a.native()) + { + auto const defXRP = XRP(30000); + if (a <= defXRP) + return defXRP; + return a + XRP(1000); + } + auto defIOU = STAmount{a.asset(), 30000}; + if (a <= defIOU) + return defIOU; + return a + STAmount{a.asset(), 1000}; + }; + auto const toFund1 = toFund(asset1); + auto const toFund2 = toFund(asset2); + BEAST_EXPECT(asset1 <= toFund1 && asset2 <= toFund2); + + if (!asset1.native() && !asset2.native()) + { + fund(env, gw, {alice, carol}, {toFund1, toFund2}, Fund::All); + } + else if (asset1.native()) + { + fund(env, gw, {alice, carol}, toFund1, {toFund2}, Fund::All); + } + else if (asset2.native()) + { + fund(env, gw, {alice, carol}, toFund2, {toFund1}, Fund::All); + } + + AMM const ammAlice(env, alice, asset1, asset2, CreateArg{.log = false, .tfee = 0}); + + Account const owner{"owner"}; + env.fund(XRP(1000000), owner); + + Vault const vault{env}; + auto [tx, k] = vault.create({.owner = owner, .asset = ammAlice.lptIssue()}); + env(tx, Ter{tecWRONG_ASSET}); + env.close(); + } + } + + void + testCreateFailMPT() + { + using namespace test::jtx; + + auto testCase = [this]( + std::function test) { + 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 vault{env}; + MPTTester mptt{env, issuer, kMptInitNoFund}; + // Locked because that is the default flag. + mptt.create(); + Asset const asset = mptt.issuanceID(); + + test(env, issuer, owner, depositor, asset, vault); + }; + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + testcase("MPT no authorization"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx, Ter(tecNO_AUTH)); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + testcase("MPT cannot set Scale=0"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 0; + env(tx, Ter{temMALFORMED}); + }); + + testCase([this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + Asset const& asset, + Vault& vault) { + testcase("MPT cannot set Scale=1"); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + tx[sfScale] = 1; + env(tx, Ter{temMALFORMED}); + }); + } + + void + testVaultDeleteMemoData() + { + using namespace test::jtx; + + Env env{*this}; + + Account const owner{"owner"}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + + auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(1)); + auto delTx = vault.del({.owner = owner, .id = keylet.key}); + + // Test VaultDelete with featureLendingProtocolV1_1 disabled + // Transaction fails if the data field is provided + { + testcase("VaultDelete memo data featureLendingProtocolV1_1 disabled"); + env.disableFeature(featureLendingProtocolV1_1); + delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A')); + env(delTx, Ter(temDISABLED)); + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + } + + // Transaction fails if the data field is too large + { + testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data too large"); + delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 1, 'A')); + env(delTx, Ter(temMALFORMED)); + env.close(); + } + + // Transaction fails if the data field is set, but is empty + { + testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data empty"); + delTx[sfMemoData] = strHex(std::string()); + env(delTx, Ter(temMALFORMED)); + env.close(); + } + + { + testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault"); + auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + + // Recreate the transaction as the vault keylet changed + auto delTx = vault.del({.owner = owner, .id = keylet.key}); + delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A')); + env(delTx, Ter(tecNO_ENTRY)); + env.close(); + } + + { + testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data valid"); + PrettyAsset const xrpAsset = xrpIssue(); + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + // Recreate the transaction as the vault keylet changed + auto delTx = vault.del({.owner = owner, .id = keylet.key}); + delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A')); + env(delTx, Ter(tesSUCCESS)); + env.close(); + } + } + + void + testVaultCreateLEVersion() + { + using namespace test::jtx; + + Account const owner{"owner"}; + PrettyAsset const xrpAsset = xrpIssue(); + + { + testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion)); + } + + { + testcase( + "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == " + "VaultVersion::CashBasis"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); + } + + { + testcase("VaultCreate rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + tx[sfLEVersion] = 2; + env(tx, Ter(temMALFORMED)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + } + + { + testcase("VaultSet rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(createTx, Ter(tesSUCCESS)); + env.close(); + + auto setTx = vault.set({.owner = owner, .id = keylet.key}); + setTx[sfLEVersion] = 2; + env(setTx, Ter(temMALFORMED)); + env.close(); + } + } + +public: + void + run() override + { + testPreflight(); + testCreateFailXRP(); + testCreateFailIOU(); + testCreateFailMPT(); + testVaultDeleteMemoData(); + testVaultCreateLEVersion(); + } +}; + +BEAST_DEFINE_TESTSUITE(VaultValidation, app, xrpl); + +} // namespace xrpl