From 9fee5966e9d60cc78c1cc0bfaadeaf4d19a3320c Mon Sep 17 00:00:00 2001 From: TateB Date: Wed, 22 Jul 2026 09:06:49 +0000 Subject: [PATCH 1/2] test(e2e): extend catch-up equivalence corpus Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C9RTnbCyhqEUSPc7xCK76p --- tests/e2e/README.md | 26 +- tests/e2e/src/harness/perturb.rs | 6 +- .../e2e/src/scenarios/catchup_equivalence.rs | 442 ++++++++++++++++-- 3 files changed, 431 insertions(+), 43 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index fbbfbd1b..b51b738a 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -589,15 +589,23 @@ route inventory or a claim that every protocol transition is covered. catch-up. Route snapshots and all normalized-event rows are exact. The full equality arm also requires the receipt-reconstructed finalized registrar `PreimageObserved` for `catchupeq.eth` to exist identically in both corpora, - so equality cannot pass if both paths omit the event. The result is certified - for the current corpus only: one finalized ENSv1 `.eth` registration with - addr/text records and a registry-only child. Wrapper events, reverse/primary - claims, renewals, expiry/grace, ENSv2, Basenames/multi-chain, non-finalized - spans, and reorged history are not yet exercised; follow-up issue #202 tracks - the corpus extension. The ENSv2-specific cases in #161 are not exercised - here. The shared baseline span contributes no detection power for catch-up - omissions because both corpora's baseline is live-derived; the catch-up - re-replay is identity-idempotent via `ON CONFLICT DO NOTHING`. + so equality cannot pass if both paths omit the event. +- `catchup_equivalence::automatic_catchup_matches_live_wrapper_reverse_outputs` + — compares two finalized ENSv1 registrations across the same live-versus- + catch-up boundary. One name is wrapped and burns owner-controlled fuses; the + other is wrapped, unwrapped back to registrar authority, and then used in a + reverse/primary-name claim. Full route snapshots and normalized-event rows + must match, including stable chain/address provenance for contract instances. + The full equality arm also requires receipt-reconstructed registrar + `PreimageObserved` rows for both names, so equal omission cannot pass. + Together, the catch-up scenarios cover finalized ENSv1 registration, + resolver records, registry children, wrapper transitions, fuse changes, + unwraps, and reverse/primary-name claims. Renewals, expiry/grace, ENSv2, + Basenames/multi-chain, non-finalized spans, and reorged history remain outside + this corpus; the ENSv2-specific cases in #161 are also not exercised. The + shared baseline span contributes no detection power for catch-up omissions + because both corpora's baseline is live-derived; the catch-up re-replay is + identity-idempotent via `ON CONFLICT DO NOTHING`. - `cross_protocol::composed_mainnet_profile_serves_both_protocols_without_leakage` — ingests a generated structural mirror of the currently checked-in mainnet families (ENSv1 ethereum + Basenames base + the L1 Basenames diff --git a/tests/e2e/src/harness/perturb.rs b/tests/e2e/src/harness/perturb.rs index 3eb9222b..547c4bbe 100644 --- a/tests/e2e/src/harness/perturb.rs +++ b/tests/e2e/src/harness/perturb.rs @@ -232,7 +232,7 @@ async fn normalized_event_rows( // chain/address identity; every other normalized-event field remains in // the comparison, including resource ids, manifest ids, raw-fact refs, // positions, before-state, and after-state. - let contract_instances = contract_instance_keys(pool).await?; + let contract_instances = contract_instance_stable_keys(pool).await?; let mut normalized = Vec::with_capacity(rows.len()); for row in rows { let mut row: Value = serde_json::from_str(&row)?; @@ -243,7 +243,9 @@ async fn normalized_event_rows( Ok(normalized) } -async fn contract_instance_keys(pool: &sqlx::PgPool) -> Result> { +pub async fn contract_instance_stable_keys( + pool: &sqlx::PgPool, +) -> Result> { let rows: Vec<(String, String)> = sqlx::query_as( "SELECT DISTINCT ON (contract_instance_id) \ contract_instance_id::TEXT, \ diff --git a/tests/e2e/src/scenarios/catchup_equivalence.rs b/tests/e2e/src/scenarios/catchup_equivalence.rs index bbd871aa..93ac62f0 100644 --- a/tests/e2e/src/scenarios/catchup_equivalence.rs +++ b/tests/e2e/src/scenarios/catchup_equivalence.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use alloy_primitives::{Address, keccak256}; use anyhow::{Result, anyhow, ensure}; use serde_json::{Value, json}; @@ -12,8 +14,13 @@ const DEPLOYMENT_PROFILE: &str = "e2e"; const NAME: &str = "catchupeq.eth"; const LABEL: &str = "catchupeq"; const SUB_LABEL: &str = "sub"; +const WRAPPED_NAME: &str = "catchupeqwrapped.eth"; +const WRAPPED_LABEL: &str = "catchupeqwrapped"; +const RESTORED_NAME: &str = "catchupeqrestored.eth"; +const RESTORED_LABEL: &str = "catchupeqrestored"; const TEXT_KEY: &str = "com.twitter"; const YEAR: u64 = 365 * 24 * 60 * 60; +const FIXTURE_FUSE: u16 = 1 | 4; // Anvil's finalized anchor trails the head by 64 blocks, so 66 is load-bearing: // fixture events must finalize for `live_ready_sql`, and the post-handoff cold-start // safe/finalized anchor must land above the last fixture event so live adapter sync @@ -45,7 +52,7 @@ struct PreparedCorpus { struct CatchupFixture { last_event_block: u64, - expected_preimage: perturb::StatelessLabelPreimage, + expected_preimages: Vec, } impl CatchupChain { @@ -59,6 +66,17 @@ impl CatchupChain { ], ) } + + fn wrapper_reverse_subjects(&self) -> perturb::RouteSnapshotSubjects { + perturb::RouteSnapshotSubjects::new( + [WRAPPED_NAME, RESTORED_NAME], + [ + format!("{:#x}", self.owner), + format!("{:#x}", self.record_target), + format!("{:#x}", self.child_owner), + ], + ) + } } fn rpc_string<'a>(value: &'a Value, path: &str) -> Result<&'a str> { @@ -80,6 +98,8 @@ async fn expected_preimage_from_registration( rpc: &RpcClient, chain: &CatchupChain, registration: &ens_v1::RegisteredName, + name: &str, + label: &str, ) -> Result { let receipt = rpc .call( @@ -135,7 +155,7 @@ async fn expected_preimage_from_registration( .strip_prefix("0x") .unwrap_or(rpc_string(event_log, "/data")?) .to_ascii_lowercase(); - let dns_encoded_name = format!("{:#x}", ens_v1::dns_encode_name(NAME)?); + let dns_encoded_name = format!("{:#x}", ens_v1::dns_encode_name(name)?); let event_identity = format!( "raw_log_preimage_observed:{REGISTRAR_SOURCE_MANIFEST_ID}:{block_hash}:{transaction_hash}:{log_index}:{emitter}" ); @@ -173,12 +193,12 @@ async fn expected_preimage_from_registration( "after_state": { "source_event": "NameRegistered", "dns_encoded_name": dns_encoded_name, - "decoded_name": NAME, + "decoded_name": name, "labelhashes": [ - format!("{:#x}", ens_v1::labelhash(LABEL)), + format!("{:#x}", ens_v1::labelhash(label)), format!("{:#x}", ens_v1::labelhash("eth")), ], - "namehash": format!("{:#x}", ens_v1::namehash(NAME)), + "namehash": format!("{:#x}", ens_v1::namehash(name)), }, })) } @@ -194,7 +214,8 @@ async fn add_rich_name_fixture(anvil: &Anvil, chain: &CatchupChain) -> Result Result Result { + let rpc = anvil.client(); + let wrapped_registration = ens_v1::register_eth_name( + &rpc, + &chain.deployment, + WRAPPED_LABEL, + chain.owner, + YEAR, + chain.resolver, + ) + .await?; + let wrapped_preimage = expected_preimage_from_registration( + &rpc, + chain, + &wrapped_registration, + WRAPPED_NAME, + WRAPPED_LABEL, + ) + .await?; + ens_v1::wrap_eth_2ld( + &rpc, + &chain.deployment, + chain.owner, + WRAPPED_LABEL, + chain.record_target, + 0, + chain.resolver, + ) + .await?; + ens_v1::set_wrapper_fuses( + &rpc, + &chain.deployment, + chain.record_target, + WRAPPED_NAME, + FIXTURE_FUSE, + ) + .await?; + + let restored_registration = ens_v1::register_eth_name( + &rpc, + &chain.deployment, + RESTORED_LABEL, + chain.owner, + YEAR, + chain.resolver, + ) + .await?; + let restored_preimage = expected_preimage_from_registration( + &rpc, + chain, + &restored_registration, + RESTORED_NAME, + RESTORED_LABEL, + ) + .await?; + ens_v1::wrap_eth_2ld( + &rpc, + &chain.deployment, + chain.owner, + RESTORED_LABEL, + chain.record_target, + 0, + chain.resolver, + ) + .await?; + ens_v1::unwrap_eth_2ld( + &rpc, + &chain.deployment, + chain.record_target, + RESTORED_LABEL, + chain.child_owner, + chain.child_owner, + ) + .await?; + ens_v1::set_reverse_name(&rpc, &chain.deployment, chain.child_owner, RESTORED_NAME).await?; + + let last_event_block = rpc.block_number().await?; + rpc.mine(FINALITY_MARGIN_BLOCKS).await?; + Ok(CatchupFixture { + last_event_block, + expected_preimages: vec![wrapped_preimage, restored_preimage], }) } +fn normalize_primary_route_contract_instance_ids( + value: &mut Value, + contract_instances: &BTreeMap, +) -> Result<()> { + match value { + Value::Array(values) => { + for value in values { + normalize_primary_route_contract_instance_ids(value, contract_instances)?; + } + } + Value::Object(fields) => { + for (key, value) in fields { + if key == "contract_instance_id" && !value.is_null() { + let id = value.as_str().ok_or_else(|| { + anyhow!("primary-name contract_instance_id is not a string: {value}") + })?; + let stable_key = contract_instances.get(id).ok_or_else(|| { + anyhow!("primary-name route references unknown contract instance {id}") + })?; + *value = Value::String(format!("")); + } else { + normalize_primary_route_contract_instance_ids(value, contract_instances)?; + } + } + } + _ => {} + } + Ok(()) +} + +#[test] +fn primary_route_normalization_preserves_contract_instance_identity() { + let live_id = "00000000-0000-0000-0000-000000000001"; + let catchup_id = "00000000-0000-0000-0000-000000000002"; + let mut live = json!({ + "claimed_primary_name": { + "source": {"contract_instance_id": live_id} + } + }); + let mut catchup = json!({ + "claimed_primary_name": { + "source": {"contract_instance_id": catchup_id} + } + }); + let live_instances = BTreeMap::from([( + live_id.to_owned(), + "ethereum-mainnet:0x0000000000000000000000000000000000000001".to_owned(), + )]); + let catchup_instances = BTreeMap::from([( + catchup_id.to_owned(), + "ethereum-mainnet:0x0000000000000000000000000000000000000002".to_owned(), + )]); + + normalize_primary_route_contract_instance_ids(&mut live, &live_instances).unwrap(); + normalize_primary_route_contract_instance_ids(&mut catchup, &catchup_instances).unwrap(); + + assert_ne!( + live, catchup, + "normalization must not hide a contract-instance provenance mismatch" + ); +} + +fn normalize_primary_route_snapshot( + value: &mut Value, + contract_instances: &BTreeMap, +) -> Result<()> { + normalize_primary_route_contract_instance_ids(value, contract_instances)?; + let last_updated = value + .get_mut("last_updated") + .ok_or_else(|| anyhow!("primary-name route snapshot lacks last_updated"))?; + ensure!( + last_updated.is_string(), + "primary-name route snapshot last_updated is not a string: {last_updated}" + ); + *last_updated = Value::String("".to_owned()); + Ok(()) +} + +async fn wrapper_reverse_route_snapshots( + run: &support::PipelineRun, + chain: &CatchupChain, +) -> Result { + let mut snapshots = support::route_snapshots(run, &chain.wrapper_reverse_subjects()).await?; + let wrapped_key = format!("GET /v1/names/ens/{WRAPPED_NAME}"); + let wrapped = snapshots + .get(&wrapped_key) + .ok_or_else(|| anyhow!("missing {wrapped_key} route snapshot"))?; + ensure!( + wrapped + .pointer("/declared_state/control/status") + .and_then(Value::as_str) + == Some("unsupported") + && wrapped + .pointer("/declared_state/control/unsupported_reason") + .and_then(Value::as_str) + == Some("ENSv1 wrapper effective control is not yet projected"), + "{WRAPPED_NAME} route snapshot does not expose the wrapper control boundary: {wrapped}" + ); + let restored_key = format!("GET /v1/names/ens/{RESTORED_NAME}"); + let restored = snapshots + .get(&restored_key) + .ok_or_else(|| anyhow!("missing {restored_key} route snapshot"))?; + ensure!( + restored + .pointer("/declared_state/registration/authority_kind") + .and_then(Value::as_str) + == Some("registrar"), + "{RESTORED_NAME} route snapshot is not registrar-authoritative after unwrap: {restored}" + ); + + let claimant = format!("{:#x}", chain.child_owner); + let primary_path = + format!("/v1/primary-names/{claimant}?namespace=ens&coin_type=60&mode=declared"); + let (status, mut primary) = run.api.get_json(&primary_path).await?; + ensure!( + status.is_success(), + "GET {primary_path} returned {status}: {primary}" + ); + ensure!( + primary + .pointer("/declared_state/claimed_primary_name/status") + .and_then(Value::as_str) + == Some("success") + && primary + .pointer("/declared_state/claimed_primary_name/name") + .and_then(Value::as_str) + == Some(RESTORED_NAME), + "{claimant} route snapshot does not carry the {RESTORED_NAME} primary-name claim: {primary}" + ); + let contract_instances = perturb::contract_instance_stable_keys(&run.db.pool).await?; + normalize_primary_route_snapshot(&mut primary, &contract_instances)?; + snapshots.insert(format!("GET {primary_path}"), primary); + Ok(snapshots) +} + fn derived_output_ready_expression(chain: &CatchupChain) -> String { let parent_node = format!("{:#x}", ens_v1::namehash(NAME)); let sub_labelhash = format!("{:#x}", ens_v1::labelhash(SUB_LABEL)); @@ -258,10 +501,69 @@ fn live_ready_sql(chain: &CatchupChain) -> String { ) } -fn catchup_ready_sql(chain: &CatchupChain, last_fixture_event_block: u64) -> String { +fn wrapper_reverse_derived_output_ready_expression(chain: &CatchupChain) -> String { + let resolver_profile_ready = support::resolver_code_hash_comparison_sql( + chain.resolver, + chain.deployment.public_resolver.address, + true, + ); + format!( + "EXISTS (SELECT 1 FROM normalized_events \ + WHERE logical_name_id = 'ens:{WRAPPED_NAME}' \ + AND event_kind = 'AuthorityEpochChanged' \ + AND source_family = 'ens_v1_wrapper_l1' \ + AND canonicality_state = 'finalized' \ + AND before_state->>'authority_kind' = 'registrar' \ + AND after_state->>'authority_kind' = 'wrapper') \ + AND (SELECT count(*) >= 2 FROM normalized_events \ + WHERE logical_name_id = 'ens:{WRAPPED_NAME}' \ + AND event_kind = 'PermissionScopeChanged' \ + AND source_family = 'ens_v1_wrapper_l1' \ + AND canonicality_state = 'finalized') \ + AND EXISTS (SELECT 1 FROM normalized_events \ + WHERE logical_name_id = 'ens:{WRAPPED_NAME}' \ + AND event_kind = 'PermissionScopeChanged' \ + AND source_family = 'ens_v1_wrapper_l1' \ + AND canonicality_state = 'finalized' \ + AND ((after_state->>'fuses')::BIGINT & {FIXTURE_FUSE}) = {FIXTURE_FUSE}) \ + AND EXISTS (SELECT 1 FROM normalized_events \ + WHERE logical_name_id = 'ens:{RESTORED_NAME}' \ + AND event_kind = 'AuthorityEpochChanged' \ + AND canonicality_state = 'finalized' \ + AND before_state->>'authority_kind' = 'wrapper' \ + AND after_state->>'authority_kind' = 'registrar') \ + AND EXISTS (SELECT 1 FROM normalized_events \ + WHERE event_kind = 'ReverseChanged' \ + AND source_family = 'ens_v1_reverse_l1' \ + AND canonicality_state = 'finalized' \ + AND lower(after_state->>'address') = '{claimant:#x}') \ + AND EXISTS (SELECT 1 FROM normalized_events \ + WHERE event_kind = 'RecordChanged' \ + AND canonicality_state = 'finalized' \ + AND after_state->>'raw_name' = '{RESTORED_NAME}' \ + AND lower(after_state->'primary_claim_source'->>'address') = '{claimant:#x}') \ + AND {resolver_profile_ready}", + claimant = chain.child_owner, + ) +} + +fn wrapper_reverse_live_ready_sql(chain: &CatchupChain) -> String { format!( "SELECT {} \ - AND EXISTS (SELECT 1 FROM normalized_replay_cursors \ + AND (SELECT count(DISTINCT after_state->>'decoded_name') = 2 \ + FROM normalized_events \ + WHERE event_kind = 'PreimageObserved' \ + AND source_family = 'ens_v1_registrar_l1' \ + AND derivation_kind = 'raw_log_preimage_observation' \ + AND after_state->>'decoded_name' IN ('{WRAPPED_NAME}', '{RESTORED_NAME}') \ + AND canonicality_state = 'finalized')", + wrapper_reverse_derived_output_ready_expression(chain), + ) +} + +fn catchup_cursor_ready_expression(last_fixture_event_block: u64) -> String { + format!( + "EXISTS (SELECT 1 FROM normalized_replay_cursors \ WHERE deployment_profile = '{DEPLOYMENT_PROFILE}' \ AND chain_id = '{CHAIN}' \ AND cursor_kind = 'raw_fact_normalized_events' \ @@ -270,8 +572,26 @@ fn catchup_ready_sql(chain: &CatchupChain, last_fixture_event_block: u64) -> Str AND next_block_number > target_block_number \ AND last_completed_block_number = target_block_number \ AND last_replayed_at IS NOT NULL \ - AND last_failure_reason IS NULL)", + AND last_failure_reason IS NULL)" + ) +} + +fn catchup_ready_sql(chain: &CatchupChain, last_fixture_event_block: u64) -> String { + format!( + "SELECT {} AND {}", derived_output_ready_expression(chain), + catchup_cursor_ready_expression(last_fixture_event_block), + ) +} + +fn wrapper_reverse_catchup_ready_sql( + chain: &CatchupChain, + last_fixture_event_block: u64, +) -> String { + format!( + "SELECT {} AND {}", + wrapper_reverse_derived_output_ready_expression(chain), + catchup_cursor_ready_expression(last_fixture_event_block), ) } @@ -320,8 +640,9 @@ async fn prepare_baseline( async fn live_ingest( anvil: &Anvil, - chain: &CatchupChain, prepared: PreparedCorpus, + ready_sql: &str, + log_suffix: &str, ) -> Result { let root = repo_root(); let PreparedCorpus { @@ -336,11 +657,11 @@ async fn live_ingest( &db.url, &profile.root, &chain_rpc_urls, - "catchup-equivalence-live-finalized", + log_suffix, ) .await?; live_session - .wait_for_checkpoint(&db.pool, fixture_head, Some(&live_ready_sql(chain))) + .wait_for_checkpoint(&db.pool, fixture_head, Some(ready_sql)) .await?; live_session.stop().await?; pipeline::worker_replay_all_current_projections(&root, &db.url).await?; @@ -349,9 +670,9 @@ async fn live_ingest( async fn automatic_catchup( anvil: &Anvil, - chain: &CatchupChain, prepared: PreparedCorpus, - last_fixture_event_block: u64, + ready_sql: &str, + log_suffix: &str, ) -> Result { let root = repo_root(); let PreparedCorpus { @@ -369,21 +690,12 @@ async fn automatic_catchup( deleted.rows_affected() == 1, "expected one {CHAIN} intake checkpoint before forcing automatic catch-up" ); - let mut session = pipeline::IndexerRunSession::start( - &root, - &db.url, - &profile.root, - &anvil.url, - "catchup-equivalence-auto", - ) - .await?; + let mut session = + pipeline::IndexerRunSession::start(&root, &db.url, &profile.root, &anvil.url, log_suffix) + .await?; let fixture_head = anvil.client().block_number().await?; session - .wait_for_checkpoint( - &db.pool, - fixture_head, - Some(&catchup_ready_sql(chain, last_fixture_event_block)), - ) + .wait_for_checkpoint(&db.pool, fixture_head, Some(ready_sql)) .await?; session.stop().await?; pipeline::worker_replay_all_current_projections(&root, &db.url).await?; @@ -409,10 +721,23 @@ async fn automatic_catchup_matches_live_ingestion_outputs() -> Result<()> { let catchup_baseline = prepare_baseline(&anvil, &chain, "catchup-equivalence-auto-base").await?; let fixture = add_rich_name_fixture(&anvil, &chain).await?; + let live_ready = live_ready_sql(&chain); + let catchup_ready = catchup_ready_sql(&chain, fixture.last_event_block); - let live = live_ingest(&anvil, &chain, live_baseline).await?; - let catchup = - automatic_catchup(&anvil, &chain, catchup_baseline, fixture.last_event_block).await?; + let live = live_ingest( + &anvil, + live_baseline, + &live_ready, + "catchup-equivalence-live-finalized", + ) + .await?; + let catchup = automatic_catchup( + &anvil, + catchup_baseline, + &catchup_ready, + "catchup-equivalence-auto", + ) + .await?; let live_snapshots = support::route_snapshots(&live, &chain.subjects()).await?; let catchup_snapshots = support::route_snapshots(&catchup, &chain.subjects()).await?; perturb::assert_snapshots_equal(&live_snapshots, &catchup_snapshots)?; @@ -420,7 +745,60 @@ async fn automatic_catchup_matches_live_ingestion_outputs() -> Result<()> { &live.db.pool, &catchup.db.pool, CATCHUP_EQUIVALENCE_CONTRACT, - &[fixture.expected_preimage], + &fixture.expected_preimages, + ) + .await?; + + live.db.cleanup().await?; + catchup.db.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn automatic_catchup_matches_live_wrapper_reverse_outputs() -> Result<()> { + let anvil = Anvil::spawn().await?; + let rpc = anvil.client(); + let deployment = ens_v1::deploy_ens_v1(&rpc, &repo_root()).await?; + let accounts = rpc.accounts().await?; + let chain = CatchupChain { + resolver: deployment.public_resolver.address, + deployment, + owner: accounts[1], + record_target: accounts[2], + child_owner: accounts[3], + }; + + rpc.mine(FINALITY_MARGIN_BLOCKS).await?; + let live_baseline = + prepare_baseline(&anvil, &chain, "catchup-wrapper-reverse-live-base").await?; + let catchup_baseline = + prepare_baseline(&anvil, &chain, "catchup-wrapper-reverse-auto-base").await?; + let fixture = add_wrapper_reverse_fixture(&anvil, &chain).await?; + let live_ready = wrapper_reverse_live_ready_sql(&chain); + let catchup_ready = wrapper_reverse_catchup_ready_sql(&chain, fixture.last_event_block); + + let live = live_ingest( + &anvil, + live_baseline, + &live_ready, + "catchup-wrapper-reverse-live-finalized", + ) + .await?; + let catchup = automatic_catchup( + &anvil, + catchup_baseline, + &catchup_ready, + "catchup-wrapper-reverse-auto", + ) + .await?; + let live_snapshots = wrapper_reverse_route_snapshots(&live, &chain).await?; + let catchup_snapshots = wrapper_reverse_route_snapshots(&catchup, &chain).await?; + perturb::assert_snapshots_equal(&live_snapshots, &catchup_snapshots)?; + perturb::assert_catchup_normalized_event_parity( + &live.db.pool, + &catchup.db.pool, + CATCHUP_EQUIVALENCE_CONTRACT, + &fixture.expected_preimages, ) .await?; From e450e0b52cf382a7fcac7e6d8a66f2d85061e94c Mon Sep 17 00:00:00 2001 From: TateB Date: Wed, 22 Jul 2026 09:25:38 +0000 Subject: [PATCH 2/2] docs(e2e): frame catch-up corpus as fixture behavior Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C9RTnbCyhqEUSPc7xCK76p --- tests/e2e/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index b51b738a..34174da4 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -591,11 +591,12 @@ route inventory or a claim that every protocol transition is covered. `PreimageObserved` for `catchupeq.eth` to exist identically in both corpora, so equality cannot pass if both paths omit the event. - `catchup_equivalence::automatic_catchup_matches_live_wrapper_reverse_outputs` - — compares two finalized ENSv1 registrations across the same live-versus- - catch-up boundary. One name is wrapped and burns owner-controlled fuses; the - other is wrapped, unwrapped back to registrar authority, and then used in a - reverse/primary-name claim. Full route snapshots and normalized-event rows - must match, including stable chain/address provenance for contract instances. + — exercises two finalized ENSv1 registrations across the same live-versus- + catch-up boundary. The fixture registers, wraps, and sets fuses on one name, + then registers, wraps, unwraps, and emits a reverse/primary-name claim for the + other. The test asserts Full equality for route snapshots and normalized-event + rows over those events, including stable chain/address provenance for contract + instances. The full equality arm also requires receipt-reconstructed registrar `PreimageObserved` rows for both names, so equal omission cannot pass. Together, the catch-up scenarios cover finalized ENSv1 registration,