mirror of
https://github.com/Xahau/xahaud.git
synced 2026-07-24 23:50:10 +00:00
Revert "Remove Export sidecar consensus authority"
This reverts commit e6237b34ab.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user