test(export): cover sidecar rejection preflights

This commit is contained in:
Nicholas Dudfield
2026-06-23 11:53:51 +07:00
parent 439031dc92
commit 77e48d553c
5 changed files with 177 additions and 25 deletions

View File

@@ -20,7 +20,8 @@ defaults:
NetworkOPs: info
env:
XAHAU_RESOURCE_PER_PORT: "1"
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333}}}'
rc:
- rng_poll_ms=333
tests:
# --- CE + Export (80% quorum, SHAMap convergence) ---
@@ -33,11 +34,10 @@ tests:
- name: export_degradation_ce
script: .testnet/scenarios/export/export_degradation.py
network:
node_env:
3:
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"no_export_sig":true}}}'
4:
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"no_export_sig":true}}}'
rc:
- rng_poll_ms=333
- n3:no_export_sig=true
- n4:no_export_sig=true
- name: export_without_unl_report
script: .testnet/scenarios/export/export_without_unl_report.py
@@ -51,9 +51,9 @@ tests:
- name: export_no_veto_missing_observation
script: .testnet/scenarios/export/export_no_veto_missing_observation.py
network:
node_env:
4:
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"no_export_sig_hash":true}}}'
rc:
- rng_poll_ms=333
- n4:no_export_sig_hash=true
# CE + Export: 1 node suppressed, 4/5 = 80% quorum, should succeed
- name: export_ce_one_node_down
@@ -61,9 +61,9 @@ tests:
params:
expect_success: true
network:
node_env:
4:
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"no_export_sig":true}}}'
rc:
- rng_poll_ms=333
- n4:no_export_sig=true
# --- Export only, no CE (80% active-view quorum) ---
- name: export_only_all_up
@@ -85,9 +85,9 @@ tests:
- Export
track_features:
- Export
node_env:
4:
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"no_export_sig":true}}}'
rc:
- rng_poll_ms=333
- n4:no_export_sig=true
- name: export_only_two_nodes_down
script: .testnet/scenarios/export/export_quorum.py
@@ -98,8 +98,7 @@ tests:
- Export
track_features:
- Export
node_env:
3:
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"no_export_sig":true}}}'
4:
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"no_export_sig":true}}}'
rc:
- rng_poll_ms=333
- n3:no_export_sig=true
- n4:no_export_sig=true

View File

@@ -2,17 +2,52 @@
from __future__ import annotations
from xahaud_scripts.testnet.config import feature_name_to_hash
from xahaud_scripts.testnet.config import _unl_report_index, feature_name_to_hash
async def require_export(ctx, log):
"""Wait for first ledger and assert Export is enabled."""
async def require_export(
ctx, log, *, require_unl_report=True, require_runtime_config=True
):
"""Wait for first ledger and assert Export is enabled.
Network-mode Export success requires a parent-ledger UNLReport-backed
active validator view. Most export scenarios seed that report in genesis;
assert it here so a success-path test cannot accidentally pass setup
without the condition Export::doApply requires. The no-UNLReport retry
scenario opts out deliberately.
The tracked export suite also uses XAHAUD_RUNTIME_TEST_CONFIG for polling
and fault-injection knobs. Default binaries reject the runtime_config RPC,
so check it up front rather than silently running without those knobs.
"""
await ctx.wait_for_ledger_close(timeout=120)
if require_runtime_config:
result = ctx.rpc.runtime_config(0)
if not result or result.get("error"):
raise AssertionError(
"Export suite requires a binary built with "
"xahaud_runtime_test_config=ON; runtime_config RPC returned "
f"{result}"
)
log("RuntimeConfig RPC active")
feature = ctx.feature_check(feature_name_to_hash("Export"), node_id=0)
if not feature or not feature.get("enabled", False):
raise AssertionError(f"Export not enabled: {feature}")
log("Export enabled")
if require_unl_report:
result = ctx.rpc.ledger_entry(0, _unl_report_index())
node = (result or {}).get("node", {})
active = node.get("ActiveValidators", [])
if node.get("LedgerEntryType") != "UNLReport" or not active:
raise AssertionError(
"Export success scenario requires a ledger UNLReport with "
f"ActiveValidators, got: {result}"
)
log(f"UNLReport active validators: {len(active)}")
def find_export_txns(ctx, seq):
"""Find Export transactions in a ledger.

View File

@@ -12,7 +12,7 @@ from export_helpers import require_export, assert_shadow_ticket
async def scenario(ctx, log):
await require_export(ctx, log)
await require_export(ctx, log, require_unl_report=False)
await ctx.fund_accounts({"alice": 10000, "bob": 1000})
log("Accounts funded")

View File

@@ -18,7 +18,8 @@ defaults:
NetworkOPs: info
env:
XAHAU_RESOURCE_PER_PORT: "1"
XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333}}}'
rc:
- rng_poll_ms=333
tests:
- name: steady_state_entropy

View File

@@ -205,6 +205,21 @@ makeRawSidecarSet(Application& app, std::string const& raw)
return map->snapShot(false);
}
STObject
makeExportSigSidecar(
uint256 const& txHash,
PublicKey const& publicKey,
Slice signature)
{
STObject sidecar(sfGeneric);
sidecar.setFieldU8(sfSidecarType, sidecarExportSig);
sidecar.setFieldH256(sfTransactionHash, txHash);
sidecar.setFieldVL(sfSigningPubKey, publicKey.slice());
if (!signature.empty())
sidecar.setFieldVL(sfTxnSignature, signature);
return sidecar;
}
void
publishAndFetchSidecarSet(
Application& app,
@@ -1732,6 +1747,107 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(fetched.buildExportSigSet(seq) == exportSigSetHash);
}
void
testExportSidecarRejectsInvalidFetchedEntries()
{
testcase("Export sidecar rejects invalid fetched entries");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& valPK = valKeys.keys->publicKey;
auto const& valSK = valKeys.keys->secretKey;
auto const signerAccount = calcAccountID(valPK);
auto const dst = calcAccountID(randomKeyPair(KeyType::secp256k1).first);
auto const innerObj = makeExportedPayment(signerAccount, dst);
auto const innerTx = makeSTTx(innerObj);
auto const exportTx = makeExportTx(innerObj, signerAccount);
auto const txHash = exportTx->getTransactionID();
auto const txSet = makeRCLTxSet(env.app(), {exportTx});
auto const sigData = buildMultiSigningData(innerTx, signerAccount);
auto const sig = sign(valPK, valSK, sigData.slice());
Buffer const validSig(sig.data(), sig.size());
std::uint8_t const invalidBytes[] = {0x30, 0x03, 0x01, 0x02, 0x03};
Buffer const invalidSig{invalidBytes, sizeof(invalidBytes)};
auto expectRejected = [&](ConsensusExtensions& ce,
std::shared_ptr<SHAMap> const& map,
uint256 const& expectedTxHash,
PublicKey const& expectedSigner) {
publishAndFetchSidecarSet(
env.app(),
ce,
map,
ConsensusExtensions::SidecarKind::exportSig);
BEAST_EXPECT(!ce.exportSigCollector().hasVerifiedSignature(
expectedTxHash, expectedSigner));
BEAST_EXPECT(
ce.exportSigCollector().signatureCount(expectedTxHash) == 0);
};
{
auto const [inactivePK, _] = randomKeyPair(KeyType::secp256k1);
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport(ledger);
ce.cacheConsensusTxSet(txSet);
expectRejected(
ce,
makeSidecarSet(
env.app(),
{makeExportSigSidecar(
txHash,
inactivePK,
Slice(validSig.data(), validSig.size()))}),
txHash,
inactivePK);
}
{
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport(ledger);
ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {}));
expectRejected(
ce,
makeSidecarSet(
env.app(),
{makeExportSigSidecar(
txHash,
valPK,
Slice(validSig.data(), validSig.size()))}),
txHash,
valPK);
}
{
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport(ledger);
ce.cacheConsensusTxSet(txSet);
expectRejected(
ce,
makeSidecarSet(
env.app(),
{makeExportSigSidecar(
txHash,
valPK,
Slice(invalidSig.data(), invalidSig.size()))}),
txHash,
valPK);
}
}
void
testExportAgreedSignaturesIgnoreLiveCollectorMutation()
{
@@ -3185,6 +3301,7 @@ public:
testProposalProofRoundTrip();
testHarvestRngDataReplacementAndRejection();
testExportSidecarBuildFetchAndMerge();
testExportSidecarRejectsInvalidFetchedEntries();
testExportAgreedSignaturesIgnoreLiveCollectorMutation();
testOnPreBuildPreservesExportDecision();
testRngSidecarBuildFetchAndMerge();