Merge remote-tracking branch 'origin/dev' into feature-export-rng

# Conflicts:
#	hook/sfcodes.h
#	include/xrpl/protocol/Feature.h
#	include/xrpl/protocol/detail/sfields.macro
This commit is contained in:
Nicholas Dudfield
2026-04-30 14:22:06 +07:00
15 changed files with 1049 additions and 40 deletions

View File

@@ -194,6 +194,7 @@
#define sfSignatureReward ((6U << 16U) + 29U)
#define sfMinAccountCreateAmount ((6U << 16U) + 30U)
#define sfLPTokenBalance ((6U << 16U) + 31U)
#define sfTrustLineRewardAccumulator ((6U << 16U) + 99U)
#define sfPublicKey ((7U << 16U) + 1U)
#define sfMessageKey ((7U << 16U) + 2U)
#define sfSigningPubKey ((7U << 16U) + 3U)
@@ -260,6 +261,7 @@
#define sfIssuingChainIssue ((24U << 16U) + 2U)
#define sfAsset ((24U << 16U) + 3U)
#define sfAsset2 ((24U << 16U) + 4U)
#define sfClaimCurrency ((24U << 16U) + 5U)
#define sfXChainBridge ((25U << 16U) + 1U)
#define sfTransactionMetaData ((14U << 16U) + 2U)
#define sfCreatedNode ((14U << 16U) + 3U)
@@ -298,7 +300,9 @@
#define sfActiveValidator ((14U << 16U) + 95U)
#define sfGenesisMint ((14U << 16U) + 96U)
#define sfRemark ((14U << 16U) + 97U)
#define sfExportResult ((14U << 16U) + 98U)
#define sfHighReward ((14U << 16U) + 98U)
#define sfLowReward ((14U << 16U) + 99U)
#define sfExportResult ((14U << 16U) + 100U)
#define sfSigners ((15U << 16U) + 3U)
#define sfSignerEntries ((15U << 16U) + 4U)
#define sfTemplate ((15U << 16U) + 5U)

View File

@@ -80,7 +80,7 @@ namespace detail {
// Feature.cpp. Because it's only used to reserve storage, and determine how
// large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than
// the actual number of amendments. A LogicError on startup will verify this.
static constexpr std::size_t numFeatures = 117;
static constexpr std::size_t numFeatures = 118;
/** Amendments that this server supports and the default voting behavior.
Whether they are enabled depends on the Rules defined in the validated

View File

@@ -31,6 +31,7 @@
// If you add an amendment here, then do not forget to increment `numFeatures`
// in include/xrpl/protocol/Feature.h.
XRPL_FEATURE(IOURewardClaim, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (IOULockedBalanceInvariant, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (ImportIssuer, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(HookAPISerializedType240, Supported::yes, VoteBehavior::DefaultNo)

View File

@@ -410,6 +410,8 @@ LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({
{sfHighQualityOut, soeOPTIONAL},
{sfLockedBalance, soeOPTIONAL},
{sfLockCount, soeOPTIONAL},
{sfHighReward, soeOPTIONAL},
{sfLowReward, soeOPTIONAL},
}))
/** The ledger object which lists the network's fee settings.

View File

@@ -262,6 +262,7 @@ TYPED_SFIELD(sfPrice, AMOUNT, 28)
TYPED_SFIELD(sfSignatureReward, AMOUNT, 29)
TYPED_SFIELD(sfMinAccountCreateAmount, AMOUNT, 30)
TYPED_SFIELD(sfLPTokenBalance, AMOUNT, 31)
TYPED_SFIELD(sfTrustLineRewardAccumulator,AMOUNT, 99)
// variable length (common)
TYPED_SFIELD(sfPublicKey, VL, 1)
@@ -345,6 +346,7 @@ TYPED_SFIELD(sfLockingChainIssue, ISSUE, 1)
TYPED_SFIELD(sfIssuingChainIssue, ISSUE, 2)
TYPED_SFIELD(sfAsset, ISSUE, 3)
TYPED_SFIELD(sfAsset2, ISSUE, 4)
TYPED_SFIELD(sfClaimCurrency, ISSUE, 5)
// bridge
TYPED_SFIELD(sfXChainBridge, XCHAIN_BRIDGE, 1)
@@ -392,7 +394,9 @@ UNTYPED_SFIELD(sfImportVLKey, OBJECT, 94)
UNTYPED_SFIELD(sfActiveValidator, OBJECT, 95)
UNTYPED_SFIELD(sfGenesisMint, OBJECT, 96)
UNTYPED_SFIELD(sfRemark, OBJECT, 97)
UNTYPED_SFIELD(sfExportResult, OBJECT, 98)
UNTYPED_SFIELD(sfHighReward, OBJECT, 98)
UNTYPED_SFIELD(sfLowReward, OBJECT, 99)
UNTYPED_SFIELD(sfExportResult, OBJECT, 100)
// array of objects (common)
// ARRAY/1 is reserved for end of array

View File

@@ -561,6 +561,7 @@ TRANSACTION(ttIMPORT, 97, Import, ({
* from a specified hook */
TRANSACTION(ttCLAIM_REWARD, 98, ClaimReward, ({
{sfIssuer, soeOPTIONAL},
{sfClaimCurrency, soeOPTIONAL},
}))
/** This transaction invokes a hook, providing arbitrary data. Essentially as a 0 drop payment. **/

View File

@@ -254,6 +254,24 @@ InnerObjectFormats::InnerObjectFormats()
{sfIssuer, soeREQUIRED},
{sfCredentialType, soeREQUIRED},
});
add(sfHighReward.jsonName,
sfHighReward.getCode(),
{
{sfRewardLgrFirst, soeREQUIRED},
{sfRewardLgrLast, soeREQUIRED},
{sfRewardTime, soeREQUIRED},
{sfTrustLineRewardAccumulator, soeREQUIRED},
});
add(sfLowReward.jsonName,
sfLowReward.getCode(),
{
{sfRewardLgrFirst, soeREQUIRED},
{sfRewardLgrLast, soeREQUIRED},
{sfRewardTime, soeREQUIRED},
{sfTrustLineRewardAccumulator, soeREQUIRED},
});
}
InnerObjectFormats const&

View File

@@ -27,6 +27,14 @@ namespace ripple {
namespace test {
struct ClaimReward_test : public beast::unit_test::suite
{
private:
// helper
void static overrideFlag(Json::Value& jv)
{
jv[jss::Flags] = hsfOVERRIDE;
}
public:
bool
expectRewards(
jtx::Env const& env,
@@ -60,6 +68,52 @@ struct ClaimReward_test : public beast::unit_test::suite
return true;
}
bool
expectRewardsIOU(
jtx::Env const& env,
jtx::Account const& acct,
jtx::IOU const& iou,
std::uint32_t ledgerFirst,
std::uint32_t ledgerLast,
STAmount accumulator,
std::uint32_t time)
{
auto const sle = env.le(keylet::line(acct, iou.account, iou.currency));
BEAST_EXPECT(!!sle);
auto const& sfRewardField =
std::minmax(acct.id(), iou.account.id()).first == acct.id()
? sfLowReward
: sfHighReward;
if (!sle->isFieldPresent(sfRewardField))
return false;
auto const& reward =
static_cast<STObject const&>(sle->peekAtField(sfRewardField));
if (!reward.isFieldPresent(sfRewardLgrFirst) ||
reward.getFieldU32(sfRewardLgrFirst) != ledgerFirst)
{
return false;
}
if (!reward.isFieldPresent(sfRewardLgrLast) ||
reward.getFieldU32(sfRewardLgrLast) != ledgerLast)
{
return false;
}
if (!reward.isFieldPresent(sfTrustLineRewardAccumulator) ||
reward.getFieldAmount(sfTrustLineRewardAccumulator) != accumulator)
{
return false;
}
if (!reward.isFieldPresent(sfRewardTime) ||
reward.getFieldU32(sfRewardTime) != time)
{
return false;
}
return true;
}
bool
expectNoRewards(jtx::Env const& env, jtx::Account const& acct)
{
@@ -83,6 +137,24 @@ struct ClaimReward_test : public beast::unit_test::suite
return true;
}
bool
expectNoRewardsIOU(
jtx::Env const& env,
jtx::Account const& acct,
jtx::IOU const& iou)
{
auto const sle = env.le(keylet::line(acct, iou.account, iou.currency));
BEAST_EXPECT(!!sle);
auto const& sfRewardField =
std::minmax(acct.id(), iou.account.id()).first == acct.id()
? sfLowReward
: sfHighReward;
if (sle->isFieldPresent(sfRewardField))
return false;
return true;
}
void
testEnabled(FeatureBitset features)
{
@@ -92,7 +164,7 @@ struct ClaimReward_test : public beast::unit_test::suite
// setup env
auto const alice = Account("alice");
auto const issuer = Account("issuer");
auto const issuer = Account::master;
for (bool const withClaimReward : {false, true})
{
@@ -102,7 +174,11 @@ struct ClaimReward_test : public beast::unit_test::suite
withClaimReward ? features : features - featureBalanceRewards;
Env env{*this, amend};
env.fund(XRP(1000), alice, issuer);
env.fund(XRP(1000), alice);
env.close();
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0),
fee(XRP(1)));
env.close();
auto const txResult =
@@ -119,7 +195,10 @@ struct ClaimReward_test : public beast::unit_test::suite
.count();
// CLAIM
env(reward::claim(alice), reward::issuer(issuer), txResult);
env(reward::claim(alice),
reward::issuer(issuer),
fee(XRP(1)),
txResult);
env.close();
if (withClaimReward)
@@ -195,14 +274,19 @@ struct ClaimReward_test : public beast::unit_test::suite
Env env{*this, amend};
auto const alice = Account("alice");
auto const issuer = Account("issuer");
auto const issuer = Account::master;
env.fund(XRP(1000), alice, issuer);
env.fund(XRP(1000), alice);
env.close();
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0),
fee(XRP(1)));
env.close();
auto tx = reward::claim(alice);
env(tx,
reward::issuer(issuer),
fee(XRP(1)),
txflags(tfFullyCanonicalSig),
withFixFlags ? ter(tesSUCCESS) : ter(temINVALID_FLAG));
env.close();
@@ -221,6 +305,105 @@ struct ClaimReward_test : public beast::unit_test::suite
env(reward::claim(alice), reward::issuer(alice), ter(temMALFORMED));
env.close();
}
// featureIOURewardClaim
// temDISABLED
// featureIOURewardClaim amendment is disabled
{
test::jtx::Env env{
*this,
network::makeNetworkConfig(21337),
features - featureIOURewardClaim};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const gw = Account("gw");
env.fund(XRP(1000), alice, bob, gw);
env.close();
jtx::IOU const USD = gw["USD"];
env(reward::claim(alice),
reward::issuer(bob),
reward::claimCurrency(USD),
ter(temDISABLED));
env.close();
}
// temMALFORMED
// ClaimCurrency.account cannot be the source account.
{
test::jtx::Env env{*this, network::makeNetworkConfig(21337)};
auto const alice = Account("alice");
auto const bob = Account("bob");
env.fund(XRP(1000), alice);
env.close();
jtx::IOU const USD = alice["USD"];
env(reward::claim(alice),
reward::issuer(bob),
reward::claimCurrency(USD),
ter(temMALFORMED));
env.close();
}
// temMALFORMED
// Issuer cannot be Genesis account if ClaimCurrency is set.
{
test::jtx::Env env{*this, network::makeNetworkConfig(21337)};
auto const alice = Account("alice");
auto const gw = Account("gw");
env.fund(XRP(1000), alice, gw);
env.close();
jtx::IOU const USD = gw["USD"];
env(reward::claim(alice),
reward::issuer(Account::master),
reward::claimCurrency(USD),
ter(temBAD_ISSUER));
}
// MPT
{
// tested in testMPTInvalidInTx() at MPToken_test.cpp
}
// XAH RewardClaim: Issuer must be the Genesis account if
// featureXahauGenesis and featureIOURewardClaim are enabled.
for (bool const withIOURewardClaim : {false, true})
{
auto const amend = withIOURewardClaim
? features
: features - featureIOURewardClaim;
auto const alice = Account("alice");
auto const badIssuer = Account("gw");
auto const issuer = Account::master;
auto const USD = badIssuer["USD"];
Env env{*this, amend};
env.fund(XRP(1000), alice, badIssuer);
env.close();
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0),
fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(badIssuer),
fee(XRP(1)),
withIOURewardClaim ? ter(temBAD_ISSUER) : ter(tesSUCCESS));
env(reward::claim(alice),
reward::issuer(issuer),
fee(XRP(1)),
ter(tesSUCCESS));
}
}
void
@@ -244,6 +427,7 @@ struct ClaimReward_test : public beast::unit_test::suite
auto const alice = Account("alice");
auto const issuer = Account("issuer");
env.memoize(alice);
auto USD = issuer["USD"];
env.fund(XRP(1000), issuer);
env.close();
@@ -251,7 +435,10 @@ struct ClaimReward_test : public beast::unit_test::suite
auto tx = reward::claim(alice);
tx[jss::Sequence] = 0;
tx[jss::Fee] = 10;
env(tx, reward::issuer(issuer), ter(terNO_ACCOUNT));
env(tx,
reward::issuer(issuer),
reward::claimCurrency(USD),
ter(terNO_ACCOUNT));
env.close();
}
@@ -261,9 +448,9 @@ struct ClaimReward_test : public beast::unit_test::suite
test::jtx::Env env{*this, network::makeNetworkConfig(21337)};
auto const alice = Account("alice");
auto const issuer = Account("issuer");
auto const issuer = Account::master;
env.fund(XRP(1000), alice, issuer);
env.fund(XRP(1000), alice);
env.close();
env(reward::claim(alice),
@@ -294,11 +481,16 @@ struct ClaimReward_test : public beast::unit_test::suite
auto const issuer = Account("issuer");
env.memoize(issuer);
auto USD = issuer["USD"];
env.fund(XRP(1000), alice);
env.close();
auto tx = reward::claim(alice);
env(tx, reward::issuer(issuer), ter(tecNO_ISSUER));
env(tx,
reward::issuer(issuer),
reward::claimCurrency(USD),
ter(tecNO_ISSUER));
env.close();
}
@@ -320,9 +512,162 @@ struct ClaimReward_test : public beast::unit_test::suite
env(reward::claim(alice),
reward::issuer(amm.ammAccount()),
reward::claimCurrency(USD),
ter(tecNO_PERMISSION));
env.close();
}
// tecNO_TARGET
// no claim reward hook
{
Env env{*this};
auto const alice = Account("alice");
auto const issuer = Account::master;
env.fund(XRP(1000), alice);
env.close();
// Doesn't have hook
{
env(reward::claim(alice),
reward::issuer(issuer),
ter(tecNO_TARGET));
env.close();
}
// Invalid HookOn
{
auto hookObj = hso(jtx::genesis::AcceptHook, overrideFlag);
hookObj[jss::HookOn] = to_string(UINT256_BIT[ttCLAIM_REWARD]);
env(hook(issuer, {{hookObj}}, 0), fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(issuer),
ter(tecNO_TARGET));
env.close();
}
// Invalid IncomingHookOn
{
auto hookObj = hso(jtx::genesis::AcceptHook, overrideFlag);
hookObj.removeMember(jss::HookOn);
hookObj[jss::HookOnIncoming] =
to_string(UINT256_BIT[ttCLAIM_REWARD]);
hookObj[jss::HookOnOutgoing] = to_string(uint256{});
env(hook(issuer, {{hookObj}}, 0), fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(issuer),
ter(tecNO_TARGET));
}
// Vaild HookOn
{
auto hookObj = hso(jtx::genesis::AcceptHook, overrideFlag);
hookObj[jss::HookOn] = to_string(~UINT256_BIT[ttCLAIM_REWARD]);
env(hook(issuer, {{hookObj}}, 0), fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(issuer),
fee(XRP(1)),
ter(tesSUCCESS));
}
// Vaild IncomingHookOn
{
auto hookObj = hso(jtx::genesis::AcceptHook, overrideFlag);
hookObj.removeMember(jss::HookOn);
hookObj[jss::HookOnIncoming] =
to_string(~UINT256_BIT[ttCLAIM_REWARD]);
hookObj[jss::HookOnOutgoing] = to_string(uint256{});
env(hook(issuer, {{hookObj}}, 0), fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(issuer),
fee(XRP(1)),
ter(tesSUCCESS));
}
// Invalid Hooks Array
{
auto hookObj = hso(jtx::genesis::AcceptHook, overrideFlag);
hookObj[jss::HookOn] = to_string(UINT256_BIT[ttCLAIM_REWARD]);
env(hook(
issuer,
{{
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
}},
0),
fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(issuer),
fee(XRP(1)),
ter(tecNO_TARGET));
}
// Vaild Hooks Array
{
auto hookObj = hso(jtx::genesis::AcceptHook, overrideFlag);
hookObj[jss::HookOn] = to_string(UINT256_BIT[ttCLAIM_REWARD]);
auto hookObj2 = hso(jtx::genesis::AcceptHook, overrideFlag);
hookObj2[jss::HookOn] = to_string(~UINT256_BIT[ttCLAIM_REWARD]);
env(hook(
issuer,
{{
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj,
hookObj2,
}},
0),
fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(issuer),
fee(XRP(1)),
ter(tesSUCCESS));
}
}
// tecNO_LINE
// trustline does not exist.
{
test::jtx::Env env{*this, network::makeNetworkConfig(21337)};
auto const alice = Account("alice");
auto const gw = Account("gw");
env.fund(XRP(1000), alice, gw);
env.close();
env(hook(gw, {{hso(jtx::genesis::AcceptHook)}}, 0), fee(XRP(1)));
env.close();
jtx::IOU const USD = gw["USD"];
env(reward::claim(alice),
reward::issuer(gw),
reward::claimCurrency(USD),
fee(XRP(1)),
ter(tecNO_LINE));
}
}
void
@@ -335,9 +680,17 @@ struct ClaimReward_test : public beast::unit_test::suite
test::jtx::Env env{*this, network::makeNetworkConfig(21337)};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const gw = Account("gw");
auto const issuer = Account("issuer");
env.fund(XRP(1000), alice, issuer);
env.fund(XRP(1000), alice, bob, gw, issuer);
env.close();
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0), fee(XRP(1)));
env.close();
env(hook(Account::master, {{hso(jtx::genesis::AcceptHook)}}, 0),
fee(XRP(1)));
env.close();
// test claim rewards - no opt out
@@ -352,7 +705,7 @@ struct ClaimReward_test : public beast::unit_test::suite
.count();
auto tx = reward::claim(alice);
env(tx, reward::issuer(issuer), ter(tesSUCCESS));
env(tx, reward::issuer(Account::master), fee(XRP(1)), ter(tesSUCCESS));
env.close();
BEAST_EXPECT(
@@ -365,6 +718,51 @@ struct ClaimReward_test : public beast::unit_test::suite
env.close();
BEAST_EXPECT(expectNoRewards(env, alice) == true);
// test iou claim rewards
{
// set trustline
env(trust(bob, gw["USD"](10000)));
env.close();
// opt in
auto const currentLedger = env.current()->seq();
auto const currentTime =
std::chrono::duration_cast<std::chrono::seconds>(
env.app()
.getLedgerMaster()
.getValidatedLedger()
->info()
.parentCloseTime.time_since_epoch())
.count();
auto tx = reward::claim(bob);
env(tx,
reward::issuer(issuer),
reward::claimCurrency(gw["USD"]),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
BEAST_EXPECT(
expectRewardsIOU(
env,
bob,
gw["USD"],
currentLedger,
currentLedger,
gw["USD"](0),
currentTime) == true);
// opt out
env(reward::claim(bob),
reward::claimCurrency(gw["USD"]),
txflags(tfOptOut),
ter(tesSUCCESS));
env.close();
BEAST_EXPECT(expectNoRewardsIOU(env, bob, gw["USD"]) == true);
}
}
void
@@ -375,16 +773,20 @@ struct ClaimReward_test : public beast::unit_test::suite
using namespace std::literals::chrono_literals;
Env env{*this, features};
auto const alice = Account("alice");
auto const issuer = Account("issuer");
env.fund(XRP(10000), alice, issuer);
auto const issuer = Account::master;
env.fund(XRP(10000), alice);
std::uint32_t aliceTicketSeq{env.seq(alice) + 1};
env(ticket::create(alice, 10));
std::uint32_t const aliceSeq{env.seq(alice)};
env.require(owners(alice, 10));
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0), fee(XRP(1)));
env.close();
env(reward::claim(alice),
reward::issuer(issuer),
ticket::use(aliceTicketSeq++),
fee(XRP(1)),
ter(tesSUCCESS));
env.require(tickets(alice, env.seq(alice) - aliceTicketSeq));
@@ -392,6 +794,317 @@ struct ClaimReward_test : public beast::unit_test::suite
env.require(owners(alice, 9));
}
void
testBalanceChanges(FeatureBitset features)
{
testcase("balance changes");
using namespace jtx;
using namespace std::literals::chrono_literals;
auto const getCurrentTime = [&](Env& env) {
return std::chrono::duration_cast<std::chrono::seconds>(
env.app()
.getLedgerMaster()
.getValidatedLedger()
->info()
.parentCloseTime.time_since_epoch())
.count();
};
// Native Reward Claim
{
Env env{*this, features};
auto const alice = Account("alice");
auto const gw = Account("gw");
auto const issuer = Account::master;
env.fund(XRP(10001), alice, gw);
env.close();
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0),
fee(XRP(1)));
env.close();
auto const currentTime = getCurrentTime(env);
auto const currentLedger = env.current()->seq();
env(reward::claim(alice), reward::issuer(issuer), fee(XRP(1)));
env.close();
env(fset(alice, 0));
env.close();
BEAST_EXPECT(
expectRewards(
env,
alice,
currentLedger,
currentLedger + 1,
10000, // 10000 XAH * time 1
currentTime) == true);
}
// IOU Reward Claim
for (bool const fromHighAccount : {true, false})
{
Env env{*this, features};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const issuer = Account("issuer");
auto const user = fromHighAccount ? alice : bob;
auto const gw = fromHighAccount ? bob : alice;
if (fromHighAccount)
BEAST_EXPECT(user.id() < gw.id());
else
BEAST_EXPECT(user.id() > gw.id());
env.fund(XRP(10000), user, gw, issuer);
env(fset(gw, asfDefaultRipple));
auto hookObj = hso(jtx::genesis::AcceptHook);
hookObj[jss::HookOn] = to_string(~UINT256_BIT[ttCLAIM_REWARD]);
env(hook(gw, {{hookObj}}, 0), fee(XRP(1)));
env.close();
env(trust(user, gw["USD"](1000000)), fee(XRP(1)));
env.close();
env(pay(gw, user, gw["USD"](10000)));
env.close();
auto currentTime = getCurrentTime(env);
auto currentLedger = env.current()->seq();
env(reward::claim(user),
reward::issuer(gw),
reward::claimCurrency(gw["USD"]),
fee(XRP(1)));
env.close();
env(pay(user, gw, gw["USD"](10000)));
env.close();
BEAST_EXPECT(
expectRewardsIOU(
env,
user,
gw["USD"],
currentLedger,
currentLedger + 1,
user["USD"](10000), // 10000 USD * time 1
currentTime) == true);
env(pay(gw, user, gw["USD"](1)));
env.close();
// check Balance == 0
BEAST_EXPECT(
expectRewardsIOU(
env,
user,
gw["USD"],
currentLedger,
currentLedger + 2,
user["USD"](10000), // 10000 USD * time 1 + 0 USD * time 1
currentTime) == true);
}
// Check Balance minus -> plus, plus -> minus
for (bool const fromHighAccount : {true, false})
{
Env env{*this, features};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const issuer = Account("issuer");
auto const user = fromHighAccount ? alice : bob;
auto const gw = fromHighAccount ? bob : alice;
if (fromHighAccount)
BEAST_EXPECT(user.id() < gw.id());
else
BEAST_EXPECT(user.id() > gw.id());
env.fund(XRP(10000), user, gw, issuer);
env(fset(gw, asfDefaultRipple));
env.close();
auto hookObj = hso(jtx::genesis::AcceptHook);
hookObj[jss::HookOn] = to_string(~UINT256_BIT[ttCLAIM_REWARD]);
env(hook(gw, {{hookObj}}, 0), fee(XRP(1)));
env.close();
env(trust(user, gw["USD"](1000000)));
env.close();
env(trust(gw, user["USD"](1000000)));
env(pay(gw, user, gw["USD"](10000)));
env.close();
auto currentTime = getCurrentTime(env);
auto currentLedger = env.current()->seq();
env(reward::claim(user),
reward::issuer(gw),
reward::claimCurrency(gw["USD"]),
fee(XRP(1)));
env.close();
env(pay(user, gw, gw["USD"](20000)));
env.close();
env(pay(user, gw, gw["USD"](1)));
env.close();
BEAST_EXPECT(
expectRewardsIOU(
env,
user,
gw["USD"],
currentLedger,
currentLedger + 2,
user["USD"](10000), // 10000 USD * time 1 + 0 USD * time 1
currentTime) == true);
}
// test with escrow (locked balance)
for (bool const fromHighAccount : {true, false})
{
for (bool const hasEscrow : {true, false})
{
Env env{*this, features};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const issuer = Account("issuer");
auto const user = fromHighAccount ? alice : bob;
auto const gw = fromHighAccount ? bob : alice;
if (fromHighAccount)
BEAST_EXPECT(user.id() < gw.id());
else
BEAST_EXPECT(user.id() > gw.id());
env.fund(XRP(10000), user, gw, issuer);
env(fset(gw, asfDefaultRipple));
auto hookObj = hso(jtx::genesis::AcceptHook);
hookObj[jss::HookOn] = to_string(~UINT256_BIT[ttCLAIM_REWARD]);
env(hook(gw, {{hookObj}}, 0), fee(XRP(1)));
env.close();
env(trust(user, gw["USD"](1000000)), fee(XRP(1)));
env.close();
env(pay(gw, user, gw["USD"](10000)));
env.close();
if (hasEscrow)
{
env(escrow(user, user, gw["USD"](2000)),
finish_time(env.now() + 1s),
fee(XRP(1)));
env.close();
}
auto currentTime = getCurrentTime(env);
auto currentLedger = env.current()->seq();
env(reward::claim(user),
reward::issuer(gw),
reward::claimCurrency(gw["USD"]),
fee(XRP(1)));
env.close();
env(pay(user, gw, gw["USD"](5000)));
env.close();
BEAST_EXPECT(
expectRewardsIOU(
env,
user,
gw["USD"],
currentLedger,
currentLedger + 1,
user["USD"](10000), // 10000 USD * time 1
currentTime) == true);
env(pay(gw, user, gw["USD"](1)));
env.close();
// check Balance == 0
BEAST_EXPECT(
expectRewardsIOU(
env,
user,
gw["USD"],
currentLedger,
currentLedger + 2,
user["USD"](
15000), // 10000 USD * time 1 + 5000 USD * time 1
currentTime) == true);
}
}
// STAmount overflow in reward accumulation should not cause
// transaction failure (tefEXCEPTION). The overflow should be
// gracefully skipped via try-catch.
for (bool const fromHighAccount : {true, false})
{
Env env{*this, features};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const issuer = Account("issuer");
auto const user = fromHighAccount ? alice : bob;
auto const gw = fromHighAccount ? bob : alice;
env.fund(XRP(10000), user, gw, issuer);
env(fset(gw, asfDefaultRipple));
env.close();
auto hookObj = hso(jtx::genesis::AcceptHook);
hookObj[jss::HookOn] = to_string(~UINT256_BIT[ttCLAIM_REWARD]);
env(hook(gw, {{hookObj}}, 0), fee(XRP(1)));
env.close();
// Use a near-max IOU balance at exponent 80. When
// multiply(balance, STAmount(lgrElapsed), issue) is called
// with lgrElapsed >= 2, the result exponent exceeds
// cMaxOffset(80), causing IOUAmount::normalize to throw
// std::overflow_error("value overflow").
auto const bigUSD = STAmount{
gw["USD"].issue(), std::uint64_t(5000000000000000ull), 80};
// Payment amount must be large enough to register a
// balance change given STAmount's 16-digit precision.
auto const payBackUSD = STAmount{
gw["USD"].issue(), std::uint64_t(1000000000000000ull), 80};
env(trust(user, bigUSD));
env.close();
env(pay(gw, user, bigUSD));
env.close();
// Claim IOU reward to initialize reward tracking
env(reward::claim(user),
reward::issuer(gw),
reward::claimCurrency(gw["USD"]),
fee(XRP(1)));
env.close();
// Advance ledger so lgrElapsed >= 2. With lgrElapsed=1
// the multiply result is exactly at cMaxOffset boundary
// (no overflow). With lgrElapsed >= 2, the result exponent
// exceeds cMaxOffset and triggers the overflow.
env.close();
// This payment modifies the trustline balance, triggering
// reward accumulation in Transactor. Without the try-catch
// fix, multiply() throws std::overflow_error("value overflow")
// and the transaction fails with tefEXCEPTION.
env(pay(user, gw, payBackUSD), ter(tesSUCCESS));
env.close();
}
}
void
testWithFeats(FeatureBitset features)
{
@@ -400,6 +1113,7 @@ struct ClaimReward_test : public beast::unit_test::suite
testInvalidPreclaim(features);
testValidNoHook(features);
testUsingTickets(features);
testBalanceChanges(features);
}
public:

View File

@@ -1878,6 +1878,13 @@ class MPToken_test : public beast::unit_test::suite
[jss::Amount] = mpt.getJson(JsonOptions::none);
test(jv, sfAmounts.jsonName.c_str());
}
// ClaimReward
{
Json::Value jv = reward::claim(alice);
jv[sfClaimCurrency.jsonName][jss::mpt_issuance_id] =
to_string(issue);
test(jv, sfClaimCurrency.jsonName.c_str());
}
}
for (const auto& str : txWithAmounts)
printf("%s\n", str.c_str());

View File

@@ -1647,8 +1647,12 @@ private:
features};
auto const account = Account("alice");
auto const issuer = Account("issuer");
env.fund(XRP(1000), account, issuer);
auto const issuer = Account::master;
env.fund(XRP(1000), account);
env.close();
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0),
fee(XRP(1)));
env.close();
// set tsh collect
@@ -1680,8 +1684,8 @@ private:
features};
auto const account = Account("alice");
auto const issuer = Account("issuer");
env.fund(XRP(1000), account, issuer);
auto const issuer = Account::master;
env.fund(XRP(1000), account);
env.close();
// set tsh collect

View File

@@ -207,19 +207,25 @@ private:
test::jtx::Env env{*this, envconfig(), features};
auto const alice = Account("alice");
auto const issuer = Account("issuer");
env.fund(XRP(1000), alice, issuer);
auto const issuer = Account::master;
env.fund(XRP(1000), alice);
env.close();
env(hook(issuer, {{hso(jtx::genesis::AcceptHook)}}, 0), fee(XRP(1)));
env.close();
// claim reward
env(reward::claim(alice), reward::issuer(issuer), ter(tesSUCCESS));
env(reward::claim(alice),
reward::issuer(issuer),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
// verify touch
validateTouch(env, alice, {"ClaimReward", "tesSUCCESS"});
auto const tt = env.current()->rules().enabled(featureTouch)
? "ClaimReward"
: "AccountSet";
: "SetHook";
validateTouch(env, issuer, {tt, "tesSUCCESS"});
}

View File

@@ -49,6 +49,13 @@ issuer::operator()(Env& env, JTx& jt) const
jt.jv[sfIssuer.jsonName] = to_string(issuer_);
}
void
claimCurrency::operator()(Env& env, JTx& jt) const
{
jt.jv[sfClaimCurrency.jsonName] =
STIssue{sfClaimCurrency, claimCurrency_}.getJson(JsonOptions::none);
}
} // namespace reward
} // namespace jtx

View File

@@ -56,6 +56,21 @@ public:
operator()(Env&, JTx& jtx) const;
};
/** Sets the optional ClaimCurrency on a JTx. */
class claimCurrency
{
private:
Issue claimCurrency_;
public:
explicit claimCurrency(Issue const& issue) : claimCurrency_(issue)
{
}
void
operator()(Env&, JTx& jtx) const;
};
} // namespace reward
} // namespace jtx

View File

@@ -17,6 +17,7 @@
*/
//==============================================================================
#include <xrpld/app/hook/applyHook.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/tx/detail/ClaimReward.h>
#include <xrpld/core/Config.h>
@@ -60,6 +61,57 @@ ClaimReward::preflight(PreflightContext const& ctx)
return temMALFORMED;
}
if (ctx.tx.isFieldPresent(sfClaimCurrency))
{
// IOU RewardClaim
if (!ctx.rules.enabled(featureIOURewardClaim))
return temDISABLED;
auto const claimAsset = ctx.tx[sfClaimCurrency];
bool const isMPT = claimAsset.holds<MPTIssue>();
if (isMPT)
{
JLOG(ctx.j.debug()) << "ClaimReward: MPT is not supported yet.";
return temMALFORMED;
}
auto const claimIssue = claimAsset.get<Issue>();
if (claimIssue.account == beast::zero || isXRP(claimIssue.currency))
return temMALFORMED;
if (claimIssue.account == ctx.tx.getAccountID(sfAccount))
return temMALFORMED;
}
if (ctx.rules.enabled(featureXahauGenesis) &&
ctx.rules.enabled(featureIOURewardClaim) &&
ctx.tx.isFieldPresent(sfIssuer))
{
static auto const genesisAccountId = calcAccountID(
generateKeyPair(
KeyType::secp256k1, generateSeed("masterpassphrase"))
.first);
auto const issuer = ctx.tx.getAccountID(sfIssuer);
if (ctx.tx.isFieldPresent(sfClaimCurrency))
{
if (issuer == genesisAccountId)
{
JLOG(ctx.j.debug()) << "ClaimReward (IOU): Issuer cannot "
"be the Genesis account";
return temBAD_ISSUER;
}
}
else
{
if (issuer != genesisAccountId)
{
JLOG(ctx.j.debug()) << "ClaimReward (XAH): Issuer must be "
"the Genesis account";
return temBAD_ISSUER;
}
}
}
return preflight2(ctx);
}
@@ -90,8 +142,53 @@ ClaimReward::preclaim(PreclaimContext const& ctx)
if (sleIssuer->isFieldPresent(sfAMMID))
return tecNO_PERMISSION;
if (ctx.view.rules().enabled(featureIOURewardClaim))
{
auto const& sleHook = ctx.view.read(keylet::hook(*issuer));
if (!sleHook || !sleHook->isFieldPresent(sfHooks) ||
sleHook->getFieldArray(sfHooks).empty())
return tecNO_TARGET;
bool hasClaimRewardHook = false;
auto const& hooks = sleHook->getFieldArray(sfHooks);
for (auto const& hook : hooks)
{
if (!hook.isFieldPresent(sfHookHash))
return tefINTERNAL; // LCOV_EXCL_LINE
auto const& hash = hook.getFieldH256(sfHookHash);
auto const& sleDef =
ctx.view.read(keylet::hookDefinition(hash));
if (!sleDef)
return tefINTERNAL; // LCOV_EXCL_LINE
auto const& hookOn =
hook::getHookOn(hook, sleDef, sfHookOnIncoming);
if (hook::canHook(ttCLAIM_REWARD, hookOn))
{
hasClaimRewardHook = true;
break;
}
}
if (!hasClaimRewardHook)
return tecNO_TARGET;
}
}
if (ctx.tx.isFieldPresent(sfClaimCurrency))
{
auto const claimCurrency = ctx.tx[sfClaimCurrency];
bool const isMPT = claimCurrency.holds<MPTIssue>();
if (isMPT)
return tefINTERNAL;
auto const claimIssue = claimCurrency.get<Issue>();
if (!ctx.view.exists(
keylet::line(id, claimIssue.account, claimIssue.currency)))
return tecNO_LINE;
}
return tesSUCCESS;
}
@@ -105,6 +202,61 @@ ClaimReward::doApply()
std::optional<uint32_t> flags = ctx_.tx[~sfFlags];
bool isOptOut = flags && *flags == tfOptOut;
uint32_t lgrCur = view().seq();
uint32_t lgrFirst = lgrCur;
uint32_t lgrLast = lgrCur;
uint64_t accumulator = 0ULL;
uint32_t rewardTime = std::chrono::duration_cast<std::chrono::seconds>(
ctx_.app.getLedgerMaster()
.getValidatedLedger()
->info()
.parentCloseTime.time_since_epoch())
.count();
if (ctx_.tx.isFieldPresent(sfClaimCurrency))
{
auto const claimCurrency = ctx_.tx[sfClaimCurrency];
bool const isMPT = claimCurrency.holds<MPTIssue>();
if (isMPT)
return tefINTERNAL;
auto const claimIssue = claimCurrency.get<Issue>();
auto lineSle = view().peek(
keylet::line(account_, claimIssue.account, claimIssue.currency));
if (!lineSle)
return tefINTERNAL;
bool const isHigh = account_ > claimIssue.account;
auto const& rewardField = isHigh ? sfHighReward : sfLowReward;
if (isOptOut)
{
if (lineSle->isFieldPresent(rewardField))
lineSle->makeFieldAbsent(rewardField);
}
else
{
auto const& rewardAccumulatorField =
lineSle->getFieldAmount(isHigh ? sfHighLimit : sfLowLimit)
.zeroed();
// all actual rewards are handled by the hook on the sfIssuer
// the tt just resets the counters
auto& reward = lineSle->peekFieldObject(rewardField);
reward.setFieldU32(sfRewardLgrFirst, lgrFirst);
reward.setFieldU32(sfRewardLgrLast, lgrLast);
reward.setFieldAmount(
sfTrustLineRewardAccumulator, rewardAccumulatorField);
reward.setFieldU32(sfRewardTime, rewardTime);
}
view().update(lineSle);
return tesSUCCESS;
}
if (isOptOut)
{
if (sle->isFieldPresent(sfRewardLgrFirst))
@@ -120,18 +272,10 @@ ClaimReward::doApply()
{
// all actual rewards are handled by the hook on the sfIssuer
// the tt just resets the counters
uint32_t lgrCur = view().seq();
sle->setFieldU32(sfRewardLgrFirst, lgrCur);
sle->setFieldU32(sfRewardLgrLast, lgrCur);
sle->setFieldU64(sfRewardAccumulator, 0ULL);
sle->setFieldU32(
sfRewardTime,
std::chrono::duration_cast<std::chrono::seconds>(
ctx_.app.getLedgerMaster()
.getValidatedLedger()
->info()
.parentCloseTime.time_since_epoch())
.count());
sle->setFieldU32(sfRewardLgrFirst, lgrFirst);
sle->setFieldU32(sfRewardLgrLast, lgrLast);
sle->setFieldU64(sfRewardAccumulator, accumulator);
sle->setFieldU32(sfRewardTime, rewardTime);
}
view().update(sle);

View File

@@ -2152,7 +2152,8 @@ Transactor::operator()()
bool const has240819 = view().rules().enabled(fix240819);
bool const has240911 = view().rules().enabled(fix240911);
bool const hasIOURewardClaim =
view().rules().enabled(featureIOURewardClaim);
auto const& sfRewardFields =
*(ripple::SField::knownCodeToField.at(917511 - has240819));
@@ -2162,11 +2163,92 @@ Transactor::operator()()
SField const& metaType = node.getFName();
uint16_t nodeType = node.getFieldU16(sfLedgerEntryType);
// we only care about ltACCOUNT_ROOT objects being modified or
// created
if (nodeType != ltACCOUNT_ROOT || metaType == sfDeletedNode)
// we only care about ltACCOUNT_ROOT and ltRIPPLE_STATE objects
// being modified or created
if ((nodeType != ltACCOUNT_ROOT && nodeType != ltRIPPLE_STATE) ||
metaType == sfDeletedNode)
continue;
// ltRippleState
if (nodeType == ltRIPPLE_STATE)
{
if (!hasIOURewardClaim)
continue;
if (!node.isFieldPresent(sfPreviousFields) ||
!node.isFieldPresent(sfLedgerIndex))
continue;
auto sle = view().peek(
Keylet{ltRIPPLE_STATE, node.getFieldH256(sfLedgerIndex)});
if (!sle)
continue;
STObject& previousFields = (const_cast<STObject&>(node))
.getField(sfPreviousFields)
.downcast<STObject>();
if (!previousFields.isFieldPresent(sfBalance))
continue;
auto balance = previousFields.getFieldAmount(sfBalance);
if (balance.native())
continue;
SField const* sfRewardFields[] = {&sfLowReward, &sfHighReward};
for (auto const* sfRewardFieldPtr : sfRewardFields)
{
auto const& sfRewardField = *sfRewardFieldPtr;
if (!sle->isFieldPresent(sfRewardField))
continue;
auto balance_ = balance;
if (sfRewardField == sfHighReward)
balance_.negate();
if (balance_.negative())
balance_.clear();
auto& reward = sle->peekFieldObject(sfRewardField);
uint32_t lgrLast = reward.getFieldU32(sfRewardLgrLast);
uint32_t lgrElapsed = lgrCur - lgrLast;
// update even in cases such as overflow or underflow.
reward.setFieldU32(sfRewardLgrLast, lgrCur);
// overflow safety
if (lgrElapsed > lgrCur || lgrElapsed == 0)
continue;
auto accum =
reward.getFieldAmount(sfTrustLineRewardAccumulator);
STAmount accumNew;
try
{
accumNew = accum +
multiply(balance_,
STAmount(((uint64_t)lgrElapsed)),
balance_.issue());
}
catch (std::exception const&)
{
// Overflow detected, skip this reward calculation
continue;
}
// check for overflow(<) and underflow(=)
if (accumNew <= accum)
continue;
reward.setFieldAmount(
sfTrustLineRewardAccumulator, accumNew);
}
view().update(sle);
continue;
}
// ltAccountRoot
if (!node.isFieldPresent(sfRewardFields) ||
!node.isFieldPresent(sfLedgerIndex))
continue;