Skip to content

Instantly share code, notes, and snippets.

@denniswon
Created February 27, 2026 15:39
Show Gist options
  • Select an option

  • Save denniswon/2bb5ca8d6956ef0ce8a82879fe6def3e to your computer and use it in GitHub Desktop.

Select an option

Save denniswon/2bb5ca8d6956ef0ce8a82879fe6def3e to your computer and use it in GitHub Desktop.
transporter analysis
★ Insight ─────────────────────────────────────
They don't need to share a key. My analysis conflated "per-chain signer" with "one signer per chain for all operations." In
reality, the dest chain contracts don't enforce that table updates and challenges come from the same address. These are
independent on-chain roles.
─────────────────────────────────────────────────
Let me verify what the contracts actually enforce:
⏺ Searched for 4 patterns, read 1 file (ctrl+o to expand)
⎿  Loaded contracts/CLAUDE.md
⎿  Loaded contracts/src/CLAUDE.md
⎿  Loaded .claude/rules/solidity.md
⏺ The contracts confirm it — they don't need to share a key at all:
┌──────────────────────────┬────────────────────────────────────────┬─────────────────────────────────┐
│ Operation │ Access Control │ Who Can Call │
├──────────────────────────┼────────────────────────────────────────┼─────────────────────────────────┤
│ confirmGlobalTableRoot │ onlyOwner on ECDSAOperatorTableUpdater │ Only the updater contract owner │
├──────────────────────────┼────────────────────────────────────────┼─────────────────────────────────┤
│ updateOperatorTable │ (same contract, same onlyOwner) │ Only the updater contract owner │
├──────────────────────────┼────────────────────────────────────────┼─────────────────────────────────┤
│ raiseAndResolveChallenge │ whenNotPaused — no caller restriction │ Anyone │
└──────────────────────────┴────────────────────────────────────────┴─────────────────────────────────┘
★ Insight ─────────────────────────────────────
Challenge submission is permissionless. raiseAndResolveChallenge on NewtonProverTaskManagerShared has no msg.sender check —
anyone can submit a valid challenge with a correct ZK proof. The ChallengeVerifier only checks that msg.sender == taskManager,
meaning the call must go through the TaskManager, but the TaskManager itself doesn't restrict who initiates it. This means the
challenger doesn't need the transport signer key at all — it can use any funded address.
─────────────────────────────────────────────────
This significantly weakens the nonce-contention argument I made earlier for combining. Let me revise:
Revised Coupling Analysis
┌───────────────────────┬──────────────────────┬────────────────────────────────┬──────────────────────────────────────────┐
│ Resource │ Transport │ Challenge │ Still Coupled? │
├───────────────────────┼──────────────────────┼────────────────────────────────┼──────────────────────────────────────────┤
│ Dest chain RPC │ Yes │ Yes │ Shared infra, but separate connections │
│ provider │ │ │ are fine │
├───────────────────────┼──────────────────────┼────────────────────────────────┼──────────────────────────────────────────┤
│ Source chain RPC │ Yes (read operator │ Yes (re-evaluate policy, L1 │ Shared infra │
│ provider │ set) │ relay) │ │
├───────────────────────┼──────────────────────┼────────────────────────────────┼──────────────────────────────────────────┤
│ ECDSA signer │ Owner key │ Any funded key │ No coupling │
│ │ (privileged) │ (permissionless) │ │
├───────────────────────┼──────────────────────┼────────────────────────────────┼──────────────────────────────────────────┤
│ Nonce sequence │ Owner key nonces │ Separate key nonces │ No coupling │
└───────────────────────┴──────────────────────┴────────────────────────────────┴──────────────────────────────────────────┘
Since the challenger can use a completely separate (even a throwaway funded) key, the nonce contention problem disappears. The
two strongest arguments for combining were:
1. ~~Shared nonce sequence~~ — Not a real constraint
2. Shared RPC connections — Marginal cost saving, not architectural
What This Means for the Architecture Decision
The case for separating is now stronger than I originally presented. The remaining arguments for combining are purely operational
convenience (fewer containers, single config), not fundamental architectural coupling.
That said, the decision still depends on how you weigh:
┌─────────────────────┬───────────────────────────────────────────────────────────┬─────────────────────────────────────────┐
│ Factor │ Combined │ Separated │
├─────────────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ Containers per │ 1 │ 2 │
│ chain │ │ │
├─────────────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ Key management │ 1 key per chain (transport = owner key, challenge = same │ 2 keys per chain (owner key + │
│ │ or different) │ challenger key) │
├─────────────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ ZK prover blast │ Must isolate internally (catch_unwind) │ Process-level isolation (free) │
│ radius │ │ │
├─────────────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ Operational │ Better │ Worse │
│ simplicity │ │ │
├─────────────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ Independent scaling │ Requires internal tuning │ Natural (give challenger more memory) │
├─────────────────────┼───────────────────────────────────────────────────────────┼─────────────────────────────────────────┤
│ Code clarity │ Mixed concerns in one binary │ Clean single-responsibility │
└─────────────────────┴───────────────────────────────────────────────────────────┴─────────────────────────────────────────┘
The per-chain signer requirement you chose doesn't force combination. It's a genuine trade-off now, not a forced hand. Would you
like me to revise the recommendation with this corrected understanding, or does this change your thinking on the combined vs.
separated question?
✻ Worked for 51s
❯ let's actually just separate the two into separate
⏺ Good call. With permissionless challenge submission, there's no architectural reason to keep them coupled. Let me lay out the
clean separation design.
★ Insight ─────────────────────────────────────
Separation is natural here because the two services have different operational profiles: the transporter is a low-frequency,
privileged writer (owner key, daily syncs), while the challenger is a high-frequency, permissionless monitor (any key, every task
response). Different keys, different timing, different failure modes — classic case for separate processes.
─────────────────────────────────────────────────
Separated Architecture
flowchart TB
subgraph source["SOURCE CHAIN (Ethereum/Sepolia)"]
OPS["Operator Set State<br/>(BN254TableCalculator,<br/>NewtonCrossChainRegistry)"]
L1_TM["NewtonProverTaskManager<br/>(slashing via raiseAndResolveChallenge)"]
end
subgraph per_chain["PER DESTINATION CHAIN"]
subgraph transporter["Transporter Container"]
CALC["WeightCalculator"]
UPD["TableUpdater"]
SRC_L["SourceChainListener<br/>(operator set events)"]
TX_T["Tx Submitter<br/>(owner key)"]
end
subgraph challenger["Challenger Container"]
DST_L["DestChainListener<br/>(NewTask + TaskResponded)"]
EVAL["ChallengeEvaluator<br/>(re-evaluate policy)"]
ZK["ZK Prover<br/>(SP1, spawn_blocking)"]
SUB_D["Dest ChallengeSubmitter<br/>(any funded key)"]
SUB_L1["L1 ChallengeRelayer<br/>(any funded key on source)"]
end
end
subgraph dest["DESTINATION CHAIN (L2)"]
OTU["ECDSAOperatorTableUpdater<br/>(onlyOwner)"]
DEST_TM["NewtonProverDestTaskManager<br/>(permissionless challenge)"]
end
OPS --> CALC
SRC_L --> CALC
CALC --> UPD
UPD --> TX_T
TX_T --> OTU
DEST_TM -->|"NewTask / TaskResponded events"| DST_L
DST_L --> EVAL
EVAL --> ZK
ZK --> SUB_D
SUB_D --> DEST_TM
SUB_D -->|"on success"| SUB_L1
SUB_L1 --> L1_TM
What Changes in the Codebase
┌─────────────────────────────────────┬──────────────────────────────────────────────────────┬───────────────────────────────┐
│ Component │ Current State │ Target State │
├─────────────────────────────────────┼──────────────────────────────────────────────────────┼───────────────────────────────┤
│ │ Contains transport + embedded challenger │ Transport only — remove │
│ crates/transporter/ │ (challenge_evaluator.rs, challenge_submitter.rs, │ challenge modules │
│ │ dest_listener.rs) │ │
├─────────────────────────────────────┼──────────────────────────────────────────────────────┼───────────────────────────────┤
│ │ Source-chain challenger only (Challenger struct) + │ Shared evaluation logic stays │
│ crates/challenger/ │ shared evaluate_and_prove() │ here; add dest-chain │
│ │ │ challenger mode │
├─────────────────────────────────────┼──────────────────────────────────────────────────────┼───────────────────────────────┤
│ bin/newton-prover-transporter/ │ Runs transporter::run() which does both │ Transport only │
├─────────────────────────────────────┼──────────────────────────────────────────────────────┼───────────────────────────────┤
│ New: │ Doesn't exist │ New binary — dest-chain │
│ bin/newton-prover-dest-challenger/ │ │ challenger with L1 relay │
└─────────────────────────────────────┴──────────────────────────────────────────────────────┴───────────────────────────────┘
Key Design Decisions
1. Where does evaluate_and_prove() live?
It stays in crates/challenger/ as a shared free function. Both the source-chain Challenger struct and the new dest-chain
challenger import it. No crate dependency changes needed for the dest challenger binary — it already depends on
newton-prover-challenger.
The transporter crate drops its dependency on newton-prover-challenger entirely.
2. Three separate signer keys per chain
┌─────────────────────┬─────────────────┬─────────────────────────────────────┬──────────────┐
│ Key │ Used By │ Access Level │ Chain │
├─────────────────────┼─────────────────┼─────────────────────────────────────┼──────────────┤
│ Transport owner key │ Transporter │ Privileged (onlyOwner) │ Dest chain │
├─────────────────────┼─────────────────┼─────────────────────────────────────┼──────────────┤
│ Dest challenger key │ Dest challenger │ Permissionless (any funded address) │ Dest chain │
├─────────────────────┼─────────────────┼─────────────────────────────────────┼──────────────┤
│ L1 relay key │ Dest challenger │ Permissionless (any funded address) │ Source chain │
└─────────────────────┴─────────────────┴─────────────────────────────────────┴──────────────┘
The transport owner key is the most sensitive (can push arbitrary operator state). The challenger keys just need gas — they can
be hot wallets.
3. Transporter config simplifies
# transporter-arbitrum.toml (transport only, no challenger flags)
update_frequency = 86400
operator_set_id = 0
[dest_chain]
chain_id = 421614
rpc_url = "wss://arb-sepolia.g.alchemy.com/v2/..."
[source_chain]
chain_id = 11155111
rpc_url = "wss://eth-sepolia.g.alchemy.com/v2/..."
[signer]
private_key_env = "TRANSPORTER_ARB_OWNER_KEY"
4. New dest-challenger config
# dest-challenger-arbitrum.toml
[dest_chain]
chain_id = 421614
rpc_url = "wss://arb-sepolia.g.alchemy.com/v2/..."
[source_chain]
chain_id = 11155111
rpc_url = "wss://eth-sepolia.g.alchemy.com/v2/..."
[signer]
# Permissionless — any funded key
private_key_env = "CHALLENGER_ARB_KEY"
[l1_relay_signer]
# For relaying challenges to source chain
private_key_env = "L1_RELAY_KEY"
[ipfs]
gateway_url = "https://gateway.pinata.cloud"
Files to Create / Modify / Remove
┌──────────┬────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────┐
│ Action │ File │ Notes │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Remove │ crates/transporter/src/challenge_evaluator.rs │ Moves to dest challenger │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Remove │ crates/transporter/src/challenge_submitter.rs │ Moves to dest challenger │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Remove │ crates/transporter/src/dest_listener.rs │ Moves to dest challenger │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Simplify │ crates/transporter/src/lib.rs │ Remove enable_challenger flag, challenge event loop arms, │
│ │ │ ChallengeEvaluator │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Simplify │ crates/transporter/src/config.rs │ Remove enable_challenger field │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Simplify │ crates/transporter/Cargo.toml │ Drop newton-prover-challenger dependency │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Create │ bin/newton-prover-dest-challenger/ │ New binary entry point │
├──────────┼────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┤
│ Create │ crates/dest-challenger/ (or expand │ Dest-chain challenge logic + L1 relay │
│ │ crates/challenger/) │ │
└──────────┴────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────┘
Open Question: New Crate or Expand Existing?
Two ways to structure the dest-chain challenger:
┌───────────────────────────────────┬─────────────────────────────────────────┬─────────────────────────────────────────────┐
│ Approach │ Pros │ Cons │
├───────────────────────────────────┼─────────────────────────────────────────┼─────────────────────────────────────────────┤
│ Expand crates/challenger/ with a │ Shared evaluate_and_prove() stays │ Mixes source-chain and dest-chain concerns │
│ dest module │ in-crate, less duplication │ in one crate │
├───────────────────────────────────┼─────────────────────────────────────────┼─────────────────────────────────────────────┤
│ New crates/dest-challenger/ crate │ Clean separation, independent │ evaluate_and_prove() either moves to core │
│ │ versioning │ or is imported cross-crate │
└───────────────────────────────────┴─────────────────────────────────────────┴─────────────────────────────────────────────┘
⏺ User answered Claude's questions:
⎿  · How should we structure the dest-chain challenger code? → Expand crates/challenger/
⏺ Good choice — keeps evaluate_and_prove() in-crate with zero dependency changes for the shared logic.
Final Architecture Summary
flowchart TB
subgraph crates["Crate Structure"]
direction TB
subgraph challenger_crate["crates/challenger/"]
shared["evaluate_and_prove()<br/>(shared free function)"]
src_mod["src/source.rs<br/>Source-chain Challenger struct<br/>(existing logic, renamed)"]
dest_mod["src/dest.rs (new)<br/>DestChainChallenger struct<br/>• DestChainListener<br/>• ChallengeEvaluator<br/>•
ChallengeSubmitter<br/>• L1ChallengeRelayer"]
end
subgraph transporter_crate["crates/transporter/"]
transport["Transport only<br/>• WeightCalculator<br/>• TableUpdater<br/>• SourceChainListener"]
end
end
subgraph bins["Binaries"]
bin_src["bin/newton-prover-challenger/<br/>(source chain, existing)"]
bin_dest["bin/newton-prover-dest-challenger/<br/>(dest chain, new)"]
bin_trans["bin/newton-prover-transporter/<br/>(transport only, simplified)"]
end
src_mod --> bin_src
dest_mod --> bin_dest
transport --> bin_trans
shared --> src_mod
shared --> dest_mod
Per Destination Chain: Two Containers
flowchart LR
subgraph arb["Arbitrum Sepolia"]
T_ARB["Transporter<br/>Owner key<br/>Daily sync + event-driven"]
C_ARB["Dest Challenger<br/>Hot wallet key<br/>Continuous monitoring"]
end
subgraph op["OP Sepolia"]
T_OP["Transporter<br/>Owner key<br/>Daily sync + event-driven"]
C_OP["Dest Challenger<br/>Hot wallet key<br/>Continuous monitoring"]
end
SRC["Source Chain<br/>(Sepolia)"] --> T_ARB
SRC --> T_OP
SRC --> SRC_C["Source Challenger<br/>(single instance)"]
C_ARB -.->|"L1 relay"| SRC
C_OP -.->|"L1 relay"| SRC
Deployment Summary for 8 Chains
┌──────────────────┬──────────────────────┬─────────────────────────────────────────────────────┬────────────────────────────┐
│ Service │ Instances │ Key Type │ Key Count │
├──────────────────┼──────────────────────┼─────────────────────────────────────────────────────┼────────────────────────────┤
│ Source │ 1 │ Any funded key (source chain) │ 1 │
│ Challenger │ │ │ │
├──────────────────┼──────────────────────┼─────────────────────────────────────────────────────┼────────────────────────────┤
│ Transporter │ 8 (one per dest │ Owner key (privileged, per dest chain) │ 8 │
│ │ chain) │ │ │
├──────────────────┼──────────────────────┼─────────────────────────────────────────────────────┼────────────────────────────┤
│ Dest Challenger │ 8 (one per dest │ Hot wallet (permissionless, per dest chain) + L1 │ 8 dest + 1 shared L1 relay │
│ │ chain) │ relay key │ = 9 │
├──────────────────┼──────────────────────┼─────────────────────────────────────────────────────┼────────────────────────────┤
│ Total │ 17 containers │ │ 18 keys │
└──────────────────┴──────────────────────┴─────────────────────────────────────────────────────┴────────────────────────────┘
The L1 relay key can be shared across all dest challengers since raiseAndResolveChallenge is permissionless on the source chain
too — nonce contention is the only concern, but challenge relays are rare events so contention is negligible in practice.
★ Insight ─────────────────────────────────────
The separation creates a clean security boundary: transport owner keys are high-value targets (can push malicious operator
state), while challenger keys are low-value (can only submit valid ZK-proven challenges). This lets you put transport keys in
HSMs/multi-sig while keeping challenger keys as simple hot wallets, matching the risk profile of each operation.
─────────────────────────────────────────────────
Crate File Layout After Refactor
crates/challenger/
├── src/
│ ├── lib.rs # evaluate_and_prove() + module exports
│ ├── config.rs # ChallengerConfig (source) + DestChallengerConfig (new)
│ ├── error.rs # ChallengerError (shared)
│ ├── source.rs # Existing Challenger struct (renamed from current lib.rs logic)
│ └── dest/
│ ├── mod.rs # DestChainChallenger struct + run loop
│ ├── listener.rs # DestChainListener (moved from transporter/dest_listener.rs)
│ ├── evaluator.rs # ChallengeEvaluator (moved from transporter/challenge_evaluator.rs)
│ ├── submitter.rs # ChallengeSubmitter (moved from transporter/challenge_submitter.rs)
│ └── l1_relay.rs # L1ChallengeRelayer (new — completes the TODO)
├── challenger.toml
└── Cargo.toml
crates/transporter/
├── src/
│ ├── lib.rs # run() — transport_update loop only (simplified)
│ ├── config.rs # TransporterConfig — no enable_challenger field
│ ├── calculator.rs # WeightCalculator (unchanged)
│ ├── updater.rs # TableUpdater (unchanged)
│ └── listener.rs # SourceChainListener (unchanged)
├── transporter.toml
└── Cargo.toml
bin/newton-prover-dest-challenger/
├── src/
│ └── main.rs # Instantiates DestChainChallenger, runs event loop
└── Cargo.toml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment