Conversation
stephenctw
commented
Mar 13, 2026
- L1 batch posting wired: EthereumBatchPoster signs and calls InputBox.addInput(app_address, payload).
- Batch payload format: 0x01 || batch_index (u64 BE) || ssz(Batch).
- S from InputBox events: input reader scans InputAdded from (prev_safe, Latest], using:
- Safe for when directs become persisted,
- Latest for advancing last_submitted_batch_index.
- S persisted in DB: submitted_batches_state.last_submitted_batch_index updated inside append_safe_direct_inputs.
- Worker uses DB S only: submits closed batches in (S ..= last_closed], at-least-once, no L1 reads.
- Config/docs cleaned up: no confirmations_depth; batch submitter config only has idle_poll_interval_ms and max_batches_per_loop.
GCdePaula
left a comment
There was a problem hiding this comment.
I've left some comments!
There was a problem hiding this comment.
The new batch-submitter config is reparsed inside the library runtime, so normal sequencer startup will reject its own CLI flags.
main already parses RunConfig, but run() then calls BatchSubmitterConfig::parse() again. That second Clap parser only knows about the submitter-specific flags, so it will see the normal sequencer flags (--eth-rpc-url, --domain-chain-id, etc.) as unknown arguments and exit the process. This should be threaded through RunConfig, not reparsed inside the runtime.
Indeed, the benchmarks just -f benchmarks/justfile all are no longer running because of this.
There was a problem hiding this comment.
The first real closed batch is treated as “already submitted” and will never be posted to L1.
initialize_open_state() creates batch 0, but the new submitted_batches_state singleton is bootstrapped to 0. The submitter then computes first_to_submit = latest_submitted + 1, so it starts at batch 1 and permanently skips closed batch 0. The tests even encode this behavior by asserting only batches 1 and 2 are submitted.
There was a problem hiding this comment.
I think the current “last submitted batch” approach is unsound for the at-least-once model we want.
Right now the system persists a monotonic submitted_batches_state cursor in SQLite, and that cursor is advanced from Latest-head observations in the input reader. That means a batch can briefly appear onchain, get recorded durably as “submitted”, then get reorged out before it is safe/final, and we will never retry it again because this worker treats the stored value as authoritative and starts from S + 1.
In other words, we’re turning an optimistic, reorgable observation into permanent truth.
I think the cleaner model is:
- keep the input reader as it was before, focused on what it already does well (safe direct-input ingestion);
- and make this submit worker fetch the latest submitted batch from the chain fresh on each loop iteration, keeping that state only in memory.
Concretely in this submit worker:
- wake up,
- read the current chain view for batch-submission inputs (maybe at
Latest - krather than rawLatest), - derive the highest submitted batch nonce,
- compare that with locally closed batches,
- submit any missing ones,
- then sleep and repeat.
That gives us the “at least once, maybe duplicates” behavior we want, without persisting reorg-prone state, and lets the next loop automatically recover from short reorgs or temporarily missing inclusions.
There was a problem hiding this comment.
...keeping that state only in memory.
My only concern is that if the sequencer restarts, it would resend all batches from the very first one right?
There was a problem hiding this comment.
...keep the input reader as it was before
Just to make sure that we want only direct input right, so the msg.sender is used to filter out the batches?
There was a problem hiding this comment.
Yes, exactly: the worker is meant to be stateless, but not blind.
On every loop iteration, it rebuilds its view from the two real sources of truth:
B: the blockchain view for our app’sInputAddedevents, up toLatest - kL: the local DB view of closed/ready batches produced by the inclusion lane
So on restart it does not resend everything from batch 0. It just fetches S from B again, compares with L, and resumes from there.
More concretely, the loop I have in mind is:
- wake up
- query
Bfor allInputAddedevents for our app up toLatest - k - from those, filter only the inputs whose
msgSenderis the batch submitter / sequencer sender - decode the batch nonce from those inputs and derive the highest contiguous submitted nonce
S(or “none yet” if nothing has been submitted) - query
Lfor the locally closed batchesC(all batches except the current open one) - compare
SwithC - if
Chas a suffix that is still missing fromB, submit that suffix (or better yet, just the first missing batch and leave the rest for the next iteration, which is probably cleaner) - sleep, then repeat
A few important properties fall out of this:
- Restart-safe: no persistent “submitted” cursor is needed, because
Sis recomputed from chain truth every time. - Reorg-safe: if a batch briefly appears and then gets reorged out, the next iteration just stops seeing it and will retry it.
- At-least-once: duplicates are fine, because the scheduler deduplicates by batch nonce.
- No optimistic state gets fossilized: we never promote a
Latest-head observation into durable DB truth.
And yes, on the second point: my suggestion is that the input reader should stay focused on direct-input ingestion only.
The reader’s job is to persist safe direct inputs.
The submitter’s job is different: it wants a fresh, possibly shallow, chain observation of which batch submissions have already been seen on L1.
Those are different concerns with different finality requirements, so I think it is cleaner to keep them separate rather than teaching the input reader about batch-submission tracking.
One small nuance: I would use the highest contiguous observed batch nonce, not just the maximum seen nonce. That avoids treating a later observed batch as proof that all earlier batches were also included.
There are ways to optimize the state fetching, but let's do the naive implementation first of just re-fetching everything every loop.
There was a problem hiding this comment.
Got it, all make perfect sense now. But I'm worried when sequencer restarts, we may be querying a large block range that would need the partition design we have in the input-reader. What do you think?
There was a problem hiding this comment.
Agreed, and even worse it would need to do this large query every iteration of the loop. It needs the partition logic. Just like the PRT dispute fighter.
Let's do it naively first.
The obvious optimization is querying only in the range between the safe block stored in the db and the latest block of the blockchain. The full list of inputs then becomes the concatenation of the inputs in the db followed by the inputs we queried.
There was a problem hiding this comment.
I think this wire format is carrying more structure than it needs.
Right now we have two different ways to classify inputs:
- Scheduler:
metadata.msg_senderat the rollups layer - Batch Submitter: an explicit tag byte in the payload
So the scheduler already has the real protocol boundary available via msg_sender, and already treats the sequencer address differently from all other senders. So the tag feels redundant.
I’d simplify this to:
- classify by sender only
- move the batch nonce into
Batchitself, so that it becomesBatch { nonce, frames }
That would make the payload just:
ssz(Batch { nonce, frames })
instead of the current:
tag || nonce || ssz(Batch { frames })
I think that would give us a much cleaner boundary:
- one source of truth for “is this a batch?”
- one protocol object
- one decode path
It would also make the nonce part of the actual wire type, which feels more honest than keeping it as an out-of-band prefix.
18d9c6a to
37dfcf9
Compare
2c3a53a to
a014506
Compare
a014506 to
1238b1a
Compare
|
Latest change:
|
GCdePaula
left a comment
There was a problem hiding this comment.
I've left a couple of comments!
sequencer/src/partition.rs
Outdated
|
|
||
| /// Lazy default, then overwritten by `init()`. No leak: default is an empty config; | ||
| /// when `init()` is called we replace it with the real config. | ||
| static CONFIG: OnceLock<RwLock<PartitionConfig>> = OnceLock::new(); |
There was a problem hiding this comment.
Not a fan of this global variable approach. I think it also allows one runtime instance silently change another instance’s RPC retry behavior.
We should either always pass down the config in the get_input_added_events function, or transform this whole module into an object/structure, that has the long_block_range_error_codes as one of its fields, along with the provider, app_address_filter and input_box_address.
sequencer/src/input_reader/logs.rs
Outdated
| error_message_matches_retry_codes(&format!("{err:?}"), codes) | ||
| } | ||
|
|
||
| pub(crate) fn decode_evm_advance_input(input: &[u8]) -> Result<EvmAdvanceCall, String> { |
There was a problem hiding this comment.
I think we could also move this function to a shared module, possibly to the partition.rs. This whole file might not make sense anymore too.