Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 72 additions & 13 deletions messages/teleporterv2/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package teleporterv2

import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"

"github.com/ava-labs/avalanchego/ids"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()
Expand Down
206 changes: 206 additions & 0 deletions messages/teleporterv2/message_handler_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}