diff --git a/messages/teleporterv2/message_handler.go b/messages/teleporterv2/message_handler.go index 49e2c62b7..2ff1e872d 100644 --- a/messages/teleporterv2/message_handler.go +++ b/messages/teleporterv2/message_handler.go @@ -5,8 +5,10 @@ package teleporterv2 import ( "context" + "errors" "fmt" "slices" + "strings" "time" "github.com/ava-labs/avalanchego/ids" @@ -38,8 +40,18 @@ const ( // Merkle verification gas (software BLS, multi-proof) is sensitive to the signer set. gasLimitBufferNumerator = 5 gasLimitBufferDenominator = 4 + + // minDeliveryOverheadGas is a conservative lower bound on the gas receiveCrossChainMessage + // consumes (intrinsic gas, attestation verification, receive bookkeeping) before it reaches + // the `gasleft() >= requiredGasLimit` check in _handleInitialMessageExecution. + minDeliveryOverheadGas = 200_000 ) +// errUndeliverable marks a message that cannot be delivered within the destination block gas +// limit. Such a message is skipped rather than retried, since every attempt would revert on-chain +// after the relayer has already paid for verification and bookkeeping. +var errUndeliverable = errors.New("message cannot be delivered within the destination block gas limit") + type factory struct { messageConfig *Config protocolAddress common.Address @@ -165,13 +177,20 @@ func (f *factory) GetMessageRoutingInfo( func (m *messageHandler) ShouldSendMessage() (bool, error) { // RequiredGasLimit is a Solidity uint256 (*big.Int). Calling Uint64() on a value that does not // fit in 64 bits is undefined, so treat any non-uint64 value as exceeding the block gas limit. + // The bound is the block gas limit less the delivery overhead: the on-chain check runs after + // verification and bookkeeping, so anything above that can never be satisfied. destBlockGasLimit := m.destinationClient.BlockGasLimit() + maxRequiredGasLimit := uint64(0) + if destBlockGasLimit > minDeliveryOverheadGas { + maxRequiredGasLimit = destBlockGasLimit - minDeliveryOverheadGas + } if !m.teleporterMessage.RequiredGasLimit.IsUint64() || - m.teleporterMessage.RequiredGasLimit.Uint64() > destBlockGasLimit { + m.teleporterMessage.RequiredGasLimit.Uint64() > maxRequiredGasLimit { m.logger.Info( "Gas limit exceeds maximum threshold", zap.Stringer("requiredGasLimit", m.teleporterMessage.RequiredGasLimit), zap.Uint64("blockGasLimit", destBlockGasLimit), + zap.Uint64("maxRequiredGasLimit", maxRequiredGasLimit), ) return false, nil } @@ -257,6 +276,13 @@ func (m *messageHandler) ProcessMessage() (common.Hash, error) { txHash, err := m.SendMessage(ctx, signedMessage, validators) if err != nil { + // Skip rather than error out, so a message that can never be delivered does not stall the + // source chain's checkpoint. + if errors.Is(err, errUndeliverable) { + m.metrics.IncFailedRelayMessageCount("message undeliverable") + m.logger.Warn("Message cannot be delivered, skipping", zap.Error(err)) + return common.Hash{}, nil + } m.metrics.IncFailedRelayMessageCount("failed to send warp message") return common.Hash{}, fmt.Errorf("failed to send warp message: %w", err) } @@ -303,7 +329,9 @@ func (m *messageHandler) SendMessage( gasLimit, err := m.estimateGasLimit(ctx, callData) if err != nil { - m.logger.Error("Failed to estimate gas limit", zap.Error(err)) + if !errors.Is(err, errUndeliverable) { + m.logger.Error("Failed to estimate gas limit", zap.Error(err)) + } return common.Hash{}, err } @@ -401,33 +429,64 @@ func (m *messageHandler) validatorsAtCommitment( } // estimateGasLimit estimates the gas for the receiveCrossChainMessage call and applies a safety -// buffer. Falls back to the configured block gas limit if estimation fails. +// buffer, bounded by the configured block gas limit. Estimation is the only simulation performed +// before the delivery is signed and broadcast, so it never resolves to a gas limit the estimate has +// not shown to be sufficient. func (m *messageHandler) estimateGasLimit(ctx context.Context, callData []byte) (uint64, error) { from := m.selectSenderAddress() + blockGasLimit := m.destinationClient.BlockGasLimit() + // Bound the node's search at the gas the delivery transaction can actually carry, so a + // successful estimate is one we can honor. estimated, err := m.destinationClient.Client().EstimateGas(ctx, ethereum.CallMsg{ From: from, To: &m.teleporterAddress, + Gas: blockGasLimit, Data: callData, }) if err != nil { - blockGasLimit := m.destinationClient.BlockGasLimit() - m.logger.Warn( - "Gas estimation failed, falling back to block gas limit", - zap.Error(err), - zap.Uint64("blockGasLimit", blockGasLimit), - ) - if blockGasLimit == 0 { - return 0, fmt.Errorf("failed to estimate gas and no block gas limit configured: %w", err) + if isUndeliverableEstimateError(err) { + return 0, fmt.Errorf("%w: %w", errUndeliverable, err) } - return blockGasLimit, nil + return 0, fmt.Errorf("failed to estimate gas: %w", err) + } + if blockGasLimit != 0 && estimated > blockGasLimit { + return 0, fmt.Errorf( + "%w: estimated gas %d exceeds block gas limit %d", + errUndeliverable, estimated, blockGasLimit, + ) } buffered := estimated * gasLimitBufferNumerator / gasLimitBufferDenominator - if blockGasLimit := m.destinationClient.BlockGasLimit(); blockGasLimit != 0 && buffered > blockGasLimit { + // Capping is safe only because the unbuffered estimate already fits under the limit, so the + // capped transaction still carries enough gas to execute. + if blockGasLimit != 0 && buffered > blockGasLimit { buffered = blockGasLimit } return buffered, nil } +// undeliverableEstimateErrors are the eth_estimateGas failures that mean the delivery cannot +// succeed at any gas value within the block gas limit. +var undeliverableEstimateErrors = []string{ + // The node exhausted its search range without the call succeeding. + "gas required exceeds allowance", + "always failing transaction", + // TeleporterMessengerV2's revert reason when gasleft() is below requiredGasLimit. + "insufficient gas", +} + +// isUndeliverableEstimateError distinguishes conclusive gas-envelope failures from errors that may +// clear on a retry. Reverts that are not gas related, such as a verification failure caused by the +// destination registry's committed validator set changing mid-delivery, are treated as retryable. +func isUndeliverableEstimateError(err error) bool { + msg := strings.ToLower(err.Error()) + for _, undeliverable := range undeliverableEstimateErrors { + if strings.Contains(msg, undeliverable) { + return true + } + } + return false +} + // selectSenderAddress picks a relayer EOA eligible to deliver the message for gas estimation. func (m *messageHandler) selectSenderAddress() common.Address { senders := m.destinationClient.SenderAddresses() diff --git a/messages/teleporterv2/message_handler_test.go b/messages/teleporterv2/message_handler_test.go new file mode 100644 index 000000000..a366595e4 --- /dev/null +++ b/messages/teleporterv2/message_handler_test.go @@ -0,0 +1,206 @@ +// Copyright (C) 2026, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package teleporterv2 + +import ( + "context" + "errors" + "math/big" + "testing" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + teleportermessengerv2 "github.com/ava-labs/icm-services/abi-bindings/go/TeleporterMessengerV2" + mock_evm "github.com/ava-labs/icm-services/vms/evm/mocks" + mock_vms "github.com/ava-labs/icm-services/vms/mocks" + ethereum "github.com/ava-labs/libevm" + "github.com/ava-labs/libevm/common" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +const testBlockGasLimit = 12_000_000 + +var ( + testTeleporterAddress = common.HexToAddress("0xd81545385803bCD83bd59f58Ba2d2c0562387F83") + testRelayerAddress = common.HexToAddress("0x0123456789abcdef0123456789abcdef01234567") +) + +func newTestHandler( + t *testing.T, + destinationClient *mock_vms.MockDestinationClient, + requiredGasLimit *big.Int, +) *messageHandler { + t.Helper() + + unsignedMessage, err := warp.NewUnsignedMessage(0, ids.Empty, []byte{1, 2, 3, 4}) + require.NoError(t, err) + + return &messageHandler{ + logger: logging.NoLog{}, + teleporterMessage: &teleportermessengerv2.TeleporterMessageV2{ + MessageNonce: big.NewInt(1), + RequiredGasLimit: requiredGasLimit, + Message: []byte{1, 2, 3, 4}, + }, + unsignedMessage: unsignedMessage, + destinationClient: destinationClient, + teleporterAddress: testTeleporterAddress, + } +} + +// TestShouldSendMessageGasLimitHeadroom checks that the RequiredGasLimit pre-check leaves room for +// the delivery overhead, so that a message at or just below the block gas limit is rejected before +// signature aggregation rather than delivered into a guaranteed revert. +func TestShouldSendMessageGasLimitHeadroom(t *testing.T) { + testCases := []struct { + name string + requiredGasLimit *big.Int + // A message that clears the gas check goes on to query delivery status. + expectMessengerCall bool + expectedResult bool + }{ + { + name: "leaves room for the delivery overhead", + requiredGasLimit: big.NewInt(testBlockGasLimit - minDeliveryOverheadGas), + expectMessengerCall: true, + expectedResult: true, + }, + { + name: "equal to the block gas limit", + requiredGasLimit: big.NewInt(testBlockGasLimit), + expectedResult: false, + }, + { + name: "within the delivery overhead of the block gas limit", + requiredGasLimit: big.NewInt(testBlockGasLimit - minDeliveryOverheadGas + 1), + expectedResult: false, + }, + { + name: "exceeds the block gas limit", + requiredGasLimit: big.NewInt(testBlockGasLimit + 1), + expectedResult: false, + }, + { + name: "does not fit in uint64", + requiredGasLimit: new(big.Int).Lsh(big.NewInt(1), 64), + expectedResult: false, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + destinationClient := mock_vms.NewMockDestinationClient(ctrl) + destinationClient.EXPECT().BlockGasLimit().Return(uint64(testBlockGasLimit)).AnyTimes() + + if test.expectMessengerCall { + notDelivered, err := teleportermessengerv2.PackMessageReceivedOutput(false) + require.NoError(t, err) + + ethClient := mock_evm.NewMockClient(ctrl) + ethClient.EXPECT(). + CallContract(gomock.Any(), gomock.Any(), gomock.Any()). + Return(notDelivered, nil) + destinationClient.EXPECT().Client().Return(ethClient) + destinationClient.EXPECT(). + SenderAddresses(). + Return([]common.Address{testRelayerAddress}) + } + + handler := newTestHandler(t, destinationClient, test.requiredGasLimit) + shouldSend, err := handler.ShouldSendMessage() + require.NoError(t, err) + require.Equal(t, test.expectedResult, shouldSend) + }) + } +} + +// TestEstimateGasLimit checks that estimation never resolves to a gas limit it has not shown to be +// sufficient, since it is the only simulation before the delivery is signed and broadcast. +func TestEstimateGasLimit(t *testing.T) { + testCases := []struct { + name string + estimated uint64 + estimateErr error + expectedGasLimit uint64 + expectedError bool + expectedUndeliverable bool + }{ + { + name: "buffer applied below the block gas limit", + estimated: 1_000_000, + expectedGasLimit: 1_250_000, + }, + { + name: "buffer capped at the block gas limit", + estimated: 11_000_000, + expectedGasLimit: testBlockGasLimit, + }, + { + name: "estimate exceeds the block gas limit", + estimated: testBlockGasLimit + 1, + expectedError: true, + expectedUndeliverable: true, + }, + { + name: "estimation exhausts the search range", + estimateErr: errors.New("gas required exceeds allowance (12000000)"), + expectedError: true, + expectedUndeliverable: true, + }, + { + name: "delivery reverts for insufficient gas", + estimateErr: errors.New("execution reverted: TeleporterMessenger: insufficient gas"), + expectedError: true, + expectedUndeliverable: true, + }, + { + // Retried rather than skipped: verification can fail transiently if the registry's + // committed validator set changes mid-delivery. + name: "delivery reverts for failed verification", + estimateErr: errors.New("execution reverted: TeleporterMessenger: message verification failed"), + expectedError: true, + }, + { + name: "transient rpc failure", + estimateErr: errors.New("connection refused"), + expectedError: true, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + ethClient := mock_evm.NewMockClient(ctrl) + ethClient.EXPECT(). + EstimateGas(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, call ethereum.CallMsg) (uint64, error) { + // The search must be bounded by the gas the delivery can carry. + require.Equal(t, uint64(testBlockGasLimit), call.Gas) + return test.estimated, test.estimateErr + }) + + destinationClient := mock_vms.NewMockDestinationClient(ctrl) + destinationClient.EXPECT().Client().Return(ethClient) + destinationClient.EXPECT().BlockGasLimit().Return(uint64(testBlockGasLimit)).AnyTimes() + destinationClient.EXPECT(). + SenderAddresses(). + Return([]common.Address{testRelayerAddress}). + AnyTimes() + + handler := newTestHandler(t, destinationClient, big.NewInt(1_000_000)) + gasLimit, err := handler.estimateGasLimit(context.Background(), []byte{1, 2, 3, 4}) + if !test.expectedError { + require.NoError(t, err) + require.Equal(t, test.expectedGasLimit, gasLimit) + return + } + require.Error(t, err) + require.Zero(t, gasLimit, "a failed estimation must not resolve to a gas limit") + require.Equal(t, test.expectedUndeliverable, errors.Is(err, errUndeliverable)) + }) + } +}