fix: Charge transfer fee on third-party vault withdrawals

A VaultWithdraw that names a Destination other than the submitter
delivered the asset with the issuer's transfer fee waived. Depositing
into a vault and withdrawing to someone else therefore moved IOUs and
MPTs without paying the fee.

Under fixCleanup3_5_0 the withdrawer's vault position pays the fee. A
fixed-asset request still delivers the requested amount and redeems
enough shares to cover the gross cost. A fixed-share request burns the
requested shares and delivers what is left after the fee. Withdrawals
to the submitter or to the issuer stay fee-free. The fee is settled by
redeeming the gross amount with the issuer and issuing the net amount
to the destination. ValidVault expects the destination delta to be the
vault outflow less the fee.
This commit is contained in:
Timur Ialymov
2026-09-22 21:47:36 +01:00
parent 8f4e9c25d8
commit e6a5157891
8 changed files with 594 additions and 51 deletions

View File

@@ -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);
/**

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -7,6 +7,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -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: " << //

View File

@@ -239,6 +239,7 @@ LoanBrokerCoverWithdraw::doApply()
brokerPseudoID,
preFeeBalance_,
amount,
amount,
j_);
}

View File

@@ -12,11 +12,13 @@
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/Rate.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // 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

View File

@@ -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<MPTIssue>()) == 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<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
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<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
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<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
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<MPTIssue>();
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<MPTIssue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
// 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<MPTIssue>();
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<Issue>();
// 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<Issue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
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<Issue>();
auto const shareIssue = funded.shares.raw().get<MPTIssue>();
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<Issue>();
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<Issue>();
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<Issue>();
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<Issue>();
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,