diff --git a/universalClient/chains/chains.go b/universalClient/chains/chains.go index bb0b102b..25413131 100644 --- a/universalClient/chains/chains.go +++ b/universalClient/chains/chains.go @@ -278,9 +278,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) } diff --git a/universalClient/chains/common/confirmation.go b/universalClient/chains/common/confirmation.go new file mode 100644 index 00000000..43815919 --- /dev/null +++ b/universalClient/chains/common/confirmation.go @@ -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 +} diff --git a/universalClient/chains/common/confirmation_test.go b/universalClient/chains/common/confirmation_test.go new file mode 100644 index 00000000..b50afbab --- /dev/null +++ b/universalClient/chains/common/confirmation_test.go @@ -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") +} diff --git a/universalClient/chains/evm/client.go b/universalClient/chains/evm/client.go index 80c7f150..1bce6ac6 100644 --- a/universalClient/chains/evm/client.go +++ b/universalClient/chains/evm/client.go @@ -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 @@ -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 { @@ -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( @@ -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 @@ -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 } diff --git a/universalClient/chains/evm/client_test.go b/universalClient/chains/evm/client_test.go index 1ea67b10..9f05086f 100644 --- a/universalClient/chains/evm/client_test.go +++ b/universalClient/chains/evm/client_test.go @@ -36,7 +36,7 @@ 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()) @@ -44,7 +44,7 @@ func TestClientInitialization(t *testing.T) { }) 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") @@ -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") @@ -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") @@ -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() @@ -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 @@ -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()) @@ -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 @@ -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() @@ -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) }) @@ -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) }) @@ -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)) @@ -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 @@ -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) { @@ -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() diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index c3003904..79a6b93d 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -160,7 +160,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.Uint64() + 1 + txBlock := receipt.BlockNumber.Uint64() + 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 @@ -245,24 +255,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 } } diff --git a/universalClient/chains/evm/event_confirmer_test.go b/universalClient/chains/evm/event_confirmer_test.go index 221729dd..f54f9816 100644 --- a/universalClient/chains/evm/event_confirmer_test.go +++ b/universalClient/chains/evm/event_confirmer_test.go @@ -375,25 +375,27 @@ func TestEventConfirmer_PendingEventsWithBlockHeightZero(t *testing.T) { assert.Equal(t, uint64(0), pending[0].BlockHeight) } +// The confirmer honors whatever depth it is given; the fallback policy lives in +// applyDefaults, so a 0 here is an intentional instant route. func TestEventConfirmer_GetRequiredConfirmations_ZeroValues(t *testing.T) { logger := zerolog.Nop() - t.Run("zero fast confirmations returns 0", func(t *testing.T) { + t.Run("zero fast confirmations honored as instant", func(t *testing.T) { ec := NewEventConfirmer(nil, nil, "eip155:1", 5, 0, 12, logger) result := ec.getRequiredConfirmations(store.ConfirmationFast) assert.Equal(t, uint64(0), result) }) - t.Run("zero standard confirmations returns 0", func(t *testing.T) { + t.Run("zero standard confirmations honored as instant", func(t *testing.T) { ec := NewEventConfirmer(nil, nil, "eip155:1", 5, 5, 0, logger) result := ec.getRequiredConfirmations(store.ConfirmationStandard) assert.Equal(t, uint64(0), result) }) - t.Run("zero standard with unknown type returns 0", func(t *testing.T) { - ec := NewEventConfirmer(nil, nil, "eip155:1", 5, 5, 0, logger) + t.Run("unknown type uses standard depth", func(t *testing.T) { + ec := NewEventConfirmer(nil, nil, "eip155:1", 5, 5, 7, logger) result := ec.getRequiredConfirmations("INSTANT") - assert.Equal(t, uint64(0), result) + assert.Equal(t, uint64(7), result) }) } diff --git a/universalClient/chains/svm/client.go b/universalClient/chains/svm/client.go index 9e96f95f..e63af21d 100644 --- a/universalClient/chains/svm/client.go +++ b/universalClient/chains/svm/client.go @@ -18,11 +18,12 @@ import ( // Client implements the ChainClient interface for Solana chains type Client struct { // Core configuration - logger zerolog.Logger - chainIDStr string - genesisHash string - registryConfig *uregistrytypes.ChainConfig - chainConfig *config.ChainSpecificConfig + logger zerolog.Logger + chainIDStr string + genesisHash string + registryConfig *uregistrytypes.ChainConfig + chainConfig *config.ChainSpecificConfig + allowZeroConfirmations bool // Infrastructure rpcClient *RPCClient @@ -51,6 +52,7 @@ func NewClient( chainConfig *config.ChainSpecificConfig, pushSigner *pushsigner.Signer, nodeHome string, + allowZeroConfirmations bool, logger zerolog.Logger, ) (*Client, error) { if config == nil { @@ -76,14 +78,15 @@ func NewClient( } client := &Client{ - logger: log, - chainIDStr: chainIDStr, - genesisHash: genesisHash, - registryConfig: config, - chainConfig: chainConfig, - database: database, - pushSigner: pushSigner, - nodeHome: nodeHome, + logger: log, + chainIDStr: chainIDStr, + genesisHash: genesisHash, + registryConfig: config, + chainConfig: chainConfig, + allowZeroConfirmations: allowZeroConfirmations, + database: database, + pushSigner: pushSigner, + nodeHome: nodeHome, } client.eventCleaner = common.NewEventCleaner( @@ -365,8 +368,8 @@ func (c *Client) applyDefaults() componentConfig { config := componentConfig{ eventPollingInterval: 5, // default gasPriceInterval: 30, // default - fastConfirmations: 5, // Solana fast confirmations - standardConfirmations: 12, // Solana standard confirmations + fastConfirmations: common.DefaultFastConfirmations, + standardConfirmations: common.DefaultStandardConfirmations, rentReclaimSweepInterval: rentReclaimSweepInterval, rentReclaimMinPDAAge: rentReclaimMinPDAAge, } @@ -408,6 +411,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 } diff --git a/universalClient/chains/svm/client_test.go b/universalClient/chains/svm/client_test.go index 50f1084f..36a5a91b 100644 --- a/universalClient/chains/svm/client_test.go +++ b/universalClient/chains/svm/client_test.go @@ -35,7 +35,7 @@ func validChainConfig() *uregistrytypes.ChainConfig { func TestNewClient_NilConfig(t *testing.T) { logger := zerolog.New(zerolog.NewTestWriter(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") @@ -49,7 +49,7 @@ func TestNewClient_InvalidVMType(t *testing.T) { VmType: uregistrytypes.VmType_EVM, // wrong VM type } - client, err := NewClient(cfg, nil, nil, nil, "", logger) + client, err := NewClient(cfg, nil, nil, nil, "", false, logger) assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "invalid VM type for Solana client") @@ -63,7 +63,7 @@ func TestNewClient_InvalidChainID(t *testing.T) { VmType: uregistrytypes.VmType_SVM, } - client, err := NewClient(cfg, nil, testChainConfig([]string{"https://rpc.example.com"}), nil, "", logger) + client, err := NewClient(cfg, nil, testChainConfig([]string{"https://rpc.example.com"}), nil, "", false, logger) assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "failed to parse chain ID") @@ -74,7 +74,7 @@ func TestNewClient_NoRPCURLs_NilChainConfig(t *testing.T) { cfg := validChainConfig() - client, err := NewClient(cfg, nil, nil, nil, "", logger) + client, err := NewClient(cfg, nil, nil, nil, "", false, logger) assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "no RPC URLs configured") @@ -85,7 +85,7 @@ func TestNewClient_NoRPCURLs_EmptySlice(t *testing.T) { cfg := validChainConfig() - client, err := NewClient(cfg, nil, testChainConfig([]string{}), nil, "", logger) + client, err := NewClient(cfg, nil, testChainConfig([]string{}), nil, "", false, logger) assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "no RPC URLs configured") @@ -103,7 +103,7 @@ func TestNewClient_ValidCreation(t *testing.T) { chainSpecific := testChainConfig([]string{"https://api.mainnet-beta.solana.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "/tmp/node", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "/tmp/node", false, logger) require.NoError(t, err) require.NotNil(t, client) @@ -122,7 +122,7 @@ func TestNewClient_WithDatabase(t *testing.T) { cfg := validChainConfig() chainSpecific := testChainConfig([]string{"https://api.mainnet-beta.solana.com"}) - client, err := NewClient(cfg, database, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, database, chainSpecific, nil, "", false, logger) require.NoError(t, err) require.NotNil(t, client) assert.Equal(t, database, client.database) @@ -134,7 +134,7 @@ func TestChainID(t *testing.T) { cfg := validChainConfig() chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) assert.Equal(t, validSVMChainID(), client.ChainID()) @@ -150,7 +150,7 @@ func TestGetConfig(t *testing.T) { } chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) got := client.GetConfig() @@ -164,7 +164,7 @@ func TestGetTxBuilder_NilBeforeStart(t *testing.T) { cfg := validChainConfig() chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) txb, err := client.GetTxBuilder() @@ -179,7 +179,7 @@ func TestIsHealthy_NotStarted(t *testing.T) { cfg := validChainConfig() chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) // rpcClient is nil before Start @@ -192,7 +192,7 @@ func TestStop_BeforeStart(t *testing.T) { cfg := validChainConfig() chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) // Calling Stop before Start should not panic @@ -206,7 +206,7 @@ func TestStop_CalledTwice(t *testing.T) { cfg := validChainConfig() chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) // Double stop should be safe @@ -220,7 +220,7 @@ func TestApplyDefaults_AllDefaults(t *testing.T) { cfg := validChainConfig() chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) defaults := client.applyDefaults() @@ -242,7 +242,7 @@ func TestApplyDefaults_EventPollingOverride(t *testing.T) { } cfg := validChainConfig() - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) defaults := client.applyDefaults() @@ -261,7 +261,7 @@ func TestApplyDefaults_GasPriceOverride(t *testing.T) { } cfg := validChainConfig() - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) defaults := client.applyDefaults() @@ -282,7 +282,7 @@ func TestApplyDefaults_BlockConfirmationOverride(t *testing.T) { } chainSpecific := testChainConfig([]string{"https://rpc.example.com"}) - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) defaults := client.applyDefaults() @@ -305,7 +305,7 @@ func TestApplyDefaults_ZeroValueNotApplied(t *testing.T) { } cfg := validChainConfig() - client, err := NewClient(cfg, nil, chainSpecific, nil, "", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "", false, logger) require.NoError(t, err) defaults := client.applyDefaults() @@ -315,6 +315,45 @@ func TestApplyDefaults_ZeroValueNotApplied(t *testing.T) { assert.Equal(t, 0, defaults.gasPriceMarkupPercent) // 0 is the default too } +// 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: "solana:mainnet", + registryConfig: zeroRegistry, + allowZeroConfirmations: false, + } + + defaults := client.applyDefaults() + assert.Equal(t, uint64(5), defaults.fastConfirmations, "zero fast must not disable depth on mainnet") + assert.Equal(t, uint64(12), defaults.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: "solana:mainnet", + registryConfig: zeroRegistry, + allowZeroConfirmations: true, + } + + defaults := client.applyDefaults() + assert.Equal(t, uint64(0), defaults.fastConfirmations, "testnet instant route keeps zero") + assert.Equal(t, uint64(0), defaults.standardConfirmations, "testnet instant route keeps zero") + }) +} + func TestParseSolanaChainID(t *testing.T) { tests := []struct { name string @@ -404,7 +443,7 @@ func TestNewClient_FullConfigGetters(t *testing.T) { GasPriceMarkupPercent: &gasMarkup, } - client, err := NewClient(cfg, nil, chainSpecific, nil, "/tmp/home", logger) + client, err := NewClient(cfg, nil, chainSpecific, nil, "/tmp/home", false, logger) require.NoError(t, err) // Verify all getters diff --git a/universalClient/chains/svm/event_confirmer.go b/universalClient/chains/svm/event_confirmer.go index c9895ff8..acb3f29b 100644 --- a/universalClient/chains/svm/event_confirmer.go +++ b/universalClient/chains/svm/event_confirmer.go @@ -180,7 +180,16 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { // Check if transaction is confirmed based on confirmation type requiredConfirmations := ec.getRequiredConfirmations(event.ConfirmationType) - confirmations := latestSlot - txSlot + 1 + confirmations, ok := chaincommon.ConfirmationDepth(latestSlot, txSlot) + if !ok { + // RPC height skew: latest slot is behind the tx slot. Defer. + ec.logger.Debug(). + Str("event_id", event.EventID). + Uint64("latest_slot", latestSlot). + Uint64("tx_slot", txSlot). + Msg("latest slot behind tx slot (RPC height skew); deferring confirmation") + continue + } if confirmations >= requiredConfirmations { // GasFeeUsed for outbound events is already set by the event parser from the on-chain event data @@ -225,24 +234,13 @@ func (ec *EventConfirmer) getTxSignatureFromEventID(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 } } diff --git a/universalClient/chains/svm/event_confirmer_test.go b/universalClient/chains/svm/event_confirmer_test.go index 10f9f797..290b8281 100644 --- a/universalClient/chains/svm/event_confirmer_test.go +++ b/universalClient/chains/svm/event_confirmer_test.go @@ -129,10 +129,11 @@ func TestEventConfirmerGetRequiredConfirmations(t *testing.T) { assert.Equal(t, uint64(5), confirmations) }) - t.Run("FAST confirmation type with zero uses default", func(t *testing.T) { + t.Run("FAST confirmation type with zero honored as instant", func(t *testing.T) { + // Fallback policy lives in applyDefaults; the confirmer honors a resolved 0. confirmer := NewEventConfirmer(nil, nil, "solana:mainnet", 5, 0, 12, logger) confirmations := confirmer.getRequiredConfirmations(store.ConfirmationFast) - assert.Equal(t, uint64(5), confirmations) // Default is 5 + assert.Equal(t, uint64(0), confirmations) }) t.Run("STANDARD confirmation type with custom value", func(t *testing.T) { @@ -141,10 +142,10 @@ func TestEventConfirmerGetRequiredConfirmations(t *testing.T) { assert.Equal(t, uint64(20), confirmations) }) - t.Run("STANDARD confirmation type with zero uses default", func(t *testing.T) { + t.Run("STANDARD confirmation type with zero honored as instant", func(t *testing.T) { confirmer := NewEventConfirmer(nil, nil, "solana:mainnet", 5, 5, 0, logger) confirmations := confirmer.getRequiredConfirmations(store.ConfirmationStandard) - assert.Equal(t, uint64(12), confirmations) // Default is 12 + assert.Equal(t, uint64(0), confirmations) }) t.Run("unknown type defaults to standard configured", func(t *testing.T) { @@ -153,10 +154,10 @@ func TestEventConfirmerGetRequiredConfirmations(t *testing.T) { assert.Equal(t, uint64(25), confirmations) }) - t.Run("unknown type with zero falls back to default 12", func(t *testing.T) { + t.Run("unknown type with zero standard honored as instant", func(t *testing.T) { confirmer := NewEventConfirmer(nil, nil, "solana:mainnet", 5, 0, 0, logger) confirmations := confirmer.getRequiredConfirmations("UNKNOWN") - assert.Equal(t, uint64(12), confirmations) + assert.Equal(t, uint64(0), confirmations) }) t.Run("empty type defaults to standard", func(t *testing.T) { @@ -326,16 +327,16 @@ func TestEventConfirmerGetRequiredConfirmations_MoreEdgeCases(t *testing.T) { assert.Equal(t, uint64(10), unknown) }) - t.Run("zero fast falls back to default 5", func(t *testing.T) { + t.Run("zero fast honored as instant", func(t *testing.T) { ec := NewEventConfirmer(nil, nil, "solana:mainnet", 5, 0, 20, logger) result := ec.getRequiredConfirmations(store.ConfirmationFast) - assert.Equal(t, uint64(5), result) // default 5 + assert.Equal(t, uint64(0), result) }) - t.Run("zero standard falls back to default 12", func(t *testing.T) { + t.Run("zero standard honored as instant", func(t *testing.T) { ec := NewEventConfirmer(nil, nil, "solana:mainnet", 5, 10, 0, logger) result := ec.getRequiredConfirmations(store.ConfirmationStandard) - assert.Equal(t, uint64(12), result) // default 12 + assert.Equal(t, uint64(0), result) }) } diff --git a/universalClient/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index fb788b7a..ab257fad 100644 --- a/universalClient/chains/svm/rpc_client.go +++ b/universalClient/chains/svm/rpc_client.go @@ -321,6 +321,7 @@ func (rc *RPCClient) GetTransaction(ctx context.Context, signature solana.Signat signature, &rpc.GetTransactionOpts{ Encoding: solana.EncodingBase64, + Commitment: rpc.CommitmentFinalized, MaxSupportedTransactionVersion: &maxVersion, }, ) diff --git a/universalClient/config/config_test.go b/universalClient/config/config_test.go index 1efc379d..b656c519 100644 --- a/universalClient/config/config_test.go +++ b/universalClient/config/config_test.go @@ -321,3 +321,25 @@ func TestGetChainCleanupSettings(t *testing.T) { assert.Contains(t, err.Error(), "cleanup_interval_seconds") }) } + +func TestNetworkGating(t *testing.T) { + cases := []struct { + network string + wantTestnet bool + }{ + {"", false}, + {"mainnet", false}, + {"MAINNET", false}, + {"prod", false}, + {"testnet", true}, + {"TESTNET", true}, + {" testnet ", true}, + } + for _, tc := range cases { + t.Run("network="+tc.network, func(t *testing.T) { + c := &Config{PushNetwork: tc.network} + assert.Equal(t, tc.wantTestnet, c.IsTestnet()) + assert.Equal(t, tc.wantTestnet, c.AllowsZeroConfirmations()) + }) + } +} diff --git a/universalClient/config/default_config.json b/universalClient/config/default_config.json index 4355eace..86867d20 100644 --- a/universalClient/config/default_config.json +++ b/universalClient/config/default_config.json @@ -2,6 +2,7 @@ "log_level": 1, "log_format": "console", "log_sampler": false, + "push_network": "mainnet", "push_chain_id": "localchain_9000-1", "push_chain_grpc_urls": [ "localhost:9090" diff --git a/universalClient/config/types.go b/universalClient/config/types.go index 8a43a709..f35484da 100644 --- a/universalClient/config/types.go +++ b/universalClient/config/types.go @@ -1,6 +1,9 @@ package config -import "fmt" +import ( + "fmt" + "strings" +) // KeyringBackend represents the type of keyring backend to use. type KeyringBackend string @@ -10,6 +13,20 @@ const ( KeyringBackendFile KeyringBackend = "file" ) +const NetworkTestnet = "testnet" + +// IsTestnet reports whether this node is on testnet. Any other value, including +// unset, is treated as mainnet. +func (c *Config) IsTestnet() bool { + return strings.EqualFold(strings.TrimSpace(c.PushNetwork), NetworkTestnet) +} + +// AllowsZeroConfirmations reports whether a registry confirmation depth of 0 is +// honored (instant routes) instead of falling back to a safe depth. +func (c *Config) AllowsZeroConfirmations() bool { + return c.IsTestnet() +} + // Config holds all configuration for the Universal Validator. type Config struct { // Logging @@ -27,6 +44,9 @@ type Config struct { ConfigRefreshIntervalSeconds int `json:"config_refresh_interval_seconds"` MaxRetries int `json:"max_retries"` + // PushNetwork is "mainnet" or "testnet"; unset/unknown is treated as mainnet. + PushNetwork string `json:"push_network"` + // Query Server QueryServerPort int `json:"query_server_port"`