Skip to content

[MEL] - Implement a Unified Replay Binary for MEL#4553

Open
rauljordan wants to merge 18 commits intomasterfrom
raul/mel-unified-binary
Open

[MEL] - Implement a Unified Replay Binary for MEL#4553
rauljordan wants to merge 18 commits intomasterfrom
raul/mel-unified-binary

Conversation

@rauljordan
Copy link
Copy Markdown
Contributor

@rauljordan rauljordan commented Mar 23, 2026

This PR brings over changes from #4152 into master.

Summary

  • Introduces a new unified-replay binary (cmd/unified-replay/) that combines both MEL (Message Extraction Layer) message extraction and block production into a single WASM-compilable replay program
  • Adds a new GetEndParentChainBlockHash host I/O opcode to support MEL proving
  • Adds the melwavmio package providing WASM imports and native stubs for MEL
  • Updates the Makefile with build targets for the unified replay WASM binary

How it Works

The unified replay binary is quite easy to understand in pythonic pseudocode. It essentially either runs message extraction or block production depending on some of the input or computed values. This means that validation of Arbitrum becomes a message extraction followed by execution of those extracted messages:

def main():
    # Initialize MEL WASM I/O stubs and hook into geth
    melwavmio.stub_init()
    require_hooked_geth()
    setup_logger(level=ERROR)
    
    # Pre-warm ECDSA signature verification caches
    populate_ecdsa_caches()

    # Read initial state from the machine's global state slots
    mel_msg_hash = melwavmio.get_mel_msg_hash()        # bytes32 slot 3
    start_mel_state_hash = melwavmio.get_start_mel_root()  # bytes32 slot 2
    mel_state = rlp_decode(read_preimage(start_mel_state_hash))

    # === MODE 1: Block Production (when there's a message to execute) ===
    if mel_msg_hash != ZERO_HASH:
        produce_block(mel_msg_hash)
        melwavmio.increase_position_in_mel()  # pos++
    
    # === MODE 2: Message Extraction (walk parent chain to find new messages) ===
    else:
        target_block_hash = melwavmio.get_end_parent_chain_block_hash()
        chain_config = read_chain_config_from_last_block()
        mel_state = extract_messages_up_to(chain_config, mel_state, target_block_hash)
        melwavmio.set_end_mel_root(mel_state.hash())

    # === Schedule next message (if any remain) ===
    position = melwavmio.get_position_in_mel()
    if mel_state.msg_count > position:
        next_msg = MessageReader(preimage_resolver).read(mel_state, position)
        melwavmio.set_mel_msg_hash(next_msg.hash())
    else:
        melwavmio.set_mel_msg_hash(ZERO_HASH)  # no more messages

Changes

New files

  • cmd/unified-replay/main.go — Unified replay entry point handling two modes: block production (when a MEL message hash is present) and message extraction (walking parent chain blocks backward to extract messages)
  • cmd/unified-replay/db.go — Preimage-backed database adapter for state trie lookups during replay
  • cmd/unified-replay/preimage_resolver.go — WAVM preimage resolver and blob reader implementations
  • melwavmio/ — MEL-specific WAVM I/O package with WASM imports

Modified files

  • Prover (Rust) — Extended GlobalState to 4 bytes32 slots with backward-compatible hashing, added GetEndParentChainBlockHash opcode, and made binary output filenames configurable
  • Makefile — Added build-unified-replay-env and build-unified-wasm-bin targets

@rauljordan rauljordan self-assigned this Mar 23, 2026
@rauljordan rauljordan assigned tsahee and unassigned rauljordan Mar 23, 2026
@rauljordan rauljordan requested review from eljobe and tsahee March 23, 2026 21:34
@codecov
Copy link
Copy Markdown

codecov Bot commented Mar 23, 2026

Codecov Report

❌ Patch coverage is 0% with 349 lines in your changes missing coverage. Please review.
✅ Project coverage is 33.99%. Comparing base (4676f43) to head (371044f).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4553      +/-   ##
==========================================
- Coverage   34.16%   33.99%   -0.18%     
==========================================
  Files         494      499       +5     
  Lines       58932    59271     +339     
==========================================
+ Hits        20137    20152      +15     
- Misses      35249    35581     +332     
+ Partials     3546     3538       -8     

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 23, 2026

❌ 15 Tests Failed:

Tests completed Failed Passed Skipped
4783 15 4768 0
View the top 3 failed tests by shortest run time
TestAliasingFlaky
Stack Traces | -0.000s run time
... [CONTENT TRUNCATED: Keeping last 20 lines]
INFO [04-06|20:15:35.324] Writing cached state to disk             block=1  hash=5c1883..a748de root=c349f6..9b02e2
INFO [04-06|20:15:35.324] Updated payload                          id=0x03dea2c59ecefe35                      number=46 hash=14f6f5..d5a64a txs=0  withdrawals=0 gas=0         fees=0              root=12ee91..0c354f elapsed="477.55µs"
INFO [04-06|20:15:35.324] Persisted trie from memory database      nodes=25  flushnodes=0 size=4.56KiB  flushsize=0.00B time="195.417µs" flushtime=0s gcnodes=0 gcsize=0.00B gctime=0s          livenodes=86  livesize=15.99KiB
INFO [04-06|20:15:35.324] Writing snapshot state to disk           root=77ae46..2fbcae
INFO [04-06|20:15:35.324] Persisted trie from memory database      nodes=0   flushnodes=0 size=0.00B    flushsize=0.00B time=721ns       flushtime=0s gcnodes=0 gcsize=0.00B gctime=0s          livenodes=86  livesize=15.99KiB
INFO [04-06|20:15:35.324] Stopping work on payload                 id=0x03dea2c59ecefe35                      reason=delivery
INFO [04-06|20:15:35.324] Blockchain stopped
INFO [04-06|20:15:35.324] Imported new potential chain segment     number=46 hash=14f6f5..d5a64a blocks=1  txs=0  mgas=0.000 elapsed="323.864µs" mgasps=0.000    triediffs=221.04KiB triedirty=0.00B
INFO [04-06|20:15:35.325] Chain head was updated                   number=46 hash=14f6f5..d5a64a root=12ee91..0c354f elapsed="19.417µs"
INFO [04-06|20:15:35.325] Ethereum protocol stopped
INFO [04-06|20:15:35.325] Submitted transaction                    hash=0x57e601445b3627d121f392223c7ddaf5b9f69d47135ad2f827a6de00c52de5fa from=0xaF24Ca6c2831f4d4F629418b50C227DF0885613A nonce=5  recipient=0xaF24Ca6c2831f4d4F629418b50C227DF0885613A value=1,000,000,000,000
INFO [04-06|20:15:35.325] Transaction pool stopped
INFO [04-06|20:15:35.325] Persisting dirty state                   head=33 root=23d214..94f403 layers=33
INFO [04-06|20:15:35.325] Starting work on payload                 id=0x039e3d1118fd95a4
INFO [04-06|20:15:35.325] Updated payload                          id=0x039e3d1118fd95a4                      number=47 hash=3958df..ac6673 txs=1  withdrawals=0 gas=21000     fees=0.002092911308 root=a60093..148e7e elapsed="294.453µs"
INFO [04-06|20:15:35.326] Persisted dirty state to disk            size=160.85KiB elapsed="866.214µs"
INFO [04-06|20:15:35.326] Stopping work on payload                 id=0x039e3d1118fd95a4                      reason=delivery
INFO [04-06|20:15:35.326] Blockchain stopped
INFO [04-06|20:15:35.326] Imported new potential chain segment     number=47 hash=3958df..ac6673 blocks=1  txs=1  mgas=0.021 elapsed="398.613µs" mgasps=52.683   triediffs=225.07KiB triedirty=0.00B
INFO [04-06|20:15:35.326] Chain head was updated                   number=47 hash=3958df..ac6673 root=a60093..148e7e elapsed="43.943µs"
TestPruningDBSizeReduction
Stack Traces | 0.000s run time
=== RUN   TestPruningDBSizeReduction
--- FAIL: TestPruningDBSizeReduction (0.00s)
TestBatchPosterL1SurplusMatchesBatchGasFlaky
Stack Traces | 0.560s run time
... [CONTENT TRUNCATED: Keeping last 20 lines]
panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x207e912]

goroutine 55 [running]:
testing.tRunner.func1.2({0x37e7220, 0x62039b0})
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1872 +0x237
testing.tRunner.func1()
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1875 +0x35b
panic({0x37e7220?, 0x62039b0?})
	/opt/hostedtoolcache/go/1.25.8/x64/src/runtime/panic.go:783 +0x132
github.com/offchainlabs/nitro/arbnode.(*InboxTracker).GetBatchCount(0x1e071900?)
	/home/runner/work/nitro/nitro/arbnode/inbox_tracker.go:210 +0x12
github.com/offchainlabs/nitro/arbnode.(*InboxTracker).FindInboxBatchContainingMessage(0x0, 0x8)
	/home/runner/work/nitro/nitro/arbnode/inbox_tracker.go:225 +0x2f
github.com/offchainlabs/nitro/system_tests.TestBatchPosterL1SurplusMatchesBatchGasFlaky(0xc0002c3180)
	/home/runner/work/nitro/nitro/system_tests/batch_poster_test.go:839 +0x725
testing.tRunner(0xc0002c3180, 0x41b9dc8)
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1934 +0xea
created by testing.(*T).Run in goroutine 1
	/opt/hostedtoolcache/go/1.25.8/x64/src/testing/testing.go:1997 +0x465

📣 Thoughts on this report? Let Codecov know! | Powered by Codecov

@rauljordan rauljordan assigned eljobe and unassigned tsahee Apr 15, 2026
Comment on lines +866 to +879
fn bytes32_last_non_zero_index(&self) -> usize {
let last_non_zero_idx = self
.bytes32_vals
.iter()
.enumerate()
.rev()
.find(|&(_, &val)| val != Bytes32::default())
.map(|(i, _)| i);

match last_non_zero_idx {
Some(idx) => std::cmp::max(1, idx),
None => 1,
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Critical] This backward-compat hashing trick is consensus-load-bearing: a legacy 2-slot state [H, S] must hash identically to a 4-slot state [H, S, 0, 0]. The logic here looks correct, but there are no tests asserting this invariant anywhere in crates/prover/src/crates/prover/src/test.rs contains only reject_reexports, reject_ambiguous_imports, and test_compress; none touch GlobalState.

A one-character regression here (e.g. max(1, idx)max(2, idx), or off-by-one on 0..=end_idx) silently invalidates every existing assertion proof, and the failure mode only surfaces during a challenge.

Please add a golden-vector unit test covering at minimum:

  • all-zeros
  • only slot 0 set
  • only slot 1 set
  • slot 2 set (new behavior, intermediate zeros preserved)
  • slot 3 set
  • legacy-vs-new equality: hash({a, b, 0, 0}) == hash_legacy({a, b})

~15 lines of Rust — cheapest possible risk reduction for a consensus invariant.

"github.com/offchainlabs/nitro/wavmio"
)

func main() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Critical] This PR adds ~800 lines of consensus-adjacent code (cmd/unified-replay/ + melwavmio/ + the new GetEndParentChainBlockHash WAVM opcode) with zero new test coverage. The melwavmio/stub.go build-tag split exists specifically to allow native (non-WASM) testing — that infrastructure is already in place, it's just not being used.

At minimum, please consider:

  • One Go integration test driving produceBlock and extractMessagesUpTo through the stub, seeded from the same preimage fixtures used by cmd/replay.
  • A Rust interpreter test for the new GetEndParentChainBlockHash opcode (nothing currently exercises the store_slice_aligned path at machine.rs:2519).

A bug that only surfaces inside the prover is very expensive to debug.

Comment thread melwavmio/stub.go
}

func StubFinal() {
log.Info("endMelStateHash", endMelStateHash.Hex())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Important] go-ethereum's log.Info expects msg, key, value…. As written, endMelStateHash.Hex() is being parsed as a bare key with no value — the logger will emit a "missing value" formatter error rather than the hash you want to see.

Suggested change
log.Info("endMelStateHash", endMelStateHash.Hex())
log.Info("final MEL state hash", "endMelStateHash", endMelStateHash.Hex())

Comment on lines +639 to 651
newBalance, err := builder.L2.Client.BalanceAt(ctx, txOpts.From, nil)
if err == nil && newBalance.Cmp(expectedBalance) == 0 {
break
}
select {
case <-tick.C:
case <-timeout.C:
t.Fatalf("timed out waiting for MEL and TransactionStreamer to detect reorg")
latestBalance, _ := builder.L2.Client.BalanceAt(ctx, txOpts.From, nil)
t.Fatalf("timed out waiting for balance to reflect reorg handling: got %v, want %v (MEL reorg logged=%v, TxStreamer reorg logged=%v)",
latestBalance, expectedBalance,
logHandler.WasLogged("MEL detected L1 reorg"),
logHandler.WasLogged("TransactionStreamer: Reorg detected!"))
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Important] This polling loop silently swallows every BalanceAt error on each 100ms tick, and the timeout message at line 646 discards the error too (latestBalance, _). Pre-PR, the test fail-loud on RPC errors via t.Fatalf("BalanceAt... unexpected error: %v", err) — that signal is now lost, so a broken RPC client becomes an opaque 1-minute timeout.

Suggest tracking lastErr across ticks and surfacing it in the timeout message, e.g.:

var lastErr error
for {
    newBalance, err := builder.L2.Client.BalanceAt(ctx, txOpts.From, nil)
    if err == nil && newBalance.Cmp(expectedBalance) == 0 { break }
    if err != nil { lastErr = err }
    select {
    case <-tick.C:
    case <-timeout.C:
        latestBalance, balErr := builder.L2.Client.BalanceAt(ctx, txOpts.From, nil)
        t.Fatalf("... got %v (balErr=%v, lastPollErr=%v), want %v ...",
            latestBalance, balErr, lastErr, expectedBalance, ...)
    }
}

return currentState
}

// Extracts all block header hashes in the range from startHash to endHash.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Important] The doc is inclusivity-ambiguous. The actual semantics are half-open (startHash, endHash]endHash is included, startHash is excluded. Two edge cases are also silently undocumented: when startHash == endHash it returns nil, and on line 304 it panics if it walks past genesis without finding startHash.

Suggest:

// walkBackwards returns the parent-chain header hashes in the half-open range
// (startHash, endHash], ordered from endHash back to startHash's child.
// Returns nil if startHash == endHash.
// Panics if walking back reaches the zero hash without finding startHash.

Comment on lines +283 to +284
/// Sets the ending parent chain block hash used for a machine when executing message extraction
/// algorithms.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Suggestion] Thin for an FFI boundary. Doesn't document: what "end" means (inclusive upper bound? target to walk to?), that *const Bytes32 must be a readable 32-byte region the callee dereferences and copies, or that null/unaligned is UB. FFI contracts generally warrant explicit # Safety sections — suggest adding one, plus a sentence clarifying that "end parent chain block hash" is the inclusive end of the parent-chain block range over which MEL message extraction executes.

// Globalstate holds:
// bytes32 - last_block_hash
// bytes32 - send_root
// bytes32 - mel_state_hash
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Optional(nit): Naming inconsistency between Rust and Go sides — Rust calls this slot mel_state_hash, while melwavmio/higher.go:23 calls it IDX_MEL_ROOT, with GetStartMELRoot/SetEndMELRoot accessors. Indexes line up (0/1/2/3 on both sides) but a reader jumping between files has to translate. Happy either way, just flagging.

Comment on lines +862 to +865
/// Finds the last non-zero index in the bytes32 values and returns
/// the max of it and 1. This is used to determine how many
/// bytes32 values to include in the hash and serialization of a global state,
/// and at least the first two values must be included for backwards compatibility.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Suggestion] Doc is technically accurate but incomplete: when every bytes32 value is zero, find returns None and the function returns 1, not 0. The current comment doesn't cover that branch. Suggest:

/// Returns the index of the last non-zero bytes32 value, or 1 if all values
/// past index 1 are zero. Always returns at least 1, so the first two slots
/// (block_hash, send_root) are always serialized — preserving the pre-MEL
/// GlobalState hash format for backwards compatibility.

// Globalstate holds:
// bytes32 - last_block_hash
// bytes32 - send_root
// bytes32 - mel_state_hash
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Suggestion] The // Globalstate holds: line just above should match the type name GlobalState on line 821 — tiny typo worth fixing while this comment block is being touched.

Comment thread cmd/unified-replay/db.go
Comment on lines +33 to +42
var hash [32]byte
copy(hash[:], key)
if len(key) == 32 {
copy(hash[:], key)
} else if len(key) == len(rawdb.CodePrefix)+32 && bytes.HasPrefix(key, rawdb.CodePrefix) {
// Retrieving code
copy(hash[:], key[len(rawdb.CodePrefix):])
} else {
return nil, fmt.Errorf("preimage DB attempted to access non-hash key %v", hex.EncodeToString(key))
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[Suggestion] The unconditional copy(hash[:], key) on line 34 is dead code: if len(key) == 32, line 36 repeats the same copy; if it's the code prefix, line 39 overwrites; in the else branch at line 40 we return before hash is ever used. This mirrors the same pattern in cmd/replay/db.go:33-42, so it's pre-existing rather than introduced here — but since the file is brand new, good moment to drop the redundant line.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants