Compare commits

...

7 Commits

Author SHA1 Message Date
Denis Angell
06e605d59c fix mixing an enumerated type 2026-02-06 13:42:07 +01:00
Denis Angell
e7fce8438c fix AMM test 2026-02-06 13:33:42 +01:00
Denis Angell
1c82989ec0 clang-format 2026-02-06 11:24:03 +01:00
Denis Angell
75fc935e39 Merge branch 'develop' into dangell7/fix-token-escrow 2026-02-06 11:20:28 +01:00
Denis Angell
945c19564e extend escrow to psuedo and blackholed
- allow LP tokens
- allow blackholed accounts
2026-02-06 11:15:11 +01:00
Denis Angell
c543d42029 change amendment name 2026-01-05 19:02:24 -05:00
Denis Angell
0769bbc20a fix TokenEscrow edge case 2026-01-05 18:34:30 -05:00
8 changed files with 414 additions and 16 deletions

View File

@@ -499,7 +499,11 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey);
* field. The amendment check is **not** performed in createPseudoAccount.
*/
[[nodiscard]] Expected<std::shared_ptr<SLE>, TER>
createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const& ownerField);
createPseudoAccount(
ApplyView& view,
uint256 const& pseudoOwnerKey,
SField const& ownerField,
std::uint32_t additionalFlags = 0);
// Returns true if and only if sleAcct is a pseudo-account or specific
// pseudo-accounts in pseudoFieldFilter.
@@ -528,6 +532,13 @@ isPseudoAccount(ReadView const& view, AccountID const& accountId, std::set<SFiel
return isPseudoAccount(view.read(keylet::account(accountId)), pseudoFieldFilter);
}
// Returns true if the account is blackholed:
// - lsfDisableMaster is set
// - Regular key (if present) is AccountID(0), AccountID(1), or AccountID(2)
// - No signer list exists
[[nodiscard]] bool
isBlackholed(ReadView const& view, std::shared_ptr<SLE const> const& sle);
[[nodiscard]] TER
canAddHolding(ReadView const& view, Asset const& asset);

View File

@@ -16,6 +16,7 @@
// Add new amendments to the top of this list.
// Keep it sorted in reverse chronological order.
XRPL_FIX (TokenEscrowV1_1, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (ExpiredNFTokenOfferRemoval, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (BatchInnerSigs, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(LendingProtocol, Supported::yes, VoteBehavior::DefaultNo)

View File

@@ -1029,8 +1029,44 @@ isPseudoAccount(std::shared_ptr<SLE const> sleAcct, std::set<SField const*> cons
}) > 0;
}
[[nodiscard]] bool
isBlackholed(ReadView const& view, std::shared_ptr<SLE const> const& sle)
{
if (!sle || sle->getType() != ltACCOUNT_ROOT)
return false;
// If master key is not disabled, not blackholed
if (!sle->isFlag(lsfDisableMaster))
return false;
// If a regular key is set, it must be AccountID(0), AccountID(1), or
// AccountID(2) for the account to be blackholed
if (sle->isFieldPresent(sfRegularKey))
{
AccountID const rk = sle->getAccountID(sfRegularKey);
static AccountID const ACCOUNT_ZERO(0);
static AccountID const ACCOUNT_ONE(1);
static AccountID const ACCOUNT_TWO(2);
if (rk != ACCOUNT_ZERO && rk != ACCOUNT_ONE && rk != ACCOUNT_TWO)
return false;
}
// If a signer list is set, not blackholed
AccountID const account = sle->getAccountID(sfAccount);
if (view.exists(keylet::signers(account)))
return false;
// All conditions met: account is blackholed
return true;
}
Expected<std::shared_ptr<SLE>, TER>
createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const& ownerField)
createPseudoAccount(
ApplyView& view,
uint256 const& pseudoOwnerKey,
SField const& ownerField,
std::uint32_t additionalFlags)
{
[[maybe_unused]]
auto const& fields = getPseudoAccountFields();
@@ -1060,7 +1096,7 @@ createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const
// Ignore reserves requirement, disable the master key, allow default
// rippling, and enable deposit authorization to prevent payments into
// pseudo-account.
account->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
account->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | additionalFlags);
// Link the pseudo-account with its owner object.
account->setFieldH256(ownerField, pseudoOwnerKey);

View File

@@ -134,6 +134,48 @@ private:
// Make sure asset comparison works.
BEAST_EXPECT(STIssue(sfAsset, STAmount(XRP(2'000)).issue()) == STIssue(sfAsset, STAmount(XRP(2'000)).issue()));
BEAST_EXPECT(STIssue(sfAsset, STAmount(XRP(2'000)).issue()) != STIssue(sfAsset, STAmount(USD(2'000)).issue()));
// AMM account flags with fixTokenEscrowV1_1
// New AMM accounts should have lsfAllowTrustLineLocking set
{
Env env{*this, testable_amendments()};
fund(env, gw, {alice}, {USD(20'000)}, Fund::All);
AMM ammAlice(env, alice, XRP(10'000), USD(10'000));
auto const ammAccount = ammAlice.ammAccount();
// Check that AMM account has expected flags
auto const sleAMM = env.le(keylet::account(ammAccount));
BEAST_EXPECT(sleAMM);
if (sleAMM)
{
// Base pseudo-account flags
BEAST_EXPECT(sleAMM->isFlag(lsfDisableMaster));
BEAST_EXPECT(sleAMM->isFlag(lsfDefaultRipple));
BEAST_EXPECT(sleAMM->isFlag(lsfDepositAuth));
BEAST_EXPECT(sleAMM->isFlag(lsfAllowTrustLineLocking));
}
}
// AMM account flags without fixTokenEscrowV1_1
// New AMM accounts should NOT have lsfAllowTrustLineLocking
{
Env env{*this, testable_amendments() - fixTokenEscrowV1_1};
fund(env, gw, {alice}, {USD(20'000)}, Fund::All);
AMM ammAlice(env, alice, XRP(10'000), USD(10'000));
auto const ammAccount = ammAlice.ammAccount();
// Check that AMM account has expected flags
auto const sleAMM = env.le(keylet::account(ammAccount));
BEAST_EXPECT(sleAMM);
if (sleAMM)
{
// Base pseudo-account flags
BEAST_EXPECT(sleAMM->isFlag(lsfDisableMaster));
BEAST_EXPECT(sleAMM->isFlag(lsfDefaultRipple));
BEAST_EXPECT(sleAMM->isFlag(lsfDepositAuth));
BEAST_EXPECT(!sleAMM->isFlag(lsfAllowTrustLineLocking));
}
}
}
void
@@ -3756,17 +3798,26 @@ private:
}
void
testFlags()
testFlags(FeatureBitset features)
{
testcase("Flags");
using namespace jtx;
testAMM([&](AMM& ammAlice, Env& env) {
auto const info = env.rpc(
"json", "account_info", std::string("{\"account\": \"" + to_string(ammAlice.ammAccount()) + "\"}"));
auto const flags = info[jss::result][jss::account_data][jss::Flags].asUInt();
BEAST_EXPECT(flags == (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth));
});
testAMM(
[&](AMM& ammAlice, Env& env) {
auto const info = env.rpc(
"json", "account_info", std::string("{\"account\": \"" + to_string(ammAlice.ammAccount()) + "\"}"));
auto const flags = info[jss::result][jss::account_data][jss::Flags].asUInt();
if (!env.enabled(fixTokenEscrowV1_1))
BEAST_EXPECT(flags == (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth));
else
BEAST_EXPECT(
flags == (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfAllowTrustLineLocking));
},
std::nullopt,
0,
std::nullopt,
{features});
}
void
@@ -6244,7 +6295,8 @@ private:
testBasicPaymentEngine(all - fixAMMv1_1 - fixAMMv1_3 - fixReducedOffersV2);
testAMMTokens();
testAmendment();
testFlags();
testFlags(all);
testFlags(all - fixTokenEscrowV1_1);
testRippling();
testAMMAndCLOB(all);
testAMMAndCLOB(all - fixAMMv1_1 - fixAMMv1_3);

View File

@@ -1,4 +1,5 @@
#include <test/jtx.h>
#include <test/jtx/AMM.h>
#include <xrpld/app/tx/applySteps.h>
@@ -342,6 +343,96 @@ struct EscrowToken_test : public beast::unit_test::suite
env.close();
}
// AMM issuer without asfAllowTrustLineLocking
// (succeeds under fixTokenEscrowV1_1, fails otherwise)
{
bool const withFix = features[fixTokenEscrowV1_1];
Env env{*this, features};
auto const baseFee = env.current()->fees().base;
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const carol = Account("carol");
auto const gw = Account{"gateway"};
env.fund(XRP(30'000), alice, bob, carol, gw);
env.close();
// Create an AMM pool (XRP/USD)
auto const USD = gw["USD"];
env.trust(USD(30'000), alice);
env.close();
env(pay(gw, alice, USD(10'000)));
env.close();
AMM ammAlice(env, alice, XRP(10'000), USD(10'000));
auto const ammAccount = ammAlice.ammAccount();
auto const lpIssue = ammAlice.lptIssue();
// Carol gets LP tokens
env.trust(STAmount{lpIssue, 10'000}, carol);
env.close();
env(pay(alice, carol, STAmount{lpIssue, 100}));
env.close();
// Try to create escrow with AMM account as issuer (LP tokens)
env(escrow::create(carol, bob, STAmount{lpIssue, 50}),
escrow::finish_time(env.now() + 1s),
fee(baseFee * 150),
ter(withFix ? TER(tesSUCCESS) : TER(tecNO_PERMISSION)));
env.close();
if (withFix)
{
// Verify the AMM account now has lsfAllowTrustLineLocking
auto const sleAMM = env.le(keylet::account(ammAccount));
BEAST_EXPECT(sleAMM && sleAMM->isFlag(lsfAllowTrustLineLocking));
}
}
// Blackholed issuer without asfAllowTrustLineLocking
// (succeeds under fixTokenEscrowV1_1, fails otherwise)
{
bool const withFix = features[fixTokenEscrowV1_1];
Env env{*this, features};
auto const baseFee = env.current()->fees().base;
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const gw = Account{"gateway"};
auto const USD = gw["USD"];
env.fund(XRP(5000), alice, bob, gw);
env.close();
env.trust(USD(10'000), alice, bob);
env.close();
env(pay(gw, alice, USD(5000)));
env(pay(gw, bob, USD(5000)));
env.close();
// Blackhole the gateway: set regular key to AccountID(1)
// (noAccount), then disable master key. AccountID(1) is a
// non-functional account that nobody can sign with.
Account const blackhole("blackhole", AccountID(1));
env(regkey(gw, blackhole));
env.close();
// Disable master key (must use master key to disable it)
env(fset(gw, asfDisableMaster), sig(gw));
env.close();
// Try to create escrow with blackholed issuer
env(escrow::create(alice, bob, USD(100)),
escrow::finish_time(env.now() + 1s),
fee(baseFee * 150),
ter(withFix ? TER(tesSUCCESS) : TER(tecNO_PERMISSION)));
env.close();
if (withFix)
{
// Verify the gateway now has lsfAllowTrustLineLocking
auto const sleGW = env.le(keylet::account(gw));
BEAST_EXPECT(sleGW && sleGW->isFlag(lsfAllowTrustLineLocking));
}
}
// tecNO_LINE: account does not have a trustline to the issuer
{
Env env{*this, features};
@@ -855,6 +946,86 @@ struct EscrowToken_test : public beast::unit_test::suite
}
}
void
testIOUCancelDoApply(FeatureBitset features)
{
testcase("IOU Cancel DoApply");
using namespace test::jtx;
using namespace std::chrono;
// Test: Creator cancels their own escrow after deleting trust line.
// The trust line should be recreated and tokens returned.
{
Env env{*this, features};
auto const baseFee = env.current()->fees().base;
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const gw = Account("gw");
auto const USD = gw["USD"];
// Fund accounts
env.fund(XRP(10'000), alice, bob, gw);
env.close();
// Enable trust line locking for escrow
env(fset(gw, asfAllowTrustLineLocking));
env.close();
// Create trust lines
env.trust(USD(100'000), alice);
env.trust(USD(100'000), bob);
env.close();
// Issue tokens to alice
env(pay(gw, alice, USD(10'000)));
env.close();
// Alice creates IOU escrow to Bob with CancelAfter
auto const seq = env.seq(alice);
env(escrow::create(alice, bob, USD(1'000)),
escrow::finish_time(env.now() + 1s),
escrow::cancel_time(env.now() + 2s),
fee(baseFee));
env.close();
// Verify escrow was created and balance decreased
BEAST_EXPECT(env.balance(alice, USD) == USD(9'000));
// Alice pays back remaining tokens to gateway
env(pay(alice, gw, USD(9'000)));
env.close();
// Alice removes her trust line (balance is 0, so this succeeds)
// The escrowed 1,000 USD is NOT tracked in the trustline balance
env(trust(alice, USD(0)));
env.close();
// Verify trust line is gone
auto const trustLineKey = keylet::line(alice.id(), gw.id(), USD.currency);
BEAST_EXPECT(!env.current()->exists(trustLineKey));
// Wait for CancelAfter to pass
env.close();
env.close();
// Alice cancels her own escrow
auto const expectedResult =
env.current()->rules().enabled(fixTokenEscrowV1_1) ? ter(tesSUCCESS) : ter(tefEXCEPTION);
env(escrow::cancel(alice, alice, seq), fee(baseFee), expectedResult);
env.close();
if (env.current()->rules().enabled(fixTokenEscrowV1_1))
{
// Verify the escrow was deleted
BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), seq)));
// Verify trust line was recreated and alice got tokens back
BEAST_EXPECT(env.current()->exists(trustLineKey));
BEAST_EXPECT(env.balance(alice, USD) == USD(1'000));
}
}
}
void
testIOUBalances(FeatureBitset features)
{
@@ -2718,6 +2889,75 @@ struct EscrowToken_test : public beast::unit_test::suite
}
}
void
testMPTCancelDoApply(FeatureBitset features)
{
testcase("MPT Cancel DoApply");
using namespace test::jtx;
using namespace std::chrono;
// Test: Creator cancels their own MPT escrow.
// Tokens should be returned and locked amount cleared.
{
Env env{*this, features};
auto const baseFee = env.current()->fees().base;
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const gw = Account("gw");
MPTTester mptGw(env, gw, {.holders = {alice, bob}});
mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow | tfMPTCanTransfer});
mptGw.authorize({.account = alice});
mptGw.authorize({.account = bob});
auto const MPT = mptGw["MPT"];
// Issue tokens to alice
env(pay(gw, alice, MPT(10'000)));
env.close();
// Alice creates MPT escrow to Bob with CancelAfter
auto const seq = env.seq(alice);
env(escrow::create(alice, bob, MPT(1'000)),
escrow::finish_time(env.now() + 1s),
escrow::cancel_time(env.now() + 2s),
fee(baseFee * 150));
env.close();
// Verify escrow was created and locked amount is tracked
BEAST_EXPECT(env.balance(alice, MPT) == MPT(9'000));
BEAST_EXPECT(mptEscrowed(env, alice, MPT) == 1'000);
// Alice pays back remaining tokens to gateway
env(pay(alice, gw, MPT(9'000)));
env.close();
// Verify MPToken still exists with locked amount
BEAST_EXPECT(env.le(keylet::mptoken(MPT.mpt(), alice)));
BEAST_EXPECT(mptEscrowed(env, alice, MPT) == 1'000);
// Wait for CancelAfter to pass
env.close();
env.close();
// Alice cancels her own escrow
env(escrow::cancel(alice, alice, seq), fee(baseFee), ter(tesSUCCESS));
env.close();
// Verify the escrow was deleted
BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), seq)));
// Verify alice got tokens back and locked amount is cleared
BEAST_EXPECT(env.balance(alice, MPT) == MPT(1'000));
BEAST_EXPECT(mptEscrowed(env, alice, MPT) == 0);
// Now alice can delete her MPToken
env(pay(alice, gw, MPT(1'000)));
mptGw.authorize({.account = alice, .flags = tfMPTUnauthorize});
env.close();
BEAST_EXPECT(!env.le(keylet::mptoken(MPT.mpt(), alice)));
}
}
void
testMPTBalances(FeatureBitset features)
{
@@ -3663,6 +3903,7 @@ struct EscrowToken_test : public beast::unit_test::suite
testIOUFinishPreclaim(features);
testIOUFinishDoApply(features);
testIOUCancelPreclaim(features);
testIOUCancelDoApply(features);
testIOUBalances(features);
testIOUMetaAndOwnership(features);
testIOURippleState(features);
@@ -3684,6 +3925,7 @@ struct EscrowToken_test : public beast::unit_test::suite
testMPTFinishPreclaim(features);
testMPTFinishDoApply(features);
testMPTCancelPreclaim(features);
testMPTCancelDoApply(features);
testMPTBalances(features);
testMPTMetaAndOwnership(features);
testMPTGateway(features);
@@ -3703,8 +3945,10 @@ public:
for (FeatureBitset const& feats : {all - featureSingleAssetVault - featureLendingProtocol, all})
{
testIOUWithFeats(feats);
testIOUWithFeats(feats - fixTokenEscrowV1_1);
testMPTWithFeats(feats);
testMPTWithFeats(feats - fixTokenEscrowV1);
testMPTWithFeats(feats - fixTokenEscrowV1 - fixTokenEscrowV1_1);
}
}
};

View File

@@ -179,7 +179,8 @@ applyCreate(ApplyContext& ctx_, Sandbox& sb, AccountID const& account_, beast::J
auto const ammKeylet = keylet::amm(amount.issue(), amount2.issue());
// Mitigate same account exists possibility
auto const maybeAccount = createPseudoAccount(sb, ammKeylet.key, sfAMMID);
std::uint32_t const additionalFlags = sb.rules().enabled(fixTokenEscrowV1_1) ? lsfAllowTrustLineLocking : 0u;
auto const maybeAccount = createPseudoAccount(sb, ammKeylet.key, sfAMMID, additionalFlags);
// AMM account already exists (should not happen)
if (!maybeAccount)
{

View File

@@ -177,7 +177,21 @@ escrowCreatePreclaimHelper<Issue>(
if (!sleIssuer)
return tecNO_ISSUER;
if (!sleIssuer->isFlag(lsfAllowTrustLineLocking))
return tecNO_PERMISSION;
{
// fixTokenEscrowV1_1: allows escrow for AMM pseudo accounts and
// blackholed issuers
if (ctx.view.rules().enabled(fixTokenEscrowV1_1))
{
bool const isAMM = isPseudoAccount(sleIssuer, {&sfAMMID});
bool const isBlackholedIssuer = isBlackholed(ctx.view, sleIssuer);
if (!isAMM && !isBlackholedIssuer)
return tecNO_PERMISSION;
}
else
{
return tecNO_PERMISSION;
}
}
// If the account does not have a trustline to the issuer, return tecNO_LINE
auto const sleRippleState = ctx.view.read(keylet::line(account, issuer, amount.getCurrency()));
@@ -440,6 +454,24 @@ EscrowCreate::doApply()
auto const xferRate = transferRate(ctx_.view(), amount);
if (xferRate != parityRate)
(*slep)[sfTransferRate] = xferRate.value;
// Add lsfAllowTrustLineLocking to AMM pseudo accounts and
// blackholed issuers under fixTokenEscrowV1_1
if (ctx_.view().rules().enabled(fixTokenEscrowV1_1))
{
AccountID const issuer = amount.getIssuer();
auto sleIssuer = ctx_.view().peek(keylet::account(issuer));
if (sleIssuer && !sleIssuer->isFlag(lsfAllowTrustLineLocking))
{
bool const isAMM = isPseudoAccount(sleIssuer, {&sfAMMID});
bool const isBlackholedIssuer = isBlackholed(ctx_.view(), sleIssuer);
if (isAMM || isBlackholedIssuer)
{
sleIssuer->setFieldU32(sfFlags, sleIssuer->getFlags() | lsfAllowTrustLineLocking);
ctx_.view().update(sleIssuer);
}
}
}
}
ctx_.view().insert(slep);
@@ -691,6 +723,13 @@ escrowUnlockApplyHelper<Issue>(
bool createAsset,
beast::Journal journal)
{
// fixTokenEscrowV1_1: sleDest must be an account root
if (view.rules().enabled(fixTokenEscrowV1_1))
{
if (!sleDest || sleDest->getType() != ltACCOUNT_ROOT)
return tecINTERNAL;
}
Keylet const trustLineKey = keylet::line(receiver, amount.issue());
bool const recvLow = issuer > receiver;
bool const senderIssuer = issuer == sender;
@@ -821,6 +860,13 @@ escrowUnlockApplyHelper<MPTIssue>(
bool createAsset,
beast::Journal journal)
{
// fixTokenEscrowV1_1: sleDest must be an account root
if (view.rules().enabled(fixTokenEscrowV1_1))
{
if (!sleDest || sleDest->getType() != ltACCOUNT_ROOT)
return tecINTERNAL;
}
bool const senderIssuer = issuer == sender;
bool const receiverIssuer = issuer == receiver;
@@ -1172,7 +1218,9 @@ EscrowCancel::doApply()
return escrowUnlockApplyHelper<T>(
ctx_.view(),
parityRate,
slep,
// fixTokenEscrowV1_1: Pass account SLE instead of
// escrow SLE
ctx_.view().rules().enabled(fixTokenEscrowV1_1) ? sle : slep,
mPriorBalance,
amount,
issuer,

View File

@@ -979,8 +979,13 @@ ValidNewAccountRoot::finalize(
if (pseudoAccount)
{
std::uint32_t const expected = (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
if (flags_ != expected)
std::uint32_t const base = (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth);
bool valid = (flags_ == base);
// fixTokenEscrowV1_1: pseudo accounts will also have
// lsfAllowTrustLineLocking set at creation time.
if (!valid && view.rules().enabled(fixTokenEscrowV1_1))
valid = (flags_ == (base | lsfAllowTrustLineLocking));
if (!valid)
{
JLOG(j.fatal()) << "Invariant failed: pseudo-account created with "
"wrong flags";