feat: blackholed token escrow

Mirrors dangell7/fix-token-escrow-v2 onto the docs-infra base so the doc-review
workflow runs on a realistic single-feature diff.
This commit is contained in:
Denis Angell
2026-05-14 10:21:03 +02:00
parent 81d1ce4fb8
commit 0893579ba3
9 changed files with 195 additions and 17 deletions

View File

@@ -99,7 +99,14 @@ isPseudoAccount(
* 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);
[[nodiscard]] bool
isBlackholed(ReadView const& view, std::shared_ptr<SLE const> const& sle);
/** Checks the destination and tag.

View File

@@ -41,6 +41,12 @@ escrowUnlockApplyHelper<Issue>(
bool createAsset,
beast::Journal journal)
{
if (view.rules().enabled(fixTokenEscrowV1_1))
{
if (!sleDest || sleDest->getType() != ltACCOUNT_ROOT)
return tecINTERNAL;
}
Issue const& issue = amount.get<Issue>();
Keylet const trustLineKey = keylet::line(receiver, issue);
bool const recvLow = issuer > receiver;
@@ -171,6 +177,12 @@ escrowUnlockApplyHelper<MPTIssue>(
bool createAsset,
beast::Journal journal)
{
if (view.rules().enabled(fixTokenEscrowV1_1))
{
if (!sleDest || sleDest->getType() != ltACCOUNT_ROOT)
return tecINTERNAL;
}
bool const senderIssuer = issuer == sender;
bool const receiverIssuer = issuer == receiver;

View File

@@ -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)

View File

@@ -208,8 +208,39 @@ isPseudoAccount(
}) > 0;
}
[[nodiscard]] bool
isBlackholed(ReadView const& view, std::shared_ptr<SLE const> 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 kACCOUNT_ZERO(0);
static AccountID const kACCOUNT_ONE(1);
static AccountID const kACCOUNT_TWO(2);
if (rk != kACCOUNT_ZERO && rk != kACCOUNT_ONE && rk != kACCOUNT_TWO)
return false;
}
AccountID const account = sle->getAccountID(sfAccount);
if (view.exists(keylet::signers(account)))
return false;
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();
@@ -241,7 +272,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);

View File

@@ -771,8 +771,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";

View File

@@ -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)
{

View File

@@ -195,7 +195,19 @@ escrowCreatePreclaimHelper<Issue>(
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 != kPARITY_RATE)
(*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);

View File

@@ -4366,19 +4366,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 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
@@ -7195,7 +7201,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/AMM.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/amount.h>
@@ -9,6 +10,8 @@
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/rate.h>
#include <test/jtx/regkey.h>
#include <test/jtx/sig.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
@@ -372,6 +375,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 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::kFINISH_TIME(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::kFINISH_TIME(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};
@@ -3928,8 +4011,10 @@ public:
{all - featureSingleAssetVault - featureLendingProtocol, all})
{
testIOUWithFeats(feats);
testIOUWithFeats(feats - fixTokenEscrowV1_1);
testMPTWithFeats(feats);
testMPTWithFeats(feats - fixTokenEscrowV1);
testMPTWithFeats(feats - fixTokenEscrowV1 - fixTokenEscrowV1_1);
}
}
};