From 22df5d37a2c3d28d6bcab5e97a0c9f6a95f33bbc Mon Sep 17 00:00:00 2001 From: aman035 Date: Wed, 12 Aug 2026 15:58:17 +0530 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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). From faa9ccd83a591b019ca1084b5c2265e5870472dd Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 25 Aug 2026 14:03:58 +0530 Subject: [PATCH 5/7] test: cover RPC height skew end to end; pin signature discovery to finalized --- .../chains/evm/event_confirmer_test.go | 73 +++++++++++++++++++ .../chains/svm/event_confirmer_test.go | 71 ++++++++++++++++++ universalClient/chains/svm/rpc_client.go | 7 +- 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/universalClient/chains/evm/event_confirmer_test.go b/universalClient/chains/evm/event_confirmer_test.go index f54f98163..3ee506bb7 100644 --- a/universalClient/chains/evm/event_confirmer_test.go +++ b/universalClient/chains/evm/event_confirmer_test.go @@ -529,3 +529,76 @@ func TestProcessPendingEvents_FailedReceiptMarkedReverted(t *testing.T) { require.NoError(t, memDB.Client().Where("event_id = ?", pending.EventID).First(&got).Error) assert.Equal(t, store.StatusReverted, got.Status, "failed receipt must transition to REVERTED, not CONFIRMED") } + +// The skew this finding reports, end to end: the endpoint serving the receipt +// is ahead of the one serving the tip, so the tx block is above the latest +// block. Unchecked, latest-tx underflows to near 2^64 and clears any threshold. +// The event must stay PENDING and be retried, never confirmed. +func TestProcessPendingEvents_RPCHeightSkew_StaysPending(t *testing.T) { + txHash := "0x3333333333333333333333333333333333333333333333333333333333333333" + const ( + eventBlockHex = "0x96" // 150, the receipt endpoint is ahead + latestBlockHex = "0x64" // 100, the tip endpoint lags by 50 blocks + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := make([]byte, r.ContentLength) + r.Body.Read(body) + bodyStr := string(body) + + switch { + case strings.Contains(bodyStr, "eth_chainId"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x1"}`)) + case strings.Contains(bodyStr, "eth_blockNumber"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"` + latestBlockHex + `"}`)) + case strings.Contains(bodyStr, "eth_getTransactionReceipt"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{` + + `"transactionHash":"` + txHash + `",` + + `"blockNumber":"` + eventBlockHex + `",` + + `"blockHash":"0x4444444444444444444444444444444444444444444444444444444444444444",` + + `"transactionIndex":"0x0",` + + `"gasUsed":"0x5208",` + + `"cumulativeGasUsed":"0x5208",` + + `"logsBloom":"0x` + strings.Repeat("0", 512) + `",` + + `"logs":[],` + + `"status":"0x1",` + + `"type":"0x2"` + + `}}`)) + default: + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer server.Close() + + logger := zerolog.Nop() + rpcClient, err := NewRPCClient([]string{server.URL}, 1, logger) + require.NoError(t, err) + defer rpcClient.Close() + + memDB, err := db.OpenInMemoryDB(true) + require.NoError(t, err) + defer memDB.Close() + + ec := NewEventConfirmer(rpcClient, memDB, "eip155:1", 5, 5, 12, logger) + cs := common.NewChainStore(memDB) + + pending := &store.Event{ + EventID: txHash + ":0", + BlockHeight: 150, + Type: store.EventTypeInbound, + ConfirmationType: store.ConfirmationStandard, + Status: store.StatusPending, + EventData: []byte(`{}`), + } + inserted, err := cs.InsertEventIfNotExists(pending) + require.NoError(t, err) + require.True(t, inserted) + + require.NoError(t, ec.processPendingEvents(context.Background())) + + var got store.Event + require.NoError(t, memDB.Client().Where("event_id = ?", pending.EventID).First(&got).Error) + assert.Equal(t, store.StatusPending, got.Status, + "a tx block above the observed tip must defer, not confirm") +} diff --git a/universalClient/chains/svm/event_confirmer_test.go b/universalClient/chains/svm/event_confirmer_test.go index 290b82811..7a812bfc3 100644 --- a/universalClient/chains/svm/event_confirmer_test.go +++ b/universalClient/chains/svm/event_confirmer_test.go @@ -442,3 +442,74 @@ func TestEventConfirmer_StartStop_ZeroPollInterval(t *testing.T) { t.Fatal("event confirmer did not stop after context cancellation with zero poll interval") } } + +// The skew this finding reports, end to end: the endpoint serving the +// transaction is ahead of the one serving the slot, so the tx slot is above the +// latest slot. Unchecked, latest-tx underflows to near 2^64 and clears any +// threshold. The event must stay PENDING and be retried, never confirmed. +func TestProcessPendingEvents_RPCHeightSkew_StaysPending(t *testing.T) { + sigStr := strings.Repeat("1", 64) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := make([]byte, r.ContentLength) + r.Body.Read(body) + bodyStr := string(body) + + switch { + case strings.Contains(bodyStr, `"getHealth"`): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"ok"}`)) + case strings.Contains(bodyStr, `"getSlot"`): + // The tip endpoint lags well behind the tx endpoint. + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":100}`)) + case strings.Contains(bodyStr, `"getTransaction"`): + // Successful tx, but at a slot the observed tip has not reached. + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{` + + `"slot":1000,` + + `"meta":{` + + `"err":null,` + + `"fee":5000,` + + `"preBalances":[],` + + `"postBalances":[],` + + `"logMessages":[],` + + `"status":{"Ok":null}` + + `},` + + `"transaction":["AQ==","base64"]` + + `}}`)) + default: + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + defer server.Close() + + logger := zerolog.Nop() + rpcClient, err := NewRPCClient([]string{server.URL}, "", logger) + require.NoError(t, err) + defer rpcClient.Close() + + memDB, err := db.OpenInMemoryDB(true) + require.NoError(t, err) + defer memDB.Close() + + ec := NewEventConfirmer(rpcClient, memDB, "solana:mainnet", 5, 5, 12, logger) + cs := common.NewChainStore(memDB) + + pending := &store.Event{ + EventID: sigStr + ":0", + BlockHeight: 1000, + Type: store.EventTypeInbound, + ConfirmationType: store.ConfirmationStandard, + Status: store.StatusPending, + EventData: []byte(`{}`), + } + inserted, err := cs.InsertEventIfNotExists(pending) + require.NoError(t, err) + require.True(t, inserted) + + require.NoError(t, ec.processPendingEvents(context.Background())) + + var got store.Event + require.NoError(t, memDB.Client().Where("event_id = ?", pending.EventID).First(&got).Error) + assert.Equal(t, store.StatusPending, got.Status, + "a tx slot above the observed tip must defer, not confirm") +} diff --git a/universalClient/chains/svm/rpc_client.go b/universalClient/chains/svm/rpc_client.go index ab257fadd..3f8170351 100644 --- a/universalClient/chains/svm/rpc_client.go +++ b/universalClient/chains/svm/rpc_client.go @@ -297,9 +297,12 @@ func calculateMedian(fees []uint64) uint64 { // otherwise it returns signatures strictly older than `before`, enabling // backward pagination. func (rc *RPCClient) GetSignaturesForAddress(ctx context.Context, address solana.PublicKey, before solana.Signature) ([]*rpc.TransactionSignature, error) { - var opts *rpc.GetSignaturesForAddressOpts + // Commitment is set explicitly rather than left to the server default, so + // discovery and the slot the confirmation depth is measured against are on + // the same footing. + opts := &rpc.GetSignaturesForAddressOpts{Commitment: rpc.CommitmentFinalized} if !before.IsZero() { - opts = &rpc.GetSignaturesForAddressOpts{Before: before} + opts.Before = before } var signatures []*rpc.TransactionSignature err := rc.executeWithFailover(ctx, "get_signatures_for_address", func(client *rpc.Client) error { From 3b5f14dae964d07244261276e7ecbae28eb77ffb Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 25 Aug 2026 14:17:18 +0530 Subject: [PATCH 6/7] feat: allow zero confirmations per chain for instant-finality sources --- universalClient/chains/chains.go | 4 +-- universalClient/config/config_test.go | 43 ++++++++++++++++++++++++++- universalClient/config/types.go | 28 ++++++++++++----- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/universalClient/chains/chains.go b/universalClient/chains/chains.go index 254131316..5d201a563 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.config.AllowsZeroConfirmations(), c.logger) + client, err = evm.NewClient(cfg, chainDB, chainConfig, c.pushSigner, c.config.AllowsZeroConfirmationsForChain(cfg.Chain), c.logger) case uregistrytypes.VmType_SVM: - client, err = svm.NewClient(cfg, chainDB, chainConfig, c.pushSigner, c.config.NodeHome, c.config.AllowsZeroConfirmations(), c.logger) + client, err = svm.NewClient(cfg, chainDB, chainConfig, c.pushSigner, c.config.NodeHome, c.config.AllowsZeroConfirmationsForChain(cfg.Chain), c.logger) default: return fmt.Errorf("unsupported VM type: %v", cfg.VmType) } diff --git a/universalClient/config/config_test.go b/universalClient/config/config_test.go index b656c5190..3dd056add 100644 --- a/universalClient/config/config_test.go +++ b/universalClient/config/config_test.go @@ -322,6 +322,48 @@ func TestGetChainCleanupSettings(t *testing.T) { }) } +// A registry depth of 0 is honored only with an out-of-band signal, since +// proto3 gives the same encoding to a deliberate 0 and an unset field. +func TestAllowsZeroConfirmationsForChain(t *testing.T) { + const instantChain = "eip155:1101" + const normalChain = "eip155:1" + + withInstant := map[string]ChainSpecificConfig{ + instantChain: {InstantFinality: true}, + normalChain: {}, + } + + t.Run("mainnet honors 0 only for a declared instant-finality chain", func(t *testing.T) { + c := &Config{PushNetwork: "mainnet", ChainConfigs: withInstant} + assert.True(t, c.AllowsZeroConfirmationsForChain(instantChain)) + assert.False(t, c.AllowsZeroConfirmationsForChain(normalChain)) + }) + + t.Run("an unconfigured chain falls back on mainnet", func(t *testing.T) { + c := &Config{PushNetwork: "mainnet", ChainConfigs: withInstant} + assert.False(t, c.AllowsZeroConfirmationsForChain("eip155:999")) + }) + + t.Run("unset network is treated as mainnet", func(t *testing.T) { + c := &Config{ChainConfigs: withInstant} + assert.True(t, c.AllowsZeroConfirmationsForChain(instantChain), + "an explicit per-chain declaration still applies") + assert.False(t, c.AllowsZeroConfirmationsForChain(normalChain)) + }) + + t.Run("testnet honors 0 everywhere", func(t *testing.T) { + c := &Config{PushNetwork: "testnet", ChainConfigs: withInstant} + assert.True(t, c.AllowsZeroConfirmationsForChain(instantChain)) + assert.True(t, c.AllowsZeroConfirmationsForChain(normalChain)) + assert.True(t, c.AllowsZeroConfirmationsForChain("eip155:999")) + }) + + t.Run("nil chain configs does not panic and falls back", func(t *testing.T) { + c := &Config{PushNetwork: "mainnet"} + assert.False(t, c.AllowsZeroConfirmationsForChain(instantChain)) + }) +} + func TestNetworkGating(t *testing.T) { cases := []struct { network string @@ -339,7 +381,6 @@ func TestNetworkGating(t *testing.T) { 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/types.go b/universalClient/config/types.go index f35484dac..238fde946 100644 --- a/universalClient/config/types.go +++ b/universalClient/config/types.go @@ -21,10 +21,18 @@ 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() +// AllowsZeroConfirmationsForChain reports whether a registry confirmation depth +// of 0 is honored for this chain instead of falling back to a safe depth. +// +// A registry 0 is ambiguous: proto3 encodes a deliberate 0 and an unset field +// identically, so it cannot be read as "instant finality" on its own. Honoring +// it therefore requires an out-of-band signal, either a testnet deployment or an +// explicit per-chain declaration. Anything else falls back. +func (c *Config) AllowsZeroConfirmationsForChain(chainID string) bool { + if c.IsTestnet() { + return true + } + return c.GetChainConfig(chainID).InstantFinality } // Config holds all configuration for the Universal Validator. @@ -72,9 +80,15 @@ type ChainSpecificConfig struct { EventPollingIntervalSeconds *int `json:"event_polling_interval_seconds,omitempty"` EventStartFrom *int64 `json:"event_start_from,omitempty"` GasPriceIntervalSeconds *int `json:"gas_price_interval_seconds,omitempty"` - GasPriceMarkupPercent *int `json:"gas_price_markup_percent,omitempty"` // % markup on fetched gas price to handle spikes - ProtocolALT string `json:"protocol_alt,omitempty"` // Protocol ALT address (base58) for V0 transactions - TokenALTs map[string]string `json:"token_alts,omitempty"` // mint address → token ALT address (base58) + GasPriceMarkupPercent *int `json:"gas_price_markup_percent,omitempty"` // % markup on fetched gas price to handle spikes + ProtocolALT string `json:"protocol_alt,omitempty"` // Protocol ALT address (base58) for V0 transactions + TokenALTs map[string]string `json:"token_alts,omitempty"` // mint address → token ALT address (base58) + + // InstantFinality declares that this chain's source finality is immediate, so + // a registry confirmation depth of 0 is honored rather than replaced by a + // safe default. Required because proto3 cannot distinguish a deliberate 0 + // from an unset field, so the intent has to be stated out of band. + InstantFinality bool `json:"instant_finality,omitempty"` // SVM rent reclaimer (orphaned StoredIxData PDA cleanup). Both default if unset. RentReclaimSweepIntervalSeconds *int `json:"rent_reclaim_sweep_interval_seconds,omitempty"` // how often to sweep From 9e86fedc22c810f5950c80d6a5d6ca6a358227b5 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 25 Aug 2026 14:21:13 +0530 Subject: [PATCH 7/7] revert per-chain instant finality opt-in; no such source chain today --- universalClient/chains/chains.go | 4 +-- universalClient/config/config_test.go | 43 +-------------------------- universalClient/config/types.go | 20 ++++--------- 3 files changed, 8 insertions(+), 59 deletions(-) diff --git a/universalClient/chains/chains.go b/universalClient/chains/chains.go index 5d201a563..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.config.AllowsZeroConfirmationsForChain(cfg.Chain), 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.config.AllowsZeroConfirmationsForChain(cfg.Chain), 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/config/config_test.go b/universalClient/config/config_test.go index 3dd056add..b656c5190 100644 --- a/universalClient/config/config_test.go +++ b/universalClient/config/config_test.go @@ -322,48 +322,6 @@ func TestGetChainCleanupSettings(t *testing.T) { }) } -// A registry depth of 0 is honored only with an out-of-band signal, since -// proto3 gives the same encoding to a deliberate 0 and an unset field. -func TestAllowsZeroConfirmationsForChain(t *testing.T) { - const instantChain = "eip155:1101" - const normalChain = "eip155:1" - - withInstant := map[string]ChainSpecificConfig{ - instantChain: {InstantFinality: true}, - normalChain: {}, - } - - t.Run("mainnet honors 0 only for a declared instant-finality chain", func(t *testing.T) { - c := &Config{PushNetwork: "mainnet", ChainConfigs: withInstant} - assert.True(t, c.AllowsZeroConfirmationsForChain(instantChain)) - assert.False(t, c.AllowsZeroConfirmationsForChain(normalChain)) - }) - - t.Run("an unconfigured chain falls back on mainnet", func(t *testing.T) { - c := &Config{PushNetwork: "mainnet", ChainConfigs: withInstant} - assert.False(t, c.AllowsZeroConfirmationsForChain("eip155:999")) - }) - - t.Run("unset network is treated as mainnet", func(t *testing.T) { - c := &Config{ChainConfigs: withInstant} - assert.True(t, c.AllowsZeroConfirmationsForChain(instantChain), - "an explicit per-chain declaration still applies") - assert.False(t, c.AllowsZeroConfirmationsForChain(normalChain)) - }) - - t.Run("testnet honors 0 everywhere", func(t *testing.T) { - c := &Config{PushNetwork: "testnet", ChainConfigs: withInstant} - assert.True(t, c.AllowsZeroConfirmationsForChain(instantChain)) - assert.True(t, c.AllowsZeroConfirmationsForChain(normalChain)) - assert.True(t, c.AllowsZeroConfirmationsForChain("eip155:999")) - }) - - t.Run("nil chain configs does not panic and falls back", func(t *testing.T) { - c := &Config{PushNetwork: "mainnet"} - assert.False(t, c.AllowsZeroConfirmationsForChain(instantChain)) - }) -} - func TestNetworkGating(t *testing.T) { cases := []struct { network string @@ -381,6 +339,7 @@ func TestNetworkGating(t *testing.T) { 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/types.go b/universalClient/config/types.go index 238fde946..7c1c39153 100644 --- a/universalClient/config/types.go +++ b/universalClient/config/types.go @@ -21,18 +21,14 @@ func (c *Config) IsTestnet() bool { return strings.EqualFold(strings.TrimSpace(c.PushNetwork), NetworkTestnet) } -// AllowsZeroConfirmationsForChain reports whether a registry confirmation depth -// of 0 is honored for this chain instead of falling back to a safe depth. +// AllowsZeroConfirmations reports whether a registry confirmation depth of 0 is +// honored instead of falling back to a safe depth. // // A registry 0 is ambiguous: proto3 encodes a deliberate 0 and an unset field // identically, so it cannot be read as "instant finality" on its own. Honoring -// it therefore requires an out-of-band signal, either a testnet deployment or an -// explicit per-chain declaration. Anything else falls back. -func (c *Config) AllowsZeroConfirmationsForChain(chainID string) bool { - if c.IsTestnet() { - return true - } - return c.GetChainConfig(chainID).InstantFinality +// it therefore needs an out-of-band signal, which today is a testnet deployment. +func (c *Config) AllowsZeroConfirmations() bool { + return c.IsTestnet() } // Config holds all configuration for the Universal Validator. @@ -84,12 +80,6 @@ type ChainSpecificConfig struct { ProtocolALT string `json:"protocol_alt,omitempty"` // Protocol ALT address (base58) for V0 transactions TokenALTs map[string]string `json:"token_alts,omitempty"` // mint address → token ALT address (base58) - // InstantFinality declares that this chain's source finality is immediate, so - // a registry confirmation depth of 0 is honored rather than replaced by a - // safe default. Required because proto3 cannot distinguish a deliberate 0 - // from an unset field, so the intent has to be stated out of band. - InstantFinality bool `json:"instant_finality,omitempty"` - // SVM rent reclaimer (orphaned StoredIxData PDA cleanup). Both default if unset. RentReclaimSweepIntervalSeconds *int `json:"rent_reclaim_sweep_interval_seconds,omitempty"` // how often to sweep RentReclaimMinPDAAgeSeconds *int `json:"rent_reclaim_min_pda_age_seconds,omitempty"` // skip PDAs younger than this