Compare commits

...

2 Commits

Author SHA1 Message Date
Richard Holland
096a2e455c ordering 2026-10-17 10:21:48 +11:00
Richard Holland
f70eaba373 testnet genesis fix 2026-10-17 10:01:46 +11:00
6 changed files with 591 additions and 2 deletions

View File

@@ -34,6 +34,10 @@
// If you add an amendment here, then do not forget to increment `numFeatures`
// in include/xrpl/protocol/Feature.h.
// Xahau testnet (NetworkID 21338) only. Registered as unsupported here; the
// server promotes it to Supported::yes / DefaultYes at startup if and only
// if it is configured with network_id 21338 (see Application.cpp).
XRPL_FIX (XahauTestnetGenesis, Supported::no, VoteBehavior::DefaultNo)
XRPL_FEATURE(RNG, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(PWALoader, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (20261005, Supported::yes, VoteBehavior::DefaultYes)

View File

@@ -18,6 +18,7 @@
#include <test/jtx.h>
#include <test/jtx/envconfig.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/misc/AmendmentTable.h>
#include <xrpld/app/misc/HashRouter.h>
#include <xrpld/app/tx/apply.h>
#include <xrpld/app/tx/detail/XahauGenesis.h>
@@ -5891,6 +5892,236 @@ struct XahauGenesis_test : public beast::unit_test::suite
testNoClaimLate(features);
}
// inject an enabling ttAMENDMENT pseudo for `amendment` and close
void
injectAmendment(jtx::Env& env, uint256 const& amendment)
{
uint32_t const seq =
env.app().getLedgerMaster().getClosedLedger()->info().seq + 1;
env.app().openLedger().modify(
[&](OpenView& view, beast::Journal j) -> bool {
STTx tx(ttAMENDMENT, [&](auto& obj) {
obj.setAccountID(sfAccount, AccountID());
obj.setFieldH256(sfAmendment, amendment);
obj.setFieldU32(sfLedgerSequence, seq);
});
uint256 txID = tx.getTransactionID();
auto s = std::make_shared<ripple::Serializer>();
tx.add(*s);
env.app().getHashRouter().setFlags(txID, SF_PRIVATE2);
view.rawTxInsert(txID, std::move(s), nullptr);
return true;
});
env.close();
}
// check the genesis account is in the shape fixXahauTestnetGenesis
// leaves it in: blackholed, governance + reward hooks, Evernode-only seat,
// no hook state
void
checkTestnetGenesis(jtx::Env& env, AccountID const& evernode)
{
auto const acc = env.le(keylet::account(genesisAccID));
BEAST_REQUIRE(!!acc);
BEAST_EXPECT(acc->getAccountID(sfRegularKey) == noAccount());
BEAST_EXPECT(acc->getFieldU32(sfFlags) == lsfDisableMaster);
BEAST_EXPECT(!acc->isFieldPresent(sfHookStateCount));
BEAST_EXPECT(!acc->isFieldPresent(sfHookNamespaces));
// governance: 1 + IRR, IRD, IS\0, IMC; reward: 1
BEAST_EXPECT(acc->getFieldU32(sfOwnerCount) == 6);
BEAST_EXPECT(!env.le(keylet::signers(genesisAccID)));
BEAST_EXPECT(!env.le(keylet::hookStateDir(genesisAccID, beast::zero)));
auto const hooksLE = env.le(keylet::hook(genesisAccID));
BEAST_REQUIRE(!!hooksLE);
auto const& hooks = hooksLE->getFieldArray(sfHooks);
BEAST_REQUIRE(hooks.size() == 2);
BEAST_EXPECT(hooks[0].getFieldH256(sfHookHash) == governHookHash);
BEAST_EXPECT(hooks[1].getFieldH256(sfHookHash) == rewardHookHash);
std::vector<std::pair<std::vector<uint8_t>, std::vector<uint8_t>>>
expected{XahauGenesis::GovernanceParameters};
expected.emplace_back(
std::vector<uint8_t>{'I', 'S', 0},
std::vector<uint8_t>(evernode.data(), evernode.data() + 20));
expected.emplace_back(
std::vector<uint8_t>{'I', 'M', 'C'}, std::vector<uint8_t>{1});
auto const& params = hooks[0].getFieldArray(sfHookParameters);
BEAST_REQUIRE(params.size() == expected.size());
for (std::size_t i = 0; i < expected.size(); ++i)
{
BEAST_EXPECT(
params[i].getFieldVL(sfHookParameterName) == expected[i].first);
BEAST_EXPECT(
params[i].getFieldVL(sfHookParameterValue) ==
expected[i].second);
}
BEAST_EXPECT(hooks[1].getFieldArray(sfHookParameters).empty());
for (auto const& h : {governHookHash, rewardHookHash})
{
auto const def = env.le(keylet::hookDefinition(h));
BEAST_EXPECT(def && def->getFieldU64(sfReferenceCount) == 1);
}
}
void
testXahauTestnetGenesisFix(FeatureBitset features)
{
using namespace jtx;
testcase("fixXahauTestnetGenesis");
auto const evernodePK = parseBase58<PublicKey>(
TokenType::NodePublic,
"nHUetpkzTgf8c6A1DTGemRrhL4nMjsiJrtcsmmg75ufzV5EDMbWU");
BEAST_REQUIRE(!!evernodePK);
AccountID const evernode = calcAccountID(*evernodePK);
auto stateAt = [&](Env& env, std::vector<uint8_t> const& k) {
uint8_t key[32]{};
std::copy(k.begin(), k.end(), key + 32 - k.size());
return env.le(keylet::hookState(
genesisAccID, uint256::fromVoid(key), beast::zero));
};
std::vector<uint8_t> const mcKey{'M', 'C'};
// only supported on 21338
{
Env env{*this, makeNetworkConfig(21337), features};
BEAST_EXPECT(!env.app().getAmendmentTable().isSupported(
fixXahauTestnetGenesis));
}
{
Env env{*this, makeNetworkConfig(21338), features};
BEAST_EXPECT(env.app().getAmendmentTable().isSupported(
fixXahauTestnetGenesis));
}
// wrongly seated governance with stale hook state gets reset
{
Env env{
*this,
makeNetworkConfig(21338),
features - featureXahauGenesis};
auto const alice = Account("alice");
auto const bob = Account("bob");
auto const invoker = Account("invoker");
auto const user = Account("user");
env.fund(XRP(10000), alice, bob, invoker, user);
env.close();
XahauGenesis::TestNonGovernanceDistribution.clear();
XahauGenesis::TestL1Membership.clear();
XahauGenesis::TestL2Membership.clear();
XahauGenesis::TestL1Membership.emplace_back(
alice.human(), XRPAmount(10000000000));
XahauGenesis::TestL1Membership.emplace_back(
bob.human(), XRPAmount(10000000000));
activate(__LINE__, env, true, false, true, {}, 2 + 3 + 2);
XahauGenesis::TestL1Membership.clear();
Json::Value invoke;
invoke[jss::TransactionType] = "Invoke";
invoke[jss::Account] = invoker.human();
invoke[jss::Destination] = env.master.human();
env(invoke, fee(XRP(10)));
env.close();
{
auto const mc = stateAt(env, mcKey);
BEAST_REQUIRE(!!mc);
BEAST_EXPECT(
mc->getFieldVL(sfHookStateData) == std::vector<uint8_t>{2});
}
injectAmendment(env, fixXahauTestnetGenesis);
BEAST_EXPECT(
env.current()->rules().enabled(fixXahauTestnetGenesis));
checkTestnetGenesis(env, evernode);
BEAST_EXPECT(!stateAt(env, mcKey));
// governance re-initialises from the new parameters
env(invoke, fee(XRP(10)));
env.close();
{
auto const mc = stateAt(env, mcKey);
BEAST_REQUIRE(!!mc);
BEAST_EXPECT(
mc->getFieldVL(sfHookStateData) == std::vector<uint8_t>{1});
auto const seat = stateAt(
env,
std::vector<uint8_t>(
evernode.data(), evernode.data() + 20));
BEAST_EXPECT(
seat &&
seat->getFieldVL(sfHookStateData) ==
std::vector<uint8_t>{0});
BEAST_EXPECT(!stateAt(
env,
std::vector<uint8_t>(
alice.id().data(), alice.id().data() + 20)));
}
// alice no longer holds a seat
env(vote(
__LINE__,
alice,
env.master,
'R',
'R',
{1, 2, 3, 4, 5, 6, 7, 8}),
fee(XRP(1)),
ter(tecHOOK_REJECTED));
env.close();
// reward claim reaches the reward hook
env(claimReward(user, env.master), fee(XRP(1)), ter(tesSUCCESS));
env.close();
// enabling it again is refused and nothing is reset
auto const ownerCount = env.le(keylet::account(genesisAccID))
->getFieldU32(sfOwnerCount);
injectAmendment(env, fixXahauTestnetGenesis);
BEAST_EXPECT(stateAt(env, mcKey));
BEAST_EXPECT(
env.le(keylet::account(genesisAccID))
->getFieldU32(sfOwnerCount) == ownerCount);
}
// never-configured genesis with a regular key, signer list and a
// foreign hook gets configured and blackholed
{
Env env{*this, makeNetworkConfig(21338), features};
auto const alice = Account("alice");
auto const user = Account("user");
env.fund(XRP(10000), alice, user);
env.memoize(env.master);
env(genesis::setAcceptHook(env.master), fee(XRP(10)));
env(signers(env.master, 1, {{alice, 1}}));
env(regkey(env.master, alice));
env.close();
BEAST_EXPECT(env.le(keylet::hookDefinition(acceptHookHash)));
injectAmendment(env, fixXahauTestnetGenesis);
checkTestnetGenesis(env, evernode);
BEAST_EXPECT(!env.le(keylet::hookDefinition(acceptHookHash)));
env(claimReward(user, env.master), fee(XRP(1)), ter(tesSUCCESS));
env.close();
}
// never touches state off 21338 even if enabled
{
Env env{*this, makeNetworkConfig(21337), features};
injectAmendment(env, fixXahauTestnetGenesis);
BEAST_EXPECT(!env.le(keylet::hook(genesisAccID)));
}
}
void
testGovernHookWithFeats(FeatureBitset features)
{
@@ -5900,6 +6131,7 @@ struct XahauGenesis_test : public beast::unit_test::suite
testWithRegularKey(features);
testGovernanceL1(features);
testGovernanceL2(features);
testXahauTestnetGenesisFix(features);
}
void

View File

@@ -1242,10 +1242,10 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
// Configure the amendments the server supports
{
auto const supported = []() {
auto const supported = [this]() {
auto const& amendments = detail::supportedAmendments();
std::vector<AmendmentTable::FeatureInfo> supported;
supported.reserve(amendments.size());
supported.reserve(amendments.size() + 1);
for (auto const& [a, vote] : amendments)
{
auto const f = ripple::getRegisteredFeature(a);
@@ -1254,6 +1254,16 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline)
if (f)
supported.emplace_back(a, *f, vote);
}
// fixXahauTestnetGenesis is registered Supported::no. It is only
// supported (and voted for) by servers configured for the Xahau
// testnet. Everywhere else it stays unsupported.
if (config_->NETWORK_ID == 21338)
supported.emplace_back(
"fixXahauTestnetGenesis",
fixXahauTestnetGenesis,
VoteBehavior::DefaultYes);
return supported;
}();
Section const& downVoted = config_->section(SECTION_VETO_AMENDMENTS);

View File

@@ -988,6 +988,335 @@ Change::activateXahauGenesis()
ctx_.rawView().rawDestroyXRP(destroyedXRP);
}
// fixXahauTestnetGenesis: one-off repair of the genesis account on the Xahau
// testnet (NetworkID 21338). The genesis hooks there were never set up the
// way featureXahauGenesis sets them up on mainnet, so reward claims misbehave.
//
// This runs exactly once: only from applyAmendment(), at the moment the
// amendment is enabled, which is after the tefALREADY check on the
// Amendments object. It is never reached from any rules().enabled() path.
//
// It resets the genesis account to the mainnet shape:
// - signer list removed, regular key blackholed, master disabled
// - every existing hook removed and every hook state entry wiped, so the
// governance hook re-runs its initial setup on the next Invoke
// - governance + reward hooks installed from the current binaries, with
// only Evernode's L1 seat filled (IMC = 1) and no L2 tables
// Balances are not touched and nothing is minted or burned.
void
Change::activateXahauTestnetGenesisFix()
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis amendment activation starting";
using namespace XahauGenesis;
// Belt and braces: the amendment is only Supported on 21338, but never
// touch state on any other network even if it were somehow enabled.
if (ctx_.app.config().NETWORK_ID != 21338)
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: NetworkID is not 21338, "
"no changes made";
return;
}
auto [ng_entries, l1_entries, l2_entries, gov_params] =
normalizeXahauGenesis(
{}, TestnetL1Membership, {}, GovernanceParameters, j_);
if (l1_entries.size() != 1 || !l2_entries.empty())
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: expected exactly one L1 "
"seat and no L2 tables, bailing";
return;
}
std::vector<std::tuple<
uint256, // hook on
std::vector<uint8_t>, // hook code
std::vector<
std::pair<std::vector<uint8_t>, std::vector<uint8_t>>>>> const
genesis_hooks = {
{GovernanceHookOn, GovernanceHook, gov_params},
{RewardHookOn, RewardHook, {}}};
Sandbox sb(&view());
static auto const accid = calcAccountID(
generateKeyPair(KeyType::secp256k1, generateSeed("masterpassphrase"))
.first);
if (!sb.exists(keylet::account(accid)))
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: genesis account doesn't "
"exist, bailing";
return;
}
// Step 1: remove any signer list. This adjusts OwnerCount itself, so it
// happens before we start our own OwnerCount bookkeeping below.
if (sb.exists(keylet::signers(accid)))
{
if (auto const ter =
SetSignerList::removeFromLedger(ctx_.app, sb, accid, j_);
!isTesSuccess(ter))
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: could not remove "
"signer list: "
<< transToken(ter) << ", bailing";
return;
}
}
auto sle = sb.peek(keylet::account(accid));
std::int64_t ownerCount = sle->getFieldU32(sfOwnerCount);
// Step 2: blackhole
sle->setAccountID(sfRegularKey, noAccount());
sle->setFieldU32(sfFlags, lsfDisableMaster);
// Step 3: release every existing hook. Definitions are only decremented
// here; zero-reference ones are erased after the new hooks are installed
// so a definition we are about to reuse is never erased and re-inserted.
std::set<uint256> releasedDefs;
auto sleHooks = sb.peek(keylet::hook(accid));
if (sleHooks && sleHooks->isFieldPresent(sfHooks))
{
for (auto const& h : sleHooks->getFieldArray(sfHooks))
{
ownerCount -= SetHook::computeHookReserve(h);
if (!h.isFieldPresent(sfHookHash))
continue;
auto const defKL =
keylet::hookDefinition(h.getFieldH256(sfHookHash));
auto def = sb.peek(defKL);
if (!def)
continue;
std::uint64_t const rc = def->isFieldPresent(sfReferenceCount)
? def->getFieldU64(sfReferenceCount)
: 0;
def->setFieldU64(sfReferenceCount, rc > 0 ? rc - 1 : 0);
sb.update(def);
releasedDefs.emplace(defKL.key);
}
}
// Step 4: wipe all hook state, in every namespace
{
std::uint16_t const scale = sle->isFieldPresent(sfHookStateScale)
? sle->getFieldU16(sfHookStateScale)
: 1;
std::uint32_t const stateCount = sle->isFieldPresent(sfHookStateCount)
? sle->getFieldU32(sfHookStateCount)
: 0;
std::set<uint256> namespaces{uint256{beast::zero}};
if (sle->isFieldPresent(sfHookNamespaces))
for (auto const& ns : sle->getFieldV256(sfHookNamespaces))
namespaces.emplace(ns);
for (auto const& ns : namespaces)
{
auto const dirKL = keylet::hookStateDir(accid, ns);
if (!sb.exists(dirKL))
continue;
std::vector<uint256> entries;
std::shared_ptr<SLE const> page;
unsigned int index{0};
uint256 entry;
if (cdirFirst(sb, dirKL.key, page, index, entry))
{
do
{
entries.push_back(entry);
} while (cdirNext(sb, dirKL.key, page, index, entry));
}
for (auto const& key : entries)
{
auto item = sb.peek(Keylet{ltHOOK_STATE, key});
if (!item)
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: hook state "
"directory references a missing or "
"non-state entry, bailing";
return;
}
if (!sb.dirRemove(dirKL, (*item)[sfOwnerNode], key, false))
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: could not "
"remove hook state entry, bailing";
return;
}
sb.erase(item);
}
// dirRemove drops the root once empty; this is just in case
if (auto dir = sb.peek(dirKL))
sb.erase(dir);
}
ownerCount -= static_cast<std::int64_t>(stateCount) * scale;
if (sle->isFieldPresent(sfHookStateCount))
sle->makeFieldAbsent(sfHookStateCount);
if (sle->isFieldPresent(sfHookStateScale))
sle->makeFieldAbsent(sfHookStateScale);
if (sle->isFieldPresent(sfHookNamespaces))
sle->makeFieldAbsent(sfHookNamespaces);
}
// Step 5: install governance + reward hooks
STArray hooks{sfHooks, genesis_hooks.size()};
std::int64_t hookReserve = 0;
for (auto const& [hookOn, wasmBytes, params] : genesis_hooks)
{
auto const hookHash = ripple::sha512Half_s(
ripple::Slice(wasmBytes.data(), wasmBytes.size()));
auto const defKL = keylet::hookDefinition(hookHash);
STObject hookObj{sfHook};
hookObj.setFieldH256(sfHookHash, hookHash);
if (auto def = sb.peek(defKL))
{
// Reuse the existing definition. It may have been created by an
// ordinary SetHook with other defaults, so pin the fields the
// genesis hooks depend on at the hook level where they differ.
std::uint64_t const rc = def->isFieldPresent(sfReferenceCount)
? def->getFieldU64(sfReferenceCount)
: 0;
def->setFieldU64(sfReferenceCount, rc + 1);
sb.update(def);
if (!def->isFieldPresent(sfHookOn) ||
def->getFieldH256(sfHookOn) != hookOn ||
def->isFieldPresent(sfHookOnIncoming) ||
def->isFieldPresent(sfHookOnOutgoing))
hookObj.setFieldH256(sfHookOn, hookOn);
if (def->getFieldH256(sfHookNamespace) != beast::zero)
hookObj.setFieldH256(sfHookNamespace, uint256{beast::zero});
if (def->isFieldPresent(sfHookCanEmit))
hookObj.setFieldH256(sfHookCanEmit, UINT256_BIT[ttHOOK_SET]);
}
else
{
std::ostringstream loggerStream;
auto result = validateGuards(
wasmBytes,
loggerStream,
"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
hook_api::getImportWhitelist(ctx_.view().rules()),
hook_api::getGuardRulesVersion(ctx_.view().rules()));
if (!result)
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: genesis hook "
"failed guard validation, bailing: "
<< loggerStream.str();
return;
}
if (auto err = hook::HookExecutor::validateWasm(
wasmBytes.data(), (size_t)wasmBytes.size()))
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: genesis hook "
"failed wasm validation, bailing: "
<< *err;
return;
}
// def is null here (the peek above found nothing)
def = std::make_shared<SLE>(defKL);
def->setFieldH256(sfHookHash, hookHash);
def->setFieldH256(sfHookOn, hookOn);
def->setFieldH256(sfHookNamespace, uint256{beast::zero});
def->setFieldU16(sfHookApiVersion, 0);
def->setFieldVL(sfCreateCode, wasmBytes);
def->setFieldH256(sfHookSetTxnID, ctx_.tx.getTransactionID());
def->setFieldU64(sfReferenceCount, 1);
def->setFieldAmount(
sfFee, XRPAmount{hook::computeExecutionFee(result->first)});
if (result->second > 0)
def->setFieldAmount(
sfHookCallbackFee,
XRPAmount{hook::computeExecutionFee(result->second)});
sb.insert(def);
}
{
std::vector<STObject> vec;
for (auto const& [k, v] : params)
{
STObject param(sfHookParameter);
param.setFieldVL(sfHookParameterName, k);
param.setFieldVL(sfHookParameterValue, v);
vec.emplace_back(std::move(param));
}
hookObj.setFieldArray(
sfHookParameters, STArray(vec, sfHookParameters));
}
hookReserve += SetHook::computeHookReserve(hookObj);
hooks.push_back(hookObj);
}
if (sleHooks)
{
sleHooks->setFieldArray(sfHooks, hooks);
sb.update(sleHooks);
}
else
{
sleHooks = std::make_shared<SLE>(keylet::hook(accid));
sleHooks->setFieldArray(sfHooks, hooks);
sleHooks->setAccountID(sfAccount, accid);
auto const page = sb.dirInsert(
keylet::ownerDir(accid),
keylet::hook(accid),
describeOwnerDir(accid));
if (!page)
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: genesis directory full "
"when inserting hooks object, bailing";
return;
}
sleHooks->setFieldU64(sfOwnerNode, *page);
sb.insert(sleHooks);
}
// Step 6: drop definitions nothing references any more
for (auto const& key : releasedDefs)
{
auto def = sb.peek(Keylet{ltHOOK_DEFINITION, key});
if (def && def->isFieldPresent(sfReferenceCount) &&
def->getFieldU64(sfReferenceCount) == 0)
sb.erase(def);
}
// Step 7: owner count
ownerCount += hookReserve;
if (ownerCount < 0)
{
JLOG(j_.warn()) << "fixXahauTestnetGenesis: owner count went negative ("
<< ownerCount << "), clamping to hook reserve";
ownerCount = hookReserve;
}
sle->setFieldU32(sfOwnerCount, static_cast<std::uint32_t>(ownerCount));
sb.update(sle);
sb.apply(ctx_.rawView());
JLOG(j_.warn()) << "fixXahauTestnetGenesis amendment executed successfully";
}
void
Change::activateTrustLinesToSelfFix()
{
@@ -1150,6 +1479,8 @@ Change::applyAmendment()
activateTrustLinesToSelfFix();
else if (amendment == featureXahauGenesis)
activateXahauGenesis();
else if (amendment == fixXahauTestnetGenesis)
activateXahauTestnetGenesisFix();
ctx_.app.getAmendmentTable().enable(amendment);

View File

@@ -62,6 +62,9 @@ private:
void
activateXahauGenesis();
void
activateXahauTestnetGenesisFix();
TER
applyAmendment();

View File

@@ -115,6 +115,15 @@ inline std::vector<std::pair<std::string, XRPAmount>> const L1Membership{
TableAmount}, // L2 Table Exchanges
};
// Governance seating used by fixXahauTestnetGenesis on the Xahau testnet
// (NetworkID 21338). Only Evernode's L1 seat is filled, every other seat is
// left empty and there are no L2 tables. The amount is unused: the fix does
// not mint or burn anything.
inline std::vector<std::pair<std::string, XRPAmount>> const TestnetL1Membership{
{"nHUetpkzTgf8c6A1DTGemRrhL4nMjsiJrtcsmmg75ufzV5EDMbWU",
InfraAmount}, // Evernode
};
// this data structure is used for testing the amendment only,
// if the ttEnableAmendment has an optional flag (that cannot be added in
// production) then whatever is in this array is used