From 418ce683020b3b6a00922bdb314f8b41838dd520 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:58:02 +0100 Subject: [PATCH] VaultClawback: Burn shares of an empty vault (#6120) - Adds a mechanism for the vault owner to burn user shares when the vault is stuck. If the Vault has 0 AssetsAvailable and Total, the owner may submit a VaultClawback to reclaim the worthless fees, and thus allow the Vault to be deleted. The Amount must be left off (unless the owner is the asset issuer), specified as 0 Shares, or specified as the number of Shares held. --- src/test/app/Vault_test.cpp | 589 ++++++++++++++++++++- src/xrpld/app/tx/detail/InvariantCheck.cpp | 77 +-- src/xrpld/app/tx/detail/InvariantCheck.h | 1 + src/xrpld/app/tx/detail/VaultClawback.cpp | 472 +++++++++++------ src/xrpld/app/tx/detail/VaultClawback.h | 8 + 5 files changed, 931 insertions(+), 216 deletions(-) diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 99e1ad2063..38ea14646c 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -40,7 +40,6 @@ #include #include #include -#include #include #include #include @@ -959,25 +958,6 @@ class Vault_test : public beast::unit_test::suite } }); - testCase([&](Env& env, - Account const& issuer, - Account const& owner, - Asset const& asset, - Vault& vault) { - testcase("clawback from self"); - - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); - - { - auto tx = vault.clawback( - {.issuer = issuer, - .id = keylet.key, - .holder = issuer, - .amount = asset(10)}); - env(tx, ter{temMALFORMED}); - } - }); - testCase([&](Env& env, Account const&, Account const& owner, @@ -1216,11 +1196,13 @@ class Vault_test : public beast::unit_test::suite auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + // Preclaim only checks for native assets. + if (asset.native()) { auto tx = vault.clawback( - {.issuer = owner, + {.issuer = issuer, .id = keylet.key, - .holder = issuer, + .holder = owner, .amount = asset(50)}); env(tx, ter(temMALFORMED)); } @@ -1943,8 +1925,20 @@ class Vault_test : public beast::unit_test::suite env.close(); { - auto tx = vault.clawback( - {.issuer = owner, .id = keylet.key, .holder = depositor}); + 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)); } }); @@ -2396,6 +2390,15 @@ class Vault_test : public beast::unit_test::suite 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, @@ -5262,6 +5265,542 @@ class Vault_test : public beast::unit_test::suite }); } + void + testVaultClawbackBurnShares() + { + using namespace test::jtx; + using namespace loanBroker; + using namespace loan; + Env env(*this, beast::severities::kWarning); + + 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 vault{env}; + + auto const& [tx, vaultKeylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx, ter(tesSUCCESS), THISLINE); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + + Asset share = vaultSle->at(sfShareMPTID); + + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = asset(100)}), + ter(tesSUCCESS), + THISLINE); + 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), + THISLINE); + env.close(); + + auto const& sharesAvailable = vaultShareBalance(vaultKeylet); + auto const& brokerKeylet = + keylet::loanbroker(owner.id(), env.seq(owner)); + + env(set(owner, vaultKeylet.key), THISLINE); + 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::interestRate(TenthBips32(0)), + gracePeriod(10), + paymentInterval(120), + paymentTotal(10), + sig(sfCounterpartySignature, owner), + fee(env.current()->fees().base * 2), + ter(tesSUCCESS), + THISLINE); + 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), + THISLINE); + env.close(); + + env.close(std::chrono::seconds{120 + 10}); + + env(manage(owner, loanKeylet.key, tfLoanDefault), + ter(tesSUCCESS), + THISLINE); + + 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); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(100).value(), + }), + // when asset is XRP or owner is not issuer clawback fail + // when owner is issuer precision loss occurs as vault is + // empty + asset.native() ? ter(temMALFORMED) + : asset.raw().getIssuer() != owner.id() + ? ter(tecNO_PERMISSION) + : ter(tecPRECISION_LOSS), + THISLINE); + env.close(); + } + + { + testcase( + "VaultClawback (share) - " + prefix + + " owner incomplete share clawback fails"); + auto [vault, vaultKeylet] = setupVault(asset, owner, depositor); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + ter(tecLIMIT_EXCEEDED), + THISLINE); + 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), + THISLINE); + 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); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + env.close(); + } + { + testcase( + "VaultClawback (share) - " + prefix + + " owner can clawback own shares"); + auto [vault, vaultKeylet] = setupVault(asset, owner, owner); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + 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 != nullptr)) + return; + Asset share = vaultSle->at(sfShareMPTID); + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + .amount = share(vaultShareBalance(vaultKeylet)).value(), + }), + ter(tesSUCCESS), + THISLINE); + + // Now the vault is empty, clawback again fails + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = owner, + }), + ter(tecNO_PERMISSION), + THISLINE); + env.close(); + } + }; + + Account owner{"alice"}; + Account depositor{"bob"}; + Account issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor); + testCase(xrp, "XRP (depositor is owner)", owner, owner); + + // Test IOU + PrettyAsset 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, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset 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); + + auto const setupVault = + [&](PrettyAsset const& asset, + Account const& owner, + Account const& depositor, + Account const& issuer) -> std::pair { + Vault vault{env}; + + auto const& [tx, vaultKeylet] = + vault.create({.owner = owner, .asset = asset}); + env(tx, ter(tesSUCCESS), THISLINE); + env.close(); + + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + env(vault.deposit( + {.depositor = depositor, + .id = vaultKeylet.key, + .amount = asset(100)}), + ter(tesSUCCESS), + THISLINE); + 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 malfored + // when asset is specified. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + .amount = asset(1).value(), + }), + ter(temMALFORMED), + THISLINE); + // When asset is implicit, clawback fails as no permission. + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = issuer, + }), + ter(tecNO_PERMISSION), + THISLINE); + return; + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " clawback for different asset fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + + Account issuer2{"issuer2"}; + PrettyAsset asset2 = issuer2["FOO"]; + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset2(1).value(), + }), + ter(tecWRONG_ASSET), + THISLINE); + } + + { + 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), + THISLINE); + } + + { + 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), + THISLINE); + + env(vault.clawback({ + .issuer = owner, + .id = vaultKeylet.key, + .holder = depositor, + .amount = asset(1).value(), + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + 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), + THISLINE); + } + + { + testcase( + "VaultClawback (asset) - " + prefix + + " issuer share clawback fails"); + auto [vault, vaultKeylet] = + setupVault(asset, owner, depositor, issuer); + auto const& vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle != nullptr); + if (!vaultSle) + return; + Asset share = vaultSle->at(sfShareMPTID); + + env(vault.clawback({ + .issuer = issuer, + .id = vaultKeylet.key, + .holder = depositor, + .amount = share(1).value(), + }), + ter(tecNO_PERMISSION), + THISLINE); + } + + { + 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), + THISLINE); + } + + { + 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), + THISLINE); + } + + { + 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), + THISLINE); + } + }; + + Account owner{"alice"}; + Account depositor{"bob"}; + Account issuer{"issuer"}; + + env.fund(XRP(10000), issuer, owner, depositor); + env.close(); + + // Test XRP + PrettyAsset xrp = xrpIssue(); + testCase(xrp, "XRP", owner, depositor, issuer); + + // Test IOU + PrettyAsset IOU = issuer["IOU"]; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + env.trust(IOU(1000), owner); + env.trust(IOU(1000), depositor); + env(pay(issuer, owner, IOU(1000))); + env(pay(issuer, depositor, IOU(1000))); + env.close(); + testCase(IOU, "IOU", owner, depositor, issuer); + + // Test MPT + MPTTester mptt{env, issuer, mptInitNoFund}; + mptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset MPT = mptt.issuanceID(); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + env(pay(issuer, depositor, MPT(1000))); + env.close(); + testCase(MPT, "MPT", owner, depositor, issuer); + } + public: void run() override @@ -5280,6 +5819,8 @@ public: testScaleIOU(); testRPC(); testDelegate(); + testVaultClawbackBurnShares(); + testVaultClawbackAssets(); } }; diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index c9632146b6..9dec921278 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -114,6 +114,7 @@ hasPrivilege(STTx const& tx, Privilege priv) switch (tx.getTxnType()) { #include + // Deprecated types default: return false; @@ -2647,6 +2648,7 @@ ValidVault::Vault::make(SLE const& from) self.key = from.key(); self.asset = from.at(sfAsset); self.pseudoId = from.getAccountID(sfAccount); + self.owner = from.at(sfOwner); self.shareMPTID = from.getFieldH192(sfShareMPTID); self.assetsTotal = from.at(sfAssetsTotal); self.assetsAvailable = from.at(sfAssetsAvailable); @@ -3092,6 +3094,10 @@ ValidVault::finalize( : std::nullopt; }; + auto const vaultHoldsNoAssets = [&](Vault const& vault) { + return vault.assetsAvailable == 0 && vault.assetsTotal == 0; + }; + // Technically this does not need to be a lambda, but it's more // convenient thanks to early "return false"; the not-so-nice // alternatives are several layers of nested if/else or more complex @@ -3474,29 +3480,56 @@ ValidVault::finalize( if (vaultAsset.native() || vaultAsset.getIssuer() != tx[sfAccount]) { - JLOG(j.fatal()) << // - "Invariant failed: clawback may only be performed by " - "the asset issuer"; - return false; // That's all we can do + // The owner can use clawback to force-burn shares when the + // vault is empty but there are outstanding shares + if (!(beforeShares && beforeShares->sharesTotal > 0 && + vaultHoldsNoAssets(beforeVault) && + beforeVault.owner == tx[sfAccount])) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback may only be performed " + "by the asset issuer, or by the vault owner of an " + "empty vault"; + return false; // That's all we can do + } } auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); + if (vaultDeltaAssets) + { + if (*vaultDeltaAssets >= zero) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback must decrease vault " + "balance"; + result = false; + } - if (!vaultDeltaAssets) + if (beforeVault.assetsTotal + *vaultDeltaAssets != + afterVault.assetsTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets outstanding " + "must add up"; + result = false; + } + + if (beforeVault.assetsAvailable + *vaultDeltaAssets != + afterVault.assetsAvailable) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets available " + "must add up"; + result = false; + } + } + else if (!vaultHoldsNoAssets(beforeVault)) { JLOG(j.fatal()) << // "Invariant failed: clawback must change vault balance"; return false; // That's all we can do } - if (*vaultDeltaAssets >= zero) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback must decrease vault " - "balance"; - result = false; - } - auto const accountDeltaShares = deltaShares(tx[sfHolder]); if (!accountDeltaShares) { @@ -3529,24 +3562,6 @@ ValidVault::finalize( result = false; } - if (beforeVault.assetsTotal + *vaultDeltaAssets != - afterVault.assetsTotal) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets outstanding " - "must add up"; - result = false; - } - - if (beforeVault.assetsAvailable + *vaultDeltaAssets != - afterVault.assetsAvailable) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets available must " - "add up"; - result = false; - } - return result; } diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index a142040a0f..0bef7fe3cb 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -880,6 +880,7 @@ class ValidVault uint256 key = beast::zero; Asset asset = {}; AccountID pseudoId = {}; + AccountID owner = {}; uint192 shareMPTID = beast::zero; Number assetsTotal = 0; Number assetsAvailable = 0; diff --git a/src/xrpld/app/tx/detail/VaultClawback.cpp b/src/xrpld/app/tx/detail/VaultClawback.cpp index 45a56a6292..941b3dd3f0 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.cpp +++ b/src/xrpld/app/tx/detail/VaultClawback.cpp @@ -18,17 +18,17 @@ //============================================================================== #include - +// #include #include #include -#include #include #include #include #include #include -#include + +#include namespace ripple { @@ -41,15 +41,6 @@ VaultClawback::preflight(PreflightContext const& ctx) return temMALFORMED; } - AccountID const issuer = ctx.tx[sfAccount]; - AccountID const holder = ctx.tx[sfHolder]; - - if (issuer == holder) - { - JLOG(ctx.j.debug()) << "VaultClawback: issuer cannot be holder."; - return temMALFORMED; - } - auto const amount = ctx.tx[~sfAmount]; if (amount) { @@ -61,17 +52,27 @@ VaultClawback::preflight(PreflightContext const& ctx) JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; return temMALFORMED; } - else if (amount->asset().getIssuer() != issuer) - { - JLOG(ctx.j.debug()) - << "VaultClawback: only asset issuer can clawback."; - return temMALFORMED; - } } return tesSUCCESS; } +[[nodiscard]] STAmount +clawbackAmount( + std::shared_ptr const& vault, + std::optional const& maybeAmount, + AccountID const& account) +{ + if (maybeAmount) + return *maybeAmount; + + Asset const share = MPTIssue{vault->at(sfShareMPTID)}; + if (account == vault->at(sfOwner)) + return STAmount{share}; + + return STAmount{vault->at(sfAsset)}; +} + TER VaultClawback::preclaim(PreclaimContext const& ctx) { @@ -79,61 +80,264 @@ VaultClawback::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; - auto account = ctx.tx[sfAccount]; - auto const issuer = ctx.view.read(keylet::account(account)); - if (!issuer) + Asset const vaultAsset = vault->at(sfAsset); + auto const account = ctx.tx[sfAccount]; + auto const holder = ctx.tx[sfHolder]; + auto const maybeAmount = ctx.tx[~sfAmount]; + auto const mptIssuanceID = vault->at(sfShareMPTID); + auto const sleShareIssuance = + ctx.view.read(keylet::mptIssuance(mptIssuanceID)); + if (!sleShareIssuance) { // LCOV_EXCL_START - JLOG(ctx.j.error()) << "VaultClawback: missing issuer account."; + JLOG(ctx.j.error()) + << "VaultClawback: missing issuance of vault shares."; return tefINTERNAL; // LCOV_EXCL_STOP } - Asset const vaultAsset = vault->at(sfAsset); - if (auto const amount = ctx.tx[~sfAmount]; - amount && vaultAsset != amount->asset()) + Asset const share = MPTIssue{mptIssuanceID}; + + // Ambiguous case: If Issuer is Owner they must specify the asset + if (!maybeAmount && !vaultAsset.native() && + vaultAsset.getIssuer() == vault->at(sfOwner)) + { + JLOG(ctx.j.debug()) + << "VaultClawback: must specify amount when issuer is owner."; return tecWRONG_ASSET; - - if (vaultAsset.native()) - { - JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; - return tecNO_PERMISSION; // Cannot clawback XRP. - } - else if (vaultAsset.getIssuer() != account) - { - JLOG(ctx.j.debug()) << "VaultClawback: only asset issuer can clawback."; - return tecNO_PERMISSION; // Only issuers can clawback. } - if (vaultAsset.holds()) - { - auto const mpt = vaultAsset.get(); - auto const mptIssue = - ctx.view.read(keylet::mptIssuance(mpt.getMptID())); - if (mptIssue == nullptr) - return tecOBJECT_NOT_FOUND; + auto const amount = clawbackAmount(vault, maybeAmount, account); - std::uint32_t const issueFlags = mptIssue->getFieldU32(sfFlags); - if (!(issueFlags & lsfMPTCanClawback)) + // There is a special case that allows the VaultOwner to use clawback to + // burn shares when Vault assets total and available are zero, but + // shares remain. However, that case is handled in doApply() directly, + // so here we just enforce checks. + if (amount.asset() == share) + { + // Only the Vault Owner may clawback shares + if (account != vault->at(sfOwner)) { JLOG(ctx.j.debug()) - << "VaultClawback: cannot clawback MPT vault asset."; + << "VaultClawback: only vault owner can clawback shares."; return tecNO_PERMISSION; } - } - else if (vaultAsset.holds()) - { - std::uint32_t const issuerFlags = issuer->getFieldU32(sfFlags); - if (!(issuerFlags & lsfAllowTrustLineClawback) || - (issuerFlags & lsfNoFreeze)) + + auto const assetsTotal = vault->at(sfAssetsTotal); + auto const assetsAvailable = vault->at(sfAssetsAvailable); + auto const sharesTotal = sleShareIssuance->at(sfOutstandingAmount); + + // Owner can clawback funds when the vault has shares but no assets + if (sharesTotal == 0 || (assetsTotal != 0 || assetsAvailable != 0)) { JLOG(ctx.j.debug()) - << "VaultClawback: cannot clawback IOU vault asset."; + << "VaultClawback: vault owner can clawback shares only" + " when vault has no assets."; return tecNO_PERMISSION; } + + // If amount is non-zero, the VaultOwner must burn all shares + if (amount != beast::zero) + { + Number const& sharesHeld = accountHolds( + ctx.view, + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + ctx.j); + + // The VaultOwner must burn all shares + if (amount != sharesHeld) + { + JLOG(ctx.j.debug()) + << "VaultClawback: vault owner must clawback all " + "shares."; + return tecLIMIT_EXCEEDED; + } + } + + return tesSUCCESS; } - return tesSUCCESS; + // The asset that is being clawed back is the vault asset + if (amount.asset() == vaultAsset) + { + // XRP cannot be clawed back + if (vaultAsset.native()) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback XRP."; + return tecNO_PERMISSION; + } + + // Only the Asset Issuer may clawback the asset + if (account != vaultAsset.getIssuer()) + { + JLOG(ctx.j.debug()) + << "VaultClawback: only asset issuer can clawback asset."; + return tecNO_PERMISSION; + } + + // The issuer cannot clawback from itself + if (account == holder) + { + JLOG(ctx.j.debug()) + << "VaultClawback: issuer cannot be the holder."; + return tecNO_PERMISSION; + } + + return std::visit( + [&](TIss const& issue) -> TER { + if constexpr (std::is_same_v) + { + auto const mptIssue = + ctx.view.read(keylet::mptIssuance(issue.getMptID())); + if (mptIssue == nullptr) + return tecOBJECT_NOT_FOUND; + + std::uint32_t const issueFlags = + mptIssue->getFieldU32(sfFlags); + if (!(issueFlags & lsfMPTCanClawback)) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback " + "MPT vault asset."; + return tecNO_PERMISSION; + } + } + else if constexpr (std::is_same_v) + { + auto const issuerSle = + ctx.view.read(keylet::account(account)); + if (!issuerSle) + { + // LCOV_EXCL_START + JLOG(ctx.j.error()) + << "VaultClawback: missing submitter account."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + std::uint32_t const issuerFlags = + issuerSle->getFieldU32(sfFlags); + if (!(issuerFlags & lsfAllowTrustLineClawback) || + (issuerFlags & lsfNoFreeze)) + { + JLOG(ctx.j.debug()) << "VaultClawback: cannot clawback " + "IOU vault asset."; + return tecNO_PERMISSION; + } + } + return tesSUCCESS; + }, + vaultAsset.value()); + } + + // Invalid asset + return tecWRONG_ASSET; +} + +Expected, TER> +VaultClawback::assetsToClawback( + std::shared_ptr const& vault, + std::shared_ptr const& sleShareIssuance, + AccountID const& holder, + STAmount const& clawbackAmount) +{ + if (clawbackAmount.asset() != vault->at(sfAsset)) + { + // preclaim should have blocked this , now it's an internal error + // LCOV_EXCL_START + JLOG(j_.error()) << "VaultClawback: asset mismatch in clawback."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + + auto const assetsAvailable = vault->at(sfAssetsAvailable); + auto const mptIssuanceID = *vault->at(sfShareMPTID); + MPTIssue const share{mptIssuanceID}; + + if (clawbackAmount == beast::zero) + { + auto const sharesDestroyed = accountHolds( + view(), + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + auto const maybeAssets = + sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + + return std::make_pair(*maybeAssets, sharesDestroyed); + } + + STAmount sharesDestroyed; + STAmount assetsRecovered = clawbackAmount; + try + { + { + auto const maybeShares = assetsToSharesWithdraw( + vault, sleShareIssuance, assetsRecovered); + if (!maybeShares) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + sharesDestroyed = *maybeShares; + } + + auto const maybeAssets = + sharesToAssetsWithdraw(vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + assetsRecovered = *maybeAssets; + + // Clamp to maximum. + if (assetsRecovered > *assetsAvailable) + { + assetsRecovered = *assetsAvailable; + // Note, it is important to truncate the number of shares, + // otherwise the corresponding assets might breach the + // AssetsAvailable + { + auto const maybeShares = assetsToSharesWithdraw( + vault, + sleShareIssuance, + assetsRecovered, + TruncateShares::yes); + if (!maybeShares) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + sharesDestroyed = *maybeShares; + } + + auto const maybeAssets = sharesToAssetsWithdraw( + vault, sleShareIssuance, sharesDestroyed); + if (!maybeAssets) + return Unexpected(tecINTERNAL); // LCOV_EXCL_LINE + assetsRecovered = *maybeAssets; + if (assetsRecovered > *assetsAvailable) + { + // LCOV_EXCL_START + JLOG(j_.error()) + << "VaultClawback: invalid rounding of shares."; + return Unexpected(tecINTERNAL); + // LCOV_EXCL_STOP + } + } + } + catch (std::overflow_error const&) + { + // It's easy to hit this exception from Number with large enough + // Scale so we avoid spamming the log and only use debug here. + JLOG(j_.debug()) // + << "VaultClawback: overflow error with" + << " scale=" << (int)vault->at(sfScale).value() // + << ", assetsTotal=" << vault->at(sfAssetsTotal).value() + << ", sharesTotal=" << sleShareIssuance->at(sfOutstandingAmount) + << ", amount=" << clawbackAmount.value(); + return Unexpected(tecPATH_DRY); + } + + return std::make_pair(assetsRecovered, sharesDestroyed); } TER @@ -144,7 +348,7 @@ VaultClawback::doApply() if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE - auto const mptIssuanceID = *((*vault)[sfShareMPTID]); + auto const mptIssuanceID = *vault->at(sfShareMPTID); auto const sleIssuance = view().read(keylet::mptIssuance(mptIssuanceID)); if (!sleIssuance) { @@ -153,105 +357,47 @@ VaultClawback::doApply() return tefINTERNAL; // LCOV_EXCL_STOP } + MPTIssue const share{mptIssuanceID}; Asset const vaultAsset = vault->at(sfAsset); - STAmount const amount = [&]() -> STAmount { - auto const maybeAmount = tx[~sfAmount]; - if (maybeAmount) - return *maybeAmount; - return {sfAmount, vaultAsset, 0}; - }(); - XRPL_ASSERT( - amount.asset() == vaultAsset, - "ripple::VaultClawback::doApply : matching asset"); + STAmount const amount = clawbackAmount(vault, tx[~sfAmount], account_); auto assetsAvailable = vault->at(sfAssetsAvailable); auto assetsTotal = vault->at(sfAssetsTotal); + [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized); XRPL_ASSERT( lossUnrealized <= (assetsTotal - assetsAvailable), "ripple::VaultClawback::doApply : loss and assets do balance"); AccountID holder = tx[sfHolder]; - MPTIssue const share{mptIssuanceID}; STAmount sharesDestroyed = {share}; - STAmount assetsRecovered; - try + STAmount assetsRecovered = {vault->at(sfAsset)}; + + // The Owner is burning shares + if (account_ == vault->at(sfOwner) && amount.asset() == share) { - if (amount == beast::zero) - { - sharesDestroyed = accountHolds( - view(), - holder, - share, - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_); - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - } - else - { - assetsRecovered = amount; - { - auto const maybeShares = - assetsToSharesWithdraw(vault, sleIssuance, assetsRecovered); - if (!maybeShares) - return tecINTERNAL; // LCOV_EXCL_LINE - sharesDestroyed = *maybeShares; - } - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - } - - // Clamp to maximum. - if (assetsRecovered > *assetsAvailable) - { - assetsRecovered = *assetsAvailable; - // Note, it is important to truncate the number of shares, otherwise - // the corresponding assets might breach the AssetsAvailable - { - auto const maybeShares = assetsToSharesWithdraw( - vault, sleIssuance, assetsRecovered, TruncateShares::yes); - if (!maybeShares) - return tecINTERNAL; // LCOV_EXCL_LINE - sharesDestroyed = *maybeShares; - } - - auto const maybeAssets = - sharesToAssetsWithdraw(vault, sleIssuance, sharesDestroyed); - if (!maybeAssets) - return tecINTERNAL; // LCOV_EXCL_LINE - assetsRecovered = *maybeAssets; - if (assetsRecovered > *assetsAvailable) - { - // LCOV_EXCL_START - JLOG(j_.error()) - << "VaultClawback: invalid rounding of shares."; - return tecINTERNAL; - // LCOV_EXCL_STOP - } - } + sharesDestroyed = accountHolds( + view(), + holder, + share, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); } - catch (std::overflow_error const&) + else // The Issuer is clawbacking vault assets { - // It's easy to hit this exception from Number with large enough Scale - // so we avoid spamming the log and only use debug here. - JLOG(j_.debug()) // - << "VaultClawback: overflow error with" - << " scale=" << (int)vault->at(sfScale).value() // - << ", assetsTotal=" << vault->at(sfAssetsTotal).value() - << ", sharesTotal=" << sleIssuance->at(sfOutstandingAmount) - << ", amount=" << amount.value(); - return tecPATH_DRY; + XRPL_ASSERT( + amount.asset() == vaultAsset, + "xrpl::VaultClawback::doApply : matching asset"); + + auto const clawbackParts = + assetsToClawback(vault, sleIssuance, holder, amount); + if (!clawbackParts) + return clawbackParts.error(); + + assetsRecovered = clawbackParts->first; + sharesDestroyed = clawbackParts->second; } if (sharesDestroyed == beast::zero) @@ -301,30 +447,34 @@ VaultClawback::doApply() // else quietly ignore, holder balance is not zero } - // Transfer assets from vault to issuer. - if (auto const ter = accountSend( - view(), - vaultAccount, - account_, - assetsRecovered, - j_, - WaiveTransferFee::Yes); - !isTesSuccess(ter)) - return ter; - - // Sanity check - if (accountHolds( - view(), - vaultAccount, - assetsRecovered.asset(), - FreezeHandling::fhIGNORE_FREEZE, - AuthHandling::ahIGNORE_AUTH, - j_) < beast::zero) + if (assetsRecovered > beast::zero) { - // LCOV_EXCL_START - JLOG(j_.error()) << "VaultClawback: negative balance of vault assets."; - return tefINTERNAL; - // LCOV_EXCL_STOP + // Transfer assets from vault to issuer. + if (auto const ter = accountSend( + view(), + vaultAccount, + account_, + assetsRecovered, + j_, + WaiveTransferFee::Yes); + !isTesSuccess(ter)) + return ter; + + // Sanity check + if (accountHolds( + view(), + vaultAccount, + assetsRecovered.asset(), + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_) < beast::zero) + { + // LCOV_EXCL_START + JLOG(j_.error()) + << "VaultClawback: negative balance of vault assets."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } } return tesSUCCESS; diff --git a/src/xrpld/app/tx/detail/VaultClawback.h b/src/xrpld/app/tx/detail/VaultClawback.h index 65f0164686..b126b9b824 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.h +++ b/src/xrpld/app/tx/detail/VaultClawback.h @@ -41,6 +41,14 @@ public: TER doApply() override; + +private: + Expected, TER> + assetsToClawback( + std::shared_ptr const& vault, + std::shared_ptr const& sleShareIssuance, + AccountID const& holder, + STAmount const& clawbackAmount); }; } // namespace ripple