From 22df5d37a2c3d28d6bcab5e97a0c9f6a95f33bbc Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 12 Aug 2026 15:58:17 +0530 Subject: [PATCH 1/4] fix: guard confirmation depth against RPC height skew; gate zero-confirmation instant routes to testnet (F-2026-18139) --- universalClient/chains/chains.go | 4 +- universalClient/chains/common/confirmation.go | 20 +++++ .../chains/common/confirmation_test.go | 42 ++++++++++ universalClient/chains/evm/client.go | 36 +++++--- universalClient/chains/evm/client_test.go | 83 ++++++++++++++++--- universalClient/chains/evm/event_confirmer.go | 37 +++++---- .../chains/evm/event_confirmer_test.go | 14 ++-- universalClient/chains/svm/client.go | 42 +++++++--- universalClient/chains/svm/client_test.go | 79 +++++++++++++----- universalClient/chains/svm/event_confirmer.go | 36 ++++---- .../chains/svm/event_confirmer_test.go | 24 +++--- universalClient/chains/svm/rpc_client.go | 1 + universalClient/config/config_test.go | 25 ++++++ universalClient/config/default_config.json | 1 + universalClient/config/types.go | 29 ++++++- 15 files changed, 369 insertions(+), 104 deletions(-) create mode 100644 universalClient/chains/common/confirmation.go create mode 100644 universalClient/chains/common/confirmation_test.go diff --git a/universalClient/chains/chains.go b/universalClient/chains/chains.go index bb0b102bc..254131316 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 000000000..0ba4e8437 --- /dev/null +++ b/universalClient/chains/common/confirmation.go @@ -0,0 +1,20 @@ +package common + +// ConfirmationDepth returns the number of confirmations for a transaction +// observed at txHeight against a chain tip at latestHeight, defined as +// latestHeight - txHeight + 1 (the inclusion block counts as one confirmation). +// +// ok is false when latestHeight < txHeight. That ordering is not physically +// possible on a single consistent view of a chain, but the latest-height and +// transaction reads are independent RPC calls that the pool round-robins across +// endpoints. When the endpoint serving the transaction is ahead of the one +// serving the tip, an unchecked latestHeight - txHeight underflows uint64 to a +// value near 2^64 and satisfies any confirmation threshold, prematurely +// finalizing an inbound. Callers must treat ok == false as "defer, still +// pending" rather than trusting the returned depth. +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 000000000..b50afbabc --- /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 80c7f1500..bf3a9aab6 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( @@ -381,6 +384,19 @@ func (c *Client) applyDefaults() componentConfig { config.standardConfirmations = uint64(c.registryConfig.BlockConfirmation.StandardInbound) } + // A registry-configured 0 disables the reorg-safety depth (confirm at the + // inclusion block). Honor it only when zero-confirmation mode is explicitly + // enabled (testnet instant routes); otherwise fall back to a safe default so + // mainnet cannot silently finalize inbounds prematurely. See F-2026-18139. + if !c.allowZeroConfirmations { + if config.fastConfirmations == 0 { + config.fastConfirmations = 2 + } + if config.standardConfirmations == 0 { + config.standardConfirmations = 12 + } + } + return config } diff --git a/universalClient/chains/evm/client_test.go b/universalClient/chains/evm/client_test.go index 1ea67b10c..31ef5cabe 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() @@ -431,6 +431,65 @@ func TestApplyDefaults(t *testing.T) { }) } +// TestApplyDefaults_ZeroConfirmations covers the zero-confirmation policy from +// F-2026-18139: a registry-configured 0 must fall back to a safe depth on +// mainnet (allowZeroConfirmations=false) and be honored as an instant route +// only on testnet (allowZeroConfirmations=true). +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(2), 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 +500,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 +523,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 +555,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 c30039040..df72289f7 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -160,7 +160,19 @@ 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 { + // Cross-RPC height skew: the endpoint that served the receipt is + // ahead of the one that served the latest block. Defer rather than + // trust a wrapped depth; a later poll with a consistent view resolves it. + ec.logger.Warn(). + 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 +257,17 @@ func (ec *EventConfirmer) getTxHashFromEventID(eventID string) string { return parts[0] } -// getRequiredConfirmations returns the required number of confirmations based on confirmation type +// getRequiredConfirmations returns the required number of confirmations based on +// confirmation type. The values are already resolved by the client's +// applyDefaults (registry value, or a safe fallback when the registry is 0 and +// zero-confirmation mode is not enabled), so a 0 here is an intentional instant +// route and is honored as-is. 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 + // Standard and unknown types both use the standard depth. + return ec.standardConfirmations } } diff --git a/universalClient/chains/evm/event_confirmer_test.go b/universalClient/chains/evm/event_confirmer_test.go index 221729dd8..d1f331067 100644 --- a/universalClient/chains/evm/event_confirmer_test.go +++ b/universalClient/chains/evm/event_confirmer_test.go @@ -375,25 +375,29 @@ func TestEventConfirmer_PendingEventsWithBlockHeightZero(t *testing.T) { assert.Equal(t, uint64(0), pending[0].BlockHeight) } +// The confirmer honors whatever depth it is given: the safe-fallback vs +// zero-confirmation policy is resolved upstream in the client's applyDefaults +// (see TestApplyDefaults_ZeroConfirmations). A 0 here is an intentional instant +// route. Regression for F-2026-18139. 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 9e96f95fa..e198cdd4e 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( @@ -408,6 +411,19 @@ func (c *Client) applyDefaults() componentConfig { config.standardConfirmations = uint64(c.registryConfig.BlockConfirmation.StandardInbound) } + // A registry-configured 0 disables the reorg-safety depth (confirm at the + // inclusion slot). Honor it only when zero-confirmation mode is explicitly + // enabled (testnet instant routes); otherwise fall back to a safe default so + // mainnet cannot silently finalize inbounds prematurely. See F-2026-18139. + if !c.allowZeroConfirmations { + if config.fastConfirmations == 0 { + config.fastConfirmations = 5 + } + if config.standardConfirmations == 0 { + config.standardConfirmations = 12 + } + } + return config } diff --git a/universalClient/chains/svm/client_test.go b/universalClient/chains/svm/client_test.go index 50f1084f7..3a2f03a4a 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,47 @@ func TestApplyDefaults_ZeroValueNotApplied(t *testing.T) { assert.Equal(t, 0, defaults.gasPriceMarkupPercent) // 0 is the default too } +// TestApplyDefaults_ZeroConfirmations covers the zero-confirmation policy from +// F-2026-18139: a registry-configured 0 must fall back to a safe depth on +// mainnet (allowZeroConfirmations=false) and be honored as an instant route +// only on testnet (allowZeroConfirmations=true). +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 +445,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 c9895ff8c..131e774f1 100644 --- a/universalClient/chains/svm/event_confirmer.go +++ b/universalClient/chains/svm/event_confirmer.go @@ -180,7 +180,18 @@ 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 { + // Cross-RPC height skew: the endpoint that served the transaction is + // ahead of the one that served the latest slot. Defer rather than + // trust a wrapped depth; a later poll with a consistent view resolves it. + ec.logger.Warn(). + 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 +236,17 @@ func (ec *EventConfirmer) getTxSignatureFromEventID(eventID string) string { return parts[0] } -// getRequiredConfirmations returns the required number of confirmations based on confirmation type +// getRequiredConfirmations returns the required number of confirmations based on +// confirmation type. The values are already resolved by the client's +// applyDefaults (registry value, or a safe fallback when the registry is 0 and +// zero-confirmation mode is not enabled), so a 0 here is an intentional instant +// route and is honored as-is. 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 + // Standard and unknown types both use the standard depth. + return ec.standardConfirmations } } diff --git a/universalClient/chains/svm/event_confirmer_test.go b/universalClient/chains/svm/event_confirmer_test.go index 10f9f7979..5048db818 100644 --- a/universalClient/chains/svm/event_confirmer_test.go +++ b/universalClient/chains/svm/event_confirmer_test.go @@ -129,10 +129,12 @@ 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 the client's applyDefaults; the confirmer + // honors a resolved 0 as an instant route. See F-2026-18139. 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 +143,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 +155,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 +328,18 @@ 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) { + // Fallback policy lives in applyDefaults; the confirmer honors a + // resolved 0 as an instant route. See F-2026-18139. 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 fb788b7a8..ab257fadd 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 1efc379da..8a46e5b37 100644 --- a/universalClient/config/config_test.go +++ b/universalClient/config/config_test.go @@ -321,3 +321,28 @@ func TestGetChainCleanupSettings(t *testing.T) { assert.Contains(t, err.Error(), "cleanup_interval_seconds") }) } + +// Regression for F-2026-18139: network gating must fail safe. Any value other +// than "testnet" (including unset) is mainnet, and only testnet unlocks +// zero-confirmation instant routes. +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{Network: 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 4355eace5..64d54e2ff 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, + "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 8a43a7091..7f8479848 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,24 @@ const ( KeyringBackendFile KeyringBackend = "file" ) +// NetworkTestnet is the Network value that unlocks testnet-only relaxed behavior. +const NetworkTestnet = "testnet" + +// IsTestnet reports whether this node is configured for testnet. Any value other +// than "testnet" (including unset) is treated as mainnet so relaxed behaviors +// fail safe. See [Config.AllowsZeroConfirmations]. +func (c *Config) IsTestnet() bool { + return strings.EqualFold(strings.TrimSpace(c.Network), NetworkTestnet) +} + +// AllowsZeroConfirmations reports whether zero-confirmation ("instant") inbound +// routes are permitted. Only testnet may honor a registry-configured +// confirmation depth of 0; on mainnet a 0 falls back to a safe depth so inbounds +// cannot finalize at their inclusion block. See F-2026-18139. +func (c *Config) AllowsZeroConfirmations() bool { + return c.IsTestnet() +} + // Config holds all configuration for the Universal Validator. type Config struct { // Logging @@ -27,6 +48,12 @@ type Config struct { ConfigRefreshIntervalSeconds int `json:"config_refresh_interval_seconds"` MaxRetries int `json:"max_retries"` + // Network identifies the deployment network: "mainnet" or "testnet". + // Unset/unknown is treated as mainnet, the safe default. Testnet unlocks + // relaxed behaviors that must never apply to mainnet — currently + // zero-confirmation ("instant") inbound routes. See F-2026-18139. + Network string `json:"network"` + // Query Server QueryServerPort int `json:"query_server_port"` From 64aefe3df5f0dbd2bd54c714c42f773494894f5b Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 12 Aug 2026 16:20:43 +0530 Subject: [PATCH 2/4] chore: rename Network to PushNetwork, trim comments (F-2026-18139) --- universalClient/chains/common/confirmation.go | 16 ++++---------- universalClient/chains/evm/client.go | 6 ++---- universalClient/chains/evm/client_test.go | 6 ++---- universalClient/chains/evm/event_confirmer.go | 12 +++-------- .../chains/evm/event_confirmer_test.go | 6 ++---- universalClient/chains/svm/client.go | 6 ++---- universalClient/chains/svm/client_test.go | 6 ++---- universalClient/chains/svm/event_confirmer.go | 12 +++-------- .../chains/svm/event_confirmer_test.go | 5 +---- universalClient/config/config_test.go | 9 +++----- universalClient/config/default_config.json | 2 +- universalClient/config/types.go | 21 +++++++------------ 12 files changed, 32 insertions(+), 75 deletions(-) diff --git a/universalClient/chains/common/confirmation.go b/universalClient/chains/common/confirmation.go index 0ba4e8437..6b2097d0f 100644 --- a/universalClient/chains/common/confirmation.go +++ b/universalClient/chains/common/confirmation.go @@ -1,17 +1,9 @@ package common -// ConfirmationDepth returns the number of confirmations for a transaction -// observed at txHeight against a chain tip at latestHeight, defined as -// latestHeight - txHeight + 1 (the inclusion block counts as one confirmation). -// -// ok is false when latestHeight < txHeight. That ordering is not physically -// possible on a single consistent view of a chain, but the latest-height and -// transaction reads are independent RPC calls that the pool round-robins across -// endpoints. When the endpoint serving the transaction is ahead of the one -// serving the tip, an unchecked latestHeight - txHeight underflows uint64 to a -// value near 2^64 and satisfies any confirmation threshold, prematurely -// finalizing an inbound. Callers must treat ok == false as "defer, still -// pending" rather than trusting the returned depth. +// 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 diff --git a/universalClient/chains/evm/client.go b/universalClient/chains/evm/client.go index bf3a9aab6..ed436f648 100644 --- a/universalClient/chains/evm/client.go +++ b/universalClient/chains/evm/client.go @@ -384,10 +384,8 @@ func (c *Client) applyDefaults() componentConfig { config.standardConfirmations = uint64(c.registryConfig.BlockConfirmation.StandardInbound) } - // A registry-configured 0 disables the reorg-safety depth (confirm at the - // inclusion block). Honor it only when zero-confirmation mode is explicitly - // enabled (testnet instant routes); otherwise fall back to a safe default so - // mainnet cannot silently finalize inbounds prematurely. See F-2026-18139. + // 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 = 2 diff --git a/universalClient/chains/evm/client_test.go b/universalClient/chains/evm/client_test.go index 31ef5cabe..03195722d 100644 --- a/universalClient/chains/evm/client_test.go +++ b/universalClient/chains/evm/client_test.go @@ -431,10 +431,8 @@ func TestApplyDefaults(t *testing.T) { }) } -// TestApplyDefaults_ZeroConfirmations covers the zero-confirmation policy from -// F-2026-18139: a registry-configured 0 must fall back to a safe depth on -// mainnet (allowZeroConfirmations=false) and be honored as an instant route -// only on testnet (allowZeroConfirmations=true). +// 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)) diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index df72289f7..89a6f35e5 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -163,9 +163,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { txBlock := receipt.BlockNumber.Uint64() confirmations, ok := chaincommon.ConfirmationDepth(latestBlock, txBlock) if !ok { - // Cross-RPC height skew: the endpoint that served the receipt is - // ahead of the one that served the latest block. Defer rather than - // trust a wrapped depth; a later poll with a consistent view resolves it. + // RPC height skew: latest block is behind the tx block. Defer. ec.logger.Warn(). Str("event_id", event.EventID). Uint64("latest_block", latestBlock). @@ -257,17 +255,13 @@ func (ec *EventConfirmer) getTxHashFromEventID(eventID string) string { return parts[0] } -// getRequiredConfirmations returns the required number of confirmations based on -// confirmation type. The values are already resolved by the client's -// applyDefaults (registry value, or a safe fallback when the registry is 0 and -// zero-confirmation mode is not enabled), so a 0 here is an intentional instant -// route and is honored as-is. +// 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: return ec.fastConfirmations default: - // Standard and unknown types both use the standard depth. return ec.standardConfirmations } } diff --git a/universalClient/chains/evm/event_confirmer_test.go b/universalClient/chains/evm/event_confirmer_test.go index d1f331067..f54f98163 100644 --- a/universalClient/chains/evm/event_confirmer_test.go +++ b/universalClient/chains/evm/event_confirmer_test.go @@ -375,10 +375,8 @@ func TestEventConfirmer_PendingEventsWithBlockHeightZero(t *testing.T) { assert.Equal(t, uint64(0), pending[0].BlockHeight) } -// The confirmer honors whatever depth it is given: the safe-fallback vs -// zero-confirmation policy is resolved upstream in the client's applyDefaults -// (see TestApplyDefaults_ZeroConfirmations). A 0 here is an intentional instant -// route. Regression for F-2026-18139. +// 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() diff --git a/universalClient/chains/svm/client.go b/universalClient/chains/svm/client.go index e198cdd4e..dd811c700 100644 --- a/universalClient/chains/svm/client.go +++ b/universalClient/chains/svm/client.go @@ -411,10 +411,8 @@ func (c *Client) applyDefaults() componentConfig { config.standardConfirmations = uint64(c.registryConfig.BlockConfirmation.StandardInbound) } - // A registry-configured 0 disables the reorg-safety depth (confirm at the - // inclusion slot). Honor it only when zero-confirmation mode is explicitly - // enabled (testnet instant routes); otherwise fall back to a safe default so - // mainnet cannot silently finalize inbounds prematurely. See F-2026-18139. + // 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 = 5 diff --git a/universalClient/chains/svm/client_test.go b/universalClient/chains/svm/client_test.go index 3a2f03a4a..36a5a91bf 100644 --- a/universalClient/chains/svm/client_test.go +++ b/universalClient/chains/svm/client_test.go @@ -315,10 +315,8 @@ func TestApplyDefaults_ZeroValueNotApplied(t *testing.T) { assert.Equal(t, 0, defaults.gasPriceMarkupPercent) // 0 is the default too } -// TestApplyDefaults_ZeroConfirmations covers the zero-confirmation policy from -// F-2026-18139: a registry-configured 0 must fall back to a safe depth on -// mainnet (allowZeroConfirmations=false) and be honored as an instant route -// only on testnet (allowZeroConfirmations=true). +// 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)) diff --git a/universalClient/chains/svm/event_confirmer.go b/universalClient/chains/svm/event_confirmer.go index 131e774f1..b78650da1 100644 --- a/universalClient/chains/svm/event_confirmer.go +++ b/universalClient/chains/svm/event_confirmer.go @@ -182,9 +182,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { requiredConfirmations := ec.getRequiredConfirmations(event.ConfirmationType) confirmations, ok := chaincommon.ConfirmationDepth(latestSlot, txSlot) if !ok { - // Cross-RPC height skew: the endpoint that served the transaction is - // ahead of the one that served the latest slot. Defer rather than - // trust a wrapped depth; a later poll with a consistent view resolves it. + // RPC height skew: latest slot is behind the tx slot. Defer. ec.logger.Warn(). Str("event_id", event.EventID). Uint64("latest_slot", latestSlot). @@ -236,17 +234,13 @@ func (ec *EventConfirmer) getTxSignatureFromEventID(eventID string) string { return parts[0] } -// getRequiredConfirmations returns the required number of confirmations based on -// confirmation type. The values are already resolved by the client's -// applyDefaults (registry value, or a safe fallback when the registry is 0 and -// zero-confirmation mode is not enabled), so a 0 here is an intentional instant -// route and is honored as-is. +// 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: return ec.fastConfirmations default: - // Standard and unknown types both use the standard depth. return ec.standardConfirmations } } diff --git a/universalClient/chains/svm/event_confirmer_test.go b/universalClient/chains/svm/event_confirmer_test.go index 5048db818..290b82811 100644 --- a/universalClient/chains/svm/event_confirmer_test.go +++ b/universalClient/chains/svm/event_confirmer_test.go @@ -130,8 +130,7 @@ func TestEventConfirmerGetRequiredConfirmations(t *testing.T) { }) t.Run("FAST confirmation type with zero honored as instant", func(t *testing.T) { - // Fallback policy lives in the client's applyDefaults; the confirmer - // honors a resolved 0 as an instant route. See F-2026-18139. + // 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(0), confirmations) @@ -329,8 +328,6 @@ func TestEventConfirmerGetRequiredConfirmations_MoreEdgeCases(t *testing.T) { }) t.Run("zero fast honored as instant", func(t *testing.T) { - // Fallback policy lives in applyDefaults; the confirmer honors a - // resolved 0 as an instant route. See F-2026-18139. ec := NewEventConfirmer(nil, nil, "solana:mainnet", 5, 0, 20, logger) result := ec.getRequiredConfirmations(store.ConfirmationFast) assert.Equal(t, uint64(0), result) diff --git a/universalClient/config/config_test.go b/universalClient/config/config_test.go index 8a46e5b37..b656c5190 100644 --- a/universalClient/config/config_test.go +++ b/universalClient/config/config_test.go @@ -322,13 +322,10 @@ func TestGetChainCleanupSettings(t *testing.T) { }) } -// Regression for F-2026-18139: network gating must fail safe. Any value other -// than "testnet" (including unset) is mainnet, and only testnet unlocks -// zero-confirmation instant routes. func TestNetworkGating(t *testing.T) { cases := []struct { - network string - wantTestnet bool + network string + wantTestnet bool }{ {"", false}, {"mainnet", false}, @@ -340,7 +337,7 @@ func TestNetworkGating(t *testing.T) { } for _, tc := range cases { t.Run("network="+tc.network, func(t *testing.T) { - c := &Config{Network: tc.network} + 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 64d54e2ff..86867d20b 100644 --- a/universalClient/config/default_config.json +++ b/universalClient/config/default_config.json @@ -2,7 +2,7 @@ "log_level": 1, "log_format": "console", "log_sampler": false, - "network": "mainnet", + "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 7f8479848..f35484dac 100644 --- a/universalClient/config/types.go +++ b/universalClient/config/types.go @@ -13,20 +13,16 @@ const ( KeyringBackendFile KeyringBackend = "file" ) -// NetworkTestnet is the Network value that unlocks testnet-only relaxed behavior. const NetworkTestnet = "testnet" -// IsTestnet reports whether this node is configured for testnet. Any value other -// than "testnet" (including unset) is treated as mainnet so relaxed behaviors -// fail safe. See [Config.AllowsZeroConfirmations]. +// 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.Network), NetworkTestnet) + return strings.EqualFold(strings.TrimSpace(c.PushNetwork), NetworkTestnet) } -// AllowsZeroConfirmations reports whether zero-confirmation ("instant") inbound -// routes are permitted. Only testnet may honor a registry-configured -// confirmation depth of 0; on mainnet a 0 falls back to a safe depth so inbounds -// cannot finalize at their inclusion block. See F-2026-18139. +// 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() } @@ -48,11 +44,8 @@ type Config struct { ConfigRefreshIntervalSeconds int `json:"config_refresh_interval_seconds"` MaxRetries int `json:"max_retries"` - // Network identifies the deployment network: "mainnet" or "testnet". - // Unset/unknown is treated as mainnet, the safe default. Testnet unlocks - // relaxed behaviors that must never apply to mainnet — currently - // zero-confirmation ("instant") inbound routes. See F-2026-18139. - Network string `json:"network"` + // PushNetwork is "mainnet" or "testnet"; unset/unknown is treated as mainnet. + PushNetwork string `json:"push_network"` // Query Server QueryServerPort int `json:"query_server_port"` From 1bc56485dc63167f4992db285804b05e025a0e3f Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 12 Aug 2026 16:32:35 +0530 Subject: [PATCH 3/4] refactor: extract default confirmation depths to common constants; EVM fast default 5 (F-2026-18139) --- universalClient/chains/common/confirmation.go | 7 +++++++ universalClient/chains/evm/client.go | 8 ++++---- universalClient/chains/evm/client_test.go | 8 ++++---- universalClient/chains/svm/client.go | 8 ++++---- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/universalClient/chains/common/confirmation.go b/universalClient/chains/common/confirmation.go index 6b2097d0f..438159197 100644 --- a/universalClient/chains/common/confirmation.go +++ b/universalClient/chains/common/confirmation.go @@ -1,5 +1,12 @@ 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 diff --git a/universalClient/chains/evm/client.go b/universalClient/chains/evm/client.go index ed436f648..1bce6ac66 100644 --- a/universalClient/chains/evm/client.go +++ b/universalClient/chains/evm/client.go @@ -359,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 @@ -388,10 +388,10 @@ func (c *Client) applyDefaults() componentConfig { // when instant routes are enabled; otherwise fall back to a safe default. if !c.allowZeroConfirmations { if config.fastConfirmations == 0 { - config.fastConfirmations = 2 + config.fastConfirmations = common.DefaultFastConfirmations } if config.standardConfirmations == 0 { - config.standardConfirmations = 12 + config.standardConfirmations = common.DefaultStandardConfirmations } } diff --git a/universalClient/chains/evm/client_test.go b/universalClient/chains/evm/client_test.go index 03195722d..9f05086fb 100644 --- a/universalClient/chains/evm/client_test.go +++ b/universalClient/chains/evm/client_test.go @@ -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,7 +426,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) }) } @@ -452,7 +452,7 @@ func TestApplyDefaults_ZeroConfirmations(t *testing.T) { } cfg := client.applyDefaults() - assert.Equal(t, uint64(2), cfg.fastConfirmations, "zero fast must not disable depth on mainnet") + 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") }) diff --git a/universalClient/chains/svm/client.go b/universalClient/chains/svm/client.go index dd811c700..e63af21df 100644 --- a/universalClient/chains/svm/client.go +++ b/universalClient/chains/svm/client.go @@ -368,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, } @@ -415,10 +415,10 @@ func (c *Client) applyDefaults() componentConfig { // when instant routes are enabled; otherwise fall back to a safe default. if !c.allowZeroConfirmations { if config.fastConfirmations == 0 { - config.fastConfirmations = 5 + config.fastConfirmations = common.DefaultFastConfirmations } if config.standardConfirmations == 0 { - config.standardConfirmations = 12 + config.standardConfirmations = common.DefaultStandardConfirmations } } From d68bde5627820e26e72f82bd1c67755b6363cf64 Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 12 Aug 2026 17:19:31 +0530 Subject: [PATCH 4/4] chore: log RPC height skew at debug not warn (F-2026-18139) --- universalClient/chains/evm/event_confirmer.go | 2 +- universalClient/chains/svm/event_confirmer.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/universalClient/chains/evm/event_confirmer.go b/universalClient/chains/evm/event_confirmer.go index 89a6f35e5..79a6b93d6 100644 --- a/universalClient/chains/evm/event_confirmer.go +++ b/universalClient/chains/evm/event_confirmer.go @@ -164,7 +164,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { confirmations, ok := chaincommon.ConfirmationDepth(latestBlock, txBlock) if !ok { // RPC height skew: latest block is behind the tx block. Defer. - ec.logger.Warn(). + ec.logger.Debug(). Str("event_id", event.EventID). Uint64("latest_block", latestBlock). Uint64("tx_block", txBlock). diff --git a/universalClient/chains/svm/event_confirmer.go b/universalClient/chains/svm/event_confirmer.go index b78650da1..acb3f29bd 100644 --- a/universalClient/chains/svm/event_confirmer.go +++ b/universalClient/chains/svm/event_confirmer.go @@ -183,7 +183,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { confirmations, ok := chaincommon.ConfirmationDepth(latestSlot, txSlot) if !ok { // RPC height skew: latest slot is behind the tx slot. Defer. - ec.logger.Warn(). + ec.logger.Debug(). Str("event_id", event.EventID). Uint64("latest_slot", latestSlot). Uint64("tx_slot", txSlot).