Created
March 5, 2026 03:15
-
-
Save denniswon/e716fd0542c617caeff89bfcb17c291d to your computer and use it in GitHub Desktop.
Design: Destination Chain Challenge Relay to L1 for Slashing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Now I have the complete picture. Let me deliver the design analysis. | |
| ★ Insight ───────────────────────────────────── | |
| ELIP-008 does NOT specify cross-chain slashing. It only covers one direction: L1→L2 operator table transport and L2 certificate | |
| verification. The reverse direction (L2 challenge → L1 slashing) is entirely AVS-specific. This means Newton has full design | |
| freedom here — there's no EigenLayer standard to conform to for the relay mechanism. | |
| Critical finding from the contract analysis: The source chain's ChallengeVerifier.raiseAndResolveChallenge() (line 109) | |
| requires allTaskHashes[taskResponse.taskId] to be non-zero — meaning the task must exist on the source chain. Destination-chain | |
| tasks are never registered on the source chain's TaskManager. This means you cannot simply call the source chain's existing | |
| raiseAndResolveChallenge() with a dest-chain task. A new contract function or a new approach is required. | |
| ───────────────────────────────────────────────── | |
| Design: Destination Chain Challenge Relay to L1 for Slashing | |
| The Core Problem | |
| ELIP-008 only defines L1→L2 transport (operator tables down to destination chains). It does not define L2→L1 challenge relay. | |
| This is intentional — slashing enforcement is AVS-specific, not part of the EigenLayer multichain standard. | |
| Newton's current contracts have a blocking constraint: the source chain's ChallengeVerifier.raiseAndResolveChallenge() at | |
| ChallengeVerifier.sol:109 checks: | |
| require( | |
| TaskLib.taskHash(task) == allTaskHashes[taskResponse.taskId], | |
| TaskLib.TaskMismatch(...) | |
| ); | |
| Destination-chain tasks never exist in the source chain's allTaskHashes mapping. This means reusing the existing function is | |
| impossible. | |
| Two Design Options | |
| Option A: New Contract Function — slashForCrossChainChallenge() | |
| Add a new function to ChallengeVerifier that accepts a proof of successful dest-chain challenge: | |
| function slashForCrossChainChallenge( | |
| uint256 destChainId, | |
| INewtonProverTaskManager.Task calldata task, | |
| INewtonProverTaskManager.TaskResponse calldata taskResponse, | |
| INewtonProverTaskManager.ResponseCertificate calldata responseCertificate, | |
| INewtonProverTaskManager.ChallengeData calldata challenge, | |
| BN254.G1Point[] calldata pubkeysOfNonSigningOperators, | |
| bytes calldata destChainChallengeProof // proof that dest chain accepted the challenge | |
| ) external nonReentrant returns (bool) | |
| The destChainChallengeProof would prove that taskSuccesfullyChallenged[taskId] == true on the destination chain. This could be: | |
| - A storage proof (Merkle proof of the dest chain's state root containing the challenged mapping) | |
| - A cross-chain message via a bridge (Optimism/Arbitrum native messaging, LayerZero, etc.) | |
| - Or simply a re-execution of the ZK proof on the source chain (since the source chain has the same RegoVerifier) | |
| Re-executing the ZK proof is the simplest and most trust-minimized approach — the source chain independently verifies the | |
| challenge is valid without trusting any bridge or relay. The only difference from the existing raiseAndResolveChallenge is | |
| skipping the allTaskHashes check (since the task doesn't exist on source) and replacing it with a different validation: | |
| // Instead of checking allTaskHashes (task doesn't exist on source chain), | |
| // verify the task was legitimately created on a registered destination chain | |
| require(registeredDestinationChains[destChainId], "unregistered dest chain"); | |
| // Re-verify the ZK proof on source chain (same RegoVerifier, same logic) | |
| IRegoVerifier.RegoContext memory context = | |
| RegoVerifier(regoVerifier).verifyRegoProof(challenge.data, challenge.proof); | |
| // Verify proof output mismatches the task response | |
| require( | |
| keccak256(abi.encode(context.evaluation)) != keccak256(abi.encode(taskResponse.evaluationResult)), | |
| ChallengeFailed() | |
| ); | |
| // Then slash (same ChallengeLib flow) | |
| ChallengeLib.slashSigningOperators(ctx, task.quorumNumbers, ...); | |
| Option B: Register Dest Tasks on Source Chain | |
| Before challenging on source, the relay would first call createNewTask() on the source chain's TaskManager to register the | |
| task, then call the existing raiseAndResolveChallenge(). This is problematic because: | |
| - createNewTask is onlyTaskGenerator — the challenger isn't the task generator | |
| - It pollutes the source chain's task registry with foreign tasks | |
| - It creates ordering dependencies | |
| Recommendation: Option A (new contract function with ZK proof re-execution). | |
| Detailed Design | |
| 1. Contract Changes (Solidity) | |
| ChallengeVerifier.sol — Add new function: | |
| /// @notice Slash operators for a challenge that was proven on a destination chain | |
| /// @dev Re-executes ZK proof on source chain; does not require task to exist in allTaskHashes | |
| /// @param destChainId The destination chain where the task was created | |
| /// @param task The original task (from dest chain) | |
| /// @param taskResponse The task response being challenged | |
| /// @param challenge ZK proof data proving the response was incorrect | |
| /// @param pubkeysOfNonSigningOperators BLS pubkeys of non-signing operators for slashing | |
| function slashForCrossChainChallenge( | |
| uint256 destChainId, | |
| INewtonProverTaskManager.Task calldata task, | |
| INewtonProverTaskManager.TaskResponse calldata taskResponse, | |
| INewtonProverTaskManager.ChallengeData calldata challenge, | |
| BN254.G1Point[] calldata pubkeysOfNonSigningOperators | |
| ) external nonReentrant returns (bool) | |
| Key differences from raiseAndResolveChallenge: | |
| - No allTaskHashes check (task doesn't exist on source chain) | |
| - No responseCertificate needed for challenge window check (the dest chain already validated timing) | |
| - Adds destChainId param to verify against registered destination chains | |
| - Only callable when serviceManager != address(0) (source chains only) | |
| - Uses a separate crossChainChallenged[destChainId][taskId] mapping to prevent double-slashing | |
| - Still re-verifies the ZK proof independently (trust-minimized) | |
| NewtonCrossChainRegistry.sol or a new mapping — needs registeredDestinationChains[chainId] to validate the destChainId | |
| parameter. | |
| 2. Non-Signer Pub Keys (The Hard Part) | |
| On the destination chain, DestChallengeSubmitter passes pub_keys_of_non_signing_operators = vec![] because slashing is skipped. | |
| For the L1 relay, the source chain's ChallengeLib.slashSigningOperators() needs the non-signer list to identify who signed (by | |
| exclusion from the full operator set). | |
| The non-signer data is available in the dest chain's ResponseCertificate.signatureData, which contains the BN254 certificate. | |
| The certificate includes non-signer witness data (per ELIP-008's BN254 certificate format). The relayer needs to: | |
| 1. Read the ResponseCertificate from the dest chain's TaskResponded event | |
| 2. Decode the BN254 certificate from responseCertificate.signatureData | |
| 3. Extract non-signer pubkeys from the certificate's witness data | |
| 4. Pass these to the source chain's slashForCrossChainChallenge() | |
| The source chain's BLSApkRegistry maps pubkey hashes → operator addresses, so the pubkeys from the cert can be resolved to | |
| addresses for slashing. | |
| 3. Rust Implementation | |
| ┌─────────────────────┬──────────────────────────────────────────┬────────────────────────────────────────────────────────┐ | |
| │ Component │ File │ Purpose │ | |
| ├─────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────┤ | |
| │ L1ChallengeRelayer │ crates/challenger/src/dest/relayer.rs │ Holds source chain signer, submits │ | |
| │ │ (new) │ slashForCrossChainChallenge │ | |
| ├─────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────┤ | |
| │ Config extension │ crates/challenger/src/config.rs │ Add source_chain section (rpc_url, task_manager, │ | |
| │ │ │ optional separate signer) │ | |
| ├─────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────┤ | |
| │ Non-signer │ crates/challenger/src/dest/relayer.rs │ Decode BN254 cert from ResponseCertificate, extract │ | |
| │ extraction │ │ non-signer G1Points │ | |
| ├─────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────┤ | |
| │ Runner integration │ crates/challenger/src/dest/runner.rs │ After submitter.submit_challenge() succeeds → │ | |
| │ │ │ relayer.relay_to_source() │ | |
| ├─────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────┤ | |
| │ Module registration │ crates/challenger/src/dest/mod.rs │ Add pub mod relayer; │ | |
| ├─────────────────────┼──────────────────────────────────────────┼────────────────────────────────────────────────────────┤ | |
| │ Contract bindings │ Auto-generated after Solidity changes │ make contracts to regenerate │ | |
| └─────────────────────┴──────────────────────────────────────────┴────────────────────────────────────────────────────────┘ | |
| L1ChallengeRelayer sketch: | |
| pub struct L1ChallengeRelayer { | |
| signer: SdkSigner, // source chain signer | |
| source_task_manager: Address, // source chain TaskManager | |
| dest_chain_id: u64, // which dest chain this challenger monitors | |
| } | |
| impl L1ChallengeRelayer { | |
| pub async fn relay_to_source( | |
| &self, | |
| proof: ChallengeProof, // same proof used for dest chain | |
| non_signer_pubkeys: Vec<G1Point>, // extracted from ResponseCertificate | |
| ) -> eyre::Result<TransactionReceipt> { | |
| // Call slashForCrossChainChallenge on source chain TaskManager | |
| } | |
| } | |
| Runner integration — fire-and-forget after dest chain success: | |
| // In runner.rs, after successful dest chain challenge: | |
| ChallengeTrigger::TaskResponded { .. } => { | |
| match evaluator.evaluate_response(task_response, response_certificate).await { | |
| Ok(Some(proof)) => { | |
| // Step 1: Invalidate on dest chain | |
| if let Ok(receipt) = submitter.submit_challenge(proof.clone()).await { | |
| // Step 2: Relay to source for slashing (best-effort) | |
| let non_signers = extract_non_signers(&response_certificate); | |
| if let Err(e) = relayer.relay_to_source(proof, non_signers).await { | |
| error!("L1 relay failed (attestation still invalidated on dest): {}", e); | |
| } | |
| } | |
| } | |
| // ... | |
| } | |
| } | |
| 4. Config Changes | |
| # challenger-dest.toml (destination chain challenger) | |
| [signer] | |
| private_key_env = "CHALLENGER_KEY" # needs gas on BOTH chains | |
| [source_chain] | |
| chain_id = 11155111 | |
| rpc_url = "https://eth-sepolia.g.alchemy.com/v2/..." | |
| task_manager = "0x..." # source chain TaskManager address | |
| # Optional: separate signer for L1 relay (defaults to main signer) | |
| # relay_signer_private_key_env = "CHALLENGER_L1_RELAY_KEY" | |
| Docker compose addition for challenger-dest: | |
| environment: | |
| - RPC_HTTP_URL_31337=http://anvil:8545 # source chain RPC | |
| - SOURCE_TASK_MANAGER=0x... # from deployment JSON | |
| Implementation Order | |
| 1. Solidity first: Add slashForCrossChainChallenge() to ChallengeVerifier.sol and NewtonProverTaskManagerShared.sol, add | |
| crossChainChallenged mapping, add dest chain registry check. Write forge tests. | |
| 2. make contracts to regenerate Rust bindings. | |
| 3. Rust relayer.rs: Implement L1ChallengeRelayer using the new contract binding. | |
| 4. Config: Extend ChallengerConfig with optional source_chain section. | |
| 5. Runner integration: Wire relayer into run_dest_challenger(). | |
| 6. E2E test: Dual-anvil test that creates a bad response on dest chain, challenges it, verifies slashing on source chain. | |
| Open Questions for Your Decision | |
| 1. Double-slash prevention: Should the source chain contract track crossChainChallenged[destChainId][taskId] to prevent the | |
| same challenge being relayed multiple times? (Recommended: yes) | |
| 2. Challenge window on source: The dest chain has its own challenge window. Should the source chain also enforce a time limit | |
| for the relay, or accept any valid ZK proof at any time? (Recommendation: no time limit on source — if the ZK proof is valid, | |
| the slash should happen regardless of when it's relayed) | |
| 3. Signer sharing: Use the same ECDSA key for both dest and source chain submissions (simpler, needs gas on both chains) or | |
| separate keys? (Recommendation: same key, per MULTICHAIN.md line 1299) | |
| 4. Non-signer extraction format: The dest chain's ResponseCertificate.signatureData encodes a BN254 certificate (not | |
| NonSignerStakesAndSignature). Need to confirm the exact struct layout in DestinationTaskResponseHandler to decode correctly. |
Author
Author
★ Insight ─────────────────────────────────────
Challenger E2E test design choices:
- Config resolution tests (test_source_challenger_config_loads, test_dest_challenger_config_loads) verify the chain
classification plumbing without any network calls. Chain 31337 is source, 31338 is destination with source_chain_id=31337 —
this is the foundation the relay relies on. - Component construction tests (test_dest_challenger_components_build) verify that every dest-mode component can be
instantiated — including L1ChallengeRelayer which resolves the source TaskManager from deployment JSON. This is the key
assertion of this PR: no manual address config needed. - Lifecycle tests (test_source_challenger_lifecycle, test_dest_challenger_lifecycle) start the full challenger daemon, verify
it connects to WebSocket, then cancel and assert clean shutdown. These catch startup regressions (bad config, missing
contracts, WS connection failures) without requiring a full gateway+operator stack.
The tests intentionally do not exercise ZK proof generation (SP1) — that's expensive and the Forge tests already cover the
contract-level challenge flow. The E2E tests focus on the Rust infrastructure: config loading, deployment JSON resolution,
component wiring, and daemon lifecycle.
─────────────────────────────────────────────────
Files changed:
- integration-tests/tests/challenger.rs — 6 E2E tests for source and destination challenger modes
- Makefile — challenger-e2e target (dual-anvil, follows transporter-e2e pattern)
- CLAUDE.md, .claude/CLAUDE.md, .claude/rules/testing.md — documented make challenger-e2e
Run with:
make challenger-e2e # uses cached deployment
make challenger-e2e force=true # fresh deployment
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
★ Insight ─────────────────────────────────────
Here's a summary of everything completed in this session for the cross-chain challenge relay feature:
Solidity Tests (new file):
enforcement
Documentation Updates (5 files):
added relayer.rs to crate structure; updated ChallengerConfig to show source_chain field; added setRegisteredDestinationChain
to destination chain setup checklist; updated key management table
relayer.rs and updated config.rs description in key files
─────────────────────────────────────────────────
The cross-chain challenge relay implementation is now fully complete: