From 945c19564eb56f2aa304681d166cd84fb17feace Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Fri, 6 Feb 2026 10:57:40 +0100 Subject: [PATCH] extend escrow to psuedo and blackholed - allow LP tokens - allow blackholed accounts --- include/xrpl/ledger/View.h | 10 ++- src/libxrpl/ledger/View.cpp | 38 ++++++++- src/test/app/AMM_test.cpp | 42 ++++++++++ src/test/app/EscrowToken_test.cpp | 93 ++++++++++++++++++++++ src/xrpld/app/tx/detail/AMMCreate.cpp | 5 +- src/xrpld/app/tx/detail/Escrow.cpp | 51 +++++++++++- src/xrpld/app/tx/detail/InvariantCheck.cpp | 9 ++- 7 files changed, 241 insertions(+), 7 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 767622596b..dcfda21399 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -653,7 +653,8 @@ pseudoAccountAddress(ReadView const& view, uint256 const& pseudoOwnerKey); createPseudoAccount( ApplyView& view, uint256 const& pseudoOwnerKey, - SField const& ownerField); + SField const& ownerField, + std::uint32_t additionalFlags = 0); // Returns true iff sleAcct is a pseudo-account or specific // pseudo-accounts in pseudoFieldFilter. @@ -688,6 +689,13 @@ 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 const& sle); + [[nodiscard]] TER canAddHolding(ReadView const& view, Asset const& asset); diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index c817a85b65..5f710a2527 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -1242,11 +1242,44 @@ isPseudoAccount( }) > 0; } +[[nodiscard]] bool +isBlackholed(ReadView const& view, std::shared_ptr 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, TER> createPseudoAccount( ApplyView& view, uint256 const& pseudoOwnerKey, - SField const& ownerField) + SField const& ownerField, + std::uint32_t additionalFlags) { [[maybe_unused]] auto const& fields = getPseudoAccountFields(); @@ -1281,7 +1314,8 @@ createPseudoAccount( // rippling, and enable deposit authorization to prevent payments into // pseudo-account. account->setFieldU32( - sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sfFlags, + lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | additionalFlags); // Link the pseudo-account with its owner object. account->setFieldH256(ownerField, pseudoOwnerKey); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index 8d64dfed2a..a3254caf1b 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -130,6 +130,48 @@ private: 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 diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index f328877b3c..c63e4034ba 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -1,4 +1,5 @@ #include +#include #include @@ -359,6 +360,97 @@ 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}; @@ -4089,6 +4181,7 @@ public: testIOUWithFeats(all - fixTokenEscrowV1_1); testMPTWithFeats(all); testMPTWithFeats(all - fixTokenEscrowV1); + testMPTWithFeats(all - fixTokenEscrowV1 - fixTokenEscrowV1_1); } }; diff --git a/src/xrpld/app/tx/detail/AMMCreate.cpp b/src/xrpld/app/tx/detail/AMMCreate.cpp index 3a3ce4b1e1..c468f882e2 100644 --- a/src/xrpld/app/tx/detail/AMMCreate.cpp +++ b/src/xrpld/app/tx/detail/AMMCreate.cpp @@ -202,7 +202,10 @@ applyCreate( 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 : 0; + auto const maybeAccount = + createPseudoAccount(sb, ammKeylet.key, sfAMMID, additionalFlags); // AMM account already exists (should not happen) if (!maybeAccount) { diff --git a/src/xrpld/app/tx/detail/Escrow.cpp b/src/xrpld/app/tx/detail/Escrow.cpp index bbaf346932..7fd5f883e7 100644 --- a/src/xrpld/app/tx/detail/Escrow.cpp +++ b/src/xrpld/app/tx/detail/Escrow.cpp @@ -184,7 +184,21 @@ escrowCreatePreclaimHelper( 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 = @@ -478,6 +492,27 @@ 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); @@ -755,6 +790,13 @@ escrowUnlockApplyHelper( 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; @@ -888,6 +930,13 @@ escrowUnlockApplyHelper( 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; diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index dadc5a7d74..44a0c77b87 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -1067,9 +1067,14 @@ ValidNewAccountRoot::finalize( if (pseudoAccount) { - std::uint32_t const expected = + std::uint32_t const base = (lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - if (flags_ != expected) + 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 "