diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index bb0817673c..b892246a9b 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -265,6 +265,13 @@ canWithdraw( [[nodiscard]] TER canWithdraw(ReadView const& view, STTx const& tx); +/** + * Pays out a withdrawal from a vault or loan broker pseudo-account. + * + * `sourceAmount` leaves `sourceAcct` and `destinationAmount` reaches `dstAcct`. + * The two differ only when the withdrawal carries a transfer fee; the + * difference is the fee and is settled through the issuer. + */ [[nodiscard]] TER doWithdraw( ApplyViewContext ctx, @@ -272,7 +279,8 @@ doWithdraw( AccountID const& dstAcct, AccountID const& sourceAcct, XRPAmount priorBalance, - STAmount const& amount, + STAmount const& destinationAmount, + STAmount const& sourceAmount, beast::Journal j); /** diff --git a/include/xrpl/ledger/helpers/TokenHelpers.h b/include/xrpl/ledger/helpers/TokenHelpers.h index 12fa8a105e..3a52716a59 100644 --- a/include/xrpl/ledger/helpers/TokenHelpers.h +++ b/include/xrpl/ledger/helpers/TokenHelpers.h @@ -316,6 +316,13 @@ transferRate(ReadView const& view, Asset const& asset); [[nodiscard]] Rate transferRate(ReadView const& view, STAmount const& amount); +/** + * Returns the amount delivered when the transfer fee is deducted from a fixed + * source amount. + */ +[[nodiscard]] STAmount +subtractTransferFee(STAmount const& sourceAmount, Rate const& rate); + //------------------------------------------------------------------------------ // // Holding operations (Asset-based dispatchers) diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 75a49187b4..ce9260a35e 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -538,9 +538,14 @@ doWithdraw( AccountID const& dstAcct, AccountID const& sourceAcct, XRPAmount priorBalance, - STAmount const& amount, + STAmount const& destinationAmount, + STAmount const& sourceAmount, beast::Journal j) { + XRPL_ASSERT( + destinationAmount.asset() == sourceAmount.asset(), + "xrpl::doWithdraw : delivered and source amounts use the same asset"); + auto const dstSle = ctx.view.read(keylet::account(dstAcct)); // Create a trust line or MPToken for a self-destination only when there @@ -550,9 +555,10 @@ doWithdraw( // create+delete MPTokens in the same transaction. if (dstAcct == senderAcct) { - if (amount > beast::kZero || !ctx.view.rules().enabled(fixCleanup3_4_0)) + if (destinationAmount > beast::kZero || !ctx.view.rules().enabled(fixCleanup3_4_0)) { - if (auto const ter = addEmptyHolding(ctx, senderAcct, priorBalance, amount.asset(), j); + if (auto const ter = + addEmptyHolding(ctx, senderAcct, priorBalance, destinationAmount.asset(), j); !isTesSuccess(ter) && ter != tecDUPLICATE) return ter; } @@ -567,17 +573,33 @@ doWithdraw( if (accountHolds( ctx.view, sourceAcct, - amount.asset(), + destinationAmount.asset(), FreezeHandling::IgnoreFreeze, AuthHandling::IgnoreAuth, - j) < amount) + j) < sourceAmount) { // LCOV_EXCL_START - JLOG(j.error()) << "doWithdraw: negative balance of broker cover assets."; + JLOG(j.error()) << "doWithdraw: source account holds fewer assets than the withdrawal."; return tefINTERNAL; // LCOV_EXCL_STOP } + // A transfer fee applies. Redeem the gross amount with the issuer, then + // issue the net amount to the destination. + if (sourceAmount != destinationAmount) + { + auto const issuer = destinationAmount.getIssuer(); + XRPL_ASSERT( + !destinationAmount.native() && sourceAcct != issuer && dstAcct != issuer, + "xrpl::doWithdraw : transfer fee applies between token holders"); + + if (auto const ter = directSendNoFee(ctx.view, sourceAcct, issuer, sourceAmount, false, j); + !isTesSuccess(ter)) + return ter; + + return directSendNoFee(ctx.view, issuer, dstAcct, destinationAmount, false, j); + } + // A reserve sponsor only covers tx.Account's own objects, so resolve the // sponsor against the destination. accountSend can auto-create a holding // for dstAcct; keying on the destination ensures a third-party destination's @@ -589,7 +611,7 @@ doWithdraw( // Move the funds directly from the broker's pseudo-account to the // dstAcct return accountSend( - ctx.view, sourceAcct, dstAcct, amount, j, *sponsorSle, WaiveTransferFee::Yes); + ctx.view, sourceAcct, dstAcct, destinationAmount, j, *sponsorSle, WaiveTransferFee::Yes); } TER diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 2c2c943a9f..7b3a0a11fd 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -548,6 +548,12 @@ transferRate(ReadView const& view, STAmount const& amount) return transferRate(view, amount.asset()); } +STAmount +subtractTransferFee(STAmount const& sourceAmount, Rate const& rate) +{ + return divideRound(sourceAmount, rate, sourceAmount.asset(), false); +} + //------------------------------------------------------------------------------ // // Holding operations diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 69c3ce92e0..bdb7938c83 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -418,6 +419,7 @@ ValidVault::finalize( { bool const enforce = view.rules().enabled(featureSingleAssetVault); bool const fix340Enabled = view.rules().enabled(fixCleanup3_4_0); + bool const fix350Enabled = view.rules().enabled(fixCleanup3_5_0); if (!isTesSuccess(ret)) return true; // Do not perform checks @@ -1144,6 +1146,17 @@ ValidVault::finalize( auto const localPseudoDeltaAssets = roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale); + bool const feeAdjustedWithdrawal = + fix350Enabled && distinctDestination && !vaultAsset.native(); + auto expectedDestinationDelta = localPseudoDeltaAssets * -1; + if (feeAdjustedWithdrawal) + { + expectedDestinationDelta = + subtractTransferFee( + STAmount{vaultAsset, expectedDestinationDelta}, + transferRate(view, vaultAsset)) + .value(); + } // For IOU assets near a precision boundary the destination's STAmount // exponent can shift, making part of the sent value unrepresentable at // the receiver's new scale — that portion is irreversibly absorbed by the @@ -1152,7 +1165,8 @@ ValidVault::finalize( // the destination's scale. Floor rounding is used so that values exactly // at the step boundary are not mistakenly dismissed. Any representable // discrepancy indicates a real accounting bug and must be caught. - auto const destroyedIsSubUlp = tolerateZeroDelta && + auto const destroyedIsSubUlp = !feeAdjustedWithdrawal && + tolerateZeroDelta && roundToAsset( vaultAsset, vaultDeltaAssets.delta * -1 - destinationDelta.delta, @@ -1160,11 +1174,11 @@ ValidVault::finalize( Number::RoundingMode::Downward) == kZero; bool const withdrawAddsUp = fix340Enabled ? agreesWithinOneUnit( - localPseudoDeltaAssets * -1, + expectedDestinationDelta, roundedDestinationDelta, vaultAsset, localMinScale) - : localPseudoDeltaAssets * -1 == roundedDestinationDelta; + : expectedDestinationDelta == roundedDestinationDelta; if (!destroyedIsSubUlp && !withdrawAddsUp) { JLOG(j.fatal()) << "Invariant failed: " << // diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp index 88b6f8c38b..3b1911522c 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp @@ -239,6 +239,7 @@ LoanBrokerCoverWithdraw::doApply() brokerPseudoID, preFeeBalance_, amount, + amount, j_); } diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 4dc5b95c89..3f438b93ea 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -12,11 +12,13 @@ #include #include #include +#include #include #include #include // IWYU pragma: keep #include #include +#include #include #include #include // IWYU pragma: keep @@ -49,6 +51,24 @@ shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const : WaiveUnrealizedLoss::No; } +static Rate +withdrawalTransferRate( + ReadView const& view, + AccountID const& account, + AccountID const& destination, + Asset const& asset) +{ + // Pre-fixCleanup3_5_0: every vault withdrawal waives the transfer fee. + // Post-fixCleanup3_5_0: the fee applies only when another token holder receives the assets. + if (!view.rules().enabled(fixCleanup3_5_0)) + return kParityRate; + + if (asset.native() || destination == account || destination == asset.getIssuer()) + return kParityRate; + + return transferRate(view, asset); +} + NotTEC VaultWithdraw::preflight(PreflightContext const& ctx) { @@ -170,11 +190,13 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (!maybeAssets) return tefINTERNAL; // LCOV_EXCL_LINE + auto const rate = withdrawalTransferRate(ctx.view, account, dstAcct, vaultAsset); + auto const amountToReceive = subtractTransferFee(*maybeAssets, rate); if (auto const ret = canWithdraw( ctx.view, account, dstAcct, - *maybeAssets, + amountToReceive, ctx.tx.isFieldPresent(sfDestinationTag), ctx.tx[~sfCredentialIDs])) return ret; @@ -302,6 +324,8 @@ VaultWithdraw::doApply() auto const amount = ctx_.tx[sfAmount]; Asset const vaultAsset = vault->at(sfAsset); + auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_); + auto const rate = withdrawalTransferRate(view(), accountID_, dstAcct, vaultAsset); MPTIssue const share{mptIssuanceID}; STAmount sharesRedeemed = {share}; @@ -329,8 +353,10 @@ VaultWithdraw::doApply() auto const truncate = view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No; { + auto const sourceAmount = + rate == kParityRate ? amount : multiplyRound(amount, rate, vaultAsset, true); auto const maybeShares = assetsToSharesWithdraw( - vault, sleIssuance, amount, truncate, waiveUnrealizedLoss); + vault, sleIssuance, sourceAmount, truncate, waiveUnrealizedLoss); if (!maybeShares) return tecINTERNAL; // LCOV_EXCL_LINE sharesRedeemed = *maybeShares; @@ -531,7 +557,17 @@ VaultWithdraw::doApply() << " assetsAvailable=" << allAvailable.getText(); } assetsWithdrawn = allAvailable; + } + auto const assetsDelivered = subtractTransferFee(assetsWithdrawn, rate); + if (assetsWithdrawn > beast::kZero && assetsDelivered == beast::kZero) + { + JLOG(j_.debug()) << "VaultWithdraw: transfer fee reduces the payout to zero"; + return tecPRECISION_LOSS; + } + + if (isFinalWithdrawal) + { // Do not let dust accumulate in the Vault. assetsTotal = 0; assetsAvailable = 0; @@ -583,9 +619,15 @@ VaultWithdraw::doApply() associateAsset(*vault, vaultAsset); - auto const dstAcct = ctx_.tx[~sfDestination].value_or(accountID_); return doWithdraw( - applyViewContext, accountID_, dstAcct, vaultAccount, preFeeBalance_, assetsWithdrawn, j_); + applyViewContext, + accountID_, + dstAcct, + vaultAccount, + preFeeBalance_, + assetsDelivered, + assetsWithdrawn, + j_); } void diff --git a/src/test/app/vault/VaultLifecycle_test.cpp b/src/test/app/vault/VaultLifecycle_test.cpp index 8d7afe67f2..04fd8392fd 100644 --- a/src/test/app/vault/VaultLifecycle_test.cpp +++ b/src/test/app/vault/VaultLifecycle_test.cpp @@ -50,6 +50,38 @@ namespace xrpl { class VaultLifecycle_test : public VaultTestBase { private: + struct FundedVault + { + Keylet keylet; + test::jtx::Account account; + test::jtx::PrettyAsset shares; + }; + + /** + * Creates a vault owned by `owner` and deposits `amount` into it from `depositor`. + */ + static FundedVault + createFundedVault( + test::jtx::Env& env, + test::jtx::Vault& vault, + test::jtx::Account const& owner, + test::jtx::Account const& depositor, + STAmount const& amount) + { + auto [tx, keylet] = vault.create({.owner = owner, .asset = amount.asset()}); + env(tx); + env.close(); + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount})); + env.close(); + + auto const sle = env.le(keylet); + test::jtx::Account const account{"vault", sle->at(sfAccount)}; + env.memoize(account); + return { + .keylet = keylet, + .account = account, + .shares = test::jtx::PrettyAsset{MPTIssue{sle->at(sfShareMPTID)}}}; + } void testSequences() { @@ -569,6 +601,7 @@ private: bool enableClawback = true; bool requireAuth = true; int initialXRP = 1000; + std::uint16_t transferFee = 0; FeatureBitset features = testableAmendments(); }; @@ -593,7 +626,8 @@ private: MPTTester mptt{env, issuer, kMptInitNoFund}; auto const kNone = LedgerSpecificFlags(0); mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock | + {.transferFee = args.transferFee, + .flags = tfMPTCanTransfer | tfMPTCanLock | (args.enableClawback ? tfMPTCanClawback : kNone) | (args.requireAuth ? tfMPTRequireAuth : kNone)}); PrettyAsset const asset = mptt.issuanceID(); @@ -611,6 +645,230 @@ private: test(env, issuer, owner, depositor, asset, vault, mptt); }; + // The MPT charges a 25% transfer fee, so 100 gross delivers 80 net. + auto const feeArgs = CaseArgs{.transferFee = 25'000}; + + auto const testTransferFeeGate = [&](FeatureBitset const& features) { + testCase( + [this, features]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + bool const feeCharged = features[fixCleanup3_5_0]; + testcase( + feeCharged ? "MPT transfer fee on third-party withdrawal" + : "MPT transfer fee waived pre-fixCleanup3_5_0"); + + auto const funded = createFundedVault(env, vault, owner, depositor, asset(100)); + + auto tx = vault.withdraw( + {.depositor = depositor, + .id = funded.keylet.key, + .amount = funded.shares(100)}); + tx[sfDestination] = owner.human(); + env(tx); + env.close(); + + auto const expected = feeCharged ? 80 : 100; + BEAST_EXPECT(mptt.checkMPTokenAmount(depositor, 900)); + BEAST_EXPECT(mptt.checkMPTokenAmount(owner, expected)); + BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(900 + expected)); + BEAST_EXPECT( + env.balance(funded.account, asset.raw().get()) == asset(0)); + + env(vault.del({.owner = owner, .id = funded.keylet.key})); + env.close(); + }, + CaseArgs{.transferFee = 25'000, .features = features}); + }; + testTransferFeeGate(testableAmendments() - fixCleanup3_5_0); + testTransferFeeGate(testableAmendments()); + + testCase( + [this]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT transfer fee on fixed-asset third-party withdrawal"); + + auto const funded = createFundedVault(env, vault, owner, depositor, asset(100)); + auto const mptIssue = asset.raw().get(); + auto const shareIssue = funded.shares.raw().get(); + + auto tx = vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = asset(40)}); + tx[sfDestination] = owner.human(); + env(tx); + env.close(); + + // The destination receives the requested 40; the gross 50 leaves the vault. + BEAST_EXPECT(mptt.checkMPTokenAmount(owner, 40)); + BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(50)); + BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(50)); + BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(990)); + }, + feeArgs); + + testCase( + [this]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT transfer fee on fixed-share third-party withdrawal"); + + auto const funded = createFundedVault(env, vault, owner, depositor, asset(100)); + auto const mptIssue = asset.raw().get(); + auto const shareIssue = funded.shares.raw().get(); + + auto tx = vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = funded.shares(25)}); + tx[sfDestination] = owner.human(); + env(tx); + env.close(); + + // The 25 shares redeem 25 assets; the destination receives 20 net. + BEAST_EXPECT(mptt.checkMPTokenAmount(owner, 20)); + BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(75)); + BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(75)); + BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(995)); + }, + feeArgs); + + testCase( + [this]( + Env& env, + Account const& issuer, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT no transfer fee on withdrawal to issuer"); + + auto const funded = createFundedVault(env, vault, owner, depositor, asset(100)); + auto const mptIssue = asset.raw().get(); + auto const shareIssue = funded.shares.raw().get(); + + auto tx = vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = asset(10)}); + tx[sfDestination] = issuer.human(); + env(tx); + env.close(); + + BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(90)); + BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(90)); + BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(990)); + }, + feeArgs); + + testCase( + [this]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT no transfer fee on self-withdrawal"); + + auto const funded = createFundedVault(env, vault, owner, depositor, asset(100)); + auto const mptIssue = asset.raw().get(); + + env(vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = asset(10)})); + env.close(); + + BEAST_EXPECT(mptt.checkMPTokenAmount(depositor, 910)); + BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(90)); + BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(1000)); + }, + feeArgs); + + testCase( + [this]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT transfer fee must fit in the vault position"); + + auto const funded = createFundedVault(env, vault, owner, depositor, asset(100)); + auto const mptIssue = asset.raw().get(); + auto const shareIssue = funded.shares.raw().get(); + + // Delivering 81 costs 102 gross, more than the 100 shares held. + auto tx = vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = asset(81)}); + tx[sfDestination] = owner.human(); + env(tx, Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + + // Delivering 80 costs exactly the 100 shares held. + tx = vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = asset(80)}); + tx[sfDestination] = owner.human(); + env(tx); + env.close(); + + BEAST_EXPECT(mptt.checkMPTokenAmount(owner, 80)); + BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(0)); + BEAST_EXPECT(env.balance(depositor, shareIssue) == funded.shares(0)); + BEAST_EXPECT(mptt.checkMPTokenOutstandingAmount(980)); + + env(vault.del({.owner = owner, .id = funded.keylet.key})); + env.close(); + }, + feeArgs); + + testCase( + [this]( + Env& env, + Account const&, + Account const& owner, + Account const& depositor, + PrettyAsset const& asset, + Vault& vault, + MPTTester& mptt) { + testcase("MPT transfer fee rounds the payout down to zero"); + + auto const funded = createFundedVault(env, vault, owner, depositor, asset(1)); + auto const mptIssue = asset.raw().get(); + + auto tx = vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = funded.shares(1)}); + tx[sfDestination] = owner.human(); + env(tx, Ter{tecPRECISION_LOSS}); + env.close(); + + // A self-withdrawal pays no fee, so the same share still redeems. + env(vault.withdraw( + {.depositor = depositor, .id = funded.keylet.key, .amount = funded.shares(1)})); + env.close(); + + BEAST_EXPECT(mptt.checkMPTokenAmount(depositor, 1000)); + BEAST_EXPECT(env.balance(funded.account, mptIssue) == asset(0)); + + env(vault.del({.owner = owner, .id = funded.keylet.key})); + env.close(); + }, + feeArgs); + testCase([this]( Env& env, Account const& issuer, @@ -1487,66 +1745,251 @@ private: env.close(); }); + // The issuer charges a 25% transfer fee, so 100 gross delivers 80 net. + auto const feeArgs = CaseArgs{.transferRate = 1.25}; + + auto const testTransferFeeGate = [&](FeatureBitset const& features) { + testCase( + [this, features]( + Env& env, + Account const& owner, + Account const& issuer, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + bool const feeCharged = features[fixCleanup3_5_0]; + testcase( + feeCharged ? "IOU transfer fee on third-party withdrawal" + : "IOU transfer fee waived pre-fixCleanup3_5_0"); + + auto const funded = createFundedVault(env, vault, owner, owner, asset(100)); + auto const issue = asset.raw().get(); + + // Deposits are fee-free. + BEAST_EXPECT(env.balance(owner, issue) == asset(100)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(100)); + + env(vault.clawback( + {.issuer = issuer, + .id = funded.keylet.key, + .holder = owner, + .amount = asset(50)})); + env.close(); + + // Clawbacks are fee-free. + BEAST_EXPECT(env.balance(owner, issue) == asset(100)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(50)); + + env(vault.withdraw( + {.depositor = owner, + .id = funded.keylet.key, + .amount = funded.shares(20'000'000)})); + env.close(); + + // Self-withdrawals are fee-free. + BEAST_EXPECT(env.balance(owner, issue) == asset(120)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(30)); + + auto tx = vault.withdraw( + {.depositor = owner, + .id = funded.keylet.key, + .amount = funded.shares(30'000'000)}); + tx[sfDestination] = charlie.human(); + env(tx); + env.close(); + + auto const expected = feeCharged ? asset(24) : asset(30); + BEAST_EXPECT(env.balance(owner, issue) == asset(120)); + BEAST_EXPECT(env.balance(charlie, issue) == expected); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(0)); + + env(vault.del({.owner = owner, .id = funded.keylet.key})); + env.close(); + }, + CaseArgs{.transferRate = 1.25, .features = features}); + }; + testTransferFeeGate(testableAmendments() - fixCleanup3_5_0); + testTransferFeeGate(testableAmendments()); + testCase( - [&, this]( + [this]( Env& env, Account const& owner, - Account const& issuer, + Account const&, Account const& charlie, - auto vaultAccount, + auto, Vault& vault, PrettyAsset const& asset, - auto issuanceId) { - testcase("IOU transfer fees not applied"); + auto&&...) { + testcase("IOU transfer fee on fixed-asset third-party withdrawal"); - auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + auto const funded = createFundedVault(env, vault, owner, owner, asset(100)); + auto const issue = asset.raw().get(); + auto const shareIssue = funded.shares.raw().get(); + + auto tx = vault.withdraw( + {.depositor = owner, .id = funded.keylet.key, .amount = asset(40)}); + tx[sfDestination] = charlie.human(); env(tx); env.close(); - env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)})); + // The destination receives the requested 40; the gross 50 leaves the vault. + BEAST_EXPECT(env.balance(charlie, issue) == asset(40)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(50)); + BEAST_EXPECT(env.balance(owner, shareIssue) == funded.shares(50'000'000)); + BEAST_EXPECT(env.balance(owner, issue) == asset(100)); + }, + feeArgs); + + testCase( + [this]( + Env& env, + Account const& owner, + Account const&, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU transfer fee on fixed-share third-party withdrawal"); + + auto const funded = createFundedVault(env, vault, owner, owner, asset(100)); + auto const issue = asset.raw().get(); + auto const shareIssue = funded.shares.raw().get(); + + auto tx = vault.withdraw( + {.depositor = owner, + .id = funded.keylet.key, + .amount = funded.shares(25'000'000)}); + tx[sfDestination] = charlie.human(); + env(tx); env.close(); + // The shares redeem 25 assets; the destination receives 20 net. + BEAST_EXPECT(env.balance(charlie, issue) == asset(20)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(75)); + BEAST_EXPECT(env.balance(owner, shareIssue) == funded.shares(75'000'000)); + }, + feeArgs); + + testCase( + [this]( + Env& env, + Account const& owner, + Account const& issuer, + Account const&, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU no transfer fee on withdrawal to issuer"); + + auto const funded = createFundedVault(env, vault, owner, owner, asset(100)); auto const issue = asset.raw().get(); - Asset const share = Asset(issuanceId(keylet)); - // transfer fees ignored on deposit + auto tx = vault.withdraw( + {.depositor = owner, .id = funded.keylet.key, .amount = asset(10)}); + tx[sfDestination] = issuer.human(); + env(tx); + env.close(); + + BEAST_EXPECT(env.balance(funded.account, issue) == asset(90)); BEAST_EXPECT(env.balance(owner, issue) == asset(100)); - BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100)); + }, + feeArgs); - { - auto tx = vault.clawback( - {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)}); - env(tx); - env.close(); - } + testCase( + [this]( + Env& env, + Account const& owner, + Account const&, + Account const&, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU no transfer fee on self-withdrawal"); - // transfer fees ignored on clawback - BEAST_EXPECT(env.balance(owner, issue) == asset(100)); - BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50)); + auto const funded = createFundedVault(env, vault, owner, owner, asset(100)); + auto const issue = asset.raw().get(); env(vault.withdraw( - {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)})); + {.depositor = owner, .id = funded.keylet.key, .amount = asset(10)})); + env.close(); - // transfer fees ignored on withdraw - BEAST_EXPECT(env.balance(owner, issue) == asset(120)); - BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30)); + BEAST_EXPECT(env.balance(owner, issue) == asset(110)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(90)); + }, + feeArgs); - { - auto tx = vault.withdraw( - {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)}); - tx[sfDestination] = charlie.human(); - env(tx); - } + testCase( + [this]( + Env& env, + Account const& owner, + Account const&, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU transfer fee must fit in the vault position"); - // 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)); + auto const funded = createFundedVault(env, vault, owner, owner, asset(100)); + auto const issue = asset.raw().get(); - env(vault.del({.owner = owner, .id = keylet.key})); + // Delivering 81 costs 101.25 gross, more than the vault position. + auto tx = vault.withdraw( + {.depositor = owner, .id = funded.keylet.key, .amount = asset(81)}); + tx[sfDestination] = charlie.human(); + env(tx, Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + + // Delivering 80 costs exactly the 100 held. + tx = vault.withdraw( + {.depositor = owner, .id = funded.keylet.key, .amount = asset(80)}); + tx[sfDestination] = charlie.human(); + env(tx); + env.close(); + + BEAST_EXPECT(env.balance(charlie, issue) == asset(80)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(0)); + + env(vault.del({.owner = owner, .id = funded.keylet.key})); env.close(); }, - CaseArgs{.transferRate = 1.25}); + feeArgs); + + testCase( + [this]( + Env& env, + Account const& owner, + Account const&, + Account const& charlie, + auto, + Vault& vault, + PrettyAsset const& asset, + auto&&...) { + testcase("IOU receiver limit applies to the net amount"); + + // The limit fits the 40 delivered but not the 50 gross. + env(trust(charlie, asset(40))); + env.close(); + + auto const funded = createFundedVault(env, vault, owner, owner, asset(100)); + auto const issue = asset.raw().get(); + + auto tx = vault.withdraw( + {.depositor = owner, .id = funded.keylet.key, .amount = asset(40)}); + tx[sfDestination] = charlie.human(); + env(tx); + env.close(); + + BEAST_EXPECT(env.balance(charlie, issue) == asset(40)); + BEAST_EXPECT(env.balance(funded.account, issue) == asset(50)); + }, + feeArgs); testCase([&, this]( Env& env,