Skip to content

Enforce reserve when creating trust line or MPToken in VaultWithdraw#5857

Merged
bthomee merged 1 commit intodevelopfrom
Bronek/VaultWithdraw_reserve
Oct 20, 2025
Merged

Enforce reserve when creating trust line or MPToken in VaultWithdraw#5857
bthomee merged 1 commit intodevelopfrom
Bronek/VaultWithdraw_reserve

Conversation

@Bronek
Copy link
Copy Markdown
Collaborator

@Bronek Bronek commented Oct 6, 2025

High Level Overview of Change

Similarly to other transaction typed which can create a trust line or MPToken for the transaction submitter (e.g. CashCheck #5285 , EscrowFinish #5185 ), VaultWithdraw should enforce reserve before creating a new object.

Additionally, enforce lsfRequireDestTag account flag for the transaction submitter.

Context of Change

This fixes #5837 and improves consistency with other transaction types.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

@codecov
Copy link
Copy Markdown

codecov bot commented Oct 6, 2025

Codecov Report

❌ Patch coverage is 95.65217% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.5%. Comparing base (afb6e0e) to head (0763af9).
⚠️ Report is 4 commits behind head on develop.

Files with missing lines Patch % Lines
src/xrpld/app/tx/detail/VaultWithdraw.cpp 94.7% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##           develop   #5857     +/-   ##
=========================================
- Coverage     79.5%   79.5%   -0.0%     
=========================================
  Files          816     816             
  Lines        72185   72176      -9     
  Branches      8297    8273     -24     
=========================================
- Hits         57372   57362     -10     
- Misses       14813   14814      +1     
Files with missing lines Coverage Δ
include/xrpl/protocol/detail/transactions.macro 100.0% <ø> (ø)
src/libxrpl/ledger/View.cpp 94.6% <100.0%> (+0.2%) ⬆️
src/xrpld/app/tx/detail/VaultDeposit.cpp 97.7% <100.0%> (+0.7%) ⬆️
src/xrpld/app/tx/detail/VaultWithdraw.cpp 97.9% <94.7%> (+1.2%) ⬆️

... and 5 files with indirect coverage changes

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@@ -1888,6 +1888,37 @@ class Vault_test : public beast::unit_test::suite
env(tx, ter(tecNO_AUTH));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's hard to tell with your test case/suite structure but do you have tests where the issuer is the depositor? Also you test frozen but I don't see deep freeze or auth or no ripple?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this branch, there is withdrawal to 3rd party which happens to be issuer

testcase(prefix + " withdraw to issuer");

However this branch does not allow the issuer to be also depositor. This is fixed in #5518 and the test to match is

testcase(prefix + " issuer deposits");

Comment on lines +319 to +335
auto const sleAcct = view().read(keylet::account(dstAcct));
if (!sleAcct)
return tecINTERNAL; // LCOV_EXCL_LINE

if (auto const sleLine =
view().read(keylet::line(dstAcct, vaultAsset.get<Issue>()));
sleLine == nullptr)
{
// This should be enforced by `requireAuth` called in `preclaim`
if (dstAcct != account_)
return tecINTERNAL; // LCOV_EXCL_LINE

// Can the account cover the trust line's reserve ?
std::uint32_t const ownerCount = sleAcct->at(sfOwnerCount);
if (mPriorBalance < view().fees().accountReserve(ownerCount + 1))
return tecNO_LINE_INSUF_RESERVE;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to suggest a little reorganization here.

Suggested change
auto const sleAcct = view().read(keylet::account(dstAcct));
if (!sleAcct)
return tecINTERNAL; // LCOV_EXCL_LINE
if (auto const sleLine =
view().read(keylet::line(dstAcct, vaultAsset.get<Issue>()));
sleLine == nullptr)
{
// This should be enforced by `requireAuth` called in `preclaim`
if (dstAcct != account_)
return tecINTERNAL; // LCOV_EXCL_LINE
// Can the account cover the trust line's reserve ?
std::uint32_t const ownerCount = sleAcct->at(sfOwnerCount);
if (mPriorBalance < view().fees().accountReserve(ownerCount + 1))
return tecNO_LINE_INSUF_RESERVE;
}
if (auto const sleLine =
view().read(keylet::line(dstAcct, vaultAsset.get<Issue>()));
sleLine == nullptr)
{
// This should be enforced by `requireAuth` called in `preclaim`
if (dstAcct != account_)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const sleAcct = view().read(keylet::account(account_));
if (!sleAcct)
return tecINTERNAL; // LCOV_EXCL_LINE
// Can account_ cover the trust line's reserve ?
std::uint32_t const ownerCount = sleAcct->at(sfOwnerCount);
if (mPriorBalance < view().fees().accountReserve(ownerCount + 1))
return tecNO_LINE_INSUF_RESERVE;
}

Basically, don't load the AccountRoot SLE until after you've verified that the line doesn't exist, and then when you do, specify it as account_, so it's clearer which account you're dealing with.

Even though computing the Keylet for the line is slightly more expensive than computing the Keylet for the account, if the line already exists, or if dstAcct != account_, you'll skip computing the account key entirely, and loading the object entirely.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is good.

@Bronek
Copy link
Copy Markdown
Collaborator Author

Bronek commented Oct 7, 2025

While writing this I've realised that VaultWithdraw should also create MPToken when the submitter is also the destination account, and the MPToken for an MPT asset does not exist but otherwise would be authorized. I will add this change here.

This is similar to what the TokenEscrow amendment has done for MPT in EscrowFinish transaction.

@Bronek Bronek requested review from a team, dangell7, gregtatcam and ximinez October 7, 2025 14:17

if (sleAcct->isFlag(lsfRequireDestTag) &&
!ctx.tx.isFieldPresent(sfDestinationTag))
return tecDST_TAG_NEEDED; // Cannot send without a tag
Copy link
Copy Markdown
Collaborator Author

@Bronek Bronek Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change is for consistency with other transaction types

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change is for consistency with other transaction types

This code is basically duplicated from the account != dstAccount block, other than the result for a missing account.

Would it make more sense to take this code out of the if statement, and check which account before deciding which TER to return?

Something like

    auto const sleDst = ctx.view.read(keylet::account(dstAcct));
    if (sleDst == nullptr)
        return account == dstAcct : tecINTERNAL : tecNO_DST;

    if (sleDst->isFlag(lsfRequireDestTag) &&
        !ctx.tx.isFieldPresent(sfDestinationTag))
        return tecDST_TAG_NEEDED;  // Cannot send without a tag

    // If sending to Account (i.e. not a transfer), we will also create
    // (only if authorized) a trust line or MPToken as needed, in doApply().
    AuthType authType = AuthType::WeakAuth;
    if (account != dstAcct)
    {
        if (sleDst->isFlag(lsfDepositAuth))
        {
            if (!ctx.view.exists(keylet::depositPreauth(dstAcct, account)))
                return tecNO_PERMISSION;
        }
        // The destination account must have consented to receive the asset by
        // creating a RippleState or MPToken
        authType = AuthType::StrongAuth;
    }

@Bronek Bronek changed the title Enforce reserve when creating trust line in VaultWithdraw Enforce reserve when creating trust line or MPToken in VaultWithdraw Oct 7, 2025
@Bronek Bronek force-pushed the Bronek/VaultWithdraw_reserve branch from 596441a to 9a64b43 Compare October 7, 2025 14:27
return account_;
}();

if (!vaultAsset.native() && dstAcct != vaultAsset.getIssuer())
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't also check if dstAcct == account_? A trusdtline or MPToken only should be created if that condition is true.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I will do it.


// If the above succeeded then the required trust line
// will be created indirectly by `accountSend` below.
return tesSUCCESS;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need this. Can just let it fall through.


// Above will perform reserve check, create MPToken,
// attach it to acccount_, adjust owner count etc.
return tesSUCCESS;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need this. Can just let it fall through.

sleLine == nullptr)
{
// Enforced by `requireAuth` called in `preclaim`
if (dstAcct != account_)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dstAcct may be different from account_. I think should only execute std::visit if dstAcct == account_.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, however this condition is not faulty - it just relies on check performed in preclaim. But I like your idea better.

Account const& owner,
Account const& depositor,
Account const& charlie,
Account const& dave,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think can simplify testSequence and testCases by defining the accounts once at the start of teseSequences:

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be true for some other code when the passed accounts are always the same. For instance, issuer is always `Account{"issuer"}'.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea

@Bronek Bronek force-pushed the Bronek/VaultWithdraw_reserve branch from c04fd01 to 7a55863 Compare October 8, 2025 16:24
@Bronek
Copy link
Copy Markdown
Collaborator Author

Bronek commented Oct 8, 2025

@gregtatcam your feedback is addressed in 7a55863 - thank you !

if constexpr (std::is_same_v<TIss, Issue>)
{
std::shared_ptr<SLE> dummy = {};
if (auto const sleLine =
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more efficient:

 if (!view().exists(keylet::line(dstAcct, issue)))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also more explicit - thanks !

}
else
{
if (auto const sleMPT = view().read(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more efficient:

 if (!view().exists(keylet::mptoken(issue.getMptID(), account_)))

Copy link
Copy Markdown
Collaborator

@gregtatcam gregtatcam left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 LGTM

Comment on lines +66 to +70
@@ -67,6 +67,7 @@ class Vault_test : public beast::unit_test::suite
Account const& owner,
Account const& depositor,
Account const& charlie,
Account const& dave,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this list of Account params ever gets too unwieldy, you can simply "recreate" them inside the function. The values are deterministic and cached, so you don't have to worry about wasting resources either.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this list of Account params ever gets too unwieldy, you can simply "recreate" them inside the function. The values are deterministic and cached, so you don't have to worry about wasting resources either.

I see you have already rewritten them to have the accounts be in an outer scope, which is even simpler.


if (sleAcct->isFlag(lsfRequireDestTag) &&
!ctx.tx.isFieldPresent(sfDestinationTag))
return tecDST_TAG_NEEDED; // Cannot send without a tag
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change is for consistency with other transaction types

This code is basically duplicated from the account != dstAccount block, other than the result for a missing account.

Would it make more sense to take this code out of the if statement, and check which account before deciding which TER to return?

Something like

    auto const sleDst = ctx.view.read(keylet::account(dstAcct));
    if (sleDst == nullptr)
        return account == dstAcct : tecINTERNAL : tecNO_DST;

    if (sleDst->isFlag(lsfRequireDestTag) &&
        !ctx.tx.isFieldPresent(sfDestinationTag))
        return tecDST_TAG_NEEDED;  // Cannot send without a tag

    // If sending to Account (i.e. not a transfer), we will also create
    // (only if authorized) a trust line or MPToken as needed, in doApply().
    AuthType authType = AuthType::WeakAuth;
    if (account != dstAcct)
    {
        if (sleDst->isFlag(lsfDepositAuth))
        {
            if (!ctx.view.exists(keylet::depositPreauth(dstAcct, account)))
                return tecNO_PERMISSION;
        }
        // The destination account must have consented to receive the asset by
        // creating a RippleState or MPToken
        authType = AuthType::StrongAuth;
    }

@Bronek Bronek requested a review from ximinez October 14, 2025 10:53
@Bronek Bronek added the Needs additional review PR requires at least one more code review approval before it can be merged label Oct 15, 2025
@Bronek Bronek requested review from shawnxie999 and removed request for mvadari October 15, 2025 16:30
{
if (auto const ter = addEmptyHolding(
view(), account_, mPriorBalance, vaultAsset, j_);
ter && ter != tecDUPLICATE)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could/should return tesSUCCESS if the line already exists imo.

Also you call isGlobalFrozen here but also call it in preclaim too right? canAddHolding does the same checks as this but I don't see canAddHolding called? Does that mean that this function can return internal?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could/should return tesSUCCESS if the line already exists imo.

There are callers where it would be a weird problem if the holding already exists. For example, the transactions that create new accounts.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally I would suggest an additional flag as a function input parameter to choose the semantics of duplicates handling. In this case, if we want to go this way, #5270 would be a better fit to make a change (I would not want this small PR to be an obstacle for the larger PR to trip on).

Copy link
Copy Markdown
Collaborator

@shawnxie999 shawnxie999 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

@Bronek Bronek added Ready to merge *PR author* thinks it's ready to merge. Has passed code review. Perf sign-off may still be required. and removed Needs additional review PR requires at least one more code review approval before it can be merged labels Oct 20, 2025
…draw

Similarly to other transaction typed that can create a trust line or MPToken for the transaction submitter (e.g. CashCheck #5285, EscrowFinish #5185 ), VaultWithdraw should enforce reserve before creating a new object. Additionally, the lsfRequireDestTag account flag should be enforced for the transaction submitter.
@bthomee bthomee force-pushed the Bronek/VaultWithdraw_reserve branch from fdfc354 to 0763af9 Compare October 20, 2025 20:52
@bthomee bthomee enabled auto-merge October 20, 2025 20:52
@bthomee bthomee added this pull request to the merge queue Oct 20, 2025
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Oct 20, 2025
@bthomee bthomee added this pull request to the merge queue Oct 20, 2025
Merged via the queue into develop with commit 83ee378 Oct 20, 2025
38 checks passed
@bthomee bthomee deleted the Bronek/VaultWithdraw_reserve branch October 20, 2025 23:32
Copy link
Copy Markdown
Collaborator

@ximinez ximinez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post merge approval 👍

Bronek added a commit that referenced this pull request Oct 27, 2025
…draw (#5857)

Similarly to other transaction typed that can create a trust line or MPToken for the transaction submitter (e.g. CashCheck #5285, EscrowFinish #5185 ), VaultWithdraw should enforce reserve before creating a new object. Additionally, the lsfRequireDestTag account flag should be enforced for the transaction submitter.

Co-authored-by: Bart Thomee <11445373+bthomee@users.noreply.github.com>
@Bronek Bronek mentioned this pull request Oct 27, 2025
1 task
Bronek added a commit that referenced this pull request Oct 31, 2025
…draw (#5857)

Similarly to other transaction typed that can create a trust line or MPToken for the transaction submitter (e.g. CashCheck #5285, EscrowFinish #5185 ), VaultWithdraw should enforce reserve before creating a new object. Additionally, the lsfRequireDestTag account flag should be enforced for the transaction submitter.

Co-authored-by: Bart Thomee <11445373+bthomee@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Ready to merge *PR author* thinks it's ready to merge. Has passed code review. Perf sign-off may still be required.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VaultWithdraw does not check for insufficient reserves to create a trust line if submitter is also destination account.

6 participants