#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{}}, ""}; }; 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::mptIssuance(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}); } }); } // 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; }; auto testCase = [this]( std::function test, CaseArgs args = {}) { Env env{*this, testableAmendments()}; 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), .mutableFlags = tmfMPTCanMutateCanTransfer}); 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, Asset const& asset, Vault& vault, MPTTester& mptt) { testcase("MPT global lock blocks deposit"); auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); env(tx); env.close(); mptt.set({.account = issuer, .flags = tfMPTLock}); env.close(); tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); env(tx, Ter{tecLOCKED}); env.close(); // Can delete empty vault, even if global lock tx = vault.del({.owner = owner, .id = keylet.key}); env(tx); }); 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 withdrawal"); 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(); // Check that the OutstandingAmount field of MPTIssuance // accounts for the issued shares. auto v = env.le(keylet); BEAST_EXPECT(v); MPTID const share = (*v)[sfShareMPTID]; auto issuance = env.le(keylet::mptIssuance(share)); BEAST_EXPECT(issuance); Number const outstandingShares = issuance->at(sfOutstandingAmount); BEAST_EXPECT(outstandingShares == 100); mptt.set({.account = issuer, .flags = tfMPTLock}); env.close(); tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); env(tx, Ter(tecLOCKED)); tx[sfDestination] = issuer.human(); env(tx, Ter(tecLOCKED)); // Clawback is still permitted, even with global lock tx = vault.clawback( {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)}); env(tx); env.close(); // Clawback removed shares MPToken auto const mptSle = env.le(keylet::mptoken(share, depositor.id())); BEAST_EXPECT(mptSle == nullptr); // Can delete empty vault, even if global lock tx = vault.del({.owner = owner, .id = keylet.key}); env(tx); }); 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).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([this]( Env& env, Account const& issuer, Account const& owner, Account const& depositor, Asset const& asset, Vault& vault, MPTTester& mptt) { testcase("MPT lock of vault pseudo-account"); auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); env(tx); env.close(); auto const vaultAccount = [&env, keylet = keylet, this]() -> AccountID { auto const vault = env.le(keylet); BEAST_EXPECT(vault != nullptr); return vault->at(sfAccount); }(); tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); env(tx); env.close(); tx = [&]() { json::Value jv; jv[jss::Account] = issuer.human(); jv[sfMPTokenIssuanceID] = to_string(asset.get().getMptID()); jv[jss::Holder] = toBase58(vaultAccount); jv[jss::TransactionType] = jss::MPTokenIssuanceSet; jv[jss::Flags] = tfMPTLock; return jv; }(); env(tx); env.close(); tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); env(tx, Ter(tecLOCKED)); tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); env(tx, Ter(tecLOCKED)); // Clawback works, even when locked tx = vault.clawback( {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(100)}); env(tx); // Can delete an empty vault even when asset is locked. tx = vault.del({.owner = owner, .id = keylet.key}); env(tx); }); { 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([this]( Env& env, Account const&, Account const& owner, Account const& depositor, PrettyAsset const& asset, Vault& vault, MPTTester& mptt) { testcase("MPT non-transferable: block deposit, allow withdraw"); 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(); // Issuer governance: clear CanTransfer. New exposure must be // blocked, but recovery paths must remain open so existing // depositors are not trapped. mptt.set({.mutableFlags = tmfMPTClearCanTransfer}); env.close(); // New deposit is blocked. env(tx, Ter{tecNO_AUTH}); env.close(); // Existing depositor can always withdraw, even though the asset // is no longer freely transferable. tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(100)}); env(tx); env.close(); // Delete vault with zero balance env(vault.del({.owner = owner, .id = keylet.key})); }); { testcase("MPT non-transferable: pre-fixCleanup3_2_0 withdraw blocked"); // Regression: before fixCleanup3_2_0 a depositor was trapped if // the issuer cleared lsfMPTCanTransfer. Verify that the legacy // (broken) behavior is preserved when the amendment is disabled. Env env{*this, testableAmendments() - fixCleanup3_2_0}; Account const issuer{"issuer"}; Account const owner{"owner"}; Account const depositor{"depositor"}; env.fund(XRP(10'000), issuer, owner, depositor); env.close(); Vault const vault{env}; MPTTester mptt{env, issuer, kMptInitNoFund}; mptt.create( {.flags = tfMPTCanTransfer | tfMPTCanLock, .mutableFlags = tmfMPTCanMutateCanTransfer}); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = depositor}); env(pay(issuer, depositor, asset(1'000))); env.close(); auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); env(tx); env.close(); env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)})); env.close(); mptt.set({.mutableFlags = tmfMPTClearCanTransfer}); env.close(); // Pre-amendment: deposit blocked (matches new behavior). env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}), Ter{tecNO_AUTH}); env.close(); // Pre-amendment: withdraw is also blocked - this is the bug // that fixCleanup3_2_0 fixes. env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(100)}), Ter{tecNO_AUTH}); env.close(); } { testcase("MPT non-transferable: vault shares inherit restriction"); Env env{*this, testableAmendments()}; Account const issuer{"issuer"}; Account const owner{"owner"}; Account const alice{"alice"}; Account const bob{"bob"}; env.fund(XRP(10'000), issuer, owner, alice, bob); env.close(); Vault const vault{env}; MPTTester mptt{env, issuer, kMptInitNoFund}; mptt.create( {.flags = tfMPTCanTransfer | tfMPTCanLock, .mutableFlags = tmfMPTCanMutateCanTransfer}); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = alice}); mptt.authorize({.account = bob}); 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)); }(); // Sanity: while CanTransfer is set on the underlying, peer-to-peer // share transfers are allowed. env(pay(alice, bob, shares(1))); env.close(); // Issuer governance: clear CanTransfer on the underlying. mptt.set({.mutableFlags = tmfMPTClearCanTransfer}); env.close(); // Vault shares inherit the restriction: third-party share-to-share // payments are blocked. env(pay(alice, bob, shares(1)), Ter{tecNO_AUTH}); env.close(); // Recovery path: existing share holders can still redeem shares // for the underlying asset via VaultWithdraw. env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = shares(1)})); 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 non-transferable: pre-fixCleanup3_2_0 share transfer succeeds"); // Regression: before fixCleanup3_2_0 a peer-to-peer share Payment // succeeded even when the underlying asset's lsfMPTCanTransfer // was cleared. Verify that the legacy (non-inheriting) behavior // is preserved when the amendment is disabled. Env env{*this, testableAmendments() - fixCleanup3_2_0}; Account const issuer{"issuer"}; Account const owner{"owner"}; Account const alice{"alice"}; Account const bob{"bob"}; env.fund(XRP(10'000), issuer, owner, alice, bob); env.close(); Vault const vault{env}; MPTTester mptt{env, issuer, kMptInitNoFund}; mptt.create( {.flags = tfMPTCanTransfer | tfMPTCanLock, .mutableFlags = tmfMPTCanMutateCanTransfer}); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = alice}); mptt.authorize({.account = bob}); 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)})); 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)); }(); mptt.set({.mutableFlags = tmfMPTClearCanTransfer}); env.close(); // Pre-amendment: share transfer leaks past underlying restriction. env(pay(alice, bob, shares(1))); env.close(); } { 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 | tfMPTCanTrade | tfMPTCanLock, .mutableFlags = tmfMPTCanMutateCanTrade}); 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)); }(); // Sanity: while CanTrade is set on the underlying, both the asset // and the vault share can be placed on the DEX. env(offer(alice, XRP(1), asset(10))); env(offer(alice, XRP(1), shares(1))); env.close(); // Issuer governance: clear CanTrade on the underlying. mptt.set({.mutableFlags = tmfMPTClearCanTrade}); env.close(); // Control: clearing CanTrade on the underlying is observable on // the DEX path for that asset. env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); env.close(); // Control: clearing CanTrade on the underlying is also observable // on the AMM path for that asset. AMM const ammUnderlyingFails( env, alice, XRP(1'000), asset(1'000), Ter{tecNO_PERMISSION}); // Post-fixCleanup3_2_0: vault shares inherit the underlying's // CanTrade restriction on the DEX path (canTrade reads the // share's sfReferenceHolding and dispatches to the underlying). env(offer(bob, XRP(1), shares(1)), Ter{tecNO_PERMISSION}); env.close(); // checkMPTAllowed mirrors the inheritance for AMM/Offer- // crossing/Check paths, so a share AMM also cannot be created // when the underlying CanTrade is cleared. AMM const ammShares(env, alice, XRP(1'000), shares(100), Ter{tecNO_PERMISSION}); // Deposit still works (canAddHolding does not consult the field). 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. env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)})); env.close(); } { 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; }; auto testCase = [&, this]( std::function vaultAccount, Vault& vault, PrettyAsset const& asset, std::function issuanceId)> test, CaseArgs args = {}) { Env env{*this, testableAmendments()}; 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 frozen trust line to vault account"); 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(); Asset const share = Asset(issuanceId(keylet)); // Freeze the trustline to the vault auto trustSet = [&, account = vaultAccount(keylet)]() { json::Value jv; jv[jss::Account] = issuer.human(); { auto& ja = jv[jss::LimitAmount] = asset(0).value().getJson(JsonOptions::Values::None); ja[jss::issuer] = toBase58(account); } jv[jss::TransactionType] = jss::TrustSet; jv[jss::Flags] = tfSetFreeze; return jv; }(); env(trustSet); env.close(); { // Note, the "frozen" state of the trust line to vault account // is reported as "locked" state of the vault shares, because // this state is attached to shares by means of the transitive // isFrozen. auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(80)}); env(tx, Ter{tecLOCKED}); } { auto tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)}); env(tx, Ter{tecLOCKED}); // also when trying to withdraw to a 3rd party tx[sfDestination] = charlie.human(); env(tx, Ter{tecLOCKED}); env.close(); } { // Clawback works, even when locked auto tx = vault.clawback( {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)}); env(tx); env.close(); } // Clear the frozen state trustSet[jss::Flags] = tfClearFreeze; env(trustSet); env.close(); env(vault.withdraw( {.depositor = owner, .id = keylet.key, .amount = share(50'000'000)})); 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 frozen trust line to depositor"); 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(); // Withdraw to 3rd party works auto const withdrawToCharlie = [&](xrpl::Keylet keylet) { auto tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); tx[sfDestination] = charlie.human(); return tx; }(keylet); env(withdrawToCharlie); // Freeze the owner env(trust(issuer, asset(0), owner, tfSetFreeze)); env.close(); // Cannot withdraw auto const withdraw = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); env(withdraw, Ter{tecFROZEN}); // Cannot withdraw to 3rd party env(withdrawToCharlie, Ter{tecLOCKED}); env.close(); { // Cannot deposit some more auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); env(tx, Ter{tecFROZEN}); } { // Clawback still works auto tx = vault.clawback( {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)}); env(tx); 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, 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::line(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).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::line(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}); testCase([&, this]( Env& env, Account const& owner, Account const& issuer, Account const& charlie, auto, Vault& vault, PrettyAsset const& asset, auto&&...) { testcase("IOU frozen 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(); // Withdraw to 3rd party works auto const withdrawToCharlie = [&](xrpl::Keylet keylet) { auto tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); tx[sfDestination] = charlie.human(); return tx; }(keylet); env(withdrawToCharlie); // Freeze the 3rd party env(trust(issuer, asset(0), charlie, tfSetFreeze)); env.close(); // Can withdraw auto const withdraw = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); env(withdraw); env.close(); // Cannot withdraw to 3rd party env(withdrawToCharlie, Ter{tecFROZEN}); env.close(); env(vault.clawback( {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)})); 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, Vault& vault, PrettyAsset const& asset, auto&&...) { testcase("IOU global freeze"); 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(); env(fset(issuer, asfGlobalFreeze)); env.close(); { // Cannot withdraw auto tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)}); env(tx, Ter{tecFROZEN}); // Cannot withdraw to 3rd party tx[sfDestination] = charlie.human(); env(tx, Ter{tecFROZEN}); env.close(); // Cannot deposit some more tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}); env(tx, Ter{tecFROZEN}); } // Clawback is permitted env(vault.clawback( {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)})); env.close(); env(vault.del({.owner = owner, .id = keylet.key})); env.close(); }); } 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 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::mptIssuance(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(), 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::mptIssuance(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 loanBroker; 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(), 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"); } } void testVaultClawbackBurnShares() { using namespace test::jtx; using namespace loanBroker; 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::mptIssuance(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(), env.seq(owner)); env(set(owner, vaultKeylet.key)); env.close(); auto const& loanKeylet = keylet::loan(brokerKeylet.key, 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 loanBroker; 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(), 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(), 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(), 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(), 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(), 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(), 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"); // Naming things is hard auto const maxInt64Plus1 = std::to_string( static_cast(std::numeric_limits::max()) + 1); BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808"); 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(); tx[sfAssetsMaximum] = maxInt64Plus1; env(tx, Ter(tefEXCEPTION)); env.close(); // This value will be rounded auto const insertAt = maxInt64Plus1.size() - 3; auto const decimalTest = maxInt64Plus1.substr(0, insertAt) + "." + maxInt64Plus1.substr(insertAt); // (max int64+1) / 1000 BEAST_EXPECT(decimalTest == "9223372036854775.808"); tx[sfAssetsMaximum] = decimalTest; auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); env(tx); env.close(); auto const vaultSle = env.le(newKeylet); if (!BEAST_EXPECT(vaultSle)) return; BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == 9223372036854776); } { 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(); tx[sfAssetsMaximum] = maxInt64Plus1; env(tx, Ter(tefEXCEPTION)); env.close(); // This value will be rounded auto const insertAt = maxInt64Plus1.size() - 1; auto const decimalTest = maxInt64Plus1.substr(0, insertAt) + "." + maxInt64Plus1.substr(insertAt); // (max int64+1) / 10 BEAST_EXPECT(decimalTest == "922337203685477580.8"); tx[sfAssetsMaximum] = decimalTest; auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); env(tx); env.close(); auto const vaultSle = env.le(newKeylet); if (!BEAST_EXPECT(vaultSle)) return; BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == 922337203685477581); } { 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(); tx[sfAssetsMaximum] = maxInt64Plus1; env(tx); env.close(); tx[sfAssetsMaximum] = "1000000000000000e80"; env.close(); tx[sfAssetsMaximum] = "1000000000000000e-96"; env.close(); // These values will be rounded to 15 significant digits { auto const insertAt = maxInt64Plus1.size() - 1; auto const decimalTest = maxInt64Plus1.substr(0, insertAt) + "." + maxInt64Plus1.substr(insertAt); // (max int64+1) / 10 BEAST_EXPECT(decimalTest == "922337203685477580.8"); tx[sfAssetsMaximum] = decimalTest; auto const newKeylet = keylet::vault(owner.id(), 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, 2, Number::Normalized{}})); } { tx[sfAssetsMaximum] = "9223372036854775807e40"; // max int64 * 10^40 auto const newKeylet = keylet::vault(owner.id(), 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(), 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(), 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 env.setParseFailureExpected(true); try { 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); } env.setParseFailureExpected(false); } } 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); } // ----------------------------------------------------------------------- // 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(), env.seq(f.lender)).key; { using namespace loanBroker; 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, 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::mptIssuance(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::mptIssuance(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::mptIssuance(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::mptIssuance(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::mptIssuance(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::mptIssuance(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::mptIssuance(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::line(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::line(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::mptIssuance(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::mptIssuance(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::mptIssuance(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); } } public: void run() override { testVaultWithdrawEqualityEnforced(); testBugIssuerVaultDepositAtEdge(); testBugMakeDeltaPosteriorScale(); testBugMakeDeltaAnteriorScale(); testVaultDepositCanonicalizeToZero(); testVaultWithdrawCanonicalizeToZero(); testVaultDepositNegativeBalanceFromOppositeLimit(); testSequences(); testPreflight(); testCreateFailXRP(); testCreateFailIOU(); testCreateFailMPT(); testWithMPT(); testWithIOU(); testWithDomainCheck(); testWithDomainChecXRP(); testNonTransferableShares(); testFailedPseudoAccount(); testScaleIOU(); testRPC(); testVaultClawbackBurnShares(); testVaultClawbackAssets(); testVaultEscrowedMPT(); testAssetsMaximum(); testBug6LimitBypassWithShares(); testRemoveEmptyHoldingLockedAmount(); testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0); testWithdrawSoleShareholderFixedAssetExit(all_); testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0); testWithdrawSoleShareholderFullSharesRejected(all_); testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0); testWithdrawSoleShareholderCleanVaultUnaffected(all_); testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice(); testWithdrawSoleShareholderLoanRepaymentExit(); testReferenceHolding(); testHoldingDeletionBlocked(); } }; BEAST_DEFINE_TESTSUITE_PRIO(Vault, app, xrpl, 1); } // namespace xrpl