From bc5b90282a0e9d3c782ffb07dd0b4db2c8389cf1 Mon Sep 17 00:00:00 2001 From: Richard Holland Date: Wed, 23 Sep 2026 11:45:32 +1000 Subject: [PATCH] export-rng cleanup - remove test infrastructure and STARTDIAG debug logging (#817) remove python scripts etc from the PR they should live elsewhere. --------- Co-authored-by: RichardAHBot --- .../followup-tests.md | 40 -- .gitignore | 7 - .testnet/.gitignore | 4 - .../entropy/consensus_entropy_crash.py | 29 -- .../entropy/entropy_with_transactions.py | 52 -- .../entropy/fallback_without_unl_report.py | 28 -- .../scenarios/entropy/hook_entropy_api.py | 123 ----- .../entropy/participant_aligned_smoke.py | 162 ------- .../entropy/quorum_degradation_smoke.py | 164 ------- .../entropy/quorum_recovery_smoke.py | 44 -- .../scenarios/entropy/steady_state_entropy.py | 27 -- .testnet/scenarios/export-suite.yml | 108 ----- .../export/export_committee_recovery.py | 126 ----- .../scenarios/export/export_degradation.py | 118 ----- .testnet/scenarios/export/export_helpers.py | 458 ------------------ .../export_no_veto_missing_observation.py | 96 ---- .testnet/scenarios/export/export_quorum.py | 129 ----- .../export/export_share_subscription.py | 226 --------- .../export/export_without_unl_report.py | 97 ---- .testnet/scenarios/export/retriable_export.py | 110 ----- .../scenarios/export/steady_state_export.py | 230 --------- .testnet/scenarios/helpers.py | 180 ------- .testnet/scenarios/latency-suite.yml | 90 ---- .../scenarios/perf/ce_export_latency_probe.py | 205 -------- .../rollout/mixed_binary_boundary.py | 82 ---- .testnet/scenarios/rollout/rolling_upgrade.py | 369 -------------- .testnet/scenarios/rollout/rollout-suite.yml | 43 -- .testnet/scenarios/suite.yml | 63 --- src/xrpld/app/consensus/RCLConsensus.cpp | 11 - src/xrpld/consensus/Consensus.h | 68 --- src/xrpld/consensus/ConsensusExtensionsTick.h | 3 + 31 files changed, 3 insertions(+), 3489 deletions(-) delete mode 100644 .ai-docs/reviews/2026-07-14/9febb4957/export-stream-terminal-review/followup-tests.md delete mode 100644 .testnet/.gitignore delete mode 100644 .testnet/scenarios/entropy/consensus_entropy_crash.py delete mode 100644 .testnet/scenarios/entropy/entropy_with_transactions.py delete mode 100644 .testnet/scenarios/entropy/fallback_without_unl_report.py delete mode 100644 .testnet/scenarios/entropy/hook_entropy_api.py delete mode 100644 .testnet/scenarios/entropy/participant_aligned_smoke.py delete mode 100644 .testnet/scenarios/entropy/quorum_degradation_smoke.py delete mode 100644 .testnet/scenarios/entropy/quorum_recovery_smoke.py delete mode 100644 .testnet/scenarios/entropy/steady_state_entropy.py delete mode 100644 .testnet/scenarios/export-suite.yml delete mode 100644 .testnet/scenarios/export/export_committee_recovery.py delete mode 100644 .testnet/scenarios/export/export_degradation.py delete mode 100644 .testnet/scenarios/export/export_helpers.py delete mode 100644 .testnet/scenarios/export/export_no_veto_missing_observation.py delete mode 100644 .testnet/scenarios/export/export_quorum.py delete mode 100644 .testnet/scenarios/export/export_share_subscription.py delete mode 100644 .testnet/scenarios/export/export_without_unl_report.py delete mode 100644 .testnet/scenarios/export/retriable_export.py delete mode 100644 .testnet/scenarios/export/steady_state_export.py delete mode 100644 .testnet/scenarios/helpers.py delete mode 100644 .testnet/scenarios/latency-suite.yml delete mode 100644 .testnet/scenarios/perf/ce_export_latency_probe.py delete mode 100644 .testnet/scenarios/rollout/mixed_binary_boundary.py delete mode 100644 .testnet/scenarios/rollout/rolling_upgrade.py delete mode 100644 .testnet/scenarios/rollout/rollout-suite.yml delete mode 100644 .testnet/scenarios/suite.yml diff --git a/.ai-docs/reviews/2026-07-14/9febb4957/export-stream-terminal-review/followup-tests.md b/.ai-docs/reviews/2026-07-14/9febb4957/export-stream-terminal-review/followup-tests.md deleted file mode 100644 index ba35f01f5f..0000000000 --- a/.ai-docs/reviews/2026-07-14/9febb4957/export-stream-terminal-review/followup-tests.md +++ /dev/null @@ -1,40 +0,0 @@ -# Export stream terminal follow-up tests - -## Scope - -Added regression coverage in `src/test/consensus/ConsensusExtensions_test.cpp` -for the LOW test-gap finding in `synthesis.md`: - -- A tracked pending-latch origin that is absent from the next validated ledger - emits one empty `terminal: true` replacement snapshot. The test verifies the - retained owner/origin identity, validated-ledger cursor, empty share set, - internal retirement, and suppression of a duplicate terminal snapshot. -- A validated-ledger callback queued behind `exportStreamMutex_` is fenced by - `stopExportShareService()`. The test verifies that stop cannot return while - the stream lock is held and that the queued callback emits no snapshot after - the service flag is cleared. - -No production files were changed. Deterministic accepted-edge/terminal lock -winner coverage was not added because there is no test seam between collector -admission and stream-lock acquisition; adding one would require production -instrumentation, while timing-only assertions would be flaky. - -## Verification - -- `x-format-changed`: passed. -- `x-quick-check`: passed for `ConsensusExtensions_test.cpp`. -- Narrow object build: passed for - `CMakeFiles/rippled.dir/src/test/consensus/ConsensusExtensions_test.cpp.o`. -- `/tmp/rippled-export-stream-tests --unittest=ripple.consensus.ConsensusExtensions`: - passed, 1 suite, 66 cases, 9,628 assertions, 0 failures. - -The normal incremental `rippled` target was stopped after Ninja scheduled 321 -steps from a stale build tree. A temporary binary was linked from existing -objects plus the newly compiled test object to execute the narrow suite without -a broad rebuild. - -## Repository state - -Work began at requested HEAD `018719fda`. A concurrent commit advanced the -shared branch to `572365baf` (`fix(export): enforce global live latch cap`) -before this follow-up was committed; that unrelated change was preserved. diff --git a/.gitignore b/.gitignore index c0220154b5..121cba965c 100644 --- a/.gitignore +++ b/.gitignore @@ -127,12 +127,5 @@ bld.rippled/ generated .vscode -# AI docs (local working documents) -.ai-docs/ - -# Local formal-methods workspace; kept as a separate repository and optionally -# symlinked here for navigation. -formal/lean/xahau_consensus - # Suggested in-tree build directory /.build/ diff --git a/.testnet/.gitignore b/.testnet/.gitignore deleted file mode 100644 index 63ee0e3ab9..0000000000 --- a/.testnet/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -output/ -__pycache__/ -scenarios/odd-cases/ -scenarios/suite-experiments.yml diff --git a/.testnet/scenarios/entropy/consensus_entropy_crash.py b/.testnet/scenarios/entropy/consensus_entropy_crash.py deleted file mode 100644 index 9d76c23881..0000000000 --- a/.testnet/scenarios/entropy/consensus_entropy_crash.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Scenario: ConsensusEntropy amendment crashes non-supporting node. - -Votes ConsensusEntropy accept on all nodes except n4, then waits for n4 -to crash as the amendment activates without its support. - - x-testnet run --scenario-script consensus_entropy_crash.py -""" - -from helpers import CONSENSUS_ENTROPY_FEATURE - - -async def scenario(ctx, log): - await ctx.wait_for_ledger_close() - ctx.feature(CONSENSUS_ENTROPY_FEATURE, vetoed=False, exclude_nodes=[4]) - - log("Waiting for ConsensusEntropy to be voted for...") - await ctx.wait_for_feature( - CONSENSUS_ENTROPY_FEATURE, - check=lambda s: not s.get("vetoed"), - exclude_nodes=[4], - timeout=60, - ) - - log("Waiting for n4 to crash...") - op = await ctx.wait_for_nodes_down(nodes=[4], timeout=600) - - ctx.assert_log("unsupported amendments activated", since=op.started, nodes=[4]) - ctx.assert_exit_status(0, nodes=[4]) - log("PASS: n4 shut down due to unsupported amendment") diff --git a/.testnet/scenarios/entropy/entropy_with_transactions.py b/.testnet/scenarios/entropy/entropy_with_transactions.py deleted file mode 100644 index 88ebb1b047..0000000000 --- a/.testnet/scenarios/entropy/entropy_with_transactions.py +++ /dev/null @@ -1,52 +0,0 @@ -""":descr: entropy stays valid under transaction load""" - -from __future__ import annotations - -from helpers import require_entropy, get_entropy_tx, assert_valid_entropy - -variants = [ - {"label": "light", "min_txns": 5, "max_txns": 10}, - {"label": "heavy", "min_txns": 50, "max_txns": 60}, - {"label": "super_heavy", "min_txns": 90, "max_txns": 120}, -] - - -async def scenario(ctx, log, *, min_txns=5, max_txns=10, **_): - await require_entropy(ctx, log) - - gen = ctx.txn_generator(min_txns=min_txns, max_txns=max_txns) - await gen.start() - await gen.wait_until_ready() - log(f"Transaction generator ready ({min_txns}-{max_txns} txns/ledger)") - - # Wait for pipeline warmup + a few txn-bearing ledgers. - await ctx.wait_for_ledgers(3, node_id=0, timeout=60) - - start_seq = ctx.validated_ledger_index(0) - await ctx.wait_for_ledgers(10, node_id=0, timeout=120) - end_seq = ctx.validated_ledger_index(0) - log(f"Inspecting ledgers {start_seq + 1} → {end_seq}") - - digests = set() - total_user_txns = 0 - - for seq in range(start_seq + 1, end_seq + 1): - ce, user_txns = get_entropy_tx(ctx, seq) - digest, count = assert_valid_entropy(ce, seq, seen_digests=digests) - total_user_txns += len(user_txns) - log( - f" Ledger {seq}: EntropyCount={count} " - f"user_txns={len(user_txns)} Digest={digest[:16]}..." - ) - - await gen.stop() - - log( - f"Verified {end_seq - start_seq} ledgers: {total_user_txns} user txns, " - f"all entropy valid and unique" - ) - - if total_user_txns == 0: - raise AssertionError("No user transactions were included in any ledger") - - log("PASS") diff --git a/.testnet/scenarios/entropy/fallback_without_unl_report.py b/.testnet/scenarios/entropy/fallback_without_unl_report.py deleted file mode 100644 index 85ed60afeb..0000000000 --- a/.testnet/scenarios/entropy/fallback_without_unl_report.py +++ /dev/null @@ -1,28 +0,0 @@ -""":descr: healthy non-standalone testnet without UNLReport mints Tier 1 fallback""" - -from __future__ import annotations - -from helpers import require_entropy, get_entropy_tx, assert_consensus_fallback - - -async def scenario(ctx, log): - await require_entropy(ctx, log) - - # Non-standalone nodes require a ledger-anchored UNLReport before assigning - # validator_quorum / participant_aligned labels. Without it, the RNG pipeline - # may still collect commits/reveals, but injection must remain Tier 1. - await ctx.wait_for_ledgers(3, node_id=0, timeout=60) - log("Pipeline warmed up without UNLReport") - - start_seq = ctx.validated_ledger_index(0) - await ctx.wait_for_ledgers(5, node_id=0, timeout=90) - end_seq = ctx.validated_ledger_index(0) - log(f"Inspecting ledgers {start_seq + 1} -> {end_seq}") - - for seq in range(start_seq + 1, end_seq + 1): - ce, _ = get_entropy_tx(ctx, seq) - digest, count = assert_consensus_fallback(ce, seq) - log(f" Ledger {seq}: EntropyCount={count} Digest={digest[:16]}...") - - log(f"Verified {end_seq - start_seq} ledgers: all consensus_fallback") - log("PASS") diff --git a/.testnet/scenarios/entropy/hook_entropy_api.py b/.testnet/scenarios/entropy/hook_entropy_api.py deleted file mode 100644 index 0ba46bd905..0000000000 --- a/.testnet/scenarios/entropy/hook_entropy_api.py +++ /dev/null @@ -1,123 +0,0 @@ -""":descr: compile, install, and invoke a Hook using the entropy_cr_* API""" - -from __future__ import annotations - -from helpers import require_entropy - - -ENTROPY_HOOK_C = r""" -#include - -extern int32_t _g(uint32_t id, uint32_t maxiter); -extern int64_t accept(uint32_t read_ptr, uint32_t read_len, int64_t error_code); -extern int64_t entropy_cr_dice(uint32_t sides, uint32_t min_tier); -extern int64_t entropy_cr_random( - uint32_t write_ptr, uint32_t write_len, uint32_t min_tier); -extern int64_t entropy_cr_status(void); - -#define GUARD(maxiter) _g((1ULL << 31U) + __LINE__, (maxiter) + 1) -#define ENTROPY_TIER(x) (((uint64_t)(x) >> 32U) & 0xFFU) -#define ENTROPY_COUNT(x) (((uint64_t)(x) >> 16U) & 0xFFFFU) -#define ENTROPY_DENOMINATOR(x) ((uint64_t)(x) & 0xFFFFU) - -int64_t -hook(uint32_t reserved) -{ - _g(1, 1); - - int64_t status = entropy_cr_status(); - if (status < 0) - return accept(0, 0, 10); - - uint32_t tier = ENTROPY_TIER(status); - uint32_t count = ENTROPY_COUNT(status); - uint32_t denominator = ENTROPY_DENOMINATOR(status); - if (tier < 3 || count < 4 || denominator < count) - return accept(0, 0, 11); - - int64_t die = entropy_cr_dice(6, 3); - if (die < 0 || die >= 6) - return accept(0, 0, 12); - - uint8_t random_bytes[32]; - for (int i = 0; GUARD(32), i < 32; ++i) - random_bytes[i] = 0; - - if (entropy_cr_random((uint32_t)random_bytes, 32, 3) != 32) - return accept(0, 0, 13); - - int nonzero = 0; - for (int i = 0; GUARD(32), i < 32; ++i) - if (random_bytes[i] != 0) - nonzero = 1; - if (!nonzero) - return accept(0, 0, 14); - - return accept(0, 0, 0); -} -""" - - -def assert_success(result, operation): - meta = result.get("meta", result.get("metaData", {})) - tx_result = meta.get("TransactionResult", result.get("engine_result", "")) - if tx_result != "tesSUCCESS": - raise AssertionError(f"{operation} failed: {result}") - return meta - - -async def scenario(ctx, log): - await require_entropy(ctx, log) - await ctx.wait_for_ledgers(3, node_id=0, timeout=60) - - await ctx.fund_accounts({"entropy_api": 1000}) - account = ctx.account("entropy_api") - - wasm = ctx.compile_hook(ENTROPY_HOOK_C, label="entropy-cr-api") - install = await ctx.submit_and_wait( - { - "TransactionType": "SetHook", - "Hooks": [ - { - "Hook": { - "CreateCode": wasm.hex().upper(), - "HookOn": "0" * 64, - "HookNamespace": "0" * 64, - "HookApiVersion": 0, - "Flags": 1, - } - } - ], - "Fee": "100000000", - }, - account.wallet, - ) - assert_success(install, "SetHook") - log("entropy_cr_* Hook installed") - - invoke = await ctx.submit_and_wait( - { - "TransactionType": "Invoke", - "Fee": "1000000", - }, - account.wallet, - ) - meta = assert_success(invoke, "Invoke") - - executions = meta.get("HookExecutions", []) - if len(executions) != 1: - raise AssertionError(f"Expected one HookExecution, got: {executions}") - - execution = executions[0].get("HookExecution", {}) - if execution.get("HookResult") != 3: - raise AssertionError(f"Hook did not ACCEPT: {execution}") - - if "HookReturnCode" not in execution: - raise AssertionError(f"HookReturnCode missing from execution: {execution}") - - return_code = execution["HookReturnCode"] - if str(return_code) != "0": - raise AssertionError(f"entropy_cr_* Hook check failed: {execution}") - - log("entropy_cr_status, entropy_cr_dice, and entropy_cr_random passed") - log("PASS") diff --git a/.testnet/scenarios/entropy/participant_aligned_smoke.py b/.testnet/scenarios/entropy/participant_aligned_smoke.py deleted file mode 100644 index a09936ef64..0000000000 --- a/.testnet/scenarios/entropy/participant_aligned_smoke.py +++ /dev/null @@ -1,162 +0,0 @@ -""":descr: 5/6 validator_quorum, 4/6 participant_aligned (tier 2), recovery - -Requires node_count: 6 (see suite.yml) — the smallest NON-degenerate Tier 2 -size. At n=6: tier2 floor = 4, validator quorum = 5, validation quorum = 5. So - 6/6, 5/6 present -> validator_quorum (EntropyTier=3) - 4/6 present -> participant_aligned (EntropyTier=2, count 4) <-- the band - 3/6 present -> consensus_fallback (EntropyTier=1) -n=5 has NO tier-2 band (tier2 == quorum == 4), which is why the existing -degradation smoke at 5 nodes only ever sees tier 3 / fallback. - -KEY: the 4/6 window is BELOW the 80% validation quorum (5). The 4 survivors -keep CLOSING ledgers that carry tier-2 entropy, but those ledgers do NOT -validate until the network recovers — exactly the transition window Tier 2 -serves. So validated_ledger_index() stalls; we instead inspect a surviving -node's CLOSED ledger (its LCL) directly, and cross-check the injection from the -cohort's logs. -""" - -from __future__ import annotations - -from helpers import ( - require_entropy, - get_entropy_tx, - assert_participant_aligned, - assert_validator_quorum, -) - - -def _closed_entropy(result): - """(seq, ConsensusEntropy tx) from a ctx.ledger('closed', transactions=True) - result, or (None, None) if the fetch returned no usable ledger. - - Enforces the per-ledger invariant that an entropy-enabled closed ledger - carries EXACTLY ONE ConsensusEntropy pseudo-tx (mirroring get_entropy_tx): - a duplicate or missing injection raises here with a clear error instead of - being silently skipped and resurfacing later as a generic 'no tier-2 ledger'. - """ - if not result or not isinstance(result.get("ledger"), dict): - return None, None - led = result["ledger"] - try: - seq = int(led.get("ledger_index")) - except (TypeError, ValueError): - return None, None - ce = [ - t - for t in led.get("transactions", []) - if isinstance(t, dict) and t.get("TransactionType") == "ConsensusEntropy" - ] - if len(ce) != 1: - raise AssertionError( - f"Closed ledger {seq}: expected 1 ConsensusEntropy txn, got {len(ce)}" - ) - return seq, ce[0] - - -async def scenario(ctx, log): - await require_entropy(ctx, log) - - # Baseline: healthy 6/6 produces validator_quorum entropy. - await ctx.wait_for_ledgers(1, node_id=0, timeout=30) - - # --- 5/6: settles back to validator_quorum (5 present >= quorum 5) --- - val_before_drop = ctx.validated_ledger_index(0) - ctx.stop_node(5) - await ctx.wait_for_nodes_down(nodes=[5], timeout=30) - # Settle a few ledgers past the membership change. The ledger right at a - # validator drop can carry a transient consensus_fallback (tier 1, count 0, - # deterministic and by design) before the commit/reveal pipeline re-primes, - # so we do NOT assume any single post-drop ledger is already tier 3. - await ctx.wait_for_ledgers(4, node_id=0, timeout=90) - - # 5/6 is at/above the 80% quorum (5), so steady state is validator_quorum. - # Scan the post-drop validated ledgers (all carry the 5-node cohort, so a - # tier-3 here has count == 5) and require at least one clean validator_quorum - # — EntropyTier=3, count >= quorum, non-zero digest — tolerating the - # transition fallback instead of depending on where the tip happened to land. - val_5of6 = ctx.validated_ledger_index(0) - t3_seq = None - for seq in range(val_5of6, val_before_drop, -1): - ce, _ = get_entropy_tx(ctx, seq) - tier = ce.get("EntropyTier") - log(f" 5/6 ledger {seq}: tier={tier} count={ce.get('EntropyCount')}") - if tier == 3: - assert_validator_quorum(ce, seq, min_count=5) - t3_seq = seq - break - if t3_seq is None: - raise AssertionError( - f"5/6: no validator_quorum (tier 3) entropy in post-drop validated " - f"ledgers {val_before_drop + 1}..{val_5of6}" - ) - log(f"5/6: validator_quorum at validated seq {t3_seq}") - - #@@start test-participant-aligned-window - # --- 4/6: participant_aligned (Tier 2) degraded window --- - ctx.stop_node(4) - await ctx.wait_for_nodes_down(nodes=[4], timeout=30) - - # ~12s window: confirm tier-2 INJECTION from the cohort's logs, and that the - # round is NOT the below-quorum fallback path (which is what distinguishes - # the tier-2 band from the tier-1 fallback regime). - op = await ctx.sleep(12, name="tier2_window") - selected_t2 = ctx.search_logs( - r"RNG: entropy selected seq=\d+ tier=2 count=4", - within=op.window, - nodes=[0, 1, 2, 3], - ) - log(f"4/6: 'entropy selected tier=2 count=4' logs: {selected_t2.count}") - if selected_t2.count == 0: - raise AssertionError( - "4/6 window injected no participant_aligned (tier 2) entropy: no " - "'RNG: entropy selected ... tier=2 count=4' on the surviving cohort" - ) - ctx.assert_not_log( - r"STALLDIAG: rng-commit-timeout-below-quorum", - within=op.window, - nodes=[0, 1, 2, 3], - ) - - # Verify the on-ledger EntropyTier=2 DIRECTLY: validation is stalled (4 < 5), - # so sample the surviving cohort's CLOSED ledger (its LCL — built but not yet - # validated). At least one must be participant_aligned with EntropyCount=4. - tier2_on_ledger = 0 - last_seq = None - for _ in range(5): - seq, ce = _closed_entropy( - ctx.ledger("closed", transactions=True, node_id=0) - ) - if ce is not None and seq is not None and seq != last_seq: - last_seq = seq - tier = ce.get("EntropyTier") - count = ce.get("EntropyCount", -1) - log(f" closed ledger {seq}: tier={tier} count={count}") - if tier == 2: - assert_participant_aligned(ce, seq, expected_count=4) - tier2_on_ledger += 1 - await ctx.sleep(3) - - if tier2_on_ledger == 0: - raise AssertionError( - "no closed participant_aligned (tier 2) ledger observed during the " - "4/6 window (tier 2 was injected per logs, but not seen on a closed " - "ledger)" - ) - log(f"4/6: {tier2_on_ledger} participant_aligned closed ledger(s) verified") - #@@end test-participant-aligned-window - - # --- Recovery: liveness — validation resumes once quorum is restored --- - ctx.start_node(4) - ctx.start_node(5) - await ctx.wait_for_ledgers(1, node_id=0, timeout=120) - - val_recovered = ctx.validated_ledger_index(0) - if not val_recovered or val_recovered <= val_5of6: - raise AssertionError( - f"Validated ledger did not advance after recovery " - f"({val_5of6} -> {val_recovered})" - ) - log(f"Recovered: validated seq {val_5of6} -> {val_recovered}") - - log("PASS") diff --git a/.testnet/scenarios/entropy/quorum_degradation_smoke.py b/.testnet/scenarios/entropy/quorum_degradation_smoke.py deleted file mode 100644 index 1ef8a107fa..0000000000 --- a/.testnet/scenarios/entropy/quorum_degradation_smoke.py +++ /dev/null @@ -1,164 +0,0 @@ -""":descr: 4/5 liveness, 3/5 fail-closed sub-quorum window, recovery""" - -from __future__ import annotations - -from helpers import ( - require_entropy, - get_entropy_tx, - entropy_fields, - assert_consensus_fallback, -) - - -def _closed_entropy(result): - """Return (seq, ConsensusEntropy tx) from a closed-ledger RPC result. - - The 3/5 window is below validation quorum, so validated-ledger history is - expected to stall. Sampling a surviving node's closed ledger catches any - local LCL that advanced despite the sub-quorum condition. - """ - if not result or not isinstance(result.get("ledger"), dict): - return None, None - ledger = result["ledger"] - try: - seq = int(ledger.get("ledger_index")) - except (TypeError, ValueError): - return None, None - ce = [ - tx - for tx in ledger.get("transactions", []) - if isinstance(tx, dict) and tx.get("TransactionType") == "ConsensusEntropy" - ] - if len(ce) != 1: - raise AssertionError( - f"Closed ledger {seq}: expected 1 ConsensusEntropy txn, got {len(ce)}" - ) - return seq, ce[0] - - -async def scenario(ctx, log): - await require_entropy(ctx, log) - - # Baseline: wait 1 ledger to confirm network is healthy. - await ctx.wait_for_ledgers(1, node_id=0, timeout=30) - - # --- 4/5 liveness --- - ctx.stop_node(4) - await ctx.wait_for_nodes_down(nodes=[4], timeout=30) - await ctx.wait_for_ledgers(1, node_id=0, timeout=30) - log("4/5: liveness OK") - - # Snapshot validated seq before dropping to 3/5. - val_before = ctx.validated_ledger_index(0) - - # --- 3/5 degraded window --- - ctx.stop_node(3) - await ctx.wait_for_nodes_down(nodes=[3], timeout=30) - - # 10s ≈ 3 rounds at 3s cadence. - await ctx.sleep(10) - - val_after = ctx.validated_ledger_index(0) - log(f"3/5: validated ledger {val_before} → {val_after}") - - if val_after and val_before and val_after > val_before: - raise AssertionError( - f"3/5 sub-quorum window unexpectedly validated ledgers " - f"({val_before} -> {val_after})" - ) - - # If the surviving cohort exposes an advanced closed ledger despite being - # below validation quorum, it must fail closed to consensus_fallback. This - # keeps the entropy assertion live without pretending validated history - # should advance at 3/5. - degraded_fallback = 0 - last_closed = None - for _ in range(5): - seq, ce = _closed_entropy(ctx.ledger("closed", transactions=True, node_id=0)) - if seq and val_before and seq > val_before and seq != last_closed: - last_closed = seq - digest, count = assert_consensus_fallback(ce, seq) - degraded_fallback += 1 - log( - f" 3/5 closed ledger {seq}: EntropyCount={count} " - f"Digest={digest[:16]}... FALLBACK" - ) - await ctx.sleep(2) - - log(f"3/5 closed-ledger fallback samples: {degraded_fallback}") - - # Log checks tied to current transition mechanics: - # - commit-set SHAMap publication is the observable output of entering the - # commit sidecar phase - # - ConvergingCommit transition is the gateway out of seq=0-only behavior - # - rng-commit-timeout-below-quorum is the degraded-window fallback path - ctx.log_level("LedgerConsensus", "trace") - ctx.log_level("ConsensusExtensions", "trace") - op = await ctx.sleep(6, name="stall_window") - - ctx.assert_not_log( - r"RNG: transitioned to ConvergingCommit", within=op.window, nodes=[0, 1, 2] - ) - ctx.assert_not_log( - r"RNG: built commitSet SHAMap", within=op.window, nodes=[0, 1, 2] - ) - - gate_blocked = ctx.search_logs( - r"STALLDIAG: establish gate blocked reason=(pause|no-tx-consensus)", - within=op.window, - nodes=[0, 1, 2], - ) - log(f"3/5: establish gate-blocked logs in 6s: {gate_blocked.count}") - - below_quorum = ctx.search_logs( - r"STALLDIAG: rng-commit-timeout-below-quorum", - within=op.window, - nodes=[0, 1, 2], - ) - log(f"3/5: RNG commit timeout below quorum logs in 6s: {below_quorum.count}") - - # --- Recovery: restart nodes, verify ledger advancement --- - ctx.start_node(3) - ctx.start_node(4) - await ctx.wait_for_ledgers(1, node_id=0, timeout=120) - - val_recovered = ctx.validated_ledger_index(0) - pre_recovery = max(v for v in [val_before, val_after] if v is not None) - log(f"Recovered: validated seq {pre_recovery} → {val_recovered}") - - if not val_recovered or val_recovered <= pre_recovery: - raise AssertionError( - f"Validated ledger did not advance after recovery " - f"({pre_recovery} → {val_recovered})" - ) - - # Inspect post-recovery ledgers separately from the degraded window above. - # Once the network is back at quorum, validator-tier entropy is expected - # again (transitional fallback ledgers are fine) and must be quorum-met. - fallback_count = 0 - validator_count = 0 - for seq in range(pre_recovery + 1, val_recovered + 1): - ce, _ = get_entropy_tx(ctx, seq) - digest, entropy_count, is_fallback = entropy_fields(ce) - - if is_fallback: - fallback_count += 1 - else: - validator_count += 1 - if entropy_count < 4: - raise AssertionError( - f"Ledger {seq}: validator entropy with sub-quorum " - f"EntropyCount={entropy_count} (need >= 4)" - ) - - log( - f" Ledger {seq}: EntropyCount={entropy_count} " - f"{'FALLBACK' if is_fallback else 'VALIDATOR'}" - ) - - log( - f"Entropy summary: {fallback_count} fallback, " - f"{validator_count} validator" - ) - - log("PASS") diff --git a/.testnet/scenarios/entropy/quorum_recovery_smoke.py b/.testnet/scenarios/entropy/quorum_recovery_smoke.py deleted file mode 100644 index 42d013dffd..0000000000 --- a/.testnet/scenarios/entropy/quorum_recovery_smoke.py +++ /dev/null @@ -1,44 +0,0 @@ -""":descr: drop 2 nodes (3/5 stall), restart both, verify recovery""" - -from __future__ import annotations - -from helpers import require_entropy - - -async def scenario(ctx, log): - await require_entropy(ctx, log) - - await ctx.wait_for_ledgers(1, node_id=0, timeout=60) - log("Baseline OK") - - # Drop 2 nodes → validation stall. - ctx.stop_node(3) - ctx.stop_node(4) - await ctx.wait_for_nodes_down(nodes=[3, 4], timeout=30) - - info = ctx.rpc.server_info(node_id=0) - val_before = info.get("info", {}).get("validated_ledger", {}).get("seq", 0) - log(f"Stalled at validated seq {val_before}") - - # Let it sit for a few rounds in degraded state. - await ctx.sleep(6) - - # Bring both nodes back. - ctx.start_node(3) - ctx.start_node(4) - log("Restarted n3 and n4, waiting for recovery...") - - # Recovery: wait for ANY validated ledger advance on n0. - await ctx.wait_for_ledger_close(node_id=0, timeout=60) - - info = ctx.rpc.server_info(node_id=0) - val_after = info.get("info", {}).get("validated_ledger", {}).get("seq", 0) - log(f"Recovered: validated seq {val_before} → {val_after}") - - if val_after <= val_before: - raise AssertionError( - f"Validated ledger did not advance after recovery " - f"({val_before} → {val_after})" - ) - - log("PASS") diff --git a/.testnet/scenarios/entropy/steady_state_entropy.py b/.testnet/scenarios/entropy/steady_state_entropy.py deleted file mode 100644 index bd5cb7f04f..0000000000 --- a/.testnet/scenarios/entropy/steady_state_entropy.py +++ /dev/null @@ -1,27 +0,0 @@ -""":descr: all 5 nodes healthy, every ledger has valid unique quorum-met entropy""" - -from __future__ import annotations - -from helpers import require_entropy, get_entropy_tx, assert_valid_entropy - - -async def scenario(ctx, log): - await require_entropy(ctx, log) - - # Wait for the RNG pipeline to warm up past initial proposal/sidecar gossip. - await ctx.wait_for_ledgers(3, node_id=0, timeout=60) - log("Pipeline warmed up") - - start_seq = ctx.validated_ledger_index(0) - await ctx.wait_for_ledgers(10, node_id=0, timeout=120) - end_seq = ctx.validated_ledger_index(0) - log(f"Inspecting ledgers {start_seq + 1} → {end_seq}") - - digests = set() - for seq in range(start_seq + 1, end_seq + 1): - ce, _ = get_entropy_tx(ctx, seq) - digest, count = assert_valid_entropy(ce, seq, seen_digests=digests) - log(f" Ledger {seq}: EntropyCount={count} Digest={digest[:16]}...") - - log(f"Verified {end_seq - start_seq} ledgers: all quorum entropy, all unique") - log("PASS") diff --git a/.testnet/scenarios/export-suite.yml b/.testnet/scenarios/export-suite.yml deleted file mode 100644 index 1fc6b9631a..0000000000 --- a/.testnet/scenarios/export-suite.yml +++ /dev/null @@ -1,108 +0,0 @@ -defaults: - network: - node_count: 5 - launcher: tmux - find_ports: true - slave_delay: 0.2 - features: - - ConsensusEntropy - - Export - track_features: - - ConsensusEntropy - - Export - unl_report: true - log_levels: - TxQ: info - Protocol: debug - Peer: debug - LedgerConsensus: debug - ConsensusExtensions: debug - NetworkOPs: info - rc: - - rng_poll_ms=333 - -tests: - # --- CE + Export (post-validation shares, sidecar root convergence) --- - - name: steady_state_export_ce - script: .testnet/scenarios/export/steady_state_export.py - - - name: retriable_export_ce - script: .testnet/scenarios/export/retriable_export.py - - - name: export_degradation_ce - script: .testnet/scenarios/export/export_degradation.py - network: - rc: - - rng_poll_ms=333 - - n3:no_export_sig=true - - n4:no_export_sig=true - - - name: export_without_unl_report - script: .testnet/scenarios/export/export_without_unl_report.py - network: - features: - - Export - track_features: - - 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 - - - name: export_share_subscription - script: .testnet/scenarios/export/export_share_subscription.py - - - name: export_two_member_committee_recovery - script: .testnet/scenarios/export/export_committee_recovery.py - - # CE + Export: 1 node suppressed, 4/5 = 80% quorum, should succeed - - name: export_ce_one_node_down - script: .testnet/scenarios/export/export_quorum.py - params: - expect_success: true - network: - rc: - - rng_poll_ms=333 - - n4:no_export_sig=true - - # --- Export only, no CE (80% active-view quorum) --- - - name: export_only_all_up - script: .testnet/scenarios/export/export_quorum.py - params: - expect_success: true - network: - features: - - Export - track_features: - - Export - - - name: export_only_one_node_down - script: .testnet/scenarios/export/export_quorum.py - params: - expect_success: true - network: - features: - - Export - track_features: - - Export - rc: - - rng_poll_ms=333 - - n4:no_export_sig=true - - - name: export_only_two_nodes_down - script: .testnet/scenarios/export/export_quorum.py - params: - expect_success: false - network: - features: - - Export - track_features: - - Export - rc: - - rng_poll_ms=333 - - n3:no_export_sig=true - - n4:no_export_sig=true diff --git a/.testnet/scenarios/export/export_committee_recovery.py b/.testnet/scenarios/export/export_committee_recovery.py deleted file mode 100644 index c09259a040..0000000000 --- a/.testnet/scenarios/export/export_committee_recovery.py +++ /dev/null @@ -1,126 +0,0 @@ -""":descr: a 2-of-5 Export committee recovers without a new intent - -The intent selects only validators n0 and n4, so qC is 2. Validator n4 is -stopped before admission: the network still validates the intent with 4/5, but -one selected share cannot form a witness. Restarting n4 must republish its share -for the same live latch and complete the witness without resubmitting Export. -""" - -from __future__ import annotations - -from export_helpers import ( - EXPORT_RETRY_LEDGER_WINDOW, - assert_export_latch, - bitmap_positions, - find_export_signature_witness, - find_export_txns, - require_export, - submit_direct_export, - wait_for_export_signature_witness, -) - - -async def scenario(ctx, log): - await require_export(ctx, log) - await ctx.fund_accounts({"alice": 10000, "bob": 1000}) - alice = ctx.account("alice") - bob = ctx.account("bob") - - if not ctx.stop_node(4): - raise AssertionError("Failed to stop selected validator n4") - await ctx.wait_for_nodes_down(nodes=[4], timeout=30) - log("Stopped selected validator n4; selected committee is n0+n4") - - current = ctx.validated_ledger_index(0) - result = await submit_direct_export( - ctx, - log, - { - "TransactionType": "Export", - "Fee": "1000000", - "ExportedTxn": { - "TransactionType": "Payment", - "Account": alice.address, - "Destination": bob.address, - "Amount": "1000000", - "Fee": "10", - "Sequence": 0, - "TicketSequence": 1, - "FirstLedgerSequence": current + 1, - "LastLedgerSequence": current + EXPORT_RETRY_LEDGER_WINDOW, - "Flags": 2147483648, - "SigningPubKey": "", - }, - }, - alice.wallet, - committee_node_ids=[0, 4], - ) - if result.get("engine_result") != "tesSUCCESS": - raise AssertionError(f"Export intent failed: {result}") - - origin = result.get("hash") - origin_seq = int(result.get("ledger_index")) - if not origin: - raise AssertionError(f"Validated Export missing hash: {result}") - - assert_export_latch( - ctx, - alice.address, - log, - origin_hash=origin, - expect_witness=False, - ) - selected = {0, 1} - - await ctx.wait_for_ledger(origin_seq + 1, node_id=0, timeout=30) - if find_export_signature_witness(ctx, origin_seq + 1, origin): - raise AssertionError( - "Witness formed while one of two selected signers was down" - ) - assert_export_latch( - ctx, - alice.address, - log, - origin_hash=origin, - expect_witness=False, - ) - log("No witness with only one selected signer; original latch remains pending") - - if not ctx.start_node(4): - raise AssertionError("Failed to restart selected validator n4") - await ctx.wait_for_nodes( - lambda node_id: ctx.rpc.server_info(node_id) is not None, - nodes=[4], - timeout=30, - poll_interval=0.5, - name="selected-export-validator-up", - ) - log("Restarted selected validator n4") - - witness = await wait_for_export_signature_witness( - ctx, log, origin, after_ledger=origin_seq - ) - contributors = bitmap_positions(witness["EntropyContributors"]) - if contributors != selected: - raise AssertionError( - f"Recovered witness contributors {contributors} != selected {selected}" - ) - if len(witness["_WitnessSigners"]) != 2: - raise AssertionError( - f"Recovered witness has {len(witness['_WitnessSigners'])} signers, need 2" - ) - witness_seq = int(witness["LedgerSequence"]) - for seq in range(origin_seq + 1, witness_seq + 1): - if any(tx.get("Account") == alice.address for tx in find_export_txns(ctx, seq)): - raise AssertionError( - f"A second Export was submitted before recovery in ledger {seq}" - ) - assert_export_latch( - ctx, - alice.address, - log, - origin_hash=origin, - expect_witness=True, - ) - log(f"Same Export {origin} completed after selected validator recovery") - log("PASS") diff --git a/.testnet/scenarios/export/export_degradation.py b/.testnet/scenarios/export/export_degradation.py deleted file mode 100644 index 2bbb4d76a1..0000000000 --- a/.testnet/scenarios/export/export_degradation.py +++ /dev/null @@ -1,118 +0,0 @@ -""":descr: Submit ttEXPORT with 2 nodes suppressing export signatures and -verify the admitted intent remains unwitnessed through its publication window. - -Nodes 3 and 4 have runtime_config no_export_sig=true, so only 3/5 nodes -provide export signatures. With 80% quorum = ceil(5*0.8) = 4 required, -the export cannot reach quorum and no ExportSignatures witness may be recorded. - -Flow: - 1. Fund alice and bob - 2. alice submits ttEXPORT with an explicit authority declaration - 3. Only 3/5 post-validation shares become available (need 4) - 4. Verify the publication window closes without a witness - 5. Verify subsequent payment still works (sequence not permanently blocked) -""" - -from __future__ import annotations - -from export_helpers import ( - EXPORT_RETRY_LEDGER_WINDOW, - assert_export_latch, - require_export, - submit_direct_export, - wait_for_export_signature_witness, -) - - -async def scenario(ctx, log): - await require_export(ctx, log) - - # --- Setup --- - 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("Nodes 3,4 have runtime_config no_export_sig=true (3/5 sigs, need 4)") - - #@@start test-export-below-quorum-expiry - # --- Submit intent; only 3/5 validators release shares. --- - result = await submit_direct_export( - ctx, - log, - { - "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, - ) - - final_seq = result.get("ledger_index", ctx.validated_ledger_index(0)) - origin_hash = result.get("hash") - engine_result = result.get("engine_result", "") - log(f"Export intent admitted at ledger {final_seq}, result: {engine_result}") - - if engine_result != "tesSUCCESS": - raise AssertionError(f"Expected admitted intent, got {engine_result}") - if not origin_hash: - raise AssertionError(f"Validated Export missing hash: {result}") - - await wait_for_export_signature_witness( - ctx, - log, - origin_hash, - after_ledger=final_seq, - expect_witness=False, - ) - assert_export_latch( - ctx, - alice.address, - log, - expect_exists=True, - origin_hash=origin_hash, - expect_witness=False, - ) - #@@end test-export-below-quorum-expiry - - # --- Verify subsequent payment works regardless --- - log("Submitting payment from alice to bob...") - pay_result = await ctx.submit_and_wait( - { - "TransactionType": "Payment", - "Destination": bob.address, - "Amount": "1000000", - "Fee": "12", - }, - alice.wallet, - timeout=30, - ) - - pay_engine = pay_result.get("engine_result", "") - log(f"Payment result: {pay_engine}") - - if pay_engine != "tesSUCCESS": - raise AssertionError( - f"Payment failed after unwitnessed export: {pay_engine} " - f"-- sequence may be blocked" - ) - - log("Payment succeeded -- account not permanently blocked") - log("PASS") diff --git a/.testnet/scenarios/export/export_helpers.py b/.testnet/scenarios/export/export_helpers.py deleted file mode 100644 index 243a0cc382..0000000000 --- a/.testnet/scenarios/export/export_helpers.py +++ /dev/null @@ -1,458 +0,0 @@ -"""Shared helpers for Export scenario tests.""" - -from __future__ import annotations - -import hashlib -import json - -from xahaud_scripts.testnet.config import ( - _decode_node_public_key, - _unl_report_index, - feature_name_to_hash, -) - -EXPORT_RETRY_LEDGER_WINDOW = 5 -EXPORT_PUBLICATION_LEDGER_WINDOW = 5 - - -async def require_export( - ctx, log, *, require_unl_report=True, require_runtime_config=True -): - """Wait for first ledger and assert Export is enabled. - - Network-mode Export success requires a parent-ledger UNLReport-backed - active validator view. Most export scenarios seed that report in genesis; - assert it here so a success-path test cannot accidentally pass setup - without the condition Export::doApply requires. The no-UNLReport retry - scenario opts out deliberately. - - The tracked export suite also uses XAHAUD_RUNTIME_TEST_CONFIG for polling - and fault-injection knobs. Default binaries reject the runtime_config RPC, - so check it up front rather than silently running without those knobs. - """ - await ctx.wait_for_ledger_close(timeout=120) - - if require_runtime_config: - result = ctx.rpc.runtime_config(0) - if not result or result.get("error"): - raise AssertionError( - "Export suite requires a binary built with " - "xahaud_runtime_test_config=ON; runtime_config RPC returned " - f"{result}" - ) - log("RuntimeConfig RPC active") - - feature = ctx.feature_check(feature_name_to_hash("Export"), node_id=0) - if not feature or not feature.get("enabled", False): - raise AssertionError(f"Export not enabled: {feature}") - log("Export enabled") - - if require_unl_report: - result = ctx.rpc.ledger_entry(0, _unl_report_index()) - node = (result or {}).get("node", {}) - active = node.get("ActiveValidators", []) - if node.get("LedgerEntryType") != "UNLReport" or not active: - raise AssertionError( - "Export success scenario requires a ledger UNLReport with " - f"ActiveValidators, got: {result}" - ) - log(f"UNLReport active validators: {len(active)}") - - -def find_export_txns(ctx, seq): - """Find Export transactions in a ledger. - - Returns list of Export transaction dicts. - """ - result = ctx.ledger(seq, transactions=True) - if not result: - return [] - - txns = result.get("ledger", {}).get("transactions", []) - return [tx for tx in txns if tx.get("TransactionType") == "Export"] - - -def _validator_master_keys_by_node(ctx): - """Return generated validator master keys keyed by testnet node id.""" - network = json.loads((ctx.base_dir / "network.json").read_text()) - return { - int(node["id"]): _decode_node_public_key(node["public_key"]) - for node in network["nodes"] - } - - -def _export_committee_fields(master_keys): - """Return the canonical roster and its protocol content digest.""" - encoded = [bytes.fromhex(key) for key in master_keys] - if not encoded or len(encoded) > 32 or len(set(encoded)) != len(encoded): - raise AssertionError("Export committee requires 1..32 unique masters") - encoded.sort() - roster = b"".join(encoded) - preimage = b"ECM\0" + len(encoded).to_bytes(4, "big") + roster - digest = hashlib.sha512(preimage).digest()[:32] - return { - "ExportCommitteeHash": digest.hex().upper(), - "ExportCommittee": roster.hex().upper(), - } - - -def bitmap_positions(bitmap): - """Return set positions from a witness contributor bitmap.""" - raw = bytes.fromhex(bitmap) if isinstance(bitmap, str) else bytes(bitmap) - return { - byte_index * 8 + bit_index - for byte_index, byte in enumerate(raw) - for bit_index in range(8) - if byte & (1 << bit_index) - } - - -def export_authority(ctx, *, require_unl_report=True, committee_node_ids=None): - """Build an account-owned committee declaration for a direct Export.""" - ledger_result = ctx.ledger("validated") or {} - ledger = ledger_result.get("ledger", {}) - ledger_hash = ledger_result.get("ledger_hash") or ledger.get("hash") - if not ledger_hash: - raise AssertionError(f"Validated ledger hash unavailable: {ledger_result}") - - report = ( - ctx.rpc.request( - 0, - "ledger_entry", - {"index": _unl_report_index(), "ledger_hash": ledger_hash}, - ) - or {} - ) - active = report.get("node", {}).get("ActiveValidators", []) - if not active: - if require_unl_report: - raise AssertionError(f"UNLReport active validators unavailable: {report}") - # The negative scenario still submits a structurally valid roster so - # source eligibility, rather than client construction, rejects it. - masters = _validator_master_keys_by_node(ctx) - selected_ids = ( - sorted(masters) if committee_node_ids is None else committee_node_ids - ) - return _export_committee_fields([masters[node_id] for node_id in selected_ids]) - - active_keys = set() - for entry in active: - validator = entry.get("ActiveValidator", entry) - key = validator.get("PublicKey") - if not key: - raise AssertionError(f"Malformed UNLReport validator entry: {entry}") - active_keys.add(key.upper()) - active_keys = sorted(active_keys, key=bytes.fromhex) - - if committee_node_ids is None: - selected_keys = active_keys - else: - masters = _validator_master_keys_by_node(ctx) - selected_keys = [] - for node_id in committee_node_ids: - if node_id not in masters: - raise AssertionError(f"Unknown testnet validator node n{node_id}") - if masters[node_id] not in active_keys: - raise AssertionError( - f"Validator n{node_id} is absent from the active UNLReport" - ) - selected_keys.append(masters[node_id]) - if not selected_keys: - raise AssertionError("Export committee must select at least one validator") - - return _export_committee_fields(selected_keys) - - -async def create_export_committee( - ctx, log, wallet, *, committee_node_ids=None -): - """Create one immutable account-owned committee and return its fields.""" - authority = export_authority(ctx, committee_node_ids=committee_node_ids) - result = await ctx.submit_and_wait( - { - "TransactionType": "Export", - "ExportCommittee": authority["ExportCommittee"], - "Fee": "1000000", - }, - wallet, - ) - meta = result.get("meta", result.get("metaData", {})) - if meta.get("TransactionResult") != "tesSUCCESS": - raise AssertionError(f"Export committee setup failed: {result}") - log(f"Export committee created: {authority['ExportCommitteeHash']}") - return authority - - -def find_export_signature_witness(ctx, seq, origin_hash): - """Find a later-ledger ExportSignatures witness for an Export origin.""" - result = ctx.ledger(seq, transactions=True) - txns = (result or {}).get("ledger", {}).get("transactions", []) - for tx in txns: - if not isinstance(tx, dict): - continue - if tx.get("TransactionType") != "ExportSignatures": - continue - if tx.get("TransactionHash") == origin_hash: - return tx - return None - - -async def wait_for_export_signature_witness( - ctx, - log, - origin_hash, - *, - after_ledger, - max_ledgers=EXPORT_PUBLICATION_LEDGER_WINDOW, - expect_witness=True, -): - """Wait through the publication window for an origin-keyed witness.""" - scanned = after_ledger - target = after_ledger + max_ledgers - while scanned < target: - current = ctx.validated_ledger_index(0) - if current is None or current <= scanned: - await ctx.wait_for_ledger(scanned + 1, node_id=0, timeout=30) - current = ctx.validated_ledger_index(0) - if current is None or current <= scanned: - continue - - scan_through = min(current, target) - for seq in range(scanned + 1, scan_through + 1): - witness = find_export_signature_witness(ctx, seq, origin_hash) - if witness: - if not expect_witness: - raise AssertionError( - f"Unexpected ExportSignatures witness in ledger {seq}" - ) - log(f" ExportSignatures witness found in ledger {seq}") - return assert_export_witness(witness, origin_hash, seq, log) - scanned = scan_through - - if expect_witness: - raise AssertionError( - f"No ExportSignatures witness for {origin_hash} within " - f"{max_ledgers} validated ledgers" - ) - log(f" No witness observed through ledger {scanned}") - return None - - -async def wait_for_validated_transaction( - ctx, tx_hash, *, after_ledger, max_ledgers=EXPORT_RETRY_LEDGER_WINDOW -): - """Resolve a raw non-tes submit result to validated transaction evidence.""" - checked = after_ledger - target = after_ledger + max_ledgers - while True: - result = ctx.rpc.request(0, "tx", {"transaction": tx_hash}) or {} - if result.get("validated"): - return result - if checked >= target: - break - await ctx.wait_for_ledger(checked + 1, node_id=0, timeout=30) - current = ctx.validated_ledger_index(0) - checked = min(target, max(checked + 1, current or checked + 1)) - raise AssertionError(f"Transaction {tx_hash} did not validate by ledger {target}") - - -async def submit_direct_export( - ctx, - log, - tx, - wallet, - *, - timeout=60, - max_rebases=2, - committee_node_ids=None, -): - """Submit a direct Export, rebasing after a validated parent mismatch.""" - for attempt in range(max_rebases + 1): - current = ctx.validated_ledger_index(0) - if current is None: - raise AssertionError("Validated ledger unavailable before Export") - - candidate = dict(tx) - candidate.update(export_authority(ctx, committee_node_ids=committee_node_ids)) - candidate["LastLedgerSequence"] = current + EXPORT_RETRY_LEDGER_WINDOW - result = await ctx.submit_and_wait(candidate, wallet, timeout=timeout) - if result.get("engine_result") != "tecEXPORT_COMMITTEE_UNAVAILABLE": - return result - - tx_hash = result.get("hash") or result.get("tx_json", {}).get("hash") - if not tx_hash: - raise AssertionError(f"Committee eligibility failure missing tx hash: {result}") - validated = result - if not result.get("validated"): - validated = await wait_for_validated_transaction( - ctx, tx_hash, after_ledger=current - ) - meta = validated.get("meta", {}) - if meta.get("TransactionResult") != "tecEXPORT_COMMITTEE_UNAVAILABLE": - raise AssertionError( - f"Unexpected validated rebase result for {tx_hash}: {validated}" - ) - log(f" Direct Export parent changed; rebasing attempt {attempt + 1}") - - raise AssertionError(f"Direct Export parent changed more than {max_rebases} times") - - -def dst_param(address): - """Encode an address as a HookParameter entry for the DST param.""" - from xrpl.core.addresscodec import decode_classic_address - - dst_hex = decode_classic_address(address).hex().upper() - return { - "HookParameter": { - "HookParameterName": "445354", # "DST" - "HookParameterValue": dst_hex, - } - } - - -def assert_hook_accepted(meta, log, *, expected_emits=1, expected_exports=None): - """Assert hook executed with ACCEPT and expected emission counts. - - Checks sfHookExecutions in transaction metadata. - Returns the hook execution entry for further inspection. - """ - hook_execs = meta.get("HookExecutions", []) - if not hook_execs: - raise AssertionError("No HookExecutions in metadata") - - exec_entry = hook_execs[0].get("HookExecution", {}) - hook_result = exec_entry.get("HookResult", -1) - emit_count = exec_entry.get("HookEmitCount", -1) - export_count = exec_entry.get("HookExportCount") - return_code = exec_entry.get("HookReturnCode", "") - - log( - f" HookResult={hook_result} EmitCount={emit_count} " - f"ExportCount={export_count} ReturnCode={return_code}" - ) - - # HookResult 3 = ExitType::ACCEPT - if hook_result != 3: - raise AssertionError( - f"Hook did not ACCEPT: HookResult={hook_result} ReturnCode={return_code}" - ) - - if emit_count != expected_emits: - raise AssertionError(f"Expected {expected_emits} emits, got {emit_count}") - - if expected_exports is not None and export_count != expected_exports: - raise AssertionError(f"Expected {expected_exports} exports, got {export_count}") - - # ReturnCode 0 = success; non-zero = ASSERT line number in hook - if return_code and str(return_code) != "0": - raise AssertionError( - f"Hook returned error code {return_code} " - f"(likely ASSERT failure at that line)" - ) - - return exec_entry - - -def _signer_entries(witness): - entries = [] - for entry in witness.get("ExportSigners", []): - signer = entry.get("ExportSigner", entry) - entries.append(signer) - return entries - - -def assert_export_witness(witness, origin_hash, ledger_seq, log): - """Assert a later-ledger witness contains one ordered signature record.""" - if witness.get("TransactionType") != "ExportSignatures": - raise AssertionError("Expected ExportSignatures witness") - if witness.get("TransactionHash") != origin_hash: - raise AssertionError("ExportSignatures origin binding mismatch") - if witness.get("LedgerSequence") != ledger_seq: - raise AssertionError("ExportSignatures ledger binding mismatch") - if witness.get("Signers"): - raise AssertionError("Witness must not contain ordinary Signers") - if witness.get("ExportedTxn", {}).get("Signers"): - raise AssertionError("Witness ExportedTxn must be unsigned") - contributors = witness.get("EntropyContributors") - if not contributors: - raise AssertionError("ExportSignatures missing contributor bitmap") - - signers = _signer_entries(witness) - if not signers: - raise AssertionError("ExportSignatures has no ExportSigners") - if any( - not signer.get("SigningPubKey") or not signer.get("TxnSignature") - for signer in signers - ): - raise AssertionError("ExportSignatures has a malformed ExportSigner") - contributor_count = sum(byte.bit_count() for byte in bytes.fromhex(contributors)) - if contributor_count != len(signers): - raise AssertionError( - "ExportSignatures contributor bitmap and ordered signer count differ" - ) - log(f" Witness signers: {len(signers)} validator(s)") - witness["_WitnessSigners"] = signers - return witness - - -def assert_export_latch( - ctx, - account_address, - log, - *, - expect_exists=True, - origin_hash=None, - expect_witness=None, - ledger_hash=None, -): - """Assert Export latch exists (or doesn't) for the account.""" - params = {"account": account_address, "ledger_index": "validated"} - if ledger_hash is not None: - del params["ledger_index"] - params["ledger_hash"] = ledger_hash - obj_result = ctx.rpc.request(0, "account_objects", params) - if not obj_result or obj_result.get("error"): - raise AssertionError(f"account_objects RPC failed: {obj_result}") - if obj_result.get("validated") is not True: - raise AssertionError(f"account_objects result is not validated: {obj_result}") - if ledger_hash is not None and obj_result.get("ledger_hash") != ledger_hash: - raise AssertionError( - "account_objects returned wrong ledger: " - f"expected {ledger_hash}, got {obj_result.get('ledger_hash')}" - ) - - all_objects = obj_result.get("account_objects", []) - export_latches = [ - obj for obj in all_objects if obj.get("LedgerEntryType") == "ExportLatch" - ] - log(f" Export latches: {len(export_latches)}") - - if origin_hash is not None: - export_latches = [ - latch - for latch in export_latches - if latch.get("TransactionHash") == origin_hash - ] - - if expect_exists and not export_latches: - raise AssertionError("Expected Export latch but none found") - if not expect_exists and export_latches: - raise AssertionError( - f"Expected no Export latches but found {len(export_latches)}" - ) - - for latch in export_latches: - if "Digest" not in latch: - raise AssertionError( - "ExportLatch missing signature-independent intent Digest" - ) - if "TransactionHash" not in latch: - raise AssertionError("ExportLatch missing Export origin TransactionHash") - if "ExportCommitteeHash" not in latch: - raise AssertionError("ExportLatch missing ExportCommitteeHash") - if expect_witness is True and "ExportSignatureHash" not in latch: - raise AssertionError("ExportLatch missing ExportSignatureHash") - if expect_witness is False and "ExportSignatureHash" in latch: - raise AssertionError("Pending ExportLatch unexpectedly witnessed") - - return export_latches diff --git a/.testnet/scenarios/export/export_no_veto_missing_observation.py b/.testnet/scenarios/export/export_no_veto_missing_observation.py deleted file mode 100644 index 4ba5e36580..0000000000 --- a/.testnet/scenarios/export/export_no_veto_missing_observation.py +++ /dev/null @@ -1,96 +0,0 @@ -""":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, - assert_export_latch, - require_export, - submit_direct_export, - wait_for_export_signature_witness, -) - - -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 submit_direct_export( - ctx, - log, - { - "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, - ) - - final_seq = result.get("ledger_index", ctx.validated_ledger_index(0)) - origin_hash = result.get("hash") - engine_result = result.get("engine_result", "") - - log(f"Export completed at ledger {final_seq}, result: {engine_result}") - if engine_result != "tesSUCCESS": - raise AssertionError(f"Expected tesSUCCESS, got {engine_result}") - if not origin_hash: - raise AssertionError(f"Validated Export missing hash: {result}") - - witness = await wait_for_export_signature_witness( - ctx, log, origin_hash, after_ledger=final_seq - ) - signers = witness.get("_WitnessSigners", []) - if len(signers) < 4: - raise AssertionError(f"Expected at least 4 signers, got {len(signers)}") - log(f"Export signer count: {len(signers)}") - - # The validated witness proves the missing observation did not veto the - # round. Pin the injected fault separately; the internal no-veto diagnostic - # may be flushed after witness observation and is not part of the contract. - withhold_logs = ctx.assert_log( - r"Export: withholding exportSigSetHash", - since=export_start, - ) - log(f"Export sidecar hash withholding logs: {withhold_logs.count}") - - assert_export_latch( - ctx, - alice.address, - log, - expect_exists=True, - origin_hash=origin_hash, - expect_witness=True, - ) - - log("PASS") diff --git a/.testnet/scenarios/export/export_quorum.py b/.testnet/scenarios/export/export_quorum.py deleted file mode 100644 index d7bcb0d9f3..0000000000 --- a/.testnet/scenarios/export/export_quorum.py +++ /dev/null @@ -1,129 +0,0 @@ -""":descr: Test Export witness quorum behavior. Every valid intent is admitted; -enough selected validators produce a later witness, while a below-quorum intent -remains unwitnessed through its bounded publication window. - -Parameterized via `expect_success` kwarg from suite.yml. - -Flow: - 1. Fund alice and bob - 2. alice submits ttEXPORT - 3. Verify the intent validates with tesSUCCESS - 4. Verify a later witness exists only when the committee reaches quorum - 5. Verify subsequent payment works regardless -""" - -from __future__ import annotations - -from export_helpers import ( - EXPORT_RETRY_LEDGER_WINDOW, - assert_export_latch, - require_export, - submit_direct_export, - wait_for_export_signature_witness, -) - - -async def scenario(ctx, log, expect_success=True): - await require_export(ctx, log) - - # --- Setup --- - 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}") - outcome = "success" if expect_success else "failure (below quorum)" - log(f"Expecting export {outcome}") - - # --- Submit ttEXPORT --- - result = await submit_direct_export( - ctx, - log, - { - "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, - ) - - final_seq = result.get("ledger_index", ctx.validated_ledger_index(0)) - origin_hash = result.get("hash") - engine_result = result.get("engine_result", "") - - log(f"Export at ledger {final_seq}, result: {engine_result}") - if engine_result != "tesSUCCESS": - raise AssertionError(f"Expected intent tesSUCCESS, got {engine_result}") - if not origin_hash: - raise AssertionError(f"Validated Export missing hash: {result}") - - if expect_success: - await wait_for_export_signature_witness( - ctx, log, origin_hash, after_ledger=final_seq - ) - assert_export_latch( - ctx, - alice.address, - log, - expect_exists=True, - origin_hash=origin_hash, - expect_witness=True, - ) - - log("Export succeeded as expected (active-view quorum reached)") - else: - await wait_for_export_signature_witness( - ctx, - log, - origin_hash, - after_ledger=final_seq, - expect_witness=False, - ) - assert_export_latch( - ctx, - alice.address, - log, - expect_exists=True, - origin_hash=origin_hash, - expect_witness=False, - ) - log("Intent remained unwitnessed as expected (below committee quorum)") - - # --- Verify subsequent payment works --- - log("Submitting payment from alice to bob...") - pay_result = await ctx.submit_and_wait( - { - "TransactionType": "Payment", - "Destination": bob.address, - "Amount": "1000000", - "Fee": "12", - }, - alice.wallet, - timeout=30, - ) - - pay_engine = pay_result.get("engine_result", "") - log(f"Payment result: {pay_engine}") - - if pay_engine != "tesSUCCESS": - raise AssertionError(f"Payment failed: {pay_engine}") - - log("Payment succeeded -- account not blocked") - log("PASS") diff --git a/.testnet/scenarios/export/export_share_subscription.py b/.testnet/scenarios/export/export_share_subscription.py deleted file mode 100644 index ab43b53201..0000000000 --- a/.testnet/scenarios/export/export_share_subscription.py +++ /dev/null @@ -1,226 +0,0 @@ -""":descr: subscribe over a real WebSocket to post-validation Export shares - -The subscriber opens before the Export is submitted. It proves the stream -publishes a quorum of independently attributable shares for the exact validated -origin and that those same signature records form the later ledger witness. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import json - -import websockets -from xahaud_scripts.testnet.config import _decode_node_public_key - -from export_helpers import ( - EXPORT_RETRY_LEDGER_WINDOW, - assert_export_latch, - bitmap_positions, - export_authority, - require_export, - submit_direct_export, - wait_for_export_signature_witness, -) - - -def _witness_records(witness): - positions = sorted(bitmap_positions(witness["EntropyContributors"])) - signers = witness["_WitnessSigners"] - if len(positions) != len(signers): - raise AssertionError("Witness bitmap and signer count differ") - return { - ( - position, - signer["SigningPubKey"].upper(), - signer["TxnSignature"].upper(), - ) - for position, signer in zip(positions, signers, strict=True) - } - - -async def scenario(ctx, log): - await require_export(ctx, log) - await ctx.fund_accounts({"alice": 10000, "bob": 1000}) - alice = ctx.account("alice") - bob = ctx.account("bob") - - network = json.loads((ctx.base_dir / "network.json").read_text()) - node0 = next(node for node in network["nodes"] if int(node["id"]) == 0) - ws_url = f"ws://127.0.0.1:{node0['port_ws']}" - events = [] - - async with websockets.connect(ws_url, open_timeout=10) as websocket: - await websocket.send( - json.dumps( - { - "id": 1, - "command": "subscribe", - "streams": ["export_signatures"], - } - ) - ) - ack = json.loads(await asyncio.wait_for(websocket.recv(), timeout=10)) - if ack.get("status") != "success": - raise AssertionError(f"export_signatures subscription failed: {ack}") - log("Subscribed to export_signatures over WebSocket") - - async def receive_events(): - while True: - event = json.loads(await websocket.recv()) - if event.get("stream") == "export_signatures": - events.append(event) - - reader = asyncio.create_task(receive_events()) - try: - current = ctx.validated_ledger_index(0) - committee_size = ( - len(bytes.fromhex(export_authority(ctx)["ExportCommittee"])) // 33 - ) - result = await submit_direct_export( - ctx, - log, - { - "TransactionType": "Export", - "Fee": "1000000", - "ExportedTxn": { - "TransactionType": "Payment", - "Account": alice.address, - "Destination": bob.address, - "Amount": "1000000", - "Fee": "10", - "Sequence": 0, - "TicketSequence": 1, - "FirstLedgerSequence": current + 1, - "LastLedgerSequence": current + EXPORT_RETRY_LEDGER_WINDOW, - "Flags": 2147483648, - "SigningPubKey": "", - }, - }, - alice.wallet, - ) - if result.get("engine_result") != "tesSUCCESS": - raise AssertionError(f"Export failed: {result}") - - origin = result.get("hash") - origin_seq = int(result.get("ledger_index")) - if not origin: - raise AssertionError(f"Validated Export missing hash: {result}") - origin_ledger = ctx.ledger(origin_seq) or {} - origin_hash = origin_ledger.get("ledger_hash") or origin_ledger.get( - "ledger", {} - ).get("hash") - if not origin_hash: - raise AssertionError( - f"Validated origin ledger {origin_seq} missing hash" - ) - - assert_export_latch( - ctx, - alice.address, - log, - origin_hash=origin, - expect_witness=False, - ) - selected = set(range(committee_size)) - quorum = (4 * committee_size + 4) // 5 - - witness = await wait_for_export_signature_witness( - ctx, log, origin, after_ledger=origin_seq - ) - expected_records = _witness_records(witness) - if len(expected_records) < quorum: - raise AssertionError( - f"Witness contains only {len(expected_records)} distinct records; " - f"need quorum {quorum}" - ) - - deadline = asyncio.get_running_loop().time() + 10 - while asyncio.get_running_loop().time() < deadline: - matching = [ - event for event in events if event.get("origin_txid") == origin - ] - events_by_record = {} - for event in matching: - record = ( - int(event["committee_position"]), - _decode_node_public_key(event["signing_key"]), - event["signature"].upper(), - ) - events_by_record.setdefault(record, []).append(event) - if expected_records <= events_by_record.keys(): - break - await asyncio.sleep(0.1) - else: - raise AssertionError( - "WebSocket stream did not publish every signature used by " - f"the witness: expected={expected_records}, " - f"observed={set(events_by_record)}" - ) - - unique_positions = set() - validated_hashes = {} - for event in matching: - if event.get("type") != "exportSignatureReceived": - raise AssertionError(f"Unexpected Export stream event: {event}") - if event.get("version") != 1: - raise AssertionError(f"Unexpected Export share version: {event}") - if event.get("owner") != alice.address: - raise AssertionError(f"Export stream owner mismatch: {event}") - if int(event.get("origin_ledger_seq", 0)) != origin_seq: - raise AssertionError( - f"Export stream origin sequence mismatch: {event}" - ) - if event.get("origin_ledger_hash") != origin_hash: - raise AssertionError(f"Export stream origin hash mismatch: {event}") - position = int(event.get("committee_position", -1)) - if position not in selected: - raise AssertionError( - f"Unselected validator streamed a share: {event}" - ) - unique_positions.add(position) - - witness_seq = int(witness["LedgerSequence"]) - for event in matching: - ledger_index = event.get("ledger_index") - ledger_hash = event.get("ledger_hash") - if isinstance(ledger_index, bool) or not isinstance(ledger_index, int): - raise AssertionError( - f"Export stream event missing numeric ledger_index: {event}" - ) - if not isinstance(ledger_hash, str) or not ledger_hash: - raise AssertionError( - f"Export stream event missing ledger_hash: {event}" - ) - if ledger_index not in validated_hashes: - observed_ledger = ctx.ledger(ledger_index) or {} - validated_hashes[ledger_index] = observed_ledger.get( - "ledger_hash" - ) or observed_ledger.get("ledger", {}).get("hash") - if ledger_hash != validated_hashes[ledger_index]: - raise AssertionError( - "Export stream cursor does not name the validated ledger: " - f"event={event}, expected_hash={validated_hashes[ledger_index]}" - ) - - for record in expected_records: - if not any( - origin_seq <= event["ledger_index"] < witness_seq - for event in events_by_record[record] - ): - raise AssertionError( - "Witness signature lacked a pre-witness stream event with a " - f"validated cursor: record={record}, " - f"events={events_by_record[record]}" - ) - log( - f"WebSocket exposed {len(unique_positions)} selected shares; " - f"witness used {len(expected_records)}" - ) - finally: - reader.cancel() - with contextlib.suppress(asyncio.CancelledError): - await reader - - log("PASS") diff --git a/.testnet/scenarios/export/export_without_unl_report.py b/.testnet/scenarios/export/export_without_unl_report.py deleted file mode 100644 index adcc488ebc..0000000000 --- a/.testnet/scenarios/export/export_without_unl_report.py +++ /dev/null @@ -1,97 +0,0 @@ -""":descr: Export fails closed without a ledger-anchored UNLReport view. - -Network-mode Export must not derive authority from a node-local trusted-config -view. An explicit parent binding still fails if that parent has no UNLReport, -and no Export latch is created. -""" - -from __future__ import annotations - -from export_helpers import ( - EXPORT_RETRY_LEDGER_WINDOW, - assert_export_latch, - export_authority, - require_export, - wait_for_validated_transaction, -) - - -async def scenario(ctx, log): - await require_export(ctx, log, require_unl_report=False) - - 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("UNLReport intentionally absent; export must not use local config view") - - result = await ctx.submit_and_wait( - { - "TransactionType": "Export", - "LastLedgerSequence": current_seq + EXPORT_RETRY_LEDGER_WINDOW, - "Fee": "1000000", - **export_authority(ctx, require_unl_report=False), - "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, - ) - - engine_result = result.get("engine_result", "") - log(f"Export submit result: {engine_result}") - - if engine_result == "tesSUCCESS": - raise AssertionError( - "Export should not succeed without a ledger-anchored UNLReport view" - ) - - if engine_result != "tecEXPORT_COMMITTEE_UNAVAILABLE": - raise AssertionError( - "Expected tecEXPORT_COMMITTEE_UNAVAILABLE without UNLReport view, " - f"got {engine_result}" - ) - - tx_hash = result.get("tx_json", {}).get("hash") - if not tx_hash: - raise AssertionError(f"Rejected Export missing tx hash: {result}") - validated = await wait_for_validated_transaction( - ctx, tx_hash, after_ledger=current_seq - ) - meta = validated.get("meta", validated.get("metaData", {})) - if meta.get("TransactionResult") != "tecEXPORT_COMMITTEE_UNAVAILABLE": - raise AssertionError(f"Unexpected validated result: {validated}") - - final_seq = validated.get("ledger_index") - final_ledger = ctx.ledger(final_seq) or {} - ledger_hash = final_ledger.get("ledger_hash") or final_ledger.get("ledger", {}).get( - "hash" - ) - if not ledger_hash: - raise AssertionError(f"Validated failure ledger unavailable: {final_ledger}") - log(f"Export failure validated in ledger {final_seq}") - - assert_export_latch( - ctx, - alice.address, - log, - expect_exists=False, - ledger_hash=ledger_hash, - ) - - log("PASS") diff --git a/.testnet/scenarios/export/retriable_export.py b/.testnet/scenarios/export/retriable_export.py deleted file mode 100644 index 959d899d1f..0000000000 --- a/.testnet/scenarios/export/retriable_export.py +++ /dev/null @@ -1,110 +0,0 @@ -""":descr: Submit ttEXPORT directly (no hook), verify the intent is admitted -and a later validated ledger records its signature witness. Then submit a -payment from the same account to verify sequence handling remains independent. - -Flow: - 1. Fund alice and bob - 2. alice submits ttEXPORT with an explicit parent-universe declaration - 3. Validation releases shares; a later ledger records ExportSignatures - 4. alice submits a Payment to bob -> should succeed (sequence not blocked) -""" - -from __future__ import annotations - -from export_helpers import ( - EXPORT_RETRY_LEDGER_WINDOW, - assert_export_latch, - require_export, - submit_direct_export, - wait_for_export_signature_witness, -) - - -async def scenario(ctx, log): - await require_export(ctx, log) - - # --- Setup --- - 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}") - - # --- 1. Submit ttEXPORT --- - result = await submit_direct_export( - ctx, - log, - { - "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_seq = result.get("ledger_index", ctx.validated_ledger_index(0)) - origin_hash = result.get("hash") - engine_result = result.get("engine_result", "") - log(f"Export completed at ledger {export_seq}, result: {engine_result}") - - if engine_result != "tesSUCCESS": - raise AssertionError(f"Expected tesSUCCESS for export, got {engine_result}") - - if not origin_hash: - raise AssertionError(f"Validated Export missing hash: {result}") - - await wait_for_export_signature_witness( - ctx, log, origin_hash, after_ledger=export_seq - ) - - assert_export_latch( - ctx, - alice.address, - log, - expect_exists=True, - origin_hash=origin_hash, - expect_witness=True, - ) - - # --- 2. Submit Payment from same account --- - log("Submitting payment from alice to bob...") - pay_result = await ctx.submit_and_wait( - { - "TransactionType": "Payment", - "Destination": bob.address, - "Amount": "1000000", - "Fee": "12", - }, - alice.wallet, - timeout=30, - ) - - pay_engine = pay_result.get("engine_result", "") - log(f"Payment result: {pay_engine}") - - if pay_engine != "tesSUCCESS": - raise AssertionError(f"Payment failed: {pay_engine}") - - log( - f"Both transactions succeeded: " - f"Export at ledger {export_seq}, Payment at ledger {ctx.validated_ledger_index(0)}" - ) - log("Sequence handling OK - export didn't block subsequent txns") - log("PASS") diff --git a/.testnet/scenarios/export/steady_state_export.py b/.testnet/scenarios/export/steady_state_export.py deleted file mode 100644 index 327780b06b..0000000000 --- a/.testnet/scenarios/export/steady_state_export.py +++ /dev/null @@ -1,230 +0,0 @@ -""":descr: install xport hook, trigger export, verify emitted ttEXPORT lifecycle - -1. Fund alice (hook holder), bob (trigger), carol (export destination) -2. Install xport hook on alice -3. bob pays alice with DST=carol → hook calls xport() → emits ttEXPORT -4. Emitted ttEXPORT enters a validated ledger and releases signatures -5. Verify a later ledger records its ExportSignatures witness -""" - -from __future__ import annotations - -from export_helpers import ( - require_export, - find_export_txns, - dst_param, - assert_hook_accepted, - assert_export_latch, - create_export_committee, - wait_for_export_signature_witness, -) - -# C source for the xport hook — verbatim from src/test/app/Export_test_hooks.h -# On Payment to the hook account, exports a 1 XAH payment to the DST param. -XPORT_HOOK_C = r""" -#include -extern int32_t _g(uint32_t id, uint32_t maxiter); -extern int64_t accept(uint32_t read_ptr, uint32_t read_len, int64_t error_code); -extern int64_t rollback(uint32_t read_ptr, uint32_t read_len, int64_t error_code); -extern int64_t xport(uint32_t write_ptr, uint32_t write_len, uint32_t read_ptr, uint32_t read_len, uint32_t committee_hash_ptr, uint32_t committee_hash_len, uint64_t callback_fee_drops); -extern int64_t xport_reserve(uint32_t count); -extern int64_t hook_account(uint32_t write_ptr, uint32_t write_len); -extern int64_t otxn_param(uint32_t write_ptr, uint32_t write_len, uint32_t name_ptr, uint32_t name_len); -extern int64_t otxn_type(void); -extern int64_t ledger_seq(void); - -#define SBUF(x) (uint32_t)(x), sizeof(x) -#define ASSERT(x) if (!(x)) rollback((uint32_t)#x, sizeof(#x), __LINE__) - -#define ttPAYMENT 0 -#define tfCANONICAL 0x80000000UL -#define amAMOUNT 1 -#define amFEE 8 -#define atACCOUNT 1 -#define atDESTINATION 3 - -#define ENCODE_TT(buf_out, tt) \ - buf_out[0] = 0x12U; buf_out[1] = (tt >> 8) & 0xFFU; buf_out[2] = tt & 0xFFU; buf_out += 3; - -#define ENCODE_FLAGS(buf_out, flags) \ - buf_out[0] = 0x22U; buf_out[1] = (flags >> 24) & 0xFFU; buf_out[2] = (flags >> 16) & 0xFFU; \ - buf_out[3] = (flags >> 8) & 0xFFU; buf_out[4] = flags & 0xFFU; buf_out += 5; - -#define ENCODE_SEQUENCE(buf_out, seq) \ - buf_out[0] = 0x24U; buf_out[1] = (seq >> 24) & 0xFFU; buf_out[2] = (seq >> 16) & 0xFFU; \ - buf_out[3] = (seq >> 8) & 0xFFU; buf_out[4] = seq & 0xFFU; buf_out += 5; - -#define ENCODE_FLS(buf_out, fls) \ - buf_out[0] = 0x20U; buf_out[1] = 0x1AU; buf_out[2] = (fls >> 24) & 0xFFU; \ - buf_out[3] = (fls >> 16) & 0xFFU; buf_out[4] = (fls >> 8) & 0xFFU; \ - buf_out[5] = fls & 0xFFU; buf_out += 6; - -#define ENCODE_LLS(buf_out, lls) \ - buf_out[0] = 0x20U; buf_out[1] = 0x1BU; buf_out[2] = (lls >> 24) & 0xFFU; \ - buf_out[3] = (lls >> 16) & 0xFFU; buf_out[4] = (lls >> 8) & 0xFFU; \ - buf_out[5] = lls & 0xFFU; buf_out += 6; - -#define ENCODE_DROPS(buf_out, drops, amt_type) \ - buf_out[0] = 0x60U + amt_type; buf_out[1] = 0x40U + ((drops >> 56) & 0x3FU); \ - buf_out[2] = (drops >> 48) & 0xFFU; buf_out[3] = (drops >> 40) & 0xFFU; \ - buf_out[4] = (drops >> 32) & 0xFFU; buf_out[5] = (drops >> 24) & 0xFFU; \ - buf_out[6] = (drops >> 16) & 0xFFU; buf_out[7] = (drops >> 8) & 0xFFU; \ - buf_out[8] = drops & 0xFFU; buf_out += 9; - -#define ENCODE_SIGNING_PUBKEY_EMPTY(buf_out) \ - buf_out[0] = 0x73U; buf_out[1] = 0x00U; buf_out += 2; - -#define ENCODE_ACCOUNT(buf_out, acc, acc_type) \ - buf_out[0] = 0x80U + acc_type; buf_out[1] = 0x14U; \ - for (int i = 0; i < 20; ++i) buf_out[2+i] = acc[i]; buf_out += 22; - -#define PREPARE_PAYMENT_SIMPLE_SIZE 270U - -int64_t hook(uint32_t reserved) { - _g(1, 1); - - if (otxn_type() != ttPAYMENT) - return accept(0, 0, 0); - - ASSERT(xport_reserve(1) == 1); - - uint8_t dst[20]; - int64_t dst_len = otxn_param(SBUF(dst), "DST", 3); - ASSERT(dst_len == 20); - - uint8_t acc[20]; - ASSERT(hook_account(SBUF(acc)) == 20); - - uint32_t cls = (uint32_t)ledger_seq(); - - uint8_t tx[PREPARE_PAYMENT_SIMPLE_SIZE]; - uint8_t* buf = tx; - - ENCODE_TT(buf, ttPAYMENT); - ENCODE_FLAGS(buf, tfCANONICAL); - ENCODE_SEQUENCE(buf, 0); - ENCODE_FLS(buf, cls + 1); - ENCODE_LLS(buf, cls + 5); - // sfTicketSequence = UINT32 field 41 = 0x20 0x29 - buf[0] = 0x20U; buf[1] = 0x29U; - buf[2] = 0; buf[3] = 0; buf[4] = 0; buf[5] = 1; - buf += 6; - - uint64_t drops = 1000000; - ENCODE_DROPS(buf, drops, amAMOUNT); - ENCODE_DROPS(buf, 10, amFEE); - - ENCODE_SIGNING_PUBKEY_EMPTY(buf); - ENCODE_ACCOUNT(buf, acc, atACCOUNT); - ENCODE_ACCOUNT(buf, dst, atDESTINATION); - - uint8_t hash[32]; - static const uint8_t committee_hash[32] = { COMMITTEE_HASH_BYTES }; - int64_t xport_result = xport( - SBUF(hash), (uint32_t)tx, buf - tx, SBUF(committee_hash), 0); - ASSERT(xport_result == 32); - - return accept(0, 0, 0); -} -""" - - -async def scenario(ctx, log): - # Wait for network to start and amendments to activate - await require_export(ctx, log) - - # --- Setup --- - await ctx.fund_accounts({"alice": 10000, "bob": 10000, "carol": 1000}) - log("Accounts funded") - - alice = ctx.account("alice") - carol = ctx.account("carol") - - authority = await create_export_committee(ctx, log, alice.wallet) - committee_bytes = bytes.fromhex(authority["ExportCommitteeHash"]) - committee_initializer = ", ".join(f"0x{byte:02X}" for byte in committee_bytes) - hook_source = XPORT_HOOK_C.replace("COMMITTEE_HASH_BYTES", committee_initializer) - - # Compile and install xport hook on alice - wasm = ctx.compile_hook(hook_source, label="xport") - await ctx.submit_and_wait( - { - "TransactionType": "SetHook", - "Hooks": [ - { - "Hook": { - "CreateCode": wasm.hex().upper(), - "HookOn": "0" * 64, - "HookNamespace": "0" * 64, - "HookApiVersion": 0, - "Flags": 1, # hsfOVERRIDE - } - } - ], - "Fee": "100000000", - }, - alice.wallet, - ) - log( - f"Hook installed on alice ({alice.address[:12]}...) " - f"ledger {ctx.validated_ledger_index(0)}" - ) - - # --- Trigger --- - # bob pays alice → hook calls xport() → emits ttEXPORT - trigger_result = await ctx.submit_and_wait( - { - "TransactionType": "Payment", - "Destination": alice.address, - "Amount": "100000000", - "Fee": "1000000", - "HookParameters": [dst_param(carol.address)], - }, - ctx.account("bob").wallet, - ) - trigger_seq = ctx.validated_ledger_index(0) - log(f"Export triggered at ledger {trigger_seq}") - - # xport() schedules a ttEXPORT through the emitted directory, but hook - # metadata reports it separately from ordinary HookEmissions. - trigger_meta = trigger_result.get("meta", {}) - assert_hook_accepted(trigger_meta, log, expected_emits=0, expected_exports=1) - - # --- Verify: check each ledger close for the Export transaction --- - max_ledgers = 10 - for i in range(max_ledgers): - await ctx.wait_for_ledgers(1, node_id=0, timeout=30) - seq = ctx.validated_ledger_index(0) - exports = find_export_txns(ctx, seq) - if exports: - export_tx = exports[0] - meta = export_tx.get("meta", export_tx.get("metaData", {})) - result = meta.get("TransactionResult", "") - log(f"Ledger {seq}: Export txn found, result={result}") - - if result != "tesSUCCESS": - raise AssertionError(f"Export did not succeed: {result}") - - origin_hash = export_tx.get("hash") - if not origin_hash: - raise AssertionError(f"Export missing transaction hash: {export_tx}") - await wait_for_export_signature_witness( - ctx, log, origin_hash, after_ledger=seq - ) - - assert_export_latch( - ctx, - alice.address, - log, - expect_exists=True, - origin_hash=origin_hash, - expect_witness=True, - ) - - log("PASS") - return - log(f"Ledger {seq}: no Export txn yet") - - raise AssertionError( - f"No Export transaction found after {max_ledgers} ledger closes" - ) diff --git a/.testnet/scenarios/helpers.py b/.testnet/scenarios/helpers.py deleted file mode 100644 index ef832e41f3..0000000000 --- a/.testnet/scenarios/helpers.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Shared helpers for ConsensusEntropy scenario tests.""" - -from __future__ import annotations - -from xahaud_scripts.testnet.config import feature_name_to_hash - -ZERO_DIGEST = "0" * 64 -CONSENSUS_ENTROPY_FEATURE = feature_name_to_hash("ConsensusEntropy") - - -def feature_hash(name: str) -> str: - """Return the amendment hash accepted by feature RPC.""" - return feature_name_to_hash(name) - - -def feature_status(ctx, name: str, node_id=0): - """Query a feature by amendment hash; feature RPC names are ambiguous.""" - return ctx.feature_check(feature_hash(name), node_id=node_id) - - -def consensus_entropy_feature(ctx, node_id=0): - """Query ConsensusEntropy by amendment hash.""" - return feature_status(ctx, "ConsensusEntropy", node_id=node_id) - - -async def require_entropy(ctx, log): - """Wait for first ledger and assert ConsensusEntropy is enabled.""" - await ctx.wait_for_ledger_close(timeout=120) - feature = consensus_entropy_feature(ctx, node_id=0) - if not feature or not feature.get("enabled", False): - raise AssertionError(f"ConsensusEntropy not enabled: {feature}") - log("ConsensusEntropy enabled") - - -def get_entropy_tx(ctx, seq): - """Fetch ledger and return (ce_tx, user_txns) or raise.""" - result = ctx.ledger(seq, transactions=True) - if not result: - raise AssertionError(f"Ledger {seq}: fetch failed") - - ledger = result.get("ledger") - if not isinstance(ledger, dict): - raise AssertionError(f"Ledger {seq}: fetch returned no ledger: {result}") - - txns = ledger.get("transactions", []) - ce = [tx for tx in txns if tx.get("TransactionType") == "ConsensusEntropy"] - user = [tx for tx in txns if tx.get("TransactionType") != "ConsensusEntropy"] - - if len(ce) != 1: - raise AssertionError( - f"Ledger {seq}: expected 1 ConsensusEntropy txn, got {len(ce)}" - ) - - return ce[0], user - - -def entropy_fields(ce_tx): - """Return (digest, entropy_count, is_fallback) from a ConsensusEntropy tx. - - consensus_fallback rounds carry a deterministic non-zero consensus-bound - digest with EntropyCount=0 and EntropyTier=1 (consensus_fallback). - Validator entropy has EntropyTier=3 (validator_quorum). - - WARNING: is_fallback is ``tier != 3``, so it lumps participant_aligned - (Tier 2) in with fallback. It is only safe where no Tier 2 band exists - (e.g. 5-node networks, where tier2 == quorum). For band-aware scenarios use - the explicit assert_consensus_fallback / assert_participant_aligned / - assert_validator_quorum helpers, which check EntropyTier directly. - """ - digest = ce_tx.get("Digest", "") - entropy_count = ce_tx.get("EntropyCount", -1) - tier = ce_tx.get("EntropyTier", None) - if tier is not None: - is_fallback = tier != 3 - else: - is_fallback = entropy_count == 0 - return digest, entropy_count, is_fallback - - -def assert_participant_aligned(ce_tx, seq, expected_count=None): - """Assert participant_aligned (Tier 2) entropy on a ConsensusEntropy tx. - - Tier 2 is the sub-quorum band: the agreed reveal cohort is >= the - participant floor but < the 80% validator quorum, so it carries - EntropyTier=2 with a deterministic non-zero digest. NOTE entropy_fields()'s - is_fallback lumps tier 2 in with fallback (is_fallback = tier != 3), so the - tier must be checked EXPLICITLY here. - """ - digest = ce_tx.get("Digest", "") - count = ce_tx.get("EntropyCount", -1) - tier = ce_tx.get("EntropyTier", None) - if tier != 2: - raise AssertionError( - f"Ledger {seq}: expected EntropyTier==2 (participant_aligned), " - f"got {tier} (EntropyCount={count})" - ) - if not digest or digest == ZERO_DIGEST: - raise AssertionError( - f"Ledger {seq}: participant_aligned digest must be non-zero, got " - f"{digest[:16]}..." - ) - if expected_count is not None and count != expected_count: - raise AssertionError( - f"Ledger {seq}: participant_aligned EntropyCount must be " - f"{expected_count} (the surviving cohort), got {count}" - ) - return digest, count - - -def assert_validator_quorum(ce_tx, seq, min_count=None): - """Assert validator_quorum (Tier 3) entropy on a ConsensusEntropy tx: - EntropyTier=3, a deterministic non-zero digest, and (optionally) - EntropyCount >= min_count (the active quorum). The count can EXCEED the - quorum (e.g. a still-full 6/6 ledger caught at a 6->5 transition), so check - >=, not ==. - """ - digest = ce_tx.get("Digest", "") - count = ce_tx.get("EntropyCount", -1) - tier = ce_tx.get("EntropyTier", None) - if tier != 3: - raise AssertionError( - f"Ledger {seq}: expected EntropyTier==3 (validator_quorum), got " - f"{tier} (EntropyCount={count})" - ) - if not digest or digest == ZERO_DIGEST: - raise AssertionError( - f"Ledger {seq}: validator_quorum digest must be non-zero, got " - f"{digest[:16]}..." - ) - if min_count is not None and count < min_count: - raise AssertionError( - f"Ledger {seq}: validator_quorum EntropyCount={count} < quorum " - f"{min_count}" - ) - return digest, count - - -def assert_consensus_fallback(ce_tx, seq): - """Assert consensus_fallback (Tier 1) entropy on a ConsensusEntropy tx: - EntropyTier=1, EntropyCount=0, and a deterministic NON-zero digest. - """ - digest = ce_tx.get("Digest", "") - count = ce_tx.get("EntropyCount", -1) - tier = ce_tx.get("EntropyTier", None) - if tier != 1: - raise AssertionError( - f"Ledger {seq}: expected EntropyTier==1 (consensus_fallback), got " - f"{tier} (EntropyCount={count})" - ) - if count != 0: - raise AssertionError( - f"Ledger {seq}: consensus_fallback EntropyCount must be 0, got " - f"{count}" - ) - if not digest or digest == ZERO_DIGEST: - raise AssertionError( - f"Ledger {seq}: consensus_fallback digest must be non-zero, got " - f"{digest[:16]}..." - ) - return digest, count - - -def assert_valid_entropy(ce_tx, seq, seen_digests=None): - """Assert quorum-met validator entropy. Optionally check uniqueness.""" - digest, entropy_count, is_fallback = entropy_fields(ce_tx) - - if is_fallback or not digest or digest == ZERO_DIGEST: - raise AssertionError(f"Ledger {seq}: fallback/empty Digest") - - if entropy_count < 4: - raise AssertionError( - f"Ledger {seq}: EntropyCount={entropy_count} < 4 (sub-quorum)" - ) - - if seen_digests is not None: - if digest in seen_digests: - raise AssertionError(f"Ledger {seq}: duplicate Digest {digest[:16]}...") - seen_digests.add(digest) - - return digest, entropy_count diff --git a/.testnet/scenarios/latency-suite.yml b/.testnet/scenarios/latency-suite.yml deleted file mode 100644 index fa7fa3ab0f..0000000000 --- a/.testnet/scenarios/latency-suite.yml +++ /dev/null @@ -1,90 +0,0 @@ -defaults: - network: - node_count: 5 - launcher: tmux - find_ports: true - slave_delay: 0.2 - features: - - ConsensusEntropy - - Export - track_features: - - ConsensusEntropy - - Export - unl_report: true - log_levels: - TxQ: info - Protocol: debug - Peer: debug - LedgerConsensus: debug - ConsensusExtensions: debug - NetworkOPs: info - rc: - - rng_poll_ms=250 -tests: - - name: latency_baseline_ce - script: .testnet/scenarios/perf/ce_export_latency_probe.py - params: - warmup_ledgers: 3 - ledgers: 8 - submit_export: false - - - name: latency_baseline_export - script: .testnet/scenarios/perf/ce_export_latency_probe.py - params: - warmup_ledgers: 3 - ledgers: 8 - submit_export: true - - - name: latency_proposal_delay_export - script: .testnet/scenarios/perf/ce_export_latency_probe.py - params: - warmup_ledgers: 3 - ledgers: 8 - submit_export: true - network: - rc: - - rng_poll_ms=250 - - delay=100,jitter=25,msg=proposal - - - name: latency_directed_pair_delay_export - script: .testnet/scenarios/perf/ce_export_latency_probe.py - params: - warmup_ledgers: 3 - ledgers: 8 - submit_export: true - network: - rc: - - rng_poll_ms=250 - - n0->n2:delay=750,jitter=100,msg=proposal - - n2->n0:delay=750,jitter=100,msg=proposal - - - name: latency_slow_minority_export - script: .testnet/scenarios/perf/ce_export_latency_probe.py - params: - warmup_ledgers: 3 - ledgers: 8 - submit_export: true - export_timeout: 120 - network: - rc: - - rng_poll_ms=250 - - n3->n0:delay=500,jitter=100,msg=proposal - - n3->n1:delay=500,jitter=100,msg=proposal - - n3->n2:delay=500,jitter=100,msg=proposal - - n4->n0:delay=500,jitter=100,msg=proposal - - n4->n1:delay=500,jitter=100,msg=proposal - - n4->n2:delay=500,jitter=100,msg=proposal - - n0->n3:delay=500,jitter=100,msg=proposal - - n1->n3:delay=500,jitter=100,msg=proposal - - n2->n3:delay=500,jitter=100,msg=proposal - - n0->n4:delay=500,jitter=100,msg=proposal - - n1->n4:delay=500,jitter=100,msg=proposal - - n2->n4:delay=500,jitter=100,msg=proposal - - - name: latency_export_no_veto_with_delay - script: .testnet/scenarios/export/export_no_veto_missing_observation.py - network: - rc: - - rng_poll_ms=250 - - delay=300,jitter=100,msg=proposal - - n4:no_export_sig_hash=true diff --git a/.testnet/scenarios/perf/ce_export_latency_probe.py b/.testnet/scenarios/perf/ce_export_latency_probe.py deleted file mode 100644 index 1c8602dc26..0000000000 --- a/.testnet/scenarios/perf/ce_export_latency_probe.py +++ /dev/null @@ -1,205 +0,0 @@ -""":descr: measure CE/export behavior while RuntimeConfig injects latency/drop. - -The suite supplies runtime fault injection through network.rc. This scenario -does not mutate RuntimeConfig itself; it observes what the launched network does -under that condition and logs enough counters to compare variants. -""" - -from __future__ import annotations - -from collections import Counter -import json - -from export.export_helpers import ( - EXPORT_RETRY_LEDGER_WINDOW, - require_export, - submit_direct_export, - wait_for_export_signature_witness, -) -from helpers import consensus_entropy_feature, get_entropy_tx - - -async def _require_runtime_config(ctx, log): - result = ctx.rpc.runtime_config(0) - if not result or result.get("error"): - raise AssertionError( - "Latency probe requires a binary built with " - "xahaud_runtime_test_config=ON; runtime_config RPC returned " - f"{result}" - ) - log("RuntimeConfig RPC active") - - -async def _require_consensus_entropy(ctx, log): - feature = consensus_entropy_feature(ctx, node_id=0) - if not feature or not feature.get("enabled", False): - raise AssertionError(f"ConsensusEntropy not enabled: {feature}") - log("ConsensusEntropy enabled") - - -def _log_runtime_config(ctx, log): - for node_id in range(ctx.node_count): - cfg = ctx.rpc.runtime_config(node_id) - if cfg is None: - raise AssertionError(f"runtime_config RPC failed on node {node_id}") - log( - f"runtime_config n{node_id}: " - f"{json.dumps(cfg, sort_keys=True, separators=(',', ':'))}" - ) - - -async def _submit_direct_export(ctx, log, *, timeout): - await ctx.fund_accounts({"alice": 10000, "bob": 1000}) - alice = ctx.account("alice") - bob = ctx.account("bob") - current_seq = ctx.validated_ledger_index(0) - - if current_seq is None: - raise AssertionError("validated ledger is not available before Export") - - log(f"Submitting direct Export at validated ledger {current_seq}") - started = ctx.mark("latency-export-submit-start") - result = await submit_direct_export( - ctx, - log, - { - "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 + 10, - "Flags": 2147483648, - "SigningPubKey": "", - }, - }, - alice.wallet, - timeout=timeout, - ) - engine_result = result.get("engine_result", "") - if engine_result != "tesSUCCESS": - raise AssertionError(f"Expected Export tesSUCCESS, got {engine_result}") - - origin_hash = result.get("hash") - origin_seq = result.get("ledger_index", ctx.validated_ledger_index(0)) - if not origin_hash: - raise AssertionError(f"Validated Export missing hash: {result}") - witness = await wait_for_export_signature_witness( - ctx, log, origin_hash, after_ledger=origin_seq - ) - ended = ctx.mark("latency-export-submit-end") - elapsed = (ended.monotonic_ns - started.monotonic_ns) / 1_000_000_000 - log(f"Export intent+witness result={engine_result} elapsed={elapsed:.3f}s") - - signers = witness.get("_WitnessSigners", []) - log(f"Export signer count={len(signers)}") - return started, ended - - -def _summarize_logs(ctx, log, *, label, started, ended): - patterns = { - "rng_selected": r"RNG: entropy selected", - "rng_fallback": r"tier=1", - "rng_participant_aligned": r"tier=2", - "rng_validator_quorum": r"tier=3", - "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) - log(f"log_count {label}.{name}={result.count}") - - -async def scenario( - ctx, - log, - *, - warmup_ledgers=3, - ledgers=8, - submit_export=False, - export_timeout=90, -): - await ctx.wait_for_ledger_close(timeout=120) - await _require_runtime_config(ctx, log) - _log_runtime_config(ctx, log) - await _require_consensus_entropy(ctx, log) - - if submit_export: - # require_export also asserts the UNLReport precondition for successful - # network-mode Export. Keep that explicit in perf runs so a missing - # report does not masquerade as a latency failure. - await require_export(ctx, log, require_runtime_config=False) - - await ctx.wait_for_ledgers(warmup_ledgers, node_id=0, timeout=120) - warm_seq = ctx.validated_ledger_index(0) - log(f"Warmup complete at validated ledger {warm_seq}") - - export_window = None - if submit_export: - export_window = await _submit_direct_export(ctx, log, timeout=export_timeout) - - started = ctx.mark("latency-probe-start") - start_seq = ctx.validated_ledger_index(0) - await ctx.wait_for_ledgers(ledgers, node_id=0, timeout=max(120, ledgers * 30)) - ended = ctx.mark("latency-probe-end") - end_seq = ctx.validated_ledger_index(0) - - if start_seq is None or end_seq is None: - raise AssertionError("validated ledger index unavailable during probe") - - elapsed = (ended.monotonic_ns - started.monotonic_ns) / 1_000_000_000 - closed = max(0, end_seq - start_seq) - cadence = elapsed / closed if closed else 0.0 - log( - f"Observed validated ledgers {start_seq + 1}..{end_seq} " - f"closed={closed} elapsed={elapsed:.3f}s cadence={cadence:.3f}s/ledger" - ) - - tiers: Counter[int] = Counter() - counts: Counter[int] = Counter() - missing_entropy = 0 - for seq in range(start_seq + 1, end_seq + 1): - try: - ce, user_txns = get_entropy_tx(ctx, seq) - except AssertionError as exc: - missing_entropy += 1 - log(f" Ledger {seq}: no ConsensusEntropy tx ({exc})") - continue - - tier = ce.get("EntropyTier", -1) - count = ce.get("EntropyCount", -1) - tiers[tier] += 1 - counts[count] += 1 - log( - f" Ledger {seq}: tier={tier} count={count} " - f"user_txns={len(user_txns)} digest={ce.get('Digest', '')[:16]}..." - ) - - log( - "SUMMARY " - f"closed={closed} elapsed_s={elapsed:.3f} cadence_s={cadence:.3f} " - f"tiers={dict(sorted(tiers.items()))} " - f"counts={dict(sorted(counts.items()))} " - f"missing_entropy={missing_entropy}" - ) - - _summarize_logs(ctx, log, label="probe", started=started, ended=ended) - if export_window is not None: - _summarize_logs( - ctx, - log, - label="export", - started=export_window[0], - ended=export_window[1], - ) - - log("PASS") diff --git a/.testnet/scenarios/rollout/mixed_binary_boundary.py b/.testnet/scenarios/rollout/mixed_binary_boundary.py deleted file mode 100644 index 35e882a8fe..0000000000 --- a/.testnet/scenarios/rollout/mixed_binary_boundary.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Mixed-binary rollout boundary for ConsensusEntropy. - -Topology (launch with per-node binaries): - n0-n2 @export-rng feature-export-rng build, supports Export + ConsensusEntropy, UNL validators - n3-n5 @release 2026.6.21 mainnet release build, NO CE support, non-UNL trackers - -What this proves: - Phase 1 CE inactive -> heterogeneous net is healthy. New validators emit - legacy 32-byte proposal positions, so old nodes parse them and track - validated ledgers. - Phase 2 CE activates -> the old @release nodes hit the upgrade boundary. Per - this branch's setAmendmentBlocked() (Change.cpp / LedgerMaster.cpp -> - NetworkOPs) they become amendment-blocked and DROP to CONNECTED, but - KEEP RUNNING (RPC stays up) -- they do NOT crash. This is the - "upgrade validators before activating" rollout invariant, observed. - -Launch (fast dev check, CE enabled at genesis): - x-testnet run -n 6 \ - --node-binary n0:@export-rng --node-binary n1:@export-rng --node-binary n2:@export-rng \ - --node-binary n3:@release --node-binary n4:@release --node-binary n5:@release \ - --feature @ConsensusEntropy \ - --scenario-script .testnet/scenarios/rollout/mixed_binary_boundary.py --teardown - -Launch (real transition, activates at the next flag ledger ~256; seed pre-satisfies the 1-min hold): - x-testnet run -n 6 \ - --seed-majority @ConsensusEntropy \ - --scenario-script .testnet/scenarios/rollout/mixed_binary_boundary.py --teardown -""" - -from helpers import CONSENSUS_ENTROPY_FEATURE - -VALIDATORS = [0, 1, 2] -OLD_NODES = [3, 4, 5] - - -def _ce_enabled(ctx, node_id=0): - s = ctx.feature_check(CONSENSUS_ENTROPY_FEATURE, node_id=node_id) - return bool(s and s.get("enabled")) - - -def _blocked(ctx, nid): - info = ctx.rpc.server_info(nid) or {} - return bool(info.get("info", {}).get("amendment_blocked")) - - -async def scenario(ctx, log): - await ctx.wait_for_ledger_close(timeout=90) - - if not _ce_enabled(ctx): - # Phase 1: with CE inactive the old @release trackers must stay in sync. - target = (await ctx.wait_for_ledgers(3, timeout=180)).result - for nid in OLD_NODES: - await ctx.wait_for_ledger(target, node_id=nid, timeout=120) - log(f"phase1 OK: old @release nodes tracked to ledger {target} (CE inactive)") - - # Trigger phase 2: vote CE up on the new validators only. - ctx.feature(CONSENSUS_ENTROPY_FEATURE, vetoed=False, nodes=VALIDATORS) - log("voted ConsensusEntropy accept on n0-n2; awaiting activation...") - else: - log("CE enabled at genesis; skipping phase 1, checking the boundary directly") - - await ctx.wait_for_feature( - CONSENSUS_ENTROPY_FEATURE, - check=lambda s: s.get("enabled"), - nodes=VALIDATORS, - timeout=1200, - ) - log("ConsensusEntropy ENABLED on validators n0-n2") - - # The upgrade boundary: old non-UNL nodes must become amendment-blocked... - await ctx.wait_for_nodes( - lambda nid: _blocked(ctx, nid), nodes=OLD_NODES, timeout=180 - ) - # ...report it in the log... - ctx.assert_log("server blocked", nodes=OLD_NODES) - # ...and still be alive (RPC responsive) -> blocked, not crashed. - for nid in OLD_NODES: - assert ctx.rpc.server_info(nid), f"n{nid} RPC unreachable (crashed?)" - - log( - "PASS: n3-n5 (@release) amendment-blocked and STILL RUNNING -- upgrade boundary confirmed" - ) diff --git a/.testnet/scenarios/rollout/rolling_upgrade.py b/.testnet/scenarios/rollout/rolling_upgrade.py deleted file mode 100644 index 4f8be9061b..0000000000 --- a/.testnet/scenarios/rollout/rolling_upgrade.py +++ /dev/null @@ -1,369 +0,0 @@ -"""Realistic rolling binary upgrade, then ConsensusEntropy activation. - -Topology (ALL start on @release, a build with NO CE support): - n0-n4 5 UNL validators (quorum 4) - n5 tracker (non-UNL) -> WILL be upgraded; keeps working after activation - n6 tracker (non-UNL) -> NOT upgraded (the straggler); becomes - amendment-blocked, then protocol-isolated - -TOPOLOGY-AWARE RESTARTS: the suite starts without fixed peers and this scenario -forms a directed ring over 127.0.0.1. A seven-node ring remains connected while -one node restarts and avoids macOS loopback-alias setup. After each restart we -restore the affected links and require every node to see both ring neighbours -before touching the next node. - -Why --seed-majority is used (NOT a magic vote): the validators still VOTE the -amendment up (ctx.feature below). Seeding only pre-writes the sfMajorities record -with CloseTime=0 so the hold is already satisfied, collapsing activation to ONE -flag ledger. If the validators don't vote yes it is cleared (tfLostMajority). See -prepare_genesis_file() in testnet/config.py. - -Arc: all-old healthy + mesh formed -> rolling-upgrade 5 validators + 1 tracker -(mesh re-forms between each) -> vote CE -> activation at the flag ledger -> -n5 (upgraded) keeps tracking, while every upgraded peer drops/rejects n6. - -Run: x-testnet --rippled-path @release suite \\ - .testnet/scenarios/rollout/rollout-suite.yml --stop-on-fail - -The upgrade target is the immutable saved-binary alias `@export-rng-gate`. -""" - -import asyncio - -from helpers import CONSENSUS_ENTROPY_FEATURE - -VALIDATORS = [0, 1, 2, 3, 4] -UPGRADED_TRACKER = 5 -STRAGGLER_TRACKER = 6 -ALL_NODES = VALIDATORS + [UPGRADED_TRACKER, STRAGGLER_TRACKER] -# Every node has its predecessor and successor in the ring. -MESH_MIN = 2 -PROTOCOL = "XRPL/2.2" -CONSENSUS_ENTROPY_CAPABILITY = "xahau-consensus-entropy" -UPGRADE_BINARY = "@export-rng-gate" -RING_EDGES = {(nid, (nid + 1) % len(ALL_NODES)) for nid in ALL_NODES} - - -def _info(ctx, nid): - return (ctx.rpc.server_info(nid) or {}).get("info", {}) - - -def _blocked(ctx, nid): - return bool(_info(ctx, nid).get("amendment_blocked")) - - -def _peers(ctx, nid): - return int(_info(ctx, nid).get("peers") or 0) - - -def _peer_public_keys(ctx, nid): - result = ctx.rpc.peers(nid) or [] - return {peer.get("public_key") for peer in result if peer.get("public_key")} - - -def _assert_capability_matrix(ctx, log, *, upgraded, phase): - """Assert per-edge handshake results from upgraded nodes. - - Capable peers negotiate the CE token without changing the overlay protocol. - Old peers remain connected on XRPL/2.2 but cannot echo the token. - """ - upgraded = set(upgraded) - old = set(ALL_NODES) - upgraded - key_to_node = {} - for nid in ALL_NODES: - public_key = _info(ctx, nid).get("pubkey_node") - assert public_key, f"n{nid} server_info missing pubkey_node" - key_to_node[public_key] = nid - - capable_edges = [] - legacy_edges = [] - for source in sorted(upgraded): - peers = ctx.rpc.peers(source) - assert peers is not None, f"n{source} peers RPC failed" - for peer in peers: - target = key_to_node.get(peer.get("public_key")) - if target is None: - continue - protocol = peer.get("protocol") - assert protocol == PROTOCOL, ( - f"{phase}: n{source}->n{target} negotiated {protocol!r}, " - f"expected {PROTOCOL}" - ) - capabilities = peer.get("capabilities") or {} - negotiated = bool(capabilities.get(CONSENSUS_ENTROPY_CAPABILITY)) - expected = target in upgraded - assert negotiated == expected, ( - f"{phase}: n{source}->n{target} capability " - f"{CONSENSUS_ENTROPY_CAPABILITY}={negotiated}, expected {expected}; " - f"peer={peer}" - ) - (capable_edges if negotiated else legacy_edges).append( - f"n{source}->n{target}" - ) - - if len(upgraded) > 1: - assert capable_edges, f"{phase}: no upgraded-upgraded edge observed" - if old: - assert legacy_edges, f"{phase}: no upgraded-old edge observed" - log( - f"{phase}: {PROTOCOL} throughout; CE capability on " - f"{len(capable_edges)} upgraded-upgraded directed edges and off on " - f"{len(legacy_edges)} upgraded-old directed edges" - ) - - -def _straggler_is_isolated(ctx): - """The old process is alive, but no upgraded node has an active session to it.""" - old_key = _info(ctx, STRAGGLER_TRACKER).get("pubkey_node") - if not old_key or _peers(ctx, STRAGGLER_TRACKER) != 0: - return False - return all(old_key not in _peer_public_keys(ctx, nid) for nid in ALL_NODES[:-1]) - - -async def _wait_for_stable_straggler_isolation(ctx, log, *, timeout=180): - await ctx.wait_for( - lambda: _straggler_is_isolated(ctx), - timeout=timeout, - poll_interval=1, - name="legacy-straggler-isolated", - ) - # Make the assertion survive PeerFinder's immediate reconnect cycle rather - # than observing only the instant between two attempts. - for _ in range(5): - await asyncio.sleep(1) - assert _straggler_is_isolated(ctx), ( - f"n{STRAGGLER_TRACKER} regained an incompatible active session" - ) - log( - f"n{STRAGGLER_TRACKER} remains RPC-alive but protocol-isolated: " - f"peers={_peers(ctx, STRAGGLER_TRACKER)}" - ) - - -async def _wait_mesh(ctx, log, *, timeout=180): - """Wait until every node sees both ring neighbours.""" - await ctx.wait_for_nodes( - lambda x: _peers(ctx, x) >= MESH_MIN, nodes=ALL_NODES, timeout=timeout - ) - peers = {nid: _peers(ctx, nid) for nid in ALL_NODES} - log(f"peer mesh healthy (>= {MESH_MIN} peers each): {peers}") - - -async def _restore_ring(ctx, log, *, timeout=90): - """Restore missing ring links using localhost plus each node's peer port.""" - await ctx.wait_for_nodes( - lambda nid: ctx.rpc.server_info(nid) is not None, - nodes=ALL_NODES, - timeout=min(timeout, 30), - ) - current = ctx.topology_snapshot(nodes=ALL_NODES).outbound_edges - missing = RING_EDGES - current - for source, target in sorted(missing): - target_node = ctx._node_info(target) - result = ctx.rpc.connect(source, "127.0.0.1", target_node.port_peer) - assert result and result.get("status") == "success", ( - f"failed to connect ring edge n{source}->n{target}: {result}" - ) - await ctx.wait_for_topology( - RING_EDGES, - nodes=ALL_NODES, - exact=False, - timeout=timeout, - stable_for=1, - ) - log(f"ring topology restored ({len(RING_EDGES)} directed edges)") - - -def _ledger_transactions(result): - """Return the transactions array from a ledger RPC result.""" - if not result: - return [] - ledger = result.get("ledger") or {} - return ledger.get("transactions") or result.get("transactions") or [] - - -def _tx_types(result): - """Return TransactionType (or 'hash') for each tx in a ledger RPC result.""" - types = [] - for tx in _ledger_transactions(result): - if isinstance(tx, dict): - types.append(str(tx.get("TransactionType", ""))) - else: - types.append("hash") - return types - - -def _rpc_summary(result): - """Return a compact, log-safe summary of an RPC result.""" - if result is None: - return "no response" - if result.get("error"): - return f"error={result.get('error')} message={result.get('error_message')}" - - ledger = result.get("ledger") or {} - transactions = _ledger_transactions(result) - tx_types = _tx_types(result) - state = result.get("state") or [] - entry_types = sorted( - { - str(entry.get("LedgerEntryType", "")) - for entry in state - if isinstance(entry, dict) - } - ) - details = ["success"] - if ledger: - details.append(f"ledger={ledger.get('ledger_index')}") - details.append(f"txs={len(transactions)} types={tx_types}") - if state: - details.append(f"state={len(state)} types={entry_types}") - if result.get("account_data"): - details.append(f"account_seq={result['account_data'].get('Sequence')}") - if result.get("marker"): - details.append("marker=yes") - if not ledger and not transactions and not state and not result.get("account_data"): - details.append(f"keys={sorted(result)}") - return "; ".join(details) - - -async def _wait_for_entropy_ledger(ctx, log, *, node_id, timeout=180): - """Wait until node_id's closed ledger expands with a ConsensusEntropy tx.""" - deadline = asyncio.get_event_loop().time() + timeout - last = None - while asyncio.get_event_loop().time() < deadline: - info = _info(ctx, node_id) - seq = info.get("validated_ledger", {}).get("seq") or info.get("ledger_index") - if seq and seq != last: - last = seq - result = ctx.rpc.request( - node_id, - "ledger", - { - "ledger_index": seq, - "transactions": True, - "expand": True, - }, - ) - types = _tx_types(result) - log( - f"n{node_id} closed/validated ledger {seq} expanded: " - f"{_rpc_summary(result)}" - ) - if "ConsensusEntropy" in types: - return seq, types - await asyncio.sleep(1) - raise TimeoutError( - f"n{node_id} did not close a ledger containing ConsensusEntropy " - f"within {timeout}s (last={last})" - ) - - -async def scenario(ctx, log): - # 1. all-old net healthy AND the ring has formed before we touch it - await _restore_ring(ctx, log) - await ctx.wait_for_ledger_close(timeout=90) - base = (await ctx.wait_for_ledgers(2, timeout=120)).result - for nid in ALL_NODES: - await ctx.wait_for_ledger(base, node_id=nid, timeout=120) - await _wait_mesh(ctx, log, timeout=180) - log(f"all-old net healthy at ledger {base} (CE inactive)") - - # 2. rolling-upgrade validators (then one tracker), one at a time. After each - # restart, restore and settle the ring BEFORE rolling the next, so we never - # leave a broken path in place while taking out another node. - upgraded = set() - for nid in [*VALIDATORS, UPGRADED_TRACKER]: - ref = next(v for v in VALIDATORS if v != nid) - role = "validator" if nid in VALIDATORS else "tracker" - log(f"rolling upgrade: n{nid} ({role}) -> {UPGRADE_BINARY}") - await ctx.restart_node_with_binary(nid, UPGRADE_BINARY, delay=3) - upgraded.add(nid) - await _restore_ring(ctx, log) - await _wait_mesh(ctx, log, timeout=180) # mesh re-formed before next roll - target = (await ctx.wait_for_ledgers(2, node_id=ref, timeout=180)).result - await ctx.wait_for_ledger(target, node_id=nid, timeout=180) - _assert_capability_matrix( - ctx, - log, - upgraded=upgraded, - phase=f"after upgrading n{nid}", - ) - log(f"n{nid} rejoined; mesh re-formed; quorum advanced to {target}") - log( - f"validators + n{UPGRADED_TRACKER} on {UPGRADE_BINARY}; " - f"n{STRAGGLER_TRACKER} left on @release; CE still inactive" - ) - - # 3. vote CE up on the validators (real vote; the seed only pre-satisfied the hold) - gate_start = ctx.mark("ce-protocol-gate") - ctx.feature(CONSENSUS_ENTROPY_FEATURE, vetoed=False, nodes=VALIDATORS) - log("voted ConsensusEntropy accept on n0-n4; crossing the flag ledger...") - - # 4. activation at the flag ledger - await ctx.wait_for_feature( - CONSENSUS_ENTROPY_FEATURE, - check=lambda s: s.get("enabled"), - nodes=VALIDATORS, - timeout=900, - ) - log("ConsensusEntropy ENABLED on the upgraded quorum") - - # 5. The amendment-blocked behavior remains visible on the old process, but - # the upgraded overlay now makes the incompatibility fail fast. - await ctx.wait_for_nodes( - lambda x: _blocked(ctx, x), nodes=[STRAGGLER_TRACKER], timeout=180 - ) - ctx.assert_log("server blocked", nodes=[STRAGGLER_TRACKER]) - for nid in sorted(upgraded): - ctx.assert_log( - r"Peer protocol feature now required: xahau-consensus-entropy", - since=gate_start, - nodes=[nid], - ) - for nid in (VALIDATORS[0], UPGRADED_TRACKER): - ctx.assert_log( - r"Missing required protocol feature xahau-consensus-entropy", - since=gate_start, - nodes=[nid], - ) - await _wait_for_stable_straggler_isolation(ctx, log) - - # Exercise both admission directions after the initial eviction. The RPC - # only schedules a connection attempt; the invariant is that neither - # attempt becomes an active peer session. - old_node = ctx._node_info(STRAGGLER_TRACKER) - new_node = ctx._node_info(VALIDATORS[0]) - new_to_old = ctx.rpc.connect(UPGRADED_TRACKER, "127.0.0.1", old_node.port_peer) - old_to_new = ctx.rpc.connect(STRAGGLER_TRACKER, "127.0.0.1", new_node.port_peer) - log( - "forced reconnect attempts in both directions: " - f"new->old={new_to_old}; old->new={old_to_new}" - ) - await _wait_for_stable_straggler_isolation(ctx, log, timeout=60) - - assert not _blocked(ctx, UPGRADED_TRACKER), ( - f"n{UPGRADED_TRACKER} (upgraded tracker) should NOT be amendment-blocked" - ) - probe_ledger, ce_types = await _wait_for_entropy_ledger( - ctx, log, node_id=UPGRADED_TRACKER, timeout=180 - ) - log( - f"n{UPGRADED_TRACKER} has ConsensusEntropy at ledger {probe_ledger}: " - f"types={ce_types}" - ) - - target = (await ctx.wait_for_ledgers(2, node_id=VALIDATORS[0], timeout=180)).result - for nid in sorted(upgraded): - await ctx.wait_for_ledger(target, node_id=nid, timeout=180) - log(f"capable six-node component continued through ledger {target}") - - snapshot = ctx._network.snapshot("ce-required-protocol-gate", keep_db=False) - log(f"captured protocol-gate and reconnect evidence at {snapshot}") - for nid in (UPGRADED_TRACKER, STRAGGLER_TRACKER): - assert ctx.rpc.server_info(nid), f"n{nid} RPC down (crashed?)" - log( - f"PASS: rolling upgrade preserved quorum 4 (mesh-gated); CE activated; " - f"n{UPGRADED_TRACKER} (upgraded tracker) still tracking; " - f"n{STRAGGLER_TRACKER} (@release straggler) amendment-blocked, still " - "running for RPC observation, and rejected from the peer protocol" - ) diff --git a/.testnet/scenarios/rollout/rollout-suite.yml b/.testnet/scenarios/rollout/rollout-suite.yml deleted file mode 100644 index 523f918014..0000000000 --- a/.testnet/scenarios/rollout/rollout-suite.yml +++ /dev/null @@ -1,43 +0,0 @@ -# Rolling binary upgrade -> ConsensusEntropy activation. -# -# All nodes start on @release (set via `--rippled-path @release` on the CLI); the -# scenario rolling-restarts them to the immutable @export-rng-gate snapshot, -# then casts a REAL vote. The -# --seed-majority (majority_features) only pre-satisfies the amendment hold time -# so activation lands at ONE flag ledger instead of two -- it is NOT a vote, and -# is cleared (tfLostMajority) if the validators don't vote yes before the flag -# ledger. start_ledger gives runway for the 6 restarts + vote to finish before -# the first flag ledger (256), so the seed survives to a real vote. -# -# Run: -# x-testnet --rippled-path @release suite \ -# .testnet/scenarios/rollout/rollout-suite.yml --stop-on-fail -# -# Suite runs auto-snapshot to .testnet/output/runs/ on failure (survives -# teardown), so `x-testnet logs-search --run latest/rolling_upgrade_ce ...` works. - -defaults: - network: - node_count: 7 # n0-n4 validators, n5/n6 non-UNL trackers - validators: 5 - quorum: 4 # 80%: a restart leaves exactly quorum on the peers up - fixed_peers: false # scenario forms a restart-safe ring on 127.0.0.1 - find_ports: true - start_ledger: 210 # measured: the mesh-gated upgrade+vote is ~23 ledgers, so - # the vote lands ~233 with a ~23-ledger margin before flag - # ledger 256 (activation). Miss -> vote after 256 -> next - # flag is 512 (~13 min) or tfLostMajority clears the seed. - majority_features: - - ConsensusEntropy # pre-satisfy the hold ONLY (still needs a real vote) - track_features: - - ConsensusEntropy - log_levels: - LedgerConsensus: debug - NetworkOPs: info - # The scenario connects a seven-node ring through each node's distinct peer - # port. This remains connected while one node rolls and works on macOS without - # privileged 127.0.0.2+ aliases. - -tests: - - name: rolling_upgrade_ce - script: .testnet/scenarios/rollout/rolling_upgrade.py diff --git a/.testnet/scenarios/suite.yml b/.testnet/scenarios/suite.yml deleted file mode 100644 index 44e499a468..0000000000 --- a/.testnet/scenarios/suite.yml +++ /dev/null @@ -1,63 +0,0 @@ -defaults: - network: - node_count: 5 - launcher: tmux - find_ports: true - slave_delay: 0.2 - features: - - ConsensusEntropy - track_features: - - ConsensusEntropy - unl_report: true - log_levels: - TxQ: info - Protocol: debug - Peer: debug - LedgerConsensus: debug - ConsensusExtensions: debug - NetworkOPs: info - rc: - - rng_poll_ms=333 - -tests: - - name: steady_state_entropy - script: .testnet/scenarios/entropy/steady_state_entropy.py - - - name: fallback_without_unl_report - script: .testnet/scenarios/entropy/fallback_without_unl_report.py - network: - unl_report: false - - - name: steady_state_entropy_fast_start - script: .testnet/scenarios/entropy/steady_state_entropy.py - network: - env: - XAHAUD_RUNTIME_TEST_CONFIG: '{"set":{"global":{"rng_poll_ms":333,"bootstrap_fast_start":true}}}' - - - name: entropy_with_transactions - script: .testnet/scenarios/entropy/entropy_with_transactions.py - - - name: hook_entropy_api - script: .testnet/scenarios/entropy/hook_entropy_api.py - - - name: quorum_recovery_smoke - script: .testnet/scenarios/entropy/quorum_recovery_smoke.py - - - name: quorum_degradation_smoke - script: .testnet/scenarios/entropy/quorum_degradation_smoke.py - network: - log_levels: - LedgerConsensus: trace - ConsensusExtensions: trace - - # Tier 2 (participant_aligned) needs 6 nodes: n=5 has no band (tier2 == - # quorum). At 6, the 4/6 window is the participant_aligned band. - - name: participant_aligned_smoke - script: .testnet/scenarios/entropy/participant_aligned_smoke.py - network: - node_count: 6 - log_levels: - LedgerConsensus: trace - ConsensusExtensions: trace - - # Export scenarios: see export-suite.yml diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index ec2ca70817..bbba6ea0ba 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -1233,13 +1233,6 @@ RCLConsensus::Adaptor::preStartRound( bool const proposing = validating_ && synced; - JLOG(j_.info()) << "STARTDIAG: preStartRound" - << " mode=" << app_.getOPs().strOperatingMode() - << " synced=" << (synced ? "yes" : "no") - << " validating=" << (validating_ ? "yes" : "no") - << " proposing=" << (proposing ? "yes" : "no") - << " seq=" << (prevLgr.seq() + 1); - // propose only if we're in sync with the network (and validating) return proposing; } @@ -1282,10 +1275,6 @@ RCLConsensus::Adaptor::updateOperatingMode(std::size_t const positions) const { if (!positions && app_.getOPs().isFull()) { - JLOG(j_.warn()) << "STARTDIAG: updateOperatingMode demoting" - << " from=FULL" - << " to=CONNECTED" - << " positions=" << positions; app_.getOPs().setMode(OperatingMode::CONNECTED); } } diff --git a/src/xrpld/consensus/Consensus.h b/src/xrpld/consensus/Consensus.h index 3b09bceeff..427ee8645e 100644 --- a/src/xrpld/consensus/Consensus.h +++ b/src/xrpld/consensus/Consensus.h @@ -827,13 +827,6 @@ Consensus::peerProposalInternal( if (newPeerProp.prevLedger() != prevLedgerID_) { - JLOG(j_.info()) << "STARTDIAG: peerProposal rejected" - << " reason=prevLedger-mismatch" - << " peer=" << newPeerProp.nodeID() - << " theirPrev=" << newPeerProp.prevLedger() - << " ourPrev=" << prevLedgerID_ - << " phase=" << to_string(phase_) - << " seq=" << newPeerProp.proposeSeq(); return false; } @@ -880,11 +873,6 @@ Consensus::peerProposalInternal( else { currPeerPositions_.emplace(peerID, newPeerPos); - JLOG(j_.info()) << "STARTDIAG: peerProposal accepted" - << " peer=" << peerID - << " peerPositions=" << currPeerPositions_.size() - << " seq=" << newPeerProp.proposeSeq() - << " phase=" << to_string(phase_); } } @@ -1530,13 +1518,6 @@ Consensus::phaseEstablish( { CLOG(clog) << "ledgerMIN_CONSENSUS not reached: " << parms.ledgerMIN_CONSENSUS.count() << "ms. "; - JLOG(j_.trace()) << "STALLDIAG: establish wait ledgerMIN_CONSENSUS" - << " roundMs=" << result_->roundTime.read().count() - << " minMs=" << parms.ledgerMIN_CONSENSUS.count() - << " peerPositions=" << currPeerPositions_.size() - << " prevProposers=" << prevProposers_ - << " phase=" << to_string(phase_) - << " mode=" << to_string(mode_.get()); return; } @@ -1548,26 +1529,12 @@ Consensus::phaseEstablish( // Nothing to do if too many laggards or we don't have consensus. if (paused || !txConsensus) { - JLOG(j_.info()) << "STALLDIAG: establish gate blocked" - << " reason=" << (paused ? "pause" : "no-tx-consensus") - << " roundMs=" << result_->roundTime.read().count() - << " peerPositions=" << currPeerPositions_.size() - << " prevProposers=" << prevProposers_ - << " phase=" << to_string(phase_) - << " mode=" << to_string(mode_.get()); return; } if (!haveCloseTimeConsensus_) { JLOG(j_.info()) << "We have TX consensus but not CT consensus"; - JLOG(j_.info()) << "STALLDIAG: establish gate blocked" - << " reason=no-close-time-consensus" - << " roundMs=" << result_->roundTime.read().count() - << " peerPositions=" << currPeerPositions_.size() - << " prevProposers=" << prevProposers_ - << " phase=" << to_string(phase_) - << " mode=" << to_string(mode_.get()); CLOG(clog) << "We have TX consensus but not CT consensus. "; return; } @@ -1619,10 +1586,6 @@ Consensus::phaseEstablish( return; } - JLOG(j_.info()) << "STARTDIAG: converge cutoff" - << " peerPositions=" << currPeerPositions_.size() - << " roundMs=" << result_->roundTime.read().count() - << " mode=" << to_string(mode_.get()); JLOG(j_.info()) << "Converge cutoff (" << currPeerPositions_.size() << " participants)"; CLOG(clog) << "Converge cutoff (" << currPeerPositions_.size() @@ -2005,19 +1968,6 @@ Consensus::haveConsensus( auto currentFinished = adaptor_.proposersFinished(previousLedger_, prevLedgerID_); - JLOG(j_.info()) << "STARTDIAG: haveConsensus" - << " agree=" << agree << " disagree=" << disagree - << " total=" << (agree + disagree) - << " peerPositions=" << currPeerPositions_.size() - << " prevProposers=" << prevProposers_ - << " roundMs=" << result_->roundTime.read().count() - << " mode=" << to_string(mode_.get()); - JLOG(j_.trace()) << "STALLDIAG: haveConsensus-self" - << " position=" << ourPosition << " closeTime=" - << result_->position.closeTime().time_since_epoch().count() - << " haveCloseTimeConsensus=" - << (haveCloseTimeConsensus_ ? "yes" : "no") - << " phase=" << to_string(phase_); if constexpr (requires(Adaptor& a) { a.ce(); }) adaptor_.ce().logPosition(ourPosition, j_); @@ -2083,15 +2033,6 @@ Consensus::haveConsensus( if (result_->state == ConsensusState::No) { - JLOG(j_.debug()) << "STALLDIAG: haveConsensus-result" - << " state=No" - << " agree=" << agree << " disagree=" << disagree - << " total=" << (agree + disagree) - << " finished=" << currentFinished - << " peerPositions=" << currPeerPositions_.size() - << " prevProposers=" << prevProposers_ - << " roundMs=" << result_->roundTime.read().count() - << " mode=" << to_string(mode_.get()); CLOG(clog) << "No consensus. "; return false; } @@ -2127,15 +2068,6 @@ Consensus::haveConsensus( // without us. if (result_->state == ConsensusState::MovedOn) { - JLOG(j_.warn()) << "STALLDIAG: haveConsensus-result" - << " state=MovedOn" - << " agree=" << agree << " disagree=" << disagree - << " total=" << (agree + disagree) - << " finished=" << currentFinished - << " peerPositions=" << currPeerPositions_.size() - << " prevProposers=" << prevProposers_ - << " roundMs=" << result_->roundTime.read().count() - << " mode=" << to_string(mode_.get()); JLOG(j_.error()) << "Unable to reach consensus"; JLOG(j_.error()) << Json::Compact{getJson(true)}; CLOG(clog) << "Unable to reach consensus " diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index 6c467d0399..35c8d7949a 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -184,6 +184,9 @@ rngTick(Ext& ext, Ctx const& ctx, Propose const& requestProposal) return "Unknown"; }; auto logRngDiag = [&](char const* reason) { + if (!ext.j_.debug()) + return; + auto const ourPos = ctx.getPosition(); auto const participants = ctx.peerPositions.size() + 1; JLOG(ext.j_.debug())