mirror of
https://github.com/Xahau/xahaud.git
synced 2026-10-04 02:48:00 +00:00
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:
@@ -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
7
.gitignore
vendored
@@ -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
4
.testnet/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
output/
|
||||
__pycache__/
|
||||
scenarios/odd-cases/
|
||||
scenarios/suite-experiments.yml
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user