From 298b95d1f13ff86cd58de8aba2b0a3b38995311b Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Wed, 20 May 2026 11:39:10 +0200 Subject: [PATCH] feat: Token Escrow - Allow Blackholed Accounts --- .../xrpl/ledger/helpers/AccountRootHelpers.h | 9 +- include/xrpl/protocol/detail/features.macro | 1 + .../ledger/helpers/AccountRootHelpers.cpp | 33 ++++++- src/libxrpl/tx/invariants/InvariantCheck.cpp | 7 +- src/libxrpl/tx/transactors/dex/AMMCreate.cpp | 4 +- .../tx/transactors/escrow/EscrowCreate.cpp | 31 ++++++- src/test/app/AMM_test.cpp | 27 +++--- src/test/app/EscrowToken_test.cpp | 88 ++++++++++++++++++- 8 files changed, 182 insertions(+), 18 deletions(-) diff --git a/include/xrpl/ledger/helpers/AccountRootHelpers.h b/include/xrpl/ledger/helpers/AccountRootHelpers.h index 353c27fe41..c7c4bd779e 100644 --- a/include/xrpl/ledger/helpers/AccountRootHelpers.h +++ b/include/xrpl/ledger/helpers/AccountRootHelpers.h @@ -99,7 +99,14 @@ isPseudoAccount( * createPseudoAccount. */ [[nodiscard]] Expected, TER> -createPseudoAccount(ApplyView& view, uint256 const& pseudoOwnerKey, SField const& ownerField); +createPseudoAccount( + ApplyView& view, + uint256 const& pseudoOwnerKey, + SField const& ownerField, + std::uint32_t additionalFlags = 0); + +[[nodiscard]] bool +isBlackholed(ReadView const& view, std::shared_ptr const& sle); /** Checks the destination and tag. diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index fd62b74d59..2ea3c627a4 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FIX (TokenEscrowV1_1, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_2_0, Supported::No, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_1_3, Supported::Yes, VoteBehavior::DefaultYes) diff --git a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp index 029cb5cd92..55900dcdde 100644 --- a/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AccountRootHelpers.cpp @@ -208,8 +208,36 @@ isPseudoAccount( }) > 0; } +[[nodiscard]] bool +isBlackholed(ReadView const& view, std::shared_ptr const& sle) +{ + if (!sle || sle->getType() != ltACCOUNT_ROOT) + return false; + + if (!sle->isFlag(lsfDisableMaster)) + return false; + + if (sle->isFieldPresent(sfRegularKey)) + { + AccountID const rk = sle->getAccountID(sfRegularKey); + static AccountID const kAccountZero(0); + static AccountID const kAccountOne(1); + static AccountID const kAccountTwo(2); + + if (rk != kAccountZero && rk != kAccountOne && rk != kAccountTwo) + return false; + } + + AccountID const account = sle->getAccountID(sfAccount); + return !view.exists(keylet::signers(account)); +} + Expected, 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(); @@ -241,7 +269,8 @@ 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); diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 2019e378e2..91e35123c3 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -770,8 +770,11 @@ 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); + if (!valid && view.rules().enabled(fixTokenEscrowV1_1)) + valid = (flags_ == (base | lsfAllowTrustLineLocking)); + if (!valid) { JLOG(j.fatal()) << "Invariant failed: pseudo-account created with " "wrong flags"; diff --git a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp index 3e1fe97a76..e2523c80c5 100644 --- a/src/libxrpl/tx/transactors/dex/AMMCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMCreate.cpp @@ -244,7 +244,9 @@ applyCreate(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Jou auto const ammKeylet = keylet::amm(amount.asset(), amount2.asset()); // 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) { diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp index 0b1db125f6..860954ec15 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp @@ -195,7 +195,19 @@ escrowCreatePreclaimHelper( if (!sleIssuer) return tecNO_ISSUER; if (!sleIssuer->isFlag(lsfAllowTrustLineLocking)) - return tecNO_PERMISSION; + { + 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, issue.currency)); @@ -469,6 +481,23 @@ EscrowCreate::doApply() auto const xferRate = transferRate(ctx_.view(), amount); if (xferRate != kParityRate) (*slep)[sfTransferRate] = xferRate.value; + + 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); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index 68ebdbcf60..34ad7b6f96 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -4365,19 +4365,25 @@ 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)); - }); + Env env{*this, features}; + fund(env, gw_, {alice_}, {USD(20'000)}, Fund::All); + AMM const ammAlice(env, alice_, XRP(10'000), USD(10'000)); + auto const ammAccount = ammAlice.ammAccount(); + + auto const sleAMM = env.le(keylet::account(ammAccount)); + if (BEAST_EXPECT(sleAMM)) + { + BEAST_EXPECT(sleAMM->isFlag(lsfDisableMaster)); + BEAST_EXPECT(sleAMM->isFlag(lsfDefaultRipple)); + BEAST_EXPECT(sleAMM->isFlag(lsfDepositAuth)); + bool const flag = sleAMM->isFlag(lsfAllowTrustLineLocking); + BEAST_EXPECT(features[fixTokenEscrowV1_1] ? flag : !flag); + } } void @@ -7194,7 +7200,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); diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index 5bb1303dba..51819fe77f 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -1,4 +1,5 @@ +#include #include #include #include @@ -9,6 +10,8 @@ #include #include #include +#include +#include #include #include #include @@ -26,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -372,6 +376,86 @@ 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(); + + auto const usd = gw["USD"]; + env.trust(usd(30'000), alice); + env.close(); + env(pay(gw, alice, usd(10'000))); + env.close(); + + AMM const ammAlice(env, alice, XRP(10'000), usd(10'000)); + auto const ammAccount = ammAlice.ammAccount(); + auto const lpIssue = ammAlice.lptIssue(); + + env.trust(STAmount{lpIssue, 10'000}, carol); + env.close(); + env(pay(alice, carol, STAmount{lpIssue, 100})); + env.close(); + + env(escrow::create(carol, bob, STAmount{lpIssue, 50}), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150), + Ter(withFix ? TER(tesSUCCESS) : TER(tecNO_PERMISSION))); + env.close(); + + if (withFix) + { + 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(); + + Account const blackhole("blackhole", AccountID(1)); + env(regkey(gw, blackhole)); + env.close(); + env(fset(gw, asfDisableMaster), Sig(gw)); + env.close(); + + env(escrow::create(alice, bob, usd(100)), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150), + Ter(withFix ? TER(tesSUCCESS) : TER(tecNO_PERMISSION))); + env.close(); + + if (withFix) + { + 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}; @@ -3993,9 +4077,11 @@ public: {all - featureSingleAssetVault - featureLendingProtocol, all}) { testIOUWithFeats(feats); - testIOUWithFeats(feats - fixCleanup3_2_0); + testIOUWithFeats(feats - fixTokenEscrowV1_1); testMPTWithFeats(feats); testMPTWithFeats(feats - fixTokenEscrowV1); + testMPTWithFeats(feats - fixTokenEscrowV1 - fixTokenEscrowV1_1); + testIOUWithFeats(feats - fixCleanup3_2_0); } } };