diff --git a/.testnet/scenarios/export-suite.yml b/.testnet/scenarios/export-suite.yml index 741ec9f33..beea34742 100644 --- a/.testnet/scenarios/export-suite.yml +++ b/.testnet/scenarios/export-suite.yml @@ -38,7 +38,7 @@ tests: # CE + Export: 1 node suppressed, 4/5 = 80% quorum, should succeed - name: export_ce_one_node_down - script: .testnet/scenarios/export/export_unanimity.py + script: .testnet/scenarios/export/export_quorum.py params: expect_success: true network: @@ -46,9 +46,9 @@ tests: 4: XAHAUD_NO_EXPORT_SIG: "1" - # --- Export only, no CE (100% quorum, unanimity) --- - - name: export_unanimity_all_up - script: .testnet/scenarios/export/export_unanimity.py + # --- Export only, no CE (80% active-view quorum) --- + - name: export_only_all_up + script: .testnet/scenarios/export/export_quorum.py params: expect_success: true network: @@ -57,8 +57,21 @@ tests: track_features: - Export - - name: export_unanimity_node_down - script: .testnet/scenarios/export/export_unanimity.py + - name: export_only_one_node_down + script: .testnet/scenarios/export/export_quorum.py + params: + expect_success: true + network: + features: + - Export + track_features: + - Export + node_env: + 4: + XAHAUD_NO_EXPORT_SIG: "1" + + - name: export_only_two_nodes_down + script: .testnet/scenarios/export/export_quorum.py params: expect_success: false network: @@ -67,5 +80,7 @@ tests: track_features: - Export node_env: + 3: + XAHAUD_NO_EXPORT_SIG: "1" 4: XAHAUD_NO_EXPORT_SIG: "1" diff --git a/.testnet/scenarios/export/export_unanimity.py b/.testnet/scenarios/export/export_quorum.py similarity index 86% rename from .testnet/scenarios/export/export_unanimity.py rename to .testnet/scenarios/export/export_quorum.py index 4d1238789..8ad518b36 100644 --- a/.testnet/scenarios/export/export_unanimity.py +++ b/.testnet/scenarios/export/export_quorum.py @@ -1,6 +1,6 @@ -""":descr: Test Export without CE (unanimity mode). When all 5 nodes are up, -100% quorum is reachable and the export should succeed. When 1 node -suppresses sigs (4/5), unanimity fails and the export should expire. +""":descr: Test Export quorum behavior. When enough active validators sign, +the export should succeed whether or not CE is enabled. When fewer than the +active-view quorum sign, the export should expire. Parameterized via `expect_success` kwarg from suite.yml. @@ -33,7 +33,8 @@ async def scenario(ctx, log, expect_success=True): current_seq = ctx.validated_ledger_index(0) log(f"Current ledger: {current_seq}") - log(f"Expecting export {'success' if expect_success else 'failure (unanimity)'}") + outcome = "success" if expect_success else "failure (below quorum)" + log(f"Expecting export {outcome}") # --- Submit ttEXPORT --- result = await ctx.submit_and_wait( @@ -77,11 +78,11 @@ async def scenario(ctx, log, expect_success=True): # Assert shadow ticket was created assert_shadow_ticket(ctx, alice.address, log, expect_exists=True) - log("Export succeeded as expected (all validators signed)") + log("Export succeeded as expected (active-view quorum reached)") else: if engine_result == "tesSUCCESS": raise AssertionError( - "Export should NOT have succeeded with sub-unanimity" + "Export should NOT have succeeded below active-view quorum" ) log(f"Export failed as expected ({engine_result})") diff --git a/src/test/consensus/ConsensusRng_test.cpp b/src/test/consensus/ConsensusRng_test.cpp index d6f84d89f..006c64ed1 100644 --- a/src/test/consensus/ConsensusRng_test.cpp +++ b/src/test/consensus/ConsensusRng_test.cpp @@ -976,12 +976,12 @@ public: } void - testExportOnlyRequiresUnanimousAlignment() + testExportOnlyQuorumIgnoresMinorityConflict() { using namespace csf; using namespace std::chrono; - testcase("Export-only sig set requires unanimous alignment"); + testcase("Export-only sig set quorum ignores minority conflict"); ConsensusParms const parms{}; Sim sim; @@ -998,12 +998,17 @@ public: sim.run(3); - BEAST_EXPECT(sim.branches(peers) == 1); - for (Peer const* peer : peers) + 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(peer->ce().lastExportSucceeded_); + BEAST_EXPECT(!peer->ce().lastExportRetried_); } + BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_); } void @@ -1103,7 +1108,7 @@ public: } while (false) RUN(testExportOnlySteadyStateSucceeds); - RUN(testExportOnlyRequiresUnanimousAlignment); + RUN(testExportOnlyQuorumIgnoresMinorityConflict); RUN(testExportSigSetQuorumAlignmentIgnoresMinorityConflict); RUN(testExportSigSetConflictWithoutQuorumRetries); diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index ab86d4d10..cd83aadf3 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -398,7 +398,7 @@ struct Peer return (std::numeric_limits::max)() / 4; auto const base = unlNodes_.empty() ? std::size_t{1} : unlNodes_.size(); - return enableRngConsensus_ ? calculateQuorumThreshold(base) : base; + return calculateQuorumThreshold(base); } std::size_t diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index ad30d314b..f6960681d 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -235,10 +235,11 @@ ConsensusExtensions::exportSigQuorumThreshold() const if (base == 0) return 1; - // Export can operate without ConsensusEntropy. In that mode it uses the - // original unanimity rule, but still relies on the same sidecar alignment - // gate so all nodes make the same accept-time decision. - return rngEnabled() ? calculateQuorumThreshold(base) : base; + // 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 calculateQuorumThreshold(base); } void diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index a56073e6d..86c1b2793 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -156,11 +156,12 @@ conflicts and produce asymmetric zero/non-zero outcomes. `featureExport` and `featureConsensusEntropy` are independently amendment gated. -Export can run without ConsensusEntropy, but then it uses a conservative -ephemeral mode: verified export signature sidecars still converge through -`ExtendedPosition`, but success requires unanimity of the active validator -view. With ConsensusEntropy active, the same sidecar machinery uses the normal -80% threshold. +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 extended proposal machinery is enabled when either feature needs signed sidecar fields. Do not make Export depend on RNG availability just because RNG @@ -180,9 +181,11 @@ once a candidate tx set exists, only signatures verified against the `ttEXPORT` in that candidate set may become quorum material or enter `exportSigSetHash`. Export success requires quorum alignment on `exportSigSetHash`, not merely a -local collector quorum. If the verified signature set cannot align by the -bounded deadline, the export retries or expires according to normal transaction -rules. +local collector quorum. If a quorum of tx-converged participants advertises the +same export signature sidecar hash, that hash is aligned and below-quorum +conflicts are ignored. 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 must not promote unverified proposal-carried signatures into current-round quorum material. It may verify and retain them for a future retry, @@ -198,8 +201,8 @@ Accept-time cleanup must preserve Export state through `buildLCL` whenever applies. CSF consensus tests model the export sidecar gate directly. Testnet scenarios -under `.testnet/scenarios/export/` cover live-node Export+CE behavior and the -Export-only unanimity mode. +under `.testnet/scenarios/export/` cover live-node Export+CE behavior and +Export-only quorum behavior. ## Review Checklist @@ -218,4 +221,6 @@ When changing consensus extension code, check these questions: - Are export signatures verified before they count? - Does export success require `exportSigSetHash` alignment, not just local collector quorum? +- 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/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index b17e1c127..dedb35bc5 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -132,9 +132,9 @@ Export::doApply() // check and sign directly with our validator keys in the blob // assembly step below. // - // Network mode: - // With CE: 80% quorum (SHAMap convergence ensures agreement). - // Without CE: unanimity (avoids non-deterministic disagreement). + // Network mode: active-view 80% quorum. Export-only rounds are still + // deterministic because exportSigSetHash is signed in ExtendedPosition and + // converged before closed-ledger apply can use the signatures. // Deserialize the inner tx early — needed both for the upgrade // pass (verify unverified sigs) and for blob assembly. auto const& exportedObj = @@ -187,14 +187,8 @@ Export::doApply() if (!ctx_.app.config().standalone()) { - std::size_t threshold; - bool const ceEnabled = view().rules().enabled(featureConsensusEntropy); - if (unlSize == 0) - threshold = 1; - else if (ceEnabled) - threshold = calculateQuorumThreshold(unlSize); - else - threshold = unlSize; + std::size_t const threshold = + unlSize == 0 ? 1 : calculateQuorumThreshold(unlSize); // The collector may contain old trusted signatures; quorum counts only // signatures whose keys resolve into the same frozen active view.