Skip to content

feat!(ethexe): transition of value claims to merkle root#5415

Open
StackOverflowExcept1on wants to merge 138 commits into
masterfrom
av/ethexe-value-claims-merkle-root
Open

feat!(ethexe): transition of value claims to merkle root#5415
StackOverflowExcept1on wants to merge 138 commits into
masterfrom
av/ethexe-value-claims-merkle-root

Conversation

@StackOverflowExcept1on
Copy link
Copy Markdown
Member

@StackOverflowExcept1on StackOverflowExcept1on commented May 8, 2026

Closes #5442
Closes #5463

Summary

Transition to merkle roots, but only for value claims for now

TODO

  • add support for RPC and client methods for generating merkle proofs

How to test

See tests that use merkle proofs to claim value

Notes

This will break state of programs on Ethereum, but will not break them in database

Checklist

  • PR title follows Conventional Commits (type(scope): description)
  • Single logical change
  • Tests added or updated (if logic changed)
  • Docs updated (if needed)

@StackOverflowExcept1on StackOverflowExcept1on self-assigned this May 8, 2026
@StackOverflowExcept1on StackOverflowExcept1on added scope: vara.eth Vara Ethereum application layer (L2) ci: full Run full CI matrix (all platforms and profiles) labels May 8, 2026
@semanticdiff-com
Copy link
Copy Markdown

semanticdiff-com Bot commented May 8, 2026

Review changes with  SemanticDiff

Changed Files
File Status
  ethexe/ethereum/abi/WrappedVara.json  100% smaller
  ethexe/ethereum/abi/BatchMulticall.json  100% smaller
  ethexe/ethereum/abi/POAMiddleware.json  99% smaller
  ethexe/ethereum/abi/DemoCaller.json  97% smaller
  ethexe/ethereum/abi/Gear.json  95% smaller
  ethexe/ethereum/src/abi/mod.rs  50% smaller
  ethexe/node-loader/src/batch.rs  46% smaller
  ethexe/service/src/tests/mod.rs  24% smaller
  ethexe/ethereum/src/abi/gear.rs  10% smaller
  Cargo.lock Unsupported file format
  ethexe/common/Cargo.toml Unsupported file format
  ethexe/common/src/gear.rs  0% smaller
  ethexe/contracts/src/IMirror.sol Unsupported file format
  ethexe/contracts/src/Mirror.sol Unsupported file format
  ethexe/contracts/src/libraries/BinaryMerkleTree.sol Unsupported file format
  ethexe/contracts/src/libraries/Gear.sol Unsupported file format
  ethexe/contracts/test/Base.t.sol Unsupported file format
  ethexe/contracts/test/POC.t.sol Unsupported file format
  ethexe/ethereum/Cargo.toml Unsupported file format
  ethexe/ethereum/abi/Middleware.json Unsupported file format
  ethexe/ethereum/abi/Mirror.json Unsupported file format
  ethexe/ethereum/abi/Router.json Unsupported file format
  ethexe/ethereum/src/mirror/mod.rs  0% smaller
  ethexe/sdk/src/mirror.rs  0% smaller
  utils/gear-workspace-hack/Cargo.toml Unsupported file format

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request transitions the value claim mechanism in the ethexe system from using arrays to Merkle roots. This change optimizes the data structure for value claims, allowing for more efficient verification via Merkle proofs. Please note that this update introduces a breaking change for existing program states on Ethereum, though it does not affect the underlying database state.

Highlights

  • Merkle Root Transition: Transitioned value claims from arrays to Merkle roots to improve verification efficiency and scalability.
  • Smart Contract Updates: Implemented the claimValue function in the Mirror contract, enabling users to claim values using Merkle proofs.
  • Dependency Integration: Integrated the binary-merkle-tree library to handle the generation and verification of Merkle proofs across the codebase.
Ignored Files
  • Ignored by pattern: ethexe/ethereum/abi/*.json (8)
    • ethexe/ethereum/abi/BatchMulticall.json
    • ethexe/ethereum/abi/DemoCaller.json
    • ethexe/ethereum/abi/Gear.json
    • ethexe/ethereum/abi/Middleware.json
    • ethexe/ethereum/abi/Mirror.json
    • ethexe/ethereum/abi/POAMiddleware.json
    • ethexe/ethereum/abi/Router.json
    • ethexe/ethereum/abi/WrappedVara.json
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@StackOverflowExcept1on StackOverflowExcept1on marked this pull request as draft May 8, 2026 07:36
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request transitions the ethexe value claim mechanism from a push-based model to a pull-based model using Merkle proofs. It introduces a BinaryMerkleTree Solidity library, updates the Mirror contract to store Merkle roots of claims, and adds a claimValue function for users to claim funds individually. Feedback was provided to ensure that failed Ether transfers in claimValue revert the transaction to allow for retries and prevent permanent fund locking. Additionally, a comment in the BinaryMerkleTree library was flagged as misleading, noting that commutative hashing would break compatibility with the Rust-side root calculation.

Comment thread ethexe/contracts/src/Mirror.sol Outdated
Comment thread ethexe/contracts/src/libraries/BinaryMerkleTree.sol
@StackOverflowExcept1on StackOverflowExcept1on changed the title feat(ethexe): transition of value claims to merkle root feat!(ethexe): transition of value claims to merkle root May 8, 2026
grishasobol and others added 22 commits May 11, 2026 10:12
…oundation)

Restores the foundational types from commit 4138374:
- ReplyInfo::to_hash() in gear-core
- PromiseEmissionMode in ethexe-common primitives
- CompactPromise / SignedCompactPromise + helpers in ethexe-common
- Mock impls for Promise
- InjectedStorage{RO,RW} extended with promise/compact_promise getters and setters
- Database/RawDatabase impls for the new storage methods

This is the foundation that downstream pieces (processor BoundPromiseSink,
compute PromisePolicy wiring, modular RPC injected API, gossipsub compact-promise
topic) build on. The marker comments for those follow-ups remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply +_+_+ rename markers across Rust + Solidity sources:
- `AnnouncesCommitted{,Event}` → `MBCommitted{,Event}` (Rust struct, enum variant, Solidity event)
- `LastAdvancedEthBlockCommitted{,Event}` → `EBCommitted{,Event}` (Rust struct, enum variant, Solidity event)
- `ANNOUNCES_COMMITTED` → `MB_COMMITTED`, `LAST_ADVANCED_ETH_BLOCK_COMMITTED` → `EB_COMMITTED` (constants)
- `announces_committed` → `mb_committed`, `last_advanced_eth_block_committed` → `eb_committed` (methods)
- `last_committed_advanced_eth_block` → `last_committed_eb` (field in BlockMeta / PreparedBlockData)
- `last_advanced_block` → `last_advanced_eb` (field in MbMeta)
- `latest_synced_block` → `latest_synced_eb`, `latest_prepared_block_hash` → `latest_prepared_eb_hash` (DBGlobals)
- `CompactBlock` → `CompactMB` (struct)
- camelCase counterparts in Solidity

Forge build regenerated ABI JSON for Router/Mirror/Middleware/POAMiddleware/WrappedVara.
`ensure_types_unchanged` hash bumped to reflect the type-name changes (SCALE encoding unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The module owns Malachite-sequencer application types (`Transaction`,
`Transactions`, etc.) so `malachite` is the more accurate name. Updates
all import paths and the `ensure_types_unchanged` hash for the new
module path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The validator state more accurately is the "idle" state (waiting for
chain head, then sync, then prepare before electing a role). Renames:
- module `wait_for_eth_block` → `idle`
- struct `WaitForEthBlock` → `Idle`
- display tag `WAIT_FOR_ETH_BLOCK` → `IDLE`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rate-stepping promise-latency experiment runner lives on a separate
branch now; drop it from the main branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `Migration` trait and `migrate()` driver that walks ascending
schema versions in `migrations()`. The vector is empty for now — the
old v1/v2/... entries had no live consumers and were removed earlier.

Wire `migrate()` into `initialize_db()` so an on-disk version below
`LATEST_VERSION` runs the framework instead of bailing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The handler now reads `mb_program_states(at)` and returns the set of
`ActorId`s present there, instead of warning and returning an empty
response. Added `MbStorageRO` to the `DbSyncDatabase` trait so the
network layer can reach the MB stores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MB-driven path has no active consumers of db_sync responses: every
incoming response was being logged-and-dropped, and every failure was
just re-issued forever. Drop the `network_fetcher` future, the
`Event::Fetching` variant, and their imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renumbers the test list to mirror master's ordering for tests common to
both branches; new MB-specific tests (`multiple_validators_ping`,
`reorg_within_quarantine`, `reorg_deeper_than_quarantine`) slot in
where their thematic neighbors live.

Also drop the now-stale `Event::Fetching` variant from the
`TestingEvent` mirror — it was already gone from the runtime enum.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`MalachiteService::new` now takes `Option<PublicKey>`:
- `Some(pk)` → `NodeRole::Validator`, validator key extracted from the signer.
- `None` → `NodeRole::FullNode`, ephemeral secret used only as the libp2p peer identity.

The service's call site no longer skips Malachite when no validator key
is configured, so connect/full nodes also join the gossip mesh and
receive proposals + sync responses. Test harness call sites updated to
pass `Some(pub_key)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move `db.set_injected_transaction(tx)` into `InjectedTxMempool::insert`
so the network and RPC handlers don't have to remember to do it
themselves. The two service-side call sites lose the duplicated
persistence calls.

Persistence happens before pool insertion so a producer that picks the
tx on the very next round is guaranteed to find it via
`injected_getTransactions`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ethexe check --computation` now walks `latest_finalized_mb_hash`
back through `CompactMB.parent` and asserts that every MB has its
cached `mb_program_states` / `mb_outcome` / `mb_schedule` records
plus `MbMeta { computed: true }`.

Re-execution through the processor (asserting cached records match
fresh execution) is intentionally left out — it requires loading
every code blob and reconstructing the runtime, which the CLI doesn't
have the context for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…k entry points

Add `StateDump::collect_from_mb_storage(storage, mb_hash, block_hash)`
as the malachite-native entry point — state lives per-MB, so callers
that already know which MB to dump shouldn't have to round-trip through
`BlockMeta::last_committed_mb`. The existing
`collect_from_storage(block_hash)` becomes a thin wrapper that derives
the MB and forwards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`WaitForUploadCode`, `WaitForProgramCreation`, and `WaitForReplyTo`
each had an inline `select! { sleep => evm_mine, … = wait => break }`
loop that duplicated the `KickingStream::find_map` kick mechanism.

Each `wait_for` now hands the `(provider, block_time*3)` kick to the
receiver via `set_kicks` before consuming it with
`filter_map_block_synced`; the resulting `KickingStream` triggers
`evm_mine` on idle automatically.

`KickExt` retains its `EventReceiver` impl so `WaitFor*` can mutate
kicks pre-conversion. Replaced the marker on `extend_malachite_endpoints`
with a TODO note — pulling endpoint setup forward into `TestEnv::new`
needs a wider tests refactor and is left for a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes `apis/injected.rs` to `apis/injected/` and splits it into:

- `mod.rs` — module entry, public re-exports
- `trait.rs` — JSON-RPC trait definition (`Injected`)
- `promise_manager.rs` — `PromiseSubscriptionManager` owns the
  `tx_hash → oneshot::Sender` map plus `try_register_subscriber`,
  `cancel_registration`, and `dispatch_promise`
- `relay.rs` — `TransactionsRelayer` does the per-validator fan-out
  and the single-recipient fallback when the era's validator vector
  isn't known yet
- `spawner.rs` — `spawn_pending_subscriber` bridges a registered
  subscriber to a jsonrpsee subscription sink with timeout / cleanup
- `server.rs` — `InjectedApi` orchestrates all of the above

Adds the `injected_getTransactionPromise` method that reads
`db.promise(tx_hash)` and `db.compact_promise(tx_hash)` and
reconstructs the `SignedPromise` via `SignedCompactPromise::restore`.
This is the user-facing payoff of the CompactPromise primitives added
in the foundation commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces every `Option<mpsc::UnboundedSender<Promise>>` field /
parameter in the processor with `Option<BoundPromiseSink>`. The sink
wraps an `UnboundedSender<(H256, Promise)>` and pre-binds each send to
the MB hash the executor is currently working on, so worker threads
no longer have to thread the binding through manually.

Ripples:
- `host::threads::ThreadParams.promise_out_tx` → `promise_sink`
- `clear_promise_out_tx` → `clear_promise_sink`
- `CommonRunContext.promise_out_tx` → `promise_sink`
- compute's per-MB channel now carries `(H256, Promise)`; the
  `MbPromisesStream` reads the hash from each message instead of
  storing it in a field
- compute constructs `BoundPromiseSink::new(sender, target_hash)` for
  the target MB and passes `None` for predecessor MBs (unchanged
  semantics)
- processor unit tests upgrade their channel to `(H256, Promise)` and
  wrap senders in `BoundPromiseSink::new(_, H256::zero())`

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…h compute_mb

Closes the remaining `+_+_+` marker on `MbComputeRequest`:
- `MbComputeRequest` gains a `promise_policy: PromisePolicy` field
- `ComputeSubService::receive_mb(mb_hash, policy)` records it
- `compute()` constructs a `BoundPromiseSink` for the target MB only
  when `policy == Enabled`, preserving the rule that ancestor MBs
  always replay with promises disabled
- `ComputeService` carries a `PromiseEmissionMode` (default
  `ConsensusDriven`) and offers `with_promise_mode(...)`. In
  `AlwaysEmit` it overrides the per-call policy to `Enabled`
- service-side `compute_mb` call passes `PromisePolicy::Enabled`
  for `MalachiteEvent::BlockProposal` (consensus may still narrow
  it via the emission mode)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `cargo fmt --all` across all touched files (line-wrap
  reorganisation only).
- `Migration::from_version` → `source_version` (clippy:
  `wrong_self_convention`).
- ProgramIds db-sync handler uses `BTreeMap::into_keys()` instead of
  `into_iter().map(|(k, _)| k)` (clippy: `iter_kv_map`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…IONS

Splits the migrations module back into the historical shape:

- `migration.rs` — `Migration` trait (now `Sync` so the slice can be
  `&'static`). `migrate(&InitConfig, &RawDatabase)` returns a pinned
  boxed future so `&dyn Migration` is dyn-compatible.
- `v5.rs` — schema-version anchor. Holds `pub const VERSION: u32 = 5;`
  and nothing else. Future schema bumps add `v6.rs`, `v7.rs`, … each
  with their own `VERSION` and (where applicable) a
  `migration_from_v(N-1)` function.
- `mod.rs` — re-introduces the user-requested constants:
    pub const OLDEST_SUPPORTED_VERSION: u32 = v5::VERSION;
    pub const LATEST_VERSION: u32 = v5::VERSION;
    pub const MIGRATIONS: &[&dyn Migration] = &[];
    const _: () = assert!(
        (LATEST_VERSION - OLDEST_SUPPORTED_VERSION) as usize == MIGRATIONS.len(),
        "Wrong number of migrations available"
    );

  The const-assert guarantees every step in the supported version
  range has a corresponding migration entry — adding a new schema
  version without a migration step (or vice versa) now fails to
  compile. Both anchor constants point at `v5::VERSION` for now;
  splits into distinct OLDEST/LATEST anchors when the next migration
  lands.

- `init.rs` — `migrate(&config, &db).await` (was `migrate(&db)`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`EventReceiver<T>` no longer carries a `kicks: Option<…>` field nor
implements `KickExt`. Kicks live exclusively on the `KickingStream<S>`
wrapper, and `TestingEventReceiver` / `ObserverEventReceiver` are now
type aliases for `KickingStream<EventReceiver<…>>`.

Side effects:
- `channel<T>(db, kicks)` returns `(EventSender, KickingStream<EventReceiver<T>>)`.
- `KickingStream` gains `Debug + Clone` derives (the inner
  `EventReceiver` is `Debug + Clone`) and forwards `FusedStream` from
  its inner stream.
- `TestingEventReceiver::find_map_with_db` and `ObserverEventReceiver::
  filter_map_*` switch from `self.db`/`self.kicks` field access to
  `self.db()`/`self.take_kicks()` helpers on the wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request transitions the ethexe value claim mechanism to a pull-based model using Merkle proofs, involving updates to state transitions, Solidity contracts, and the addition of proof-related RPC endpoints. Feedback identifies several critical issues: the consensus logic fails to store outgoing actions during the validation path, which would prevent non-producer nodes from serving proofs; the CLI's --watch functionality is broken due to commented-out code; and the node loader's claim tracking is currently disabled with placeholders. These regressions must be addressed to maintain system statistics and user-facing functionality.

Comment on lines +111 to +132
if let Ok(Some(BatchCommitment {
chain_commitment: Some(ChainCommitment { transitions, .. }),
..
})) = batch_commitment.as_ref()
{
for StateTransition {
new_state_hash,
value_claims,
..
} in transitions
{
let mut outgoing_actions = vec![];

for value_claim in value_claims {
outgoing_actions.push(OutgoingAction::ValueClaim(value_claim.clone()));
}

let outgoing_actions: OutgoingActions = outgoing_actions.into();
self.db
.set_outgoing_actions(*new_state_hash, outgoing_actions);
}
}
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.

high

The logic for storing outgoing actions in the database is currently only implemented in the batch creation path (create_batch_commitment). It must also be included in the validation path (validate_batch_commitment) to ensure that all validators store these actions. Without this, non-producer nodes will be unable to serve RPC requests for Merkle proofs, which breaks the pull-based claim model for users connected to those nodes.

Comment thread ethexe/cli/src/commands/tx.rs Outdated
Comment thread ethexe/cli/src/commands/tx.rs Outdated
Comment thread ethexe/node-loader/src/batch.rs Outdated
grishasobol and others added 6 commits May 22, 2026 14:15
…outgoing_action tests

`Mirror._merkleRoots` is keyed per state hash and stores the merkle root of
`value_claims` committed in that specific transition. The follow-up "force-fold"
no-op submitted by these tests lands in an MB without claims, so
`mirror.query().state_hash()` returns a stateHash whose merkleRoot is bytes32(0)
and `processOutgoingAction` reverts with `OutgoingActionMerkleRootNotFound`
whenever the claim and the no-op end up in different MBs.

Use a `find_value_claim_proof` helper that walks the local Mirror.StateChanged
stream, probes `db.outgoing_actions(stateHash)`, and returns the stateHash
that actually carries the claim. The `mailbox` test additionally re-derives
the surrounding `mb_hash` via `mb_outcome` because Router.MBCommitted is
emitted after Mirror.StateChanged in the same eth block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@StackOverflowExcept1on StackOverflowExcept1on force-pushed the av/ethexe-value-claims-merkle-root branch from 413ba5b to f6d3dce Compare May 25, 2026 09:42
@StackOverflowExcept1on StackOverflowExcept1on changed the base branch from master to gsobol/ethexe/malachite-new May 25, 2026 09:43
@StackOverflowExcept1on
Copy link
Copy Markdown
Member Author

@claude opus review

@claude
Copy link
Copy Markdown

claude Bot commented May 25, 2026

Claude finished @StackOverflowExcept1on's task in 10m 52s —— View job


Code review — Opus

Reviewed against origin/gsobol/ethexe/malachite-new. The PR transitions value claims from arrays carried in events to a Merkle-root + pull-based model. Overall direction is sound; the Rust ↔ Solidity hash paths agree for value claims and the new program_outgoingActionMerkleProof RPC + SDK helpers compose cleanly. Most findings are about state that's only written on the producer path and stub code paths in the new on‑chain handler.


severity: criticaloutgoing_actions is only persisted in create_batch_commitment; non‑producer nodes can't serve proofs

File: ethexe/consensus/src/validator/batch/manager.rs lines 135‑156

set_outgoing_actions(new_state_hash, …) is only called from the coordinator's create_batch_commitment path. validate_batch_commitment reconstructs ChainCommitment (line ~338) but never writes the per-state outgoing actions to the local DB. The RPC at ethexe/rpc/src/apis/program.rs:294 (program_outgoingActionMerkleProof) reads from db.outgoing_actions(state_hash) and returns "Failed to read outgoing actions by hash" when missing, so any user whose node was not the producer for that batch cannot fetch a proof and mirror.processOutgoingAction(...) can't be assembled.

Since the actions are needed by every node that exposes RPC, persistence has to happen wherever mb_outcome lands (compute pipeline) or be mirrored in both batch paths. Persisting in only one path effectively partitions the pull‑based claim flow by which validator a user is connected to.

Fix this →


severity: highprocessOutgoingAction for non‑VALUE_CLAIM discriminants is a no-op that still marks the message processed

File: ethexe/contracts/src/Mirror.sol lines 686‑715

For MAILBOXED_MESSAGE and REPLY_MESSAGE, both the leaf-hash computation and the action body are // TODO. Today the flow is "fail-closed" only because outgoingActionHash is bytes32(0) and no real leaf will be zero — but:

  1. Control flow still writes _processedMessages[messageId] = true (line 701) before the discriminant switch, then runs the empty stub branch and returns true. Anyone who does find a way to land a zero-leaf in the merkle tree (e.g. via a future bug, or once these branches are filled in incorrectly), gets a permanent "processed" stamp with no value transfer.
  2. The path-length check (lines 651‑653) only enforces VALUE_CLAIM_SIZE == 69 for the value-claim discriminant; mailboxed/reply payloads pass through with no size enforcement.

Either gate the function so anything other than VALUE_CLAIM reverts (OutgoingActionDiscriminantUnsupported), or implement+test these branches in this PR. Leaving them as silent successes that mutate _processedMessages is dangerous as soon as the merkle-tree contents grow beyond pure value claims.


severity: highBinaryMerkleTree and processOutgoingAction are effectively untested

  • ethexe/contracts/test/ contains no test that exercises processOutgoingAction or BinaryMerkleTree.verifyProof[Calldata]. POC.t.sol only stubs merkleRoot: bytes32(uint256(2))/(3).
  • The Rust integration tests (ethexe/service/src/tests/mod.rs:53–88, find_value_claim_proof) explicitly assert actions.len() == 1 and return proof = Vec::<H256>::new(). Every claim test (mod.rs:766, 1049, 1210) only verifies the trivial single-leaf tree, where the verifier never iterates the proof loop.

The whole non-trivial algorithm — position parity, the "last node in odd-width row" lift case, width = (width-1)/2 + 1 recurrence, keccak256(a,b) vs keccak256(b,a) ordering — has zero coverage in either suite. The Rust side uses Substrate's binary_merkle_tree::{merkle_root_raw, merkle_proof_raw} from a vendored polkadot-sdk fork (Cargo.toml:381), and the Solidity verifier is a hand-rolled re-implementation. A Forge test with at least one multi‑leaf proof (3 and 5 leaves cover the lift edge case) and a Rust test that generates a real proof and verifies via mirror.process_outgoing_action(...) against Anvil should land with this PR before it merges. Without it, a future protocol change can silently desync Rust ↔ Solidity verification.


severity: medium_transferEther 5 000-gas budget breaks contract claimers in the pull‑based model

File: ethexe/contracts/src/Mirror.sol lines 708‑709, 1256‑1262

bool success = _transferEther(destination, value);
require(success, ValueClaimFailed(messageId, value));

_transferEther forwards only gas: 5_000. That budget made sense in the old push model where the Mirror called destinations during performStateTransition and a malicious receiver could DoS the state transition. In the new model the caller of processOutgoingAction pays gas and is typically the same party that receives the funds, so the DoS surface is gone but the limit is still applied. Common smart‑contract receivers (Gnosis Safe, ERC‑4337 wallets, anything with non‑trivial receive()) blow past 5 000 gas; for those destinations every claim attempt reverts on ValueClaimFailed and the user cannot ever pull their value out, even though their tx funded the gas. The funds aren't lost (the revert undoes _processedMessages[messageId] = true, so retries are possible), but no amount of retrying helps. Either forward all remaining gas (call{value: value}("") without an explicit cap), or expose a per-claim gas hint controlled by the caller.


severity: medium — comment for OFFSET3 constant is wrong

File: ethexe/contracts/src/Mirror.sol lines 123‑126

/**
 * @dev `DISCRIMINANT_SIZE + MESSAGE_ID_SIZE + DESTINATION_SIZE + VALUE_SIZE` offset.
 */
uint256 internal constant OFFSET3 = 53;

OFFSET3 = 53 = 1 + 32 + 20, i.e. it does not include VALUE_SIZE; in the body it's the offset of the value word, not past it. The inline comment at line 639 is correct ("skip discriminant, messageId, destination"); only the docstring at the constant definition is misleading. Easy to fix, and worth doing because the offsets are the most error-prone part of the parser.


Other observations (not findings)

  • Gemini's earlier comment about funds being permanently locked on transfer failure is no longer applicable — require(success, ValueClaimFailed(...)) does revert the _processedMessages write. The earlier --watch regression is also resolved by the local wait_for_value_claim helper in ethexe/cli/src/commands/tx.rs:1635 and Mirror::wait_for_value_claim_with_receipt in ethexe/sdk/src/mirror.rs:346.
  • state.outgoing_actions_counter is correctly bumped in both ReplyQueueingRequested and ValueClaimingRequested handlers (processor/src/handling/events.rs:160, 215) plus the schedule-driven remove_from_mailbox (runtime/common/src/schedule.rs:44). This keeps new_state_hash unique per claim and prevents merkle-root collisions across MBs that touch the same program memory.
  • The // TODO: consider optimizing this (use OpenZeppelin's commutativeKeccak256 instead) notes at BinaryMerkleTree.sol:73,146 should be removed: commutative hashing would break compatibility with Substrate's non-commutative binary-merkle-tree. Worth replacing with a positive note that asymmetry is required.

  • Read CLAUDE.md and .gemini/styleguide.md
  • Inspect PR diff against origin/gsobol/ethexe/malachite-new
  • Review Solidity changes (Mirror, BinaryMerkleTree, IMirror, Gear)
  • Review Rust changes (consensus, sdk, rpc, common, db, node-loader)
  • Compose findings and publish review
    · branch av/ethexe-value-claims-merkle-root

Base automatically changed from gsobol/ethexe/malachite-new to master May 25, 2026 16:59
}

#[allow(dead_code)]
pub async fn vara_eth_api(&self, ethereum: Ethereum) -> Option<VaraEthApi> {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We were planning to rewrite tests to SDK. I'd love to hear your thoughts on this. Since we're moving to RPC API for processOutgoingAction, we need method for creating SDK instance.

Copy link
Copy Markdown
Member

@breathx breathx left a comment

Choose a reason for hiding this comment

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

Design to be discussed

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

Labels

ci: full Run full CI matrix (all platforms and profiles) scope: vara.eth Vara Ethereum application layer (L2) type: feat New functionality or user-visible improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use Soldeer package manager Add Merkle-based verification for ETH value claims

4 participants