Skip to content

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

Draft
QuentinI wants to merge 19 commits into
espresso/batcher-fallbackfrom
espresso/batcher
Draft

Espresso 3b: TEE batcher (re-hosted)#459
QuentinI wants to merge 19 commits into
espresso/batcher-fallbackfrom
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
batcherAddr := l.Txmgr.From()

isActive := (activeIsEspresso && l.Config.Espresso.Enabled) ||
(!activeIsEspresso && !l.Config.Espresso.Enabled)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Mirrored from #447 (unresolved review thread) to keep tracking it here after the re-host. Original location: op-batcher/batcher/espresso_active.go:52.

Original transcript:


@chatgpt-codex-connector — 2026-05-26:

P2 Badge Check configured signer address in active-batcher decision

isBatcherActive determines activity solely from activeIsEspresso vs EspressoEnabled, without verifying that this node’s Txmgr.From() matches the currently authorized batcher address for that mode. In fallback mode, a node with the wrong sender key will still consider itself active and keep attempting publishes that revert at BatchAuthenticator, causing avoidable failure loops and no progress if this is the only running batcher.

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.

@piersy piersy Jul 16, 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.

I think this is worth doing. It's low severity — it just stops an incorrectly configured batcher (wrong sender key) from thinking it's active and looping on publishes that revert at the BatchAuthenticator. Right now isBatcherActive already computes batcherAddr := l.Txmgr.From() but only logs it; the isActive decision is still purely activeIsEspresso vs Espresso.Enabled, so the sender key is never checked against the authorized batcher.

The contract gives you the value to compare against: espressoBatcher() returns the authorized espresso batcher, and for fallback mode the expected sender is the SystemConfig batcher (there's no direct getter, but the contract holds systemConfig() and reverts UnauthorizedFallbackBatcher(sender, expected) against it). So once you've decided this node is in the active mode, also confirm the key matches:

batcherAddr := l.Txmgr.From()

modeActive := (activeIsEspresso && l.Config.Espresso.Enabled) ||
	(!activeIsEspresso && !l.Config.Espresso.Enabled)
if !modeActive {
	return false, nil // not our mode — stay idle
}

// Our mode is active; make sure our sender key is the authorized batcher for it,
// otherwise every publish reverts (Unauthorized*Batcher) in a loop.
var expected common.Address
if activeIsEspresso {
	expected, err = batchAuthenticator.EspressoBatcher(callOpts)
	if err != nil {
		return false, fmt.Errorf("failed to read espressoBatcher: %w", err)
	}
} else {
	expected, err = l.fallbackBatcherAddr(callOpts) // read from SystemConfig
	if err != nil {
		return false, err
	}
}

if batcherAddr != expected {
	l.Log.Error("configured batcher key is not the authorized batcher for the active mode",
		"batcherAddr", batcherAddr, "expected", expected, "activeIsEspresso", activeIsEspresso)
	return false, nil
}
return true, nil

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
// ChainSigner interface and stores the embedded ChainSigner on the service.
// Espresso uses ChainSigner to sign batch authentication payloads sent to the
// BatchAuthenticator contract; the cast is required by every Espresso path.
func (bs *BatcherService) initChainSigner() error {

@piersy piersy Jul 6, 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.

I think this breaks batcher startup entirely. initTxManager (op-batcher/batcher/service.go:389) always calls initChainSigner, which asserts bs.TxManager to opcrypto.ChainSigner. But bs.TxManager is always a *txmgr.SimpleTxManager, which has neither Sign nor SignTransaction, so the assertion always fails and every batcher start — espresso or not — errors with "tx manager does not implement ChainSigner". The driver tests miss it because they build BatchSubmitter directly and skip service init.

The only ChainSigner implementations here (clientSigner, privateKeySigner in op-service/crypto/espresso.go) aren't wired into production — their only caller is a test.

Fix: build a real ChainSigner instead of casting the tx manager, or at least gate initChainSigner behind cfg.Espresso.Enabled so non-espresso batchers still start.

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.

i just added a flag for non espresso mode
700f549

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That's still not fixing it, this type assertion will always fail, which means in espresso mode it will fail because the TxMgr is a SimpleTxMgr and that doesn't implement ChainSigner:

	cast, castOk := bs.TxManager.(opcrypto.ChainSigner)
	if !castOk {
		return fmt.Errorf("tx manager does not implement ChainSigner")
	}

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.

Yes we missed porting over a file from our fork.

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.

I am actually going to hold off on this, as it looks like the changes in our fork will overlap with this discussion:
#459 (comment)

go l.receiptsLoop(l.wg, receiptsCh) // ranges over receiptsCh channel
go l.publishingLoop(l.killCtx, l.wg, receiptsCh, publishSignal) // ranges over publishSignal, spawns routines which send on receiptsCh. Closes receiptsCh when done.
go l.blockLoadingLoop(l.shutdownCtx, l.wg, unsafeBytesUpdated, publishSignal) // sends on unsafeBytesUpdated (if throttling enabled), and publishSignal. Closes them both when done
if l.Config.Espresso.Enabled {

@piersy piersy Jul 6, 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.

throttlingLoop starts by default (LowerThreshold defaults to 3,200,000) and just ranges over unsafeBytesUpdated with no context select. The non-espresso path sends on that channel and closes it on exit, but the espresso loops never touch it.

Two consequences: DA throttling silently does nothing in TEE mode, and on shutdown throttlingLoop blocks forever on the never-closed channel, so the wg.Wait in StopBatchSubmitting (op-batcher/batcher/driver.go:298) never returns — admin_stopBatcher or SIGTERM hangs until SIGKILL.

Fix: feed and close unsafeBytesUpdated from the espresso path like the non-espresso one

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.

This i may need you to take a close look:
abb7ee1

After every block loaded from the streamer I send it to the throttling loop now to check if it should throttle.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One follow-up on the fix: sending after every AddL2Block is more expensive than it looks. sendToThrottlingLoop calls channelMgr.UnsafeDABytes() under channelMgrMutex, and that re-walks the pending-block and open-channel collections on every call (channel_manager.go:648-673) — it isn't a running counter. Inside the drain loop that adds block after block during a backlog, that's an O(pending) walk per block, so roughly O(N²) over a large catch-up, plus a second mutex acquisition per block on top of AddL2Block's.

The non-espresso path avoids this deliberately: loadBlocksIntoState signals only every 100 blocks (driver.go:361) and the caller signals once more after the range. The throttling signal coalesces anyway (buffered size-1, non-blocking send, the loop only reads the latest value), so per-block frequency buys nothing.

Suggest matching that cadence — signal every 100 blocks inside the drain loop, and once per loop iteration after it:

blocksAdded := 0
for {
	batch = l.peekNextBatch(ctx, newSyncStatus)
	if batch == nil {
		break
	}

	block, err := batch.ToBlock(l.RollupConfig)
	if err != nil {
		l.Log.Error("failed to convert singular batch to block", "err", err)
		l.EspressoStreamer().Next(ctx)
		continue
	}

	l.channelMgrMutex.Lock()
	err = l.channelMgr.AddL2Block(block)
	l.channelMgrMutex.Unlock()
	if err != nil {
		l.Log.Error("failed to add L2 block to channel manager", "err", err)
		l.clearState(ctx)
		l.EspressoStreamer().Reset()
		break
	}

	l.EspressoStreamer().Next(ctx)
	l.Log.Info("Added L2 block to channel manager", "blockNr", block.NumberU64())

	// During a large drain, signal periodically so throttling can engage
	// before the whole backlog is consumed (mirrors loadBlocksIntoState).
	blocksAdded++
	if blocksAdded%100 == 0 {
		l.sendToThrottlingLoop(unsafeBytesUpdated)
	}
}

// Once per iteration: covers drains of fewer than 100 blocks and reflects the
// final unsafe-DA total for this tick.
l.sendToThrottlingLoop(unsafeBytesUpdated)
l.tryPublishSignal(publishSignal, pubInfo{})

That keeps throttling responsive mid-drain (it engages before the whole backlog loads) while dropping the per-block quadratic cost, and mirrors the every-100 + final-signal pattern the non-espresso loop already uses.

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.

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!

Comment thread op-node/rollup/derive/espresso_batch.go Outdated
Comment thread op-node/rollup/derive/espresso_batch.go Outdated
}

func (b EspressoBatch) Number() uint64 {
return bigs.Uint64Strict(b.BatchHeader.Number)

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 is an unauthenticated remote crash. Number() calls bigs.Uint64Strict(BatchHeader.Number), which panics if the value doesn't fit in uint64 (op-service/bigs/bigutil.go:24). UnmarshalEspressoTransaction does no bounds check on the header number, and RLP will happily decode a big.Int of any size into Header.Number, so a decoded batch can carry a number >= 2^64.

The reason an arbitrary attacker can reach it: posting to an Espresso namespace is permissionless — a namespace is just a tag, anyone can submit a transaction under it. The streamer defends against unauthorized batches by recovering the signer and checking it against the on-chain registered batcher, but that check happens too late. In espresso-streamers v1.1.0 the receive path processEspressoTransaction unmarshals the batch (op_streamer.go:557) and then calls CheckBatch (op_streamer.go:563), whose very first line calls batch.Number() (op_streamer.go:255) — while the authorized-signer check is ~40 lines later at op_streamer.go:296. So the panic fires before the signer is ever validated.

Concretely: anyone with a throwaway key signs a batch whose BatchHeader.Number exceeds 2^64 and posts it to the namespace. Unmarshal succeeds (the signature is well-formed, the recovered signer is just some junk address), then CheckBatch calls Number() and every consumer of the namespace — op-node derivation, the caff node — panics before it gets to reject the unauthorized signer. One message takes them down.

Hash() at line 41 has the same shape: it dereferences b.L1InfoDeposit.Hash(), and nothing guarantees L1InfoDeposit is non-nil after decode, so a batch that omits it triggers a nil-pointer panic.

Fix: bounds-check at the decode boundary in UnmarshalEspressoTransaction — reject a BatchHeader.Number that doesn't fit in uint64, and reject a nil L1InfoDeposit — returning an error so the batch is dropped at op_streamer.go:558 instead of reaching CheckBatch. This is the same decode-side validation the ToBlock/livelock issue needs, so both can land together.

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.

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.

It actually lives in Espresso streamers now see
c48c8a8

@piersy piersy Jul 16, 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.

EspressoSystems/espresso-streamers#32 looks good, we just need to ensure that we update this PR to use a proper version once EspressoSystems/espresso-streamers#32 has been merged.

Comment thread op-batcher/batcher/espresso.go Outdated
Comment thread op-batcher/batcher/espresso.go Outdated
}

if l.prevSyncStatus == nil {
l.prevSyncStatus = newSyncStatus

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

prevSyncStatus needs to be set somewhere when exiting the function, I'm not sure if it's on every return site or just some, but currently it's set once and never updated

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.

c828d61

I think it should always be set after the check

QuentinI and others added 3 commits July 16, 2026 14:07
Bring in op-service/crypto/espresso.go (ChainSigner interface unifying
SignTransaction and arbitrary-data signing) and op-service/signer/espresso.go
(SignerClient.Sign wrapper around eth_sign).

Required by the Espresso batcher to sign batch-authentication payloads with
either a remote signer or a local private key, in addition to the existing
transaction-signing path.

Co-authored-by: OpenCode <noreply@opencode.ai>
Introduce op-node/rollup/derive/espresso_batch.go defining EspressoBatch
(a SingularBatch with block number, L1 info deposit transaction, and
signer address attached), along with BlockToEspressoBatch and the
unmarshaler used by the streamer.

Also pulls in the github.com/EspressoSystems/espresso-network/sdks/go
dependency, which provides the Espresso transaction and namespace types.

Consumed by the Espresso batcher (next commits) to convert L2 blocks into
batches submitted to Espresso, and to round-trip those batches back through
the streamer.

Co-authored-by: OpenCode <noreply@opencode.ai>
Bring in the parts of the espresso/ shared package that are TEE-only:

- espresso/cli.go: full CLI flag set for --espresso.enabled, query
  service URLs, light-client/L1 endpoints, batch-authenticator address,
  receipt-verification tuning, namespace/origin-height parameters used
  to construct the espresso-streamer. (The --espresso.fallback-auth-lead-time
  flag lives in op-batcher/flags/flags.go and was added by the fallback PR.)
- espresso/interface.go: EspressoStreamer[B] interface that wraps
  github.com/EspressoSystems/espresso-streamers/op.BatchStreamer.
- espresso/ethclient.go: AdaptL1BlockRefClient adapter (used by cli.go to
  construct the streamer) and FetchEspressoBatcherAddress helper.

Also adds the EspressoSystems/espresso-network/sdks/go and
EspressoSystems/espresso-streamers Go module dependencies.

The regenerated BatchAuthenticator bindings already live in the
fallback PR's espresso/bindings/.

Co-authored-by: OpenCode <noreply@opencode.ai>
QuentinI and others added 11 commits July 16, 2026 14:07
Bring in op-batcher/enclave/attestation.go: a thin wrapper around the
hf/nsm library that obtains an AWS Nitro NSM attestation document over
a given public key. Used by the Espresso batcher (next commit) to attach
a TEE attestation to its registration with the BatchAuthenticator.

Adds the github.com/hf/nsm dependency. Builds on all platforms; NSM
device access is only attempted at runtime when invoked from inside
a Nitro enclave.

Co-authored-by: OpenCode <noreply@opencode.ai>
Add the Espresso TEE batcher write-path on top of the fallback batcher:

- op-batcher/batcher/espresso.go: Espresso submission loop (peeks the
  channel manager, converts each L2 block to an EspressoBatch, submits
  it to Espresso, waits for inclusion, and then posts the batch txs to
  L1 with TEE-attested BatchAuthenticator.authenticateBatchInfo calls).
- op-batcher/batcher/espresso_service.go: EspressoBatcherConfig,
  initEspresso (Espresso client / light-client construction, optional
  TEE attestation gathering), and the initChainSigner hook that wraps
  the txmgr into a opcrypto.ChainSigner.
- op-batcher/batcher/espresso_helpers_test.go and
  espresso_transaction_submitter_test.go: unit tests for the helpers and
  the TEE transaction submitter.

Extends the existing fallback wiring:

- op-batcher/batcher/espresso_driver.go: adds EspressoDriverSetup
  fields (Client/LightClient/ChainSigner/SequencerAddress/Attestation),
  batcherL1Adapter, setupEspressoStreamer, startEspressoLoops,
  resetEspressoStreamer; extends dispatchAuthenticatedSendTx with the
  TEE branch (always authenticates when Espresso.Enabled).
- op-batcher/batcher/espresso_active.go: adds isBatcherActive
  (queries BatchAuthenticator.activeIsEspresso to gate publishing
  against this batcher's role).
- op-batcher/batcher/driver.go: extends DriverSetup with the Espresso
  EspressoDriverSetup field; adds espressoSubmitter / espressoStreamer /
  teeVerifierAddress / degradedLog fields on BatchSubmitter; calls
  setupEspressoStreamer in NewBatchSubmitter; branches
  StartBatchSubmitting on Espresso.Enabled to call startEspressoLoops;
  calls resetEspressoStreamer in clearState.
- op-batcher/batcher/service.go: BatcherConfig.Espresso field;
  EspressoClient / EspressoLightClient / ChainSigner / Attestation
  runtime fields; initEspresso / initChainSigner /
  applyEspressoDriverSetup call-outs.
- op-batcher/batcher/config.go: thread Espresso espresso.CLIConfig
  through CLIConfig.
- op-batcher/flags/flags.go: register espresso.CLIFlags (TEE-only
  flags; the --espresso.fallback-auth-lead-time flag added by the
  fallback PR continues to live in op-batcher/flags/flags.go).

Also adds op-service/log/repeat_state.go (RepeatStateLogger) and its
test, used by the Espresso submission loop's tick-driven warnings.
A safeTestRecorder helper is inlined into the test to avoid pulling
in the unrelated debouncer.

Adds the github.com/hf/nitrite dependency (transitively required by
hf/nsm for attestation document parsing).

Co-authored-by: OpenCode <noreply@opencode.ai>
The TEE batcher's Espresso submission path called Txmgr.Send directly for both
the authenticateBatchInfo tx and the batch inbox tx, and ran inside authGroup,
so it bypassed MaxPendingTransactions, assigned nonces nondeterministically
(violating Holocene's in-order L1 inclusion requirement), and never checked
whether the auth tx reverted — a reverted authenticateBatchInfo emits no event,
so the verifier would silently drop the batch.

Submit both txs through the ordered queue.Send path on the publishing-loop
goroutine (auth first, batch second) so the auth tx takes the lower nonce and is
mined first, and both stay under MaxPendingTransactions. A watcher goroutine
(tracked by authGroup) collects both receipts, fails the pair if the auth tx
reverted, runs the lookback-window check, and emits a single synthetic receipt.

This is the same fix already applied to the fallback path; extract the shared
submission + receipt-watching flow into submitAuthenticatedBatch /
watchAuthReceipts so both paths reuse it and differ only in how the
authenticateBatchInfo calldata is built (TEE-attested signature vs empty
signature). Rename fallback_auth.go to espresso_auth.go to reflect the shared
scope, and restructure the tests: one suite drives the shared flow directly,
plus per-path tests asserting the distinguishing auth calldata (empty sig vs a
recoverable EIP-712 signature).

Co-authored-by: OpenCode <noreply@opencode.ai>
Port TestBatchRoundtrip from the integration branch and fold it into
espresso_batch_test.go. It is the only test covering ToEspressoTransaction
and the batcher->derivation serialization path; it asserts the decoded batch
matches the original and that the recovered signer is the batcher.

Also drop the decodedBlock.ExecutionWitness() comparison in
TestEspressoBatchConversion: that method does not exist on the op-geth
types.Block pinned in the rebase-18 base, so go vet of the derive_test
package failed to build. EspressoBatch/ToBlock carries no execution witness.

Co-authored-by: OpenCode <noreply@opencode.ai>
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.

4 participants