diff --git a/op-batcher/batcher/fallback_auth.go b/op-batcher/batcher/fallback_auth.go index 419fb68d36e..678a7f8cda4 100644 --- a/op-batcher/batcher/fallback_auth.go +++ b/op-batcher/batcher/fallback_auth.go @@ -94,7 +94,9 @@ func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, ca ) if len(candidate.Blobs) > 0 { - // SendPair doesn't support blobs. + // SendPair doesn't support blobs. Unreachable while calldata-only DA is + // enforced: checkEspressoDataAvailability refuses to start a blob- or + // auto-configured batcher on a chain with EspressoTime set. l.sendFallbackAuthSerialized(transactionReference, authReference, verifyCandidate, candidate, queue, receiptsCh) return } @@ -119,6 +121,8 @@ func (l *BatchSubmitter) sendTxWithFallbackAuth(txdata txData, isCancel bool, ca // sendFallbackAuthSerialized submits an auth+batch pair serially: the auth tx is sent // and confirmed first, then the batch tx. It runs on the publishing-loop goroutine and // blocks it for the pair's full confirmation cycle, so consecutive pairs never overlap. +// Its only caller is the blob branch of sendTxWithFallbackAuth, so it cannot run while +// calldata-only DA is enforced. func (l *BatchSubmitter) sendFallbackAuthSerialized(transactionReference, authReference txRef, verifyCandidate txmgr.TxCandidate, candidate *txmgr.TxCandidate, queue TxSender[txRef], receiptsCh chan txmgr.TxReceipt[txRef]) { authReceiptCh := make(chan txmgr.TxReceipt[txRef], 1) queue.Send(authReference, verifyCandidate, authReceiptCh) diff --git a/op-batcher/batcher/fallback_auth_test.go b/op-batcher/batcher/fallback_auth_test.go index 980c910bf0b..6b6084163ca 100644 --- a/op-batcher/batcher/fallback_auth_test.go +++ b/op-batcher/batcher/fallback_auth_test.go @@ -79,7 +79,9 @@ func testFallbackTxData() txData { } // testBlobCandidate returns a tx candidate carrying one (zero) blob, which -// routes sendTxWithFallbackAuth onto the serialized blob path. +// routes sendTxWithFallbackAuth onto the serialized blob path. That path is +// unreachable in production while calldata-only DA is enforced; its tests are kept +// so it stays covered if the restriction is lifted. func testBlobCandidate() *txmgr.TxCandidate { return &txmgr.TxCandidate{Blobs: []*eth.Blob{{}}} } @@ -410,6 +412,8 @@ func TestComputeCommitment_Parity(t *testing.T) { } }) + // Same caveat as testBlobCandidate: no live path computes a blob commitment while + // calldata-only DA is enforced. t.Run("blobs", func(t *testing.T) { for _, n := range []int{1, 3} { blobs := make([]*eth.Blob, n) diff --git a/op-batcher/batcher/service.go b/op-batcher/batcher/service.go index 4c8892c6c74..a0af0e9ecad 100644 --- a/op-batcher/batcher/service.go +++ b/op-batcher/batcher/service.go @@ -171,6 +171,9 @@ func (bs *BatcherService) initFromCLIConfig(ctx context.Context, closeApp contex if err := bs.initRollupConfig(ctx); err != nil { return fmt.Errorf("failed to load rollup config: %w", err) } + if err := bs.checkEspressoDataAvailability(cfg); err != nil { + return err + } if err := bs.checkFallbackAuthConfirmations(cfg); err != nil { return err } @@ -259,19 +262,49 @@ func (bs *BatcherService) initRollupConfig(ctx context.Context) error { return nil } +// checkEspressoDataAvailability enforces the calldata-only DA restriction of the +// Espresso integration: from Espresso activation the derivation +// pipeline drops blob batch transactions because the Celo fault-proof host cannot +// retrieve blob contents. A blob- or auto-configured batcher would have every blob +// batch silently ignored by verifiers once the fork activates: the safe head stalls +// for one sequence window, then verifiers force empty batches and reorg away the +// unsafe chain, discarding the transactions in it. Refuse to start instead. +// +// Deliberately broader than derivation's gate, which drops blobs only once Espresso +// is active: a proof walks back channel_timeout L1 blocks into pre-fork territory, and +// this check is the only thing keeping blob batches out of that window. Do not narrow +// it to IsEspresso. +func (bs *BatcherService) checkEspressoDataAvailability(cfg *CLIConfig) error { + if bs.RollupConfig.EspressoTime == nil { + return nil + } + if cfg.DataAvailabilityType != flags.CalldataType { + return fmt.Errorf("data availability type %q is not supported on chains with Espresso scheduled: "+ + "batch data must be posted as calldata only (blob DA is dropped by post-Espresso derivation)", + cfg.DataAvailabilityType) + } + return nil +} + // checkFallbackAuthConfirmations validates that the configured number of L1 // confirmations leaves enough headroom inside BatchAuthLookbackWindow for the // batch tx to land after its auth tx (see sendTxWithFallbackAuth). The bound // only applies when the BatchAuthenticator is configured on the chain, which -// is only known once the rollup config is loaded +// is only known once the rollup config is loaded. +// +// While calldata-only DA is enforced this cannot return an error: it runs after +// checkEspressoDataAvailability, which rejects the one configuration the bound +// applies to, a scheduled EspressoTime with a non-calldata DA type. It is kept as a +// second line of defence and applies again if the restriction is ever lifted. func (bs *BatcherService) checkFallbackAuthConfirmations(cfg *CLIConfig) error { if bs.RollupConfig.BatchAuthenticatorAddress == (common.Address{}) { return nil } // Fallback auth is gated behind the EspressoTime hardfork // (dispatchAuthenticatedSendTx): with no activation scheduled no - // auth→batch pair can be emitted, so the bound does not apply. A future - // activation must still be checked — it switches the send path mid-run. + // auth→batch pair can be emitted, so the bound does not apply. A scheduled + // activation counts the same as an active one, since it switches the send + // path mid-run. if bs.RollupConfig.EspressoTime == nil { return nil } diff --git a/op-batcher/batcher/service_test.go b/op-batcher/batcher/service_test.go index 1fe7ba29a8e..adcfcabf0c7 100644 --- a/op-batcher/batcher/service_test.go +++ b/op-batcher/batcher/service_test.go @@ -12,9 +12,56 @@ import ( "github.com/ethereum-optimism/optimism/op-service/txmgr" ) +// TestCheckEspressoDataAvailability: chains with Espresso scheduled are +// calldata-only — post-Espresso derivation drops blob batches, so a +// blob/auto DA configuration must be rejected at startup. +func TestCheckEspressoDataAvailability(t *testing.T) { + espressoTime := uint64(0) + // Far enough out that the fork cannot have activated. The check keys on EspressoTime + // being set rather than on activation, deliberately: derivation only drops blobs from + // activation, so this is the only thing keeping blobs out of the pre-fork blocks a + // post-Espresso proof walks back through. + futureEspressoTime := uint64(1) << 40 + + tests := []struct { + name string + espressoTime *uint64 + daType flags.DataAvailabilityType + wantErr bool + }{ + {"espresso not scheduled: blobs allowed", nil, flags.BlobsType, false}, + {"espresso not scheduled: auto allowed", nil, flags.AutoType, false}, + {"espresso scheduled: calldata allowed", &espressoTime, flags.CalldataType, false}, + {"espresso scheduled: blobs rejected", &espressoTime, flags.BlobsType, true}, + {"espresso scheduled: auto rejected", &espressoTime, flags.AutoType, true}, + {"espresso scheduled but not active: calldata allowed", &futureEspressoTime, flags.CalldataType, false}, + {"espresso scheduled but not active: blobs rejected", &futureEspressoTime, flags.BlobsType, true}, + {"espresso scheduled but not active: auto rejected", &futureEspressoTime, flags.AutoType, true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + bs := &BatcherService{RollupConfig: &rollup.Config{ + EspressoTime: test.espressoTime, + }} + cfg := &CLIConfig{DataAvailabilityType: test.daType} + err := bs.checkEspressoDataAvailability(cfg) + if test.wantErr { + require.ErrorContains(t, err, "calldata only") + } else { + require.NoError(t, err) + } + }) + } +} + // TestCheckFallbackAuthConfirmations: the NumConfirmations headroom bound only // applies when a blob/auto DA batcher can actually emit auth→batch pairs — a // BatchAuthenticator is configured AND the EspressoTime fork is scheduled. +// +// Cases pairing a set espressoTime with a non-calldata DA type describe batchers +// checkEspressoDataAvailability refuses to start. They only occur if the calldata-only +// restriction is lifted, and are kept so the bound stays covered if it is. func TestCheckFallbackAuthConfirmations(t *testing.T) { espressoTime := uint64(0) authAddr := common.Address{0x01} diff --git a/op-node/rollup/derive/batch_authenticator.go b/op-node/rollup/derive/batch_authenticator.go index 52e9f801608..9b5f5aae0f8 100644 --- a/op-node/rollup/derive/batch_authenticator.go +++ b/op-node/rollup/derive/batch_authenticator.go @@ -67,14 +67,18 @@ func NewBatchAuthCaches() *BatchAuthCaches { return &BatchAuthCaches{AuthCache: authCache, RefCache: refCache} } -// ComputeCalldataBatchHash computes keccak256(calldata), matching the BatchAuthenticator -// contract's calldata batch validation path. +// ComputeCalldataBatchHash computes keccak256(calldata), the commitment a calldata batch +// is authenticated under. BatchAuthenticator.authenticateBatchInfo takes the commitment as +// an opaque bytes32 and never inspects how it was derived, so this encoding is agreed +// off-chain between the batcher and derivation. func ComputeCalldataBatchHash(data []byte) common.Hash { return crypto.Keccak256Hash(data) } -// ComputeBlobBatchHash computes keccak256(concat(blobHashes)), matching the BatchAuthenticator -// contract's blob batch validation path. +// ComputeBlobBatchHash computes keccak256(concat(blobHashes)), the same commitment for a +// blob batch, agreed off-chain as above. No live path consumes it while calldata-only DA +// is enforced: derivation drops blob batches before hashing them, and the batcher cannot +// start with blob DA once espresso_time is set. func ComputeBlobBatchHash(blobHashes []common.Hash) common.Hash { concatenated := make([]byte, 32*len(blobHashes)) for i, h := range blobHashes { diff --git a/op-node/rollup/derive/blob_data_source.go b/op-node/rollup/derive/blob_data_source.go index 2f6cfcf9692..e242f32de64 100644 --- a/op-node/rollup/derive/blob_data_source.go +++ b/op-node/rollup/derive/blob_data_source.go @@ -119,19 +119,32 @@ func (ds *BlobDataSource) open(ctx context.Context) ([]blobOrCalldata, error) { // creates a placeholder blobOrCalldata element for each returned blob hash that must be populated // by fillBlobPointers after blob bodies are retrieved. // -// Before Espresso event-auth is enforced (Espresso inactive at the L1 origin time of -// `ref`, or within BatchAuthEnforcementDelaySecs of activation), this runs upstream -// Optimism semantics: filter by batch inbox + sender == batcher. +// Every transaction is filtered by the batch inbox address first. Two further rules then +// apply, both keyed on the L1 origin time of `ref`. // -// Once enforced, it collects all authenticated batch hashes from a lookback -// window once and rejects any batch whose commitment hash is not in the -// authenticated set. For blob transactions, the batch hash is computed from -// the concatenated blob versioned hashes. +// From Espresso activation onward (including the enforcement grace window), batch +// data is calldata-only: blob-carrying inbox transactions are dropped entirely, +// authenticated or not. The Celo fault-proof host (celo-kona) does not implement +// the L1Blob preimage hint, so a blob batch accepted here would stall fault-proof +// execution at its L1 block. +// +// That only covers L1 blocks at or after espresso_time. A proof also walks back +// channel_timeout blocks into pre-fork territory; those are kept blob-free by the +// batcher (checkEspressoDataAvailability), not by consensus. +// +// The transactions that survive that rule are authorized by upstream Optimism semantics +// (sender == batcher) until Espresso event-auth is enforced, which happens once Espresso +// has been active for BatchAuthEnforcementDelaySecs. Once enforced, it collects all +// authenticated batch hashes from a lookback window once and rejects any batch whose +// commitment hash is not in the authenticated set. func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *DataSourceConfig, batcherAddr common.Address, fetcher L1Fetcher, ref eth.L1BlockRef, logger log.Logger) ([]blobOrCalldata, []common.Hash, error) { + // Espresso activation and event-auth enforcement are both properties of the L1 origin + // time of the block we're scanning, so they hold for every transaction in it. + espressoActive := config.rollupCfg.IsEspresso(ref.Time) + // Only collect authenticated batch commitments once event-based authentication is - // enforced at the L1 origin time of the block we're scanning (Espresso active plus - // the enforcement grace period). Before that, the upstream sender-based - // authorization path is used and authenticatedHashes is unused. + // enforced (Espresso active plus the enforcement grace period). Before that, the + // upstream sender-based authorization path is used and authenticatedHashes is unused. var authenticatedHashes map[common.Hash]common.Address if isEspressoAuthEnforced(config.rollupCfg, ref.Time) { var err error @@ -151,7 +164,21 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D continue } - // Compute batch hash depending on tx type + // Post-Espresso, blob DA is unsupported (calldata-only): drop blob + // batch transactions before any authorization check so derivation never + // requires blob preimages the Celo fault-proof host cannot supply. + if tx.Type() == types.BlobTxType && espressoActive { + logger.Warn("ignoring blob batch tx: blob DA is unsupported post-Espresso", + "txHash", tx.Hash()) + continue + } + + // Compute batch hash depending on tx type. The blob arm computes a value nothing + // reads: a blob tx only gets past the drop above pre-Espresso, and pre-Espresso + // isBatchTxAuthorized takes the sender-based path, which ignores batchHash. Keep it + // anyway. Folding it into the calldata arm would hash a blob tx over its + // usually-empty calldata, so if the drop above were ever narrowed, every blob batch + // would fail authentication for a reason the logs would not explain. var batchHash common.Hash if tx.Type() == types.BlobTxType { batchHash = ComputeBlobBatchHash(tx.BlobHashes()) @@ -171,6 +198,7 @@ func dataAndHashesFromTxs(ctx context.Context, txs types.Transactions, config *D continue } // handle blob batcher transactions by extracting their blob hashes, ignoring any calldata. + // Pre-Espresso only, for the reason given at the batch hash above. if len(tx.Data()) > 0 { log.Warn("blob tx has calldata, which will be ignored", "txhash", tx.Hash()) } diff --git a/op-node/rollup/derive/espresso_blob_data_source_test.go b/op-node/rollup/derive/espresso_blob_data_source_test.go index e38d2af21a3..54f1aebb427 100644 --- a/op-node/rollup/derive/espresso_blob_data_source_test.go +++ b/op-node/rollup/derive/espresso_blob_data_source_test.go @@ -3,6 +3,7 @@ package derive import ( "context" "crypto/ecdsa" + "io" "math/big" "math/rand" "testing" @@ -98,8 +99,9 @@ func mockAuthEvents(l1F *testutils.MockL1Source, rng *rand.Rand, ref eth.L1Block return updatedRef } -// TestDataAndHashesFromTxsEventAuth tests event-based batch authentication for both -// calldata and blob transactions in the blob data source path. +// TestDataAndHashesFromTxsEventAuth tests event-based batch authentication in the blob +// data source path, and that blob-carrying inbox transactions are dropped post-fork +// regardless of authentication (calldata-only DA). // // Event-based authentication is only enforced once the fork has been active for // BatchAuthEnforcementDelaySecs; the fixture activates the fork at L1 origin time 0 @@ -155,7 +157,10 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { l1F.AssertExpectations(t) }) - t.Run("authenticated blob tx accepted", func(t *testing.T) { + t.Run("authenticated blob tx rejected: blob DA unsupported post-fork", func(t *testing.T) { + // Even a fully event-authenticated blob batch must be dropped post-fork: + // batch data is calldata-only because the Celo fault-proof + // host cannot supply blob preimages. l1F := &testutils.MockL1Source{} blobHash := testutils.RandomHash(rng) blobTxData := &types.BlobTx{ @@ -173,11 +178,8 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{blobTx}, &config, batcherAddr, l1F, ref, logger) require.NoError(t, err) - require.Equal(t, 1, len(data)) - require.Equal(t, 1, len(blobHashes)) - require.Equal(t, blobHash, blobHashes[0]) // the authenticated blob's hash, not just any - require.Nil(t, data[0].calldata) // blob placeholder - require.Nil(t, data[0].blob) // blob placeholder + require.Equal(t, 0, len(data), "authenticated blob batch must be dropped post-fork") + require.Equal(t, 0, len(blobHashes), "no blob preimages may be requested post-fork") l1F.AssertExpectations(t) }) @@ -317,10 +319,16 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { l1F.AssertExpectations(t) }) - t.Run("multiple authenticated txs each accepted for their own commitment", func(t *testing.T) { + t.Run("mixed calldata+blob block: only the calldata batch accepted", func(t *testing.T) { // One calldata batch and one blob batch in the same block, each authenticated - // via its own commitment (calldata hash vs blob-hash concatenation). Both must - // be accepted and mapped to their own data. + // via its own commitment (calldata hash vs blob-hash concatenation). Post-fork + // only the calldata batch may pass; the blob batch is dropped despite its + // valid authentication (calldata-only DA). + // + // The blob tx goes first, ahead of the calldata batch. That ordering is what makes + // the drop's per-transaction scope observable: a whole-block short-circuit (break + // where the gate has continue) would swallow the calldata batch behind it. With the + // calldata batch first, this test passes either way. l1F := &testutils.MockL1Source{} calldataTx, _ := types.SignNewTx(privateKey, signer, &types.LegacyTx{ Nonce: rng.Uint64(), @@ -344,15 +352,12 @@ func TestDataAndHashesFromTxsEventAuth(t *testing.T) { ComputeBlobBatchHash([]common.Hash{blobHash}), }) - data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{calldataTx, blobTx}, &config, batcherAddr, l1F, ref, logger) + data, blobHashes, err := dataAndHashesFromTxs(ctx, types.Transactions{blobTx, calldataTx}, &config, batcherAddr, l1F, ref, logger) require.NoError(t, err) - require.Equal(t, 2, len(data)) - require.Equal(t, 1, len(blobHashes)) - require.Equal(t, blobHash, blobHashes[0]) - require.NotNil(t, data[0].calldata, "first entry must be the calldata batch") + require.Equal(t, 1, len(data), "only the calldata batch may pass post-fork") + require.Equal(t, 0, len(blobHashes)) + require.NotNil(t, data[0].calldata) require.Equal(t, eth.Data(calldataTx.Data()), *data[0].calldata) - require.Nil(t, data[1].calldata, "second entry must be the blob placeholder") - require.Nil(t, data[1].blob) l1F.AssertExpectations(t) }) } @@ -485,4 +490,107 @@ func TestDataAndHashesFromTxsForkBoundary(t *testing.T) { require.Equal(t, eth.Data(txData), *data[0].calldata) l1F.AssertExpectations(t) }) + + // newBlobBatchTx builds a blob batch tx to the inbox, signed by the batcher key. + newBlobBatchTx := func(t *testing.T, blobHash common.Hash) *types.Transaction { + t.Helper() + tx, err := types.SignNewTx(privateKey, signer, &types.BlobTx{ + Nonce: rng.Uint64(), + Gas: 2_000_000, + To: batchInboxAddr, + BlobHashes: []common.Hash{blobHash}, + }) + require.NoError(t, err) + return tx + } + + t.Run("pre-fork: blob batcher tx accepted via sender auth", func(t *testing.T) { + // Pre-fork the pipeline keeps upstream semantics: blob batches from the + // batcher are accepted and their versioned hashes requested. + l1F := &testutils.MockL1Source{} + blobHash := testutils.RandomHash(rng) + tx := newBlobBatchTx(t, blobHash) + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime - 1, Hash: testutils.RandomHash(rng)} + data, hashes, err := dataAndHashesFromTxs(ctx, types.Transactions{tx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 1, len(data), "pre-fork blob batcher tx should be accepted via sender-based auth") + require.Equal(t, 1, len(hashes)) + require.Equal(t, blobHash, hashes[0]) + l1F.AssertExpectations(t) + }) + + t.Run("post-fork: blob batcher tx dropped from activation onward", func(t *testing.T) { + // From the moment the fork activates — including the grace window, where + // calldata batches still pass on sender auth — blob batches are dropped + // (calldata-only DA). The empty mock also asserts no receipt + // scanning happens pre-enforcement. + l1F := &testutils.MockL1Source{} + tx := newBlobBatchTx(t, testutils.RandomHash(rng)) + + for _, refTime := range []uint64{espressoTime, espressoTime + BatchAuthEnforcementDelaySecs - 1} { + ref := eth.L1BlockRef{Number: 1, Time: refTime, Hash: testutils.RandomHash(rng)} + data, hashes, err := dataAndHashesFromTxs(ctx, types.Transactions{tx}, &config, batcherAddr, l1F, ref, logger) + require.NoError(t, err) + require.Equal(t, 0, len(data), "post-fork blob batcher tx must be dropped") + require.Equal(t, 0, len(hashes), "no blob preimages may be requested post-fork") + } + l1F.AssertExpectations(t) + }) +} + +// TestOpenDataDropsBlobsPostEspresso drives the whole data-source path — NewDataSourceFactory, +// OpenData, Next — rather than calling dataAndHashesFromTxs directly, and asserts that a blob +// inbox tx costs no blob fetch once Espresso is active. The blobs fetcher has no expectations +// set, so any GetBlobsByHash call is unexpected and fails the test; that is what pins the +// no-blobs-to-fetch short-circuit in BlobDataSource.open. +// +// The ref sits past the enforcement delay with a non-zero espresso_time, so the auth event +// scan runs and the drop is exercised on the enforced path rather than the grace window. +func TestOpenDataDropsBlobsPostEspresso(t *testing.T) { + rng := rand.New(rand.NewSource(5555)) + privateKey := testutils.InsecureRandomKey(rng) + batcherAddr := crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)) + batchInboxAddr := testutils.RandomAddress(rng) + authenticatorAddr := testutils.RandomAddress(rng) + logger := testlog.Logger(t, log.LvlInfo) + + chainId := new(big.Int).SetUint64(rng.Uint64()) + signer := types.NewPragueSigner(chainId) + + // Ecotone at genesis so OpenData selects the blob source at all; Espresso at 1000. + ecotoneTime := uint64(0) + espressoTime := uint64(1000) + cfg := &rollup.Config{ + L1ChainID: chainId, + BatchInboxAddress: batchInboxAddr, + EcotoneTime: &ecotoneTime, + EspressoTime: &espressoTime, + BatchAuthenticatorAddress: authenticatorAddr, + } + + blobTx, err := types.SignNewTx(privateKey, signer, &types.BlobTx{ + Nonce: rng.Uint64(), + Gas: 2_000_000, + To: batchInboxAddr, + BlobHashes: []common.Hash{testutils.RandomHash(rng)}, + }) + require.NoError(t, err) + + l1F := &testutils.MockL1Source{} + blobsFetcher := &testutils.MockBlobsFetcher{} + + ref := eth.L1BlockRef{Number: 1, Time: espressoTime + BatchAuthEnforcementDelaySecs, Hash: testutils.RandomHash(rng)} + ref = mockAuthEvents(l1F, rng, ref, authenticatorAddr, batcherAddr, nil) + l1F.ExpectInfoAndTxsByHash(ref.Hash, testutils.RandomBlockInfo(rng), types.Transactions{blobTx}, nil) + + ctx := context.Background() + src, err := NewDataSourceFactory(logger, cfg, l1F, blobsFetcher, nil).OpenData(ctx, ref, batcherAddr) + require.NoError(t, err) + + _, err = src.Next(ctx) + require.ErrorIs(t, err, io.EOF, "the blob batch must be dropped, leaving no data to return") + + l1F.AssertExpectations(t) + blobsFetcher.AssertExpectations(t) }