fix(consensus): use quorum for export-only sidecars

Export-only originally used unanimity as a conservative substitute for the CE/RNG sidecar machinery. That made sense before Export had its own signed ExtendedPosition field and exportSigSetHash convergence gate.

Now Export sidecars are signed and converged independently of RNG, so a quorum-aligned exportSigSetHash plus verified active-view signature quorum is deterministic enough for Export-only mode. Keeping unanimity would let one active validator veto an otherwise converged export round.

Update CSF and testnet coverage to treat Export-only the same way: one missing/conflicting signer in a 5-validator network succeeds at 4/5, while below-quorum still retries or expires.
This commit is contained in:
Nicholas Dudfield
2026-04-28 10:54:04 +07:00
parent 92bdd2ed9f
commit 96b1104646
7 changed files with 66 additions and 45 deletions

View File

@@ -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"

View File

@@ -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})")

View File

@@ -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<Peer*>{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);

View File

@@ -398,7 +398,7 @@ struct Peer
return (std::numeric_limits<std::size_t>::max)() / 4;
auto const base =
unlNodes_.empty() ? std::size_t{1} : unlNodes_.size();
return enableRngConsensus_ ? calculateQuorumThreshold(base) : base;
return calculateQuorumThreshold(base);
}
std::size_t

View File

@@ -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

View File

@@ -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?

View File

@@ -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.