Skip to content

Espresso 3b: TEE batcher (re-hosted) - #459

Open
QuentinI wants to merge 38 commits into
celo-rebase-18from
espresso/batcher
Open

Espresso 3b: TEE batcher (re-hosted)#459
QuentinI wants to merge 38 commits into
celo-rebase-18from
espresso/batcher

Conversation

@QuentinI

@QuentinI QuentinI commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Based on #448

Pulls in the Espresso/TEE batcher .

  • In op-node: adds EspressoBatch type and marshaling logic for it. This is the datastructure that ends up posted to Espresso.
  • In espresso package: adds the CLI flags and interfaces for the streamer.
  • In the batcher: adds NSM helper in op-batcher/enclave/attestation.go and modifies the driver to add an Espresso path. Bulk of the changes is in espresso_-prefixed files.

This is #447, re-hosted from an in-repo branch (now properly stacked).

Comment thread op-batcher/batcher/service.go Outdated
Comment thread espresso/cli.go
Comment thread op-batcher/batcher/espresso_active.go
Comment thread op-batcher/batcher/espresso.go Outdated
@QuentinI
QuentinI force-pushed the espresso/batcher-fallback branch from f840eee to 12b46a6 Compare June 17, 2026 16:22
@QuentinI
QuentinI force-pushed the espresso/batcher-fallback branch from 12b46a6 to f8480f8 Compare June 17, 2026 16:27
@QuentinI
QuentinI force-pushed the espresso/batcher-fallback branch 3 times, most recently from 9330de1 to 1616378 Compare June 18, 2026 14:25
@QuentinI
QuentinI force-pushed the espresso/batcher branch 2 times, most recently from 6cf6a1f to eb6ff32 Compare June 18, 2026 16:40
@QuentinI
QuentinI force-pushed the espresso/batcher-fallback branch from 1616378 to 25a6c63 Compare June 18, 2026 16:40
Comment thread op-batcher/batcher/espresso_active.go
Comment thread op-batcher/batcher/espresso_service.go Outdated
Comment thread op-batcher/batcher/driver.go
Comment thread op-batcher/batcher/espresso.go Outdated
Comment thread op-batcher/batcher/driver.go Outdated
Comment thread op-node/rollup/derive/espresso_batch.go Outdated
// Sign represents the interface for signing things via eth_sign.
func (s *SignerClient) Sign(ctx context.Context, address common.Address, data []byte) ([]byte, error) {
var result hexutil.Bytes
if err := s.client.CallContext(ctx, &result, "eth_sign", address, data); err != nil {

@piersy piersy Jul 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This eth_sign call can't work against op-signer, and I don't think op-signer should be extended to make it work either.

op-signer doesn't serve eth_sign. Its server registers only two namespaces — eth (eth_signTransaction) and opsigner (signBlockPayload, signBlockPayloadV2): https://github.com/ethereum-optimism/infra/blob/main/op-signer/service/service.go#L73-L82. There is no arbitrary-data signing method. And this SignerClient can only talk to op-signer in the first place: NewSignerClient dials with op-signer's mutual-TLS and then handshakes with a health_status ping before returning, so pointing it at a plain geth or another HSM front-end fails at construction. So the call here errors method-not-found at runtime against a real op-signer.

The reason op-signer has no such method is deliberate, and it's why I'd argue against adding one. An HSM-backed signer must never sign raw bytes the caller hands it. If it did, a compromised batcher could pass a 32-byte value that is really the sighash of an L1 transaction spending the funded key, or a block payload for equivocation, and the HSM would sign it. That's why every op-signer method reconstructs the thing being signed server-side from typed arguments and binds a domain tag and chain id into the hash — see BlockPayloadArgs (domain, chainId, payloadBytes) and Message().ToSigningHash(). The client never sends a bare hash. Adding an eth_sign that signs any digest would remove exactly that protection for a key that also signs L1 transactions.

There's a second, backend-independent problem: eth_sign applies the EIP-191 prefix ("\x19Ethereum Signed Message:\n32" || hash), but the verify side recovers over the raw digest (crypto.SigToPub(batchHash, sig) in op-node/rollup/derive/espresso_batch.go):

batchHash := crypto.Keccak256(batchData)
signerKey, err := crypto.SigToPub(batchHash, signatureData)
So even a signer that did serve eth_sign would recover the wrong address and every batch would be rejected.

If remote HSM signing of Espresso batches is a requirement, the right shape is a purpose-built op-signer method modeled on signBlockPayload: the client sends typed args (a fixed domain tag, the L2 chain id / namespace, and the batch commitment), op-signer reconstructs the domain-separated digest and signs it with the HSM key, and op-node verifies the same digest. That also resolves the separate domain-separation gap (the batch digest is currently a bare keccak256(rlp(batch)) with no namespace binding). It is a change in the op-signer repo, so it can't land from this PR alone — until it exists, only the local private-key ChainSigner actually works. I'd suggest dropping this eth_sign helper and the clientSigner branch here rather than shipping a path that can't sign or verify.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Jean is going to look and respond here as he did this work.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the thorough writeup. The analysis is right about op-signer, and the fact that it reads as targeting op-signer at all is a documentation gap on our side, so let me fill in the missing context first.

This Sign call doesn't talk to op-signer. The --signer.endpoint it's deployed against is espresso-kms-signer (https://github.com/EspressoSystems/espresso-kms-signer), a small AWS KMS signing sidecar we built specifically to speak the signer protocol this batcher uses: health_status, eth_signTransaction, and eth_sign (https://github.com/EspressoSystems/espresso-kms-signer/blob/758e4d0/src/rpc.rs#L26-L46). It runs as an ECS sidecar next to op-batcher-tee and has done full batch-posting cycles on our kms test devnets (batches accepted on L1 and on HotShot). On the client side, NewSignerClient isn't actually op-signer-specific: mTLS only kicks in when tlsConfig.Enabled is set (https://github.com/celo-org/optimism/blob/c828d61cd9/op-service/signer/client.go#L31-L65) (plain HTTP otherwise), and the handshake is just a health_status call into a Go string, which the sidecar answers. That genericness is what let us add KMS signing with no batcher code changes.

On EIP-191: agreed that a standard eth_sign would break recovery, but the sidecar's eth_sign is deliberately non-standard; it signs the raw 32-byte digest with no message prefix and returns r||s||v with v ∈ {0,1}, (i.e. go-ethereum's crypto). Sign convention, which makes it semantically identical to the privateKeySigner path in this PR (crypto.Sign(hash, privKey) (https://github.com/celo-org/optimism/blob/c828d61cd9/op-service/crypto/espresso.go#L161)). Both verify the same way under SigToPub. And it's pinned rather than hoped-for: the sidecar's fixture generator (https://github.com/EspressoSystems/espresso-kms-signer/blob/758e4d0/tests/fixtures/gen/main.go#L158-L178) is a Go program that imports op-service/signer itself and records the exact JSON-RPC params bytes geth's RPC client marshals (including the base64 []byte encoding), which CI replays against the production handler; there's also a localstack test that runs the real KMS path end-to-end and asserts the recovered address. That said, you're completely right that calling a method eth_sign while breaking eth_sign semantics is asking for exactly this confusion. I'd be happy to rename it in a follow-up, and we'll add a doc comment on SignerClient.Sign pointing at the sidecar and its semantics either way. (Small note: the espresso_batch.go verify code you linked has since moved into espresso-streamers digest recovery (https://github.com/EspressoSystems/espresso-streamers/blob/1884a718fbf7/op/derivation/espresso_batch.go#L102-L104).)

On "an HSM signer should never sign raw caller-supplied bytes", no pushback on the principle, and I'd rather be precise about what it costs us here. A raw-digest endpoint does mean the sidecar's eth_signTransaction guards (chainId, from, to-allowlist) only protect against a buggy caller, not a malicious one; anyone who can reach the endpoint can get a signature over an arbitrary digest, including an L1 tx sighash. What bounds the damage is that this key was never a batch-eligibility authority: batch acceptance in TEE mode requires an EIP-712 commitment signature from the ephemeral key generated inside the Nitro enclave and verified on-chain via the TEE verifier; the sidecar never touches that key. So a compromised sidecar (or its host) can spend the batcher address's gas funds and inject noise into our own HotShot namespace, but it can't make derivation accept a batch the enclave didn't produce. That's our documented trust model: the sidecar is trusted for availability, not integrity.

You're also right about the missing domain separation, and that one stands on its own: the namespace lives in the transaction envelope outside the signed bytes, so the signature binds neither chain nor namespace, under the local key just as much as the remote one. Fixing it means changing the digest every verifier reconstructs, so it's a coordinated change across the batcher and espresso-streamers with a migration story for payloads already in the stream, and we'll file it as a tracked issue rather than fold it into this PR.

Where I'd push back is on the remedy. A typed, domain-separated signing method modeled on signBlockPayload is the right end state, but it belongs in espresso-kms-signer (op-signer isn't in this deployment), and it should land together with the digest-scheme change since both alter what verifiers recover over. Dropping clientSigner in the meantime wouldn't remove the capability this comment worries about; it would move the key from KMS hardware into batcher memory, which is a strict downgrade for the same attack surface. So my proposal: keep clientSigner/Sign as-is here, add a comment linking the sidecar and its non-standard semantics, and file two linked follow-ups, one for the typed domain-separated method (including the eth_sign rename/retirement) and one for the digest-scheme migration (which I will discuss with the team). Happy to talk through the typed-method design if you have opinions on the shape!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any thoughts or remarks @piersy?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hi @jjeangal, I hadn't realised this was talking to the kms signer.

So yes, agreed on the followups 👍

Comment thread op-node/rollup/derive/espresso_batch.go Outdated
Comment thread op-node/rollup/derive/espresso_batch.go Outdated
Comment thread op-batcher/batcher/espresso.go Outdated
Comment thread op-batcher/batcher/espresso.go Outdated
@lukeiannucci
lukeiannucci force-pushed the espresso/batcher branch 3 times, most recently from 05288e9 to 78e33c9 Compare July 20, 2026 18:31
@philippecamacho
philippecamacho marked this pull request as ready for review August 8, 2026 14:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4037710ec1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// Sign represents the interface for signing things via eth_sign.
func (s *SignerClient) Sign(ctx context.Context, address common.Address, data []byte) ([]byte, error) {
var result hexutil.Bytes
if err := s.client.CallContext(ctx, &result, "eth_sign", address, data); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Encode eth_sign payload as hex data

When Espresso is configured with a remote signer, this passes a raw []byte as the JSON-RPC argument to eth_sign. Bare Go byte slices marshal as base64 JSON strings, but Ethereum JSON-RPC DATA parameters are expected to be 0x-hex encoded, so remote signers will reject the request or sign unexpected bytes; wrap the payload as hexutil.Bytes(data) before calling the signer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Working as designed. The remote signer is espresso-kms-signer, whose eth_sign expects Go's default []byte JSON encoding (pinned by fixtures and validated end-to-end). Applying hexutil.Bytes would actually break it.

philippecamacho and others added 12 commits August 12, 2026 17:09
Addresses the stall found in the PR 459 review of peekNextBatch: the
old streamer could discard a good batch when the channel manager tip
and the streamer position described different blocks, stalling it for
good. The v2 streamer owns the tip tracking: position and tip hash move
together, repositioning is explicit via SetBatchPosition, and Reset /
SetProperHead / Refresh / Update are gone.

- pin espresso-streamers to 22c396aa (PR 36, op streamer v2)
- construct the streamer in StartBatchSubmitting (needs a context and
  a synced L2 node to resolve its anchor), anchor it at the safe L2
  head, and run it from its own poll loops via Start/Stop
- drop peekNextBatch and the cachedSyncStatus provider; the loading
  loop now just Peeks batches extending the tracked tip and advances
  after the channel manager accepts them
- re-anchor instead of skipping on ToBlock/AddL2Block failures
- drop the dead BatchStreamerFromCLIConfig (no callers, v1-only API)

Co-Authored-By: Claude Fable 5 <[email protected]>
…art does not block later start attempts.

Co-Authored-By: Claude Fable 5 <[email protected]>
…t startup does not depend on the L2 endpoint serving the historical origin height.

Co-Authored-By: Claude Fable 5 <[email protected]>
…oint so that the Espresso batcher never treats pre-activation blocks as its own.

Co-Authored-By: Claude Fable 5 <[email protected]>
…k timeout so that a stalled endpoint cannot hang StartBatchSubmitting while it holds the start mutex.

Co-Authored-By: Claude Fable 5 <[email protected]>
…cal-safe head so that already-derived blocks are not resubmitted after a restart.

Co-Authored-By: Claude Fable 5 <[email protected]>
…choring on a cached unsafe floor block, whose reorg could permanently wedge fork selection.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ero LocalSafeL2 cannot floor the queue at genesis and re-enqueue derived history.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ocalSafeL2 cannot disable the stale-batch re-anchor floor and republish derived blocks.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ent sync-status failure cannot empty the channel manager while leaving the streamer at its old cursor.

Co-Authored-By: Claude Fable 5 <[email protected]>
@palango

palango commented Aug 12, 2026

Copy link
Copy Markdown

@jjeangal I merged the streamer-v2 branch into this. Let's give this another round of reviews before merging.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ee6cc80d9f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// call reverts (Unauthorized{Espresso,Fallback}Batcher) and the batcher loops.
func (l *BatchSubmitter) isBatcherActive(ctx context.Context) (bool, error) {
// Check if contract code exists at the address
code, err := l.L1Client.CodeAt(ctx, l.RollupConfig.BatchAuthenticatorAddress, nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wrap the active-check CodeAt call in a timeout

When a BatchAuthenticator is configured, publishStateToL1 reaches this check on every publish tick via shouldSkipPublishForActiveSeq. This CodeAt call uses the publishing loop's lifetime killCtx, while the contract calls just below are bounded by NetworkTimeout; if the L1 RPC accepts the request but stalls here, the publishing loop never reaches publishTxToL1 and graceful shutdown waits until the caller's stop context force-cancels the kill context. Please wrap this call in the same per-RPC timeout used for the subsequent reads.

Useful? React with 👍 / 👎.

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.

6 participants