diff --git a/hook/sfcodes.h b/hook/sfcodes.h index bc85248c8..4d213b86d 100644 --- a/hook/sfcodes.h +++ b/hook/sfcodes.h @@ -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) diff --git a/include/xrpl/protocol/Feature.h b/include/xrpl/protocol/Feature.h index 0e3a04cd5..46e4c3556 100644 --- a/include/xrpl/protocol/Feature.h +++ b/include/xrpl/protocol/Feature.h @@ -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 diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index ad2e30e68..1bae5a18b 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -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) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index c2b395975..595b58a4b 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -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. diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index e5bbcd9f3..6145d30e7 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -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 diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 6f12afe00..0720c6a28 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -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. **/ diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index bec47d07d..0f10b468e 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -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& diff --git a/src/test/app/ClaimReward_test.cpp b/src/test/app/ClaimReward_test.cpp index d33e68aa2..84fcd5358 100644 --- a/src/test/app/ClaimReward_test.cpp +++ b/src/test/app/ClaimReward_test.cpp @@ -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(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( + 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( + 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: diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index 9c843586d..0d89008ae 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -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()); diff --git a/src/test/app/SetHookTSH_test.cpp b/src/test/app/SetHookTSH_test.cpp index 4f0dd9490..c1450039d 100644 --- a/src/test/app/SetHookTSH_test.cpp +++ b/src/test/app/SetHookTSH_test.cpp @@ -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 diff --git a/src/test/app/Touch_test.cpp b/src/test/app/Touch_test.cpp index f3a545f5d..bcb7f246c 100644 --- a/src/test/app/Touch_test.cpp +++ b/src/test/app/Touch_test.cpp @@ -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"}); } diff --git a/src/test/jtx/impl/reward.cpp b/src/test/jtx/impl/reward.cpp index 5b48a0e64..132a5ddce 100644 --- a/src/test/jtx/impl/reward.cpp +++ b/src/test/jtx/impl/reward.cpp @@ -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 diff --git a/src/test/jtx/reward.h b/src/test/jtx/reward.h index bb0e0c74b..183dd2b95 100644 --- a/src/test/jtx/reward.h +++ b/src/test/jtx/reward.h @@ -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 diff --git a/src/xrpld/app/tx/detail/ClaimReward.cpp b/src/xrpld/app/tx/detail/ClaimReward.cpp index 6f99c4884..e298a4015 100644 --- a/src/xrpld/app/tx/detail/ClaimReward.cpp +++ b/src/xrpld/app/tx/detail/ClaimReward.cpp @@ -17,6 +17,7 @@ */ //============================================================================== +#include #include #include #include @@ -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(); + + if (isMPT) + { + JLOG(ctx.j.debug()) << "ClaimReward: MPT is not supported yet."; + return temMALFORMED; + } + + auto const claimIssue = claimAsset.get(); + 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(); + + if (isMPT) + return tefINTERNAL; + + auto const claimIssue = claimCurrency.get(); + + if (!ctx.view.exists( + keylet::line(id, claimIssue.account, claimIssue.currency))) + return tecNO_LINE; + } return tesSUCCESS; } @@ -105,6 +202,61 @@ ClaimReward::doApply() std::optional 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( + 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(); + + if (isMPT) + return tefINTERNAL; + + auto const claimIssue = claimCurrency.get(); + + 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( - 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); diff --git a/src/xrpld/app/tx/detail/Transactor.cpp b/src/xrpld/app/tx/detail/Transactor.cpp index 0541f3afb..879d775ae 100644 --- a/src/xrpld/app/tx/detail/Transactor.cpp +++ b/src/xrpld/app/tx/detail/Transactor.cpp @@ -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(node)) + .getField(sfPreviousFields) + .downcast(); + 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;