Skip to content
Merged
4 changes: 2 additions & 2 deletions universalClient/chains/chains.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,9 @@ func (c *Chains) addChain(ctx context.Context, cfg *uregistrytypes.ChainConfig)
var client common.ChainClient
switch cfg.VmType {
case uregistrytypes.VmType_EVM:
client, err = evm.NewClient(cfg, chainDB, chainConfig, c.pushSigner, c.logger)
client, err = evm.NewClient(cfg, chainDB, chainConfig, c.pushSigner, c.config.AllowsZeroConfirmations(), c.logger)
case uregistrytypes.VmType_SVM:
client, err = svm.NewClient(cfg, chainDB, chainConfig, c.pushSigner, c.config.NodeHome, c.logger)
client, err = svm.NewClient(cfg, chainDB, chainConfig, c.pushSigner, c.config.NodeHome, c.config.AllowsZeroConfirmations(), c.logger)
default:
return fmt.Errorf("unsupported VM type: %v", cfg.VmType)
}
Expand Down
19 changes: 19 additions & 0 deletions universalClient/chains/common/confirmation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package common

// Safe fallback confirmation depths used when the registry configures 0 and
// instant routes are not enabled.
const (
DefaultFastConfirmations uint64 = 5
DefaultStandardConfirmations uint64 = 12
)

// ConfirmationDepth returns latestHeight - txHeight + 1, the confirmation count
// with the inclusion block counted as one. ok is false when latestHeight <
// txHeight (a cross-RPC height skew); callers must defer rather than trust the
// depth, since the unchecked subtraction would underflow.
func ConfirmationDepth(latestHeight, txHeight uint64) (depth uint64, ok bool) {
if latestHeight < txHeight {
return 0, false
}
return latestHeight - txHeight + 1, true
}
42 changes: 42 additions & 0 deletions universalClient/chains/common/confirmation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package common

import (
"math"
"testing"

"github.com/stretchr/testify/assert"
)

func TestConfirmationDepth(t *testing.T) {
tests := []struct {
name string
latest uint64
tx uint64
wantDepth uint64
wantOK bool
}{
{"latest greater than tx", 110, 100, 11, true},
{"latest equals tx (inclusion block)", 100, 100, 1, true},
{"latest one below tx (skew)", 99, 100, 0, false},
{"latest far below tx (skew)", 1, math.MaxUint64, 0, false},
{"no underflow to near-2^64", 0, 1, 0, false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
depth, ok := ConfirmationDepth(tc.latest, tc.tx)
assert.Equal(t, tc.wantOK, ok)
assert.Equal(t, tc.wantDepth, depth)
})
}
}

// TestConfirmationDepth_SkewNeverSatisfiesThreshold guards the exact finding:
// a transaction one block ahead of the observed tip must not produce a depth
// that clears a realistic confirmation threshold.
func TestConfirmationDepth_SkewNeverSatisfiesThreshold(t *testing.T) {
const threshold = uint64(12)
depth, ok := ConfirmationDepth(500, 501)
assert.False(t, ok, "skewed read must be flagged not-ok")
assert.False(t, depth >= threshold, "skewed depth must not satisfy threshold")
}
38 changes: 26 additions & 12 deletions universalClient/chains/evm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import (
// Client implements the ChainClient interface for EVM chains
type Client struct {
// Core configuration
logger zerolog.Logger
chainIDStr string
registryConfig *uregistrytypes.ChainConfig
chainConfig *config.ChainSpecificConfig
logger zerolog.Logger
chainIDStr string
registryConfig *uregistrytypes.ChainConfig
chainConfig *config.ChainSpecificConfig
allowZeroConfirmations bool

// Infrastructure
rpcClient *RPCClient
Expand All @@ -49,6 +50,7 @@ func NewClient(
database *db.DB,
chainConfig *config.ChainSpecificConfig,
pushSigner *pushsigner.Signer,
allowZeroConfirmations bool,
logger zerolog.Logger,
) (*Client, error) {
if config == nil {
Expand All @@ -68,12 +70,13 @@ func NewClient(
}

client := &Client{
logger: log,
chainIDStr: chainIDStr,
registryConfig: config,
chainConfig: chainConfig,
database: database,
pushSigner: pushSigner,
logger: log,
chainIDStr: chainIDStr,
registryConfig: config,
chainConfig: chainConfig,
allowZeroConfirmations: allowZeroConfirmations,
database: database,
pushSigner: pushSigner,
}

client.eventCleaner = common.NewEventCleaner(
Expand Down Expand Up @@ -356,8 +359,8 @@ func (c *Client) applyDefaults() componentConfig {
config := componentConfig{
eventPollingInterval: 5, // default
gasPriceInterval: 30, // default
fastConfirmations: 2,
standardConfirmations: 12,
fastConfirmations: common.DefaultFastConfirmations,
standardConfirmations: common.DefaultStandardConfirmations,
}

// Apply event polling interval
Expand All @@ -381,6 +384,17 @@ func (c *Client) applyDefaults() componentConfig {
config.standardConfirmations = uint64(c.registryConfig.BlockConfirmation.StandardInbound)
}

// A registry-configured 0 disables the reorg-safety depth. Honor it only
// when instant routes are enabled; otherwise fall back to a safe default.
if !c.allowZeroConfirmations {
if config.fastConfirmations == 0 {
config.fastConfirmations = common.DefaultFastConfirmations
}
if config.standardConfirmations == 0 {
config.standardConfirmations = common.DefaultStandardConfirmations
}
}

return config
}

Expand Down
87 changes: 72 additions & 15 deletions universalClient/chains/evm/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ func TestClientInitialization(t *testing.T) {
}

chainSpecificConfig := testChainConfig([]string{"https://eth-mainnet.example.com"})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, chainConfig, client.GetConfig())
assert.Equal(t, "eip155:1", client.ChainID())
})

t.Run("Nil config", func(t *testing.T) {
client, err := NewClient(nil, nil, nil, nil, logger)
client, err := NewClient(nil, nil, nil, nil, false, logger)
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "config is nil")
Expand All @@ -57,7 +57,7 @@ func TestClientInitialization(t *testing.T) {
}

chainSpecificConfig := testChainConfig([]string{})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "no RPC URLs configured")
Expand All @@ -69,7 +69,7 @@ func TestClientInitialization(t *testing.T) {
VmType: uregistrytypes.VmType_SVM, // Wrong VM type
}

client, err := NewClient(chainConfig, nil, nil, nil, logger)
client, err := NewClient(chainConfig, nil, nil, nil, false, logger)
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "invalid VM type for EVM client")
Expand Down Expand Up @@ -177,7 +177,7 @@ func TestClientStartStop(t *testing.T) {
}

chainSpecificConfig := testChainConfig([]string{server.URL})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

ctx := context.Background()
Expand All @@ -199,7 +199,7 @@ func TestClientStartStop(t *testing.T) {

chainSpecificConfig := testChainConfig([]string{"http://invalid.localhost:99999"})

client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

// Use context with timeout to ensure fast failure
Expand Down Expand Up @@ -238,7 +238,7 @@ func TestClientStartStop(t *testing.T) {

// Use valid URL but cancel context immediately
chainSpecificConfig := testChainConfig([]string{server.URL})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

ctx, cancel := context.WithCancel(context.Background())
Expand Down Expand Up @@ -295,7 +295,7 @@ func TestClientIsHealthy(t *testing.T) {
}

chainSpecificConfig := testChainConfig([]string{server.URL})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

// Start the client
Expand All @@ -320,7 +320,7 @@ func TestClientIsHealthy(t *testing.T) {
// Provide valid RPC URLs for NewClient to succeed
// But don't start the client
chainSpecificConfig := testChainConfig([]string{"https://eth-mainnet.example.com"})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

healthy := client.IsHealthy()
Expand All @@ -342,7 +342,7 @@ func TestApplyDefaults(t *testing.T) {
assert.Equal(t, 5, cfg.eventPollingInterval)
assert.Equal(t, 30, cfg.gasPriceInterval)
assert.Equal(t, 0, cfg.gasPriceMarkupPercent)
assert.Equal(t, uint64(2), cfg.fastConfirmations)
assert.Equal(t, uint64(5), cfg.fastConfirmations)
assert.Equal(t, uint64(12), cfg.standardConfirmations)
})

Expand Down Expand Up @@ -412,7 +412,7 @@ func TestApplyDefaults(t *testing.T) {
}

cfg := client.applyDefaults()
assert.Equal(t, uint64(2), cfg.fastConfirmations)
assert.Equal(t, uint64(5), cfg.fastConfirmations)
assert.Equal(t, uint64(12), cfg.standardConfirmations)
})

Expand All @@ -426,11 +426,68 @@ func TestApplyDefaults(t *testing.T) {
}

cfg := client.applyDefaults()
assert.Equal(t, uint64(2), cfg.fastConfirmations)
assert.Equal(t, uint64(5), cfg.fastConfirmations)
assert.Equal(t, uint64(12), cfg.standardConfirmations)
})
}

// A registry-configured 0 falls back to a safe depth unless instant routes are
// enabled, in which case it is honored.
func TestApplyDefaults_ZeroConfirmations(t *testing.T) {
logger := zerolog.New(zerolog.NewTestWriter(t))

zeroRegistry := &uregistrytypes.ChainConfig{
BlockConfirmation: &uregistrytypes.BlockConfirmation{
FastInbound: 0,
StandardInbound: 0,
},
}

t.Run("mainnet falls back to safe depth", func(t *testing.T) {
client := &Client{
logger: logger,
chainIDStr: "eip155:1",
registryConfig: zeroRegistry,
allowZeroConfirmations: false,
}

cfg := client.applyDefaults()
assert.Equal(t, uint64(5), cfg.fastConfirmations, "zero fast must not disable depth on mainnet")
assert.Equal(t, uint64(12), cfg.standardConfirmations, "zero standard must not disable depth on mainnet")
})

t.Run("testnet honors zero as instant", func(t *testing.T) {
client := &Client{
logger: logger,
chainIDStr: "eip155:1",
registryConfig: zeroRegistry,
allowZeroConfirmations: true,
}

cfg := client.applyDefaults()
assert.Equal(t, uint64(0), cfg.fastConfirmations, "testnet instant route keeps zero")
assert.Equal(t, uint64(0), cfg.standardConfirmations, "testnet instant route keeps zero")
})

t.Run("nonzero registry values unaffected by flag", func(t *testing.T) {
client := &Client{
logger: logger,
chainIDStr: "eip155:1",
registryConfig: &uregistrytypes.ChainConfig{
BlockConfirmation: &uregistrytypes.BlockConfirmation{
FastInbound: 3,
StandardInbound: 9,
},
},
allowZeroConfirmations: false,
}

cfg := client.applyDefaults()
assert.Equal(t, uint64(3), cfg.fastConfirmations)
assert.Equal(t, uint64(9), cfg.standardConfirmations)
})
}

// TestGetTxBuilderNil tests GetTxBuilder when txBuilder is not initialized
func TestGetTxBuilderNil(t *testing.T) {
logger := zerolog.New(zerolog.NewTestWriter(t))
Expand All @@ -441,7 +498,7 @@ func TestGetTxBuilderNil(t *testing.T) {
}

chainSpecificConfig := testChainConfig([]string{"https://eth-mainnet.example.com"})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

// txBuilder is nil because gateway is not configured / Start not called
Expand All @@ -464,7 +521,7 @@ func TestClientGetMethods(t *testing.T) {
}

chainSpecificConfig := testChainConfig([]string{"https://eth-sepolia.example.com"})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

t.Run("ChainID", func(t *testing.T) {
Expand Down Expand Up @@ -496,7 +553,7 @@ func TestClientConcurrency(t *testing.T) {
}

chainSpecificConfig := testChainConfig([]string{server.URL})
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, logger)
client, err := NewClient(chainConfig, nil, chainSpecificConfig, nil, false, logger)
require.NoError(t, err)

ctx := context.Background()
Expand Down
31 changes: 15 additions & 16 deletions universalClient/chains/evm/event_confirmer.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,17 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error {

// Check if transaction is confirmed based on confirmation type
requiredConfirmations := ec.getRequiredConfirmations(event.ConfirmationType)
confirmations := latestBlock - receipt.BlockNumber + 1
txBlock := receipt.BlockNumber
confirmations, ok := chaincommon.ConfirmationDepth(latestBlock, txBlock)
if !ok {
// RPC height skew: latest block is behind the tx block. Defer.
ec.logger.Debug().
Str("event_id", event.EventID).
Uint64("latest_block", latestBlock).
Uint64("tx_block", txBlock).
Msg("latest block behind tx block (RPC height skew); deferring confirmation")
continue
}

if confirmations >= requiredConfirmations {
var rowsAffected int64
Expand Down Expand Up @@ -242,24 +252,13 @@ func (ec *EventConfirmer) getTxHashFromEventID(eventID string) string {
return parts[0]
}

// getRequiredConfirmations returns the required number of confirmations based on confirmation type
// getRequiredConfirmations returns the depth for a confirmation type. Values are
// resolved by applyDefaults, so a 0 here is an intentional instant route.
func (ec *EventConfirmer) getRequiredConfirmations(confirmationType string) uint64 {
switch confirmationType {
case store.ConfirmationFast:
if ec.fastConfirmations >= 0 {
return ec.fastConfirmations
}
return 5
case store.ConfirmationStandard:
if ec.standardConfirmations >= 0 {
return ec.standardConfirmations
}
return 12
return ec.fastConfirmations
default:
// Default to standard if unknown
if ec.standardConfirmations >= 0 {
return ec.standardConfirmations
}
return 12
return ec.standardConfirmations
}
}
Loading
Loading