Skip to content

Frozen IOUs should still be transferable to/from the issuer - #6113

Draft
vlntb wants to merge 641 commits into
developfrom
vlntb/RIPD-4257-fix-tecFROZEN
Draft

Frozen IOUs should still be transferable to/from the issuer#6113
vlntb wants to merge 641 commits into
developfrom
vlntb/RIPD-4257-fix-tecFROZEN

Conversation

@vlntb

@vlntb vlntb commented Dec 5, 2025

Copy link
Copy Markdown
Contributor

High Level Overview of Change

The freeze logic was incorrectly blocking IOU issuers from transacting with their own frozen currency. This violated the XRP Ledger protocol, which specifies that "Counterparties of the frozen issuer can still send and receive payments directly to and from the issuing address."

Context of Change

Three issues were fixed:

  1. isFrozen() function was returning true for issuers checking their own currency during global freeze, causing the issuer to be treated as frozen. The early return prevented reaching the later issuer exemption check.
  2. LoanBrokerCoverDeposit::preclaim() was using accountHolds() with FreezeHandling::fhZERO_IF_FROZEN for all accounts, including issuers. Since issuers don't have a "balance" of their own tokens (infinite issuance ability), the balance check would return 0 and fail with tecINSUFFICIENT_FUNDS.
  3. VaultWithdraw::preclaim() had checkFrozen() calls that were always executed, even when the destination or submitter was the asset issuer. Now skips freeze checks when either the destination or submitter is the IOU issuer.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (non-breaking change that only restructures code)
  • Performance (increase or change in throughput and/or latency)
  • Tests (you added tests for code that already exists, or your new feature included in this PR)
  • Documentation update
  • Chore (no impact to binary, e.g. .gitignore, formatting, dropping support for older tooling)
  • Release

API Impact

  • Public API: New feature (new methods and/or new fields)
  • Public API: Breaking change (in general, breaking changes should only impact the next api_version)
  • libxrpl change (any change that may affect libxrpl or dependents of libxrpl)
  • Peer protocol change (must be backward compatible or bump the peer protocol version)

ximinez and others added 30 commits October 20, 2025 13:40
- Move management fee calculations out of transactors an into the
  appropriate functions in LendingHelpers.h.
- Rewrite how overpayments are handled. May changed based on numerical
  analysis.
- Update, fix, and clean up unit tests. Includes adding tolerances for
  some checks where an exact match is unlikely.
- Add "integral()" function to Asset to simplify a common check.
…5904)

* Add unit-test to verify correct behavior on broker deep freeze
- Uses the same name and value as for LoanSet: tfLoanOverpayment.
- Untested.
- Also create several placeholders for missing test cases.
* XRPLF/develop:
  Moved fix1513 to retire state (5919)
- Loan scale is completely irrelevant to integral types (XRP, MPT), and
  the field is "soeDEFAULT", so when set to 0, it won't be stored on
  ledger, saving a little bit of space.
- Implement a new helper accountCanSend, which is like accountHolds, but
  returns a meaningful value for issuers, and will include the available
  credit on the other side of a trust line. (The sfHighLimit or
  sfLowLimit as appropriate.)
- Use this new helper when checking the available balance in LoanPay.
* XRPLF/develop:
  refactor: Retire fix1515 amendment (5920)
  Use "${ENVVAR}" instead of ${{ env.ENVVAR }} syntax in GitHub Actions (5923)
- Add convenience functions to MPT test-framework.
- Fix LoanSet.calculateBaseFee with multisign.
- Cleanups.
- Add View helper functions for Loan and Vault transactions
  - preflightDestinationAndTag
  - checkDestinationAndTag
  - canSendToAccount
- Used the helpers in appropriate Loan and Vault transactions.
  - They could also be used in older transactions, I'll save that for
    later.
- Correct interest rounding calculation
- Fix some comments
- Don't explicitly break out of the multiple payment loop for the final
  payment. Assert that it will exit by the loop condition.
- canSendToAccount will check if a destination tag is required _before_
  checking if the sender is the destination. This is the original
  VaultWithdraw behavior, and I want to stay consistent.
…nez/lending-XLS-66

* mywork/ximinez/crashed-tests:
  test: Count crashed test suites
- Renamed canSendToAccount to canWithdraw, because the semantics are
  a little different from a payment. Notably, if withdrawing to self,
  you can still include a destination tag.
- Simplified the interface to canWithdraw to just pass in the
  STTx.
- preflightDestinationAndTag is pretty pointless now, so removed it.
- Adds a check to the MPToken creation invariant to ensure none are created for the issuer.
- `addEmptyHolding()` will return success without doing anything for these scenarios. There is nothing to do, as with XRP.

---------

Co-authored-by: Ed Hennis <ed@ripple.com>
- Add tests for unauthorized borrower and lender
- This is an intermediate commit. It leaves the old variables in place,
  so I can do verifications that the new computations are correct. They
  will be removed in the next commit.
- PaymentComponents is an class used internally to break a payment value
  into principal, interest, and fees.
vlntb and others added 6 commits December 19, 2025 19:57
* Replace accountHolds with accountSpendable when checking
for account funds in VaultDeposit and LoanBrokerCoverDeposit
- Updates or fixes a couple of things I noticed while reviewing changes
  to the spec.
- Rename sfPreviousPaymentDate to sfPreviousPaymentDueDate.
- Make the vault asset cap check added in #6124 a little more robust:
  1. Check in preflight if the vault is _already_ over the limit.
  2. Prevent overflow when checking with the loan value. (Subtract
     instead of adding, in case the values are near maxint. Both return
     the same result. Also add a unit test so each case is covered.

@ximinez ximinez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There are a bunch of unit test failures

Comment thread src/test/app/Freeze_test.cpp Outdated
coverDeposit(holder, brokerKeylet.key, USD(10)),
ter(tecFROZEN));

env_post(coverDeposit(issuer, brokerKeylet.key, USD(20)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't look right. Only the broker owner (owner) should be able to deposit cover. Not because of the freeze, but because of the LP rules. The CI results confirm that - there are many unit test failures.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread src/test/app/Freeze_test.cpp Outdated
Comment on lines +2045 to +2048
// Verify issuer can still receive payments (uses isFrozen internally)
env(pay(holder, issuer, USD(10)));
env.close();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should also verify that the issuer can still send payments.

        // Verify issuer can still send payments (uses isFrozen internally)
        env(pay(issuer, holder, USD(10)));
        env.close();

I see a lot of changes where the loan broker deposits and withdraws, but I would like to see a simple payment from the issuer here in testIsFrozenDirectly

Comment on lines +2006 to +2007
void
testIsFrozenDirectly(FeatureBitset features)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should these tests be part of testGlobalFreeze in this same file?

Also, I wonder why those tests did not catch this obvious fault?

Comment on lines +370 to +391
// Check global freeze for IOU payments where sender is the issuer.
// This is NOT Lending Protocol/Vault specific, so issuers do NOT get
// exemption from their own global freeze for regular Payment transactions.
// IMPORTANT: This restriction only applies when featureLendingProtocol is
// enabled. Before this amendment, issuers were allowed to send under global
// freeze.
if (ctx.view.rules().enabled(featureLendingProtocol))
{
STAmount const maxSourceAmount =
getMaxSourceAmount(ctx.tx[sfAccount], dstAmount, sendMax);
if (!maxSourceAmount.native() && !maxSourceAmount.holds<MPTIssue>())
{
auto const& issue = maxSourceAmount.get<Issue>();
auto const& account = ctx.tx[sfAccount];

// If sender is the issuer and global freeze is enabled, payment
// fails
if (account == issue.getIssuer() && isGlobalFrozen(ctx.view, issue))
return tecPATH_DRY;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why? isn't the issuer always allowed to transfer their own asset regardless if the asset is frozen or not?

asset.value());
}

/** Lending Protocol-specific freeze check with issuer exemption.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i'm not sure about these lending specific freeze rules. What's the rationale for this approach compared to the old one, where we added the check directly in the existing freeze checking helper (this seemed to be more elegant and simpler).

also, IIRC, freeze check should always exempt the issuer, not just for lending. so is having lending specific freeze-helpers really necessary?

@ximinez ximinez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's no diplomatic way to say this. The changes since my last review
(8011c43..4c8d9b5) are almost entirely the wrong way to go about this.

An not-necessarily-exhaustive list of the problems:

  1. None of the new functions in View.h/cpp should have been added.
    • Refer to
      https://xrpl.org/docs/concepts/tokens/fungible-tokens/freezes. We
      should not be changing any of those rules for either existing or
      new transactions. It's also true for pseudo-accounts - even though
      they are not acting on their own, they are still sending directly
      to or from the issuer if the issuer is the one acting via
      a VaultDeposit, or LoanBrokerCoverWithdraw, etc.
    • Instead, the existing corresponding functions should be modified to
      include changes if the SAV or LP amendments are enabled. They
      should not be rewritten, but only add the parameters and checks
      needed to determine if either sender or receiver is the issuer.
    • Modifying the existing functions is not changing the rules - it's
      centralizing them.
  2. I don't think any of the Invariant changes are correct either. We're
    not changing the existing freeze rules. Specifically, I don't think
    pseudo-accounts should be treated any differently as far as freezes
    are concerned.
  3. In LoanBrokerCoverDeposit:
    • I don't understand why you moved the
      account != sleBroker->at(sfOwner) check to later in the function.
      If the account doesn't have permission, we shouldn't waste time
      reading the Vault object before returning.
    • The change from checkFrozen to isIndividualFrozen skips the
      global freeze check, which is only correct if the account is the
      issuer. If you make the changes above, then it will automatically
      do the right thing.
    • It's probably correct to change the accountSpendable call to
      ignore freeze, because with the changes above, any real freezes
      will be caught before we get to that point.
  4. In LoanBrokerCoverWithdraw:
    • Again, moving the account != sleBroker->at(sfOwner) check doesn't
      make sense.
    • If the dstAcct is not the issuer, then you should absolutely be
      checking if the pseudoaccount is frozen. It should not be able to
      send funds to anyone except the issuer.
    • Again, it's probably correct to change the accountSpendable call to
      ignore freeze, because with the changes above, any freezes that
      should cause failure will be caught before we get to that point.
  5. LoanSet is fine. It's just part of the project rename.
  6. There is no reason to make any changes to the Payment transaction,
    particularly one that takes existing, correct, functionality away
    from the issuer.
  7. VaultClawback looks fine.
  8. VaultDeposit and VaultWithdraw should go back to using the
    corrected isFrozen (or converted to checkFrozen). But I am
    a little confused why you would need to check if the shares of the
    vault (vaultShare) are frozen, because they are issued by the
    pseudoaccount, which can't change the MPTIssuance, and I don't think
    there's any other mechanism to do that, is there?
    • Though with the updated isFrozen / checkFrozen, VaultWithdraw
      won't need to check if either account is the issuer.

@vlntb
vlntb marked this pull request as draft January 7, 2026 10:46
@ximinez
ximinez force-pushed the ximinez/lending-XLS-66-ongoing branch from c6fcce0 to b314566 Compare January 13, 2026 18:56
Base automatically changed from ximinez/lending-XLS-66-ongoing to develop January 13, 2026 19:43
@github-actions

Copy link
Copy Markdown

This PR has conflicts, please resolve them in order for the PR to be reviewed.

@godexsoft

Copy link
Copy Markdown
Contributor

We recently merged a refactor to develop that enables clang-tidy's readability-identifier-naming. Your branch now has heavy conflicts that are largely mechanical. Below is a workflow that aligns your branch's naming with develop before merging, which should minimize the merge conflicts.

One-time setup

If you don't already have clang-tidy working in your env, on macOS:

brew install llvm@21
# Follow brew's hint to put $(brew --prefix llvm@21)/bin on PATH so run-clang-tidy is found.

Workflow on your branch (before merging develop)

1. Grab the new .clang-tidy from develop without pulling anything else. Sync your fork on GitHub first, then:

git remote -v   # should show 'upstream' among others; if not:
# git remote set-url upstream git@github.com:XRPLF/rippled.git
git fetch upstream
git checkout upstream/develop -- .clang-tidy

2. Reconfigure conan/cmake so compile_commands.json is fresh.

3. Apply renames for the files modified in your PR:

git diff --name-only $(git merge-base HEAD upstream/develop) HEAD \
  | grep -E '\.(cpp|h|hpp|ipp)$' \
  | xargs run-clang-tidy -p build -fix -allow-no-checks
# or -p .build, or whatever your build dir is called

4. Build + test, then commit as a single dedicated commit:

cmake --build build -j8
git commit -am "refactor: Align identifier naming with develop"

5. Now merge develop:

git merge upstream/develop

Extra

Run clang-tidy once more after the merge to catch any stragglers introduced from develop's side:

run-clang-tidy -p build -fix -allow-no-checks src tests
# or -p .build, or whatever your build dir is called

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants