diff --git a/.testnet/scenarios/export-suite.yml b/.testnet/scenarios/export-suite.yml index 9f4783446..87ffc0803 100644 --- a/.testnet/scenarios/export-suite.yml +++ b/.testnet/scenarios/export-suite.yml @@ -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 diff --git a/.testnet/scenarios/export/export_helpers.py b/.testnet/scenarios/export/export_helpers.py index 84fb14e6a..80d265d35 100644 --- a/.testnet/scenarios/export/export_helpers.py +++ b/.testnet/scenarios/export/export_helpers.py @@ -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. diff --git a/.testnet/scenarios/export/export_without_unl_report.py b/.testnet/scenarios/export/export_without_unl_report.py index 8d906474f..c034ec701 100644 --- a/.testnet/scenarios/export/export_without_unl_report.py +++ b/.testnet/scenarios/export/export_without_unl_report.py @@ -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") diff --git a/.testnet/scenarios/suite.yml b/.testnet/scenarios/suite.yml index e80ef86f8..8094c4b41 100644 --- a/.testnet/scenarios/suite.yml +++ b/.testnet/scenarios/suite.yml @@ -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 diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index 9c7e2160f..c1c40d8b7 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -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 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();