diff --git a/.testnet/scenarios/export-suite.yml b/.testnet/scenarios/export-suite.yml index f3c2e47b4..b6833b0b2 100644 --- a/.testnet/scenarios/export-suite.yml +++ b/.testnet/scenarios/export-suite.yml @@ -46,6 +46,13 @@ tests: - Export unl_report: false + - name: export_no_veto_missing_observation + script: .testnet/scenarios/export/export_no_veto_missing_observation.py + network: + 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 script: .testnet/scenarios/export/export_quorum.py diff --git a/.testnet/scenarios/export/export_no_veto_missing_observation.py b/.testnet/scenarios/export/export_no_veto_missing_observation.py new file mode 100644 index 000000000..501fe86be --- /dev/null +++ b/.testnet/scenarios/export/export_no_veto_missing_observation.py @@ -0,0 +1,88 @@ +""":descr: Export succeeds when quorum sidecar material exists but one active +validator withholds exportSigSetHash observation. + +Node 4 has runtime_config no_export_sig_hash=true. It still attaches export +signatures, but it does not publish its exportSigSetHash in proposals. The +remaining 4/5 active validators can still align on the same export sidecar +hash, so the round must not retry/expire just because fullObservation is false. +""" + +from __future__ import annotations + +from export_helpers import ( + EXPORT_RETRY_LEDGER_WINDOW, + require_export, + assert_export_result, + assert_shadow_ticket, +) + + +async def scenario(ctx, log): + await require_export(ctx, log) + + await ctx.fund_accounts({"alice": 10000, "bob": 1000}) + log("Accounts funded") + + alice = ctx.account("alice") + bob = ctx.account("bob") + current_seq = ctx.validated_ledger_index(0) + + log(f"Current ledger: {current_seq}") + log("Node 4 withholds exportSigSetHash but still attaches export signatures") + + export_start = ctx.mark("export-no-veto-submit-start") + result = await ctx.submit_and_wait( + { + "TransactionType": "Export", + "LastLedgerSequence": current_seq + EXPORT_RETRY_LEDGER_WINDOW, + "Fee": "1000000", + "ExportedTxn": { + "TransactionType": "Payment", + "Account": alice.address, + "Destination": bob.address, + "Amount": "1000000", + "Fee": "10", + "Sequence": 0, + "TicketSequence": 1, + "FirstLedgerSequence": current_seq + 1, + "LastLedgerSequence": current_seq + EXPORT_RETRY_LEDGER_WINDOW, + "Flags": 2147483648, + "SigningPubKey": "", + }, + }, + alice.wallet, + timeout=60, + ) + export_end = ctx.mark("export-no-veto-submit-end") + + final_seq = ctx.validated_ledger_index(0) + engine_result = result.get("engine_result", "") + meta = result.get("meta", {}) + + log(f"Export completed at ledger {final_seq}, result: {engine_result}") + if engine_result != "tesSUCCESS": + raise AssertionError(f"Expected tesSUCCESS, got {engine_result}") + + export_result = assert_export_result(meta, log, ctx=ctx, require_signers=True) + signers = export_result.get("_WitnessSigners", []) + if len(signers) < 4: + raise AssertionError(f"Expected at least 4 signers, got {len(signers)}") + log(f"Export signer count: {len(signers)}") + + no_veto_logs = ctx.assert_log( + r"Export: missing exportSigSetHash observation ignored", + since=export_start, + until=export_end, + ) + log(f"Export no-veto missing-observation logs: {no_veto_logs.count}") + + withhold_logs = ctx.assert_log( + r"Export: withholding exportSigSetHash", + since=export_start, + until=export_end, + ) + log(f"Export sidecar hash withholding logs: {withhold_logs.count}") + + assert_shadow_ticket(ctx, alice.address, log, expect_exists=True) + + log("PASS") diff --git a/.testnet/scenarios/latency-suite.yml b/.testnet/scenarios/latency-suite.yml index 5d20d72d1..fa7fa3ab0 100644 --- a/.testnet/scenarios/latency-suite.yml +++ b/.testnet/scenarios/latency-suite.yml @@ -80,3 +80,11 @@ tests: - n0->n4:delay=500,jitter=100,msg=proposal - n1->n4:delay=500,jitter=100,msg=proposal - n2->n4:delay=500,jitter=100,msg=proposal + + - name: latency_export_no_veto_with_delay + script: .testnet/scenarios/export/export_no_veto_missing_observation.py + network: + rc: + - rng_poll_ms=250 + - delay=300,jitter=100,msg=proposal + - n4:no_export_sig_hash=true diff --git a/.testnet/scenarios/perf/ce_export_latency_probe.py b/.testnet/scenarios/perf/ce_export_latency_probe.py index 651fd19b0..581b518eb 100644 --- a/.testnet/scenarios/perf/ce_export_latency_probe.py +++ b/.testnet/scenarios/perf/ce_export_latency_probe.py @@ -98,6 +98,10 @@ def _summarize_logs(ctx, log, *, label, started, ended): "rng_participant_aligned": r"tier=2", "rng_validator_quorum": r"tier=3", "export_retry": r"terRETRY_EXPORT", + "export_quorum_timeout": r"Export: exportSigSet quorum alignment timeout", + "export_missing_observation_ignored": ( + r"Export: missing exportSigSetHash observation ignored" + ), } for name, pattern in patterns.items(): result = ctx.search_logs(pattern, since=started, until=ended, limit=500) diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index f64c036e7..228c8bd41 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -808,26 +808,38 @@ struct Export_test : public beast::unit_test::suite // applies the emitted ttEXPORT through the transactor. env.close(); - // The emitted ttEXPORT retries and is not included. The retired - // same-ledger sidecar path no longer synthesizes a signature witness. + // The emitted ttEXPORT and its per-export signature witness should now + // appear in the closed ledger. The witness is transaction-stream input, + // not metadata decoration, so replay has the same signatures apply saw. { auto const ledger = env.closed(); int exportCount = 0; int witnessCount = 0; + std::optional exportHash; + std::optional witnessExportHash; for (auto const& [stx, meta] : ledger->txs) { if (stx->getTxnType() == ttEXPORT) { BEAST_EXPECT(stx->isFieldPresent(sfEmitDetails)); + exportHash = stx->getTransactionID(); ++exportCount; continue; } - if (stx->getTxnType() == ttEXPORT_SIGNATURES) - ++witnessCount; + BEAST_EXPECT(stx->getTxnType() == ttEXPORT_SIGNATURES); + BEAST_EXPECT(stx->isFieldPresent(sfTransactionHash)); + witnessExportHash = stx->getFieldH256(sfTransactionHash); + auto signatures = + ExportResultBuilder::signaturesFromWitness(*stx); + BEAST_EXPECT(signatures); + ++witnessCount; } - BEAST_EXPECT(exportCount == 0); - BEAST_EXPECT(witnessCount == 0); + BEAST_EXPECT(exportCount == 1); + BEAST_EXPECT(witnessCount == 1); + BEAST_EXPECT(exportHash && witnessExportHash); + if (exportHash && witnessExportHash) + BEAST_EXPECT(*witnessExportHash == *exportHash); } } @@ -1161,9 +1173,9 @@ struct Export_test : public beast::unit_test::suite } void - testExportNetworkApplyUsesProvidedWitness(FeatureBitset features) + testExportNetworkApplyUsesAgreedSidecar(FeatureBitset features) { - testcase("ttEXPORT network apply uses provided witness"); + testcase("ttEXPORT network apply uses agreed export sidecar"); using namespace jtx; @@ -1204,9 +1216,26 @@ struct Export_test : public beast::unit_test::suite ce.cacheUNLReport(env.app().getLedgerMaster().getClosedLedger()); auto const view = ce.activeValidatorView(); BEAST_EXPECT(view->fromUNLReport); + ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx})); + auto const originalSig = ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK); auto const applySeq = env.closed()->seq() + 1; + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, originalSig, applySeq); + auto const agreedHash = ce.buildExportSigSet(applySeq); + BEAST_EXPECT( + env.app().getInboundTransactions().getSet(agreedHash, false)); + ce.acceptExportSigSet(agreedHash); + ce.setExportSigConvergenceFailed(); + + // Simulate an asynchronous collector mutation after sidecar agreement. + // Closed-ledger apply must derive the signature snapshot from the + // agreed sidecar, not from the live collector or a local timeout flag. + std::uint8_t const lateBytes[] = {9, 8, 7}; + Buffer const lateSig{lateBytes, sizeof(lateBytes)}; + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, lateSig, applySeq); ExportResultBuilder::SignatureSnapshot expectedSigs; expectedSigs.emplace(valPK, originalSig); @@ -1619,21 +1648,43 @@ struct Export_test : public beast::unit_test::suite forceNonStandalone(env.app()); BEAST_EXPECT(!env.app().config().standalone()); + 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 seq = env.current()->seq(); auto const ticketSeq = std::uint32_t{1}; auto const lls = seq + ExportLimits::maxRetryLedgers; auto innerObj = buildExportedPayment( alice.id(), carol.id(), seq + 1, lls, ticketSeq); + auto const innerTx = makeSTTx(innerObj); auto jt = makeExportJTx(env, alice, innerObj, lls); auto const exportTx = jt.stx; BEAST_EXPECT(exportTx); if (!exportTx) return; + auto const txHash = exportTx->getTransactionID(); + auto& ce = env.app().getConsensusExtensions(); ce.setExportEnabledThisRound(true); ce.cacheUNLReport(env.app().getLedgerMaster().getClosedLedger()); auto const view = ce.activeValidatorView(); BEAST_EXPECT(!view->fromUNLReport); + ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx})); + + auto const sig = + ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK); + auto const applySeq = env.closed()->seq() + 1; + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, sig, applySeq); + auto const agreedHash = ce.buildExportSigSet(applySeq); + BEAST_EXPECT( + env.app().getInboundTransactions().getSet(agreedHash, false)); + ce.acceptExportSigSet(agreedHash); + auto const parent = env.app().getLedgerMaster().getClosedLedger(); auto next = std::make_shared( *parent, env.app().timeKeeper().closeTime()); @@ -1692,12 +1743,21 @@ struct Export_test : public beast::unit_test::suite ce.cacheUNLReport(parent); auto const view = ce.activeValidatorView(); BEAST_EXPECT(view->fromUNLReport); + ce.cacheConsensusTxSet(makeRCLTxSet(env.app(), {exportTx})); + ExportResultBuilder::SignatureWitnesses exportSignatureWitnesses; if (withQuorum) { auto const sig = ExportResultBuilder::signExportedTxn(innerTx, valPK, valSK); + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, sig, applySeq); + auto const agreedHash = ce.buildExportSigSet(applySeq); + BEAST_EXPECT(env.app().getInboundTransactions().getSet( + agreedHash, false)); + ce.acceptExportSigSet(agreedHash); + ExportResultBuilder::SignatureSnapshot signatures; signatures.emplace(valPK, sig); exportSignatureWitnesses = @@ -2304,7 +2364,8 @@ struct Export_test : public beast::unit_test::suite // ttEXPORT transactor tests testExportTxnOpenLedger(allWithExport); testExportNetworkRetryWithoutQuorum(allWithExport); - testExportNetworkApplyUsesProvidedWitness(allWithExport); + testExportProposalSigningRequiresBoundedUNLReport(allWithExport); + testExportNetworkApplyUsesAgreedSidecar(allWithExport); testExportNetworkRetriesOversizedActiveView(allWithExport); testExportShadowTicketInsufficientReserve(allWithExport); testExportHistoricalReplayIgnoresCurrentManifestMap(allWithExport); @@ -2312,12 +2373,19 @@ struct Export_test : public beast::unit_test::suite testExportNetworkRetryWithoutUNLReport(allWithExport); testExportNetworkLastLedgerSequenceBoundary(allWithExport); testOpenLedgerExportLimit(allWithExport); + testShadowTicketLimit(allWithExport); + testShadowTicketLifecycle(allWithExport); + testCancelShadowTicketViaTxn(allWithExport); testExportRejectsNoTicketSequence(allWithExport); testExportRejectsMissingLastLedgerSequence(allWithExport); testExportRejectsSignedInnerTransaction(allWithExport); testExportRejectsLongRetryWindow(allWithExport); testExportRejectsMalformed(allWithExport); + // Round-trip test + testExportImportRoundTrip(allWithExport); + testExportImportWaitsForShadowTicket(allWithExport); + testExportImportRejectsStaleImportVL(allWithExport); } }; diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index ab5b85ae1..d762e28f0 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -190,6 +190,36 @@ makeExportedPayment( return obj; } +std::shared_ptr +makeExportTx(STObject const& inner, AccountID const& account) +{ + STObject exportObj(sfGeneric); + exportObj.setFieldU16(sfTransactionType, ttEXPORT); + exportObj.setAccountID(sfAccount, account); + exportObj.setFieldU32(sfSequence, 0); + exportObj.setFieldVL(sfSigningPubKey, Blob{}); + exportObj.setFieldU32(sfFirstLedgerSequence, 2); + exportObj.setFieldU32(sfLastLedgerSequence, 6); + exportObj.setFieldAmount(sfFee, XRPAmount{0}); + exportObj.set(std::make_unique(inner)); + + return std::make_shared(makeSTTx(exportObj)); +} + +std::shared_ptr +makeCancelExportTx(AccountID const& account, std::uint32_t sequence) +{ + STObject exportObj(sfGeneric); + exportObj.setFieldU16(sfTransactionType, ttEXPORT); + exportObj.setAccountID(sfAccount, account); + exportObj.setFieldU32(sfSequence, sequence); + exportObj.setFieldU32(sfCancelTicketSequence, 1000 + sequence); + exportObj.setFieldVL(sfSigningPubKey, Blob{}); + exportObj.setFieldAmount(sfFee, XRPAmount{0}); + + return std::make_shared(makeSTTx(exportObj)); +} + std::shared_ptr makeConsensusEntropyTx( std::uint32_t ledgerSeq, @@ -249,6 +279,15 @@ makeRCLTxSet(Application& app, std::vector> txns) return RCLTxSet{map->snapShot(false)}; } +std::size_t +sidecarLeafCount(SHAMap const& map) +{ + std::size_t count = 0; + map.visitLeaves( + [&](boost::intrusive_ptr const&) { ++count; }); + return count; +} + void forceNonStandalone(Application& app) { @@ -341,8 +380,13 @@ struct FakeExtensions std::chrono::steady_clock::time_point commitHashConflictStart_{}; bool entropySetPublished_{false}; std::chrono::steady_clock::time_point entropyPublishStart_{}; + bool exportSigGateStarted_{false}; + std::chrono::steady_clock::time_point exportSigGateStart_{}; + bool exportSigConvergenceFailed_{false}; bool rngOn{false}; - bool exportOn{false}; + bool localExportSigs{true}; + bool consensusExportTxns{false}; + bool exportOn{true}; bool entropyFailed{false}; bool commitFrozen{false}; std::size_t sidecarQuorum{4}; @@ -352,11 +396,14 @@ struct FakeExtensions bool commitQuorum{true}; bool minimumReveals{true}; bool anyReveals{true}; + uint256 exportHash{makeHash("local-export-sig-set")}; uint256 commitHash{makeHash("local-commit-set")}; uint256 entropyHash{makeHash("local-entropy-set")}; + std::deque exportHashSequence; std::deque commitHashSequence; std::deque entropyHashSequence; int commitBuilds = 0; + int exportBuilds = 0; int entropyBuilds = 0; int participantDiagnostics = 0; int selfSeeds = 0; @@ -373,6 +420,12 @@ struct FakeExtensions return exportOn; } + bool + testSuppressExportSigSetHash() const + { + return false; + } + std::size_t quorumThreshold() const { @@ -387,6 +440,12 @@ struct FakeExtensions return sidecarQuorum; } + std::size_t + exportRootAlignmentThreshold() const + { + return sidecarQuorum; + } + // Membership is a no-op in the FakeExtensions tick tests (every peer // counts, local counts) so the gate behavior is unchanged; F1 active-view // filtering is exercised directly against inspectTxConvergedSidecarPeers. @@ -505,6 +564,47 @@ struct FakeExtensions commitFrozen = true; } + bool + hasPendingExportSigs() const + { + return localExportSigs; + } + + bool + hasConsensusExportTxns() const + { + return consensusExportTxns; + } + + uint256 + buildExportSigSet(LedgerIndex) + { + ++exportBuilds; + if (!exportHashSequence.empty()) + { + auto ret = exportHashSequence.front(); + exportHashSequence.pop_front(); + return ret; + } + return exportHash; + } + + void + setExportSigConvergenceFailed() + { + exportSigConvergenceFailed_ = true; + } + + void + acceptExportSigSet(uint256 const&) + { + } + + void + clearAcceptedExportSigSet() + { + } + template void recordParticipantDiagnostics(ConsensusMode, PeerPositions const&) @@ -544,6 +644,18 @@ struct ExtensionTickHarness int updates = 0; int proposes = 0; + void + addPeer( + std::uint8_t id, + std::optional exportSigSetHash, + uint256 txSetHash = makeHash("tx-set")) + { + ExtendedPosition peerPosition{txSetHash}; + peerPosition.exportSigSetHash = exportSigSetHash; + peers.emplace( + makeNode(id), FakePeerPosition{makeNode(id), peerPosition}); + } + void addEntropyPeer( std::uint8_t id, @@ -742,14 +854,14 @@ class ConsensusExtensions_test : public beast::unit_test::suite ExtensionTickHarness harness; auto const localHash = makeHash("sidecar-local"); auto const conflictHash = makeHash("sidecar-conflict"); - harness.position.entropySetHash = localHash; - harness.addEntropyPeer(1, localHash); - harness.addEntropyPeer(2, conflictHash); - harness.addEntropyPeer(3, std::nullopt); - harness.addEntropyPeer(4, localHash, makeHash("other-tx-set")); + harness.position.exportSigSetHash = localHash; + harness.addPeer(1, localHash); + harness.addPeer(2, conflictHash); + harness.addPeer(3, std::nullopt); + harness.addPeer(4, localHash, makeHash("other-tx-set")); - auto const sidecarHashOf = [](auto const& position) { - return position.entropySetHash; + auto const exportHashOf = [](auto const& position) { + return position.exportSigSetHash; }; auto const allMembers = [](auto const&) { return true; }; @@ -758,7 +870,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite harness.peers, harness.position, true, - sidecarHashOf, + exportHashOf, allMembers, [&](auto const& hash) { if (hash) @@ -778,12 +890,12 @@ class ConsensusExtensions_test : public beast::unit_test::suite if (!mismatches.empty()) BEAST_EXPECT(mismatches.front() == conflictHash); - harness.position.entropySetHash.reset(); + harness.position.exportSigSetHash.reset(); auto const unpublishedState = detail::inspectTxConvergedSidecarPeers( harness.peers, harness.position, true, - sidecarHashOf, + exportHashOf, allMembers, [](auto const&) {}); BEAST_EXPECT(!unpublishedState.localCounts); @@ -797,9 +909,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite // proposer (node 5) that tx-converges and aligns on the SAME hash must // NOT inflate alignedParticipants() — otherwise two equivocation // cohorts padded by non-active peers could each clear the gate. - harness.position.entropySetHash = localHash; - harness.addEntropyPeer( - 5, localHash); // trusted, but outside the active view + harness.position.exportSigSetHash = localHash; + harness.addPeer(5, localHash); // trusted, but outside the active view auto const activeOnly = [](auto const& id) { return id != makeNode(5); // nodes 1..4 active; 5 is not }; @@ -808,7 +919,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite harness.peers, harness.position, true, - sidecarHashOf, + exportHashOf, allMembers, [](auto const&) {}); BEAST_EXPECT(padded.aligned == 2); // node 1 + node 5, unfiltered @@ -817,7 +928,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite harness.peers, harness.position, true, - sidecarHashOf, + exportHashOf, activeOnly, [](auto const&) {}); BEAST_EXPECT(filtered.aligned == 1); // node 5 excluded @@ -829,7 +940,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite harness.peers, harness.position, false, - sidecarHashOf, + exportHashOf, activeOnly, [](auto const&) {}); BEAST_EXPECT(!nonActiveLocal.localCounts); @@ -844,8 +955,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite auto const hashA = makeHash("split-brain-sidecar-a"); auto const hashB = makeHash("split-brain-sidecar-b"); - auto const sidecarHashOf = [](auto const& position) { - return position.entropySetHash; + auto const exportHashOf = [](auto const& position) { + return position.exportSigSetHash; }; auto const allMembers = [](auto const&) { return true; }; @@ -854,10 +965,10 @@ class ConsensusExtensions_test : public beast::unit_test::suite std::vector const& hashANodes, std::vector const& hashBNodes) { ExtensionTickHarness harness; - harness.position.entropySetHash = localHash; + harness.position.exportSigSetHash = localHash; auto addPeer = [&](std::uint8_t id, uint256 const& hash) { if (id != localId) - harness.addEntropyPeer(id, hash); + harness.addPeer(id, hash); }; for (auto id : hashANodes) addPeer(id, hashA); @@ -868,7 +979,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite harness.peers, harness.position, true, - sidecarHashOf, + exportHashOf, allMembers, [](auto const&) {}); }; @@ -1084,6 +1195,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite ConsensusExtensions::selectEntropyTierForView( true, 2, cappedView.size(), cappedView.originalViewSize) == entropyTierConsensusFallback); + BEAST_EXPECT( + ConsensusExtensions::exportRootAlignmentThreshold(cappedView) == 6); BEAST_EXPECT( ConsensusExtensions::exportWitnessThreshold(cappedView) == 6); } @@ -1266,15 +1379,20 @@ class ConsensusExtensions_test : public beast::unit_test::suite ConsensusExtensions ce{env.app(), activeNoopJournal()}; BEAST_EXPECT(!ce.testBootstrapFastStartEnabled()); + BEAST_EXPECT(!ce.testSuppressExportSigSetHash()); ConsensusTestConfig cfg; cfg.bootstrapFastStart = true; + cfg.noExportSigHash = true; env.app().getRuntimeConfig().setGlobalConfig(cfg); BEAST_EXPECT(ce.testBootstrapFastStartEnabled()); + BEAST_EXPECT(ce.testSuppressExportSigSetHash()); cfg.bootstrapFastStart = false; + cfg.noExportSigHash = false; env.app().getRuntimeConfig().setGlobalConfig(cfg); BEAST_EXPECT(!ce.testBootstrapFastStartEnabled()); + BEAST_EXPECT(!ce.testSuppressExportSigSetHash()); } void @@ -2134,6 +2252,57 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(ce.pendingRevealCount() == 1); } + void + testExportSidecarBuildsLocalSnapshot() + { + testcase("Export sidecar builds local snapshot"); + + 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 seq = ledger->seq() + 1; + + ConsensusExtensions source{env.app(), activeNoopJournal()}; + source.setExportEnabledThisRound(true); + source.cacheUNLReport(ledger); + source.cacheConsensusTxSet(txSet); + source.cacheConsensusTxSet(txSet); + BEAST_EXPECT(source.hasConsensusExportTxns()); + BEAST_EXPECT(!source.hasPendingExportSigs()); + + auto const sigData = buildMultiSigningData(innerTx, signerAccount); + auto const sig = sign(valPK, valSK, sigData.slice()); + Buffer sigBuf(sig.data(), sig.size()); + source.exportSigCollector().addUnverifiedSignature( + txHash, valPK, sigBuf, seq); + BEAST_EXPECT(source.verifyPendingExportSigs(txSet, seq) == 1); + BEAST_EXPECT( + source.exportSigCollector().hasVerifiedSignature(txHash, valPK)); + BEAST_EXPECT(source.hasPendingExportSigs()); + + auto const exportSigSetHash = source.buildExportSigSet(seq); + auto const exportedSet = + env.app().getInboundTransactions().getSet(exportSigSetHash, false); + BEAST_EXPECT(exportedSet); + if (exportedSet) + BEAST_EXPECT(sidecarLeafCount(*exportedSet) == 1); + } + void testTransactionAcquireRejectsSidecarWireNodes() { @@ -2221,6 +2390,293 @@ class ConsensusExtensions_test : public beast::unit_test::suite //@@end test-acquired-ce-pseudo-reject } + void + testExportSidecarIgnoresCancelOnlyExports() + { + testcase("Export sidecar ignores cancel-only exports"); + + 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 seq = ledger->seq() + 1; + + 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(); + + std::vector> txs; + for (std::uint32_t sequence = 1; + txs.size() < ExportLimits::maxPendingExports && sequence < 1000; + ++sequence) + { + auto cancelTx = makeCancelExportTx(signerAccount, sequence); + if (cancelTx->getTransactionID() < txHash) + txs.push_back(std::move(cancelTx)); + } + BEAST_EXPECT(txs.size() == ExportLimits::maxPendingExports); + if (txs.size() != ExportLimits::maxPendingExports) + return; + txs.push_back(exportTx); + + auto const txSet = makeRCLTxSet(env.app(), txs); + + ConsensusExtensions source{env.app(), activeNoopJournal()}; + source.setExportEnabledThisRound(true); + source.cacheUNLReport(ledger); + source.cacheConsensusTxSet(txSet); + + auto const sigData = buildMultiSigningData(innerTx, signerAccount); + auto const sig = sign(valPK, valSK, sigData.slice()); + Buffer sigBuf(sig.data(), sig.size()); + source.exportSigCollector().addVerifiedSignature( + txHash, valPK, sigBuf, seq); + + auto const exportSigSetHash = source.buildExportSigSet(seq); + auto const exportedSet = + env.app().getInboundTransactions().getSet(exportSigSetHash, false); + BEAST_EXPECT(exportedSet); + if (exportedSet) + BEAST_EXPECT(sidecarLeafCount(*exportedSet) == 1); + + source.acceptExportSigSet(exportSigSetHash); + BEAST_EXPECT(source.agreedExportSignatures(*exportTx, txHash, 1)); + } + + void + testExportSidecarBuildCapsConsensusCandidates() + { + testcase("Export sidecar build caps consensus candidates"); + + 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 seq = ledger->seq() + 1; + + std::vector> exportTxs; + std::vector> signatures; + for (std::size_t i = 0; i <= ExportLimits::maxPendingExports; ++i) + { + 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 sigData = buildMultiSigningData(innerTx, signerAccount); + auto const sig = sign(valPK, valSK, sigData.slice()); + + exportTxs.push_back(exportTx); + signatures.emplace_back(txHash, Buffer(sig.data(), sig.size())); + } + + auto const txSet = makeRCLTxSet(env.app(), exportTxs); + + ConsensusExtensions source{env.app(), activeNoopJournal()}; + source.setExportEnabledThisRound(true); + source.cacheUNLReport(ledger); + source.cacheConsensusTxSet(txSet); + for (auto const& [txHash, sig] : signatures) + source.exportSigCollector().addVerifiedSignature( + txHash, valPK, sig, seq); + + auto const exportSigSetHash = source.buildExportSigSet(seq); + auto const exportedSet = + env.app().getInboundTransactions().getSet(exportSigSetHash, false); + BEAST_EXPECT(exportedSet); + if (exportedSet) + BEAST_EXPECT( + sidecarLeafCount(*exportedSet) == + ExportLimits::maxPendingExports); + + source.acceptExportSigSet(exportSigSetHash); + BEAST_EXPECT(!source.agreedExportSignatures( + *exportTxs.back(), + exportTxs.back()->getTransactionID(), + ExportLimits::maxPendingExports + 1)); + } + + void + testExportAgreedSignaturesIgnoreLiveCollectorMutation() + { + testcase("Export apply uses agreed sidecar signatures"); + + 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 seq = ledger->seq() + 1; + + ConsensusExtensions ce{env.app(), activeNoopJournal()}; + ce.setExportEnabledThisRound(true); + ce.cacheUNLReport(ledger); + ce.cacheConsensusTxSet(txSet); + + auto const sigData = buildMultiSigningData(innerTx, signerAccount); + auto const sig = sign(valPK, valSK, sigData.slice()); + Buffer const originalSig(sig.data(), sig.size()); + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, originalSig, seq); + auto const exportSigSetHash = ce.buildExportSigSet(seq); + BEAST_EXPECT( + env.app().getInboundTransactions().getSet(exportSigSetHash, false)); + auto const view = ce.activeValidatorView(); + + // A locally-built export signature map is not closed-ledger material + // until the export sidecar gate accepts that exact root. + BEAST_EXPECT(!ce.agreedExportSignatures(*exportTx, txHash, 1)); + ce.acceptExportSigSet(makeHash("wrong-export-sigset-root")); + BEAST_EXPECT(!ce.agreedExportSignatures(*exportTx, txHash, 1)); + ce.acceptExportSigSet(exportSigSetHash); + + // Simulate a late local collector mutation after the sidecar hash has + // converged. The live collector now differs from the agreed sidecar + // map. + std::uint8_t const lateBytes[] = {9, 8, 7}; + Buffer const lateSig{lateBytes, sizeof(lateBytes)}; + ce.exportSigCollector().addVerifiedSignature( + txHash, valPK, lateSig, seq); + + auto const live = ce.exportSigCollector().checkQuorumAndSnapshot( + txHash, 1, [&](PublicKey const& pk) { + return ce.isActiveValidator(pk, *view); + }); + BEAST_EXPECT(live); + if (live) + BEAST_EXPECT(live->at(valPK) == lateSig); + + auto const agreed = ce.agreedExportSignatures(*exportTx, txHash, 1); + BEAST_EXPECT(agreed); + if (agreed) + { + BEAST_EXPECT(agreed->size() == 1); + BEAST_EXPECT(agreed->at(valPK) == originalSig); + } + } + + void + testExportAgreedSignaturesTrustAcceptedRootMembership() + { + testcase("Export agreed signatures trust accepted root membership"); + + using namespace jtx; + Env env{ + *this, envconfig(validator, ""), supported_amendments(), nullptr}; + auto const ledger = env.app().getLedgerMaster().getClosedLedger(); + + ConsensusExtensions ce{env.app(), activeNoopJournal()}; + ce.setExportEnabledThisRound(true); + ce.cacheUNLReport(ledger); + auto const view = ce.activeValidatorView(); + + auto const inactive = randomKeyPair(KeyType::secp256k1); + auto const& valPK = inactive.first; + auto const& valSK = inactive.second; + BEAST_EXPECT(!ce.isActiveValidator(valPK, *view)); + + 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 sigData = buildMultiSigningData(innerTx, signerAccount); + auto const sig = sign(valPK, valSK, sigData.slice()); + Buffer const sigBuf(sig.data(), sig.size()); + + STObject sidecar(sfGeneric); + sidecar.setFieldU8(sfSidecarType, sidecarExportSig); + sidecar.setFieldH256(sfTransactionHash, txHash); + sidecar.setFieldVL(sfSigningPubKey, valPK.slice()); + sidecar.setFieldVL(sfTxnSignature, Slice(sigBuf.data(), sigBuf.size())); + + Serializer itemSer; + sidecar.add(itemSer); + auto map = std::make_shared( + SHAMapType::SIDECAR, env.app().getNodeFamily()); + map->setUnbacked(); + map->addItem( + SHAMapNodeType::tnSIDECAR, + make_shamapitem( + sidecar.getHash(HashPrefix::sidecar), itemSer.slice())); + map = map->snapShot(false); + + auto const acceptedHash = map->getHash().as_uint256(); + env.app().getInboundTransactions().giveSet(acceptedHash, map, false); + ce.acceptExportSigSet(acceptedHash); + + auto const agreed = ce.agreedExportSignatures(*exportTx, txHash, 1); + BEAST_EXPECT(agreed); + if (agreed) + { + BEAST_EXPECT(agreed->size() == 1); + BEAST_EXPECT(agreed->at(valPK) == sigBuf); + } + } + + void + testOnPreBuildPreservesExportDecision() + { + testcase("onPreBuild preserves export state through buildLCL"); + + using namespace jtx; + Env env{ + *this, + envconfig(validator, ""), + supported_amendments() | featureConsensusEntropy | featureExport, + nullptr}; + + ConsensusExtensions ce{env.app(), activeNoopJournal()}; + ce.setExportEnabledThisRound(true); + ce.setRngEnabledThisRound(true); + ce.setExportSigConvergenceFailed(); + auto const tx = makeHash("export-prebuild-preserve"); + auto const pk = makeValidatorKeys().front(); + std::uint8_t const sigBytes[] = {1, 2, 3}; + Buffer const sig{sigBytes, sizeof(sigBytes)}; + ce.exportSigCollector().addVerifiedSignature(tx, pk, sig, 10); + + CanonicalTXSet retriableTxs{makeHash("preserve-export-state")}; + ce.onPreBuild(retriableTxs, env.closed()->seq() + 1, makeHash("txset")); + + BEAST_EXPECT(ce.exportSigConvergenceFailed()); + BEAST_EXPECT(ce.exportSigCollector().signatureCount(tx) == 1); + } + void testRngSidecarBuildsLocalSnapshots() { @@ -2409,6 +2865,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite ExtendedPosition pos{makeHash("diagnostic-tx-set")}; pos.commitSetHash = makeHash("diagnostic-commit-set"); pos.entropySetHash = makeHash("diagnostic-entropy-set"); + pos.exportSigSetHash = makeHash("diagnostic-export-set"); pos.exportSignaturesHash = makeHash("diagnostic-export-signatures"); pos.observedParticipantsHash = ce.observedParticipantsHash(); pos.myCommitment = makeHash("diagnostic-commitment"); @@ -2440,6 +2897,34 @@ class ConsensusExtensions_test : public beast::unit_test::suite //@@end test-decorate-position-disabled-legacy } + void + testExportSigGateRequiresQuorumAlignment() + { + testcase("Export sig gate requires quorum alignment"); + + FakeExtensions ext; + ExtensionTickHarness harness; + auto const localHash = ext.exportHash; + + harness.addPeer(1, localHash); + harness.addPeer(2, localHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(harness.position.exportSigSetHash == localHash); + BEAST_EXPECT(ext.exportSigGateStarted_); + + result = harness.tick(ext, std::chrono::milliseconds{100}); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + + result = harness.tick( + ext, + harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(ext.exportSigConvergenceFailed_); + } + void testRngEntropyGateAllowsQuorumDespiteMissingObservation() { @@ -2903,6 +3388,191 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(harness.position.entropySetHash == localHash); } + void + testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict() + { + testcase("Export sig gate ignores minority conflict after quorum"); + + FakeExtensions ext; + ExtensionTickHarness harness; + auto const localHash = ext.exportHash; + auto const conflictHash = makeHash("conflicting-export-sig-set"); + + harness.addPeer(1, localHash); + harness.addPeer(2, localHash); + harness.addPeer(3, localHash); + harness.addPeer(4, conflictHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + + result = harness.tick(ext, std::chrono::milliseconds{100}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + } + + void + testExportSigGateAllowsQuorumDespiteMissingObservation() + { + testcase( + "Export sig gate allows quorum despite missing sidecar " + "observation"); + + FakeExtensions ext; + ExtensionTickHarness harness; + auto const localHash = ext.exportHash; + + harness.addPeer(1, localHash); + harness.addPeer(2, localHash); + harness.addPeer(3, localHash); + harness.addPeer(4, std::nullopt); + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(harness.position.exportSigSetHash == localHash); + BEAST_EXPECT(ext.exportSigGateStarted_); + + // A quorum-aligned signed exportSigSetHash is enough even if a + // tx-converged minority peer has not advertised any exportSigSetHash. + result = harness.tick(ext, std::chrono::milliseconds{100}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + } + + void + testExportSigGateObservesAdvertisedPeerSets() + { + testcase("Export sig gate observes advertised peer sets"); + + FakeExtensions ext; + ext.localExportSigs = false; + ext.consensusExportTxns = true; + ExtensionTickHarness harness; + auto const peerHash = makeHash("peer-export-sig-set"); + + harness.addPeer(1, peerHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(ext.exportSigGateStarted_); + BEAST_EXPECT(!harness.position.exportSigSetHash); + + result = harness.tick( + ext, + harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(ext.exportSigConvergenceFailed_); + } + + void + testExportSigGateIgnoresAdvertisedSetsWithoutExportTxns() + { + testcase("Export sig gate ignores advertised sets without export txns"); + + FakeExtensions ext; + ext.localExportSigs = false; + ext.consensusExportTxns = false; + ExtensionTickHarness harness; + auto const peerHash = makeHash("empty-round-export-sig-set"); + + harness.addPeer(1, peerHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(!ext.exportSigGateStarted_); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + } + + void + testExportSigGateObservingModeDoesNotPropose() + { + testcase("Export sig gate observing mode does not propose"); + + FakeExtensions ext; + ExtensionTickHarness harness; + harness.mode = ConsensusMode::observing; + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(harness.position.exportSigSetHash == ext.exportHash); + BEAST_EXPECT(harness.updates == 1); + BEAST_EXPECT(harness.proposes == 0); + } + + void + testExportSigGateRefreshesHashBeforeWaiting() + { + testcase("Export sig gate refreshes hash before waiting"); + + FakeExtensions ext; + auto const staleHash = makeHash("stale-export-sig-set"); + auto const refreshedHash = makeHash("refreshed-export-sig-set"); + auto const conflictHash = makeHash("conflicting-export-sig-set"); + ext.exportHashSequence.push_back(staleHash); + ext.exportHashSequence.push_back(refreshedHash); + + ExtensionTickHarness harness; + harness.start = + std::chrono::steady_clock::time_point{} + std::chrono::seconds{1}; + harness.position.exportSigSetHash = staleHash; + ext.exportSigGateStarted_ = true; + ext.exportSigGateStart_ = harness.start; + harness.addPeer(1, conflictHash); + + auto result = harness.tick(ext, std::chrono::milliseconds{100}); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + BEAST_EXPECT(ext.exportBuilds == 2); + BEAST_EXPECT(harness.position.exportSigSetHash == refreshedHash); + BEAST_EXPECT(harness.updates == 1); + BEAST_EXPECT(harness.proposes == 1); + } + + void + testExportSigGateBoundsCandidateObservationWindow() + { + testcase("Export sig gate bounds candidate observation window"); + + FakeExtensions ext; + ext.localExportSigs = false; + ext.consensusExportTxns = true; + ExtensionTickHarness harness; + + auto result = harness.tick(ext); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(ext.exportSigGateStarted_); + BEAST_EXPECT(!harness.position.exportSigSetHash); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + + result = harness.tick(ext, std::chrono::milliseconds{100}); + BEAST_EXPECT(!result.readyForAccept); + BEAST_EXPECT(!ext.exportSigConvergenceFailed_); + + result = harness.tick( + ext, + harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1}); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(ext.exportSigConvergenceFailed_); + } + + void + testExportSigGateSkipsWhenExportDisabled() + { + testcase("Export sig gate skips when Export disabled"); + + FakeExtensions ext; + ext.exportOn = false; + ExtensionTickHarness harness; + + harness.addPeer(1, ext.exportHash); + + auto result = harness.tick(ext); + BEAST_EXPECT(result.readyForAccept); + BEAST_EXPECT(!ext.exportSigGateStarted_); + BEAST_EXPECT(!harness.position.exportSigSetHash); + BEAST_EXPECT(ext.exportBuilds == 0); + } + void testParticipantDiagnosticsOnlyWhenExtensionEnabled() { @@ -2918,6 +3588,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(ext.participantDiagnostics == 0); ext.exportOn = true; + ext.localExportSigs = false; result = harness.tick(ext); BEAST_EXPECT(result.readyForAccept); BEAST_EXPECT(ext.participantDiagnostics == 1); @@ -3131,8 +3802,12 @@ class ConsensusExtensions_test : public beast::unit_test::suite auto const ledger = env.app().getLedgerMaster().getClosedLedger(); ce.cacheUNLReport(ledger); + BEAST_EXPECT(ce.exportRootAlignmentThreshold() == 1); BEAST_EXPECT(ce.exportWitnessThreshold() == 1); + ce.setExportSigConvergenceFailed(); + BEAST_EXPECT(ce.exportSigConvergenceFailed()); + ce.setEntropyFailed(); ce.generateEntropySecret(); @@ -3295,13 +3970,20 @@ public: testOnPreBuildTier2WithNegativeUNL(); testProposalProofRoundTrip(); testHarvestRngDataReplacementAndRejection(); + testExportSidecarBuildsLocalSnapshot(); testTransactionAcquireRejectsSidecarWireNodes(); testAcquiredSetsRejectConsensusExtensionPseudos(); + testExportSidecarIgnoresCancelOnlyExports(); + testExportSidecarBuildCapsConsensusCandidates(); + testExportAgreedSignaturesIgnoreLiveCollectorMutation(); + testExportAgreedSignaturesTrustAcceptedRootMembership(); + testOnPreBuildPreservesExportDecision(); testRngSidecarBuildsLocalSnapshots(); testOnPreBuildInjectsStandaloneEntropy(); testOnPreBuildEntropyMismatchKeepsAgreed(); testDiagnosticsJsonAndPositionLogging(); testDecoratePositionSkipsWhenDisabled(); + testExportSigGateRequiresQuorumAlignment(); testRngEntropyGateAllowsQuorumDespiteMissingObservation(); testRngEntropyConflictAllowsQuorumDespiteMissingObservation(); testRngFastPathWaitsAfterEntropyPublish(); @@ -3319,6 +4001,14 @@ public: testRngEntropyConflictTimeoutClearsHash(); testRngEntropyConflictRefreshesHashBeforeWaiting(); testRngEntropyConflictIgnoredWithQuorumAlignment(); + testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict(); + testExportSigGateAllowsQuorumDespiteMissingObservation(); + testExportSigGateObservesAdvertisedPeerSets(); + testExportSigGateIgnoresAdvertisedSetsWithoutExportTxns(); + testExportSigGateObservingModeDoesNotPropose(); + testExportSigGateRefreshesHashBeforeWaiting(); + testExportSigGateBoundsCandidateObservationWindow(); + testExportSigGateSkipsWhenExportDisabled(); testParticipantDiagnosticsOnlyWhenExtensionEnabled(); testExportDisabledRoundClearsCollector(); testValidatorKeylessAuthoringNoops(); diff --git a/src/test/consensus/ConsensusRng_test.cpp b/src/test/consensus/ConsensusRng_test.cpp index 4a5a165e5..c03c393fb 100644 --- a/src/test/consensus/ConsensusRng_test.cpp +++ b/src/test/consensus/ConsensusRng_test.cpp @@ -1216,5 +1216,300 @@ public: BEAST_DEFINE_TESTSUITE(ConsensusRng, consensus, ripple); +class ConsensusExport_test : public beast::unit_test::suite +{ + SuiteJournal journal_; + +public: + ConsensusExport_test() : journal_("ConsensusExport_test", *this) + { + } + + void + testExportOnlySteadyStateSucceeds() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export-only sig set converges"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + peer->ce().enableExportConsensus_ = true; + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(2); + + BEAST_EXPECT(sim.synchronized(peers)); + for (Peer const* peer : peers) + { + BEAST_EXPECT(peer->ce().lastExportSucceeded_); + BEAST_EXPECT(!peer->ce().lastExportRetried_); + } + } + + void + testExportOnlyQuorumIgnoresMinorityConflict() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export-only sig set quorum ignores minority conflict"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + peer->ce().enableExportConsensus_ = true; + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + peers[0]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-only")); + + sim.run(3); + + PeerGroup honest{ + std::vector{peers[1], peers[2], peers[3], peers[4]}}; + BEAST_EXPECT(sim.branches(honest) == 1); + BEAST_EXPECT(sim.synchronized(honest)); + + for (Peer const* peer : honest) + { + BEAST_EXPECT(peer->ce().lastExportSucceeded_); + BEAST_EXPECT(!peer->ce().lastExportRetried_); + } + BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_); + } + + void + testExportOnlyMissingProposalSignaturesRetries() + { + //@@start export-missing-signatures-fallback-test + using namespace csf; + using namespace std::chrono; + + testcase("Export-only missing proposal signatures retries"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + peer->ce().enableExportConsensus_ = true; + + // Peer 0 remains an active validator/proposer, but drops + // proposal-carried export signatures from peers. Advertised sidecar + // roots do not reconstruct missing signature material; the export must + // retry locally while peers that received quorum signatures can apply. + // + // CSF's Ledger ID is still the base tx-set only. In production the + // quorum peers' ttEXPORT_SIGNATURES witness would make their synthetic + // ledger differ from peer 0's retry ledger until validations pull the + // missing-material peer onto the quorum ledger. Assert the quorum + // cohort's decision rather than full-network synchronization at a + // fixed simulator tick. + peers[0]->ce().suppressOwnExportSig_ = true; + for (std::size_t i = 1; i < peers.size(); ++i) + peers[0]->ce().dropExportSigFrom_.insert(peers[i]->id); + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(3); + + PeerGroup honest{ + std::vector{peers[1], peers[2], peers[3], peers[4]}}; + BEAST_EXPECT(sim.branches(honest) == 1); + BEAST_EXPECT(sim.synchronized(honest)); + BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_); + BEAST_EXPECT(peers[0]->ce().lastExportRetried_); + + for (std::size_t i = 1; i < peers.size(); ++i) + { + BEAST_EXPECT(peers[i]->ce().lastExportSucceeded_); + BEAST_EXPECT(!peers[i]->ce().lastExportRetried_); + } + //@@end export-missing-signatures-fallback-test + } + + void + testExportSigSetQuorumAlignmentIgnoresMinorityConflict() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export sig set quorum ignores minority conflict"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + { + peer->ce().enableRngConsensus_ = true; + peer->ce().enableExportConsensus_ = true; + } + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + // Warmup: let peer proposals and close times settle before checking + // the extension tick scenario. + sim.run(1); + BEAST_EXPECT(sim.synchronized(peers)); + + peers[0]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-minority")); + + sim.run(3); + + PeerGroup honest{ + std::vector{peers[1], peers[2], peers[3], peers[4]}}; + BEAST_EXPECT(sim.branches(honest) == 1); + BEAST_EXPECT(sim.synchronized(honest)); + + for (Peer const* peer : honest) + { + BEAST_EXPECT(peer->ce().lastExportSucceeded_); + BEAST_EXPECT(!peer->ce().lastExportRetried_); + } + BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_); + } + + void + testExportSigSetConflictWithoutQuorumRetries() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export sig set conflict without quorum retries"); + + ConsensusParms const parms{}; + Sim sim; + PeerGroup peers = sim.createGroup(5); + + for (Peer* peer : peers) + { + peer->ce().enableRngConsensus_ = true; + peer->ce().enableExportConsensus_ = true; + } + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + sim.run(1); + BEAST_EXPECT(sim.synchronized(peers)); + + peers[0]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-conflict-a")); + peers[1]->ce().forcedExportSigSetHash_ = + sha512Half(std::string("forced-export-conflict-b")); + + sim.run(3); + + BEAST_EXPECT(sim.branches(peers) == 1); + for (Peer const* peer : peers) + { + BEAST_EXPECT(!peer->ce().lastExportSucceeded_); + BEAST_EXPECT(peer->ce().lastExportRetried_); + } + } + + void + testExportSigSetRejectsEquivocatedSplitMajorities() + { + using namespace csf; + using namespace std::chrono; + + testcase("Export sig set rejects equivocated split majorities"); + + // Same shape as the entropy equivocation test: 2 honest validators on + // each side, one equivocator advertising a matching sidecar hash to + // each side. Each side sees 3/5 aligned, which must remain below the + // export quorum threshold of 4/5. + ConsensusParms const parms{}; + Sim sim; + + PeerGroup peers = sim.createGroup(5); + PeerGroup left{std::vector{peers[0], peers[1]}}; + Peer* equivocator = peers[2]; + PeerGroup right{std::vector{peers[3], peers[4]}}; + PeerGroup honest = left + right; + + for (Peer* peer : peers) + peer->ce().enableExportConsensus_ = true; + + auto const fast = round(0.2 * parms.ledgerGRANULARITY); + peers.trustAndConnect(peers, fast); + + sim.run(1); + BEAST_EXPECT(sim.synchronized(peers)); + + left.disconnect(right); + + auto const leftHash = sha512Half(std::string("export-equiv-left")); + auto const rightHash = sha512Half(std::string("export-equiv-right")); + for (Peer* peer : left) + { + peer->ce().forcedExportSigSetHash_ = leftHash; + for (Peer const* blocked : right) + peer->ce().dropExportSigFrom_.insert(blocked->id); + } + for (Peer* peer : right) + { + peer->ce().forcedExportSigSetHash_ = rightHash; + for (Peer const* blocked : left) + peer->ce().dropExportSigFrom_.insert(blocked->id); + } + + for (Peer* peer : left) + equivocator->ce().equivocateSidecarsTo_[peer->id].exportSigSetHash = + leftHash; + for (Peer* peer : right) + equivocator->ce().equivocateSidecarsTo_[peer->id].exportSigSetHash = + rightHash; + + sim.run(3); + + for (Peer const* peer : honest) + { + BEAST_EXPECT(!peer->ce().lastExportSucceeded_); + BEAST_EXPECT(peer->ce().lastExportRetried_); + } + } + + void + run() override + { + auto const* filter = std::getenv("XAHAU_EXPORT_TEST"); + std::string f = filter ? filter : ""; + +#define RUN(method) \ + do \ + { \ + if (f.empty() || std::string(#method).find(f) != std::string::npos) \ + method(); \ + } while (false) + + RUN(testExportOnlySteadyStateSucceeds); + RUN(testExportOnlyQuorumIgnoresMinorityConflict); + RUN(testExportOnlyMissingProposalSignaturesRetries); + RUN(testExportSigSetQuorumAlignmentIgnoresMinorityConflict); + RUN(testExportSigSetConflictWithoutQuorumRetries); + RUN(testExportSigSetRejectsEquivocatedSplitMajorities); + +#undef RUN + } +}; + +BEAST_DEFINE_TESTSUITE(ConsensusExport, consensus, ripple); } // namespace test } // namespace ripple diff --git a/src/test/consensus/ExtendedPosition_test.cpp b/src/test/consensus/ExtendedPosition_test.cpp index 1f49e4b35..7e4881c3b 100644 --- a/src/test/consensus/ExtendedPosition_test.cpp +++ b/src/test/consensus/ExtendedPosition_test.cpp @@ -67,6 +67,7 @@ class ExtendedPosition_test : public beast::unit_test::suite BEAST_EXPECT(!deserialized->myReveal); BEAST_EXPECT(!deserialized->commitSetHash); BEAST_EXPECT(!deserialized->entropySetHash); + BEAST_EXPECT(!deserialized->exportSigSetHash); BEAST_EXPECT(!deserialized->exportSignaturesHash); BEAST_EXPECT(!deserialized->observedParticipantsHash); } @@ -131,6 +132,7 @@ class ExtendedPosition_test : public beast::unit_test::suite auto const txSet = makeHash("txset-c"); auto const commitSet = makeHash("commitset-c"); auto const entropySet = makeHash("entropyset-c"); + auto const exportSigSet = makeHash("exportsigset-c"); auto const exportSigs = makeHash("exportsigs-c"); auto const participants = makeHash("participants-c"); auto const commit = makeHash("commit-c"); @@ -139,6 +141,7 @@ class ExtendedPosition_test : public beast::unit_test::suite ExtendedPosition pos{txSet}; pos.commitSetHash = commitSet; pos.entropySetHash = entropySet; + pos.exportSigSetHash = exportSigSet; pos.exportSignaturesHash = exportSigs; pos.observedParticipantsHash = participants; pos.myCommitment = commit; @@ -147,8 +150,8 @@ class ExtendedPosition_test : public beast::unit_test::suite Serializer s; pos.add(s); - // 32 + 1 + 6*32 = 225 - BEAST_EXPECT(s.getDataLength() == 225); + // 32 + 1 + 7*32 = 257 + BEAST_EXPECT(s.getDataLength() == 257); SerialIter sit(s.slice()); auto deserialized = @@ -160,6 +163,7 @@ class ExtendedPosition_test : public beast::unit_test::suite BEAST_EXPECT(deserialized->txSetHash == txSet); BEAST_EXPECT(deserialized->commitSetHash == commitSet); BEAST_EXPECT(deserialized->entropySetHash == entropySet); + BEAST_EXPECT(deserialized->exportSigSetHash == exportSigSet); BEAST_EXPECT(deserialized->exportSignaturesHash == exportSigs); BEAST_EXPECT( deserialized->observedParticipantsHash == participants); @@ -351,6 +355,7 @@ class ExtendedPosition_test : public beast::unit_test::suite ExtendedPosition pos{makeHash("txset-peer")}; pos.commitSetHash = makeHash("commitset-peer"); pos.entropySetHash = makeHash("entropyset-peer"); + pos.exportSigSetHash = makeHash("exportsigset-peer"); pos.exportSignaturesHash = makeHash("exportsigs-peer"); pos.observedParticipantsHash = makeHash("participants-peer"); pos.myCommitment = makeHash("commitment-peer"); @@ -658,12 +663,14 @@ class ExtendedPosition_test : public beast::unit_test::suite auto const txSet = makeHash("txset-json"); auto const commitSet = makeHash("commitset-json"); auto const entropySet = makeHash("entropyset-json"); + auto const exportSigSet = makeHash("exportsigset-json"); auto const exportSigs = makeHash("exportsigs-json"); auto const participants = makeHash("participants-json"); ExtendedPosition pos{txSet}; pos.commitSetHash = commitSet; pos.entropySetHash = entropySet; + pos.exportSigSetHash = exportSigSet; pos.exportSignaturesHash = exportSigs; pos.observedParticipantsHash = participants; @@ -677,6 +684,8 @@ class ExtendedPosition_test : public beast::unit_test::suite BEAST_EXPECT(json["tx_set"].asString() == to_string(txSet)); BEAST_EXPECT(json["commit_set"].asString() == to_string(commitSet)); BEAST_EXPECT(json["entropy_set"].asString() == to_string(entropySet)); + BEAST_EXPECT( + json["export_sig_set"].asString() == to_string(exportSigSet)); BEAST_EXPECT( json["export_signatures"].asString() == to_string(exportSigs)); BEAST_EXPECT( diff --git a/src/test/consensus/ProposalPrecheck_test.cpp b/src/test/consensus/ProposalPrecheck_test.cpp index 76907b1c8..1fc4955a1 100644 --- a/src/test/consensus/ProposalPrecheck_test.cpp +++ b/src/test/consensus/ProposalPrecheck_test.cpp @@ -117,7 +117,7 @@ public: protocol::TMProposeSet exportSet; setPreviousLedger(exportSet); ExtendedPosition exportPos{makeHash("export-lazy-position")}; - exportPos.exportSignaturesHash = makeHash("lazy-export-shares"); + exportPos.exportSigSetHash = makeHash("lazy-export-sidecar"); setPosition(exportSet, exportPos); BEAST_EXPECT( detail::checkProposalExtensions( @@ -184,7 +184,7 @@ public: protocol::TMProposeSet exportSet; setPreviousLedger(exportSet); ExtendedPosition exportPos{makeHash("export-position")}; - exportPos.exportSignaturesHash = makeHash("export-shares"); + exportPos.exportSigSetHash = makeHash("export-sidecar"); setPosition(exportSet, exportPos); BEAST_EXPECT( detail::checkProposalExtensions(exportSet, true, false) diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index 683ae60fb..fafefd1e2 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -61,7 +61,7 @@ namespace bc = boost::container; /// deterministically, but peers do not merge sidecar roots from each other. struct SidecarStore { - enum class Type { commit, reveal }; + enum class Type { commit, reveal, exportSig }; using EntrySet = hash_map; @@ -325,13 +325,18 @@ struct Peer std::chrono::steady_clock::time_point commitHashConflictStart_{}; bool entropySetPublished_{false}; std::chrono::steady_clock::time_point entropyPublishStart_{}; + bool exportSigGateStarted_{false}; + std::chrono::steady_clock::time_point exportSigGateStart_{}; + bool exportSigConvergenceFailed_{false}; // RNG state bool enableRngConsensus_ = false; + bool enableExportConsensus_ = false; hash_set unlNodes_; hash_set likelyParticipants_; hash_map pendingCommits_; hash_map pendingReveals_; + hash_map pendingExportSigs_; hash_map nodeKeys_; uint256 myEntropySecret_; bool commitSetFrozen_ = false; @@ -341,6 +346,7 @@ struct Peer // that accepted snapshot may feed finalizeRoundEntropy. uint256 lastEntropySetHash_{}; std::optional acceptedEntropySetHash_; + std::optional acceptedExportSigSetHash_; bool entropyFailed_ = false; // Last round summary (for test assertions) @@ -349,11 +355,15 @@ struct Peer std::uint16_t lastEntropyDenominator_ = 0; bool lastEntropyWasFallback_ = true; EntropyTier lastEntropyTier_ = entropyTierNone; + bool lastExportSucceeded_ = false; + bool lastExportRetried_ = false; // Optional test hook: force a specific commit-set hash std::optional forcedCommitSetHash_; // Optional test hook: force a specific entropy-set hash std::optional forcedEntropySetHash_; + // Optional test hook: force a specific export sig-set hash + std::optional forcedExportSigSetHash_; // Optional test hook: remain an active proposer but omit the // entropySetHash advertisement after building the sidecar. This models // a silent sidecar advertiser without shrinking the fixed UNL @@ -364,6 +374,7 @@ struct Peer { std::optional commitSetHash; std::optional entropySetHash; + std::optional exportSigSetHash; }; // Optional test hooks: send recipient-specific sidecar hashes in @@ -374,6 +385,11 @@ struct Peer // Optional test hook: drop reveals from specific peers // (simulates asymmetric reveal delivery / packet loss) hash_set dropRevealFrom_; + // Optional test hook: drop proposal-carried export signatures. + hash_set dropExportSigFrom_; + // Optional test hook: stay an active proposer but do not originate an + // export signature, so tests can force missing local export material. + bool suppressOwnExportSig_ = false; // Optional test hook: exercise generic Consensus bootstrap timing // without making the CSF runtime-config aware. bool testBootstrapFastStartEnabled_ = false; @@ -392,6 +408,12 @@ struct Peer bool exportEnabled() const + { + return enableExportConsensus_; + } + + bool + testSuppressExportSigSetHash() const { return false; } @@ -430,6 +452,26 @@ struct Peer return std::min(quorumThreshold(), tier2Threshold()); } + std::size_t + exportRootAlignmentThreshold() const + { + if (!enableExportConsensus_) + return (std::numeric_limits::max)() / 4; + auto const base = + unlNodes_.empty() ? std::size_t{1} : unlNodes_.size(); + return calculateQuorumThreshold(base); + } + + std::size_t + exportWitnessThreshold() const + { + if (!enableExportConsensus_) + return (std::numeric_limits::max)() / 4; + auto const base = + unlNodes_.empty() ? std::size_t{1} : unlNodes_.size(); + return calculateQuorumThreshold(base); + } + std::size_t pendingCommitCount() const { @@ -506,6 +548,17 @@ struct Peer return hash; } + uint256 + buildExportSigSet(Ledger::Seq seq) + { + if (forcedExportSigSetHash_) + return *forcedExportSigSetHash_; + auto const hash = hashRngSet(pendingExportSigs_, seq, "export-sig"); + peer.sidecarStore.publish( + hash, SidecarStore::Type::exportSig, pendingExportSigs_); + return hash; + } + void generateEntropySecret() { @@ -568,6 +621,18 @@ struct Peer acceptedEntropySetHash_.reset(); } + void + acceptExportSigSet(uint256 const& hash) + { + acceptedExportSigSetHash_ = hash; + } + + void + clearAcceptedExportSigSet() + { + acceptedExportSigSetHash_.reset(); + } + Proposal proposalWithRecipientSidecarHashes( Proposal const& proposal, @@ -582,6 +647,9 @@ struct Peer position.commitSetHash = it->second.commitSetHash; if (it->second.entropySetHash) position.entropySetHash = it->second.entropySetHash; + if (it->second.exportSigSetHash) + position.exportSigSetHash = it->second.exportSigSetHash; + return Proposal{ proposal.prevLedger(), proposal.proposeSeq(), @@ -609,13 +677,18 @@ struct Peer { pendingCommits_.clear(); pendingReveals_.clear(); + pendingExportSigs_.clear(); nodeKeys_.clear(); likelyParticipants_.clear(); myEntropySecret_.zero(); lastEntropySetHash_.zero(); acceptedEntropySetHash_.reset(); + acceptedExportSigSetHash_.reset(); entropyFailed_ = false; commitSetFrozen_ = false; + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; } void @@ -666,7 +739,7 @@ struct Peer Ledger::ID const& prevLedger, std::uint64_t) { - if (!enableRngConsensus_) + if (!enableRngConsensus_ && !enableExportConsensus_) return; if (!isUNLReportMember(nodeId)) return; @@ -685,8 +758,13 @@ struct Peer } } - if (!position.myReveal) + if (!enableRngConsensus_ || !position.myReveal) + { + if (enableExportConsensus_ && position.myExportSignature && + dropExportSigFrom_.count(nodeId) == 0) + pendingExportSigs_[nodeId] = *position.myExportSignature; return; + } // Test hook: drop reveals from specific peers if (dropRevealFrom_.count(nodeId) == 0) @@ -710,6 +788,10 @@ struct Peer } } } + + if (enableExportConsensus_ && position.myExportSignature && + dropExportSigFrom_.count(nodeId) == 0) + pendingExportSigs_[nodeId] = *position.myExportSignature; } bool @@ -841,6 +923,35 @@ struct Peer lastEntropyWasFallback_ = false; } + void + finalizeRoundExport() + { + if (!enableExportConsensus_) + { + lastExportSucceeded_ = false; + lastExportRetried_ = false; + return; + } + + auto const* acceptedSet = acceptedExportSigSetHash_ + ? peer.sidecarStore.fetch(*acceptedExportSigSetHash_) + : nullptr; + std::size_t activeSigCount = 0; + if (acceptedSet && + acceptedSet->type == SidecarStore::Type::exportSig) + { + activeSigCount = std::count_if( + acceptedSet->entries.begin(), + acceptedSet->entries.end(), + [&](auto const& entry) { + return isUNLReportMember(entry.first); + }); + } + + lastExportSucceeded_ = activeSigCount >= exportWitnessThreshold(); + lastExportRetried_ = !lastExportSucceeded_; + } + // --- Lifecycle hooks (matching design doc) --- template @@ -882,6 +993,8 @@ struct Peer Ledger_t const& prevLedger, bool proposing) { + decorateExportPosition(pos, prevLedger, proposing); + if (!enableRngConsensus_ || !proposing || !peer.runAsValidator) return; generateEntropySecret(); @@ -896,6 +1009,30 @@ struct Peer nodeKeys_.insert_or_assign(peer.id, peer.key); } + template + void + decorateExportPosition( + ProposalPosition& pos, + Ledger_t const& prevLedger, + bool proposing) + { + if (!enableExportConsensus_ || !proposing || !peer.runAsValidator) + return; + + auto const seq = static_cast(prevLedger.seq()) + 1; + auto const sig = sha512Half( + std::string("csf-export-sig"), + static_cast(peer.id), + peer.key.second, + seq); + if (!suppressOwnExportSig_) + { + pos.myExportSignature = sig; + pendingExportSigs_[peer.id] = sig; + } + nodeKeys_.insert_or_assign(peer.id, peer.key); + } + void appendJson(Json::Value&) const { @@ -916,11 +1053,30 @@ struct Peer { return testBootstrapFastStartEnabled_; } + bool + hasPendingExportSigs() const + { + return enableExportConsensus_ && !pendingExportSigs_.empty(); + } + bool + hasConsensusExportTxns() const + { + return enableExportConsensus_; + } + void + setExportSigConvergenceFailed() + { + if (enableExportConsensus_) + exportSigConvergenceFailed_ = true; + } + // --- Sub-state accessors --- bool extensionsBusy() const { - return estState_ != EstablishState::ConvergingTx; + return estState_ != EstablishState::ConvergingTx || + (exportEnabled() && + (exportSigGateStarted_ || hasPendingExportSigs())); } EstablishState estState() const @@ -936,6 +1092,9 @@ struct Peer entropySetPublished_ = false; entropyPublishStart_ = {}; commitSetFrozen_ = false; + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; } /// Defined in test/csf/PeerTick.h (keeps xrpld/app dependency @@ -1293,6 +1452,8 @@ struct Peer seq, static_cast(prevLedger.id()), result.txns.id()); + ce().finalizeRoundExport(); + TxSet const acceptedTxs = injectTxs(prevLedger, result.txns); Ledger const newLedger = oracle.accept( prevLedger, diff --git a/src/test/csf/Proposal.h b/src/test/csf/Proposal.h index 43cb60f80..cd137e937 100644 --- a/src/test/csf/Proposal.h +++ b/src/test/csf/Proposal.h @@ -43,8 +43,10 @@ struct RngPosition TxSet::ID txSetHash{}; std::optional commitSetHash; std::optional entropySetHash; + std::optional exportSigSetHash; std::optional myCommitment; std::optional myReveal; + std::optional myExportSignature; RngPosition() = default; explicit RngPosition(TxSet::ID txSet) : txSetHash(txSet) @@ -84,8 +86,10 @@ hash_append(Hasher& h, RngPosition const& pos) hash_append(h, pos.txSetHash); appendOpt(pos.commitSetHash); appendOpt(pos.entropySetHash); + appendOpt(pos.exportSigSetHash); appendOpt(pos.myCommitment); appendOpt(pos.myReveal); + appendOpt(pos.myExportSignature); } /** Proposal is a position taken in the consensus process. diff --git a/src/test/rpc/RuntimeConfig_test.cpp b/src/test/rpc/RuntimeConfig_test.cpp index a86568d03..6d6c4b319 100644 --- a/src/test/rpc/RuntimeConfig_test.cpp +++ b/src/test/rpc/RuntimeConfig_test.cpp @@ -162,7 +162,7 @@ class RuntimeConfig_test : public beast::unit_test::suite R"({"set":{"global":{"rng_claim_drop_pct":3.5,)" R"("rng_reveal_drop_pct":4.5,)" R"("bootstrap_fast_start":false,"rng_poll_ms":5,)" - R"("no_export_sig":true},)" + R"("no_export_sig":true,"no_export_sig_hash":true},)" R"("peer_defaults":{"send_delay_ms":100,)" R"("send_delay_jitter_ms":20,"send_drop_pct":1.25,)" R"("message_types":["proposal"]},)" @@ -180,6 +180,8 @@ class RuntimeConfig_test : public beast::unit_test::suite BEAST_EXPECT(global->rngPollMs == 50); BEAST_EXPECT(global->noExportSig.has_value()); BEAST_EXPECT(*global->noExportSig == true); + BEAST_EXPECT(global->noExportSigHash.has_value()); + BEAST_EXPECT(*global->noExportSigHash == true); auto defaults = rc.getPeerFaultConfig("10.0.0.6:51235"); if (!BEAST_EXPECT(defaults.has_value())) @@ -264,6 +266,7 @@ class RuntimeConfig_test : public beast::unit_test::suite params["set"]["global"] = Json::objectValue; params["set"]["global"]["rng_poll_ms"] = 5; params["set"]["global"]["no_export_sig"] = true; + params["set"]["global"]["no_export_sig_hash"] = true; params["set"]["peer_defaults"] = Json::objectValue; params["set"]["peer_defaults"]["send_delay_ms"] = 100; params["set"]["peer_defaults"]["send_drop_pct"] = 10.0; @@ -277,6 +280,7 @@ class RuntimeConfig_test : public beast::unit_test::suite BEAST_EXPECT(configs.isMember("global")); BEAST_EXPECT(configs["global"]["rng_poll_ms"].asInt() == 50); BEAST_EXPECT(configs["global"]["no_export_sig"].asBool() == true); + BEAST_EXPECT(configs["global"]["no_export_sig_hash"].asBool() == true); BEAST_EXPECT(configs.isMember("peer_defaults")); BEAST_EXPECT(configs.isMember("peer:10.0.0.2:51235")); @@ -286,6 +290,8 @@ class RuntimeConfig_test : public beast::unit_test::suite BEAST_EXPECT(global->rngPollMs == 50); BEAST_EXPECT(global->noExportSig.has_value()); BEAST_EXPECT(*global->noExportSig == true); + BEAST_EXPECT(global->noExportSigHash.has_value()); + BEAST_EXPECT(*global->noExportSigHash == true); auto peerCfg = rc.getPeerFaultConfig("10.0.0.2:51235"); if (!BEAST_EXPECT(peerCfg.has_value())) @@ -441,6 +447,14 @@ class RuntimeConfig_test : public beast::unit_test::suite expectInvalid(params); } + { + Json::Value params; + params["set"] = Json::objectValue; + params["set"]["peer:10.0.0.2:51235"] = Json::objectValue; + params["set"]["peer:10.0.0.2:51235"]["no_export_sig_hash"] = true; + expectInvalid(params); + } + { Json::Value params; params["set"] = Json::objectValue; diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index fe8f47a58..bc253960c 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -341,6 +341,25 @@ ConsensusExtensions::quorumThreshold() const return safeQuorumThreshold(base); } +std::size_t +ConsensusExtensions::exportRootAlignmentThreshold() const +{ + return exportRootAlignmentThreshold(*activeValidatorView()); +} + +std::size_t +ConsensusExtensions::exportRootAlignmentThreshold( + ActiveValidatorView const& validatorView) +{ + auto const base = validatorView.size(); + + // Export sidecar hashes are signed through ExtendedPosition even when RNG + // is disabled, so a quorum-aligned exportSigSetHash is deterministic + // enough for Export-only mode. Unanimity would let one active validator + // veto an otherwise converged export round. + return safeQuorumThreshold(base); +} + std::size_t ConsensusExtensions::exportWitnessThreshold() const { @@ -1052,6 +1071,13 @@ ConsensusExtensions::exportEnabled() const return exportEnabledThisRound_.load(std::memory_order_relaxed); } +bool +ConsensusExtensions::testSuppressExportSigSetHash() const +{ + auto const cfg = app_.getRuntimeConfig().getConsensusTestConfig(); + return cfg && cfg->noExportSigHash.has_value() && *cfg->noExportSigHash; +} + bool ConsensusExtensions::testBootstrapFastStartEnabled() const { @@ -1194,6 +1220,269 @@ ConsensusExtensions::buildEntropySet(LedgerIndex seq) return hash; } +uint256 +ConsensusExtensions::buildExportSigSet(LedgerIndex seq) +{ + //@@start current-export-global-sigset-build + auto map = + std::make_shared(SHAMapType::SIDECAR, app_.getNodeFamily()); + map->setUnbacked(); + + auto const validatorView = activeValidatorView(); + // Export sidecar convergence should not advertise signatures from trusted + // but inactive validators; those signatures cannot count at apply time. + auto const allSigs = exportSigCollector_.snapshotWithSigs( + activeSignerFilter(*this, validatorView)); + // Only signatures for export txns in the consensus candidate can affect + // this round's sidecar hash; open-ledger-only txns stay cached for later. + std::size_t entryCount = 0; + + for (auto const& [txHash, valSigs] : allSigs) + { + // Candidate membership is the deterministic publication gate. A sig + // may have been verified earlier from the open ledger, but it only + // enters the sidecar hash if the same tx hash is in the converged set. + if (consensusExportTxns_.find(txHash) == consensusExportTxns_.end()) + continue; + + for (auto const& [valPK, sigBuf] : valSigs) + { + STObject sidecar(sfGeneric); + sidecar.setFieldU8(sfSidecarType, sidecarExportSig); + sidecar.setFieldH256(sfTransactionHash, txHash); + sidecar.setFieldVL(sfSigningPubKey, valPK.slice()); + if (sigBuf.size() > 0) + sidecar.setFieldVL( + sfTxnSignature, Slice(sigBuf.data(), sigBuf.size())); + + map->addItem(SHAMapNodeType::tnSIDECAR, makeSidecarItem(sidecar)); + ++entryCount; + } + } + + auto const maxExportSidecarLeaves = validatorView->size() * + std::min(consensusExportTxns_.size(), + static_cast(ExportLimits::maxPendingExports)); + XRPL_ASSERT( + entryCount <= maxExportSidecarLeaves, + "ripple::ConsensusExtensions::buildExportSigSet : " + "export sidecar leaf count must stay within bounded local cap"); + + map = map->snapShot(false); + exportSigSetMap_ = map; + + auto const hash = map->getHash().as_uint256(); + // TODO: move consensus-extension snapshots out of InboundTransactions. + // They are same-process materialization caches only; sidecar roots are no + // longer advertised, fetched, served, or merged from peers. + app_.getInboundTransactions().giveSet(hash, map, false); + + JLOG(j_.debug()) << "Export: built exportSigSet SHAMap" + << " hash=" << hash << " seq=" << seq + << " entries=" << entryCount + << " candidateExportTxns=" << consensusExportTxns_.size() + << " activeValidators=" << validatorView->size(); + //@@end current-export-global-sigset-build + return hash; +} + +bool +ConsensusExtensions::hasPendingExportSigs() const +{ + auto const validatorView = activeValidatorView(); + // The export convergence gate only needs to run for signatures that are + // eligible under the active view used by final quorum evaluation. + auto const allSigs = exportSigCollector_.snapshotWithSigs( + activeSignerFilter(*this, validatorView)); + if (allSigs.empty() || !consensusTxSetMap_) + return false; + + for (auto const& entry : allSigs) + { + if (consensusExportTxns_.find(entry.first) != + consensusExportTxns_.end()) + return true; + } + return false; +} + +bool +ConsensusExtensions::hasConsensusExportTxns() const +{ + return !consensusExportTxns_.empty(); +} + +void +ConsensusExtensions::setExportSigConvergenceFailed() +{ + exportSigConvergenceFailed_ = true; +} + +bool +ConsensusExtensions::exportSigConvergenceFailed() const +{ + return exportSigConvergenceFailed_; +} + +void +ConsensusExtensions::acceptExportSigSet(uint256 const& hash) +{ + acceptedExportSigSetHash_ = hash; +} + +void +ConsensusExtensions::clearAcceptedExportSigSet() +{ + acceptedExportSigSetHash_.reset(); +} + +std::optional +ConsensusExtensions::agreedExportSignatures( + STTx const& exportTx, + uint256 const& txHash, + std::size_t threshold) const +{ + // A local exportSigSetMap_ is only candidate material until the sidecar + // gate accepts its root. Without this guard, a timed-out node with a local + // partial-but-quorum map could mint a different signed export blob from + // the quorum-aligned nodes. + if (!acceptedExportSigSetHash_) + { + JLOG(j_.warn()) << "Export: exportSigSet not accepted" + << " txHash=" << txHash << " threshold=" << threshold; + return std::nullopt; + } + + auto const acceptedHash = *acceptedExportSigSetHash_; + std::shared_ptr agreedMap; + if (exportSigSetMap_ && + exportSigSetMap_->getHash().as_uint256() == acceptedHash) + { + agreedMap = exportSigSetMap_; + } + else + { + agreedMap = app_.getInboundTransactions().getSet(acceptedHash, false); + } + + if (!agreedMap) + { + JLOG(j_.warn()) << "Export: accepted exportSigSet missing" + << " acceptedHash=" << acceptedHash + << " txHash=" << txHash << " threshold=" << threshold; + return std::nullopt; + } + if (agreedMap->mapType() != SHAMapType::SIDECAR) + { + JLOG(j_.warn()) << "Export: accepted exportSigSet has wrong map type" + << " acceptedHash=" << acceptedHash + << " txHash=" << txHash; + return std::nullopt; + } + + auto const agreedHash = agreedMap->getHash().as_uint256(); + if (agreedHash != acceptedHash) + { + JLOG(j_.warn()) << "Export: accepted exportSigSet hash mismatch" + << " setHash=" << agreedHash + << " acceptedHash=" << acceptedHash + << " txHash=" << txHash; + return std::nullopt; + } + + // The accepted root is the membership decision. Candidate construction + // filters live active signers, but materialization must not re-resolve + // signer keys through mutable manifests or nodes can diverge after a + // rotation. Keep cryptographic verification below; drop only the live + // membership re-filter. + ExportSignatureSnapshot signatures; + bool invalid = false; + agreedMap->visitLeaves( + [&](boost::intrusive_ptr const& item) { + if (invalid) + return; + + try + { + auto admitted = admitSidecarLeaf( + item->key(), + item->slice(), + agreedHash, + j_, + "Export", + "exportSigSet", + "agreed", + ExportLimits::maxExportSignatureSidecarBytes, + &invalid); + if (!admitted || admitted->type != sidecarExportSig) + return; + auto const& sidecar = admitted->sidecar; + + if (!sidecar.isFieldPresent(sfTransactionHash) || + !sidecar.isFieldPresent(sfSigningPubKey) || + !sidecar.isFieldPresent(sfTxnSignature)) + return; + + if (sidecar.getFieldH256(sfTransactionHash) != txHash) + return; + + auto const pk = sidecar.getFieldVL(sfSigningPubKey); + if (!publicKeyType(makeSlice(pk))) + return; + + PublicKey const valPK{makeSlice(pk)}; + + auto const sigVL = sidecar.getFieldVL(sfTxnSignature); + auto const sigSlice = makeSlice(sigVL); + if (!verifyExportSignatureAgainstTx( + exportTx, + valPK, + sigSlice, + txHash, + j_, + "agreed exportSigSet")) + { + invalid = true; + return; + } + + Buffer sigBuf(sigSlice.data(), sigSlice.size()); + if (auto const [_, inserted] = + signatures.emplace(valPK, std::move(sigBuf)); + !inserted) + { + JLOG(j_.warn()) + << "Export: accepted exportSigSet duplicate signer" + << " setHash=" << agreedHash << " txHash=" << txHash + << " signer=" << toBase58(TokenType::NodePublic, valPK); + invalid = true; + } + } + catch (std::exception const& e) + { + JLOG(j_.warn()) + << "Export: agreed exportSigSet parse failed" + << " setHash=" << agreedHash << " txHash=" << txHash + << " error=" << e.what(); + invalid = true; + } + }); + + if (invalid) + return std::nullopt; + + if (signatures.size() < threshold) + { + JLOG(j_.info()) << "Export: accepted exportSigSet below quorum" + << " setHash=" << agreedHash << " txHash=" << txHash + << " signers=" << signatures.size() + << " threshold=" << threshold; + return std::nullopt; + } + + return signatures; +} + void ConsensusExtensions::generateEntropySecret() { @@ -1250,6 +1539,7 @@ ConsensusExtensions::clearRngStatePreservingExport() acceptedEntropySetHash_.reset(); rngRoundSeq_.reset(); roundPrevLedgerHash_ = uint256{}; + consensusTxSetMap_.reset(); consensusExportTxns_.clear(); consensusTxSetHash_.reset(); observedParticipantsHash_.reset(); @@ -1277,7 +1567,12 @@ ConsensusExtensions::clearRngState() // material waiting for a later re-enable. exportSigCollector_.clearAll(); } + exportSigSetMap_.reset(); + acceptedExportSigSetHash_.reset(); consensusExportTxns_.clear(); + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; //@@end round-stop-export-reset clearRngStatePreservingExport(); @@ -1478,6 +1773,7 @@ ConsensusExtensions::cacheConsensusTxSet(RCLTxSet const& txns) if (consensusTxSetHash_ && *consensusTxSetHash_ == txSetHash) return; + consensusTxSetMap_ = txns.map_; consensusExportTxns_ = buildExportTxnLookup(*txns.map_, j_); consensusTxSetHash_ = txSetHash; } @@ -1690,9 +1986,157 @@ ConsensusExtensions::onPreBuild( //@@end rng-inject-pseudotx } + if (exportEnabled()) + { + //@@start export-witness-scrub-stale + auto const validatorView = activeValidatorView(); + for (auto it = retriableTxs.begin(); it != retriableTxs.end();) + { + auto const& tx = it->second; + if (tx && tx->getTxnType() == ttEXPORT_SIGNATURES) + { + // Live witnesses are build-time materializations of the + // accepted sidecar root. Remove stale or externally supplied + // pseudos before reinserting the deterministic witness below. + it = retriableTxs.erase(it); + continue; + } + ++it; + } + //@@end export-witness-scrub-stale + + if (app_.config().standalone()) + { + auto const& valKeys = app_.getValidatorKeys(); + if (valKeys.keys) + { + for (auto const& entry : retriableTxs) + { + auto const& stx = entry.second; + if (!stx || stx->getTxnType() != ttEXPORT || + !stx->isFieldPresent(sfExportedTxn)) + { + continue; + } + + auto const exportTxHash = stx->getTransactionID(); + auto const existing = std::find_if( + retriableTxs.begin(), + retriableTxs.end(), + [&](auto const& candidate) { + auto const& tx = candidate.second; + return tx && + tx->getTxnType() == ttEXPORT_SIGNATURES && + tx->isFieldPresent(sfTransactionHash) && + tx->getFieldH256(sfTransactionHash) == + exportTxHash; + }); + + if (existing != retriableTxs.end()) + continue; + + auto innerTx = ExportLedgerOps::innerExportedTx(*stx); + if (!innerTx) + { + JLOG(j_.warn()) << "Export: standalone witness skipped" + << " exportTxHash=" << exportTxHash + << " reason=inner-tx-parse-failed"; + continue; + } + + ExportResultBuilder::SignatureSnapshot signatures; + signatures.emplace( + valKeys.keys->publicKey, + ExportResultBuilder::signExportedTxn( + *innerTx, + valKeys.keys->publicKey, + valKeys.keys->secretKey)); + + auto witness = ExportResultBuilder::buildSignatureWitness( + exportTxHash, signatures, seq); + + // Standalone uses the same replay witness shape as + // network mode, but the witness is locally synthesized + // from the node's validator key instead of quorum sidecar + // convergence. + retriableTxs.insert( + std::make_shared(std::move(witness))); + } + } + } + //@@start export-witness-from-accepted-root + else if (validatorView->fromUNLReport) + { + auto const threshold = exportWitnessThreshold(*validatorView); + for (auto const& entry : retriableTxs) + { + auto const& stx = entry.second; + if (!stx || stx->getTxnType() != ttEXPORT || + !stx->isFieldPresent(sfExportedTxn)) + continue; + + auto const exportTxHash = stx->getTransactionID(); + auto sigs = + agreedExportSignatures(*stx, exportTxHash, threshold); + if (!sigs) + continue; + + auto witness = ExportResultBuilder::buildSignatureWitness( + exportTxHash, *sigs, seq); + auto const witnessHash = witness.getTransactionID(); + auto const existing = std::find_if( + retriableTxs.begin(), + retriableTxs.end(), + [&](auto const& candidate) { + auto const& tx = candidate.second; + return tx && tx->getTxnType() == ttEXPORT_SIGNATURES && + tx->isFieldPresent(sfTransactionHash) && + tx->getFieldH256(sfTransactionHash) == exportTxHash; + }); + + if (existing != retriableTxs.end()) + { + auto const existingHash = + existing->second->getTransactionID(); + if (existingHash == witnessHash) + continue; + + JLOG(j_.error()) + << "Export: signature witness pseudo-tx mismatch" + << " exportTxHash=" << exportTxHash + << " witnessHash=" << witnessHash + << " existingHash=" << existingHash + << " action=replace-with-agreed"; + // The witness is build-time materialization of the + // accepted sidecar, not a base consensus-set transaction. + // Replacing a mismatch keeps the tx stream tied to the + // accepted root instead of preserving stale local input. + retriableTxs.erase(existing); + } + + // Export signatures determine source quorum success and the + // exported-result witness reference, so they must be tx-stream + // input, not only accepted sidecar memory. The matching + // ttEXPORT consumes this pseudo through the BuildLedger + // pre-scan; the pseudo itself has no ledger effect. + retriableTxs.insert(std::make_shared(std::move(witness))); + } + } + //@@end export-witness-from-accepted-root + else if (!consensusExportTxns_.empty()) + { + JLOG(j_.warn()) + << "Export: not injecting signature witnesses" + << " reason=no-ledger-anchored-validator-view" + << " seq=" << seq + << " candidateExportTxns=" << consensusExportTxns_.size(); + } + } + //@@start accept-time-cleanup-success - // Clear round-local RNG state while preserving proposal-carried Export - // shares for the collector's round-boundary lifecycle. + // Export's ledger-defining signature witness is now in the tx stream. + // After this point build/replay must use the pre-scanned pseudo, not + // ephemeral sidecar state retained from consensus establish. clearRngStatePreservingExport(); //@@end accept-time-cleanup-success } @@ -2065,6 +2509,9 @@ ConsensusExtensions::logPosition( << " entropySetHash=" << (pos.entropySetHash ? to_string(*pos.entropySetHash) : std::string{"none"}) + << " exportSigSetHash=" + << (pos.exportSigSetHash ? to_string(*pos.exportSigSetHash) + : std::string{"none"}) << " exportSignaturesHash=" << (pos.exportSignaturesHash ? to_string(*pos.exportSignaturesHash) @@ -2432,6 +2879,7 @@ ConsensusExtensions::onTick(TickContext const& ctx) } else { + consensusTxSetMap_.reset(); consensusExportTxns_.clear(); consensusTxSetHash_.reset(); } diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index 9569e4e18..6b2c2cca8 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -68,6 +68,7 @@ public: using ActiveValidatorView = ripple::ActiveValidatorView; using ActiveValidatorViewPtr = std::shared_ptr; + using ExportSignatureSnapshot = std::map; private: enum class RngContributionKind : uint8_t { commit, reveal }; @@ -91,14 +92,20 @@ private: // Real SHAMaps for the current round (unbacked, ephemeral) std::shared_ptr commitSetMap_; std::shared_ptr entropySetMap_; + std::shared_ptr exportSigSetMap_; // Candidate entropy maps are local snapshots until the gate accepts the // exact root. This hash is set only after the alignment/observation checks // pass. std::optional acceptedEntropySetHash_; + // Export signature maps are also built from local collector state before + // accept. Closed-ledger export apply may only consume the map root the + // sidecar gate accepted for this round. + std::optional acceptedExportSigSetHash_; std::optional rngRoundSeq_; // Consensus parent ledger hash, pinned at round start. Input to the // Tier 1 consensus_fallback entropy digest. uint256 roundPrevLedgerHash_; + std::shared_ptr consensusTxSetMap_; hash_map> consensusExportTxns_; std::optional consensusTxSetHash_; @@ -123,6 +130,9 @@ public: std::chrono::steady_clock::time_point commitHashConflictStart_{}; bool entropySetPublished_{false}; std::chrono::steady_clock::time_point entropyPublishStart_{}; + bool exportSigGateStarted_{false}; + std::chrono::steady_clock::time_point exportSigGateStart_{}; + bool exportSigConvergenceFailed_{false}; private: void @@ -178,6 +188,12 @@ public: std::size_t quorumThreshold() const; + std::size_t + exportRootAlignmentThreshold() const; + + static std::size_t + exportRootAlignmentThreshold(ActiveValidatorView const& validatorView); + std::size_t exportWitnessThreshold() const; @@ -292,6 +308,9 @@ public: bool exportEnabled() const; + bool + testSuppressExportSigSetHash() const; + bool testBootstrapFastStartEnabled() const; @@ -301,6 +320,33 @@ public: uint256 buildEntropySet(LedgerIndex seq); + uint256 + buildExportSigSet(LedgerIndex seq); + + bool + hasPendingExportSigs() const; + + bool + hasConsensusExportTxns() const; + + void + setExportSigConvergenceFailed(); + + bool + exportSigConvergenceFailed() const; + + void + acceptExportSigSet(uint256 const& hash); + + void + clearAcceptedExportSigSet(); + + std::optional + agreedExportSignatures( + STTx const& exportTx, + uint256 const& txHash, + std::size_t threshold) const; + ActiveValidatorViewPtr activeValidatorView() const; @@ -515,7 +561,9 @@ public: bool extensionsBusy() const { - return estState_ != EstablishState::ConvergingTx; + return estState_ != EstablishState::ConvergingTx || + (exportEnabled() && + (exportSigGateStarted_ || hasPendingExportSigs())); } EstablishState @@ -532,6 +580,9 @@ public: commitHashConflictStart_ = {}; entropySetPublished_ = false; entropyPublishStart_ = {}; + exportSigGateStarted_ = false; + exportSigGateStart_ = {}; + exportSigConvergenceFailed_ = false; } }; diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 66099b2eb..d5ff5b1fb 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -1,7 +1,7 @@ # Consensus Extension Design Principles This note captures the principles behind the Xahau consensus extensions: -ConsensusEntropy/RNG, proposal sidecars, and Export signature transport. +ConsensusEntropy/RNG, proposal sidecars, and export signature convergence. Read this before changing `ConsensusExtensions`, `ConsensusExtensionsTick`, `ExtendedPosition`, sidecar SHAMap handling, or the related CSF tests. @@ -15,11 +15,13 @@ extension timing must not create divergent closed-ledger effects when a bounded coordination step can avoid it. Fast means those coordination steps stay short and conditional, never becoming an open-ended wait for an extension feature to succeed. Works means missed or late extension material follows that feature's -deterministic fallback rather than blocking core consensus. +deterministic fallback, such as Tier 1 consensus_fallback entropy for RNG or normal +Export retry/expiry, rather than blocking core consensus. ## Fallback Semantics -RNG closes with a deterministic consensus-bound fallback +RNG and Export use similar positive-path sidecar gates, but they do not have +the same safe fallback. RNG closes with a deterministic consensus-bound fallback digest (Tier 1) in either of two cases: (1) when peers cannot establish an accepted participant_aligned or validator_quorum entropy set in time, or (2) whenever the round's active validator view is not UNLReport-backed — no on-ledger @@ -32,7 +34,9 @@ sequence) is already consensus-agreed at injection time, so no second agreement is needed. The result is explicitly labeled (`EntropyTier = consensus_fallback`, `EntropyCount = 0`) — it is user-influenceable via transaction submission and must never be presented -under validator-entropy semantics. +under validator-entropy semantics. Export has no equivalent fallback value: +without quorum-aligned verified export signatures, the export must not be +treated as complete and must retry or expire under transaction rules. The fallback/non-fallback decision is itself ledger-defining. A local node may diagnose that progress looks unlikely from its current peer view, but it must @@ -56,16 +60,18 @@ sidecar gate has had its bounded chance to use proofed/quorum material. `ExtendedPosition` has no whole-position equality or implicit `uint256` conversion. Callers that need the ordinary consensus identity compare `txSetHash` explicitly, or use the generic `positionTxSetID(position)` helper - in templated consensus code. RNG commit-set and entropy-set hashes are - proposal sidecars. Export proposal blobs are bound by a payload digest, but - there is no Export signature-set root or establish-phase Export gate. + in templated consensus code. RNG, export sig, commit-set, and entropy-set + hashes are proposal sidecars. They are coordinated during establish, but they + do not define whether peers agree on the ordinary transaction set. 2. Extension waits are bounded. - RNG sidecar convergence may wait briefly inside establish, but it must not - block ledger close indefinitely. If RNG cannot establish + RNG and export sidecar convergence may wait briefly inside establish, but + they must not block ledger close indefinitely. If RNG cannot establish an accepted entropy set, it injects the deterministic Tier 1 - consensus_fallback digest (labeled `consensus_fallback`, count 0). + consensus_fallback digest (labeled `consensus_fallback`, count 0). If export + signatures cannot converge, export retries or expires according to + transaction rules. The bounded fallback rule is not permission for local shortcuts to decide ledger output. "Cannot establish" means the accepted-hash gate did not @@ -82,20 +88,20 @@ sidecar gate has had its bounded chance to use proofed/quorum material. 4. Align signed inputs, not just derived outputs. - RNG commits and reveals are the verifiable inputs. The design aligns on - signed roots over those input sets using local sidecar SHAMap snapshots. The - final entropy digest is derived from the accepted local snapshot, not from - live collector state. + RNG commits, RNG reveals, and export signatures are the verifiable inputs. + The design aligns on signed roots over those input sets using local sidecar + SHAMap snapshots. The final entropy digest and export quorum result are + derived from the accepted local snapshot, not from live collector state. 5. Sidecars are not transactions. - Commit and reveal entries are `STObject(sfGeneric)` leaves in ephemeral - `SHAMapType::SIDECAR` maps. They use `sfSidecarType` + Commit, reveal, and export signature entries are `STObject(sfGeneric)` + leaves in ephemeral `SHAMapType::SIDECAR` maps. They use `sfSidecarType` to distinguish payloads and `HashPrefix::sidecar` for item hashes. Current same-round consensus does not advertise, fetch, serve, or merge these maps from peers. The maps are local immutable snapshots used to materialize the root a node signed into its proposal and, if accepted, to build the - ledger-visible entropy pseudo. + ledger-visible pseudo/witness. 6. Proposal-visible or validation-visible extension data must be signed. @@ -116,11 +122,12 @@ sidecar gate has had its bounded chance to use proofed/quorum material. A sidecar root proves only byte identity for the local snapshot that produced it. It does not prove that a contribution is well-formed, authorized, or - round-correct. Proposal-carried commits, reveals, and Export signatures must + round-correct. Proposal-carried commits, reveals, and export signatures must pass cheap structural checks, safe key-type checks, active-view membership, - and the relevant cryptographic proof before entering pending state. Cluster - trust may affect relay and resource policy, but material must be harvested - only after the signed proposal verifies against the claimed validator key. + and the relevant cryptographic proof before entering pending RNG/export + state. Cluster trust may affect relay and resource policy, but extension + sidecars become ledger inputs and must be harvested only after the signed + proposal verifies against the claimed validator key. 8. Ledger-defining sidecar material crosses apply as transaction-stream input. @@ -133,7 +140,7 @@ sidecar gate has had its bounded chance to use proofed/quorum material. ## Validator Set And Quorum -The active validator view is used by RNG and retained Export witness checks: +The active validator view is the shared denominator for RNG and export: - Prefer `UNLReport.sfActiveValidators` from the consensus parent ledger. - If no report is available, fall back to configured trusted validators so @@ -148,7 +155,7 @@ The active validator view is used by RNG and retained Export witness checks: When `featureNegativeUNLActiveViewCap` is enabled, NegativeUNL vote production also uses the parent-ledger `UNLReport.sfActiveValidators` count as the 25-percent disable-cap denominator. This aligns the producer-side nUNL vote -policy with the active-view universe that these checks use. Without +policy with the active-view universe that RNG and Export proofs use. Without that amendment, legacy NegativeUNL voting can still cap against the locally configured trusted UNL size; the consumer-side active-view builder remains defensive and caps any raw ledger NegativeUNL overage against `originalViewSize`. @@ -190,19 +197,24 @@ mechanism in base consensus. Transaction sets are fetchable by hash; proposals are not. A node that joins or falls behind mid-round normally observes for a few ticks/rounds until the relayed proposal stream is coherent enough to participate. -RNG adds a stricter requirement on top of that proposal stream: its sidecar -roots are accepted by absolute quorum over a fixed parent-ledger validator -denominator, not by percentages over whichever proposers this node happens to -observe. Export proposal blobs remain signed transport only. +RNG and Export add a stricter requirement on top of that proposal stream: their +sidecar roots are accepted by absolute quorum over a fixed parent-ledger +validator denominator, not by percentages over whichever proposers this node +happens to observe. That fixed denominator is intentional. It gives the +sidecar gates deterministic, intersection-safe semantics: two quorum-aligned +cohorts cannot both make conflicting sidecar roots ledger material under the +same active-view assumptions. The cost is that missed proposal-borne material +does not shrink the target the way observed-proposer percentages do; it leaves +the node short of the fixed quorum. This branch deliberately does not add same-round sidecar reconciliation for that gap. Sidecar roots are still signed into proposals, but the backing `SHAMapType::SIDECAR` maps are local snapshots only. They are not advertised, served, fetched, or merged from peers, and generic transaction-set acquisition must reject them. A node that missed proposal-carried material may therefore be -unable to materialize the quorum root this round. RNG falls back to the -explicit Tier 1 consensus digest or accepts a lower locally materialized tier. -If a quorum of validators did +unable to materialize the quorum root this round. For RNG it falls back to the +explicit Tier 1 consensus digest or accepts a lower locally materialized tier; for +Export the transaction retries or expires. If a quorum of validators did materialize and validate a richer synthetic ledger, a missing-material validator follows that ledger through the normal validation/LCL path after the round, just as it would after failing to build any other majority ledger. @@ -391,12 +403,11 @@ Sidecar SHAMaps are local immutable snapshots: - Peer-advertised roots are alignment evidence, not payload availability. - Nodes never fetch, advertise, serve, or merge sidecar maps from peers. - If a quorum root cannot be materialized locally before the bounded deadline, - RNG degrades or falls back. + RNG degrades/falls back and Export retries/expires. -Do not use avalanche-style transaction inclusion logic for RNG sidecar inputs. -The disagreement to resolve is usually timing or delivery, not whether a valid -contribution should be included. Export witnesses follow ordinary -transaction-set admission after this transition. +Do not use avalanche-style transaction inclusion logic for sidecar inputs. +For RNG and export sidecars, the disagreement to resolve is usually timing or +delivery, not whether a valid contribution should be included. The entropy sidecar gate always gives peers at least one observation tick after publishing `entropySetHash`. Publishing and accepting in the same tick can hide @@ -407,41 +418,187 @@ that quorum root will not build the richer synthetic ledger in that round. ## Export Principles -`ExportIntent.md` records the current transition boundary and TODOs. +`ExportIntent.md` is the normative spine for Export invariants, especially the +replay-witness rule. This section explains the current mechanics and should not +be read as permission to make closed-ledger Export output depend on ephemeral +sidecar memory. `featureExport` and `featureConsensusEntropy` are independently amendment gated. -### Export transition state +Export can run without ConsensusEntropy and still uses the active validator +view's 80% quorum threshold. Verified export signature sidecars converge +through `ExtendedPosition`, and the `exportSigSetHash` is signed by proposals +whether or not RNG is enabled. Do not make Export liveness depend on unanimity: +one active validator with a missing, delayed, or conflicting sidecar must not +veto an otherwise quorum-aligned export round. -The former Export sidecar authority has been removed. `ExtendedPosition` no -longer carries an Export signature-set root, the consensus tick has no Export -root-alignment gate or wait, and `onPreBuild` does not synthesize or replace an -Export witness from process-local collector state. Export therefore cannot -change a closed ledger through a second accept-time decision outside ordinary -transaction-set consensus. +Non-standalone Export completion requires a UNLReport-backed active validator +view. If the parent ledger has no `UNLReport`, Export has no safe deterministic +fallback result, so validators do not publish target-chain signature shares and +the export retries or expires rather than finalizing against local +trusted-configuration thresholds. -Proposal-carried `exportSignatures` blobs remain as bounded signature transport. -Their ordered digest is bound into the signed proposal through -`exportSignaturesHash`, proposal precheck rejects missing or mismatched payloads, -and the harvester still verifies and stores valid shares. The current attachment -trigger still inspects the open ledger; those shares are not ledger authority -and cannot complete an Export after this excision. +Export's original pre-NegativeUNL validator population must also fit the +32-member target serialization bound. Validators publish no target-chain shares +and Export cannot materialize while it exceeds that bound; temporary NegativeUNL +filtering must not select an implicit bridge committee. -`ttEXPORT_SIGNATURES` remains the canonical witness interface for direct apply -and historical replay. Ledger build can pre-scan a witness already present in -the ordered transaction stream, and `Export::doApply` verifies its signatures -and threshold against the parent-ledger active validator view. There is -deliberately no live network producer for that witness in this transition -commit, so a network Export without an explicit canonical witness retries or -expires. The follow-up design must release shares only for validated Export -intents and admit a locally qC-valid witness through ordinary transaction-set -consensus. +The bounded deployment contract then mirrors the source validator-derived key +universe, weights, and Export/validation threshold in the destination account's +SignerList. A lower destination threshold permits target execution before the +authority needed for source materialization exists. A higher threshold preserves +that safety direction but can strand a successful source latch. The destination +network's ledger-validation quorum is separate: it validates the authorized +transaction's containing ledger, and XPOP proves that finality on return. A +static destination SignerList should remain anchored to the original source +universe during NegativeUNL periods, trading Export liveness for unchanged +destination authority. -The target-chain signer cap, normalized intent-hash latch, witness reference, -and historical replay checks remain relevant to that follow-up. They must not -be used to reintroduce an ephemeral accepted root or a process-local apply-time -decision. +The bounded MVP deployment requires a submitter co-signer in the destination +SignerList, held by the target-submission process. Choose weights so destination +quorum requires both the source-validator threshold and that key; for validator +weight total `V` and required validator weight `q`, submitter weight `V` with +target quorum `V + q` is the simple construction. Proposal-carried validator +shares then remain inert until the submitter observes a validated source latch +and signs. The key cannot authorize alone but becomes a liveness and censorship +dependency. It consumes one destination SignerList entry, so this deployment +supports at most 31 source validator identities under a 32-entry limit. + +The current code has no committee selector. A future committee must be explicit, +ledger-anchored, and versioned; a locally configured or sorted UNL subset would +reintroduce nondeterministic or unnamed authority. Full source consensus would +still validate the Export, while committee quorum plus the submitter would be +the honestly stated destination authority. + +The extended proposal machinery is enabled when either feature needs signed +sidecar fields. Do not make Export depend on RNG availability just because RNG +was the first consumer of `ExtendedPosition`. + +Rollout invariant: once a network enables `featureConsensusEntropy`, proposal +messages may use the legacy `currenttxhash` protobuf field to carry a serialized +`ExtendedPosition`, not just a raw 32-byte transaction-set hash. This is a +proposal wire-format change, not a sidecar-reconciliation detail. Disabling +sidecar fetch/reconciliation does not restore compatibility with older binaries +that require `currenttxhash` to be exactly 32 bytes. A network that activates CE +therefore needs every binary expected to process live proposals to understand +the extended position format, or it needs explicit version/capability +negotiation before activation. + +When `featureExport` is disabled, the export sidecar gate is disabled too. Stale +collector entries must not keep a stopped amendment active. + +Only verified export signatures count toward quorum or enter export sidecar +SHAMaps. Proposal-ingress signatures are sender-bound to the trusted proposal +validator and may be stored as unverified until the matching export transaction +is available for cryptographic verification. + +The consensus candidate transaction set is the authority for export signature +verification. The open ledger may be used for early proposal ingestion, but +once a candidate tx set exists, only signatures verified against the `ttEXPORT` +in that candidate set may become quorum material or enter `exportSigSetHash`. + +Export sidecar publication is local-material only. A node may publish only the +verified export signatures it actually has locally, and only for `ttEXPORT` +transactions in the consensus candidate set. Peer-advertised export roots are +used for quorum alignment; they do not reconstruct missing signature material. +The accepted local snapshot root, once quorum-aligned, is the source for the +ledger witness. A node that cannot materialize the accepted quorum witness +locally retries or expires the export and follows the quorum ledger later through +normal validation if other validators built the witness. +`ttEXPORT_SIGNATURES` is the export signature witness interface. Network mode +derives it from the accepted `exportSigSetHash` sidecar snapshot; standalone/dev +helpers may synthesize the same witness from the local validator key. The pseudo +carries the full source-side validator signature witness and binds it to the +matching `ttEXPORT` via `sfTransactionHash`. It has no ledger-state effect by +itself; it exists so apply/replay sees the same signature material through the +transaction stream regardless of which producer supplied it. + +If the consensus candidate contains a `ttEXPORT` but the node has no eligible +local export signatures yet, the export sidecar gate opens only a bounded +safety window for tx-converged peers to advertise `exportSigSetHash`. This is +not a wait-for-Export-success mechanism; it is a short opportunity to avoid +closing a minority ledger while sidecar convergence is already reachable. If no +advertised sidecar appears by the deadline, the gate stops waiting and the +export retries or expires through normal transaction rules. + +Export success requires quorum alignment on `exportSigSetHash`, not merely a +local collector quorum. Since `featureExport` enables signed extended proposal +fields, a quorum-aligned `exportSigSetHash` is enough to proceed even if a +tx-converged minority peer has not advertised an export sidecar hash. Do not let +one active validator with a missing sidecar force an otherwise quorum-aligned +export round to retry or expire. Full observation remains useful diagnostics; it +is not an Export success precondition. If no export signature hash reaches quorum +alignment by the bounded deadline, do not choose the largest non-quorum set; the +export retries or expires according to normal transaction rules. +Closed-ledger apply consumes the pre-scanned `ttEXPORT_SIGNATURES` witness, not +the live collector and not ephemeral sidecar state. `Export::doApply` rebuilds +the active validator view from the parent ledger, verifies each witness +signature against the `ttEXPORT` inner transaction, requires source-view quorum, +then canonically assembles the target-chain multisigned transaction. In live +consensus builds, `onPreBuild` first removes any pre-existing export witness and +re-materializes the witness from the accepted `exportSigSetHash` root. That +accepted transaction-stream witness supplies signer membership; apply must not +re-resolve those signing keys through the current manifest cache, because +manifest gossip can differ while the parent-ledger active view is the same. +Historical `LedgerReplay` consumes the same persisted witness after manifests +may have rotated. In both modes, apply still checks signatures and threshold. A +node that times out before accepting a root has no witness and retries/expires; +a node that proceeds uses the same transaction-stream witness during live build +and historical replay. The build-scoped witness map is only an index over that +ordered transaction stream, not hidden consensus state. This avoids +successful-but-different export blobs while preserving the bounded wait model. + +`sfExportResult` metadata stores `sfExportSignatureHash`, a direct reference to +the witness pseudo, rather than duplicating the full signature payload. Clients +assemble the final foreign-chain blob from `ttEXPORT` plus the witness +signatures, or can use a convenience RPC/helper that performs that pure +read-time assembly. That expansion must match `ExportResultBuilder`: canonical +AccountID signer ordering, empty `SigningPubKey`, and the target-chain signer +cap (`STTx::maxMultiSigners()`) before hashing or submitting. The witness can +carry more source-side signatures than the destination transaction may include. +Export currently refuses to sign or materialize when the original UNLReport +validator population exceeds that cap, even if NegativeUNL temporarily shrinks +the effective view below it. The assembly cap remains a defensive serialization +bound, not an implicit committee-selection policy. + +The resulting shadow ticket stores the normalized target transaction's canonical +signing hash in `sfDigest`, not one assembled multisigned transaction ID. Import +therefore accepts any destination-valid signer subset for that exact signing +intent. A missing latch returns `telSHADOW_TICKET_REQUIRED` before consensus or +Hook execution, so an XPOP that races source materialization can be relayed +later. + +Shadow-ticket cancellation is source resource reclamation. It releases account +reserve and an outstanding-ticket slot when a round trip is abandoned, but it +cannot revoke shares already published to peers. Operators should delete a latch +only with external evidence such as target expiry, destination Ticket +consumption, or SignerList invalidation, or with an explicit policy that accepts +later target execution without callback readiness. + +This is intentionally leaner than XPOP. XPOP carries its own UNL and manifest +bundle so it can be independently verified as an external proof. Export witnesses +are not external proof bundles; they are inputs that made it into validated +ledger history. Making them self-contained would require embedding manifest +material or equivalent signing-key history in every witness, which is a separate +protocol/storage design. + +Closed-ledger apply must not promote unverified proposal-carried signatures into +current-round quorum material. It may verify and retain them for a future retry, +where they can be published in a sidecar set and converged before use. + +Export sig convergence runs in parallel with RNG. An export-side convergence +failure must not change RNG semantics; an RNG fallback must not make export +unsafe. Each feature has its own gate and fallback. + +Accept-time cleanup must preserve Export state through `onPreBuild` whenever +`featureExport` is enabled so the signature witness pseudo can be injected. +After the ordered transaction set contains that witness, replay must not need +the round's export sidecar convergence state. + +CSF consensus tests model the export sidecar gate directly. Testnet scenarios +under `.testnet/scenarios/export/` cover live-node Export+CE behavior and +Export-only quorum behavior. ## Review Checklist @@ -465,10 +622,10 @@ When changing consensus extension code, check these questions: - Are proposal-visible or validation-visible sidecar fields covered by the relevant signature and duplicate/replay identity? - Are export signatures verified before they count? -- Are proposal-carried Export shares treated as transport only, never as - independent closed-ledger authority? +- Does export success require `exportSigSetHash` alignment, not just local + collector quorum? - Does every ledger-defining export signature enter replay as a `ttEXPORT_SIGNATURES` witness before `ttEXPORT` applies? -- Does a live witness candidate satisfy local qC before ordinary transaction-set - admission? A peer vote cannot substitute for missing local signatures. +- Can one bad validator deny Export to an honest quorum? It must not. +- Can timeout select a largest-but-below-quorum export sidecar set? It must not. - Are CE and Export still independently gated and independently stoppable? diff --git a/src/xrpld/app/consensus/ExportIntent.md b/src/xrpld/app/consensus/ExportIntent.md index b4c06d178..aba78f6f9 100644 --- a/src/xrpld/app/consensus/ExportIntent.md +++ b/src/xrpld/app/consensus/ExportIntent.md @@ -1,32 +1,193 @@ -# Export Design Transition +# Export — Design Intent (canonical spine) -The former same-ledger Export sidecar authority has been removed. This file is -intentionally minimal until its replacement is implemented and reviewed. +This is the **normative** intent for `featureExport`: the invariants that must +hold regardless of how the implementation is refactored. The verbose mechanics +live in `ConsensusExtensionsDesign.md`; the reviewer-facing walkthrough lives in +the PR description. **Both defer to this file.** -## Current State +How to use it: if code contradicts an invariant below, the *code* is wrong — or +the invariant is being changed and **this file must be consciously edited in the +same change, with the rationale**. In particular, Export is not replay-clean +unless every closed-ledger effect can be rebuilt from the parent ledger and the +closed transaction set alone. -- Proposal `exportSignatures` payloads remain bounded and are signed through - `exportSignaturesHash`. -- Signature harvesting and collection remain available as transport plumbing. -- `ttEXPORT_SIGNATURES` remains readable by direct apply and historical replay. -- No Export signature-set root, establish-phase Export gate, accepted-root - state, or `onPreBuild` witness materializer remains. -- There is currently no live network producer for a canonical Export witness; - network Export retries or expires without one. +## Purpose (one line) -## TODO +Export lets a quorum of active validators produce a foreign-chain-submittable +transaction from an agreed `ttEXPORT`, without letting local sidecar timing, +collector state, or validator silence change the bytes of a successful closed +ledger result. -- Persist the validated Export intent/latch and its chosen validator universe. -- Release and relay validator signatures only after the origin ledger validates. -- Require local qC before a witness becomes an ordinary transaction-set - candidate; peer popularity must never substitute for missing evidence. -- Decide and specify the canonical next-ledger pseudo. The leading option is a - self-verifying pseudo containing the fully assembled ordinary XRPL multisigned - transaction, linked to the origin Export transaction and source ledger. -- Keep callback identity bound to the normalized unsigned target intent, not to - one signature-envelope-dependent target transaction ID. -- Specify retry, expiry, bump, subscription, Import, and historical replay - behavior before enabling live completion. +## Invariants -The active design record and evidence live under `.ai-docs/`; source comments -should not recreate superseded designs while these TODOs remain open. +**INV-1 — Quorum, not unanimity.** +Export success is gated by active-validator quorum alignment on the export +signature set. A missing, delayed, or silent minority must not veto an otherwise +quorum-aligned export. +*Anti-pattern:* requiring full observation of every tx-converged validator before +success. + +**INV-2 — Fixed active-view denominator.** +Export thresholds are computed over the parent-ledger active validator view. The +denominator must never be derived from locally observed peers, locally available +signatures, or the subset that happened to advertise sidecar hashes. +On networks that use NegativeUNL, `featureNegativeUNLActiveViewCap` should be +active before or with Export so producer-side nUNL voting caps against the same +UNLReport active-source universe that bounds NegativeUNL shrink. Export quorum +itself is computed over the effective post-NegativeUNL active view. Direct +Export apply still rebuilds and defensively caps the parent active view before +checking the witness threshold. +*Anti-pattern:* letting silence shrink the quorum threshold. + +**INV-3 — Accepted sidecar root, not live collector.** +Any successful Export apply path must use the signature set rooted at the +`exportSigSetHash` accepted by the tick gate. Late local collector arrivals, +timeout flags, or unverified proposal-carried signatures must not change the +signer set selected for the ledger. +*Anti-pattern:* assembling from `ExportSigCollector` at apply time. + +**INV-4 — Replay witness in the transaction stream.** +If export sidecar material changes closed-ledger output, that material must be +represented by canonical ledger input before apply. The closed ledger must be +replayable from `(parent ledger, ordered closed transaction set)` without live +consensus sidecar memory. Export signatures are such a witness: they determine +source quorum success and exported-result metadata, so they must be carried by +a replayable companion pseudo transaction or equivalent transaction-stream +artifact. The shadow-ticket intent hash is signature-independent. +*Anti-pattern:* using ephemeral accepted sidecar state to create a shadow ticket +or result that cannot be reconstructed by ledger delta replay. + +Current shape: `ttEXPORT_SIGNATURES` is the signature witness interface. It +carries the full source-validator signature witness and binds it to the matching +`ttEXPORT` via `sfTransactionHash`. Network consensus produces it from the +accepted sidecar set; standalone/dev helpers may produce the same witness from +the local validator key. Ledger build pre-scans the ordered transaction stream +into a build-local index, and `ttEXPORT` apply consumes that pre-scanned witness. +The index is not an extra consensus input; it is only an efficient lookup over +the canonical transaction set. In live consensus builds, `onPreBuild` first +removes any pre-existing export witness and re-materializes the witness from the +accepted sidecar root. That witness is the signer-membership source for apply; +current manifest-cache state must not re-decide which accepted signing keys +count. Historical `LedgerReplay` uses the same membership rule because current +manifests may no longer map old rotated signing keys. Both paths still verify +each signature against the inner transaction and require the parent-view +threshold. Direct apply paths that did not run `onPreBuild` remain conservative +and filter witness signers through the live active-validator view. + +**INV-5 — Store the witness once.** +The signature witness is canonical input; metadata is output. Metadata may carry +hashes and references for client discovery, but it should not duplicate the full +signature payload merely to avoid a client dereference. A convenience RPC may +expand `ttEXPORT + witness` into the foreign-chain-submittable blob on read. +*Anti-pattern:* storing the same validator signatures once as replay input and +again as a full metadata blob without a separate consensus reason. + +**INV-6 — Bounded retry window.** +An Export that cannot obtain quorum-aligned signatures within its bounded ledger +window retries or expires through normal transaction semantics. It must not wait +unboundedly, pick the largest sub-quorum set, or finalize against local trusted +configuration as a fallback. + +The success-vs-retry decision remains a bounded timing edge, like ordinary +consensus convergence: one node may observe the quorum-aligned witness before +its deadline while another retries. Validation resolves that ledger disagreement. +What must never happen is a "successful" export whose signature bytes come from +live collector state, late proposal arrivals, or a node-local sub-quorum set +instead of the accepted witness in the transaction stream. + +**INV-7 — Shadow tickets are latches, not global tombstones.** +The shadow-ticket object binds the canonical target signing intent, +not one authorization-envelope-dependent target transaction ID. Any +destination-valid execution of that exact intent may complete the callback. +Deletion permits a later re-mint of the same `(account, ticketSequence)` latch, +so replay protection beyond the live latch is a separate protocol decision, not +an implicit property of shadow tickets. + +Cancellation exists to reclaim the account reserve and bounded outstanding- +ticket slot when a round trip will not complete. It deletes callback readiness; +it does not revoke target-chain signatures already published in proposals. Safe +cleanup therefore depends on external evidence that the capability is no longer +executable or the callback is intentionally abandoned, such as target +`LastLedgerSequence` expiry, destination Ticket consumption, or SignerList +invalidation. + +**INV-8 — Export signatures are public capabilities.** +Proposal-carried signature shares may be observed, assembled, and submitted as +soon as destination quorum exists. Source-side witness agreement governs what +Xahau records; it is not a confidentiality or destination-execution gate. +Import therefore waits outside consensus when its shadow ticket does not yet +exist and matches a later XPOP against the signature-independent intent. +The main defense for exposing shares before source finality is authority +equivalence: destination execution must require the same validator-derived +authority that Xahau requires to validate and materialize the Export. +*Anti-pattern:* relying on proposal timing or canonical signer selection to +hide or delay an otherwise valid destination transaction. + +**INV-9 — The active source authority must fit the destination protocol.** +Export does not publish shares without a ledger-anchored `UNLReport`, and does +not publish shares or materialize a result when the source validator population +before NegativeUNL filtering exceeds `STTx::maxMultiSigners()`. Silently +selecting a capped subset would replace source-view authority with an implicit +bridge committee. Any future bounded committee must be an explicit, separately +reviewed policy. + +The current source implementation enforces the 32-signer bound. The bounded MVP +deployment additionally requires one destination submitter co-signer, so its +practical full-view limit is 31 source validator identities. Source code cannot +inspect that remote configuration; activation tooling and monitoring must. + +**INV-10 — Source and destination authorization must be equivalent.** +The bounded deployment contract uses the same validator-derived key universe +and equivalent weighted threshold for Xahau Export/validation and the target +account's SignerList. A lower destination threshold defeats the pre-finality +share-exposure defense. A higher threshold is safety-conservative but can leave +a successful source latch without enough witness authority to execute. + +The target network's ledger-validation quorum is independent: the SignerList +authorizes the account transaction, target consensus validates the containing +ledger, and XPOP later proves that finality. Because an ordinary target +SignerList is static, configure it against the original pre-NegativeUNL source +universe and accept reduced Export liveness during NegativeUNL periods rather +than lowering destination authority. + +The bounded MVP requires a submitter co-signer held by the target-submission +process. If validator weights total `V`, validator threshold is `q`, submitter +weight is `C`, and target quorum is `C + q > V`, validator shares alone cannot +execute and the submitter still needs validator weight `q`. It signs only after +observing the validated source latch, turning the submitter into a +liveness/censorship dependency rather than a sole safety authority. + +A future committee may decouple total UNL size from the destination cap, but it +must be explicit ledger-anchored source state with versioned membership and +rotation. Full source consensus validates the intent; at most 31 committee keys +supply shares; the submitter releases after validation. The target trust claim +then becomes committee quorum plus submitter, not full-UNL destination authority. + +## Replay Witness Shape + +The accepted local export sidecar snapshot is not consumed directly by +`Export::doApply`. +Before ledger build, a producer injects one `ttEXPORT_SIGNATURES` pseudo for +each export that has usable signatures. In network mode that producer is the +consensus extension accept path; in standalone/dev mode it can be a local helper. The +pseudo has no ledger-state effect by itself; it is the ledger's replay witness +for the validator signatures. + +Metadata stores `sfExportSignatureHash`, a direct reference to the witness +pseudo, rather than duplicating the signature payload as an assembled +`sfExportedTxn` blob. Clients assemble the final foreign-chain transaction from +the original `ttEXPORT` inner transaction plus the witness signatures. Assembly +must follow the same deterministic contract as `ExportResultBuilder`: sort +signers canonically by AccountID, use an empty `SigningPubKey`, and cap the +target-chain `Signers` array at `STTx::maxMultiSigners()` before computing or +submitting the blob. The witness may contain extra source-side signatures that +are valid replay input but are not part of the target-chain blob. Source-chain +export does not prove the destination account's SignerList or quorum policy; +that compatibility is an operator/client contract for the chosen target chain. + +This is not an XPOP-style self-contained proof. XPOP embeds its UNL and manifest +bundle because it is imported as external proof material. Export witnesses are +validated-history replay inputs. If we later want trustless historical +re-verification without relying on validated inclusion, the larger design is to +ledger-anchor validator signing-key history (for example via `UNLReport`) or to +embed manifest proof material; that is intentionally out of scope here. diff --git a/src/xrpld/app/consensus/ProposalPrecheck.h b/src/xrpld/app/consensus/ProposalPrecheck.h index 52968db79..3472190e9 100644 --- a/src/xrpld/app/consensus/ProposalPrecheck.h +++ b/src/xrpld/app/consensus/ProposalPrecheck.h @@ -121,7 +121,7 @@ checkProposalExtensions( parsedPosition->myReveal; bool const hasExtensionDiagnostics = parsedPosition->observedParticipantsHash.has_value(); - bool const hasExportMaterial = + bool const hasExportMaterial = parsedPosition->exportSigSetHash || parsedPosition->exportSignaturesHash || set.exportsignatures_size() > 0; if (hasEntropyMaterial && !isEntropyEnabled()) return {ProposalPrecheckResult::entropyDisabled, parsedPosition}; diff --git a/src/xrpld/app/consensus/RCLCxPeerPos.h b/src/xrpld/app/consensus/RCLCxPeerPos.h index 0ce65bc98..cd9e36bbb 100644 --- a/src/xrpld/app/consensus/RCLCxPeerPos.h +++ b/src/xrpld/app/consensus/RCLCxPeerPos.h @@ -57,6 +57,7 @@ struct ExtendedPosition // === Set Hashes (sub-state quorum, not core tx-set identity) === std::optional commitSetHash; std::optional entropySetHash; + std::optional exportSigSetHash; std::optional exportSignaturesHash; // Signed diagnostic only: not a quorum input and not part of tx-set // identity. @@ -126,8 +127,9 @@ struct ExtendedPosition // Wire compatibility: if no extensions, emit exactly 32 bytes // so legacy nodes that expect a plain uint256 work unchanged. - if (!commitSetHash && !entropySetHash && !exportSignaturesHash && - !observedParticipantsHash && !myCommitment && !myReveal) + if (!commitSetHash && !entropySetHash && !exportSigSetHash && + !exportSignaturesHash && !observedParticipantsHash && + !myCommitment && !myReveal) return; std::uint8_t flags = 0; @@ -139,6 +141,8 @@ struct ExtendedPosition flags |= 0x04; if (myReveal) flags |= 0x08; + if (exportSigSetHash) + flags |= 0x10; if (exportSignaturesHash) flags |= 0x20; if (observedParticipantsHash) @@ -153,6 +157,8 @@ struct ExtendedPosition s.addBitString(*myCommitment); if (myReveal) s.addBitString(*myReveal); + if (exportSigSetHash) + s.addBitString(*exportSigSetHash); if (exportSignaturesHash) s.addBitString(*exportSignaturesHash); if (observedParticipantsHash) @@ -169,6 +175,8 @@ struct ExtendedPosition ret["commit_set"] = to_string(*commitSetHash); if (entropySetHash) ret["entropy_set"] = to_string(*entropySetHash); + if (exportSigSetHash) + ret["export_sig_set"] = to_string(*exportSigSetHash); if (exportSignaturesHash) ret["export_signatures"] = to_string(*exportSignaturesHash); if (observedParticipantsHash) @@ -205,9 +213,8 @@ struct ExtendedPosition if (flags == 0) return std::nullopt; - // Reject unknown or retired flag bits (reduces wire malleability). - // 0x10 was the removed Export sidecar-root advertisement. - if (flags & 0x90) + // Reject unknown flag bits (reduces wire malleability) + if (flags & 0x80) return std::nullopt; // Validate exact byte count for the flagged fields. @@ -228,6 +235,8 @@ struct ExtendedPosition pos.myCommitment = sit.get256(); if (flags & 0x08) pos.myReveal = sit.get256(); + if (flags & 0x10) + pos.exportSigSetHash = sit.get256(); if (flags & 0x20) pos.exportSignaturesHash = sit.get256(); if (flags & 0x40) diff --git a/src/xrpld/app/misc/RuntimeConfig.h b/src/xrpld/app/misc/RuntimeConfig.h index 8fcd50b57..3261b8dd9 100644 --- a/src/xrpld/app/misc/RuntimeConfig.h +++ b/src/xrpld/app/misc/RuntimeConfig.h @@ -96,6 +96,9 @@ struct ConsensusTestConfig std::optional rngPollMs; // Disable export signature attachment (testing sub-quorum scenarios). std::optional noExportSig; + // Withhold exportSigSetHash publication while still attaching export + // signatures (testing no-veto missing-observation scenarios). + std::optional noExportSigHash; // Standalone-only entropy selection overrides for hook API tests. std::optional standaloneEntropyTier; std::optional standaloneEntropyCount; @@ -107,7 +110,8 @@ struct ConsensusTestConfig return (rngClaimDropPctX100 && *rngClaimDropPctX100 > 0) || (rngRevealDropPctX100 && *rngRevealDropPctX100 > 0) || (bootstrapFastStart && *bootstrapFastStart) || rngPollMs || - (noExportSig && *noExportSig) || standaloneEntropyTier || + (noExportSig && *noExportSig) || + (noExportSigHash && *noExportSigHash) || standaloneEntropyTier || standaloneEntropyCount || standaloneEntropyDenominator; } }; diff --git a/src/xrpld/app/misc/detail/RuntimeConfig.cpp b/src/xrpld/app/misc/detail/RuntimeConfig.cpp index 94ce05b81..bf4fc8f8e 100644 --- a/src/xrpld/app/misc/detail/RuntimeConfig.cpp +++ b/src/xrpld/app/misc/detail/RuntimeConfig.cpp @@ -245,7 +245,7 @@ isGlobalField(std::string const& name) { return name == "rng_claim_drop_pct" || name == "bootstrap_fast_start" || name == "rng_reveal_drop_pct" || name == "rng_poll_ms" || - name == "no_export_sig"; + name == "no_export_sig" || name == "no_export_sig_hash"; } bool @@ -445,6 +445,13 @@ parseConsensusTestConfig(Json::Value const& v, std::string& error) return std::nullopt; cfg.noExportSig = parsed; } + else if (name == "no_export_sig_hash") + { + bool parsed = false; + if (!parseBool(v[name], name, parsed, error)) + return std::nullopt; + cfg.noExportSigHash = parsed; + } } return cfg; } @@ -517,6 +524,8 @@ consensusTestConfigJson(ConsensusTestConfig const& cfg) entry["rng_poll_ms"] = *cfg.rngPollMs; if (cfg.noExportSig.has_value()) entry["no_export_sig"] = *cfg.noExportSig; + if (cfg.noExportSigHash.has_value()) + entry["no_export_sig_hash"] = *cfg.noExportSigHash; return entry; } } // namespace diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index dffff201d..07e5f1f40 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -320,6 +320,10 @@ Export::doApply() << " witnessSigs=" << signatures.size() << " collectorSigs=" << sigCount << " threshold=" << threshold << " unlSize=" << unlSize + << " exportSigConvergenceFailed=" + << (consensusExtensions.exportSigConvergenceFailed() + ? "yes" + : "no") << " result=terRETRY_EXPORT"; return terRETRY_EXPORT; } diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index d79ba0f7b..4643d1cfa 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -259,6 +259,10 @@ extensionsTick(Ext& ext, Ctx const& ctx) << " entropySetHash=" << (ourPos.entropySetHash ? to_string(*ourPos.entropySetHash) : std::string{"none"}) + << " exportSigSetHash=" + << (ourPos.exportSigSetHash + ? to_string(*ourPos.exportSigSetHash) + : std::string{"none"}) << " myCommitment=" << (ourPos.myCommitment ? "yes" : "no") << " myReveal=" << (ourPos.myReveal ? "yes" : "no"); @@ -689,7 +693,8 @@ extensionsTick(Ext& ext, Ctx const& ctx) // 2. Subsequent ticks: check for conflict and rebuild if needed, // bounded by deadline. // - // Same pattern as commitSetHash conflict handling (line ~308). + // Same pattern as commitSetHash conflict handling (line ~308) + // and exportSigSetHash convergence gate (line ~674). { auto const ourPos = ctx.getPosition(); if (ourPos.entropySetHash) @@ -930,6 +935,308 @@ extensionsTick(Ext& ext, Ctx const& ctx) << " mode=" << to_string(ctx.mode); } + // Export sig convergence gate: runs after RNG sub-states when Export has + // verified signatures to publish or when tx-converged peers advertise + // exportSigSetHash roots we can locally materialize. This is a bounded + // safety coordination window, not a wait-for-Export-success mechanism. + if constexpr (requires { ctx.getPosition().exportSigSetHash; }) + { + if (!ext.exportEnabled()) + return {.readyForAccept = true}; + + auto startExportSigGate = [&]() -> bool { + if (ext.exportSigGateStarted_) + return false; + ext.exportSigGateStarted_ = true; + ext.exportSigGateStart_ = ctx.nowSteady; + return true; + }; + + auto observedPeerExportSigSets = [&](auto const& pos) { + std::size_t peerSets = 0; + for (auto const& [_, peerPos] : ctx.peerPositions) + { + auto const& pp = peerPos.proposal().position(); + if (positionTxSetID(pp) != positionTxSetID(pos)) + continue; // not tx-converged + if (!pp.exportSigSetHash) + continue; + + ++peerSets; + } + return peerSets; + }; + + bool hasLocalExportSigs = ext.hasPendingExportSigs(); + //@@start export-sigset-material-wait + if (!hasLocalExportSigs && ext.hasConsensusExportTxns()) + { + auto const peerSets = observedPeerExportSigSets(ctx.getPosition()); + if (peerSets > 0) + { + startExportSigGate(); + hasLocalExportSigs = ext.hasPendingExportSigs(); + if (!hasLocalExportSigs) + { + auto const elapsed = + ctx.nowSteady - ext.exportSigGateStart_; + auto const deadline = + detail::sidecarConvergenceTimeout(ctx.parms); + if (elapsed <= deadline) + { + JLOG(ext.j_.debug()) + << "Export: bounded wait for advertised " + "exportSigSet local material" + << " buildSeq=" << ctx.buildSeq + << " peerSets=" << peerSets + << " elapsedMs=" << toMs(elapsed) + << " deadlineMs=" << toMs(deadline); + return {}; + } + + ext.setExportSigConvergenceFailed(); + ext.clearAcceptedExportSigSet(); + JLOG(ext.j_.warn()) + << "Export: advertised exportSigSet material timeout" + << " buildSeq=" << ctx.buildSeq + << " peerSets=" << peerSets + << " elapsedMs=" << toMs(elapsed) + << " deadlineMs=" << toMs(deadline) + << " action=retry-or-expire"; + } + } + else + { + // A candidate ttEXPORT with no local sig material gets one + // short observation window so proposal-carried signatures can + // arrive before apply. If nothing appears in time, apply takes + // the retry/expire path. + startExportSigGate(); + auto const elapsed = ctx.nowSteady - ext.exportSigGateStart_; + auto const deadline = + detail::sidecarConvergenceTimeout(ctx.parms); + if (elapsed <= deadline) + { + JLOG(ext.j_.debug()) + << "Export: bounded wait for exportSigSet " + "advertisement" + << " buildSeq=" << ctx.buildSeq + << " elapsedMs=" << toMs(elapsed) + << " deadlineMs=" << toMs(deadline) + << " candidateExportTxns=yes"; + return {}; + } + + ext.setExportSigConvergenceFailed(); + ext.clearAcceptedExportSigSet(); + JLOG(ext.j_.warn()) + << "Export: exportSigSet advertisement timeout" + << " buildSeq=" << ctx.buildSeq + << " elapsedMs=" << toMs(elapsed) + << " deadlineMs=" << toMs(deadline) + << " action=retry-or-expire"; + } + } + //@@end export-sigset-material-wait + + if (hasLocalExportSigs) + { + //@@start export-publish-sigset-hash + auto const buildSeqExport = ctx.buildSeq; + auto const exportHash = ext.buildExportSigSet(buildSeqExport); + + auto currentPos = ctx.getPosition(); + bool publishedNewHash = false; + if (ext.testSuppressExportSigSetHash()) + { + if (currentPos.exportSigSetHash) + { + currentPos.exportSigSetHash.reset(); + ctx.updatePosition(currentPos); + + if (ctx.mode == ConsensusMode::proposing) + ctx.propose(); + } + + JLOG(ext.j_.debug()) + << "Export: withholding exportSigSetHash" + << " reason=runtime-config-noExportSigHash" + << " buildSeq=" << buildSeqExport << " hash=" << exportHash; + } + else + { + publishedNewHash = !currentPos.exportSigSetHash || + *currentPos.exportSigSetHash != exportHash; + if (publishedNewHash) + { + currentPos.exportSigSetHash = exportHash; + ctx.updatePosition(currentPos); + + if (ctx.mode == ConsensusMode::proposing) + ctx.propose(); + + JLOG(ext.j_.debug()) << "Export: published exportSigSetHash" + << " buildSeq=" << buildSeqExport + << " hash=" << exportHash; + } + } + //@@end export-publish-sigset-hash + + //@@start export-sigset-conflict-wait + // Check quorum agreement on exportSigSetHash. Like RNG entropy, + // Export success is an accept-time derived effect outside tx-set + // equality. A local-only quorum must not succeed unless enough + // tx-converged peers advertise the same export sig sidecar hash. + { + if (startExportSigGate() || publishedNewHash) + { + JLOG(ext.j_.debug()) << "Export: exportSigSet published" + << " buildSeq=" << buildSeqExport + << " hash=" << exportHash + << " action=wait-for-peer-observation"; + return {}; + } + + auto inspectExportPeers = [&](auto const& pos) { + return detail::inspectTxConvergedSidecarPeers( + ctx.peerPositions, + pos, + ext.localIsActiveValidator(), + [](auto const& position) { + return position.exportSigSetHash; + }, + [&ext](auto const& nodeId) { + return ext.isUNLReportMember(nodeId); + }, + [](auto const&) {}); + }; + + auto exportState = inspectExportPeers(ctx.getPosition()); + auto const exportQuorum = ext.exportRootAlignmentThreshold(); + auto quorumAligned = [&] { + return exportState.quorumAligned(exportQuorum); + }; + bool acceptedExportSigHash = false; + //@@start export-sigset-alignment-check + if (exportState.conflict && !quorumAligned()) + { + auto const refreshedHash = + ext.buildExportSigSet(buildSeqExport); + auto current = ctx.getPosition(); + if (!current.exportSigSetHash || + *current.exportSigSetHash != refreshedHash) + { + auto const oldHash = current.exportSigSetHash; + current.exportSigSetHash = refreshedHash; + ctx.updatePosition(current); + if (ctx.mode == ConsensusMode::proposing) + ctx.propose(); + JLOG(ext.j_.debug()) + << "Export: refreshed exportSigSetHash" + << " reason=local-refresh" + << " buildSeq=" << buildSeqExport << " oldHash=" + << (oldHash ? to_string(*oldHash) + : std::string{"none"}) + << " newHash=" << refreshedHash; + } + + exportState = inspectExportPeers(ctx.getPosition()); + } + //@@end export-sigset-alignment-check + + //@@start export-no-veto-quorum-branches + if (exportState.conflict && quorumAligned()) + { + // Export sidecar roots are signed through ExtendedPosition + // whenever featureExport is active. A quorum-aligned hash + // is therefore enough to proceed; requiring every + // tx-converged active peer to publish an exportSigSetHash + // would let a missing minority sidecar force retry/expiry. + JLOG(ext.j_.info()) + << "Export: exportSigSetHash conflict ignored" + << " reason=quorum-aligned" + << " buildSeq=" << buildSeqExport + << " alignedParticipants=" + << exportState.alignedParticipants() + << " quorum=" << exportQuorum + << " peersSeen=" << exportState.peersSeen + << " txConverged=" << exportState.txConverged; + acceptedExportSigHash = true; + } + else if (quorumAligned() && !exportState.fullObservation()) + { + JLOG(ext.j_.info()) + << "Export: missing exportSigSetHash observation " + "ignored" + << " reason=quorum-aligned" + << " buildSeq=" << buildSeqExport + << " alignedParticipants=" + << exportState.alignedParticipants() + << " quorum=" << exportQuorum + << " peersSeen=" << exportState.peersSeen + << " txConverged=" << exportState.txConverged; + acceptedExportSigHash = true; + } + //@@end export-no-veto-quorum-branches + else if (exportState.conflict || !quorumAligned()) + { + auto const elapsed = + ctx.nowSteady - ext.exportSigGateStart_; + auto const deadline = + detail::sidecarConvergenceTimeout(ctx.parms); + if (elapsed <= deadline) + { + JLOG(ext.j_.debug()) + << "Export: waiting for exportSigSet quorum " + "alignment" + << " buildSeq=" << buildSeqExport + << " alignedParticipants=" + << exportState.alignedParticipants() + << " quorum=" << exportQuorum + << " peersSeen=" << exportState.peersSeen + << " txConverged=" << exportState.txConverged + << " conflict=" + << (exportState.conflict ? "yes" : "no") + << " elapsedMs=" << toMs(elapsed) + << " deadlineMs=" << toMs(deadline); + return {}; + } + + ext.setExportSigConvergenceFailed(); + ext.clearAcceptedExportSigSet(); + JLOG(ext.j_.warn()) + << "Export: exportSigSet quorum alignment timeout" + << " buildSeq=" << buildSeqExport + << " action=retry-or-expire" + << " alignedParticipants=" + << exportState.alignedParticipants() + << " quorum=" << exportQuorum + << " peersSeen=" << exportState.peersSeen + << " txConverged=" << exportState.txConverged + << " conflict=" << (exportState.conflict ? "yes" : "no") + << " elapsedMs=" << toMs(elapsed) + << " deadlineMs=" << toMs(deadline); + } + else + { + acceptedExportSigHash = true; + } + + if (acceptedExportSigHash) + { + // Apply must consume exactly the sidecar root that passed + // the export gate. Local collector state may continue to + // grow after this point, but it is not part of the agreed + // closed-ledger export material. + if (auto const accepted = + ctx.getPosition().exportSigSetHash) + ext.acceptExportSigSet(*accepted); + } + } + //@@end export-sigset-conflict-wait + } + } + return {.readyForAccept = true}; }