Compare commits

...

2 Commits

Author SHA1 Message Date
Nicholas Dudfield
30a065e726 chore: drop projected-source markers 2026-09-10 16:31:11 +07:00
Nicholas Dudfield
bf339822c1 feat(amendment): NoRecipientLimit, trust limits govern intermediaries, not recipients
A trust-line limit governs an account used as an intermediary, never an
account receiving its issuer's token. Under featureNoRecipientLimit:

- payment engine: the direct step that is the delivered asset's issuer
  crediting the strand's destination in the delivered currency
  (dst == strandDst && src == strandDeliver.account &&
  currency == strandDeliver.currency, covering the last step and the
  implied issuer step after a book or AMM) drops the limit cap in the
  issuing direction and skips the limit dry test in check(). Funds caps,
  intermediary caps, a non-issuer's last hop, the destination acting as
  an intermediary in another currency, and auth/freeze/NoRipple are
  unchanged. The lifted cap is the largest IOU amount, not the requested
  output, because a destination QualityIn below one makes src->dst exceed
  the output.
- trustTransferLockedBalance: the third-party-finisher limit check on IOU
  EscrowFinish/PayChanClaim is skipped (the destination finishing itself
  was already exempt).
- persist flag: lsfLowPersist/lsfHighPersist on RippleState, set and
  cleared by TrustSet tfSetPersist/tfClearPersist on the holder's own
  side; a persisting side is never default, keeps its reserve, and is not
  deleted at zero balance. Remit sets it on the destination side of a
  line it creates. Hook SDK flag headers regenerated.

Tests: Flow_test::testRecipientLimit (eight cases, amendment on and off),
SetTrust_test::testPersist, Remit_test::testPersistLine; existing
assertions that a recipient's limit bites are gated on the amendment in
Flow, Check, PayChan, SetTrust, TrustAndBalance, Path, URIToken and
DeliveredAmount tests, each also run with the amendment off. Source
carries //@@start/end markers for the projected-source design doc.
2026-09-10 16:25:01 +07:00
20 changed files with 624 additions and 66 deletions

View File

@@ -37,6 +37,8 @@ enum ltRIPPLE_STATE {
lsfHighFreeze = 0x00800000,
lsfLowDeepFreeze = 0x02000000,
lsfHighDeepFreeze = 0x04000000,
lsfLowPersist = 0x08000000,
lsfHighPersist = 0x10000000,
lsfAMMNode = 0x01000000,
};
enum ltSIGNER_LIST {

View File

@@ -55,7 +55,9 @@ enum TrustSetFlags : uint32_t {
tfSetFreeze = 0x00100000,
tfClearFreeze = 0x00200000,
tfSetDeepFreeze = 0x00400000,
tfClearDeepFreeze = 0x00800000
tfClearDeepFreeze = 0x00800000,
tfSetPersist = 0x01000000,
tfClearPersist = 0x02000000
};
enum EnableAmendmentFlags : uint32_t {

View File

@@ -166,6 +166,8 @@ enum LedgerSpecificFlags {
lsfHighFreeze = 0x00800000, // True, high side has set freeze flag
lsfLowDeepFreeze = 0x02000000, // True, low side has set deep freeze flag
lsfHighDeepFreeze = 0x04000000, // True, high side has set deep freeze flag
lsfLowPersist = 0x08000000, // True, low side keeps the line at zero
lsfHighPersist = 0x10000000, // True, high side keeps the line at zero
lsfAMMNode = 0x01000000, // True, trust line to AMM. Used by client
// apps to identify payments via AMM.

View File

@@ -125,11 +125,14 @@ enum TrustSetFlags : uint32_t {
tfSetFreeze = 0x00100000,
tfClearFreeze = 0x00200000,
tfSetDeepFreeze = 0x00400000,
tfClearDeepFreeze = 0x00800000
tfClearDeepFreeze = 0x00800000,
tfSetPersist = 0x01000000,
tfClearPersist = 0x02000000
};
constexpr std::uint32_t tfTrustSetMask =
~(tfUniversal | tfSetfAuth | tfSetNoRipple | tfClearNoRipple | tfSetFreeze |
tfClearFreeze | tfSetDeepFreeze | tfClearDeepFreeze);
tfClearFreeze | tfSetDeepFreeze | tfClearDeepFreeze | tfSetPersist |
tfClearPersist);
// EnableAmendment flags:
enum EnableAmendmentFlags : uint32_t {

View File

@@ -34,6 +34,7 @@
// If you add an amendment here, then do not forget to increment `numFeatures`
// in include/xrpl/protocol/Feature.h.
XRPL_FEATURE(NoRecipientLimit, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(OnChainManifests, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (HookMap, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FIX (GuardDepth32, Supported::yes, VoteBehavior::DefaultNo)

View File

@@ -529,7 +529,12 @@ class Check_test : public beast::unit_test::suite
env.close();
env(check::create(gw1, alice, USD(50)), ter(tecFROZEN));
env.close();
env(pay(gw1, alice, USD(1)), ter(tecPATH_DRY));
// A pure issue cannot be frozen; what refused this payment was
// alice's limit of zero, which no longer caps her issuer under
// NoRecipientLimit.
env(pay(gw1, alice, USD(1)),
ter(features[featureNoRecipientLimit] ? TER(tesSUCCESS)
: TER(tecPATH_DRY)));
env.close();
// Clear that freeze.
@@ -736,11 +741,13 @@ class Check_test : public beast::unit_test::suite
// bob sets up the trust line, but not at a high enough limit.
env(trust(bob, USD(9.5)));
env.close();
if (!cashCheckMakesTrustLine)
if (!cashCheckMakesTrustLine && !features[featureNoRecipientLimit])
{
// If cashing a check is allowed to exceed the trust line
// limit then this returns tesSUCCESS and the check is
// removed from the ledger which would mess up later tests.
// limit (CheckCashMakesTrustLine, or NoRecipientLimit
// exempting bob from his limit on his issuer) then this
// returns tesSUCCESS and the check is removed from the
// ledger which would mess up later tests.
env(check::cash(bob, chkId1, USD(10)), ter(tecPATH_PARTIAL));
env.close();
}
@@ -829,9 +836,22 @@ class Check_test : public beast::unit_test::suite
// a payment to bob cannot exceed that trust line, but cashing
// a check can.
// Payment of 20 USD fails.
env(pay(gw, bob, USD(20)), ter(tecPATH_PARTIAL));
env.close();
// Payment of 20 USD fails, unless recipients are exempt
// from their limit, in which case it succeeds and is undone
// so the check below still tells the same story.
if (features[featureNoRecipientLimit])
{
env(pay(gw, bob, USD(20)));
env.close();
env.require(balance(bob, USD(30)));
env(pay(bob, gw, USD(20)));
env.close();
}
else
{
env(pay(gw, bob, USD(20)), ter(tecPATH_PARTIAL));
env.close();
}
uint256 const chkId20{getCheckIndex(gw, env.seq(gw))};
env(check::create(gw, bob, USD(20)));
@@ -977,7 +997,9 @@ class Check_test : public beast::unit_test::suite
// bob tries to cash the check again but fails because his trust
// limit is too low.
if (!cashCheckMakesTrustLine)
bool const exceedsLimit =
cashCheckMakesTrustLine || features[featureNoRecipientLimit];
if (!exceedsLimit)
{
// If cashing a check is allowed to exceed the trust line
// limit then this returns tesSUCCESS and the check is
@@ -994,7 +1016,7 @@ class Check_test : public beast::unit_test::suite
// o If it can build a trust line, then the check is allowed to
// exceed the trust limit and bob gets the full transfer.
env(check::cash(bob, chkId, check::DeliverMin(USD(4))));
STAmount const bobGot = cashCheckMakesTrustLine ? USD(7) : USD(5);
STAmount const bobGot = exceedsLimit ? USD(7) : USD(5);
verifyDeliveredAmount(env, bobGot);
env.require(balance(alice, USD(8) - bobGot));
env.require(balance(bob, bobGot));
@@ -2690,8 +2712,10 @@ class Check_test : public beast::unit_test::suite
testCreateValid(features);
testCreateDisallowIncoming(features);
testCreateInvalid(features);
testCreateInvalid(features - featureNoRecipientLimit);
testCashXRP(features);
testCashIOU(features);
testCashIOU(features - featureNoRecipientLimit);
testCashXferFee(features);
testCashQuality(features);
testCashInvalid(features);

View File

@@ -960,12 +960,16 @@ struct Flow_test : public beast::unit_test::suite
env.require(balance(alice, EUR(600)));
aliceOffers = offersOnAccount(env, alice);
BEAST_EXPECT(aliceOffers.size() == 1);
// alice's EUR limit is 606 and she holds 600: the last step, gw2
// issuing to alice, is capped at 6 EUR unless recipients are exempt
// from their limit, in which case all 60 EUR cross.
bool const exempt = features[featureNoRecipientLimit];
for (auto const& offerPtr : aliceOffers)
{
auto const offer = *offerPtr;
BEAST_EXPECT(offer[sfLedgerEntryType] == ltOFFER);
BEAST_EXPECT(offer[sfTakerGets] == EUR(594));
BEAST_EXPECT(offer[sfTakerPays] == USD(495));
BEAST_EXPECT(offer[sfTakerGets] == (exempt ? EUR(540) : EUR(594)));
BEAST_EXPECT(offer[sfTakerPays] == (exempt ? USD(450) : USD(495)));
}
}
void
@@ -1400,6 +1404,173 @@ struct Flow_test : public beast::unit_test::suite
env.require(balance(alice, XRP(9000) - drops(20)));
}
void
testRecipientLimit(FeatureBitset features)
{
// A trust line limit governs an account used as an intermediary, not
// an account receiving its issuer's token: with
// featureNoRecipientLimit the issuer's step into the destination
// ignores the destination's limit. Without it, today's behaviour
// holds. Funds caps, intermediary caps and a non-issuer rippling into
// the destination are unchanged in both regimes.
bool const exempt = features[featureNoRecipientLimit];
testcase(
exempt ? "Recipient limit (exempt)" : "Recipient limit (enforced)");
using namespace jtx;
auto const gw = Account("gw");
auto const USD = gw["USD"];
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const carol = Account("carol");
auto const dan = Account("dan");
{
// Issuer pays a holder more than the holder's limit.
Env env(*this, features);
env.fund(XRP(10000), gw, alice);
env.trust(USD(100), alice);
env(pay(gw, alice, USD(150)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_PARTIAL)));
env.require(balance(alice, exempt ? USD(150) : USD(0)));
}
{
// A line already at its limit: the dry check in the direct step.
Env env(*this, features);
env.fund(XRP(10000), gw, alice);
env.trust(USD(100), alice);
env(pay(gw, alice, USD(100)));
env(pay(gw, alice, USD(1)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_DRY)));
env.require(balance(alice, exempt ? USD(101) : USD(100)));
}
{
// Holder pays holder through the issuer; the recipient's limit is
// the last step's limit.
Env env(*this, features);
env.fund(XRP(10000), gw, alice, bob);
env.trust(USD(1000), alice);
env.trust(USD(100), bob);
env(pay(gw, alice, USD(500)));
env(pay(alice, bob, USD(150)),
paths(USD),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_PARTIAL)));
env.require(balance(bob, exempt ? USD(150) : USD(0)));
}
{
// Cross-currency: the last direct step follows a book step.
Env env(*this, features);
env.fund(XRP(10000), gw, alice, bob, carol);
env.trust(USD(1000), carol);
env.trust(USD(100), bob);
env(pay(gw, carol, USD(500)));
env(offer(carol, XRP(150), USD(150)));
env(pay(alice, bob, USD(150)),
sendmax(XRP(150)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_PARTIAL)));
env.require(balance(bob, exempt ? USD(150) : USD(0)));
}
{
// A partial payment delivers up to the limit today and all of it
// when the recipient is exempt.
Env env(*this, features);
env.fund(XRP(10000), gw, alice, bob);
env.trust(USD(1000), alice);
env.trust(USD(100), bob);
env(pay(gw, alice, USD(500)));
env(pay(alice, bob, USD(150)),
paths(USD),
txflags(tfPartialPayment));
env.require(balance(bob, exempt ? USD(150) : USD(100)));
}
{
// Rippling through an intermediary is still capped by the
// intermediary's limit in both regimes: bob's limit on alice's
// USD is 10, and bob is not the destination.
auto const USDA = alice["USD"];
auto const USDB = bob["USD"];
auto const USDC = carol["USD"];
Env env(*this, features);
env.fund(XRP(10000), alice, bob, carol, dan);
env.trust(USDA(10), bob);
env.trust(USDB(1000), carol);
env.trust(USDC(1000), dan);
env(pay(alice, dan, USDC(15)), paths(USDA), ter(tecPATH_PARTIAL));
env.require(balance(dan, USDC(0)));
env(pay(alice, dan, USDC(10)), paths(USDA));
env.require(
balance(bob, USDA(10)),
balance(carol, USDB(10)),
balance(dan, USDC(10)));
}
{
// A non-issuer crediting the destination is still capped by the
// destination's limit on that account in both regimes: the
// payment names carol's own USD, so the engine does not route
// through an issuer, and the last hop is bob issuing bob's USD to
// carol, whose limit on bob is 10.
auto const USDA = alice["USD"];
auto const USDB = bob["USD"];
auto const USDC = carol["USD"];
Env env(*this, features);
env.fund(XRP(10000), alice, bob, carol);
env.trust(USDA(100), bob);
env.trust(USDB(10), carol);
env(pay(alice, carol, USDC(15)), path(bob), ter(tecPATH_PARTIAL));
env.require(balance(carol, USDB(0)));
env(pay(alice, carol, USDC(10)), path(bob));
env.require(balance(bob, USDA(10)), balance(carol, USDB(10)));
}
{
// The destination can be an intermediary earlier in the same
// strand, in another currency: gw pays bob EUR through bob's own
// USD line and mike's EUR/bobUSD offer. The USD hop into bob is
// not the issuer delivering the named token, so bob's USD limit
// (10) still caps it in both regimes.
auto const EUR = gw["EUR"];
auto const mike = Account("mike");
auto const USDB = bob["USD"];
Env env(*this, features);
env.fund(XRP(10000), gw, bob, mike);
env(fset(bob, asfDefaultRipple));
env.close();
env.trust(USD(10), bob);
env.trust(EUR(100), bob);
env.trust(USDB(100), mike);
env.trust(EUR(100), mike);
env(pay(gw, mike, EUR(50)));
env(offer(mike, USDB(15), EUR(15)));
env.close();
env(pay(gw, bob, EUR(15)),
sendmax(USD(15)),
path(bob, ~EUR),
txflags(tfNoRippleDirect),
ter(tecPATH_PARTIAL));
env.require(balance(bob, USD(0)), balance(bob, EUR(0)));
env(pay(gw, bob, EUR(10)),
sendmax(USD(10)),
path(bob, ~EUR),
txflags(tfNoRippleDirect));
env.require(
balance(bob, USD(10)),
balance(bob, EUR(10)),
balance(mike, USDB(10)));
}
{
// Redeeming to the issuer is capped by the sender's balance, not
// by any limit, in both regimes. Once the balance is spent the
// strand would have the sender issue its own USD to the issuer;
// the sender is not the issuer named by the payment, so the
// issuer's zero limit still refuses that.
Env env(*this, features);
env.fund(XRP(10000), gw, alice);
env.trust(USD(1000), alice);
env(pay(gw, alice, USD(50)));
env(pay(alice, gw, USD(60)), ter(tecPATH_PARTIAL));
env.require(balance(alice, USD(50)));
}
}
void
testWithFeats(FeatureBitset features)
{
@@ -1417,12 +1588,15 @@ struct Flow_test : public beast::unit_test::suite
testTransferRate(features | ownerPaysFee);
testSelfPayment1(features);
testSelfPayment2(features);
testSelfPayment2(features - featureNoRecipientLimit);
testSelfFundedXRPEndpoint(false, features);
testSelfFundedXRPEndpoint(true, features);
testUnfundedOffer(features);
testReexecuteDirectStep(features);
testSelfPayLowQualityOffer(features);
testTicketPay(features);
testRecipientLimit(features);
testRecipientLimit(features - featureNoRecipientLimit);
}
void

View File

@@ -80,17 +80,27 @@ class Path_test : public beast::unit_test::suite
{
jtx::Env
pathTestEnv()
{
using namespace jtx;
return pathTestEnv(supported_amendments());
}
jtx::Env
pathTestEnv(FeatureBitset features)
{
// These tests were originally written with search parameters that are
// different from the current defaults. This function creates an env
// with the search parameters that the tests were written for.
using namespace jtx;
return Env(*this, envconfig([](std::unique_ptr<Config> cfg) {
cfg->PATH_SEARCH_OLD = 7;
cfg->PATH_SEARCH = 7;
cfg->PATH_SEARCH_MAX = 10;
return cfg;
}));
return Env(
*this,
envconfig([](std::unique_ptr<Config> cfg) {
cfg->PATH_SEARCH_OLD = 7;
cfg->PATH_SEARCH = 7;
cfg->PATH_SEARCH_MAX = 10;
return cfg;
}),
features);
}
public:
@@ -734,11 +744,11 @@ public:
}
void
issues_path_negative_issue()
issues_path_negative_issue(FeatureBitset features)
{
testcase("path negative: Issue #5");
using namespace jtx;
Env env = pathTestEnv();
Env env = pathTestEnv(features);
env.fund(XRP(10000), "alice", "bob", "carol", "dan");
env.trust(Account("bob")["USD"](100), "alice", "carol", "dan");
env.trust(Account("alice")["USD"](100), "dan");
@@ -751,14 +761,19 @@ public:
find_paths(env, "alice", "bob", Account("bob")["USD"](25));
BEAST_EXPECT(std::get<0>(result).empty());
env(pay("alice", "bob", Account("alice")["USD"](25)), ter(tecPATH_DRY));
// alice issuing her own USD to bob, who has no limit on her, is
// refused; with featureNoRecipientLimit bob's limit does not cap
// his issuer and the payment succeeds.
bool const exempt = env.enabled(featureNoRecipientLimit);
env(pay("alice", "bob", Account("alice")["USD"](25)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_DRY)));
result = find_paths(env, "alice", "bob", Account("alice")["USD"](25));
BEAST_EXPECT(std::get<0>(result).empty());
env.require(balance("alice", Account("bob")["USD"](0)));
env.require(balance("alice", Account("bob")["USD"](exempt ? -25 : 0)));
env.require(balance("alice", Account("dan")["USD"](0)));
env.require(balance("bob", Account("alice")["USD"](0)));
env.require(balance("bob", Account("alice")["USD"](exempt ? 25 : 0)));
env.require(balance("bob", Account("carol")["USD"](-75)));
env.require(balance("bob", Account("dan")["USD"](0)));
env.require(balance("carol", Account("bob")["USD"](75)));
@@ -1518,7 +1533,9 @@ public:
alternative_paths_consume_best_transfer();
alternative_paths_consume_best_transfer_first();
alternative_paths_limit_returned_paths_to_best_quality();
issues_path_negative_issue();
issues_path_negative_issue(jtx::supported_amendments());
issues_path_negative_issue(
jtx::supported_amendments() - featureNoRecipientLimit);
issues_path_negative_ripple_client_issue_23_smaller();
issues_path_negative_ripple_client_issue_23_larger();
via_offers_via_gateway();

View File

@@ -4959,9 +4959,12 @@ struct PayChan_test : public beast::unit_test::suite
BEAST_EXPECT(
postLocked ==
(t.negative ? (preLocked + delta) : (preLocked - delta)));
// src claim fails because trust limit is 0
// src claim fails because trust limit is 0, unless a limit no
// longer governs the receiver
auto const testResult =
t.hasTrustline ? ter(tesSUCCESS) : ter(tecPATH_DRY);
(t.hasTrustline || features[featureNoRecipientLimit])
? ter(tesSUCCESS)
: ter(tecPATH_DRY);
env(paychan::claim(t.src, chan, authAmt, authAmt), testResult);
}
}
@@ -5288,11 +5291,25 @@ struct PayChan_test : public beast::unit_test::suite
assert(reqBal <= chanAmt);
auto const preLocked = -lockedAmount(env, alice, gw, USD);
BEAST_EXPECT(preLocked == USD(1000));
// alice cannot claim because bobs amount would be > than limit
env(paychan::claim(alice, chan, reqBal, authAmt), ter(tecPATH_DRY));
auto const preBobLimit = limitAmount(env, bob, gw, USD);
if (features[featureNoRecipientLimit])
{
// bob's limit does not govern bob receiving, whoever
// finishes the claim
env(paychan::claim(alice, chan, reqBal, authAmt));
env.close();
BEAST_EXPECT(env.balance(bob, USD) == USD(1000) + delta);
reqBal = reqBal + delta;
}
else
{
// alice cannot claim because bobs amount would be > than
// limit
env(paychan::claim(alice, chan, reqBal, authAmt),
ter(tecPATH_DRY));
}
// bob can claim, increasing the limit amount
auto const preBobLimit = limitAmount(env, bob, gw, USD);
auto const sig =
signClaimIOUAuth(alice.pk(), alice.sk(), chan, authAmt);
env(paychan::claim(
@@ -5927,9 +5944,11 @@ struct PayChan_test : public beast::unit_test::suite
testIOUUsingTickets(features);
testIOUAutoTL(features);
testIOURippleState(features);
testIOURippleState(features - featureNoRecipientLimit);
testIOUGateway(features);
testIOULockedRate(features);
testIOUTLLimitAmount(features);
testIOUTLLimitAmount(features - featureNoRecipientLimit);
testIOUTLRequireAuth(features);
testIOUTLFreeze(features);
testIOUTLINSF(features);

View File

@@ -2924,6 +2924,78 @@ struct Remit_test : public beast::unit_test::suite
ter(tecNO_PERMISSION));
}
void
testPersistLine(FeatureBitset features)
{
using namespace jtx;
bool const persisted = features[featureNoRecipientLimit];
testcase(
std::string("remit-created line ") +
(persisted ? "persists" : "is deleted") + " at zero balance");
Env env{*this, features};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const gw = Account("gw");
auto const USD = gw["USD"];
env.fund(XRP(1000), alice, bob, gw);
env.close();
env.trust(USD(100000), alice);
env.close();
env(pay(gw, alice, USD(10000)));
env.close();
auto const lineKey = keylet::line(bob, gw, USD.currency);
auto const persistFlag =
bob.id() > gw.id() ? lsfHighPersist : lsfLowPersist;
auto const persists = [&]() {
auto const sle = env.le(lineKey);
return sle && ((*sle)[sfFlags] & persistFlag);
};
// The remit creates bob's line and, under the amendment, marks
// bob's side as persisting.
env(remit::remit(alice, bob), remit::amts({USD(1)}));
env.close();
BEAST_EXPECT(env.le(lineKey));
BEAST_EXPECT(persists() == persisted);
BEAST_EXPECT(env.ownerCount(bob) == 1);
// A second remit onto the existing line changes nothing about it.
env(remit::remit(alice, bob), remit::amts({USD(1)}));
env.close();
BEAST_EXPECT(persists() == persisted);
BEAST_EXPECT(env.ownerCount(bob) == 1);
BEAST_EXPECT(env.balance(bob, USD.issue()) == USD(2));
// bob spends the whole balance back to the issuer. Without the flag
// the default line is deleted with it.
env(pay(bob, gw, USD(2)));
env.close();
BEAST_EXPECT(bool(env.le(lineKey)) == persisted);
BEAST_EXPECT(env.ownerCount(bob) == (persisted ? 1 : 0));
if (!persisted)
return;
// bob can still receive on the kept line, and can clear the flag
// to let it go once it is empty.
env(remit::remit(alice, bob), remit::amts({USD(3)}));
env.close();
BEAST_EXPECT(env.balance(bob, USD.issue()) == USD(3));
BEAST_EXPECT(env.ownerCount(bob) == 1);
env(trust(bob, USD(0), tfClearPersist));
env.close();
BEAST_EXPECT(env.le(lineKey) && !persists());
env(pay(bob, gw, USD(3)));
env.close();
BEAST_EXPECT(!env.le(lineKey));
BEAST_EXPECT(env.ownerCount(bob) == 0);
}
void
testWithFeats(FeatureBitset features)
{
@@ -2946,6 +3018,8 @@ struct Remit_test : public beast::unit_test::suite
testURIToken(features);
testOptionals(features);
testDestAMM(features);
testPersistLine(features);
testPersistLine(features - featureNoRecipientLimit);
}
public:

View File

@@ -342,14 +342,17 @@ public:
}
void
testExceedTrustLineLimit()
testExceedTrustLineLimit(FeatureBitset features)
{
testcase(
"Ensure that trust line limits are respected in payment "
"transactions");
using namespace jtx;
Env env{*this};
Env env{*this, features};
// With featureNoRecipientLimit the issuer's payment into the
// holder is not capped by the holder's limit.
bool const exempt = features[featureNoRecipientLimit];
auto const gw = Account{"gateway"};
auto const alice = Account{"alice"};
@@ -360,8 +363,10 @@ public:
env.close();
// send a payment for a large quantity through the trust line
env(pay(gw, alice, gw["USD"](200)), ter(tecPATH_PARTIAL));
env(pay(gw, alice, gw["USD"](200)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_PARTIAL)));
env.close();
env.require(balance(alice, gw["USD"](exempt ? 200 : 0)));
// on the other hand, smaller payments should succeed
env(pay(gw, alice, gw["USD"](20)));
@@ -398,14 +403,17 @@ public:
}
void
testTrustLineLimitsWithRippling()
testTrustLineLimitsWithRippling(FeatureBitset features)
{
testcase(
"Check that trust line limits are respected in conjunction "
"with rippling feature");
using namespace jtx;
Env env{*this};
Env env{*this, features};
// With featureNoRecipientLimit bob, issuing his own USD to alice,
// is not capped by alice's (zero) limit on him.
bool const exempt = features[featureNoRecipientLimit];
auto const bob = Account{"bob"};
auto const alice = Account{"alice"};
@@ -426,9 +434,12 @@ public:
env.close();
// bob cannot place alice in his debt i.e. alice's balance of the USD
// tokens cannot go below zero.
env(pay(bob, alice, bob["USD"](11)), ter(tecPATH_PARTIAL));
// tokens cannot go below zero, unless recipients are exempt from
// their limit: then bob issues 1 USD of his own to alice.
env(pay(bob, alice, bob["USD"](11)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_PARTIAL)));
env.close();
env.require(balance(bob, alice["USD"](exempt ? -1 : 10)));
// payments that respect the trust line limits of alice should succeed
env(pay(bob, alice, bob["USD"](10)), ter(tesSUCCESS));
@@ -618,6 +629,106 @@ public:
env.close();
}
void
testPersist(FeatureBitset features)
{
using namespace jtx;
bool const enabled = features[featureNoRecipientLimit];
testcase(
std::string("Persist flag ") + (enabled ? "enabled" : "disabled"));
Env env{*this, features};
auto const gw = Account{"gateway"};
auto const alice = Account{"alice"};
auto const USD = gw["USD"];
env.fund(XRP(10000), gw, alice);
env.close();
if (!enabled)
{
env(trust(alice, USD(100), tfSetPersist), ter(temINVALID_FLAG));
env(trust(alice, USD(100), tfClearPersist), ter(temINVALID_FLAG));
return;
}
auto const lineKey = keylet::line(alice, gw, USD.currency);
auto const persistFlag =
alice.id() > gw.id() ? lsfHighPersist : lsfLowPersist;
auto const persists = [&]() {
auto const sle = env.le(lineKey);
return sle && ((*sle)[sfFlags] & persistFlag);
};
// Set and clear together is malformed.
env(trust(alice, USD(100), tfSetPersist | tfClearPersist),
ter(temINVALID_FLAG));
// Set on a fresh line: the line is created with the flag.
env(trust(alice, USD(100), tfSetPersist));
env.close();
BEAST_EXPECT(persists());
BEAST_EXPECT(env.ownerCount(alice) == 1);
// Dropping the limit to zero leaves a persisting line in place and
// alice keeps paying its reserve.
env(trust(alice, USD(0)));
env.close();
BEAST_EXPECT(persists());
BEAST_EXPECT(env.ownerCount(alice) == 1);
// Clearing the flag on an otherwise default line deletes it.
env(trust(alice, USD(0), tfClearPersist));
env.close();
BEAST_EXPECT(!env.le(lineKey));
BEAST_EXPECT(env.ownerCount(alice) == 0);
// Set alone, with a default limit, is not redundant: it creates the
// line so a zero-limit holder can keep it.
env(trust(alice, USD(0), tfSetPersist));
env.close();
BEAST_EXPECT(persists());
BEAST_EXPECT(env.ownerCount(alice) == 1);
// A balance that comes and goes does not delete a persisting line.
env(pay(gw, alice, USD(10)));
env(pay(alice, gw, USD(10)));
env.close();
BEAST_EXPECT(persists());
BEAST_EXPECT(env.balance(alice, USD.issue()) == USD(0));
// Clearing with no balance and no limit deletes the line.
env(trust(alice, USD(0), tfClearPersist));
env.close();
BEAST_EXPECT(!env.le(lineKey));
BEAST_EXPECT(env.ownerCount(alice) == 0);
// Clearing a line that never persisted is a no-op on the flag but
// still a valid, non-redundant request when a limit is set.
env(trust(alice, USD(50), tfClearPersist));
env.close();
BEAST_EXPECT(env.le(lineKey) && !persists());
// Each side owns its own bit. With both set, clearing one leaves
// the other side's claim, and its reserve, in place.
auto const gwPersistFlag =
persistFlag == lsfHighPersist ? lsfLowPersist : lsfHighPersist;
env(trust(alice, USD(0), tfSetPersist));
env(trust(gw, alice["USD"](0), tfSetPersist));
env.close();
BEAST_EXPECT(persists());
BEAST_EXPECT(env.ownerCount(gw) == 1);
env(trust(alice, USD(0), tfClearPersist));
env.close();
BEAST_EXPECT(env.le(lineKey) && !persists());
BEAST_EXPECT((*env.le(lineKey))[sfFlags] & gwPersistFlag);
BEAST_EXPECT(env.ownerCount(alice) == 0);
BEAST_EXPECT(env.ownerCount(gw) == 1);
env(trust(gw, alice["USD"](0), tfClearPersist));
env.close();
BEAST_EXPECT(!env.le(lineKey));
BEAST_EXPECT(env.ownerCount(gw) == 0);
}
void
testWithFeats(FeatureBitset features)
{
@@ -636,9 +747,13 @@ public:
testDisallowIncoming(features);
testTrustLineResetWithAuthFlag();
testTrustLineDelete();
testExceedTrustLineLimit();
testExceedTrustLineLimit(features);
testExceedTrustLineLimit(features - featureNoRecipientLimit);
testAuthFlagTrustLines();
testTrustLineLimitsWithRippling();
testTrustLineLimitsWithRippling(features);
testTrustLineLimitsWithRippling(features - featureNoRecipientLimit);
testPersist(features);
testPersist(features - featureNoRecipientLimit);
}
public:

View File

@@ -176,9 +176,12 @@ class TrustAndBalance_test : public beast::unit_test::suite
env(pay(bob, alice, bob["USD"](1300)));
env.require(balance(bob, alice["USD"](-600)));
// bob sends past limit
env(pay(bob, alice, bob["USD"](1)), ter(tecPATH_DRY));
env.require(balance(bob, alice["USD"](-600)));
// bob sends past limit; with featureNoRecipientLimit bob is the
// issuer of what alice receives and her limit does not cap him
bool const exempt = features[featureNoRecipientLimit];
env(pay(bob, alice, bob["USD"](1)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_DRY)));
env.require(balance(bob, alice["USD"](exempt ? -601 : -600)));
}
void
@@ -467,6 +470,7 @@ public:
auto testWithFeatures = [this](FeatureBitset features) {
testPayNonexistent(features);
testDirectRipple(features);
testDirectRipple(features - featureNoRecipientLimit);
testWithTransferFee(false, false, features);
testWithTransferFee(false, true, features);
testWithTransferFee(true, false, features);

View File

@@ -2294,7 +2294,11 @@ struct URIToken_test : public beast::unit_test::suite
env.close();
auto const postLimit = limitAmount(env, bob, gw, USD);
BEAST_EXPECT(postLimit == preLimit);
env(pay(alice, carol, USD(1)), ter(tecPATH_DRY));
// carol already holds her 1000 limit; the issuer's step into
// her is dry unless recipients are exempt from their limit.
env(pay(alice, carol, USD(1)),
ter(features[featureNoRecipientLimit] ? TER(tesSUCCESS)
: TER(tecPATH_DRY)));
}
}
@@ -2649,6 +2653,7 @@ struct URIToken_test : public beast::unit_test::suite
testTransferRate(features);
testDisallowXRP(features);
testLimitAmount(features);
testLimitAmount(features - featureNoRecipientLimit);
testURIUTF8(features);
}

View File

@@ -206,15 +206,21 @@ class DeliveredAmount_test : public beast::unit_test::suite
env(pay(gw, alice, XRP(50)));
checkDeliveredAmount.adjCountersSuccess();
// partial payment
// Without recipient limits, the partial-payment flag does
// not prevent the issuer from delivering the full amount.
bool const exempt = features[featureNoRecipientLimit];
env(pay(gw, bob, USD(9999999)), txflags(tfPartialPayment));
checkDeliveredAmount.adjCountersPartialPayment();
env.require(balance(bob, USD(1000)));
env.require(balance(bob, USD(exempt ? 9999999 : 1000)));
// failed payment
env(pay(bob, carol, USD(9999999)), ter(tecPATH_PARTIAL));
checkDeliveredAmount.adjCountersFail();
env.require(balance(carol, USD(0)));
// bob is now fully funded only with the amendment enabled.
env(pay(bob, carol, USD(9999999)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_PARTIAL)));
if (exempt)
checkDeliveredAmount.adjCountersSuccess();
else
checkDeliveredAmount.adjCountersFail();
env.require(balance(carol, USD(exempt ? 9999999 : 0)));
}
auto wsc = makeWSClient(env.app().config());
@@ -285,15 +291,21 @@ class DeliveredAmount_test : public beast::unit_test::suite
env(pay(gw, alice, XRP(50)));
checkDeliveredAmount.adjCountersSuccess();
// partial payment
// Without recipient limits, the partial-payment flag does
// not prevent the issuer from delivering the full amount.
bool const exempt = features[featureNoRecipientLimit];
env(pay(gw, bob, USD(9999999)), txflags(tfPartialPayment));
checkDeliveredAmount.adjCountersPartialPayment();
env.require(balance(bob, USD(1000)));
env.require(balance(bob, USD(exempt ? 9999999 : 1000)));
// failed payment
env(pay(gw, carol, USD(9999999)), ter(tecPATH_PARTIAL));
checkDeliveredAmount.adjCountersFail();
env.require(balance(carol, USD(0)));
// The issuer is likewise no longer capped by carol's limit.
env(pay(gw, carol, USD(9999999)),
ter(exempt ? TER(tesSUCCESS) : TER(tecPATH_PARTIAL)));
if (exempt)
checkDeliveredAmount.adjCountersSuccess();
else
checkDeliveredAmount.adjCountersFail();
env.require(balance(carol, USD(exempt ? 9999999 : 0)));
env.close();
std::string index;
@@ -318,6 +330,8 @@ public:
FeatureBitset const all{supported_amendments() - featureXahauGenesis};
testTxDeliveredAmountRPC(all);
testAccountDeliveredAmountSubscribe(all);
testTxDeliveredAmountRPC(all - featureNoRecipientLimit);
testAccountDeliveredAmountSubscribe(all - featureNoRecipientLimit);
}
};

View File

@@ -24,6 +24,14 @@
namespace ripple {
// Trust line limits are consulted here and in creditLimit2, by the payment
// engine's direct step, which applies them to intermediary hops and (with
// featureNoRecipientLimit) not to the issuer's step into the destination.
// The only other reader is trustTransferLockedBalance, gated the same way.
// A transactor that credits an account through accountSend or rippleCredit
// never reads a limit; keep it that way so the invariant "a limit governs an
// intermediary, never an account receiving its issuer's token" holds for
// every transaction type without per-transactor exceptions.
STAmount
creditLimit(
ReadView const& view,

View File

@@ -44,6 +44,17 @@ protected:
// Charge transfer fees when the prev step redeems
Step const* const prevStep_ = nullptr;
bool const isLast_;
// This step is the delivered asset's issuer crediting the strand's
// destination. Not the same as isLast_: the implied issuer-to-destination
// step after a book or AMM is built without isLast, and isLast_ also
// selects quality semantics. Not any step into the destination either: a
// non-issuer crediting the destination is rippling the destination's
// acceptance of that account's IOU, which its limit still governs. And
// the currency must be the delivered one: the destination can appear
// earlier in the same strand as an intermediary in another currency
// (its own USD line feeding a USD/EUR book that delivers EUR), and that
// hop is capped like any intermediary hop.
bool const issuesToDst_;
beast::Journal const j_;
struct Cache
@@ -99,6 +110,9 @@ public:
, currency_(c)
, prevStep_(ctx.prevStep)
, isLast_(ctx.isLast)
, issuesToDst_(
dst == ctx.strandDst && src == ctx.strandDeliver.account &&
c == ctx.strandDeliver.currency)
, j_(ctx.j)
{
}
@@ -376,7 +390,25 @@ DirectIOfferCrossingStep::quality(ReadView const&, QualityDirection qDir) const
std::pair<IOUAmount, DebtDirection>
DirectIPaymentStep::maxFlow(ReadView const& sb, IOUAmount const&) const
{
return maxPaymentFlow(sb);
auto const [amount, direction] = maxPaymentFlow(sb);
// A trust line limit governs an account being used as an intermediary,
// not an account receiving its issuer's token. The issuer's step into the
// strand's destination is not capped by the destination's limit. Only the
// issuing direction is capped by a limit; when the source redeems,
// `amount` is the source's own balance and stays as the cap.
//
// The cap is the largest IOU amount rather than `desired`: in the
// reverse pass `desired` is the step's output, and a destination
// QualityIn below one makes src->dst larger than the output, so an
// uncapped step would be reported as limiting and the re-executed
// limiting step would never agree with itself.
if (issuesToDst_ && issues(direction) &&
sb.rules().enabled(featureNoRecipientLimit))
return {
IOUAmount(STAmount::cMaxValue, STAmount::cMaxOffset), direction};
return {amount, direction};
}
std::pair<IOUAmount, DebtDirection>
@@ -441,6 +473,10 @@ DirectIPaymentStep::check(
}
}
// The destination's limit does not apply to its issuer's step into it
// (see maxFlow); a dry test against it would refuse a payment the
// recipient is allowed to receive.
if (!(issuesToDst_ && ctx.view.rules().enabled(featureNoRecipientLimit)))
{
auto const owed = creditBalance(ctx.view, dst_, src_, currency_);
if (owed <= beast::zero)

View File

@@ -564,8 +564,10 @@ Remit::doApply()
// if the target trustline doesn't exist we need to create it and
// pay its reserve
if (!sb.exists(
keylet::line(dstAccID, issuerAccID, amount.getCurrency())))
auto const lineKey =
keylet::line(dstAccID, issuerAccID, amount.getCurrency());
bool const lineExisted = sb.exists(lineKey);
if (!lineExisted)
{
if (nativeRemit + objectReserve < nativeRemit)
return tecINTERNAL;
@@ -584,6 +586,22 @@ Remit::doApply()
true);
!isTesSuccess(result))
return result;
// A line this remit created was never configured by the
// destination. Mark the destination's side as persisting so the
// line survives a zero balance until the destination clears it.
if (!lineExisted && sb.rules().enabled(featureNoRecipientLimit))
{
if (auto const sleLine = sb.peek(lineKey))
{
bool const dstHigh = dstAccID > issuerAccID;
sleLine->setFieldU32(
sfFlags,
sleLine->getFieldU32(sfFlags) |
(dstHigh ? lsfHighPersist : lsfLowPersist));
sb.update(sleLine);
}
}
}
}

View File

@@ -91,6 +91,16 @@ SetTrust::preflight(PreflightContext const& ctx)
}
}
if (uTxFlags & (tfSetPersist | tfClearPersist))
{
// Persist flags are valid only under the amendment, and not both.
if (!ctx.rules.enabled(featureNoRecipientLimit) ||
((uTxFlags & tfSetPersist) && (uTxFlags & tfClearPersist)))
{
return temINVALID_FLAG;
}
}
STAmount const saLimitAmount(tx.getFieldAmount(sfLimitAmount));
if (!isLegalNet(saLimitAmount))
@@ -342,6 +352,8 @@ SetTrust::doApply()
bool const bClearFreeze = (uTxFlags & tfClearFreeze);
bool const bSetDeepFreeze = (uTxFlags & tfSetDeepFreeze);
bool const bClearDeepFreeze = (uTxFlags & tfClearDeepFreeze);
bool const bSetPersist = (uTxFlags & tfSetPersist);
bool const bClearPersist = (uTxFlags & tfClearPersist);
auto viewJ = ctx_.app.journal("View");
@@ -508,6 +520,11 @@ SetTrust::doApply()
uFlagsOut &= ~(bHigh ? lsfHighNoRipple : lsfLowNoRipple);
}
if (bSetPersist)
uFlagsOut |= (bHigh ? lsfHighPersist : lsfLowPersist);
else if (bClearPersist)
uFlagsOut &= ~(bHigh ? lsfHighPersist : lsfLowPersist);
// Have to use lsfNoFreeze to maintain pre-deep freeze behavior
bool const bNoFreeze = sle->isFlag(lsfNoFreeze);
uFlagsOut = computeFreezeFlags(
@@ -531,14 +548,14 @@ SetTrust::doApply()
bool const bLowReserveSet = uLowQualityIn || uLowQualityOut ||
((uFlagsOut & lsfLowNoRipple) == 0) != bLowDefRipple ||
(uFlagsOut & lsfLowFreeze) || saLowLimit ||
saLowBalance > beast::zero;
(uFlagsOut & lsfLowFreeze) || (uFlagsOut & lsfLowPersist) ||
saLowLimit || saLowBalance > beast::zero;
bool const bLowReserveClear = !bLowReserveSet;
bool const bHighReserveSet = uHighQualityIn || uHighQualityOut ||
((uFlagsOut & lsfHighNoRipple) == 0) != bHighDefRipple ||
(uFlagsOut & lsfHighFreeze) || saHighLimit ||
saHighBalance > beast::zero;
(uFlagsOut & lsfHighFreeze) || (uFlagsOut & lsfHighPersist) ||
saHighLimit || saHighBalance > beast::zero;
bool const bHighReserveClear = !bHighReserveSet;
bool const bDefault = bLowReserveClear && bHighReserveClear;
@@ -621,7 +638,7 @@ SetTrust::doApply()
// setting default quality in.
(!bQualityOut || !uQualityOut) && // Not setting quality out or
// setting default quality out.
(!bSetAuth))
(!bSetAuth) && (!bSetPersist)) // Not asking the line to persist.
{
JLOG(j_.trace())
<< "Redundant: Setting non-existent ripple line to defaults.";
@@ -663,6 +680,18 @@ SetTrust::doApply()
uQualityIn,
uQualityOut,
viewJ);
if (isTesSuccess(terResult) && bSetPersist)
{
if (auto const sleLine = view().peek(k))
{
sleLine->setFieldU32(
sfFlags,
sleLine->getFieldU32(sfFlags) |
(bHigh ? lsfHighPersist : lsfLowPersist));
view().update(sleLine);
}
}
}
return terResult;

View File

@@ -1155,8 +1155,10 @@ trustTransferLockedBalance(
}
// if final is more than dest limit and tx acct is not dest acct -
// fail
if (finalBalance > dstLimit && actingAccID != dstAccID)
// fail. Under NoRecipientLimit a limit never governs the account
// receiving, whoever finishes the instrument.
if (finalBalance > dstLimit && actingAccID != dstAccID &&
!view.rules().enabled(featureNoRecipientLimit))
{
JLOG(j.trace())
<< "trustTransferLockedBalance would increase dest "

View File

@@ -1127,10 +1127,15 @@ isTrustDefault(
const auto fNoRipple{high ? lsfHighNoRipple : lsfLowNoRipple};
const auto fFreeze{high ? lsfHighFreeze : lsfLowFreeze};
const auto fPersist{high ? lsfHighPersist : lsfLowPersist};
if (tlFlags & fFreeze)
return false;
// A persisting side keeps its claim on the line at zero balance.
if (tlFlags & fPersist)
return false;
if ((acFlags & lsfDefaultRipple) && (tlFlags & fNoRipple))
return false;
@@ -1268,6 +1273,8 @@ rippleCreditIOU(
view.read(keylet::account(uSenderID))->getFlags() &
lsfDefaultRipple) &&
!(uFlags & (!bSenderHigh ? lsfLowFreeze : lsfHighFreeze)) &&
// Sender does not persist the line.
!(uFlags & (!bSenderHigh ? lsfLowPersist : lsfHighPersist)) &&
!sleRippleState->getFieldAmount(
!bSenderHigh ? sfLowLimit : sfHighLimit)
// Sender trust limit is 0.
@@ -1749,6 +1756,8 @@ updateTrustLine(
flags & (!bSenderHigh ? lsfLowNoRipple : lsfHighNoRipple)) !=
static_cast<bool>(sle->getFlags() & lsfDefaultRipple) &&
!(flags & (!bSenderHigh ? lsfLowFreeze : lsfHighFreeze)) &&
// Sender does not persist the line.
!(flags & (!bSenderHigh ? lsfLowPersist : lsfHighPersist)) &&
!state->getFieldAmount(!bSenderHigh ? sfLowLimit : sfHighLimit)
// Sender trust limit is 0.
&& !state->getFieldU32(!bSenderHigh ? sfLowQualityIn : sfHighQualityIn)