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 <bot@xahau.org>
This commit is contained in:
Richard Holland
2026-09-23 11:45:32 +10:00
committed by GitHub
parent 64974242d2
commit bc5b90282a
31 changed files with 3 additions and 3489 deletions

View File

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

7
.gitignore vendored
View File

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

4
.testnet/.gitignore vendored
View File

@@ -1,4 +0,0 @@
output/
__pycache__/
scenarios/odd-cases/
scenarios/suite-experiments.yml

View File

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

View File

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

View File

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

View File

@@ -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 <stdint.h>
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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <stdint.h>
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"
)

View File

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

View File

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

View File

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

View File

@@ -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 <same --node-binary set> \
--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"
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -827,13 +827,6 @@ Consensus<Adaptor>::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<Adaptor>::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<Adaptor>::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<Adaptor>::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<Adaptor>::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<Adaptor>::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<Adaptor>::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<Adaptor>::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 "

View File

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