Skip to content

feat: range reduction breaks paired claims / unclaims#345

Open
joanestebanr wants to merge 7 commits into
mainfrom
feat/unclaim_unpaired-343
Open

feat: range reduction breaks paired claims / unclaims#345
joanestebanr wants to merge 7 commits into
mainfrom
feat/unclaim_unpaired-343

Conversation

@joanestebanr
Copy link
Copy Markdown

@joanestebanr joanestebanr commented Apr 17, 2026

Summary

This PR adds two independent safety features:

Fix: #343 Detect broken import/unclaim pairs after range reduction

When the proposer reduces the end_block of a certificate (range reduction), it is possible that an imported_bridge_exit falls within the new range while its matching unclaim does not. This would cause the import to appear as valid in the proof even though it was intentionally cancelled.

A new validate_no_broken_pairs check is added in aggchain-proof-service that, whenever the proposer returns a different end_block than the one originally requested, verifies that no (imported_bridge_exit, unclaim) pair is split by the new boundary. Pairs are matched by global_index in chronological order (k-th import ↔ k-th unclaim). If a broken pair is found, proof generation is aborted with a descriptive error.

Fix: #346 full error chain returned to gRPC clients

Previously, gRPC error responses only included the top-level error message (error.to_string()), silently dropping all nested cause messages. A new format_error_chain helper walks the full source() chain and concatenates all messages, so clients now receive the complete error description.

Chore: IGNORE_VKEY_MISMATCH environment variable to be able to develop/debug locally

Adds an opt-in escape hatch for development/testing environments: when IGNORE_VKEY_MISMATCH is set, vkey hash and range vkey commitment mismatches between the on-chain op-succinct config and the local ELF are logged as warnings instead of returning an error, allowing the server to continue running. The mismatch is still clearly surfaced in the logs. This flag should never be set in production.

Chore: scrtipt automates the setup needed to run the aggkit-prover locally against a live Kurtosis enclave (a local multi-service test environment).

Changes

  • aggchain-proof-service/src/validation.rs — new module with validate_no_broken_pairs and unit tests
  • aggchain-proof-service/src/service.rs — calls the validation after receiving a proposer response with a reduced end block
  • aggchain-proof-service/src/error.rs — new BrokenImportUnclaimPair error variant
  • aggchain-proof-builder/src/lib.rsIGNORE_VKEY_MISMATCH support for aggregation vkey hash and range vkey commitment checks
  • aggkit-prover/src/rpc.rsformat_error_chain helper; full error chain now returned to gRPC clients
  • proposer-client/src/rpc/mod.rs — minor error wrapping fix
  • scripts/generate_config_based_kurtosis_to_run_locally.sh — new helper script for local Kurtosis-based testing

Fixes #343, #346

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 17, 2026

CLA assistant check
All committers have signed the CLA.

@joanestebanr joanestebanr requested a review from Copilot April 17, 2026 13:17
@joanestebanr joanestebanr changed the title feat:: detect if range reduction breaks paired claims / unclaims feat: detect if range reduction breaks paired claims / unclaims Apr 17, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a safety validation to detect when a proposer-reduced end_block would cause an import/unclaim pair to be split (and therefore incorrectly treated as a valid claim), plus a helper script to generate a locally-runnable config from a Kurtosis enclave.

Changes:

  • Add validate_no_broken_pairs to detect broken (imported_bridge_exit, unclaim) pairs under range reduction, with unit tests.
  • Wire the validation into AggchainProofService and introduce a dedicated error variant.
  • Add a script to download the Kurtosis config artifact and rewrite internal endpoints/paths for local execution.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
scripts/generate_config_based_kurtosis_to_run_locally.sh Downloads config/genesis from Kurtosis and rewrites endpoints/paths for local runs.
crates/aggchain-proof-service/src/validation.rs Implements broken-pair detection logic with a comprehensive test suite.
crates/aggchain-proof-service/src/service.rs Calls the new validation when the proposer returns a different end_block.
crates/aggchain-proof-service/src/lib.rs Registers the new validation module.
crates/aggchain-proof-service/src/error.rs Adds an error variant for broken import/unclaim pairs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/aggchain-proof-service/src/service.rs
Comment thread scripts/generate_config_based_kurtosis_to_run_locally.sh Outdated
Comment thread scripts/generate_config_based_kurtosis_to_run_locally.sh
Comment thread crates/aggchain-proof-service/src/service.rs
@hadjiszs hadjiszs self-requested a review April 20, 2026 07:34
@joanestebanr joanestebanr self-assigned this Apr 21, 2026
@joanestebanr joanestebanr marked this pull request as ready for review April 21, 2026 09:28
@joanestebanr joanestebanr requested a review from a team as a code owner April 21, 2026 09:28
@joanestebanr joanestebanr changed the title feat: detect if range reduction breaks paired claims / unclaims feat: range reduction breaks paired claims / unclaims Apr 21, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0545b620d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/aggchain-proof-service/src/validation.rs Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/aggchain-proof-service/src/validation.rs
Comment thread crates/aggchain-proof-service/src/error.rs Outdated
Comment thread scripts/generate_config_based_kurtosis_to_run_locally.sh Outdated
Comment on lines +13 to +60
/// The k-th unclaim for a given `global_index` (sorted by block_number)
/// is matched to the k-th import with the same `global_index` (also sorted by
/// block_number). A pair is broken when the import falls within the new block
/// range but its matching unclaim does not, meaning the import would appear as
/// valid in the proof even though it was intended to be cancelled.
pub(crate) fn validate_no_broken_pairs(
imported_bridge_exits: &[ImportedBridgeExitWithBlockNumber],
unclaims: &[UnclaimWithBlockNumber],
new_end_block: u64,
) -> Result<(), Error> {
// Collect import block_numbers per global_index.
let mut imports_by_global_index: HashMap<U256, Vec<u64>> = HashMap::new();
for import in imported_bridge_exits {
let global_index: U256 = import.global_index.into();
imports_by_global_index
.entry(global_index)
.or_default()
.push(import.block_number);
}
for blocks in imports_by_global_index.values_mut() {
blocks.sort_unstable();
}

// Collect unclaim block_numbers per global_index.
let mut unclaims_by_global_index: HashMap<U256, Vec<u64>> = HashMap::new();
for unclaim in unclaims {
unclaims_by_global_index
.entry(unclaim.global_index)
.or_default()
.push(unclaim.block_number);
}
for blocks in unclaims_by_global_index.values_mut() {
blocks.sort_unstable();
}

// Check each (import_k, unclaim_k) pair for the same global_index.
for (global_index, unclaim_blocks) in &unclaims_by_global_index {
let import_blocks = imports_by_global_index
.get(global_index)
.map(Vec::as_slice)
.unwrap_or(&[]);

for (k, &unclaim_block) in unclaim_blocks.iter().enumerate() {
let Some(&import_block) = import_blocks.get(k) else {
// No matching import for this unclaim position; not a split.
continue;
};

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate_no_broken_pairs collects only block_number (dropping log_index) and then sorts, which can mis-order events that occur in the same block and therefore mis-pair imports/unclaims. Since ImportedBridgeExitWithBlockNumber and UnclaimWithBlockNumber define Ord including log_index, store and sort full events (or at least (block_number, log_index)) when building the per-global_index vectors so pairing is deterministic and chronologically correct.

Suggested change
/// The k-th unclaim for a given `global_index` (sorted by block_number)
/// is matched to the k-th import with the same `global_index` (also sorted by
/// block_number). A pair is broken when the import falls within the new block
/// range but its matching unclaim does not, meaning the import would appear as
/// valid in the proof even though it was intended to be cancelled.
pub(crate) fn validate_no_broken_pairs(
imported_bridge_exits: &[ImportedBridgeExitWithBlockNumber],
unclaims: &[UnclaimWithBlockNumber],
new_end_block: u64,
) -> Result<(), Error> {
// Collect import block_numbers per global_index.
let mut imports_by_global_index: HashMap<U256, Vec<u64>> = HashMap::new();
for import in imported_bridge_exits {
let global_index: U256 = import.global_index.into();
imports_by_global_index
.entry(global_index)
.or_default()
.push(import.block_number);
}
for blocks in imports_by_global_index.values_mut() {
blocks.sort_unstable();
}
// Collect unclaim block_numbers per global_index.
let mut unclaims_by_global_index: HashMap<U256, Vec<u64>> = HashMap::new();
for unclaim in unclaims {
unclaims_by_global_index
.entry(unclaim.global_index)
.or_default()
.push(unclaim.block_number);
}
for blocks in unclaims_by_global_index.values_mut() {
blocks.sort_unstable();
}
// Check each (import_k, unclaim_k) pair for the same global_index.
for (global_index, unclaim_blocks) in &unclaims_by_global_index {
let import_blocks = imports_by_global_index
.get(global_index)
.map(Vec::as_slice)
.unwrap_or(&[]);
for (k, &unclaim_block) in unclaim_blocks.iter().enumerate() {
let Some(&import_block) = import_blocks.get(k) else {
// No matching import for this unclaim position; not a split.
continue;
};
/// The k-th unclaim for a given `global_index` (sorted chronologically using
/// the event type's `Ord`, including `block_number` and `log_index`) is
/// matched to the k-th import with the same `global_index` (sorted the same
/// way). A pair is broken when the import falls within the new block range
/// but its matching unclaim does not, meaning the import would appear as
/// valid in the proof even though it was intended to be cancelled.
pub(crate) fn validate_no_broken_pairs(
imported_bridge_exits: &[ImportedBridgeExitWithBlockNumber],
unclaims: &[UnclaimWithBlockNumber],
new_end_block: u64,
) -> Result<(), Error> {
// Collect imports per global_index and preserve full event ordering.
let mut imports_by_global_index: HashMap<U256, Vec<&ImportedBridgeExitWithBlockNumber>> =
HashMap::new();
for import in imported_bridge_exits {
let global_index: U256 = import.global_index.into();
imports_by_global_index
.entry(global_index)
.or_default()
.push(import);
}
for imports in imports_by_global_index.values_mut() {
imports.sort_unstable();
}
// Collect unclaims per global_index and preserve full event ordering.
let mut unclaims_by_global_index: HashMap<U256, Vec<&UnclaimWithBlockNumber>> =
HashMap::new();
for unclaim in unclaims {
unclaims_by_global_index
.entry(unclaim.global_index)
.or_default()
.push(unclaim);
}
for unclaims_for_global_index in unclaims_by_global_index.values_mut() {
unclaims_for_global_index.sort_unstable();
}
// Check each (import_k, unclaim_k) pair for the same global_index.
for (global_index, unclaim_events) in &unclaims_by_global_index {
let import_events: &[&ImportedBridgeExitWithBlockNumber] = imports_by_global_index
.get(global_index)
.map(Vec::as_slice)
.unwrap_or(&[]);
for (k, unclaim) in unclaim_events.iter().enumerate() {
let Some(import) = import_events.get(k) else {
// No matching import for this unclaim position; not a split.
continue;
};
let import_block = import.block_number;
let unclaim_block = unclaim.block_number;

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +46
// Collect unclaim block_numbers per global_index.
let mut unclaims_by_global_index: HashMap<U256, Vec<u64>> = HashMap::new();
for unclaim in unclaims {
unclaims_by_global_index
.entry(unclaim.global_index)
.or_default()
.push(unclaim.block_number);
}
for blocks in unclaims_by_global_index.values_mut() {
blocks.sort_unstable();
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue for unclaims: only block_number is stored and sorted, ignoring log_index. This can cause incorrect pairing and false negatives/positives when multiple unclaims for the same global_index are emitted in the same block. Consider sorting UnclaimWithBlockNumber (or (block_number, log_index)) instead of plain block numbers.

Copilot uses AI. Check for mistakes.
joanestebanr and others added 6 commits April 28, 2026 09:28
Add Bash 4.2+ version check (macOS ships 3.2), a portable sedi()
function to handle sed -i differences between Linux and macOS, and
a portable_realpath() fallback for systems without GNU coreutils.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tonic::Status is 176 bytes and is part of the Proofs trait signature
generated by tonic — we have no control over its size in test closures
or in the mockall::mock! expansion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…order

Events with the same global_index in the same block were only sorted by
block_number, causing the k-th import to be paired with the wrong k-th
unclaim and either missing a real split or raising a false positive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@joanestebanr joanestebanr force-pushed the feat/unclaim_unpaired-343 branch from 545e56b to 9f96677 Compare April 28, 2026 07:46
Copy link
Copy Markdown
Contributor

@hadjiszs hadjiszs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Here is a first pass of review

Comment on lines +57 to +60
let Some(&(import_block, _)) = import_events.get(k) else {
// No matching import for this unclaim position; not a split.
continue;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we end up in this else? Having one unclaim without a corresponding import in the block range that we are processing leads to an error normally no?

/// import falls within the new block range but its matching unclaim does not,
/// meaning the import would appear as valid in the proof even though it was
/// intended to be cancelled.
pub(crate) fn validate_no_broken_pairs(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked claude to try to make this function more readable

it suggested something like below (not tested nor properly reviewed), wdyt? Happy to review another version if you find better 👌
but basically trying to have dedicated structs if possible, and splitting this function into "build pairs into well-suited data structures" followed by "check the pairs"

#[derive(Debug)]
struct ImportUnclaimPair {
    global_index: U256,
    import_block: u64,
    unclaim_block: u64,
}

pub(crate) fn validate_no_broken_pairs(
    imports: &[ImportedBridgeExitWithBlockNumber],
    unclaims: &[UnclaimWithBlockNumber],
    new_end_block: u64,
) -> Result<(), Error> {
    for ImportUnclaimPair {
        global_index,
        import_block,
        unclaim_block,
    } in pair_imports_with_unclaims(imports, unclaims)
    {
        if import_block <= new_end_block && unclaim_block > new_end_block {
            return Err(Error::BrokenImportUnclaimPair {
                global_index,
                import_block,
                unclaim_block,
                new_end_block,
            });
        }
    }
    Ok(())
}

/// Pairs the k-th import event for each `global_index` with the k-th unclaim
/// for the same `global_index`, both in (block_number, log_index) order.
/// Excess events on either side are dropped — they correspond to imports
/// not yet cancelled, or unclaims targeting imports outside this witness.
fn pair_imports_with_unclaims(
    imports: &[ImportedBridgeExitWithBlockNumber],
    unclaims: &[UnclaimWithBlockNumber],
) -> Vec<ImportUnclaimPair> {
    #[derive(Default)]
    struct EventLists {
        imports: Vec<(u64, u64)>,
        unclaims: Vec<(u64, u64)>,
    }

    let mut by_gi: HashMap<U256, EventLists> = HashMap::new();

    for ev in imports {
        by_gi
            .entry(ev.global_index.into())
            .or_default()
            .imports
            .push((ev.block_number, ev.log_index));
    }

    for ev in unclaims {
        by_gi
            .entry(ev.global_index)
            .or_default()
            .unclaims
            .push((ev.block_number, ev.log_index));
    }

    by_gi
        .into_iter()
        .flat_map(|(global_index, mut events)| {
            events.imports.sort_unstable();
            events.unclaims.sort_unstable();

            events
                .imports
                .into_iter()
                .zip(events.unclaims)
                .map(move |((import_block, _), (unclaim_block, _))| ImportUnclaimPair {
                    global_index,
                    import_block,
                    unclaim_block,
                })
        })
        .collect()
}

expected_by_contract: Box::new(retrieved_from_contracts),
expected_by_verifier: Box::new(aggregation_proof_public_values.clone()),
});
if std::env::var("IGNORE_VKEY_MISMATCH").is_ok() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the check isn't only about vkeys here but on all the public values of the proof received from the op-succinct proposer. To check that they are the same as the ones expected by the L1 verifier (e.g., prev / new blocks, l1 head etc)

just to be sure I understand the motivation, this check is a problem with the aggkit-prover run in which way and with which setup?

Copy link
Copy Markdown
Author

@joanestebanr joanestebanr Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I introduce that to be able to run aggkit-prover even that is going to fail to return a proof becuase is not going to pass the verify of the proof locally. This allow to start the server and attend gRPC requests...
If you think that can be dangerous in some way I'll remove it. WDTY?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants