From 2cf083286df6fce19c140babaa45f51f7ff4119a Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Wed, 15 Apr 2026 12:54:42 -0700 Subject: [PATCH 01/10] EVM watcher tests + testability * Seperated main code within goroutines for message processing into separate functions. * Added tests for processBlock (non-instant path) and instant path on EVM. --- node/pkg/watchers/evm/watcher.go | 360 ++++----- node/pkg/watchers/evm/watcher_test.go | 747 +++++++++++++++++- .../watchers/evm/watcher_test_helpers_test.go | 362 +++++++++ 3 files changed, 1258 insertions(+), 211 deletions(-) create mode 100644 node/pkg/watchers/evm/watcher_test_helpers_test.go diff --git a/node/pkg/watchers/evm/watcher.go b/node/pkg/watchers/evm/watcher.go index dbd3e1111ef..33bf36b2e69 100644 --- a/node/pkg/watchers/evm/watcher.go +++ b/node/pkg/watchers/evm/watcher.go @@ -502,7 +502,6 @@ func (w *Watcher) Run(parentCtx context.Context) error { defer headerSubscription.Unsubscribe() common.RunWithScissors(ctx, errC, "evm_fetch_headers", func(ctx context.Context) error { - stats := gossipv1.Heartbeat_Network{ContractAddress: w.contract.Hex()} for { select { case <-ctx.Done(): @@ -524,7 +523,6 @@ func (w *Watcher) Run(parentCtx context.Context) error { continue } - start := time.Now() currentHash := ev.Hash logger.Debug("processing new header", zap.Stringer("current_block", ev.Number), @@ -534,185 +532,11 @@ func (w *Watcher) Run(parentCtx context.Context) error { ) readiness.SetReady(w.readinessSync) - blockNumberU := ev.Number.Uint64() - var thisConsistencyLevel uint8 - switch ev.Finality { - case connectors.Latest: - thisConsistencyLevel = vaa.ConsistencyLevelPublishImmediately - atomic.StoreUint64(&w.latestBlockNumber, blockNumberU) - currentEthHeight.WithLabelValues(w.networkName).Set(float64(blockNumberU)) - stats.Height = int64(blockNumberU) // #nosec G115 -- This conversion is safe indefinitely - w.ccqAddLatestBlock(ev) - case connectors.Safe: - thisConsistencyLevel = vaa.ConsistencyLevelSafe - atomic.StoreUint64(&w.latestSafeBlockNumber, blockNumberU) - currentEthSafeHeight.WithLabelValues(w.networkName).Set(float64(blockNumberU)) - stats.SafeHeight = int64(blockNumberU) // #nosec G115 -- This conversion is safe indefinitely - case connectors.Finalized: - thisConsistencyLevel = vaa.ConsistencyLevelFinalized - atomic.StoreUint64(&w.latestFinalizedBlockNumber, blockNumberU) - currentEthFinalizedHeight.WithLabelValues(w.networkName).Set(float64(blockNumberU)) - stats.FinalizedHeight = int64(blockNumberU) // #nosec G115 -- This conversion is safe indefinitely - default: - logger.Error("unexpected finality in block", zap.Stringer("finality", ev.Finality), zap.Any("event", ev)) - errC <- fmt.Errorf("unexpected finality in block: %v", ev.Finality) + if err := w.processNewBlock(ctx, ev); err != nil { + errC <- err //nolint:channelcheck // The watcher will exit anyway p2p.DefaultRegistry.AddErrorCount(w.chainID, 1) return nil } - w.updateNetworkStats(&stats) - - w.pendingMu.Lock() - for key, pLock := range w.pending { - // Don't process the observation if it is waiting on a different consistency level. - if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { - continue - } - - // Don't process the observation if we haven't reached the desired block height yet. - // CCL 'additionalBlocks' is used after consistency level handling. - // For instance, we need to wait for FINAL consistency level and then five more blocks. - if pLock.height+pLock.additionalBlocks > blockNumberU { - continue - } - - // Transaction is now ready - msm := time.Now() - timeout, cancel := context.WithTimeout(ctx, 5*time.Second) - tx, err := w.ethConn.TransactionReceipt(timeout, eth_common.BytesToHash(pLock.message.TxID)) - queryLatency.WithLabelValues(w.networkName, "transaction_receipt").Observe(time.Since(msm).Seconds()) - cancel() - - // SECURITY: Protection against reorgs that remove or change observations - // If the node returns an error after waiting expectedConfirmation blocks, - // it means the chain reorged and the transaction was orphaned. The - // TransactionReceipt call is using the same websocket connection than the - // head notifications, so it's guaranteed to be atomic. - // - // Check multiple possible error cases - the node seems to return a - // "not found" error most of the time, but it could conceivably also - // return a nil tx or rpc.ErrNoResult. - if tx == nil || errors.Is(err, rpc.ErrNoResult) || (err != nil && err.Error() == "not found") { - logger.Warn("tx was orphaned", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", pLock.message.TxIDString()), - zap.Stringer("blockHash", key.BlockHash), - zap.Uint64("observedHeight", pLock.height), - zap.Uint64("additionalBlocks", pLock.additionalBlocks), - zap.Stringer("current_blockNum", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockHash", currentHash), - zap.Error(err)) - delete(w.pending, key) - ethMessagesOrphaned.WithLabelValues(w.networkName, "not_found").Inc() - continue - } - - // SECURITY: Check that the tx succeeded. - // This should never happen - if we got this far, it means that logs were emitted, - // which is only possible if the transaction succeeded. We check it anyway just - // in case the EVM implementation is buggy. - if tx.Status != gethTypes.ReceiptStatusSuccessful { - logger.Error("transaction receipt with non-success status", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", pLock.message.TxIDString()), - zap.Stringer("blockHash", key.BlockHash), - zap.Uint64("observedHeight", pLock.height), - zap.Uint64("additionalBlocks", pLock.additionalBlocks), - zap.Stringer("current_blockNum", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockHash", currentHash), - zap.Error(err)) - delete(w.pending, key) - ethMessagesOrphaned.WithLabelValues(w.networkName, "tx_failed").Inc() - continue - } - - // Any error other than "not found" is likely transient - we retry next block. - if err != nil { - if pLock.height+MaxWaitConfirmations <= blockNumberU { - // An error from this "transient" case has persisted for more than MaxWaitConfirmations. - logger.Info("observation timed out", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", pLock.message.TxIDString()), - zap.Stringer("blockHash", key.BlockHash), - zap.Uint64("observedHeight", pLock.height), - zap.Uint64("additionalBlocks", pLock.additionalBlocks), - zap.Stringer("current_blockNum", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockHash", currentHash), - ) - ethMessagesOrphaned.WithLabelValues(w.networkName, "timeout").Inc() - delete(w.pending, key) - } else { - logger.Warn("transaction could not be fetched", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", pLock.message.TxIDString()), - zap.Stringer("blockHash", key.BlockHash), - zap.Uint64("observedHeight", pLock.height), - zap.Uint64("additionalBlocks", pLock.additionalBlocks), - zap.Stringer("current_blockNum", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockHash", currentHash), - zap.Error(err)) - } - continue - } - - // It's possible for a transaction to be orphaned and then included in a different block - // but with the same tx hash. Drop the observation (it will be re-observed and needs to - // wait for the full confirmation time again). - if tx.BlockHash != key.BlockHash { - logger.Info("tx got dropped and mined in a different block; the message should have been reobserved", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", pLock.message.TxIDString()), - zap.Stringer("blockHash", key.BlockHash), - zap.Uint64("observedHeight", pLock.height), - zap.Uint64("additionalBlocks", pLock.additionalBlocks), - zap.Stringer("current_blockNum", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockHash", currentHash), - ) - delete(w.pending, key) - ethMessagesOrphaned.WithLabelValues(w.networkName, "blockhash_mismatch").Inc() - continue - } - - logger.Info("observation confirmed", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", pLock.message.TxIDString()), - zap.Stringer("blockHash", key.BlockHash), - zap.Uint64("observedHeight", pLock.height), - zap.Uint64("additionalBlocks", pLock.additionalBlocks), - zap.Stringer("current_blockNum", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockHash", currentHash), - ) - delete(w.pending, key) - - // Note that `tx` here is actually a receipt - txHash := eth_common.Hash(pLock.message.TxID) - - // SECURITY: The TxHash existing within the particular block hash guarantees - // that the event exists without being modified. We don't - // check for the event mutating into something that's invalid (wrong event type, etc.) - // because of this. - pubErr := w.verifyAndPublish(pLock.message, ctx, txHash, tx) - if pubErr != nil { - logger.Error("could not publish message", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", txHash.String()), - zap.Error(pubErr), - ) - } - } - - w.pendingMu.Unlock() - logger.Debug("processed new header", - zap.Stringer("current_block", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockhash", currentHash), - zap.Duration("took", time.Since(start)), - ) } } }) @@ -1043,6 +867,186 @@ func (w *Watcher) postMessage( w.pendingMu.Unlock() } +// processNewBlock handles a new incoming block from the subscriber. It loops through all w.pending messages for processing. +func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) error { + start := time.Now() + logger := w.logger + stats := gossipv1.Heartbeat_Network{ContractAddress: w.contract.Hex()} + currentHash := ev.Hash + + blockNumberU := ev.Number.Uint64() + var thisConsistencyLevel uint8 + switch ev.Finality { + case connectors.Latest: + thisConsistencyLevel = vaa.ConsistencyLevelPublishImmediately + atomic.StoreUint64(&w.latestBlockNumber, blockNumberU) + currentEthHeight.WithLabelValues(w.networkName).Set(float64(blockNumberU)) + stats.Height = int64(blockNumberU) // #nosec G115 -- This conversion is safe indefinitely + w.ccqAddLatestBlock(ev) + case connectors.Safe: + thisConsistencyLevel = vaa.ConsistencyLevelSafe + atomic.StoreUint64(&w.latestSafeBlockNumber, blockNumberU) + currentEthSafeHeight.WithLabelValues(w.networkName).Set(float64(blockNumberU)) + stats.SafeHeight = int64(blockNumberU) // #nosec G115 -- This conversion is safe indefinitely + case connectors.Finalized: + thisConsistencyLevel = vaa.ConsistencyLevelFinalized + atomic.StoreUint64(&w.latestFinalizedBlockNumber, blockNumberU) + currentEthFinalizedHeight.WithLabelValues(w.networkName).Set(float64(blockNumberU)) + stats.FinalizedHeight = int64(blockNumberU) // #nosec G115 -- This conversion is safe indefinitely + default: + logger.Error("unexpected finality in block", zap.Stringer("finality", ev.Finality), zap.Any("event", ev)) + p2p.DefaultRegistry.AddErrorCount(w.chainID, 1) + return fmt.Errorf("unexpected finality in block: %v", ev.Finality) + } + w.updateNetworkStats(&stats) + + w.pendingMu.Lock() + for key, pLock := range w.pending { + // Don't process the observation if it is waiting on a different consistency level. + if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { + continue + } + + // Don't process the observation if we haven't reached the desired block height yet. + if pLock.height+pLock.additionalBlocks > blockNumberU { + continue + } + + // Transaction is now ready + msm := time.Now() + timeout, cancel := context.WithTimeout(ctx, 5*time.Second) + tx, err := w.ethConn.TransactionReceipt(timeout, eth_common.BytesToHash(pLock.message.TxID)) + queryLatency.WithLabelValues(w.networkName, "transaction_receipt").Observe(time.Since(msm).Seconds()) + cancel() + + // If the node returns an error after waiting expectedConfirmation blocks, + // it means the chain reorged and the transaction was orphaned. The + // TransactionReceipt call is using the same websocket connection than the + // head notifications, so it's guaranteed to be atomic. + // + // Check multiple possible error cases - the node seems to return a + // "not found" error most of the time, but it could conceivably also + // return a nil tx or rpc.ErrNoResult. + if tx == nil || errors.Is(err, rpc.ErrNoResult) || (err != nil && err.Error() == "not found") { + logger.Warn("tx was orphaned", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Uint64("observedHeight", pLock.height), + zap.Uint64("additionalBlocks", pLock.additionalBlocks), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockHash", currentHash), + zap.Error(err)) + delete(w.pending, key) + ethMessagesOrphaned.WithLabelValues(w.networkName, "not_found").Inc() + continue + } + + // This should never happen - if we got this far, it means that logs were emitted, + // which is only possible if the transaction succeeded. We check it anyway just + // in case the EVM implementation is buggy. + if tx.Status != 1 { + logger.Error("transaction receipt with non-success status", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Uint64("observedHeight", pLock.height), + zap.Uint64("additionalBlocks", pLock.additionalBlocks), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockHash", currentHash), + zap.Error(err)) + delete(w.pending, key) + ethMessagesOrphaned.WithLabelValues(w.networkName, "tx_failed").Inc() + continue + } + + // Any error other than "not found" is likely transient - we retry next block. + if err != nil { + if pLock.height+MaxWaitConfirmations <= blockNumberU { + // An error from this "transient" case has persisted for more than MaxWaitConfirmations. + logger.Info("observation timed out", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Uint64("observedHeight", pLock.height), + zap.Uint64("additionalBlocks", pLock.additionalBlocks), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockHash", currentHash), + ) + ethMessagesOrphaned.WithLabelValues(w.networkName, "timeout").Inc() + delete(w.pending, key) + } else { + logger.Warn("transaction could not be fetched", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Uint64("observedHeight", pLock.height), + zap.Uint64("additionalBlocks", pLock.additionalBlocks), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockHash", currentHash), + zap.Error(err)) + } + continue + } + + // It's possible for a transaction to be orphaned and then included in a different block + // but with the same tx hash. Drop the observation (it will be re-observed and needs to + // wait for the full confirmation time again). + if tx.BlockHash != key.BlockHash { + logger.Info("tx got dropped and mined in a different block; the message should have been reobserved", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Uint64("observedHeight", pLock.height), + zap.Uint64("additionalBlocks", pLock.additionalBlocks), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockHash", currentHash), + ) + delete(w.pending, key) + ethMessagesOrphaned.WithLabelValues(w.networkName, "blockhash_mismatch").Inc() + continue + } + + logger.Info("observation confirmed", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Uint64("observedHeight", pLock.height), + zap.Uint64("additionalBlocks", pLock.additionalBlocks), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockHash", currentHash), + ) + delete(w.pending, key) + + // Note that `tx` here is actually a receipt + txHash := eth_common.Hash(pLock.message.TxID) + pubErr := w.verifyAndPublish(pLock.message, ctx, txHash, tx) + if pubErr != nil { + logger.Error("could not publish message", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", txHash.String()), + zap.Error(pubErr), + ) + } + } + + w.pendingMu.Unlock() + logger.Debug("processed new header", + zap.Stringer("current_block", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockhash", currentHash), + zap.Duration("took", time.Since(start)), + ) + + return nil +} + // blockNotFoundErrors is used by `canRetryGetBlockTime`. It is a map of the error returns from `getBlockTime` that can trigger a retry. var blockNotFoundErrors = map[string]struct{}{ "not found": {}, diff --git a/node/pkg/watchers/evm/watcher_test.go b/node/pkg/watchers/evm/watcher_test.go index aea2c7131a8..e2fc2e0b788 100644 --- a/node/pkg/watchers/evm/watcher_test.go +++ b/node/pkg/watchers/evm/watcher_test.go @@ -4,10 +4,10 @@ import ( "context" "encoding/json" "errors" + "math/big" "testing" "github.com/certusone/wormhole/node/pkg/common" - "github.com/certusone/wormhole/node/pkg/txverifier" "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors" "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors/ethabi" ethereum "github.com/ethereum/go-ethereum" @@ -17,7 +17,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wormhole-foundation/wormhole/sdk/vaa" - "go.uber.org/zap" ) func TestMsgIdFromLogEvent(t *testing.T) { @@ -180,37 +179,6 @@ func TestVerifyAndPublish(t *testing.T) { require.Equal(t, common.Valid.String(), publishedMsg.VerificationState().String()) } -// Helper function to set up a test Ethereum Watcher -func NewWatcherForTest(t *testing.T, msgC chan<- *common.MessagePublication) *Watcher { - t.Helper() - logger := zap.NewNop() - - w := &Watcher{ - // this is implicit but added here for clarity - txVerifierEnabled: false, - msgC: msgC, - logger: logger, - } - - return w -} - -type MockTransferVerifier[E ethclient.Client, C connectors.Connector] struct { - success bool -} - -// TransferIsValid simulates the evaluation made by the Transfer Verifier. -// Always returns nil. The error should be non-nil only when a parsing or RPC error occurs. -// For now, these are not included in the unit tests. -func (m *MockTransferVerifier[E, C]) TransferIsValid(_ context.Context, _ string, _ eth_common.Hash, _ *types.Receipt) (bool, error) { - return m.success, nil -} -func (m *MockTransferVerifier[E, C]) Addrs() *txverifier.TVAddresses { - return &txverifier.TVAddresses{ - TokenBridgeAddr: eth_common.BytesToAddress([]byte{0x01}), - } -} - // TestVerifyDoesNotMutateOriginalMessage checks that verify() does not modify // the original MessagePublication passed to it. func TestVerifyDoesNotMutateOriginalMessage(t *testing.T) { @@ -235,6 +203,719 @@ func TestVerifyDoesNotMutateOriginalMessage(t *testing.T) { require.Equal(t, common.NotVerified.String(), msg.VerificationState().String()) } +// Several test cases for a pending message getting processed depending on the block number +func TestProcessBlockPendingByFinality(t *testing.T) { + tests := []struct { + name string + cl uint8 + finality connectors.FinalityLevel + blockNumber uint64 + expectPending int + expectPublish bool + }{ + {"finalized", vaa.ConsistencyLevelFinalized, connectors.Finalized, 105, 0, true}, + {"safe", vaa.ConsistencyLevelSafe, connectors.Safe, 105, 0, true}, + {"instant", vaa.ConsistencyLevelPublishImmediately, connectors.Latest, 105, 0, true}, + {"finalized_before_block", vaa.ConsistencyLevelFinalized, connectors.Finalized, 99, 1, false}, + {"finalized_with_safe_block", vaa.ConsistencyLevelFinalized, connectors.Safe, 105, 1, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + w.addPendingMsg(txHash, blockHash, 100, tc.cl, 0, 1) + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + + err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality)) + require.NoError(t, err) + + assert.Equal(t, tc.expectPending, len(w.pending)) + if tc.expectPublish { + require.Equal(t, 1, len(msgC)) + assert.Equal(t, txHash.Bytes(), (<-msgC).TxID) + } else { + assert.Equal(t, 0, len(msgC)) + } + }) + } +} + +// Handling both a finalized and a safe message +func TestProcessBlockPendingFinalizedAndSafe(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash1 := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + txHash2 := eth_common.HexToHash("0xe2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ee") + blockHash1 := eth_common.BigToHash(big.NewInt(111)) + blockHash2 := eth_common.BigToHash(big.NewInt(222)) + + w.addPendingMsg(txHash1, blockHash1, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash2, blockHash2, 100, vaa.ConsistencyLevelSafe, 0, 1) + mock.receipts[txHash1] = &types.Receipt{Status: 1, BlockHash: blockHash1} + mock.receipts[txHash2] = &types.Receipt{Status: 1, BlockHash: blockHash2} + + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Removed one from pending + assert.Equal(t, 1, len(w.pending)) + + // Published finalized message + require.Equal(t, 1, len(msgC)) + assert.Equal(t, txHash1.Bytes(), (<-msgC).TxID) + + err = w.processNewBlock(context.TODO(), newBlock(105, connectors.Safe)) + require.NoError(t, err) + + // Removed both + assert.Equal(t, 0, len(w.pending)) + + // Published safe message + require.Equal(t, 1, len(msgC)) + assert.Equal(t, txHash2.Bytes(), (<-msgC).TxID) +} + +// Removal of the message without publication if the blockhash differs +func TestProcessBlockPendingWrongBlockHash(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHashBlock := eth_common.BigToHash(big.NewInt(111)) + blockHashMessage := eth_common.BigToHash(big.NewInt(222)) + + w.addPendingMsg(txHash, blockHashMessage, 100, vaa.ConsistencyLevelFinalized, 0, 1) + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHashBlock} + + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Removed from pending + assert.Equal(t, 0, len(w.pending)) + + // Not published + assert.Equal(t, 0, len(msgC)) +} + +// Failed transaction status gets rejected +func TestProcessBlockPendingFailedTx(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + mock.receipts[txHash] = &types.Receipt{Status: 0, BlockHash: blockHash} + + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Removed from pending + assert.Equal(t, 0, len(w.pending)) + + // Not published + assert.Equal(t, 0, len(msgC)) +} + +// Failed receipt test case +func TestProcessBlockValidReceiptWithError(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.errors[txHash] = errors.New("not found") + + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Removed from pending + assert.Equal(t, 0, len(w.pending)) + + // Not published + assert.Equal(t, 0, len(msgC)) +} + +// No receipt is found. This should remove from the pending list. +func TestProcessBlockInValidReceiptNoError(t *testing.T) { + w, _, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Removed from pending + assert.Equal(t, 0, len(w.pending)) + + // Not published + assert.Equal(t, 0, len(msgC)) +} + +// Transient errors on receipt RPC requests should be retried +func TestProcessBlockTransientError(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.errors[txHash] = errors.New("transient error") + + w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Not removed from pending + assert.Equal(t, 1, len(w.pending)) + + // Not published + assert.Equal(t, 0, len(msgC)) + + // Try the message again after the transient error has disappeared + mock.errors[txHash] = nil + err = w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Removed from pending + assert.Equal(t, 0, len(w.pending)) + + // Published with correct TxID + require.Equal(t, 1, len(msgC)) + assert.Equal(t, txHash.Bytes(), (<-msgC).TxID) +} + +// AdditionalBlocks test cases for waiting the proper amount of time before publication +func TestProcessBlockAdditionalBlocks(t *testing.T) { + tests := []struct { + name string + cl uint8 + finality connectors.FinalityLevel + additionalBlocks uint64 + blockNumber uint64 + expectPending int + expectPublish bool + }{ + {"before", vaa.ConsistencyLevelFinalized, connectors.Finalized, 20, 100, 1, false}, + {"before_by_one", vaa.ConsistencyLevelFinalized, connectors.Finalized, 20, 119, 1, false}, + {"exact", vaa.ConsistencyLevelFinalized, connectors.Finalized, 20, 120, 0, true}, + {"none", vaa.ConsistencyLevelPublishImmediately, connectors.Latest, 0, 100, 0, true}, + {"maximum", vaa.ConsistencyLevelPublishImmediately, connectors.Latest, 65535, 100 + 65535, 0, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + w.addPendingMsg(txHash, blockHash, 100, tc.cl, tc.additionalBlocks, 1) + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + + err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality)) + require.NoError(t, err) + + assert.Equal(t, tc.expectPending, len(w.pending)) + if tc.expectPublish { + require.Equal(t, 1, len(msgC)) + assert.Equal(t, txHash.Bytes(), (<-msgC).TxID) + } else { + assert.Equal(t, 0, len(msgC)) + } + }) + } +} + +// Effective consistency level (CL) and the VAA CL should differ. +func TestProcessBlockCCLEffectiveCLDiffersFromMessageCL(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + // Simulate CCL: message.ConsistencyLevel = Custom, but effectiveCL = Finalized + key := w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 5, 1) + w.pending[key].message.ConsistencyLevel = vaa.ConsistencyLevelCustom + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + + // Finalized block at height+additionalBlocks: should confirm based on effectiveCL + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Removed from pending and published + assert.Equal(t, 0, len(w.pending)) + require.Equal(t, 1, len(msgC)) + + // The published message must still have ConsistencyLevelCustom for VAA hash consistency + published := <-msgC + assert.Equal(t, vaa.ConsistencyLevelCustom, published.ConsistencyLevel) +} + +// Pending messages with different finalizations and block times +func TestProcessBlockCCLMultiplePendingDifferentAdditionalBlocks(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + + txHashA := eth_common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + txHashB := eth_common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + // Message A: effectiveCL=Finalized, additionalBlocks=0, height=100 -> ready at block 100 + w.addPendingMsg(txHashA, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + mock.receipts[txHashA] = &types.Receipt{Status: 1, BlockHash: blockHash} + + // Message B: effectiveCL=Finalized, additionalBlocks=10, height=100 -> ready at block 110 + w.addPendingMsg(txHashB, blockHash, 100, vaa.ConsistencyLevelFinalized, 10, 2) + mock.receipts[txHashB] = &types.Receipt{Status: 1, BlockHash: blockHash} + + // Send finalized block at 105: A should confirm, B should stay pending + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + assert.Equal(t, 1, len(w.pending), "only message B should remain pending") + require.Equal(t, 1, len(msgC), "only message A should be published") + assert.Equal(t, txHashA.Bytes(), (<-msgC).TxID) + + // Send finalized block at 110: B should now confirm + err = w.processNewBlock(context.TODO(), newBlock(110, connectors.Finalized)) + require.NoError(t, err) + + assert.Equal(t, 0, len(w.pending), "no messages should remain pending") + require.Equal(t, 1, len(msgC), "message B should now be published") + assert.Equal(t, txHashB.Bytes(), (<-msgC).TxID) +} + +// Orphaned tx handling +func TestProcessBlockOneConfirmedOneOrphaned(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + blockHash := eth_common.BigToHash(big.NewInt(100)) + + txHashGood := eth_common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + txHashOrphaned := eth_common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + + w.addPendingMsg(txHashGood, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHashOrphaned, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 2) + + // Good tx has a valid receipt + mock.receipts[txHashGood] = &types.Receipt{Status: 1, BlockHash: blockHash} + + // Orphaned tx returns nil receipt (not in mock.receipts, so defaults to nil) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + // Both removed from pending + assert.Equal(t, 0, len(w.pending)) + + // Only the valid message was published + assert.Equal(t, 1, len(msgC)) + + published := <-msgC + assert.Equal(t, txHashGood.Bytes(), published.TxID) +} + +// Invalid finality level process. Should return an error. +func TestProcessBlockUnexpectedFinality(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + err := w.processNewBlock(context.TODO(), newBlock(100, connectors.FinalityLevel(99))) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected finality in block") + + // Nothing published + assert.Equal(t, 0, len(msgC)) +} + +// TxVerifier support in processBlock +func TestProcessBlockTxVerifier(t *testing.T) { + tests := []struct { + name string + success bool + useTokenBridge bool + expectState common.VerificationState + }{ + {"success", true, true, common.Valid}, + {"failure", false, true, common.Rejected}, + {"not_applicable", false, false, common.NotApplicable}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.txVerifier = &MockTransferVerifier[ethclient.Client, connectors.Connector]{success: tc.success} + + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + + key := w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + if tc.useTokenBridge { + w.pending[key].message.EmitterAddress = PadAddress(testTokenBridge) + } + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + require.NoError(t, err) + + assert.Equal(t, 0, len(w.pending)) + require.Equal(t, 1, len(msgC)) + + msg := <-msgC + assert.Equal(t, txHash.Bytes(), msg.TxID) + assert.Equal(t, tc.expectState, msg.VerificationState()) + }) + } +} + +// Safe and finalized add to pending in postMessage +func TestPostMessageAddsToPending(t *testing.T) { + tests := []struct { + name string + cl uint8 + }{ + {"finalized", vaa.ConsistencyLevelFinalized}, + {"safe", vaa.ConsistencyLevelSafe}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + ev := newTestLogEvent(100, tc.cl) + w.postMessage(context.TODO(), ev, 1234) + + require.Equal(t, 1, len(w.pending)) + assert.Equal(t, 0, len(msgC)) + + key := pendingKey{ + TxHash: ev.Raw.TxHash, + BlockHash: ev.Raw.BlockHash, + EmitterAddress: PadAddress(ev.Sender), + Sequence: ev.Sequence, + } + pe := w.pending[key] + require.NotNil(t, pe) + + assertMessageMatchesEvent(t, pe.message, ev, 1234) + assertPendingMetadata(t, pe, tc.cl, 100, 0) + }) + } +} + +// Custom consistency level (CCL) edge cases lead to finalized by default +func TestPostMessageCustomDefaultToFinalized(t *testing.T) { + tests := []struct { + name string + setupCCL func(w *Watcher) + }{ + {"ccl_disabled", nil}, + {"ccl_enabled_nothing_special", func(w *Watcher) { + w.enableCCL() + w.seedCCLNothingSpecial(testEmitter) + }}, + {"invalid_additional_blocks_config", func(w *Watcher) { + w.enableCCL() + w.seedCCLAdditionalBlocks(testEmitter, 1, 1) + }}, + {"invalid_type", func(w *Watcher) { + w.enableCCL() + w.seedCCLInvalidType(testEmitter) + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + if tc.setupCCL != nil { + tc.setupCCL(w) + } + + ev := newTestLogEvent(100, vaa.ConsistencyLevelCustom) + w.postMessage(context.TODO(), ev, 1234) + + require.Equal(t, 1, len(w.pending)) + assert.Equal(t, 0, len(msgC)) + + key := pendingKey{ + TxHash: ev.Raw.TxHash, + BlockHash: ev.Raw.BlockHash, + EmitterAddress: PadAddress(ev.Sender), + Sequence: ev.Sequence, + } + pe := w.pending[key] + require.NotNil(t, pe) + assertMessageMatchesEvent(t, pe.message, ev, 1234) + assertPendingMetadata(t, pe, vaa.ConsistencyLevelFinalized, 100, 0) + }) + } +} + +// AdditionalBlocks basic testing +func TestPostMessageCustomAdditionalBlocks(t *testing.T) { + tests := []struct { + name string + effectiveCL uint8 + additionalBlocks uint16 + }{ + {"finalized", vaa.ConsistencyLevelFinalized, 101}, + {"safe", vaa.ConsistencyLevelSafe, 50}, + {"instant", vaa.ConsistencyLevelPublishImmediately, 10}, + {"zero_blocks", vaa.ConsistencyLevelFinalized, 0}, + {"one_block", vaa.ConsistencyLevelFinalized, 1}, + {"small_blocks", vaa.ConsistencyLevelFinalized, 5}, + {"medium_blocks", vaa.ConsistencyLevelFinalized, 500}, + {"large_blocks", vaa.ConsistencyLevelFinalized, 10000}, + {"max_uint16", vaa.ConsistencyLevelFinalized, 0xFFFF}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + w.enableCCL() + w.seedCCLAdditionalBlocks(testEmitter, tc.effectiveCL, tc.additionalBlocks) + ev := newTestLogEvent(100, vaa.ConsistencyLevelCustom) + w.postMessage(context.TODO(), ev, 1234) + + require.Equal(t, 1, len(w.pending)) + assert.Equal(t, 0, len(msgC)) + + key := pendingKey{ + TxHash: ev.Raw.TxHash, + BlockHash: ev.Raw.BlockHash, + EmitterAddress: PadAddress(ev.Sender), + Sequence: ev.Sequence, + } + pe := w.pending[key] + require.NotNil(t, pe) + assertMessageMatchesEvent(t, pe.message, ev, 1234) + assertPendingMetadata(t, pe, tc.effectiveCL, 100, uint64(tc.additionalBlocks)) + }) + } +} + +// Instant message is published instead of being added to the pending queue +func TestPostMessageInstantPublishes(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + w.postMessage(context.TODO(), ev, 1234) + + // Should be published instead of being added to the pending queue + require.Equal(t, 0, len(w.pending)) + assert.Equal(t, 1, len(msgC)) + + msg := <-msgC + + assertMessageMatchesEvent(t, msg, ev, 1234) + assert.Equal(t, common.NotVerified, msg.VerificationState()) +} + +// Multiple instant publishes are handled properly +func TestPostMessageTwoInstantPublishes(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + ev1 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + + ev2 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev2.Sender = eth_common.HexToAddress("0x388C818CA8B9251b393131C08a736A67ccB19297") + ev2.Nonce = 20 + ev2.Sequence = 2 + + w.postMessage(context.TODO(), ev1, 1234) + w.postMessage(context.TODO(), ev2, 1234) + + // Should be added to pending, not published immediately + require.Equal(t, 0, len(w.pending)) + assert.Equal(t, 2, len(msgC)) + + msg1 := <-msgC + msg2 := <-msgC + + assertMessageMatchesEvent(t, msg1, ev1, 1234) + assert.Equal(t, common.NotVerified, msg1.VerificationState()) + + assertMessageMatchesEvent(t, msg2, ev2, 1234) + assert.Equal(t, common.NotVerified, msg2.VerificationState()) +} + +// An instant and a final message go to the proper spots (msgC and pending) +func TestPostMessageInstantAndFinalized(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + // ev1: instant publish + ev1 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + + // ev2: finalized (goes to pending) — use a different emitter and sequence + ev2 := newTestLogEventFromParams(testLogEventParams{ + sender: eth_common.HexToAddress("0x388C818CA8B9251b393131C08a736A67ccB19297"), + sequence: 2, + blockNumber: 100, + consistencyLevel: vaa.ConsistencyLevelFinalized, + }) + + w.postMessage(context.TODO(), ev1, 1234) + w.postMessage(context.TODO(), ev2, 1234) + + // One message published immediately, one added to pending. + require.Equal(t, 1, len(w.pending)) + assert.Equal(t, 1, len(msgC)) + + // Verify the instant-published message + msg := <-msgC + assertMessageMatchesEvent(t, msg, ev1, 1234) + assert.Equal(t, common.NotVerified, msg.VerificationState()) + + // Verify the finalized pending entry + key := pendingKey{ + TxHash: ev2.Raw.TxHash, + BlockHash: ev2.Raw.BlockHash, + EmitterAddress: PadAddress(ev2.Sender), + Sequence: ev2.Sequence, + } + pe := w.pending[key] + require.NotNil(t, pe) + + assertMessageMatchesEvent(t, pe.message, ev2, 1234) + assertPendingMetadata(t, pe, vaa.ConsistencyLevelFinalized, 100, 0) +} + +// Transaction contains multiple events +func TestPostMessageMultipleEventsFromSameTransaction(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.HexToHash("0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1") + + ev1 := newTestLogEventFromParams(testLogEventParams{ + sender: testEmitter, + sequence: 1, + blockNumber: 100, + consistencyLevel: vaa.ConsistencyLevelFinalized, + txHash: txHash, + blockHash: blockHash, + }) + ev2 := newTestLogEventFromParams(testLogEventParams{ + sender: testEmitter, + sequence: 2, + blockNumber: 100, + consistencyLevel: vaa.ConsistencyLevelFinalized, + txHash: txHash, + blockHash: blockHash, + }) + + w.postMessage(context.TODO(), ev1, 1234) + w.postMessage(context.TODO(), ev2, 1234) + + assert.Equal(t, 0, len(msgC)) + require.Equal(t, 2, len(w.pending)) + + key1 := pendingKey{ + TxHash: txHash, + BlockHash: blockHash, + EmitterAddress: PadAddress(testEmitter), + Sequence: 1, + } + key2 := pendingKey{ + TxHash: txHash, + BlockHash: blockHash, + EmitterAddress: PadAddress(testEmitter), + Sequence: 2, + } + + pe1 := w.pending[key1] + pe2 := w.pending[key2] + require.NotNil(t, pe1) + require.NotNil(t, pe2) + + assert.Equal(t, txHash.Bytes(), pe1.message.TxID) + assert.Equal(t, uint64(1), pe1.message.Sequence) + assert.Equal(t, txHash.Bytes(), pe2.message.TxID) + assert.Equal(t, uint64(2), pe2.message.Sequence) +} + +func TestPostMessageRemovedLogIsIgnored(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev.Raw.Removed = true + + w.postMessage(context.TODO(), ev, 1234) + + assert.Equal(t, 0, len(msgC), "removed log should not be published to msgC") + assert.Equal(t, 0, len(w.pending), "removed log should not be added to pending") +} + +func TestPostMessageWrongContractAddressIsIgnored(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev.Raw.Address = eth_common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + + w.postMessage(context.TODO(), ev, 1234) + + assert.Equal(t, 0, len(msgC), "log from wrong contract should not be published to msgC") + assert.Equal(t, 0, len(w.pending), "log from wrong contract should not be added to pending") +} + +func TestPostMessageWrongEventSignatureIsIgnored(t *testing.T) { + w, _, msgC := newTestWatcher(t) + + ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev.Raw.Topics = []eth_common.Hash{ + eth_common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + eth_common.BytesToHash(testEmitter.Bytes()), + } + + w.postMessage(context.TODO(), ev, 1234) + + assert.Equal(t, 0, len(msgC), "log with wrong event signature should not be published to msgC") + assert.Equal(t, 0, len(w.pending), "log with wrong event signature should not be added to pending") +} + +// TxVerifier is used on postMessage +func TestPostMessageTxVerifier(t *testing.T) { + tests := []struct { + name string + success bool + useTokenBridge bool + expectState common.VerificationState + }{ + {"success", true, true, common.Valid}, + {"failure", false, true, common.Rejected}, + {"not_applicable", false, false, common.NotApplicable}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, _, msgC := newTestWatcher(t) + w.txVerifier = &MockTransferVerifier[ethclient.Client, connectors.Connector]{success: tc.success} + + sender := testEmitter + if tc.useTokenBridge { + sender = testTokenBridge + } + ev := newTestLogEventFromParams(testLogEventParams{ + sender: sender, + sequence: 1, + blockNumber: 100, + consistencyLevel: vaa.ConsistencyLevelPublishImmediately, + }) + w.postMessage(context.TODO(), ev, 1234) + + require.Equal(t, 0, len(w.pending)) + require.Equal(t, 1, len(msgC)) + + msg := <-msgC + assertMessageMatchesEvent(t, msg, ev, 1234) + assert.Equal(t, tc.expectState, msg.VerificationState()) + }) + } +} + +/* +TODO - add failed transaction status to the flow. +- Requires mocking the receipt gathering in every RPC call. +*/ + func TestConsistencyLevelMatches(t *testing.T) { // Success cases. assert.True(t, consistencyLevelMatches(vaa.ConsistencyLevelPublishImmediately, vaa.ConsistencyLevelPublishImmediately)) diff --git a/node/pkg/watchers/evm/watcher_test_helpers_test.go b/node/pkg/watchers/evm/watcher_test_helpers_test.go new file mode 100644 index 00000000000..a98314e25e4 --- /dev/null +++ b/node/pkg/watchers/evm/watcher_test_helpers_test.go @@ -0,0 +1,362 @@ +package evm + +import ( + "context" + "encoding/binary" + "math/big" + "testing" + "time" + + "github.com/certusone/wormhole/node/pkg/common" + "github.com/certusone/wormhole/node/pkg/txverifier" + "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors" + dgAbi "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors/delegated_guardians" + "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors/ethabi" + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + eth_common "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/event" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wormhole-foundation/wormhole/sdk/vaa" + "go.uber.org/zap" +) + +// testEmitter is a default non-zero emitter address for use across tests. +var testEmitter = eth_common.HexToAddress("0x0290FB167208Af455bB137780163b7B7a9a10C16") + +// testTokenBridge is the token bridge address used by MockTransferVerifier. +var testTokenBridge = eth_common.HexToAddress("0x3ee18B2214AFF97000D974cf647E7C347E8fa585") + +// NewWatcherForTest creates a minimal Watcher for verifyAndPublish tests. +func NewWatcherForTest(t *testing.T, msgC chan<- *common.MessagePublication) *Watcher { + t.Helper() + logger := zap.NewNop() + + w := &Watcher{ + // this is implicit but added here for clarity + txVerifierEnabled: false, + msgC: msgC, + logger: logger, + } + + return w +} + +// newTestWatcher creates a Watcher wired up with a mockConnector for processNewBlock and postMessage tests. +// Returns the watcher, the mock (for configuring receipts/errors), and the msgC channel (for reading published messages). +func newTestWatcher(t *testing.T) (*Watcher, *mockConnector, chan *common.MessagePublication) { + t.Helper() + mock := newMockConnector() + msgC := make(chan *common.MessagePublication, 10) + + w := &Watcher{ + logger: zap.NewNop(), + cclLogger: zap.NewNop(), + ethConn: mock, + msgC: msgC, + pending: make(map[pendingKey]*pendingMessage), + networkName: "test", + chainID: vaa.ChainIDEthereum, + } + + return w, mock, msgC +} + +type MockTransferVerifier[E ethclient.Client, C connectors.Connector] struct { + success bool +} + +// TransferIsValid simulates the evaluation made by the Transfer Verifier. +// Always returns nil. The error should be non-nil only when a parsing or RPC error occurs. +// For now, these are not included in the unit tests. +func (m *MockTransferVerifier[E, C]) TransferIsValid(_ context.Context, _ string, _ eth_common.Hash, _ *types.Receipt) (bool, error) { + return m.success, nil +} +func (m *MockTransferVerifier[E, C]) Addrs() *txverifier.TVAddresses { + return &txverifier.TVAddresses{ + TokenBridgeAddr: testTokenBridge, + } +} + +// mockConnector is a minimal mock of the connectors.Connector interface for testing +// processNewBlock and postMessage. Only TransactionReceipt has real behavior; all other methods panic +// because they should not be called in these tests. +type mockConnector struct { + // receipts maps txHash -> receipt to return + receipts map[eth_common.Hash]*types.Receipt + // errors maps txHash -> error to return + errors map[eth_common.Hash]error +} + +func newMockConnector() *mockConnector { + return &mockConnector{ + receipts: make(map[eth_common.Hash]*types.Receipt), + errors: make(map[eth_common.Hash]error), + } +} + +func (m *mockConnector) TransactionReceipt(_ context.Context, txHash eth_common.Hash) (*types.Receipt, error) { + return m.receipts[txHash], m.errors[txHash] +} + +// Stub implementations that are required by the interface +func (m *mockConnector) NetworkName() string { + panic("not implemented") +} +func (m *mockConnector) ContractAddress() eth_common.Address { + panic("not implemented") +} +func (m *mockConnector) GetCurrentGuardianSetIndex(ctx context.Context) (uint32, error) { + panic("not implemented") +} +func (m *mockConnector) GetGuardianSet(ctx context.Context, index uint32) (ethabi.StructsGuardianSet, error) { + panic("not implemented") +} +func (m *mockConnector) GetDelegatedGuardianConfig(ctx context.Context) ([]dgAbi.WormholeDelegatedGuardiansDelegatedGuardianSet, error) { + panic("not implemented") +} +func (m *mockConnector) WatchLogMessagePublished(ctx context.Context, errC chan error, sink chan<- *ethabi.AbiLogMessagePublished) (event.Subscription, error) { + panic("not implemented") +} +func (m *mockConnector) TimeOfBlockByHash(ctx context.Context, hash eth_common.Hash) (uint64, error) { + panic("not implemented") +} +func (m *mockConnector) ParseLogMessagePublished(log types.Log) (*ethabi.AbiLogMessagePublished, error) { + panic("not implemented") +} +func (m *mockConnector) SubscribeForBlocks(ctx context.Context, errC chan error, sink chan<- *connectors.NewBlock) (ethereum.Subscription, error) { + panic("not implemented") +} +func (m *mockConnector) GetLatest(ctx context.Context) (latest, finalized, safe uint64, err error) { + panic("not implemented") +} +func (m *mockConnector) RawCallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error { + panic("not implemented") +} +func (m *mockConnector) RawBatchCallContext(ctx context.Context, b []rpc.BatchElem) error { + panic("not implemented") +} +func (m *mockConnector) Client() *ethclient.Client { + panic("not implemented") +} +func (m *mockConnector) SubscribeNewHead(ctx context.Context, ch chan<- *types.Header) (ethereum.Subscription, error) { + panic("not implemented") +} + +// newBlock creates a *connectors.NewBlock with sensible defaults for testing. +// Hash is deterministically derived from the block number. +func newBlock(number uint64, finality connectors.FinalityLevel) *connectors.NewBlock { + return &connectors.NewBlock{ + Number: big.NewInt(int64(number)), + Hash: eth_common.BigToHash(big.NewInt(int64(number))), + Time: number, + Finality: finality, + } +} + +// addPendingMsg adds an entry to w.pending and returns the key. +// The MessagePublication is constructed with TxID, EmitterChain, and ConsistencyLevel set from the arguments. +func (w *Watcher) addPendingMsg( + txHash eth_common.Hash, + blockHash eth_common.Hash, + height uint64, + effectiveCL uint8, + additionalBlocks uint64, + sequence uint64, +) pendingKey { + key := pendingKey{ + TxHash: txHash, + BlockHash: blockHash, + Sequence: sequence, + } + + msg := &common.MessagePublication{ + TxID: txHash.Bytes(), + EmitterChain: w.chainID, + } + + w.pending[key] = &pendingMessage{ + message: msg, + height: height, + effectiveCL: effectiveCL, + additionalBlocks: additionalBlocks, + } + + return key +} + +// enableCCL enables the CCL feature on the watcher and initializes the cache. +func (w *Watcher) enableCCL() { + w.cclEnabled = true + w.cclLogger = zap.NewNop() + w.cclCache = CCLCache{} +} + +// seedCCLAdditionalBlocks pre-populates the CCL cache for emitter with an AdditionalBlocks config. +// Allows for mocking, since no RPC calls will be made. +func (w *Watcher) seedCCLAdditionalBlocks(emitter eth_common.Address, consistencyLevel uint8, additionalBlocks uint16) { + var data [32]byte + data[0] = byte(AdditionalBlocksType) + data[1] = consistencyLevel + binary.BigEndian.PutUint16(data[2:4], additionalBlocks) + + w.cclCache[emitter] = CCLCacheEntry{ + data: data, + readTime: time.Now(), + } +} + +// seedCCLNothingSpecial pre-populates the CCL cache for emitter with a NothingSpecial config +// (all zeros), meaning no custom handling — treated as finalized. +func (w *Watcher) seedCCLNothingSpecial(emitter eth_common.Address) { + w.cclCache[emitter] = CCLCacheEntry{ + data: cclEmptyData, + readTime: time.Now(), + } +} + +// seedCCLInvalidType pre-populates the CCL cache for emitter with an invalid type byte, +// triggering a parse error in cclReadAndParseConfig. +func (w *Watcher) seedCCLInvalidType(emitter eth_common.Address) { + var data [32]byte + data[0] = 0xFF // invalid CCL request type + + w.cclCache[emitter] = CCLCacheEntry{ + data: data, + readTime: time.Now(), + } +} + +// testLogParams holds parameters for constructing a types.Log with ABI-encoded data +// matching the LogMessagePublished event. Only fields that affect control flow are exposed; +// nonce and payload are hardcoded to zero/empty. +type testLogParams struct { + sender eth_common.Address + sequence uint64 + consistencyLevel uint8 + txHash eth_common.Hash + blockHash eth_common.Hash + blockNumber uint64 + contractAddr eth_common.Address +} + +// logMessagePublishedArgs returns the ABI argument types for the non-indexed fields of +// LogMessagePublished(address indexed sender, uint64 sequence, uint32 nonce, bytes payload, uint8 consistencyLevel). +func logMessagePublishedArgs() abi.Arguments { + uint64Ty, _ := abi.NewType("uint64", "", nil) + uint32Ty, _ := abi.NewType("uint32", "", nil) + bytesTy, _ := abi.NewType("bytes", "", nil) + uint8Ty, _ := abi.NewType("uint8", "", nil) + + return abi.Arguments{ + {Name: "sequence", Type: uint64Ty}, + {Name: "nonce", Type: uint32Ty}, + {Name: "payload", Type: bytesTy}, + {Name: "consistencyLevel", Type: uint8Ty}, + } +} + +// newTestLog builds a types.Log with properly ABI-encoded data for the LogMessagePublished event. +// Use this for testing ParseLogMessagePublished and MessageEventsForTransaction. +// To test failure modes, mutate the returned log directly (e.g. truncate Data, swap Topics). +func newTestLog(t *testing.T, p testLogParams) types.Log { + t.Helper() + + txHash := p.txHash + if txHash == (eth_common.Hash{}) { + txHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber))) + } + blockHash := p.blockHash + if blockHash == (eth_common.Hash{}) { + blockHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber + 0xff))) + } + + data, err := logMessagePublishedArgs().Pack(p.sequence, uint32(0), []byte{}, p.consistencyLevel) + require.NoError(t, err) + + senderTopic := eth_common.BytesToHash(p.sender.Bytes()) + + return types.Log{ + Address: p.contractAddr, + Topics: []eth_common.Hash{LogMessagePublishedTopic, senderTopic}, + Data: data, + BlockNumber: p.blockNumber, + TxHash: txHash, + TxIndex: 0, + BlockHash: blockHash, + Index: 0, + Removed: false, + } +} + +// testLogEventParams holds parameters for constructing an AbiLogMessagePublished for postMessage tests. +// Only fields that affect control flow are exposed; nonce and payload are left at zero values. +type testLogEventParams struct { + sender eth_common.Address + sequence uint64 + consistencyLevel uint8 + txHash eth_common.Hash + blockHash eth_common.Hash + blockNumber uint64 +} + +// newTestLogEvent creates an AbiLogMessagePublished with sensible non-zero defaults for postMessage tests. +// Only blockNumber and consistencyLevel are required; sender and sequence get deterministic non-zero values. +func newTestLogEvent(blockNumber uint64, consistencyLevel uint8) *ethabi.AbiLogMessagePublished { + return newTestLogEventFromParams(testLogEventParams{ + sender: testEmitter, + sequence: 1, + blockNumber: blockNumber, + consistencyLevel: consistencyLevel, + }) +} + +// newTestLogEventFromParams creates an AbiLogMessagePublished with full control over fields +// that affect control flow (sender, sequence, consistencyLevel, hashes). +func newTestLogEventFromParams(p testLogEventParams) *ethabi.AbiLogMessagePublished { + txHash := p.txHash + if txHash == (eth_common.Hash{}) { + txHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber))) + } + blockHash := p.blockHash + if blockHash == (eth_common.Hash{}) { + blockHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber + 0xff))) + } + + return ðabi.AbiLogMessagePublished{ + Sender: p.sender, + Sequence: p.sequence, + Payload: []byte{}, + ConsistencyLevel: p.consistencyLevel, + Raw: types.Log{ + TxHash: txHash, + BlockHash: blockHash, + BlockNumber: p.blockNumber, + }, + } +} + +// assertMessageMatchesEvent verifies that all fields on a MessagePublication match the source event. +func assertMessageMatchesEvent(t *testing.T, msg *common.MessagePublication, ev *ethabi.AbiLogMessagePublished, blockTime int64) { + t.Helper() + assert.Equal(t, ev.Raw.TxHash.Bytes(), msg.TxID) + assert.Equal(t, time.Unix(blockTime, 0), msg.Timestamp) + assert.Equal(t, ev.Nonce, msg.Nonce) + assert.Equal(t, ev.Sequence, msg.Sequence) + assert.Equal(t, vaa.ChainIDEthereum, msg.EmitterChain) + assert.Equal(t, PadAddress(ev.Sender), msg.EmitterAddress) + assert.Equal(t, ev.Payload, msg.Payload) + assert.Equal(t, ev.ConsistencyLevel, msg.ConsistencyLevel) +} + +// assertPendingMetadata verifies the metadata fields on a pendingMessage. +func assertPendingMetadata(t *testing.T, pe *pendingMessage, effectiveCL uint8, height uint64, additionalBlocks uint64) { + t.Helper() + assert.Equal(t, effectiveCL, pe.effectiveCL) + assert.Equal(t, height, pe.height) + assert.Equal(t, additionalBlocks, pe.additionalBlocks) +} From c4771e13b3108dba00af545fa4055d19c78cd80e Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Thu, 16 Apr 2026 14:06:04 -0700 Subject: [PATCH 02/10] Add reobservation test cases on EVM --- node/pkg/watchers/evm/by_transaction.go | 1 + node/pkg/watchers/evm/reobserve_test.go | 372 ++++++++++++++++++ .../watchers/evm/watcher_test_helpers_test.go | 97 ++++- 3 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 node/pkg/watchers/evm/reobserve_test.go diff --git a/node/pkg/watchers/evm/by_transaction.go b/node/pkg/watchers/evm/by_transaction.go index 3f467237879..ea51d5fc689 100644 --- a/node/pkg/watchers/evm/by_transaction.go +++ b/node/pkg/watchers/evm/by_transaction.go @@ -52,6 +52,7 @@ func MessageEventsForTransaction( tx eth_common.Hash) (*types.Receipt, uint64, []*common.MessagePublication, error) { // Get transactions logs from transaction + // API only returns transactions that have been included in a block. Nothing in the mempool receipt, err := ethConn.TransactionReceipt(ctx, tx) if err != nil { return nil, 0, nil, fmt.Errorf("failed to get transaction receipt: %w", err) diff --git a/node/pkg/watchers/evm/reobserve_test.go b/node/pkg/watchers/evm/reobserve_test.go new file mode 100644 index 00000000000..222f7652caa --- /dev/null +++ b/node/pkg/watchers/evm/reobserve_test.go @@ -0,0 +1,372 @@ +package evm + +import ( + "context" + "errors" + "testing" + + "github.com/certusone/wormhole/node/pkg/common" + "github.com/certusone/wormhole/node/pkg/txverifier" + "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors" + eth_common "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + "github.com/wormhole-foundation/wormhole/sdk/vaa" +) + +func TestReobserveSingleMessage(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) + mock.seedLog(log, 1234) + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(1), numObs) + require.Equal(t, 1, len(msgC)) + + ev, err := mock.ParseLogMessagePublished(*log) + require.NoError(t, err) + msg := <-msgC + require.True(t, msg.IsReobservation) + require.Equal(t, common.NotVerified, msg.VerificationState()) + assertMessageMatchesEvent(t, msg, ev, 1234) +} + +func TestReobserveInstant(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelPublishImmediately) + mock.seedLog(log, 1234) + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, 1, 1) + require.NoError(t, err) + require.Equal(t, uint32(1), numObs) + require.Equal(t, 1, len(msgC)) + + ev, err := mock.ParseLogMessagePublished(*log) + require.NoError(t, err) + msg := <-msgC + require.True(t, msg.IsReobservation) + assertMessageMatchesEvent(t, msg, ev, 1234) +} + +// TestReobserveSingleMessageEarly covers cases where the log's block number is ahead of the +// current chain head for the message's consistency level, so the reobservation is dropped. +func TestReobserveSingleMessageEarly(t *testing.T) { + tests := []struct { + name string + consistencyLevel uint8 + finalizedBlockNum uint64 + safeBlockNum uint64 + }{ + {"finalized", vaa.ConsistencyLevelFinalized, testBlockNumber - 1, testSafeBlockNum}, + {"safe", vaa.ConsistencyLevelSafe, testFinalizedBlockNum, testBlockNumber - 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + log := newValidWormholeLog(t, testBlockNumber, tc.consistencyLevel) + mock.seedLog(log, 1234) + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, tc.finalizedBlockNum, tc.safeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(0), numObs) + require.Equal(t, 0, len(msgC)) + }) + } +} + +func TestReobserveInvalidChainId(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + + numObs, err := w.handleReobservationRequest(context.TODO(), vaa.ChainIDSolana, []byte{}, mock, testFinalizedBlockNum, testSafeBlockNum) + require.Error(t, err) + require.Equal(t, uint32(0), numObs) + require.Equal(t, 0, len(msgC)) +} + +func TestReobserveReceiptError(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + txHash := eth_common.HexToHash("0x1234") + mock.errors[txHash] = errors.New("rpc failure") + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, txHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.Error(t, err) + require.Equal(t, uint32(0), numObs) + require.Equal(t, 0, len(msgC)) +} + +func TestReobserveFailedTransactionStatus(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) + receipt := newTestReceipt(log.BlockNumber, []*types.Log{log}) + receipt.Status = 0 + mock.receipts[log.TxHash] = receipt + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.Error(t, err) + require.Equal(t, uint32(0), numObs) + require.Equal(t, 0, len(msgC)) +} + +// TestReobserveLogSkipped covers receipt logs that should not produce a MessagePublication. +// Each subtest starts with a fully valid log and mutates a single field so the log is rejected +// by by_transaction.go. All cases should return numObs=0 with no error. +func TestReobserveLogSkipped(t *testing.T) { + tests := []struct { + name string + mutate func(*types.Log) + }{ + { + name: "wrong_contract_address", + mutate: func(l *types.Log) { + l.Address = eth_common.HexToAddress("0x396343362be2A4dA1cE0C1C210945346fb82Aa49") + }, + }, + { + name: "wrong_event_topic", + mutate: func(l *types.Log) { + l.Topics[0] = eth_common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef") + }, + }, + { + name: "removed_flag_set", + mutate: func(l *types.Log) { + l.Removed = true + }, + }, + { + name: "empty_topics", + mutate: func(l *types.Log) { + l.Topics = []eth_common.Hash{} + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) + tc.mutate(log) + mock.seedLog(log, 1234) + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(0), numObs) + require.Equal(t, 0, len(msgC)) + }) + } +} + +// TestReobserveDeterministicOrdering verifies processing the same message +// always leads to the same result. This test is meant to catch non-determinism issues +// that get added to this code path. +func TestReobserveDeterministicOrdering(t *testing.T) { + const numEvents = 20 + const numRuns = 5 + + var firstRun []uint64 + for run := 0; run < numRuns; run++ { + w, mock, _ := newTestWatcher(t) + w.contract = testEmitter + msgC := make(chan *common.MessagePublication, numEvents) + w.msgC = msgC + + logs := make([]*types.Log, numEvents) + for i := 0; i < numEvents; i++ { + log := newTestLog(t, testLogParams{ + sender: testTokenBridge, + contractAddr: testEmitter, + sequence: uint64(i), + consistencyLevel: vaa.ConsistencyLevelFinalized, + blockNumber: testBlockNumber, + }) + logs[i] = &log + } + + receipt := newTestReceipt(testBlockNumber, logs) + mock.receipts[logs[0].TxHash] = receipt + mock.blockTimes[receipt.BlockHash] = 1234 + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, logs[0].TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(numEvents), numObs) + + seqs := make([]uint64, numEvents) + for i := 0; i < numEvents; i++ { + seqs[i] = (<-msgC).Sequence + } + + if run == 0 { + firstRun = seqs + continue + } + require.Equal(t, firstRun, seqs, "run %d produced a different ordering than run 0", run) + } +} + +// TestReobserve1KEventsInReceipt verifies that a receipt containing 1000 LogMessagePublished +// events produces 1000 published MessagePublications with sequences preserved in order. +func TestReobserve1KEventsInReceipt(t *testing.T) { + const numEvents = 1000 + + w, mock, _ := newTestWatcher(t) + w.contract = testEmitter + msgC := make(chan *common.MessagePublication, numEvents) + w.msgC = msgC + + logs := make([]*types.Log, numEvents) + for i := 0; i < numEvents; i++ { + log := newTestLog(t, testLogParams{ + sender: testTokenBridge, + contractAddr: testEmitter, + sequence: uint64(i), + consistencyLevel: vaa.ConsistencyLevelFinalized, + blockNumber: testBlockNumber, + }) + logs[i] = &log + } + + receipt := newTestReceipt(testBlockNumber, logs) + mock.receipts[logs[0].TxHash] = receipt + mock.blockTimes[receipt.BlockHash] = 1234 + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, logs[0].TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(numEvents), numObs) + require.Equal(t, numEvents, len(msgC)) + + for i := 0; i < numEvents; i++ { + msg := <-msgC + require.True(t, msg.IsReobservation) + require.Equal(t, uint64(i), msg.Sequence) + } +} + +func TestReobserveTwoValidEvents(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + log1 := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) + mock.seedLog(log1, 1234) + + log2 := newTestLog(t, testLogParams{ + sender: testTokenBridge, + contractAddr: testEmitter, + sequence: 2, + consistencyLevel: vaa.ConsistencyLevelFinalized, + blockNumber: testBlockNumber, + }) + + receipt := newTestReceipt(log1.BlockNumber, []*types.Log{log1, &log2}) + mock.receipts[log1.TxHash] = receipt + mock.blockTimes[receipt.BlockHash] = 1234 + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log1.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(2), numObs) + require.Equal(t, 2, len(msgC)) + + ev, err := mock.ParseLogMessagePublished(*log1) + require.NoError(t, err) + msg := <-msgC + require.True(t, msg.IsReobservation) + assertMessageMatchesEvent(t, msg, ev, 1234) + + ev, err = mock.ParseLogMessagePublished(log2) + require.NoError(t, err) + msg = <-msgC + require.True(t, msg.IsReobservation) + assertMessageMatchesEvent(t, msg, ev, 1234) +} + +func TestReobserveValidAndInvalid(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + + log1 := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) + mock.seedLog(log1, 1234) + + log2 := newTestLog(t, testLogParams{ + sender: testTokenBridge, + contractAddr: testEmitter, + sequence: 2, + consistencyLevel: vaa.ConsistencyLevelFinalized, + blockNumber: testBlockNumber, + }) + + log2.Topics[0] = eth_common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef") + + receipt := newTestReceipt(log1.BlockNumber, []*types.Log{log1, &log2}) + mock.receipts[log1.TxHash] = receipt + mock.blockTimes[receipt.BlockHash] = 1234 + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log1.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(1), numObs) + require.Equal(t, 1, len(msgC)) + + ev, err := mock.ParseLogMessagePublished(*log1) + require.NoError(t, err) + msg := <-msgC + require.True(t, msg.IsReobservation) + assertMessageMatchesEvent(t, msg, ev, 1234) +} + +func TestReobserveTxVerifierIntegration(t *testing.T) { + otherSender := eth_common.HexToAddress("0x000000000000000000000000000000000000dEaD") + + tests := []struct { + name string + verifier txverifier.TransferVerifierInterface + sender eth_common.Address + expectState common.VerificationState + }{ + {"success", &MockTransferVerifier[ethclient.Client, connectors.Connector]{success: true}, testTokenBridge, common.Valid}, + {"failure", &MockTransferVerifier[ethclient.Client, connectors.Connector]{success: false}, testTokenBridge, common.Rejected}, + {"notapplicable", &MockTransferVerifier[ethclient.Client, connectors.Connector]{success: false}, otherSender, common.NotApplicable}, + {"notverified", nil, testTokenBridge, common.NotVerified}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + w.contract = testEmitter + w.txVerifier = tc.verifier + + log := newTestLog(t, testLogParams{ + sender: tc.sender, + contractAddr: testEmitter, + sequence: 1, + consistencyLevel: vaa.ConsistencyLevelFinalized, + blockNumber: testBlockNumber, + }) + mock.seedLog(&log, 1234) + + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(1), numObs) + require.Equal(t, 1, len(msgC)) + + ev, err := mock.ParseLogMessagePublished(log) + require.NoError(t, err) + msg := <-msgC + require.True(t, msg.IsReobservation) + require.Equal(t, tc.expectState, msg.VerificationState()) + assertMessageMatchesEvent(t, msg, ev, 1234) + }) + } +} diff --git a/node/pkg/watchers/evm/watcher_test_helpers_test.go b/node/pkg/watchers/evm/watcher_test_helpers_test.go index a98314e25e4..3a9390429ce 100644 --- a/node/pkg/watchers/evm/watcher_test_helpers_test.go +++ b/node/pkg/watchers/evm/watcher_test_helpers_test.go @@ -3,6 +3,7 @@ package evm import ( "context" "encoding/binary" + "fmt" "math/big" "testing" "time" @@ -31,6 +32,14 @@ var testEmitter = eth_common.HexToAddress("0x0290FB167208Af455bB137780163b7B7a9a // testTokenBridge is the token bridge address used by MockTransferVerifier. var testTokenBridge = eth_common.HexToAddress("0x3ee18B2214AFF97000D974cf647E7C347E8fa585") +// Default block heights used across reobserve tests: the log is observed at testBlockNumber, +// with testFinalizedBlockNum / testSafeBlockNum representing current chain heads. +var ( + testBlockNumber = uint64(100) + testFinalizedBlockNum = uint64(200) + testSafeBlockNum = uint64(150) +) + // NewWatcherForTest creates a minimal Watcher for verifyAndPublish tests. func NewWatcherForTest(t *testing.T, msgC chan<- *common.MessagePublication) *Watcher { t.Helper() @@ -50,7 +59,7 @@ func NewWatcherForTest(t *testing.T, msgC chan<- *common.MessagePublication) *Wa // Returns the watcher, the mock (for configuring receipts/errors), and the msgC channel (for reading published messages). func newTestWatcher(t *testing.T) (*Watcher, *mockConnector, chan *common.MessagePublication) { t.Helper() - mock := newMockConnector() + mock := newMockConnector(t) msgC := make(chan *common.MessagePublication, 10) w := &Watcher{ @@ -83,24 +92,54 @@ func (m *MockTransferVerifier[E, C]) Addrs() *txverifier.TVAddresses { } // mockConnector is a minimal mock of the connectors.Connector interface for testing -// processNewBlock and postMessage. Only TransactionReceipt has real behavior; all other methods panic -// because they should not be called in these tests. +// processNewBlock, postMessage, and reobservation flows. TransactionReceipt, TimeOfBlockByHash, +// and ParseLogMessagePublished have real behavior; all other methods panic because they should +// not be called in these tests. type mockConnector struct { // receipts maps txHash -> receipt to return receipts map[eth_common.Hash]*types.Receipt - // errors maps txHash -> error to return + // errors maps txHash -> error to return from TransactionReceipt errors map[eth_common.Hash]error + // blockTimes maps blockHash -> time to return from TimeOfBlockByHash + blockTimes map[eth_common.Hash]uint64 + // filterer delegates ParseLogMessagePublished to the real ABI parser. + filterer *ethabi.AbiFilterer } -func newMockConnector() *mockConnector { +func newMockConnector(t *testing.T) *mockConnector { + t.Helper() + // The zero address and nil filterer are fine here: UnpackLog only needs the parsed ABI + // metadata baked into the generated package, not an RPC-capable filterer. + filterer, err := ethabi.NewAbiFilterer(eth_common.Address{}, nil) + require.NoError(t, err) return &mockConnector{ - receipts: make(map[eth_common.Hash]*types.Receipt), - errors: make(map[eth_common.Hash]error), + receipts: make(map[eth_common.Hash]*types.Receipt), + errors: make(map[eth_common.Hash]error), + blockTimes: make(map[eth_common.Hash]uint64), + filterer: filterer, } } +// seedLog wires the mock to return a successful receipt wrapping `log` (keyed by its TxHash) +// and the given block time (keyed by the receipt's BlockHash). For receipts with Status != 1 +// or custom shapes, write directly to mock.receipts / mock.blockTimes. +func (m *mockConnector) seedLog(log *types.Log, blockTime uint64) { + receipt := newTestReceipt(log.BlockNumber, []*types.Log{log}) + m.receipts[log.TxHash] = receipt + m.blockTimes[receipt.BlockHash] = blockTime +} + +// TransactionReceipt returns the configured receipt/error. When neither is set for the txHash, +// it returns a "not found" error to match the real ethclient behavior (and avoid nil-deref in +// callers that don't check err before dereferencing the receipt). func (m *mockConnector) TransactionReceipt(_ context.Context, txHash eth_common.Hash) (*types.Receipt, error) { - return m.receipts[txHash], m.errors[txHash] + if err, ok := m.errors[txHash]; ok { + return nil, err + } + if r, ok := m.receipts[txHash]; ok { + return r, nil + } + return nil, fmt.Errorf("receipt not found for tx %s", txHash.Hex()) } // Stub implementations that are required by the interface @@ -122,11 +161,17 @@ func (m *mockConnector) GetDelegatedGuardianConfig(ctx context.Context) ([]dgAbi func (m *mockConnector) WatchLogMessagePublished(ctx context.Context, errC chan error, sink chan<- *ethabi.AbiLogMessagePublished) (event.Subscription, error) { panic("not implemented") } -func (m *mockConnector) TimeOfBlockByHash(ctx context.Context, hash eth_common.Hash) (uint64, error) { - panic("not implemented") + +// TimeOfBlockByHash returns the configured block time for the given hash, or 0 if unset. +// Tests that care about the exact timestamp should seed blockTimes explicitly. +func (m *mockConnector) TimeOfBlockByHash(_ context.Context, hash eth_common.Hash) (uint64, error) { + return m.blockTimes[hash], nil } + +// ParseLogMessagePublished delegates to the real generated ABI parser so tests exercise the +// actual unpack path against logs produced by newTestLog. func (m *mockConnector) ParseLogMessagePublished(log types.Log) (*ethabi.AbiLogMessagePublished, error) { - panic("not implemented") + return m.filterer.ParseLogMessagePublished(log) } func (m *mockConnector) SubscribeForBlocks(ctx context.Context, errC chan error, sink chan<- *connectors.NewBlock) (ethereum.Subscription, error) { panic("not implemented") @@ -293,6 +338,36 @@ func newTestLog(t *testing.T, p testLogParams) types.Log { } } +// newValidWormholeLog returns a pointer to a LogMessagePublished log entry with valid ABI-encoded +// data that MessageEventsForTransaction will parse and surface as a MessagePublication. +// Defaults: sender = testEmitter, contract address = testEmitter, sequence = 1. Caller must set +// w.contract = testEmitter so the by_transaction.go address filter passes. +// For full control over fields, use newTestLog with a testLogParams struct. +func newValidWormholeLog(t *testing.T, blockNumber uint64, consistencyLevel uint8) *types.Log { + t.Helper() + log := newTestLog(t, testLogParams{ + sender: testTokenBridge, + contractAddr: testEmitter, + sequence: 1, + consistencyLevel: consistencyLevel, + blockNumber: blockNumber, + }) + return &log +} + +// newTestReceipt builds a successful (Status=1) receipt at the given block number, wiring logs verbatim. +// BlockHash matches the default derivation used by newTestLog (blockNumber + 0xff) so a log built +// from the same blockNumber will have a matching receipt.BlockHash by default. +// For Status != 1 or a custom BlockHash, construct the receipt inline in the test. +func newTestReceipt(blockNumber uint64, logs []*types.Log) *types.Receipt { + return &types.Receipt{ + Status: 1, + BlockHash: eth_common.BigToHash(big.NewInt(int64(blockNumber + 0xff))), // #nosec G115 -- test-only + BlockNumber: big.NewInt(int64(blockNumber)), // #nosec G115 -- test-only + Logs: logs, + } +} + // testLogEventParams holds parameters for constructing an AbiLogMessagePublished for postMessage tests. // Only fields that affect control flow are exposed; nonce and payload are left at zero values. type testLogEventParams struct { From 2d58076f050c1ce1b64455ed7baa550efef8c689 Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Fri, 1 May 2026 10:37:05 -0700 Subject: [PATCH 03/10] EVM watcher test changes + watcher hardening * Added new tests to deal with previous hardening changes * Changed error handling on tx == nil within processNewBlock in order to allow for transient retries * Added stored txhash is the same as the found txhash that was just queried. --- node/pkg/watchers/evm/reobserve_test.go | 78 ++++------ node/pkg/watchers/evm/watcher.go | 78 +++++++--- node/pkg/watchers/evm/watcher_test.go | 141 ++++++++++++------ .../watchers/evm/watcher_test_helpers_test.go | 35 +++-- 4 files changed, 205 insertions(+), 127 deletions(-) diff --git a/node/pkg/watchers/evm/reobserve_test.go b/node/pkg/watchers/evm/reobserve_test.go index 222f7652caa..c66934b63fc 100644 --- a/node/pkg/watchers/evm/reobserve_test.go +++ b/node/pkg/watchers/evm/reobserve_test.go @@ -16,42 +16,35 @@ import ( ) func TestReobserveSingleMessage(t *testing.T) { - w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter - - log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) - mock.seedLog(log, 1234) - - numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) - require.NoError(t, err) - require.Equal(t, uint32(1), numObs) - require.Equal(t, 1, len(msgC)) - - ev, err := mock.ParseLogMessagePublished(*log) - require.NoError(t, err) - msg := <-msgC - require.True(t, msg.IsReobservation) - require.Equal(t, common.NotVerified, msg.VerificationState()) - assertMessageMatchesEvent(t, msg, ev, 1234) -} + tests := []struct { + name string + consistencyLevel uint8 + }{ + {"finalized", vaa.ConsistencyLevelFinalized}, + {"safe", vaa.ConsistencyLevelSafe}, + {"instant", vaa.ConsistencyLevelPublishImmediately}, + } -func TestReobserveInstant(t *testing.T) { - w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, mock, msgC := newTestWatcher(t) - log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelPublishImmediately) - mock.seedLog(log, 1234) + log := newValidWormholeLog(t, testBlockNumber, tc.consistencyLevel) + mock.seedLog(log, 1234) - numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, 1, 1) - require.NoError(t, err) - require.Equal(t, uint32(1), numObs) - require.Equal(t, 1, len(msgC)) + numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) + require.NoError(t, err) + require.Equal(t, uint32(1), numObs) + require.Equal(t, 1, len(msgC)) - ev, err := mock.ParseLogMessagePublished(*log) - require.NoError(t, err) - msg := <-msgC - require.True(t, msg.IsReobservation) - assertMessageMatchesEvent(t, msg, ev, 1234) + ev, err := mock.ParseLogMessagePublished(*log) + require.NoError(t, err) + msg := recvMsg(t, msgC) + require.True(t, msg.IsReobservation) + require.Equal(t, common.NotVerified, msg.VerificationState()) + assertMessageMatchesEvent(t, msg, ev, 1234) + }) + } } // TestReobserveSingleMessageEarly covers cases where the log's block number is ahead of the @@ -70,7 +63,6 @@ func TestReobserveSingleMessageEarly(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter log := newValidWormholeLog(t, testBlockNumber, tc.consistencyLevel) mock.seedLog(log, 1234) @@ -94,7 +86,6 @@ func TestReobserveInvalidChainId(t *testing.T) { func TestReobserveReceiptError(t *testing.T) { w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter txHash := eth_common.HexToHash("0x1234") mock.errors[txHash] = errors.New("rpc failure") @@ -107,7 +98,6 @@ func TestReobserveReceiptError(t *testing.T) { func TestReobserveFailedTransactionStatus(t *testing.T) { w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) receipt := newTestReceipt(log.BlockNumber, []*types.Log{log}) @@ -157,7 +147,6 @@ func TestReobserveLogSkipped(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) tc.mutate(log) @@ -181,7 +170,6 @@ func TestReobserveDeterministicOrdering(t *testing.T) { var firstRun []uint64 for run := 0; run < numRuns; run++ { w, mock, _ := newTestWatcher(t) - w.contract = testEmitter msgC := make(chan *common.MessagePublication, numEvents) w.msgC = msgC @@ -207,7 +195,7 @@ func TestReobserveDeterministicOrdering(t *testing.T) { seqs := make([]uint64, numEvents) for i := 0; i < numEvents; i++ { - seqs[i] = (<-msgC).Sequence + seqs[i] = recvMsg(t, msgC).Sequence } if run == 0 { @@ -224,7 +212,6 @@ func TestReobserve1KEventsInReceipt(t *testing.T) { const numEvents = 1000 w, mock, _ := newTestWatcher(t) - w.contract = testEmitter msgC := make(chan *common.MessagePublication, numEvents) w.msgC = msgC @@ -250,7 +237,7 @@ func TestReobserve1KEventsInReceipt(t *testing.T) { require.Equal(t, numEvents, len(msgC)) for i := 0; i < numEvents; i++ { - msg := <-msgC + msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) require.Equal(t, uint64(i), msg.Sequence) } @@ -258,7 +245,6 @@ func TestReobserve1KEventsInReceipt(t *testing.T) { func TestReobserveTwoValidEvents(t *testing.T) { w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter log1 := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) mock.seedLog(log1, 1234) @@ -282,20 +268,19 @@ func TestReobserveTwoValidEvents(t *testing.T) { ev, err := mock.ParseLogMessagePublished(*log1) require.NoError(t, err) - msg := <-msgC + msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) assertMessageMatchesEvent(t, msg, ev, 1234) ev, err = mock.ParseLogMessagePublished(log2) require.NoError(t, err) - msg = <-msgC + msg = recvMsg(t, msgC) require.True(t, msg.IsReobservation) assertMessageMatchesEvent(t, msg, ev, 1234) } func TestReobserveValidAndInvalid(t *testing.T) { w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter log1 := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) mock.seedLog(log1, 1234) @@ -321,7 +306,7 @@ func TestReobserveValidAndInvalid(t *testing.T) { ev, err := mock.ParseLogMessagePublished(*log1) require.NoError(t, err) - msg := <-msgC + msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) assertMessageMatchesEvent(t, msg, ev, 1234) } @@ -344,7 +329,6 @@ func TestReobserveTxVerifierIntegration(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { w, mock, msgC := newTestWatcher(t) - w.contract = testEmitter w.txVerifier = tc.verifier log := newTestLog(t, testLogParams{ @@ -363,7 +347,7 @@ func TestReobserveTxVerifierIntegration(t *testing.T) { ev, err := mock.ParseLogMessagePublished(log) require.NoError(t, err) - msg := <-msgC + msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) require.Equal(t, tc.expectState, msg.VerificationState()) assertMessageMatchesEvent(t, msg, ev, 1234) diff --git a/node/pkg/watchers/evm/watcher.go b/node/pkg/watchers/evm/watcher.go index 33bf36b2e69..accb68f9925 100644 --- a/node/pkg/watchers/evm/watcher.go +++ b/node/pkg/watchers/evm/watcher.go @@ -19,6 +19,7 @@ import ( "github.com/certusone/wormhole/node/pkg/p2p" gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/rpc" "github.com/prometheus/client_golang/prometheus/promauto" @@ -901,6 +902,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) w.updateNetworkStats(&stats) w.pendingMu.Lock() + defer w.pendingMu.Unlock() for key, pLock := range w.pending { // Don't process the observation if it is waiting on a different consistency level. if !consistencyLevelMatches(thisConsistencyLevel, pLock.effectiveCL) { @@ -926,8 +928,8 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) // // Check multiple possible error cases - the node seems to return a // "not found" error most of the time, but it could conceivably also - // return a nil tx or rpc.ErrNoResult. - if tx == nil || errors.Is(err, rpc.ErrNoResult) || (err != nil && err.Error() == "not found") { + // return a rpc.ErrNoResult. + if errors.Is(err, rpc.ErrNoResult) || errors.Is(err, ethereum.NotFound) || (err != nil && err.Error() == "not found") { logger.Warn("tx was orphaned", zap.String("msgId", pLock.message.MessageIDString()), zap.String("txHash", pLock.message.TxIDString()), @@ -943,26 +945,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) continue } - // This should never happen - if we got this far, it means that logs were emitted, - // which is only possible if the transaction succeeded. We check it anyway just - // in case the EVM implementation is buggy. - if tx.Status != 1 { - logger.Error("transaction receipt with non-success status", - zap.String("msgId", pLock.message.MessageIDString()), - zap.String("txHash", pLock.message.TxIDString()), - zap.Stringer("blockHash", key.BlockHash), - zap.Uint64("observedHeight", pLock.height), - zap.Uint64("additionalBlocks", pLock.additionalBlocks), - zap.Stringer("current_blockNum", ev.Number), - zap.Stringer("finality", ev.Finality), - zap.Stringer("current_blockHash", currentHash), - zap.Error(err)) - delete(w.pending, key) - ethMessagesOrphaned.WithLabelValues(w.networkName, "tx_failed").Inc() - continue - } - - // Any error other than "not found" is likely transient - we retry next block. + // Transient errors (rate limit, transport, etc.) retry next block. if err != nil { if pLock.height+MaxWaitConfirmations <= blockNumberU { // An error from this "transient" case has persisted for more than MaxWaitConfirmations. @@ -993,6 +976,56 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) continue } + // tx is nil and err is nil. A check to prevent panics later + if tx == nil { + logger.Error("connector returned nil receipt with no error", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + ) + delete(w.pending, key) + ethMessagesOrphaned.WithLabelValues(w.networkName, "nil_receipt").Inc() + continue + } + + // SECURITY: Defense in depth against a buggy or malicious connector returning + // a receipt for a different transaction than the one we queried. + expectedTxHash := eth_common.BytesToHash(pLock.message.TxID) + if tx.TxHash != expectedTxHash { + logger.Error("transaction receipt hash does not match queried tx", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("expectedTxHash", expectedTxHash.Hex()), + zap.Stringer("receiptTxHash", tx.TxHash), + zap.Stringer("blockHash", key.BlockHash), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + ) + delete(w.pending, key) + ethMessagesOrphaned.WithLabelValues(w.networkName, "tx_hash_mismatch").Inc() + continue + } + + // This should never happen - if we got this far, it means that logs were emitted, + // which is only possible if the transaction succeeded. We check it anyway just + // in case the EVM implementation is buggy. + if tx.Status != gethTypes.ReceiptStatusSuccessful { + logger.Error("transaction receipt with non-success status", + zap.String("msgId", pLock.message.MessageIDString()), + zap.String("txHash", pLock.message.TxIDString()), + zap.Stringer("blockHash", key.BlockHash), + zap.Uint64("observedHeight", pLock.height), + zap.Uint64("additionalBlocks", pLock.additionalBlocks), + zap.Stringer("current_blockNum", ev.Number), + zap.Stringer("finality", ev.Finality), + zap.Stringer("current_blockHash", currentHash), + zap.Error(err)) + delete(w.pending, key) + ethMessagesOrphaned.WithLabelValues(w.networkName, "tx_failed").Inc() + continue + } + // It's possible for a transaction to be orphaned and then included in a different block // but with the same tx hash. Drop the observation (it will be re-observed and needs to // wait for the full confirmation time again). @@ -1036,7 +1069,6 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) } } - w.pendingMu.Unlock() logger.Debug("processed new header", zap.Stringer("current_block", ev.Number), zap.Stringer("finality", ev.Finality), diff --git a/node/pkg/watchers/evm/watcher_test.go b/node/pkg/watchers/evm/watcher_test.go index e2fc2e0b788..8b702a600dd 100644 --- a/node/pkg/watchers/evm/watcher_test.go +++ b/node/pkg/watchers/evm/watcher_test.go @@ -87,13 +87,12 @@ func TestVerifyAndPublish(t *testing.T) { err = w.verifyAndPublish(&msg, ctx, eth_common.Hash{}, &types.Receipt{}) require.NoError(t, err) require.Equal(t, 1, len(msgC)) - publishedMsg := <-msgC + publishedMsg := recvMsg(t, msgC) require.NotNil(t, publishedMsg) require.Equal(t, 0, len(msgC)) require.Equal(t, common.NotVerified.String(), publishedMsg.VerificationState().String()) - tbAddr, byteErr := vaa.BytesToAddress([]byte{0x01}) - require.NoError(t, byteErr) + tbAddr := PadAddress(testTokenBridge) // Check scenario where transfer verifier is enabled on the watcher level but // there is no Transfer Verifier instantiated. In this case, fail open and continue @@ -107,7 +106,7 @@ func TestVerifyAndPublish(t *testing.T) { err = w.verifyAndPublish(&msg, ctx, eth_common.Hash{}, &types.Receipt{}) require.NoError(t, err) require.Equal(t, 1, len(msgC)) - publishedMsg = <-msgC + publishedMsg = recvMsg(t, msgC) require.Equal(t, common.NotVerified.String(), publishedMsg.VerificationState().String()) // Check that message status is not changed if it didn't come from token bridge. @@ -118,7 +117,7 @@ func TestVerifyAndPublish(t *testing.T) { err = w.verifyAndPublish(&msg, ctx, eth_common.Hash{}, &types.Receipt{}) require.Nil(t, err) require.Equal(t, 1, len(msgC)) - publishedMsg = <-msgC + publishedMsg = recvMsg(t, msgC) require.Equal(t, common.NotVerified.String(), publishedMsg.VerificationState().String()) // Check scenario where the message already has a verification status. @@ -146,7 +145,7 @@ func TestVerifyAndPublish(t *testing.T) { err = w.verifyAndPublish(&msg, ctx, eth_common.Hash{}, &types.Receipt{}) require.Nil(t, err) require.Equal(t, 1, len(msgC)) - publishedMsg = <-msgC + publishedMsg = recvMsg(t, msgC) require.NotNil(t, publishedMsg) require.Equal(t, 0, len(msgC)) require.Equal(t, common.Rejected.String(), publishedMsg.VerificationState().String()) @@ -159,7 +158,7 @@ func TestVerifyAndPublish(t *testing.T) { err = w.verifyAndPublish(&msg, ctx, eth_common.Hash{}, &types.Receipt{}) require.Nil(t, err) require.Equal(t, 1, len(msgC)) - publishedMsg = <-msgC + publishedMsg = recvMsg(t, msgC) require.Equal(t, common.NotApplicable.String(), publishedMsg.VerificationState().String()) // Check happy path where txverifier is enabled, initialized, and the message is from the token bridge. @@ -173,7 +172,7 @@ func TestVerifyAndPublish(t *testing.T) { err = w.verifyAndPublish(&msg, ctx, eth_common.Hash{}, &types.Receipt{}) require.NoError(t, err) require.Equal(t, 1, len(msgC)) - publishedMsg = <-msgC + publishedMsg = recvMsg(t, msgC) require.NotNil(t, publishedMsg) require.Equal(t, 0, len(msgC)) require.Equal(t, common.Valid.String(), publishedMsg.VerificationState().String()) @@ -182,8 +181,7 @@ func TestVerifyAndPublish(t *testing.T) { // TestVerifyDoesNotMutateOriginalMessage checks that verify() does not modify // the original MessagePublication passed to it. func TestVerifyDoesNotMutateOriginalMessage(t *testing.T) { - tbAddr, err := vaa.BytesToAddress([]byte{0x01}) - require.NoError(t, err) + tbAddr := PadAddress(testTokenBridge) msg := &common.MessagePublication{ EmitterAddress: tbAddr, @@ -227,7 +225,7 @@ func TestProcessBlockPendingByFinality(t *testing.T) { blockHash := eth_common.BigToHash(big.NewInt(100)) w.addPendingMsg(txHash, blockHash, 100, tc.cl, 0, 1) - mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality)) require.NoError(t, err) @@ -235,7 +233,7 @@ func TestProcessBlockPendingByFinality(t *testing.T) { assert.Equal(t, tc.expectPending, len(w.pending)) if tc.expectPublish { require.Equal(t, 1, len(msgC)) - assert.Equal(t, txHash.Bytes(), (<-msgC).TxID) + assert.Equal(t, txHash.Bytes(), recvMsg(t, msgC).TxID) } else { assert.Equal(t, 0, len(msgC)) } @@ -253,8 +251,8 @@ func TestProcessBlockPendingFinalizedAndSafe(t *testing.T) { w.addPendingMsg(txHash1, blockHash1, 100, vaa.ConsistencyLevelFinalized, 0, 1) w.addPendingMsg(txHash2, blockHash2, 100, vaa.ConsistencyLevelSafe, 0, 1) - mock.receipts[txHash1] = &types.Receipt{Status: 1, BlockHash: blockHash1} - mock.receipts[txHash2] = &types.Receipt{Status: 1, BlockHash: blockHash2} + mock.receipts[txHash1] = &types.Receipt{Status: 1, BlockHash: blockHash1, TxHash: txHash1} + mock.receipts[txHash2] = &types.Receipt{Status: 1, BlockHash: blockHash2, TxHash: txHash2} err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) require.NoError(t, err) @@ -264,7 +262,7 @@ func TestProcessBlockPendingFinalizedAndSafe(t *testing.T) { // Published finalized message require.Equal(t, 1, len(msgC)) - assert.Equal(t, txHash1.Bytes(), (<-msgC).TxID) + assert.Equal(t, txHash1.Bytes(), recvMsg(t, msgC).TxID) err = w.processNewBlock(context.TODO(), newBlock(105, connectors.Safe)) require.NoError(t, err) @@ -274,7 +272,7 @@ func TestProcessBlockPendingFinalizedAndSafe(t *testing.T) { // Published safe message require.Equal(t, 1, len(msgC)) - assert.Equal(t, txHash2.Bytes(), (<-msgC).TxID) + assert.Equal(t, txHash2.Bytes(), recvMsg(t, msgC).TxID) } // Removal of the message without publication if the blockhash differs @@ -285,7 +283,7 @@ func TestProcessBlockPendingWrongBlockHash(t *testing.T) { blockHashMessage := eth_common.BigToHash(big.NewInt(222)) w.addPendingMsg(txHash, blockHashMessage, 100, vaa.ConsistencyLevelFinalized, 0, 1) - mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHashBlock} + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHashBlock, TxHash: txHash} err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) require.NoError(t, err) @@ -304,7 +302,7 @@ func TestProcessBlockPendingFailedTx(t *testing.T) { blockHash := eth_common.BigToHash(big.NewInt(100)) w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) - mock.receipts[txHash] = &types.Receipt{Status: 0, BlockHash: blockHash} + mock.receipts[txHash] = &types.Receipt{Status: 0, BlockHash: blockHash, TxHash: txHash} err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) require.NoError(t, err) @@ -323,7 +321,7 @@ func TestProcessBlockValidReceiptWithError(t *testing.T) { blockHash := eth_common.BigToHash(big.NewInt(100)) w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) - mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} mock.errors[txHash] = errors.New("not found") err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) @@ -359,8 +357,8 @@ func TestProcessBlockTransientError(t *testing.T) { w, mock, msgC := newTestWatcher(t) txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") blockHash := eth_common.BigToHash(big.NewInt(100)) - mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} - mock.errors[txHash] = errors.New("transient error") + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} + mock.errors[txHash] = errors.New("rate limit 429 error") w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) @@ -383,7 +381,28 @@ func TestProcessBlockTransientError(t *testing.T) { // Published with correct TxID require.Equal(t, 1, len(msgC)) - assert.Equal(t, txHash.Bytes(), (<-msgC).TxID) + assert.Equal(t, txHash.Bytes(), recvMsg(t, msgC).TxID) +} + +// A transient error that persists past MaxWaitConfirmations should remove the pending entry. +func TestProcessBlockTransientErrorTimeout(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") + blockHash := eth_common.BigToHash(big.NewInt(100)) + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} + mock.errors[txHash] = errors.New("transient error") + + w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + + // blockNumberU = pLock.height + MaxWaitConfirmations exactly hits the timeout branch. + err := w.processNewBlock(context.TODO(), newBlock(100+MaxWaitConfirmations, connectors.Finalized)) + require.NoError(t, err) + + // Removed from pending after timeout + assert.Equal(t, 0, len(w.pending)) + + // Not published + assert.Equal(t, 0, len(msgC)) } // AdditionalBlocks test cases for waiting the proper amount of time before publication @@ -411,7 +430,7 @@ func TestProcessBlockAdditionalBlocks(t *testing.T) { blockHash := eth_common.BigToHash(big.NewInt(100)) w.addPendingMsg(txHash, blockHash, 100, tc.cl, tc.additionalBlocks, 1) - mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality)) require.NoError(t, err) @@ -419,7 +438,7 @@ func TestProcessBlockAdditionalBlocks(t *testing.T) { assert.Equal(t, tc.expectPending, len(w.pending)) if tc.expectPublish { require.Equal(t, 1, len(msgC)) - assert.Equal(t, txHash.Bytes(), (<-msgC).TxID) + assert.Equal(t, txHash.Bytes(), recvMsg(t, msgC).TxID) } else { assert.Equal(t, 0, len(msgC)) } @@ -436,7 +455,7 @@ func TestProcessBlockCCLEffectiveCLDiffersFromMessageCL(t *testing.T) { // Simulate CCL: message.ConsistencyLevel = Custom, but effectiveCL = Finalized key := w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 5, 1) w.pending[key].message.ConsistencyLevel = vaa.ConsistencyLevelCustom - mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} // Finalized block at height+additionalBlocks: should confirm based on effectiveCL err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) @@ -447,7 +466,7 @@ func TestProcessBlockCCLEffectiveCLDiffersFromMessageCL(t *testing.T) { require.Equal(t, 1, len(msgC)) // The published message must still have ConsistencyLevelCustom for VAA hash consistency - published := <-msgC + published := recvMsg(t, msgC) assert.Equal(t, vaa.ConsistencyLevelCustom, published.ConsistencyLevel) } @@ -461,11 +480,11 @@ func TestProcessBlockCCLMultiplePendingDifferentAdditionalBlocks(t *testing.T) { // Message A: effectiveCL=Finalized, additionalBlocks=0, height=100 -> ready at block 100 w.addPendingMsg(txHashA, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) - mock.receipts[txHashA] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHashA] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHashA} // Message B: effectiveCL=Finalized, additionalBlocks=10, height=100 -> ready at block 110 w.addPendingMsg(txHashB, blockHash, 100, vaa.ConsistencyLevelFinalized, 10, 2) - mock.receipts[txHashB] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHashB] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHashB} // Send finalized block at 105: A should confirm, B should stay pending err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) @@ -473,7 +492,7 @@ func TestProcessBlockCCLMultiplePendingDifferentAdditionalBlocks(t *testing.T) { assert.Equal(t, 1, len(w.pending), "only message B should remain pending") require.Equal(t, 1, len(msgC), "only message A should be published") - assert.Equal(t, txHashA.Bytes(), (<-msgC).TxID) + assert.Equal(t, txHashA.Bytes(), recvMsg(t, msgC).TxID) // Send finalized block at 110: B should now confirm err = w.processNewBlock(context.TODO(), newBlock(110, connectors.Finalized)) @@ -481,7 +500,7 @@ func TestProcessBlockCCLMultiplePendingDifferentAdditionalBlocks(t *testing.T) { assert.Equal(t, 0, len(w.pending), "no messages should remain pending") require.Equal(t, 1, len(msgC), "message B should now be published") - assert.Equal(t, txHashB.Bytes(), (<-msgC).TxID) + assert.Equal(t, txHashB.Bytes(), recvMsg(t, msgC).TxID) } // Orphaned tx handling @@ -496,7 +515,7 @@ func TestProcessBlockOneConfirmedOneOrphaned(t *testing.T) { w.addPendingMsg(txHashOrphaned, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 2) // Good tx has a valid receipt - mock.receipts[txHashGood] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHashGood] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHashGood} // Orphaned tx returns nil receipt (not in mock.receipts, so defaults to nil) err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) @@ -508,7 +527,7 @@ func TestProcessBlockOneConfirmedOneOrphaned(t *testing.T) { // Only the valid message was published assert.Equal(t, 1, len(msgC)) - published := <-msgC + published := recvMsg(t, msgC) assert.Equal(t, txHashGood.Bytes(), published.TxID) } @@ -549,7 +568,7 @@ func TestProcessBlockTxVerifier(t *testing.T) { if tc.useTokenBridge { w.pending[key].message.EmitterAddress = PadAddress(testTokenBridge) } - mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash} + mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) require.NoError(t, err) @@ -557,7 +576,7 @@ func TestProcessBlockTxVerifier(t *testing.T) { assert.Equal(t, 0, len(w.pending)) require.Equal(t, 1, len(msgC)) - msg := <-msgC + msg := recvMsg(t, msgC) assert.Equal(t, txHash.Bytes(), msg.TxID) assert.Equal(t, tc.expectState, msg.VerificationState()) }) @@ -694,16 +713,17 @@ func TestPostMessageCustomAdditionalBlocks(t *testing.T) { // Instant message is published instead of being added to the pending queue func TestPostMessageInstantPublishes(t *testing.T) { - w, _, msgC := newTestWatcher(t) + w, mock, msgC := newTestWatcher(t) ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + mock.receipts[ev.Raw.TxHash] = newTestReceipt(ev.Raw.BlockNumber, nil) w.postMessage(context.TODO(), ev, 1234) // Should be published instead of being added to the pending queue require.Equal(t, 0, len(w.pending)) assert.Equal(t, 1, len(msgC)) - msg := <-msgC + msg := recvMsg(t, msgC) assertMessageMatchesEvent(t, msg, ev, 1234) assert.Equal(t, common.NotVerified, msg.VerificationState()) @@ -711,7 +731,7 @@ func TestPostMessageInstantPublishes(t *testing.T) { // Multiple instant publishes are handled properly func TestPostMessageTwoInstantPublishes(t *testing.T) { - w, _, msgC := newTestWatcher(t) + w, mock, msgC := newTestWatcher(t) ev1 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) @@ -720,6 +740,8 @@ func TestPostMessageTwoInstantPublishes(t *testing.T) { ev2.Nonce = 20 ev2.Sequence = 2 + mock.receipts[ev1.Raw.TxHash] = newTestReceipt(ev1.Raw.BlockNumber, nil) + mock.receipts[ev2.Raw.TxHash] = newTestReceipt(ev2.Raw.BlockNumber, nil) w.postMessage(context.TODO(), ev1, 1234) w.postMessage(context.TODO(), ev2, 1234) @@ -727,8 +749,8 @@ func TestPostMessageTwoInstantPublishes(t *testing.T) { require.Equal(t, 0, len(w.pending)) assert.Equal(t, 2, len(msgC)) - msg1 := <-msgC - msg2 := <-msgC + msg1 := recvMsg(t, msgC) + msg2 := recvMsg(t, msgC) assertMessageMatchesEvent(t, msg1, ev1, 1234) assert.Equal(t, common.NotVerified, msg1.VerificationState()) @@ -739,7 +761,7 @@ func TestPostMessageTwoInstantPublishes(t *testing.T) { // An instant and a final message go to the proper spots (msgC and pending) func TestPostMessageInstantAndFinalized(t *testing.T) { - w, _, msgC := newTestWatcher(t) + w, mock, msgC := newTestWatcher(t) // ev1: instant publish ev1 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) @@ -752,6 +774,7 @@ func TestPostMessageInstantAndFinalized(t *testing.T) { consistencyLevel: vaa.ConsistencyLevelFinalized, }) + mock.receipts[ev1.Raw.TxHash] = newTestReceipt(ev1.Raw.BlockNumber, nil) w.postMessage(context.TODO(), ev1, 1234) w.postMessage(context.TODO(), ev2, 1234) @@ -760,7 +783,7 @@ func TestPostMessageInstantAndFinalized(t *testing.T) { assert.Equal(t, 1, len(msgC)) // Verify the instant-published message - msg := <-msgC + msg := recvMsg(t, msgC) assertMessageMatchesEvent(t, msg, ev1, 1234) assert.Equal(t, common.NotVerified, msg.VerificationState()) @@ -886,7 +909,7 @@ func TestPostMessageTxVerifier(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - w, _, msgC := newTestWatcher(t) + w, mock, msgC := newTestWatcher(t) w.txVerifier = &MockTransferVerifier[ethclient.Client, connectors.Connector]{success: tc.success} sender := testEmitter @@ -899,22 +922,46 @@ func TestPostMessageTxVerifier(t *testing.T) { blockNumber: 100, consistencyLevel: vaa.ConsistencyLevelPublishImmediately, }) + mock.receipts[ev.Raw.TxHash] = newTestReceipt(ev.Raw.BlockNumber, nil) w.postMessage(context.TODO(), ev, 1234) require.Equal(t, 0, len(w.pending)) require.Equal(t, 1, len(msgC)) - msg := <-msgC + msg := recvMsg(t, msgC) assertMessageMatchesEvent(t, msg, ev, 1234) assert.Equal(t, tc.expectState, msg.VerificationState()) }) } } -/* -TODO - add failed transaction status to the flow. -- Requires mocking the receipt gathering in every RPC call. -*/ +// Instant publish must drop the message when the receipt cannot be fetched. +func TestPostMessageInstantReceiptError(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + + ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + mock.errors[ev.Raw.TxHash] = errors.New("rpc failure") + + w.postMessage(context.TODO(), ev, 1234) + + assert.Equal(t, 0, len(w.pending)) + assert.Equal(t, 0, len(msgC)) +} + +// Instant publish must drop the message when the receipt reports a non-success status. +func TestPostMessageInstantTxFailed(t *testing.T) { + w, mock, msgC := newTestWatcher(t) + + ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + receipt := newTestReceipt(ev.Raw.BlockNumber, nil) + receipt.Status = 0 + mock.receipts[ev.Raw.TxHash] = receipt + + w.postMessage(context.TODO(), ev, 1234) + + assert.Equal(t, 0, len(w.pending)) + assert.Equal(t, 0, len(msgC)) +} func TestConsistencyLevelMatches(t *testing.T) { // Success cases. diff --git a/node/pkg/watchers/evm/watcher_test_helpers_test.go b/node/pkg/watchers/evm/watcher_test_helpers_test.go index 3a9390429ce..8dd5244d2e6 100644 --- a/node/pkg/watchers/evm/watcher_test_helpers_test.go +++ b/node/pkg/watchers/evm/watcher_test_helpers_test.go @@ -3,7 +3,6 @@ package evm import ( "context" "encoding/binary" - "fmt" "math/big" "testing" "time" @@ -30,7 +29,7 @@ import ( var testEmitter = eth_common.HexToAddress("0x0290FB167208Af455bB137780163b7B7a9a10C16") // testTokenBridge is the token bridge address used by MockTransferVerifier. -var testTokenBridge = eth_common.HexToAddress("0x3ee18B2214AFF97000D974cf647E7C347E8fa585") +var testTokenBridge = eth_common.BytesToAddress([]byte{0x01}) // Default block heights used across reobserve tests: the log is observed at testBlockNumber, // with testFinalizedBlockNum / testSafeBlockNum representing current chain heads. @@ -70,11 +69,26 @@ func newTestWatcher(t *testing.T) (*Watcher, *mockConnector, chan *common.Messag pending: make(map[pendingKey]*pendingMessage), networkName: "test", chainID: vaa.ChainIDEthereum, + contract: testEmitter, } return w, mock, msgC } +// recvMsg reads from msgC, failing the test (rather than blocking forever) if no +// message arrives within a short timeout. Prefer this over a bare `<-msgC` so a +// missing publish surfaces as a clear failure instead of a `go test` hang. +func recvMsg(t *testing.T, msgC <-chan *common.MessagePublication) *common.MessagePublication { + t.Helper() + select { + case msg := <-msgC: + return msg + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for message on msgC") + return nil + } +} + type MockTransferVerifier[E ethclient.Client, C connectors.Connector] struct { success bool } @@ -133,13 +147,12 @@ func (m *mockConnector) seedLog(log *types.Log, blockTime uint64) { // it returns a "not found" error to match the real ethclient behavior (and avoid nil-deref in // callers that don't check err before dereferencing the receipt). func (m *mockConnector) TransactionReceipt(_ context.Context, txHash eth_common.Hash) (*types.Receipt, error) { - if err, ok := m.errors[txHash]; ok { - return nil, err - } - if r, ok := m.receipts[txHash]; ok { - return r, nil + err := m.errors[txHash] + r, hasReceipt := m.receipts[txHash] + if err == nil && !hasReceipt { + return nil, ethereum.NotFound } - return nil, fmt.Errorf("receipt not found for tx %s", txHash.Hex()) + return r, err } // Stub implementations that are required by the interface @@ -340,8 +353,8 @@ func newTestLog(t *testing.T, p testLogParams) types.Log { // newValidWormholeLog returns a pointer to a LogMessagePublished log entry with valid ABI-encoded // data that MessageEventsForTransaction will parse and surface as a MessagePublication. -// Defaults: sender = testEmitter, contract address = testEmitter, sequence = 1. Caller must set -// w.contract = testEmitter so the by_transaction.go address filter passes. +// Defaults: sender = testEmitter, contract address = testEmitter, sequence = 1. +// newTestWatcher sets w.contract = testEmitter by default, so the by_transaction.go address filter passes. // For full control over fields, use newTestLog with a testLogParams struct. func newValidWormholeLog(t *testing.T, blockNumber uint64, consistencyLevel uint8) *types.Log { t.Helper() @@ -408,6 +421,8 @@ func newTestLogEventFromParams(p testLogEventParams) *ethabi.AbiLogMessagePublis Payload: []byte{}, ConsistencyLevel: p.consistencyLevel, Raw: types.Log{ + Address: testEmitter, + Topics: []eth_common.Hash{LogMessagePublishedTopic}, TxHash: txHash, BlockHash: blockHash, BlockNumber: p.blockNumber, From 18702a5771428dcef2fe7f7c2c718bcbb280c441 Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Fri, 1 May 2026 11:52:46 -0700 Subject: [PATCH 04/10] Fix lints and reinitialization bug on network stats --- node/pkg/watchers/evm/reobserve_test.go | 36 +++--- node/pkg/watchers/evm/watcher.go | 13 +- node/pkg/watchers/evm/watcher_test.go | 117 +++++++++--------- .../watchers/evm/watcher_test_helpers_test.go | 32 ++--- 4 files changed, 99 insertions(+), 99 deletions(-) diff --git a/node/pkg/watchers/evm/reobserve_test.go b/node/pkg/watchers/evm/reobserve_test.go index c66934b63fc..2ad67dd9290 100644 --- a/node/pkg/watchers/evm/reobserve_test.go +++ b/node/pkg/watchers/evm/reobserve_test.go @@ -30,7 +30,7 @@ func TestReobserveSingleMessage(t *testing.T) { w, mock, msgC := newTestWatcher(t) log := newValidWormholeLog(t, testBlockNumber, tc.consistencyLevel) - mock.seedLog(log, 1234) + mock.seedLog(log) numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) require.NoError(t, err) @@ -42,7 +42,7 @@ func TestReobserveSingleMessage(t *testing.T) { msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) require.Equal(t, common.NotVerified, msg.VerificationState()) - assertMessageMatchesEvent(t, msg, ev, 1234) + assertMessageMatchesEvent(t, msg, ev) }) } } @@ -65,7 +65,7 @@ func TestReobserveSingleMessageEarly(t *testing.T) { w, mock, msgC := newTestWatcher(t) log := newValidWormholeLog(t, testBlockNumber, tc.consistencyLevel) - mock.seedLog(log, 1234) + mock.seedLog(log) numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, tc.finalizedBlockNum, tc.safeBlockNum) require.NoError(t, err) @@ -150,7 +150,7 @@ func TestReobserveLogSkipped(t *testing.T) { log := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) tc.mutate(log) - mock.seedLog(log, 1234) + mock.seedLog(log) numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) require.NoError(t, err) @@ -178,7 +178,7 @@ func TestReobserveDeterministicOrdering(t *testing.T) { log := newTestLog(t, testLogParams{ sender: testTokenBridge, contractAddr: testEmitter, - sequence: uint64(i), + sequence: uint64(i), // #nosec G115 -- test-only consistencyLevel: vaa.ConsistencyLevelFinalized, blockNumber: testBlockNumber, }) @@ -187,7 +187,7 @@ func TestReobserveDeterministicOrdering(t *testing.T) { receipt := newTestReceipt(testBlockNumber, logs) mock.receipts[logs[0].TxHash] = receipt - mock.blockTimes[receipt.BlockHash] = 1234 + mock.blockTimes[receipt.BlockHash] = testBlockTime numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, logs[0].TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) require.NoError(t, err) @@ -220,7 +220,7 @@ func TestReobserve1KEventsInReceipt(t *testing.T) { log := newTestLog(t, testLogParams{ sender: testTokenBridge, contractAddr: testEmitter, - sequence: uint64(i), + sequence: uint64(i), // #nosec G115 -- test-only consistencyLevel: vaa.ConsistencyLevelFinalized, blockNumber: testBlockNumber, }) @@ -229,7 +229,7 @@ func TestReobserve1KEventsInReceipt(t *testing.T) { receipt := newTestReceipt(testBlockNumber, logs) mock.receipts[logs[0].TxHash] = receipt - mock.blockTimes[receipt.BlockHash] = 1234 + mock.blockTimes[receipt.BlockHash] = testBlockTime numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, logs[0].TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) require.NoError(t, err) @@ -239,7 +239,7 @@ func TestReobserve1KEventsInReceipt(t *testing.T) { for i := 0; i < numEvents; i++ { msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) - require.Equal(t, uint64(i), msg.Sequence) + require.Equal(t, uint64(i), msg.Sequence) // #nosec G115 -- test-only } } @@ -247,7 +247,7 @@ func TestReobserveTwoValidEvents(t *testing.T) { w, mock, msgC := newTestWatcher(t) log1 := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) - mock.seedLog(log1, 1234) + mock.seedLog(log1) log2 := newTestLog(t, testLogParams{ sender: testTokenBridge, @@ -259,7 +259,7 @@ func TestReobserveTwoValidEvents(t *testing.T) { receipt := newTestReceipt(log1.BlockNumber, []*types.Log{log1, &log2}) mock.receipts[log1.TxHash] = receipt - mock.blockTimes[receipt.BlockHash] = 1234 + mock.blockTimes[receipt.BlockHash] = testBlockTime numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log1.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) require.NoError(t, err) @@ -270,20 +270,20 @@ func TestReobserveTwoValidEvents(t *testing.T) { require.NoError(t, err) msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) - assertMessageMatchesEvent(t, msg, ev, 1234) + assertMessageMatchesEvent(t, msg, ev) ev, err = mock.ParseLogMessagePublished(log2) require.NoError(t, err) msg = recvMsg(t, msgC) require.True(t, msg.IsReobservation) - assertMessageMatchesEvent(t, msg, ev, 1234) + assertMessageMatchesEvent(t, msg, ev) } func TestReobserveValidAndInvalid(t *testing.T) { w, mock, msgC := newTestWatcher(t) log1 := newValidWormholeLog(t, testBlockNumber, vaa.ConsistencyLevelFinalized) - mock.seedLog(log1, 1234) + mock.seedLog(log1) log2 := newTestLog(t, testLogParams{ sender: testTokenBridge, @@ -297,7 +297,7 @@ func TestReobserveValidAndInvalid(t *testing.T) { receipt := newTestReceipt(log1.BlockNumber, []*types.Log{log1, &log2}) mock.receipts[log1.TxHash] = receipt - mock.blockTimes[receipt.BlockHash] = 1234 + mock.blockTimes[receipt.BlockHash] = testBlockTime numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log1.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) require.NoError(t, err) @@ -308,7 +308,7 @@ func TestReobserveValidAndInvalid(t *testing.T) { require.NoError(t, err) msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) - assertMessageMatchesEvent(t, msg, ev, 1234) + assertMessageMatchesEvent(t, msg, ev) } func TestReobserveTxVerifierIntegration(t *testing.T) { @@ -338,7 +338,7 @@ func TestReobserveTxVerifierIntegration(t *testing.T) { consistencyLevel: vaa.ConsistencyLevelFinalized, blockNumber: testBlockNumber, }) - mock.seedLog(&log, 1234) + mock.seedLog(&log) numObs, err := w.handleReobservationRequest(context.TODO(), w.chainID, log.TxHash.Bytes(), mock, testFinalizedBlockNum, testSafeBlockNum) require.NoError(t, err) @@ -350,7 +350,7 @@ func TestReobserveTxVerifierIntegration(t *testing.T) { msg := recvMsg(t, msgC) require.True(t, msg.IsReobservation) require.Equal(t, tc.expectState, msg.VerificationState()) - assertMessageMatchesEvent(t, msg, ev, 1234) + assertMessageMatchesEvent(t, msg, ev) }) } } diff --git a/node/pkg/watchers/evm/watcher.go b/node/pkg/watchers/evm/watcher.go index accb68f9925..46e71458625 100644 --- a/node/pkg/watchers/evm/watcher.go +++ b/node/pkg/watchers/evm/watcher.go @@ -433,7 +433,7 @@ func (w *Watcher) Run(parentCtx context.Context) error { } numObservations, handleErr := w.handleReobservationRequest( ctx, - vaa.ChainID(r.ChainId), + vaa.ChainID(r.ChainId), // #nosec G115 -- bounded by MaxUint16 check above r.TxHash, w.ethConn, atomic.LoadUint64(&w.latestFinalizedBlockNumber), @@ -503,6 +503,7 @@ func (w *Watcher) Run(parentCtx context.Context) error { defer headerSubscription.Unsubscribe() common.RunWithScissors(ctx, errC, "evm_fetch_headers", func(ctx context.Context) error { + stats := gossipv1.Heartbeat_Network{ContractAddress: w.contract.Hex()} for { select { case <-ctx.Done(): @@ -533,10 +534,10 @@ func (w *Watcher) Run(parentCtx context.Context) error { ) readiness.SetReady(w.readinessSync) - if err := w.processNewBlock(ctx, ev); err != nil { + if err := w.processNewBlock(ctx, ev, &stats); err != nil { errC <- err //nolint:channelcheck // The watcher will exit anyway p2p.DefaultRegistry.AddErrorCount(w.chainID, 1) - return nil + return nil //nolint:nilerr // error propagated via errC } } } @@ -869,10 +870,9 @@ func (w *Watcher) postMessage( } // processNewBlock handles a new incoming block from the subscriber. It loops through all w.pending messages for processing. -func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) error { +func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, stats *gossipv1.Heartbeat_Network) error { start := time.Now() logger := w.logger - stats := gossipv1.Heartbeat_Network{ContractAddress: w.contract.Hex()} currentHash := ev.Hash blockNumberU := ev.Number.Uint64() @@ -896,10 +896,9 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock) stats.FinalizedHeight = int64(blockNumberU) // #nosec G115 -- This conversion is safe indefinitely default: logger.Error("unexpected finality in block", zap.Stringer("finality", ev.Finality), zap.Any("event", ev)) - p2p.DefaultRegistry.AddErrorCount(w.chainID, 1) return fmt.Errorf("unexpected finality in block: %v", ev.Finality) } - w.updateNetworkStats(&stats) + w.updateNetworkStats(stats) w.pendingMu.Lock() defer w.pendingMu.Unlock() diff --git a/node/pkg/watchers/evm/watcher_test.go b/node/pkg/watchers/evm/watcher_test.go index 8b702a600dd..ba2cb5a6cfb 100644 --- a/node/pkg/watchers/evm/watcher_test.go +++ b/node/pkg/watchers/evm/watcher_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/certusone/wormhole/node/pkg/common" + gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1" "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors" "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors/ethabi" ethereum "github.com/ethereum/go-ethereum" @@ -224,10 +225,10 @@ func TestProcessBlockPendingByFinality(t *testing.T) { txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") blockHash := eth_common.BigToHash(big.NewInt(100)) - w.addPendingMsg(txHash, blockHash, 100, tc.cl, 0, 1) + w.addPendingMsg(txHash, blockHash, tc.cl, 0, 1) mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} - err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality)) + err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) assert.Equal(t, tc.expectPending, len(w.pending)) @@ -249,12 +250,12 @@ func TestProcessBlockPendingFinalizedAndSafe(t *testing.T) { blockHash1 := eth_common.BigToHash(big.NewInt(111)) blockHash2 := eth_common.BigToHash(big.NewInt(222)) - w.addPendingMsg(txHash1, blockHash1, 100, vaa.ConsistencyLevelFinalized, 0, 1) - w.addPendingMsg(txHash2, blockHash2, 100, vaa.ConsistencyLevelSafe, 0, 1) + w.addPendingMsg(txHash1, blockHash1, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash2, blockHash2, vaa.ConsistencyLevelSafe, 0, 1) mock.receipts[txHash1] = &types.Receipt{Status: 1, BlockHash: blockHash1, TxHash: txHash1} mock.receipts[txHash2] = &types.Receipt{Status: 1, BlockHash: blockHash2, TxHash: txHash2} - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed one from pending @@ -264,7 +265,7 @@ func TestProcessBlockPendingFinalizedAndSafe(t *testing.T) { require.Equal(t, 1, len(msgC)) assert.Equal(t, txHash1.Bytes(), recvMsg(t, msgC).TxID) - err = w.processNewBlock(context.TODO(), newBlock(105, connectors.Safe)) + err = w.processNewBlock(context.TODO(), newBlock(105, connectors.Safe), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed both @@ -282,10 +283,10 @@ func TestProcessBlockPendingWrongBlockHash(t *testing.T) { blockHashBlock := eth_common.BigToHash(big.NewInt(111)) blockHashMessage := eth_common.BigToHash(big.NewInt(222)) - w.addPendingMsg(txHash, blockHashMessage, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash, blockHashMessage, vaa.ConsistencyLevelFinalized, 0, 1) mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHashBlock, TxHash: txHash} - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed from pending @@ -301,10 +302,10 @@ func TestProcessBlockPendingFailedTx(t *testing.T) { txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") blockHash := eth_common.BigToHash(big.NewInt(100)) - w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) mock.receipts[txHash] = &types.Receipt{Status: 0, BlockHash: blockHash, TxHash: txHash} - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed from pending @@ -320,11 +321,11 @@ func TestProcessBlockValidReceiptWithError(t *testing.T) { txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") blockHash := eth_common.BigToHash(big.NewInt(100)) - w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} mock.errors[txHash] = errors.New("not found") - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed from pending @@ -340,9 +341,9 @@ func TestProcessBlockInValidReceiptNoError(t *testing.T) { txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") blockHash := eth_common.BigToHash(big.NewInt(100)) - w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed from pending @@ -360,9 +361,9 @@ func TestProcessBlockTransientError(t *testing.T) { mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} mock.errors[txHash] = errors.New("rate limit 429 error") - w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Not removed from pending @@ -373,7 +374,7 @@ func TestProcessBlockTransientError(t *testing.T) { // Try the message again after the transient error has disappeared mock.errors[txHash] = nil - err = w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err = w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed from pending @@ -392,10 +393,10 @@ func TestProcessBlockTransientErrorTimeout(t *testing.T) { mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} mock.errors[txHash] = errors.New("transient error") - w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHash, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) // blockNumberU = pLock.height + MaxWaitConfirmations exactly hits the timeout branch. - err := w.processNewBlock(context.TODO(), newBlock(100+MaxWaitConfirmations, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(100+MaxWaitConfirmations, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed from pending after timeout @@ -429,10 +430,10 @@ func TestProcessBlockAdditionalBlocks(t *testing.T) { txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") blockHash := eth_common.BigToHash(big.NewInt(100)) - w.addPendingMsg(txHash, blockHash, 100, tc.cl, tc.additionalBlocks, 1) + w.addPendingMsg(txHash, blockHash, tc.cl, tc.additionalBlocks, 1) mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} - err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality)) + err := w.processNewBlock(context.TODO(), newBlock(tc.blockNumber, tc.finality), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) assert.Equal(t, tc.expectPending, len(w.pending)) @@ -453,12 +454,12 @@ func TestProcessBlockCCLEffectiveCLDiffersFromMessageCL(t *testing.T) { blockHash := eth_common.BigToHash(big.NewInt(100)) // Simulate CCL: message.ConsistencyLevel = Custom, but effectiveCL = Finalized - key := w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 5, 1) + key := w.addPendingMsg(txHash, blockHash, vaa.ConsistencyLevelFinalized, 5, 1) w.pending[key].message.ConsistencyLevel = vaa.ConsistencyLevelCustom mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} // Finalized block at height+additionalBlocks: should confirm based on effectiveCL - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Removed from pending and published @@ -479,15 +480,15 @@ func TestProcessBlockCCLMultiplePendingDifferentAdditionalBlocks(t *testing.T) { blockHash := eth_common.BigToHash(big.NewInt(100)) // Message A: effectiveCL=Finalized, additionalBlocks=0, height=100 -> ready at block 100 - w.addPendingMsg(txHashA, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHashA, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) mock.receipts[txHashA] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHashA} // Message B: effectiveCL=Finalized, additionalBlocks=10, height=100 -> ready at block 110 - w.addPendingMsg(txHashB, blockHash, 100, vaa.ConsistencyLevelFinalized, 10, 2) + w.addPendingMsg(txHashB, blockHash, vaa.ConsistencyLevelFinalized, 10, 2) mock.receipts[txHashB] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHashB} // Send finalized block at 105: A should confirm, B should stay pending - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) assert.Equal(t, 1, len(w.pending), "only message B should remain pending") @@ -495,7 +496,7 @@ func TestProcessBlockCCLMultiplePendingDifferentAdditionalBlocks(t *testing.T) { assert.Equal(t, txHashA.Bytes(), recvMsg(t, msgC).TxID) // Send finalized block at 110: B should now confirm - err = w.processNewBlock(context.TODO(), newBlock(110, connectors.Finalized)) + err = w.processNewBlock(context.TODO(), newBlock(110, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) assert.Equal(t, 0, len(w.pending), "no messages should remain pending") @@ -511,14 +512,14 @@ func TestProcessBlockOneConfirmedOneOrphaned(t *testing.T) { txHashGood := eth_common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") txHashOrphaned := eth_common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") - w.addPendingMsg(txHashGood, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) - w.addPendingMsg(txHashOrphaned, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 2) + w.addPendingMsg(txHashGood, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) + w.addPendingMsg(txHashOrphaned, blockHash, vaa.ConsistencyLevelFinalized, 0, 2) // Good tx has a valid receipt mock.receipts[txHashGood] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHashGood} // Orphaned tx returns nil receipt (not in mock.receipts, so defaults to nil) - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) // Both removed from pending @@ -535,7 +536,7 @@ func TestProcessBlockOneConfirmedOneOrphaned(t *testing.T) { func TestProcessBlockUnexpectedFinality(t *testing.T) { w, _, msgC := newTestWatcher(t) - err := w.processNewBlock(context.TODO(), newBlock(100, connectors.FinalityLevel(99))) + err := w.processNewBlock(context.TODO(), newBlock(100, connectors.FinalityLevel(99)), &gossipv1.Heartbeat_Network{}) require.Error(t, err) assert.Contains(t, err.Error(), "unexpected finality in block") @@ -564,13 +565,13 @@ func TestProcessBlockTxVerifier(t *testing.T) { txHash := eth_common.HexToHash("0xd2d35ab0d18dd19e81a58dfe8d97ad8c68659bd81d7017bcdf4d9719b32119ef") blockHash := eth_common.BigToHash(big.NewInt(100)) - key := w.addPendingMsg(txHash, blockHash, 100, vaa.ConsistencyLevelFinalized, 0, 1) + key := w.addPendingMsg(txHash, blockHash, vaa.ConsistencyLevelFinalized, 0, 1) if tc.useTokenBridge { w.pending[key].message.EmitterAddress = PadAddress(testTokenBridge) } mock.receipts[txHash] = &types.Receipt{Status: 1, BlockHash: blockHash, TxHash: txHash} - err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized)) + err := w.processNewBlock(context.TODO(), newBlock(105, connectors.Finalized), &gossipv1.Heartbeat_Network{}) require.NoError(t, err) assert.Equal(t, 0, len(w.pending)) @@ -597,7 +598,7 @@ func TestPostMessageAddsToPending(t *testing.T) { t.Run(tc.name, func(t *testing.T) { w, _, msgC := newTestWatcher(t) - ev := newTestLogEvent(100, tc.cl) + ev := newTestLogEvent(tc.cl) w.postMessage(context.TODO(), ev, 1234) require.Equal(t, 1, len(w.pending)) @@ -612,8 +613,8 @@ func TestPostMessageAddsToPending(t *testing.T) { pe := w.pending[key] require.NotNil(t, pe) - assertMessageMatchesEvent(t, pe.message, ev, 1234) - assertPendingMetadata(t, pe, tc.cl, 100, 0) + assertMessageMatchesEvent(t, pe.message, ev) + assertPendingMetadata(t, pe, tc.cl, 0) }) } } @@ -647,7 +648,7 @@ func TestPostMessageCustomDefaultToFinalized(t *testing.T) { tc.setupCCL(w) } - ev := newTestLogEvent(100, vaa.ConsistencyLevelCustom) + ev := newTestLogEvent(vaa.ConsistencyLevelCustom) w.postMessage(context.TODO(), ev, 1234) require.Equal(t, 1, len(w.pending)) @@ -661,8 +662,8 @@ func TestPostMessageCustomDefaultToFinalized(t *testing.T) { } pe := w.pending[key] require.NotNil(t, pe) - assertMessageMatchesEvent(t, pe.message, ev, 1234) - assertPendingMetadata(t, pe, vaa.ConsistencyLevelFinalized, 100, 0) + assertMessageMatchesEvent(t, pe.message, ev) + assertPendingMetadata(t, pe, vaa.ConsistencyLevelFinalized, 0) }) } } @@ -691,7 +692,7 @@ func TestPostMessageCustomAdditionalBlocks(t *testing.T) { w.enableCCL() w.seedCCLAdditionalBlocks(testEmitter, tc.effectiveCL, tc.additionalBlocks) - ev := newTestLogEvent(100, vaa.ConsistencyLevelCustom) + ev := newTestLogEvent(vaa.ConsistencyLevelCustom) w.postMessage(context.TODO(), ev, 1234) require.Equal(t, 1, len(w.pending)) @@ -705,8 +706,8 @@ func TestPostMessageCustomAdditionalBlocks(t *testing.T) { } pe := w.pending[key] require.NotNil(t, pe) - assertMessageMatchesEvent(t, pe.message, ev, 1234) - assertPendingMetadata(t, pe, tc.effectiveCL, 100, uint64(tc.additionalBlocks)) + assertMessageMatchesEvent(t, pe.message, ev) + assertPendingMetadata(t, pe, tc.effectiveCL, uint64(tc.additionalBlocks)) }) } } @@ -715,7 +716,7 @@ func TestPostMessageCustomAdditionalBlocks(t *testing.T) { func TestPostMessageInstantPublishes(t *testing.T) { w, mock, msgC := newTestWatcher(t) - ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) mock.receipts[ev.Raw.TxHash] = newTestReceipt(ev.Raw.BlockNumber, nil) w.postMessage(context.TODO(), ev, 1234) @@ -725,7 +726,7 @@ func TestPostMessageInstantPublishes(t *testing.T) { msg := recvMsg(t, msgC) - assertMessageMatchesEvent(t, msg, ev, 1234) + assertMessageMatchesEvent(t, msg, ev) assert.Equal(t, common.NotVerified, msg.VerificationState()) } @@ -733,9 +734,9 @@ func TestPostMessageInstantPublishes(t *testing.T) { func TestPostMessageTwoInstantPublishes(t *testing.T) { w, mock, msgC := newTestWatcher(t) - ev1 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev1 := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) - ev2 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev2 := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) ev2.Sender = eth_common.HexToAddress("0x388C818CA8B9251b393131C08a736A67ccB19297") ev2.Nonce = 20 ev2.Sequence = 2 @@ -752,10 +753,10 @@ func TestPostMessageTwoInstantPublishes(t *testing.T) { msg1 := recvMsg(t, msgC) msg2 := recvMsg(t, msgC) - assertMessageMatchesEvent(t, msg1, ev1, 1234) + assertMessageMatchesEvent(t, msg1, ev1) assert.Equal(t, common.NotVerified, msg1.VerificationState()) - assertMessageMatchesEvent(t, msg2, ev2, 1234) + assertMessageMatchesEvent(t, msg2, ev2) assert.Equal(t, common.NotVerified, msg2.VerificationState()) } @@ -764,7 +765,7 @@ func TestPostMessageInstantAndFinalized(t *testing.T) { w, mock, msgC := newTestWatcher(t) // ev1: instant publish - ev1 := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev1 := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) // ev2: finalized (goes to pending) — use a different emitter and sequence ev2 := newTestLogEventFromParams(testLogEventParams{ @@ -784,7 +785,7 @@ func TestPostMessageInstantAndFinalized(t *testing.T) { // Verify the instant-published message msg := recvMsg(t, msgC) - assertMessageMatchesEvent(t, msg, ev1, 1234) + assertMessageMatchesEvent(t, msg, ev1) assert.Equal(t, common.NotVerified, msg.VerificationState()) // Verify the finalized pending entry @@ -797,8 +798,8 @@ func TestPostMessageInstantAndFinalized(t *testing.T) { pe := w.pending[key] require.NotNil(t, pe) - assertMessageMatchesEvent(t, pe.message, ev2, 1234) - assertPendingMetadata(t, pe, vaa.ConsistencyLevelFinalized, 100, 0) + assertMessageMatchesEvent(t, pe.message, ev2) + assertPendingMetadata(t, pe, vaa.ConsistencyLevelFinalized, 0) } // Transaction contains multiple events @@ -858,7 +859,7 @@ func TestPostMessageMultipleEventsFromSameTransaction(t *testing.T) { func TestPostMessageRemovedLogIsIgnored(t *testing.T) { w, _, msgC := newTestWatcher(t) - ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) ev.Raw.Removed = true w.postMessage(context.TODO(), ev, 1234) @@ -870,7 +871,7 @@ func TestPostMessageRemovedLogIsIgnored(t *testing.T) { func TestPostMessageWrongContractAddressIsIgnored(t *testing.T) { w, _, msgC := newTestWatcher(t) - ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) ev.Raw.Address = eth_common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") w.postMessage(context.TODO(), ev, 1234) @@ -882,7 +883,7 @@ func TestPostMessageWrongContractAddressIsIgnored(t *testing.T) { func TestPostMessageWrongEventSignatureIsIgnored(t *testing.T) { w, _, msgC := newTestWatcher(t) - ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) ev.Raw.Topics = []eth_common.Hash{ eth_common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), eth_common.BytesToHash(testEmitter.Bytes()), @@ -929,7 +930,7 @@ func TestPostMessageTxVerifier(t *testing.T) { require.Equal(t, 1, len(msgC)) msg := recvMsg(t, msgC) - assertMessageMatchesEvent(t, msg, ev, 1234) + assertMessageMatchesEvent(t, msg, ev) assert.Equal(t, tc.expectState, msg.VerificationState()) }) } @@ -939,7 +940,7 @@ func TestPostMessageTxVerifier(t *testing.T) { func TestPostMessageInstantReceiptError(t *testing.T) { w, mock, msgC := newTestWatcher(t) - ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) mock.errors[ev.Raw.TxHash] = errors.New("rpc failure") w.postMessage(context.TODO(), ev, 1234) @@ -952,7 +953,7 @@ func TestPostMessageInstantReceiptError(t *testing.T) { func TestPostMessageInstantTxFailed(t *testing.T) { w, mock, msgC := newTestWatcher(t) - ev := newTestLogEvent(100, vaa.ConsistencyLevelPublishImmediately) + ev := newTestLogEvent(vaa.ConsistencyLevelPublishImmediately) receipt := newTestReceipt(ev.Raw.BlockNumber, nil) receipt.Status = 0 mock.receipts[ev.Raw.TxHash] = receipt diff --git a/node/pkg/watchers/evm/watcher_test_helpers_test.go b/node/pkg/watchers/evm/watcher_test_helpers_test.go index 8dd5244d2e6..02c5e47e1ae 100644 --- a/node/pkg/watchers/evm/watcher_test_helpers_test.go +++ b/node/pkg/watchers/evm/watcher_test_helpers_test.go @@ -37,6 +37,7 @@ var ( testBlockNumber = uint64(100) testFinalizedBlockNum = uint64(200) testSafeBlockNum = uint64(150) + testBlockTime = uint64(1234) ) // NewWatcherForTest creates a minimal Watcher for verifyAndPublish tests. @@ -137,10 +138,10 @@ func newMockConnector(t *testing.T) *mockConnector { // seedLog wires the mock to return a successful receipt wrapping `log` (keyed by its TxHash) // and the given block time (keyed by the receipt's BlockHash). For receipts with Status != 1 // or custom shapes, write directly to mock.receipts / mock.blockTimes. -func (m *mockConnector) seedLog(log *types.Log, blockTime uint64) { +func (m *mockConnector) seedLog(log *types.Log) { receipt := newTestReceipt(log.BlockNumber, []*types.Log{log}) m.receipts[log.TxHash] = receipt - m.blockTimes[receipt.BlockHash] = blockTime + m.blockTimes[receipt.BlockHash] = testBlockTime } // TransactionReceipt returns the configured receipt/error. When neither is set for the txHash, @@ -209,8 +210,8 @@ func (m *mockConnector) SubscribeNewHead(ctx context.Context, ch chan<- *types.H // Hash is deterministically derived from the block number. func newBlock(number uint64, finality connectors.FinalityLevel) *connectors.NewBlock { return &connectors.NewBlock{ - Number: big.NewInt(int64(number)), - Hash: eth_common.BigToHash(big.NewInt(int64(number))), + Number: big.NewInt(int64(number)), // #nosec G115 -- test-only + Hash: eth_common.BigToHash(big.NewInt(int64(number))), // #nosec G115 -- test-only Time: number, Finality: finality, } @@ -221,7 +222,6 @@ func newBlock(number uint64, finality connectors.FinalityLevel) *connectors.NewB func (w *Watcher) addPendingMsg( txHash eth_common.Hash, blockHash eth_common.Hash, - height uint64, effectiveCL uint8, additionalBlocks uint64, sequence uint64, @@ -239,7 +239,7 @@ func (w *Watcher) addPendingMsg( w.pending[key] = &pendingMessage{ message: msg, - height: height, + height: testBlockNumber, effectiveCL: effectiveCL, additionalBlocks: additionalBlocks, } @@ -326,11 +326,11 @@ func newTestLog(t *testing.T, p testLogParams) types.Log { txHash := p.txHash if txHash == (eth_common.Hash{}) { - txHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber))) + txHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber))) // #nosec G115 -- test-only } blockHash := p.blockHash if blockHash == (eth_common.Hash{}) { - blockHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber + 0xff))) + blockHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber + 0xff))) // #nosec G115 -- test-only } data, err := logMessagePublishedArgs().Pack(p.sequence, uint32(0), []byte{}, p.consistencyLevel) @@ -394,11 +394,11 @@ type testLogEventParams struct { // newTestLogEvent creates an AbiLogMessagePublished with sensible non-zero defaults for postMessage tests. // Only blockNumber and consistencyLevel are required; sender and sequence get deterministic non-zero values. -func newTestLogEvent(blockNumber uint64, consistencyLevel uint8) *ethabi.AbiLogMessagePublished { +func newTestLogEvent(consistencyLevel uint8) *ethabi.AbiLogMessagePublished { return newTestLogEventFromParams(testLogEventParams{ sender: testEmitter, sequence: 1, - blockNumber: blockNumber, + blockNumber: testBlockNumber, consistencyLevel: consistencyLevel, }) } @@ -408,11 +408,11 @@ func newTestLogEvent(blockNumber uint64, consistencyLevel uint8) *ethabi.AbiLogM func newTestLogEventFromParams(p testLogEventParams) *ethabi.AbiLogMessagePublished { txHash := p.txHash if txHash == (eth_common.Hash{}) { - txHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber))) + txHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber))) // #nosec G115 -- test-only } blockHash := p.blockHash if blockHash == (eth_common.Hash{}) { - blockHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber + 0xff))) + blockHash = eth_common.BigToHash(big.NewInt(int64(p.blockNumber + 0xff))) // #nosec G115 -- test-only } return ðabi.AbiLogMessagePublished{ @@ -431,10 +431,10 @@ func newTestLogEventFromParams(p testLogEventParams) *ethabi.AbiLogMessagePublis } // assertMessageMatchesEvent verifies that all fields on a MessagePublication match the source event. -func assertMessageMatchesEvent(t *testing.T, msg *common.MessagePublication, ev *ethabi.AbiLogMessagePublished, blockTime int64) { +func assertMessageMatchesEvent(t *testing.T, msg *common.MessagePublication, ev *ethabi.AbiLogMessagePublished) { t.Helper() assert.Equal(t, ev.Raw.TxHash.Bytes(), msg.TxID) - assert.Equal(t, time.Unix(blockTime, 0), msg.Timestamp) + assert.Equal(t, time.Unix(int64(testBlockTime), 0), msg.Timestamp) // #nosec G115 -- test-only assert.Equal(t, ev.Nonce, msg.Nonce) assert.Equal(t, ev.Sequence, msg.Sequence) assert.Equal(t, vaa.ChainIDEthereum, msg.EmitterChain) @@ -444,9 +444,9 @@ func assertMessageMatchesEvent(t *testing.T, msg *common.MessagePublication, ev } // assertPendingMetadata verifies the metadata fields on a pendingMessage. -func assertPendingMetadata(t *testing.T, pe *pendingMessage, effectiveCL uint8, height uint64, additionalBlocks uint64) { +func assertPendingMetadata(t *testing.T, pe *pendingMessage, effectiveCL uint8, additionalBlocks uint64) { t.Helper() assert.Equal(t, effectiveCL, pe.effectiveCL) - assert.Equal(t, height, pe.height) + assert.Equal(t, testBlockNumber, pe.height) assert.Equal(t, additionalBlocks, pe.additionalBlocks) } From c68ffa41f296de1411d4995c0c5c7108c98163b8 Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Fri, 1 May 2026 12:46:27 -0700 Subject: [PATCH 05/10] Fix gofmt error --- node/pkg/watchers/evm/watcher_test_helpers_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/pkg/watchers/evm/watcher_test_helpers_test.go b/node/pkg/watchers/evm/watcher_test_helpers_test.go index 02c5e47e1ae..980c3e9e4a1 100644 --- a/node/pkg/watchers/evm/watcher_test_helpers_test.go +++ b/node/pkg/watchers/evm/watcher_test_helpers_test.go @@ -210,7 +210,7 @@ func (m *mockConnector) SubscribeNewHead(ctx context.Context, ch chan<- *types.H // Hash is deterministically derived from the block number. func newBlock(number uint64, finality connectors.FinalityLevel) *connectors.NewBlock { return &connectors.NewBlock{ - Number: big.NewInt(int64(number)), // #nosec G115 -- test-only + Number: big.NewInt(int64(number)), // #nosec G115 -- test-only Hash: eth_common.BigToHash(big.NewInt(int64(number))), // #nosec G115 -- test-only Time: number, Finality: finality, From 9ec13b334e373c777a6c04fb4652c6e7dec47acc Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Wed, 20 May 2026 13:43:52 -0700 Subject: [PATCH 06/10] Fixed channelcheck comment and renamed tx to txreceipt in watcher processing --- node/pkg/watchers/evm/watcher.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/node/pkg/watchers/evm/watcher.go b/node/pkg/watchers/evm/watcher.go index 46e71458625..a28be2f6407 100644 --- a/node/pkg/watchers/evm/watcher.go +++ b/node/pkg/watchers/evm/watcher.go @@ -535,7 +535,7 @@ func (w *Watcher) Run(parentCtx context.Context) error { readiness.SetReady(w.readinessSync) if err := w.processNewBlock(ctx, ev, &stats); err != nil { - errC <- err //nolint:channelcheck // The watcher will exit anyway + errC <- err p2p.DefaultRegistry.AddErrorCount(w.chainID, 1) return nil //nolint:nilerr // error propagated via errC } @@ -916,7 +916,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, // Transaction is now ready msm := time.Now() timeout, cancel := context.WithTimeout(ctx, 5*time.Second) - tx, err := w.ethConn.TransactionReceipt(timeout, eth_common.BytesToHash(pLock.message.TxID)) + txreceipt, err := w.ethConn.TransactionReceipt(timeout, eth_common.BytesToHash(pLock.message.TxID)) queryLatency.WithLabelValues(w.networkName, "transaction_receipt").Observe(time.Since(msm).Seconds()) cancel() @@ -975,8 +975,8 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, continue } - // tx is nil and err is nil. A check to prevent panics later - if tx == nil { + // txreceipt is nil and err is nil. A check to prevent panics later + if txreceipt == nil { logger.Error("connector returned nil receipt with no error", zap.String("msgId", pLock.message.MessageIDString()), zap.String("txHash", pLock.message.TxIDString()), @@ -992,11 +992,11 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, // SECURITY: Defense in depth against a buggy or malicious connector returning // a receipt for a different transaction than the one we queried. expectedTxHash := eth_common.BytesToHash(pLock.message.TxID) - if tx.TxHash != expectedTxHash { - logger.Error("transaction receipt hash does not match queried tx", + if txreceipt.TxHash != expectedTxHash { + logger.Error("transaction receipt hash does not match queried txreceipt", zap.String("msgId", pLock.message.MessageIDString()), zap.String("expectedTxHash", expectedTxHash.Hex()), - zap.Stringer("receiptTxHash", tx.TxHash), + zap.Stringer("receiptTxHash", txreceipt.TxHash), zap.Stringer("blockHash", key.BlockHash), zap.Stringer("current_blockNum", ev.Number), zap.Stringer("finality", ev.Finality), @@ -1009,7 +1009,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, // This should never happen - if we got this far, it means that logs were emitted, // which is only possible if the transaction succeeded. We check it anyway just // in case the EVM implementation is buggy. - if tx.Status != gethTypes.ReceiptStatusSuccessful { + if txreceipt.Status != gethTypes.ReceiptStatusSuccessful { logger.Error("transaction receipt with non-success status", zap.String("msgId", pLock.message.MessageIDString()), zap.String("txHash", pLock.message.TxIDString()), @@ -1028,7 +1028,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, // It's possible for a transaction to be orphaned and then included in a different block // but with the same tx hash. Drop the observation (it will be re-observed and needs to // wait for the full confirmation time again). - if tx.BlockHash != key.BlockHash { + if txreceipt.BlockHash != key.BlockHash { logger.Info("tx got dropped and mined in a different block; the message should have been reobserved", zap.String("msgId", pLock.message.MessageIDString()), zap.String("txHash", pLock.message.TxIDString()), @@ -1056,9 +1056,8 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, ) delete(w.pending, key) - // Note that `tx` here is actually a receipt txHash := eth_common.Hash(pLock.message.TxID) - pubErr := w.verifyAndPublish(pLock.message, ctx, txHash, tx) + pubErr := w.verifyAndPublish(pLock.message, ctx, txHash, txreceipt) if pubErr != nil { logger.Error("could not publish message", zap.String("msgId", pLock.message.MessageIDString()), From 907344a218747b1505f9619b809e6e1e170c4cce Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Fri, 5 Jun 2026 13:45:17 -0700 Subject: [PATCH 07/10] Add security comments back in --- node/pkg/watchers/evm/watcher.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/node/pkg/watchers/evm/watcher.go b/node/pkg/watchers/evm/watcher.go index a28be2f6407..3c3e6dceb06 100644 --- a/node/pkg/watchers/evm/watcher.go +++ b/node/pkg/watchers/evm/watcher.go @@ -819,6 +819,10 @@ func (w *Watcher) postMessage( verifyCtx, cancel := context.WithCancel(parentCtx) defer cancel() + // SECURITY: The TxHash existing within the particular block hash guarantees + // that the event exists without being modified. We don't + // check for the event mutating into something that's invalid (wrong event type, etc.) + // because of this. pubErr := w.verifyAndPublish(msg, verifyCtx, ev.Raw.TxHash, receipt) if pubErr != nil { w.logger.Error("could not publish message: transfer verification failed", @@ -920,6 +924,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, queryLatency.WithLabelValues(w.networkName, "transaction_receipt").Observe(time.Since(msm).Seconds()) cancel() + // SECURITY: Protection against reorgs that remove or change observations // If the node returns an error after waiting expectedConfirmation blocks, // it means the chain reorged and the transaction was orphaned. The // TransactionReceipt call is using the same websocket connection than the @@ -1006,6 +1011,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, continue } + // SECURITY: Check that the tx succeeded. // This should never happen - if we got this far, it means that logs were emitted, // which is only possible if the transaction succeeded. We check it anyway just // in case the EVM implementation is buggy. From ff7be85d5338006fc6b910686ffed2c8e02ed513 Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Fri, 5 Jun 2026 13:46:30 -0700 Subject: [PATCH 08/10] Add security comments back in --- node/pkg/watchers/evm/watcher.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/node/pkg/watchers/evm/watcher.go b/node/pkg/watchers/evm/watcher.go index 3c3e6dceb06..7f8ca5df60c 100644 --- a/node/pkg/watchers/evm/watcher.go +++ b/node/pkg/watchers/evm/watcher.go @@ -819,10 +819,6 @@ func (w *Watcher) postMessage( verifyCtx, cancel := context.WithCancel(parentCtx) defer cancel() - // SECURITY: The TxHash existing within the particular block hash guarantees - // that the event exists without being modified. We don't - // check for the event mutating into something that's invalid (wrong event type, etc.) - // because of this. pubErr := w.verifyAndPublish(msg, verifyCtx, ev.Raw.TxHash, receipt) if pubErr != nil { w.logger.Error("could not publish message: transfer verification failed", @@ -1062,6 +1058,10 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, ) delete(w.pending, key) + // SECURITY: The TxHash existing within the particular block hash guarantees + // that the event exists without being modified. We don't + // check for the event mutating into something that's invalid (wrong event type, etc.) + // because of this. txHash := eth_common.Hash(pLock.message.TxID) pubErr := w.verifyAndPublish(pLock.message, ctx, txHash, txreceipt) if pubErr != nil { From 960d1948373cc643d950861235f113f50a07c060 Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Wed, 10 Jun 2026 10:28:20 -0700 Subject: [PATCH 09/10] Better error handling on receipt == nil and logging bad status --- node/pkg/watchers/evm/by_transaction.go | 2 +- node/pkg/watchers/evm/watcher.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/node/pkg/watchers/evm/by_transaction.go b/node/pkg/watchers/evm/by_transaction.go index ea51d5fc689..1d6cdfc27a9 100644 --- a/node/pkg/watchers/evm/by_transaction.go +++ b/node/pkg/watchers/evm/by_transaction.go @@ -54,7 +54,7 @@ func MessageEventsForTransaction( // Get transactions logs from transaction // API only returns transactions that have been included in a block. Nothing in the mempool receipt, err := ethConn.TransactionReceipt(ctx, tx) - if err != nil { + if receipt == nil || err != nil { return nil, 0, nil, fmt.Errorf("failed to get transaction receipt: %w", err) } diff --git a/node/pkg/watchers/evm/watcher.go b/node/pkg/watchers/evm/watcher.go index 7f8ca5df60c..7f415cc90f7 100644 --- a/node/pkg/watchers/evm/watcher.go +++ b/node/pkg/watchers/evm/watcher.go @@ -799,7 +799,7 @@ func (w *Watcher) postMessage( receipt, err := w.ethConn.TransactionReceipt(receiptCtx, ev.Raw.TxHash) receiptCancel() queryLatency.WithLabelValues(w.networkName, "transaction_receipt").Observe(time.Since(msm).Seconds()) - if err != nil { + if receipt == nil || err != nil { w.logger.Error("failed to get transaction receipt for instant message", zap.String("msgId", msg.MessageIDString()), zap.String("txHash", msg.TxIDString()), @@ -1021,7 +1021,7 @@ func (w *Watcher) processNewBlock(ctx context.Context, ev *connectors.NewBlock, zap.Stringer("current_blockNum", ev.Number), zap.Stringer("finality", ev.Finality), zap.Stringer("current_blockHash", currentHash), - zap.Error(err)) + zap.Any("status", txreceipt.Status)) delete(w.pending, key) ethMessagesOrphaned.WithLabelValues(w.networkName, "tx_failed").Inc() continue From ac834fdb76a185dc1257d4154e387b2c3e7d94ad Mon Sep 17 00:00:00 2001 From: mdulin2 Date: Thu, 25 Jun 2026 11:29:02 -0700 Subject: [PATCH 10/10] Add Close() to mockConnector to satisfy Connector interface --- node/pkg/watchers/evm/watcher_test_helpers_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/node/pkg/watchers/evm/watcher_test_helpers_test.go b/node/pkg/watchers/evm/watcher_test_helpers_test.go index 980c3e9e4a1..ee0deb2a0c9 100644 --- a/node/pkg/watchers/evm/watcher_test_helpers_test.go +++ b/node/pkg/watchers/evm/watcher_test_helpers_test.go @@ -205,6 +205,9 @@ func (m *mockConnector) Client() *ethclient.Client { func (m *mockConnector) SubscribeNewHead(ctx context.Context, ch chan<- *types.Header) (ethereum.Subscription, error) { panic("not implemented") } +func (m *mockConnector) Close() { + panic("not implemented") +} // newBlock creates a *connectors.NewBlock with sensible defaults for testing. // Hash is deterministically derived from the block number.