Compare commits

..

4 Commits

Author SHA1 Message Date
Richard Holland
639ea34377 Fixhookmap (#756) 2026-06-16 17:06:25 +10:00
tequ
089c0dc3fe Fix ammLPHolds logic to include escrowed cases (#757) 2026-06-16 15:26:29 +10:00
tequ
78167e09c0 change sfHookOn of ltHookDefinition to soeOptional for HookOnV2 (#755) 2026-06-16 13:58:53 +10:00
tequ
607a7fdf98 Change AMM to Supported::no (#758) 2026-06-16 13:56:58 +10:00
10 changed files with 301 additions and 76 deletions

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_FIX (HookMap, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FIX (GuardDepth32, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(NamedHooks, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(IOURewardClaim, Supported::yes, VoteBehavior::DefaultNo)

View File

@@ -93,7 +93,7 @@ LEDGER_ENTRY(ltCHECK, 0x0043, Check, check, ({
*/
LEDGER_ENTRY(ltHOOK_DEFINITION, 'D', HookDefinition, hook_definition, ({
{sfHookHash, soeREQUIRED},
{sfHookOn, soeREQUIRED},
{sfHookOn, soeOPTIONAL},
{sfHookOnIncoming, soeOPTIONAL},
{sfHookOnOutgoing, soeOPTIONAL},
{sfHookCanEmit, soeOPTIONAL},

View File

@@ -18,6 +18,7 @@
//==============================================================================
#include <test/jtx.h>
#include <test/jtx/AMM.h>
#include <xrpld/app/tx/applySteps.h>
#include <xrpld/ledger/Dir.h>
#include <xrpl/protocol/Feature.h>
@@ -4316,6 +4317,93 @@ struct Escrow_test : public beast::unit_test::suite
env.close();
}
void
testIOUAMM(FeatureBitset features)
{
testcase("IOU AMM");
using namespace test::jtx;
using namespace std::chrono;
Account alice{"alice"};
Account bob{"bob"};
Account gw{"gw"};
auto const USD = gw["USD"];
// AMMCreate fails - insufficient balance
Env env(*this, features | featureAMM | featureAMMClawback);
env.fund(XRP(10000), alice, bob, gw);
env.close();
env.trust(USD(100000), alice);
env.close();
env(pay(gw, alice, USD(1000)));
env.close();
env(escrow(alice, bob, USD(1000)), finish_time(env.now() + 1s));
env.close();
// AMMCreate fails - insufficient balance
AMM ammFail(env, alice, XRP(1000), USD(1000), ter(tecUNFUNDED_AMM));
env(pay(gw, alice, USD(1000)));
env.close();
AMM ammAlice(env, alice, XRP(1000), USD(1000));
BEAST_EXPECT(ammAlice.ammExists());
// Single Asset Deposit fails - insufficient balance
ammAlice.deposit(
alice,
USD(1000),
std::nullopt,
std::nullopt,
std::nullopt,
ter(tecUNFUNDED_AMM));
// Double Asset Deposit fails - insufficient balance
ammAlice.deposit(
alice,
USD(1000),
XRP(1000),
std::nullopt,
std::nullopt,
ter(tecUNFUNDED_AMM));
auto const lptoken = ammAlice.getLPTokensBalance(alice);
// lock all LP tokens
env(escrow(alice, bob, STAmount{lptoken, ammAlice.lptIssue()}),
finish_time(env.now() + 1s));
env.close();
// Withdraw
ammAlice.withdraw(
alice, USD(1000), std::nullopt, std::nullopt, ter(tecAMM_BALANCE));
ammAlice.withdrawAll(alice, USD(1000), ter(tecAMM_BALANCE));
env(ammAlice.bid(BidArg{
.account = alice,
.bidMax = 100,
.assets = {{USD, XRP}},
}),
ter(tecAMM_INVALID_TOKENS));
ammAlice.vote(
alice,
1'000,
std::nullopt,
std::nullopt,
{{USD, XRP}},
ter(tecAMM_INVALID_TOKENS));
// Cannot escrow clawbackable tokens, so we cannot ammClawback escrowed
// tokens
// env(amm::ammClawback(gw, alice, USD, XRP, USD(100)),
// ter(tecAMM_BALANCE));
// env(amm::ammClawback(gw, alice, USD, XRP, std::nullopt),
// ter(tecAMM_BALANCE));
}
static uint256
getEscrowIndex(AccountID const& account, std::uint32_t uSequence)
{
@@ -4729,6 +4817,7 @@ struct Escrow_test : public beast::unit_test::suite
testIOUTLINSF(features);
testIOUPrecisionLoss(features);
testIOUClawback(features);
testIOUAMM(features);
}
public:

View File

@@ -3632,8 +3632,9 @@ public:
auto const alice = Account{"alice"};
auto const bob = Account{"bob"};
auto const claire = Account{"claire"};
Env env{*this, features};
env.fund(XRP(100000), alice, bob);
env.fund(XRP(100000), alice, bob, claire);
env.close();
// Compute hook hash for the accept hook
@@ -3773,6 +3774,84 @@ public:
BEAST_EXPECT(result2.has_value());
BEAST_EXPECT(result2.value() == newData.size());
}
{
// fixHookMap: foreign state set without grant after state previous
// modified
HookStateMap stateMap;
auto hookCtx = makeStubHookContext(
applyCtx, alice.id(), bob.id(), {}, stateMap);
AccountID const aliceid = alice.id();
// Pre-populate stateMap
stateMap[alice.id()] = {
100, // availableForReserves
1, // namespaceCount
1, // hookStateScale
{}};
auto& api = hookCtx.api();
// setup a hook on alice, and on claire, no grants
env(hook(alice, {{hso(genesis::AcceptHook)}}, 0), fee(XRP(1)));
env(hook(claire, {{hso(genesis::AcceptHook)}}, 0), fee(XRP(1)));
env.close();
// First modification
auto result1 =
api.state_foreign_set(testKey, testNs, aliceid, testData);
BEAST_EXPECT(result1.has_value());
// Second modification this time using bob as hookacc (should hit
// cache)
auto hookCtx2 = makeStubHookContext(
applyCtx, claire.id(), bob.id(), {}, stateMap);
// check the state entry is carried into the second context
// does the map contain the account?
BEAST_EXPECT(
hookCtx2.result.stateMap.find(aliceid) !=
hookCtx2.result.stateMap.end());
// the name space?
BEAST_EXPECT(
std::get<3>(hookCtx2.result.stateMap[aliceid]).find(testNs) !=
std::get<3>(hookCtx2.result.stateMap[aliceid]).end());
// the key entry?
BEAST_EXPECT(
std::get<3>(hookCtx2.result.stateMap[aliceid])[testNs].find(
testKey) !=
std::get<3>(hookCtx2.result.stateMap[aliceid])[testNs].end());
// is the entry marked as modified?
BEAST_EXPECT(
std::get<3>(hookCtx2.result.stateMap[aliceid])[testNs][testKey]
.first);
auto& api2 = hookCtx2.api();
Bytes newData{0x04, 0x05};
auto result2 =
api2.state_foreign_set(testKey, testNs, aliceid, newData);
if (features[fixHookMap])
{
// new behaviour: grant is missing, cannot write
BEAST_EXPECT(!result2.has_value());
BEAST_EXPECT(result2.error() == NOT_AUTHORIZED);
BEAST_EXPECT(hookCtx2.result.foreignStateSetDisabled);
}
else
{
// old behaviour: allow this illegal write due to the entry
// being modified previously in the map
BEAST_EXPECT(result2.has_value());
BEAST_EXPECT(result2.value() == newData.size());
}
}
}
void
@@ -4832,6 +4911,7 @@ public:
test_state(features);
test_state_foreign(features);
test_state_foreign_set(features - fixHookMap);
test_state_foreign_set(features);
test_state_foreign_set_max(features);
test_state_set(features);

View File

@@ -31,7 +31,7 @@ namespace ripple {
namespace test {
namespace jtx {
static Number
[[maybe_unused]] static Number
number(STAmount const& a)
{
if (isXRP(a))

View File

@@ -15,6 +15,7 @@
#include <memory>
#include <optional>
#include <queue>
#include <utility>
#include <vector>
#include <wasmedge/wasmedge.h>
@@ -174,6 +175,8 @@ struct HookResult
false; // hook_again allows strong pre-apply to nominate
// additional weak post-apply execution
std::shared_ptr<STObject const> provisionalMeta;
std::set<std::pair<AccountID, uint256 /* namespace */>>
foreignStateGrantCache; // add found grants here to avoid rechecking
};
class HookExecutor;

View File

@@ -1920,88 +1920,114 @@ HookAPI::state_foreign_set(
if (hookCtx.result.foreignStateSetDisabled)
return Unexpected(PREVIOUS_FAILURE_PREVENTS_RETRY);
// first check if we've already modified this state
auto cacheEntry = lookup_state_cache(account, ns, key);
if (cacheEntry && cacheEntry->get().first)
{
// if a cache entry already exists and it has already been modified
// don't check grants again
if (auto ret = set_state_cache(account, ns, key, data, true);
!ret.has_value())
return Unexpected(ret.error());
bool const hasFix = hookCtx.applyCtx.view().rules().enabled(fixHookMap);
return data.size();
if (!hasFix)
{
// first check if we've already modified this state
auto cacheEntry = lookup_state_cache(account, ns, key);
if (cacheEntry && cacheEntry->get().first)
{
// if a cache entry already exists and it has already been modified
// don't check grants again
if (auto ret = set_state_cache(account, ns, key, data, true);
!ret.has_value())
return Unexpected(ret.error());
return data.size();
}
}
// cache miss or cache was present but entry was not marked as previously
// modified therefore before continuing we need to check grants
auto const sle =
hookCtx.applyCtx.view().read(ripple::keylet::hook(account));
if (!sle)
return Unexpected(INTERNAL_ERROR);
bool found_auth = false;
// we do this by iterating the hooks installed on the foreign account and in
// turn their grants and namespaces
auto const& hooks = sle->getFieldArray(sfHooks);
for (auto const& hookObj : hooks)
// check if we've used a grant to modify this state entry before, if not
// look up possible grants
if (!hasFix ||
hookCtx.result.foreignStateGrantCache.find({account, ns}) ==
hookCtx.result.foreignStateGrantCache.end())
{
// skip blank entries
if (!hookObj.isFieldPresent(sfHookHash))
continue;
auto const sle =
hookCtx.applyCtx.view().read(ripple::keylet::hook(account));
if (!hookObj.isFieldPresent(sfHookGrants))
continue;
auto const& hookGrants = hookObj.getFieldArray(sfHookGrants);
if (hookGrants.size() < 1)
continue;
// the grant allows the hook to modify the granter's namespace only
if (hookObj.isFieldPresent(sfHookNamespace))
if (!sle)
{
if (hookObj.getFieldH256(sfHookNamespace) != ns)
continue;
}
else
{
// fetch the hook definition
auto const def =
hookCtx.applyCtx.view().read(ripple::keylet::hookDefinition(
hookObj.getFieldH256(sfHookHash)));
if (!def) // should never happen except in a rare race condition
continue;
if (def->getFieldH256(sfHookNamespace) != ns)
continue;
}
// this is expensive search so we'll disallow after one failed attempt
for (auto const& hookGrantObj : hookGrants)
{
bool hasAuthorizedField = hookGrantObj.isFieldPresent(sfAuthorize);
if (hookGrantObj.getFieldH256(sfHookHash) ==
hookCtx.result.hookHash &&
(!hasAuthorizedField ||
hookGrantObj.getAccountID(sfAuthorize) ==
hookCtx.result.account))
if (hasFix)
{
found_auth = true;
break;
hookCtx.result.foreignStateSetDisabled = true;
return Unexpected(NOT_AUTHORIZED);
}
return Unexpected(INTERNAL_ERROR);
}
if (found_auth)
break;
}
// RH TODO: test this code path more completely
if (!found_auth)
{
// hook only gets one attempt
hookCtx.result.foreignStateSetDisabled = true;
return Unexpected(NOT_AUTHORIZED);
bool found_auth = false;
// we do this by iterating the hooks installed on the foreign account
// and in turn their grants and namespaces
auto const& hooks = sle->getFieldArray(sfHooks);
for (auto const& hookObj : hooks)
{
// skip blank entries
if (!hookObj.isFieldPresent(sfHookHash))
continue;
if (!hookObj.isFieldPresent(sfHookGrants))
continue;
auto const& hookGrants = hookObj.getFieldArray(sfHookGrants);
if (hookGrants.size() < 1)
continue;
// the grant allows the hook to modify the granter's namespace only
if (hookObj.isFieldPresent(sfHookNamespace))
{
if (hookObj.getFieldH256(sfHookNamespace) != ns)
continue;
}
else
{
// fetch the hook definition
auto const def =
hookCtx.applyCtx.view().read(ripple::keylet::hookDefinition(
hookObj.getFieldH256(sfHookHash)));
if (!def) // should never happen except in a rare race
// condition
continue;
if (def->getFieldH256(sfHookNamespace) != ns)
continue;
}
// this is expensive search so we'll disallow after one failed
// attempt
for (auto const& hookGrantObj : hookGrants)
{
bool hasAuthorizedField =
hookGrantObj.isFieldPresent(sfAuthorize);
if (hookGrantObj.getFieldH256(sfHookHash) ==
hookCtx.result.hookHash &&
(!hasAuthorizedField ||
hookGrantObj.getAccountID(sfAuthorize) ==
hookCtx.result.account))
{
found_auth = true;
break;
}
}
if (found_auth)
break;
}
if (!found_auth)
{
// hook only gets one attempt
hookCtx.result.foreignStateSetDisabled = true;
return Unexpected(NOT_AUTHORIZED);
}
// add the grant to the cache
hookCtx.result.foreignStateGrantCache.emplace(account, ns);
}
if (auto ret = set_state_cache(account, ns, key, data, true);

View File

@@ -148,6 +148,31 @@ ammLPHolds(
// Put balance in account terms.
amount.negate();
}
// If tokens can be escrowed then they can be locked in the trustline
// which means we must never spend them until the escrow is released.
if (view.rules().enabled(featurePaychanAndEscrowForTokens) &&
sle->isFieldPresent(sfLockedBalance))
{
STAmount const lockedBalance = sle->getFieldAmount(sfLockedBalance);
STAmount const spendableBalance = amount -
(lpAccount > ammAccount ? -lockedBalance : lockedBalance);
// RH NOTE: this is defensively programmed, it should never fire
// if something bad does happen the trustline acts as a frozen line.
if (spendableBalance < beast::zero || spendableBalance > amount)
{
// LCOV_EXCL_START
JLOG(j.error())
<< "SpendableBalance has illegal value in accountHolds "
<< spendableBalance;
amount.clear(Issue{currency, ammAccount});
// LCOV_EXCL_STOP
}
else
amount = spendableBalance;
}
amount.setIssuer(ammAccount);
JLOG(j.trace()) << "ammLPHolds:"

View File

@@ -259,7 +259,6 @@ AMMClawback::equalWithdrawMatchingOneAmount(
STAmount const& amount)
{
auto frac = Number{amount} / amountBalance;
auto amount2Withdraw = amount2Balance * frac;
auto const lpTokensWithdraw =
toSTAmount(lptAMMBalance.issue(), lptAMMBalance * frac);

View File

@@ -375,10 +375,12 @@ accountHolds(
// if something bad does happen the trustline acts as a frozen line.
if (spendableBalance < beast::zero || spendableBalance > amount)
{
// LCOV_EXCL_START
JLOG(j.error())
<< "SpendableBalance has illegal value in accountHolds "
<< spendableBalance;
amount.clear(Issue{currency, issuer});
// LCOV_EXCL_STOP
}
else
amount = spendableBalance;