Revert "Remove Export sidecar consensus authority"

This reverts commit e6237b34ab.
This commit is contained in:
Nicholas Dudfield
2026-07-13 16:21:55 +07:00
parent f6e03d4529
commit 5f8145eadc
22 changed files with 2642 additions and 144 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<uint256> exportHash;
std::optional<uint256> 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<Ledger>(
*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);
}
};

View File

@@ -190,6 +190,36 @@ makeExportedPayment(
return obj;
}
std::shared_ptr<STTx const>
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<STObject>(inner));
return std::make_shared<STTx const>(makeSTTx(exportObj));
}
std::shared_ptr<STTx const>
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<STTx const>(makeSTTx(exportObj));
}
std::shared_ptr<STTx const>
makeConsensusEntropyTx(
std::uint32_t ledgerSeq,
@@ -249,6 +279,15 @@ makeRCLTxSet(Application& app, std::vector<std::shared_ptr<STTx const>> txns)
return RCLTxSet{map->snapShot(false)};
}
std::size_t
sidecarLeafCount(SHAMap const& map)
{
std::size_t count = 0;
map.visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> 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<uint256> exportHashSequence;
std::deque<uint256> commitHashSequence;
std::deque<uint256> 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 <class PeerPositions>
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<uint256> 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<std::uint8_t> const& hashANodes,
std::vector<std::uint8_t> 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<std::shared_ptr<STTx const>> 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<std::shared_ptr<STTx const>> exportTxs;
std::vector<std::pair<uint256, Buffer>> 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<SHAMap>(
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();

View File

@@ -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<milliseconds>(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<milliseconds>(0.2 * parms.ledgerGRANULARITY));
peers[0]->ce().forcedExportSigSetHash_ =
sha512Half(std::string("forced-export-only"));
sim.run(3);
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(!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<milliseconds>(0.2 * parms.ledgerGRANULARITY));
sim.run(3);
PeerGroup honest{
std::vector<Peer*>{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<milliseconds>(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<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(!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<milliseconds>(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<Peer*>{peers[0], peers[1]}};
Peer* equivocator = peers[2];
PeerGroup right{std::vector<Peer*>{peers[3], peers[4]}};
PeerGroup honest = left + right;
for (Peer* peer : peers)
peer->ce().enableExportConsensus_ = true;
auto const fast = round<milliseconds>(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

View File

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

View File

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

View File

@@ -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<PeerID, uint256>;
@@ -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<PeerID> unlNodes_;
hash_set<PeerID> likelyParticipants_;
hash_map<PeerID, uint256> pendingCommits_;
hash_map<PeerID, uint256> pendingReveals_;
hash_map<PeerID, uint256> pendingExportSigs_;
hash_map<PeerID, PeerKey> nodeKeys_;
uint256 myEntropySecret_;
bool commitSetFrozen_ = false;
@@ -341,6 +346,7 @@ struct Peer
// that accepted snapshot may feed finalizeRoundEntropy.
uint256 lastEntropySetHash_{};
std::optional<uint256> acceptedEntropySetHash_;
std::optional<uint256> 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<uint256> forcedCommitSetHash_;
// Optional test hook: force a specific entropy-set hash
std::optional<uint256> forcedEntropySetHash_;
// Optional test hook: force a specific export sig-set hash
std::optional<uint256> 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<uint256> commitSetHash;
std::optional<uint256> entropySetHash;
std::optional<uint256> 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<PeerID> dropRevealFrom_;
// Optional test hook: drop proposal-carried export signatures.
hash_set<PeerID> 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<std::size_t>::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<std::size_t>::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 <class Ledger_t>
@@ -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 <class Ledger_t>
void
decorateExportPosition(
ProposalPosition& pos,
Ledger_t const& prevLedger,
bool proposing)
{
if (!enableExportConsensus_ || !proposing || !peer.runAsValidator)
return;
auto const seq = static_cast<std::uint32_t>(prevLedger.seq()) + 1;
auto const sig = sha512Half(
std::string("csf-export-sig"),
static_cast<std::uint32_t>(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<std::uint32_t>(prevLedger.id()),
result.txns.id());
ce().finalizeRoundExport();
TxSet const acceptedTxs = injectTxs(prevLedger, result.txns);
Ledger const newLedger = oracle.accept(
prevLedger,

View File

@@ -43,8 +43,10 @@ struct RngPosition
TxSet::ID txSetHash{};
std::optional<uint256> commitSetHash;
std::optional<uint256> entropySetHash;
std::optional<uint256> exportSigSetHash;
std::optional<uint256> myCommitment;
std::optional<uint256> myReveal;
std::optional<uint256> 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.

View File

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

View File

@@ -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<SHAMap>(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<std::size_t>(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::ExportSignatureSnapshot>
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<SHAMap> 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<SHAMapItem const> 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<STTx>(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<STTx>(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();
}

View File

@@ -68,6 +68,7 @@ public:
using ActiveValidatorView = ripple::ActiveValidatorView;
using ActiveValidatorViewPtr = std::shared_ptr<ActiveValidatorView const>;
using ExportSignatureSnapshot = std::map<PublicKey, Buffer>;
private:
enum class RngContributionKind : uint8_t { commit, reveal };
@@ -91,14 +92,20 @@ private:
// Real SHAMaps for the current round (unbacked, ephemeral)
std::shared_ptr<SHAMap> commitSetMap_;
std::shared_ptr<SHAMap> entropySetMap_;
std::shared_ptr<SHAMap> 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<uint256> 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<uint256> acceptedExportSigSetHash_;
std::optional<LedgerIndex> rngRoundSeq_;
// Consensus parent ledger hash, pinned at round start. Input to the
// Tier 1 consensus_fallback entropy digest.
uint256 roundPrevLedgerHash_;
std::shared_ptr<SHAMap const> consensusTxSetMap_;
hash_map<uint256, std::shared_ptr<STTx const>> consensusExportTxns_;
std::optional<uint256> 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<ExportSignatureSnapshot>
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;
}
};

View File

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

View File

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

View File

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

View File

@@ -57,6 +57,7 @@ struct ExtendedPosition
// === Set Hashes (sub-state quorum, not core tx-set identity) ===
std::optional<uint256> commitSetHash;
std::optional<uint256> entropySetHash;
std::optional<uint256> exportSigSetHash;
std::optional<uint256> 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)

View File

@@ -96,6 +96,9 @@ struct ConsensusTestConfig
std::optional<int> rngPollMs;
// Disable export signature attachment (testing sub-quorum scenarios).
std::optional<bool> noExportSig;
// Withhold exportSigSetHash publication while still attaching export
// signatures (testing no-veto missing-observation scenarios).
std::optional<bool> noExportSigHash;
// Standalone-only entropy selection overrides for hook API tests.
std::optional<int> standaloneEntropyTier;
std::optional<int> 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;
}
};

View File

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

View File

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

View File

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