Compare commits

...

3 Commits

Author SHA1 Message Date
Gregory Tsipenyuk
ec1ec7a77c Address reviewer's comments 2026-10-06 16:50:51 -04:00
Gregory Tsipenyuk
cb75e1f4d7 Fix clang-tidy 2026-10-05 08:45:46 -04:00
Gregory Tsipenyuk
ee2f76a142 fix: Check vault share freeze at MPT strand endpoints
MPTEndpointStep::check didn't check isVaultPseudoAccountFrozen(), so
a cross-currency payment that sells vault shares from, or delivers
them to, a holder frozen on the vault's underlying asset passed
strand construction and was stopped only by ValidMPTBalanceChanges
with tecINVARIANT_FAILED.

Check the vault freeze at both endpoints. The strand is now rejected
and the payment fails with tecPATH_DRY. A direct share payment under
an inherited lock also returns tecPATH_DRY instead of tecLOCKED,
matching other MPT locks under MPTokensV2.

Add VaultLifecycle tests for MPT and IOU underlying assets, covering
frozen holders at strand endpoints and frozen offer owners with
shares as the intermediate book asset.
2026-10-04 12:30:09 -04:00
2 changed files with 246 additions and 6 deletions

View File

@@ -6,6 +6,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/PaymentSandbox.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
@@ -874,15 +875,18 @@ MPTEndpointStep<TDerived>::check(StrandContext const& ctx) const
// pure issue/redeem can't be frozen (issuer/holder)
// For the first step: check global freeze of the step's own asset.
// For the last step: check only the per-holder MPToken lock.
// For the last step: check only the per-holder lock.
// Global freeze of the deliver asset is not checked here
// because MPT semantics allow issuer<->holder transfers even when globally
// locked — only holder-to-holder DEX paths are restricted.
// For both steps: a vault share inherits the freeze of the vault's
// underlying asset.
if (!(ctx.isLast && ctx.isFirst))
{
auto const& account = ctx.isFirst ? src_ : dst_;
bool const frozen = (ctx.isFirst && isGlobalFrozen(ctx.view, mptIssue_)) ||
isIndividualFrozen(ctx.view, account, mptIssue_);
isIndividualFrozen(ctx.view, account, mptIssue_) ||
isVaultPseudoAccountFrozen(ctx.view, account, mptIssue_, 0);
if (frozen)
return terLOCKED;
}

View File

@@ -15,6 +15,7 @@
#include <test/jtx/ter.h>
#include <test/jtx/ticket.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
@@ -1245,26 +1246,261 @@ private:
env.close();
// Direct vault share payment inherits the underlying lock via
// sfReferenceHolding.
// sfReferenceHolding. Caught in the strand check, like a global
// or individual share lock.
BEAST_EXPECT(shareBalance(alice) == 499);
BEAST_EXPECT(shareBalance(bob) == 501);
env(pay(alice, bob, shares(1)), Ter{tecLOCKED});
env(pay(alice, bob, shares(1)), Ter{tecPATH_DRY});
env.close();
BEAST_EXPECT(shareBalance(alice) == 499);
BEAST_EXPECT(shareBalance(bob) == 501);
// The same inherited lock must also block DEX payment paths that
// would consume an offer selling vault shares.
// would consume an offer selling vault shares. bob, the
// destination, inherits the lock and is caught in the strand
// check.
env(pay(carol, bob, shares(1)),
Sendmax(XRP(1)),
Path(BookSpec{shares.raw()}),
Ter{tecPATH_PARTIAL});
Ter{tecPATH_DRY});
env.close();
BEAST_EXPECT(shareBalance(alice) == 499);
BEAST_EXPECT(shareBalance(bob) == 501);
BEAST_EXPECT(expectOffers(env, alice, 1));
}
// A holder individually frozen on the underlying can neither sell
// nor buy vault shares through an unfrozen holder's offer. The
// inherited freeze is caught when the strand is built, before the
// offer is consumed.
auto testShareDEXEndpointFrozen = [&](Env& env,
PrettyAsset const& asset,
Account const& owner,
Account const& alice,
Account const& bob,
Account const& carol,
std::function<void(bool)> const& setAliceFrozen) {
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
env.close();
PrettyAsset const shares = MPTIssue(env.le(keylet)->at(sfShareMPTID));
auto const shareMptID = shares.raw().get<MPTIssue>().getMptID();
auto const shareBalance = [&](Account const& account) {
auto const sle = env.le(keylet::mptoken(shareMptID, account));
return sle ? sle->at(sfMPTAmount) : 0;
};
// bob bids XRP for shares and asks more XRP for shares, so that
// his offers don't cross
env(offer(bob, shares(1), XRP(1)));
env(offer(bob, XRP(2), shares(1)));
env.close();
setAliceFrozen(true);
auto const aliceShares = shareBalance(alice);
auto const bobShares = shareBalance(bob);
auto const expectUnchanged = [&]() {
BEAST_EXPECT(shareBalance(alice) == aliceShares);
BEAST_EXPECT(shareBalance(bob) == bobShares);
BEAST_EXPECT(expectOffers(env, bob, 2));
};
// alice sells shares: the first step's source is frozen
env(pay(alice, carol, XRP(1)),
Sendmax(shares(1)),
Path(~XRP),
Txflags(tfNoRippleDirect),
Ter{tecPATH_DRY});
env.close();
expectUnchanged();
// carol buys shares for alice: the last step's destination is
// frozen
env(pay(carol, alice, shares(1)),
Sendmax(XRP(2)),
Path(BookSpec{shares.raw()}),
Txflags(tfNoRippleDirect),
Ter{tecPATH_DRY});
env.close();
expectUnchanged();
// Both succeed once alice is unfrozen
setAliceFrozen(false);
env(pay(alice, carol, XRP(1)),
Sendmax(shares(1)),
Path(~XRP),
Txflags(tfNoRippleDirect));
env.close();
BEAST_EXPECT(shareBalance(bob) == bobShares + 1);
env(pay(carol, alice, shares(1)),
Sendmax(XRP(2)),
Path(BookSpec{shares.raw()}),
Txflags(tfNoRippleDirect));
env.close();
BEAST_EXPECT(shareBalance(alice) == aliceShares);
BEAST_EXPECT(shareBalance(bob) == bobShares);
BEAST_EXPECT(expectOffers(env, bob, 0));
};
// Vault shares as the intermediate asset between two books. Only the
// offer owners hold the shares in transit. An owner individually
// frozen on the underlying can neither give nor receive the shares.
auto testShareDEXMiddleFrozen = [&](Env& env,
PrettyAsset const& asset,
Account const& owner,
Account const& alice,
Account const& bob,
Account const& carol,
std::function<void(bool)> const& setAliceFrozen) {
Vault const vault{env};
auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
env(tx);
env.close();
env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
env.close();
PrettyAsset const shares = MPTIssue(env.le(keylet)->at(sfShareMPTID));
auto const shareMptID = shares.raw().get<MPTIssue>().getMptID();
auto const shareBalance = [&](Account const& account) {
auto const sle = env.le(keylet::mptoken(shareMptID, account));
return sle ? sle->at(sfMPTAmount) : 0;
};
// alice gives the shares: XRP -> shares (alice) -> asset (bob)
{
env(offer(alice, XRP(1), shares(1)));
env(offer(bob, shares(1), asset(1)));
env.close();
setAliceFrozen(true);
auto const aliceShares = shareBalance(alice);
auto const bobShares = shareBalance(bob);
env(pay(carol, owner, asset(1)),
Sendmax(XRP(1)),
Path(BookSpec{shares.raw()}, BookSpec{asset.raw()}),
Txflags(tfNoRippleDirect),
Ter{tecPATH_PARTIAL});
env.close();
BEAST_EXPECT(shareBalance(alice) == aliceShares);
BEAST_EXPECT(shareBalance(bob) == bobShares);
setAliceFrozen(false);
env(pay(carol, owner, asset(1)),
Sendmax(XRP(1)),
Path(BookSpec{shares.raw()}, BookSpec{asset.raw()}),
Txflags(tfNoRippleDirect));
env.close();
BEAST_EXPECT(shareBalance(alice) == aliceShares - 1);
BEAST_EXPECT(shareBalance(bob) == bobShares + 1);
BEAST_EXPECT(expectOffers(env, alice, 0));
BEAST_EXPECT(expectOffers(env, bob, 0));
}
// alice receives the shares: asset -> shares (bob) -> XRP (alice)
{
env(offer(bob, asset(1), shares(1)));
env(offer(alice, shares(1), XRP(1)));
env.close();
setAliceFrozen(true);
auto const aliceShares = shareBalance(alice);
auto const bobShares = shareBalance(bob);
env(pay(owner, carol, XRP(1)),
Sendmax(asset(1)),
Path(BookSpec{shares.raw()}, ~XRP),
Txflags(tfNoRippleDirect),
Ter{tecPATH_PARTIAL});
env.close();
BEAST_EXPECT(shareBalance(alice) == aliceShares);
BEAST_EXPECT(shareBalance(bob) == bobShares);
setAliceFrozen(false);
env(pay(owner, carol, XRP(1)),
Sendmax(asset(1)),
Path(BookSpec{shares.raw()}, ~XRP),
Txflags(tfNoRippleDirect));
env.close();
BEAST_EXPECT(shareBalance(alice) == aliceShares + 1);
BEAST_EXPECT(shareBalance(bob) == bobShares - 1);
BEAST_EXPECT(expectOffers(env, alice, 0));
BEAST_EXPECT(expectOffers(env, bob, 0));
}
};
{
testcase("MPT individually locked: vault share DEX inherits underlying lock");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
env.close();
MPTTester asset{
{.env = env,
.issuer = issuer,
.holders = {owner, alice, bob},
.flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}};
env(pay(issuer, owner, asset(1'000)));
env(pay(issuer, alice, asset(2'000)));
env(pay(issuer, bob, asset(2'000)));
env.close();
auto const setAliceFrozen = [&](bool frozen) {
asset.set({.holder = alice, .flags = frozen ? tfMPTLock : tfMPTUnlock});
env.close();
};
testShareDEXEndpointFrozen(env, asset, owner, alice, bob, carol, setAliceFrozen);
testShareDEXMiddleFrozen(env, asset, owner, alice, bob, carol, setAliceFrozen);
}
{
testcase("IOU individually frozen: vault share DEX inherits underlying freeze");
Env env{*this, testableAmendments()};
Account const issuer{"issuer"};
Account const owner{"owner"};
Account const alice{"alice"};
Account const bob{"bob"};
Account const carol{"carol"};
env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
env.close();
// Deliver the IOU from a book to a holder
env(fset(issuer, asfDefaultRipple));
PrettyAsset const asset = issuer["IOU"];
env.trust(asset(10'000), owner, alice, bob);
env(pay(issuer, owner, asset(1'000)));
env(pay(issuer, alice, asset(2'000)));
env(pay(issuer, bob, asset(2'000)));
env.close();
auto const setAliceFrozen = [&](bool frozen) {
env(trust(issuer, asset(0), alice, frozen ? tfSetFreeze : tfClearFreeze));
env.close();
};
testShareDEXEndpointFrozen(env, asset, owner, alice, bob, carol, setAliceFrozen);
testShareDEXMiddleFrozen(env, asset, owner, alice, bob, carol, setAliceFrozen);
}
{
testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM");