From b914ea138800fe0e03d03f75ebe986c5963bfaa9 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Wed, 25 Mar 2026 01:19:32 -0300 Subject: [PATCH 01/22] feat(net): remove Boar bootstrap nodes and replace with operator peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all Boar bootstrap node entries with curated DNS-backed operator peers to complete the bootstrap infrastructure decommission. This is the final phase — Staked nodes were removed in v2.5.1. Peer list changes: - Mainnet: 2 Boar entries replaced with 5 operator peers at keep-nodes.io - Testnet: 1 Boar entry replaced with 2 operator peers at test.keep-nodes.io - All entries use /dns4/ format for IP change resilience Security — AllowList decoupling: - Pass firewall.EmptyAllowList instead of extracting embedded peer keys - All peers (including embedded operators) now pass IsRecognized() staking checks with no firewall bypass - Remove dead ExtractPeersPublicKeys function and its tests Deprecations and renames: - Deprecate --network.bootstrap flag with runtime warning - Rename connected_bootstrap_count metric to connected_wellknown_peers_count Documentation: - Add operator migration guide covering Boar address removal, --network.peers override behavior, and monitoring updates --- cmd/flags.go | 2 +- cmd/start.go | 17 +-- cmd/start_test.go | 59 ++++++++ config/_peers/mainnet | 7 +- config/_peers/testnet | 3 +- config/peers_test.go | 10 +- docs/network-peer-migration-guide.md | 218 +++++++++++++++++++++++++++ pkg/clientinfo/metrics.go | 22 +-- pkg/clientinfo/metrics_test.go | 159 +++++++++++++++++++ pkg/firewall/firewall_test.go | 79 +++++++++- pkg/net/libp2p/libp2p.go | 41 ----- pkg/net/libp2p/libp2p_test.go | 98 ------------ 12 files changed, 544 insertions(+), 171 deletions(-) create mode 100644 cmd/start_test.go create mode 100644 docs/network-peer-migration-guide.md create mode 100644 pkg/clientinfo/metrics_test.go diff --git a/cmd/flags.go b/cmd/flags.go index 6ce094c2e6..4a5916eab1 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -206,7 +206,7 @@ func initNetworkFlags(cmd *cobra.Command, cfg *config.Config) { &cfg.LibP2P.Bootstrap, "network.bootstrap", false, - "Run the client in bootstrap mode.", + "[DEPRECATED] Run the client in bootstrap mode. This flag is deprecated and will be removed in a future release.", ) cmd.Flags().StringSliceVar( diff --git a/cmd/start.go b/cmd/start.go index cfaece274c..7be632c086 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -188,6 +188,9 @@ func start(cmd *cobra.Command) error { } func isBootstrap() bool { + if clientConfig.LibP2P.Bootstrap { + logger.Warnf("--network.bootstrap is deprecated and will be removed in a future release") + } return clientConfig.LibP2P.Bootstrap } @@ -197,19 +200,9 @@ func initializeNetwork( operatorPrivateKey *operator.PrivateKey, blockCounter chain.BlockCounter, ) (net.Provider, error) { - bootstrapPeersPublicKeys, err := libp2p.ExtractPeersPublicKeys( - clientConfig.LibP2P.Peers, - ) - if err != nil { - return nil, fmt.Errorf( - "error extracting bootstrap peers public keys: [%v]", - err, - ) - } - firewall := firewall.AnyApplicationPolicy( applications, - firewall.NewAllowList(bootstrapPeersPublicKeys), + firewall.EmptyAllowList, ) netProvider, err := libp2p.Connect( @@ -244,7 +237,7 @@ func initializeClientInfo( config.ClientInfo.NetworkMetricsTick, ) - registry.ObserveConnectedBootstrapCount( + registry.ObserveConnectedWellknownPeersCount( netProvider, config.LibP2P.Peers, config.ClientInfo.NetworkMetricsTick, diff --git a/cmd/start_test.go b/cmd/start_test.go new file mode 100644 index 0000000000..8c2d695c21 --- /dev/null +++ b/cmd/start_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/keep-network/keep-core/config" + "github.com/spf13/cobra" +) + +func TestNetworkBootstrapFlagDescription_ContainsDeprecationNotice(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cfg := &config.Config{} + + initNetworkFlags(cmd, cfg) + + flag := cmd.Flags().Lookup("network.bootstrap") + if flag == nil { + t.Fatal("expected network.bootstrap flag to be registered") + } + + usageLower := strings.ToLower(flag.Usage) + if !strings.Contains(usageLower, "deprecated") { + t.Errorf( + "expected flag description to contain deprecation notice, got: %q", + flag.Usage, + ) + } +} + +func TestIsBootstrap(t *testing.T) { + tests := map[string]struct { + bootstrapValue bool + expected bool + }{ + "returns true when bootstrap flag is set": { + bootstrapValue: true, + expected: true, + }, + "returns false when bootstrap flag is not set": { + bootstrapValue: false, + expected: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + originalBootstrap := clientConfig.LibP2P.Bootstrap + defer func() { clientConfig.LibP2P.Bootstrap = originalBootstrap }() + + clientConfig.LibP2P.Bootstrap = tc.bootstrapValue + + got := isBootstrap() + if got != tc.expected { + t.Errorf("expected isBootstrap() to return %v, got %v", tc.expected, got) + } + }) + } +} diff --git a/config/_peers/mainnet b/config/_peers/mainnet index c7b87358e4..676524e6b9 100644 --- a/config/_peers/mainnet +++ b/config/_peers/mainnet @@ -1,2 +1,5 @@ -/dns4/bst-a01.tbtc.boar.network/tcp/5001/ipfs/16Uiu2HAmAmCrLuUmnBgpavU8y8JBUN6jWAQ93JwydZy3ABRyY6wU -/dns4/bst-b01.tbtc.boar.network/tcp/5001/ipfs/16Uiu2HAm4w5HdJQxBnadGRepaiGfWVvtMzhdAGZVcrf9i71mv69V +/dns4/keep-operator-1.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmVUxCz2YjBpGaGirVLx6RGtHbPg5rygEWMPoUFE4bHTkr +/dns4/keep-operator-2.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAm8bLqTcGMDFaNPGPC6gxStKCnJr2DaVsMbce1ZEyaKo9S +/dns4/keep-operator-3.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmQLCwPnNmFMDQkc5hLfapGKtXPvFJQKB3rUFYa1wjVnfi +/dns4/keep-operator-4.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmTv4atEFadTVPz7BWhE3gRFMeJ5Kk4LQfgN2V8ViWYFRx +/dns4/keep-operator-5.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmPwQuywYq9qFRn8gLCtiKaDZwg2u3JQhWia7RYHRdfk1r diff --git a/config/_peers/testnet b/config/_peers/testnet index d86e88643f..8e4f9e3775 100644 --- a/config/_peers/testnet +++ b/config/_peers/testnet @@ -1 +1,2 @@ -/dns4/bst-a01.test.keep.boar.network/tcp/6001/ipfs/16Uiu2HAmSLDSahiKyTbCNNu8wJmZAsiKF7wuYJ8mogY8ZuAG1jhu +/dns4/keep-operator-1.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL +/dns4/keep-operator-2.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa diff --git a/config/peers_test.go b/config/peers_test.go index 56892b5028..9bcaa0ddf0 100644 --- a/config/peers_test.go +++ b/config/peers_test.go @@ -18,13 +18,17 @@ func TestResolvePeers(t *testing.T) { "mainnet network": { network: network.Mainnet, expectedPeers: []string{ - "/dns4/bst-a01.tbtc.boar.network/tcp/5001/ipfs/16Uiu2HAmAmCrLuUmnBgpavU8y8JBUN6jWAQ93JwydZy3ABRyY6wU", - "/dns4/bst-b01.tbtc.boar.network/tcp/5001/ipfs/16Uiu2HAm4w5HdJQxBnadGRepaiGfWVvtMzhdAGZVcrf9i71mv69V", + "/dns4/keep-operator-1.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmVUxCz2YjBpGaGirVLx6RGtHbPg5rygEWMPoUFE4bHTkr", + "/dns4/keep-operator-2.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAm8bLqTcGMDFaNPGPC6gxStKCnJr2DaVsMbce1ZEyaKo9S", + "/dns4/keep-operator-3.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmQLCwPnNmFMDQkc5hLfapGKtXPvFJQKB3rUFYa1wjVnfi", + "/dns4/keep-operator-4.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmTv4atEFadTVPz7BWhE3gRFMeJ5Kk4LQfgN2V8ViWYFRx", + "/dns4/keep-operator-5.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmPwQuywYq9qFRn8gLCtiKaDZwg2u3JQhWia7RYHRdfk1r", }}, "sepolia network": { network: network.Testnet, expectedPeers: []string{ - "/dns4/bst-a01.test.keep.boar.network/tcp/6001/ipfs/16Uiu2HAmSLDSahiKyTbCNNu8wJmZAsiKF7wuYJ8mogY8ZuAG1jhu", + "/dns4/keep-operator-1.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL", + "/dns4/keep-operator-2.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa", }, }, "developer network": { diff --git a/docs/network-peer-migration-guide.md b/docs/network-peer-migration-guide.md new file mode 100644 index 0000000000..3d336e003d --- /dev/null +++ b/docs/network-peer-migration-guide.md @@ -0,0 +1,218 @@ +# Network Peer Migration Guide + +## Quick Action + +**If you have NOT manually configured `--network.peers` or `[LibP2P].Peers`**, +no action is needed. Your node uses embedded defaults that are updated +automatically with each client release. + +**If you HAVE manually configured peers**, check your configuration for any +address containing `boar.network` or `staked.cloud` and remove it. The +recommended fix is to remove the `--network.peers` / `[LibP2P].Peers` setting +entirely so your node uses the new built-in defaults. See +[Required Action](#required-action-remove-hardcoded-boar-addresses) below. + +> **Warning**: If you do nothing and your custom peer list contains only Boar +> addresses, your node will lose network connectivity when Boar infrastructure +> is decommissioned. + +--- + +## Summary of Changes + +The keep-client embedded peer list has been updated to replace Boar bootstrap +node addresses with curated operator-run peers. + +The following changes are included in this release: + +- **Boar bootstrap addresses removed** from embedded peer lists (mainnet and + testnet). The Boar infrastructure (`bst-*.boar.network`) is being + decommissioned. +- **New curated operator peers** are now embedded as defaults, hosted at + `keep-nodes.io` (mainnet) and `test.keep-nodes.io` (testnet). +- **Firewall validation strengthened**: all peers are now validated through + on-chain staking checks. Previously, embedded peer public keys bypassed + staking validation via a firewall allow-list. +- **`--network.bootstrap` flag deprecated**: the flag still functions but will + be removed in a future release. +- **Metric renamed**: `connected_bootstrap_count` has been renamed to + `connected_wellknown_peers_count`. + +Most operators do not need to take any action. If you have manually configured +`--network.peers` or `[LibP2P].Peers` in your configuration, read the next +section carefully. + +--- + +## Required Action: Remove Hardcoded Boar Addresses + +**If your node is configured with `--network.peers` (CLI flag) or +`[LibP2P].Peers` (configuration file), you must review and update your +configuration before Boar infrastructure is decommissioned.** Failure to act +will result in your node being unable to discover peers on the network. + +### How to Check if You Are Affected + +1. **Check your startup command** for the `--network.peers` flag: + ``` + --network.peers=/dns4/bst-a01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm... + ``` + +2. **Check your configuration file** (TOML, YAML, or JSON) for a `Peers` + entry under the `[LibP2P]` section: + ```toml + [LibP2P] + Peers = [ + "/dns4/bst-a01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm...", + "/dns4/bst-b01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm..." + ] + ``` + +3. **If neither is set**, you are using the embedded defaults and no action is + needed. The new operator peers are included automatically. + +### Boar Addresses to Remove + +Remove any peer address whose hostname contains `boar.network` or +`staked.cloud`. The specific Boar hostnames being decommissioned are: + +| Network | Hostname | +|---------|----------| +| Mainnet | `bst-a01.tbtc.boar.network` | +| Mainnet | `bst-b01.tbtc.boar.network` | +| Testnet | `bst-a01.test.keep.boar.network` | + +The peer ID suffix (the `/ipfs/16Uiu2HAm...` portion) varies, but the hostname +is the identifying part. Any multiaddress containing one of the hostnames above +must be removed. + +### What to Do + +**Option A (recommended):** Remove `--network.peers` / `[LibP2P].Peers` +entirely. Your node will then use the new embedded defaults, which are +maintained and updated with each client release. + +Before: +```toml +[LibP2P] + Peers = [ + "/dns4/bst-a01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm...", + "/dns4/bst-b01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm..." + ] +``` + +After: +```toml +[LibP2P] + # Peers removed -- the client now uses built-in operator peer defaults. +``` + +Or, if using the CLI flag, simply remove `--network.peers` from your startup +command. + +**Option B:** Replace the Boar addresses with currently active operator peer +addresses. Only use this option if you have a specific reason to maintain a +custom peer list. + +### Why This Matters + +When you set `--network.peers` (or `[LibP2P].Peers` in your configuration +file), the client uses your list exclusively and ignores the built-in defaults. + +Internally, the `resolvePeers()` function in `config/peers.go` checks whether +`LibP2P.Peers` is already populated. If it is, the function returns immediately +without loading the embedded peer list. This means manually configured peers +completely override the defaults -- there is no merging. + +If your custom peer list contains only Boar addresses, your node will be unable +to discover any peers once Boar infrastructure is decommissioned. + +--- + +## Bootstrap Flag Deprecation + +The `--network.bootstrap=true` flag is deprecated and will be removed in a +future release. + +Historically, this flag marked a node as a bootstrap/relay node, which enabled +special behaviors such as adjusted dissemination timing and self-dial skipping. +As the network has matured, dedicated bootstrap mode is no longer necessary for +standard peer discovery. + +The flag still functions and the node will log a deprecation warning when it is +used. Operators currently running with `--network.bootstrap=true` should plan +to stop using it. If you need to adjust dissemination timing directly, use the +`--network.disseminationTime` flag instead. + +--- + +## Metric Rename + +The `connected_bootstrap_count` metric has been renamed to +`connected_wellknown_peers_count`. + +The metric semantics are unchanged -- it tracks the number of currently +connected well-known peers that are embedded in the client. Only the name has +changed to more accurately reflect that these peers are curated operator nodes +rather than traditional bootstrap nodes. + +If you have Grafana dashboards, Prometheus alerts, or other monitoring that +references the old metric name, update your queries: + +| Before | After | +|--------|-------| +| `connected_bootstrap_count` | `connected_wellknown_peers_count` | + +The metric is exposed on the Client Info HTTP endpoint (port 9601 by default). + +--- + +## Technical Background + +This section provides additional context for operators who want to understand +the underlying mechanisms. + +### Embedded Peer Resolution + +The keep-client binary embeds a default peer list at compile time using Go's +`embed` package. The peer addresses are stored in the `config/_peers/` directory +with separate files for each network (mainnet, testnet). + +When the client starts, `resolvePeers()` in `config/peers.go` runs the +following logic: + +1. If `LibP2P.Peers` is already populated (via `--network.peers` flag or + configuration file), return immediately without changes. +2. If the network type is `developer` or `unknown`, log a warning and return + (no embedded defaults for these networks). +3. Otherwise, read the embedded peer list for the current network and set + `LibP2P.Peers` to those values. + +This design means that manually configured peers always take precedence over +embedded defaults. There is no merging of the two lists. + +### Firewall Allow-List Change + +Previously, the public keys of embedded peers were added to a firewall +allow-list, which allowed them to bypass the `IsRecognized()` on-chain staking +validation. This was a convenience for bootstrap infrastructure but weakened +the security model. + +With this release, the allow-list is no longer populated with embedded peer +keys. All peers -- including the embedded operator peers -- must pass staking +validation through the on-chain contracts. This ensures that only properly +staked operators can participate in the network. + +### Network Discovery Flow + +The overall peer discovery architecture follows this path: + +1. **Embedded peers** provide initial connectivity (the addresses in + `config/_peers/`). +2. **libp2p connections** are established to these well-known peers. +3. **DHT discovery** uses these initial connections to find additional peers + across the network. +4. **Full mesh connectivity** is established as more peers are discovered. + +Removing Boar addresses from the embedded list and replacing them with active +operator peers ensures that step 1 connects to reliable, staked infrastructure. diff --git a/pkg/clientinfo/metrics.go b/pkg/clientinfo/metrics.go index c80755cab8..9398fd48ab 100644 --- a/pkg/clientinfo/metrics.go +++ b/pkg/clientinfo/metrics.go @@ -15,11 +15,11 @@ type Source func() float64 // Names under which metrics are exposed. const ( - ConnectedPeersCountMetricName = "connected_peers_count" - ConnectedBootstrapCountMetricName = "connected_bootstrap_count" - EthConnectivityMetricName = "eth_connectivity" - BtcConnectivityMetricName = "btc_connectivity" - ClientInfoMetricName = "client_info" + ConnectedPeersCountMetricName = "connected_peers_count" + ConnectedWellknownPeersCountMetricName = "connected_wellknown_peers_count" + EthConnectivityMetricName = "eth_connectivity" + BtcConnectivityMetricName = "btc_connectivity" + ClientInfoMetricName = "client_info" ) const ( @@ -55,17 +55,17 @@ func (r *Registry) ObserveConnectedPeersCount( ) } -// ObserveConnectedBootstrapCount triggers an observation process of the -// connected_bootstrap_count metric. -func (r *Registry) ObserveConnectedBootstrapCount( +// ObserveConnectedWellknownPeersCount triggers an observation process of the +// connected_wellknown_peers_count metric. +func (r *Registry) ObserveConnectedWellknownPeersCount( netProvider net.Provider, - bootstraps []string, + wellknownPeers []string, tick time.Duration, ) { input := func() float64 { currentCount := 0 - for _, address := range bootstraps { + for _, address := range wellknownPeers { if netProvider.ConnectionManager().IsConnected(address) { currentCount++ } @@ -75,7 +75,7 @@ func (r *Registry) ObserveConnectedBootstrapCount( } r.observe( - ConnectedBootstrapCountMetricName, + ConnectedWellknownPeersCountMetricName, input, validateTick(tick, DefaultNetworkMetricsTick), ) diff --git a/pkg/clientinfo/metrics_test.go b/pkg/clientinfo/metrics_test.go new file mode 100644 index 0000000000..19082f81c7 --- /dev/null +++ b/pkg/clientinfo/metrics_test.go @@ -0,0 +1,159 @@ +package clientinfo + +import ( + "context" + "testing" + "time" + + keepclientinfo "github.com/keep-network/keep-common/pkg/clientinfo" + "github.com/keep-network/keep-core/pkg/net" + "github.com/keep-network/keep-core/pkg/operator" +) + +// TestConnectedWellknownPeersCountMetricName verifies that the metric constant +// for well-known peers connectivity has the correct string value used by +// Prometheus for metric registration. +func TestConnectedWellknownPeersCountMetricName(t *testing.T) { + expected := "connected_wellknown_peers_count" + actual := ConnectedWellknownPeersCountMetricName + + if actual != expected { + t.Errorf( + "expected metric name %q, got %q", + expected, + actual, + ) + } +} + +// TestMetricConstants verifies that all metric name constants are defined with +// the expected non-empty string values. This ensures no accidental changes to +// metric names that would break Prometheus queries and Grafana dashboards. +func TestMetricConstants(t *testing.T) { + tests := []struct { + name string + constant string + expected string + }{ + { + name: "connected peers count", + constant: ConnectedPeersCountMetricName, + expected: "connected_peers_count", + }, + { + name: "connected wellknown peers count", + constant: ConnectedWellknownPeersCountMetricName, + expected: "connected_wellknown_peers_count", + }, + { + name: "eth connectivity", + constant: EthConnectivityMetricName, + expected: "eth_connectivity", + }, + { + name: "btc connectivity", + constant: BtcConnectivityMetricName, + expected: "btc_connectivity", + }, + { + name: "client info", + constant: ClientInfoMetricName, + expected: "client_info", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.constant != tc.expected { + t.Errorf( + "expected metric name %q, got %q", + tc.expected, + tc.constant, + ) + } + if tc.constant == "" { + t.Error("metric name constant must not be empty") + } + }) + } +} + +// mockTransportIdentifier implements net.TransportIdentifier for testing. +type mockTransportIdentifier struct{} + +func (m *mockTransportIdentifier) String() string { return "mock-id" } + +// mockConnectionManager implements net.ConnectionManager for testing. +type mockConnectionManager struct { + connectedAddresses map[string]bool +} + +func (m *mockConnectionManager) ConnectedPeers() []string { return nil } +func (m *mockConnectionManager) ConnectedPeersAddrInfo() map[string][]string { + return nil +} +func (m *mockConnectionManager) GetPeerPublicKey(string) (*operator.PublicKey, error) { + return nil, nil +} +func (m *mockConnectionManager) DisconnectPeer(string) {} +func (m *mockConnectionManager) AddrStrings() []string { return nil } +func (m *mockConnectionManager) IsConnected(address string) bool { + if m.connectedAddresses == nil { + return false + } + return m.connectedAddresses[address] +} + +// mockProvider implements net.Provider for testing. +type mockProvider struct { + connectionManager net.ConnectionManager +} + +func (m *mockProvider) ID() net.TransportIdentifier { return &mockTransportIdentifier{} } +func (m *mockProvider) Type() string { return "mock" } +func (m *mockProvider) BroadcastChannelFor(string) (net.BroadcastChannel, error) { + return nil, nil +} +func (m *mockProvider) ConnectionManager() net.ConnectionManager { + return m.connectionManager +} +func (m *mockProvider) CreateTransportIdentifier( + *operator.PublicKey, +) (net.TransportIdentifier, error) { + return nil, nil +} +func (m *mockProvider) BroadcastChannelForwarderFor(string) {} + +// TestObserveConnectedWellknownPeersCount_Callable verifies that the renamed +// function exists on the Registry type and can be called without panicking. +func TestObserveConnectedWellknownPeersCount_Callable(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + registry := &Registry{keepclientinfo.NewRegistry(), ctx} + + provider := &mockProvider{ + connectionManager: &mockConnectionManager{ + connectedAddresses: map[string]bool{ + "/ip4/127.0.0.1/tcp/3919": true, + }, + }, + } + + // The function should execute without panic. We use a recovered call + // to detect if the method does not exist or panics. + defer func() { + if r := recover(); r != nil { + t.Fatalf( + "ObserveConnectedWellknownPeersCount panicked: %v", + r, + ) + } + }() + + registry.ObserveConnectedWellknownPeersCount( + provider, + []string{"/ip4/127.0.0.1/tcp/3919"}, + 1*time.Minute, + ) +} diff --git a/pkg/firewall/firewall_test.go b/pkg/firewall/firewall_test.go index 6354aec87b..e05dd13e0e 100644 --- a/pkg/firewall/firewall_test.go +++ b/pkg/firewall/firewall_test.go @@ -305,8 +305,10 @@ func TestValidate_PeerIsAllowlistedNode(t *testing.T) { t.Fatal(err) } - // Mark the peer as an allowlisted node, so that it validated despite not - // being recognized by any application + // This test validates that the AllowList type mechanism still works + // correctly at the type level. In production, EmptyAllowList is used, + // so no peer receives this bypass. See the EmptyAllowList tests below + // for the production-relevant security behavior. allowList := NewAllowList([]*operator.PublicKey{peerOperatorPublicKey}) policy := &anyApplicationPolicy{ @@ -322,6 +324,79 @@ func TestValidate_PeerIsAllowlistedNode(t *testing.T) { } } +func TestValidate_EmptyAllowList_RecognizedPeerAccepted(t *testing.T) { + _, peerOperatorPublicKey, err := operator.GenerateKeyPair( + local_v1.DefaultCurve, + ) + if err != nil { + t.Fatal(err) + } + + application := newMockApplication() + application.setIsRecognized(peerOperatorPublicKey, result{ + isRecognized: true, + err: nil, + }) + + // With EmptyAllowList, a recognized peer must pass validation through + // the IsRecognized path, not through an AllowList bypass. + policy := &anyApplicationPolicy{ + applications: []Application{application}, + allowList: EmptyAllowList, + positiveResultCache: cache.NewTimeCache(cachingPeriod), + negativeResultCache: cache.NewTimeCache(cachingPeriod), + } + + err = policy.Validate(peerOperatorPublicKey) + if err != nil { + t.Fatal(err) + } +} + +func TestValidate_EmptyAllowList_UnrecognizedPeerRejected(t *testing.T) { + _, peerOperatorPublicKey, err := operator.GenerateKeyPair( + local_v1.DefaultCurve, + ) + if err != nil { + t.Fatal(err) + } + + // With EmptyAllowList, a peer not recognized by any application must + // be rejected. No AllowList bypass is available. + policy := &anyApplicationPolicy{ + applications: []Application{newMockApplication()}, + allowList: EmptyAllowList, + positiveResultCache: cache.NewTimeCache(cachingPeriod), + negativeResultCache: cache.NewTimeCache(cachingPeriod), + } + + err = policy.Validate(peerOperatorPublicKey) + testutils.AssertErrorsSame(t, errNotRecognized, err) +} + +func TestValidate_EmptyAllowList_PreviouslyAllowlistedPeerMustPassIsRecognized(t *testing.T) { + _, peerOperatorPublicKey, err := operator.GenerateKeyPair( + local_v1.DefaultCurve, + ) + if err != nil { + t.Fatal(err) + } + + // This is the core security assertion: a peer that would have been on + // a populated AllowList (e.g., a bootstrap node) is now subject to + // standard IsRecognized staking checks when EmptyAllowList is used. + // The peer is not recognized by the application and must be rejected. + policy := &anyApplicationPolicy{ + applications: []Application{newMockApplication()}, + allowList: EmptyAllowList, + positiveResultCache: cache.NewTimeCache(cachingPeriod), + negativeResultCache: cache.NewTimeCache(cachingPeriod), + } + + err = policy.Validate(peerOperatorPublicKey) + testutils.AssertErrorsSame(t, errNotRecognized, err) +} + func newMockApplication() *mockApplication { return &mockApplication{ results: make(map[*operator.PublicKey]result), diff --git a/pkg/net/libp2p/libp2p.go b/pkg/net/libp2p/libp2p.go index 4d429a8f4f..0d8339df86 100644 --- a/pkg/net/libp2p/libp2p.go +++ b/pkg/net/libp2p/libp2p.go @@ -716,44 +716,3 @@ func multiaddressWithIdentity( ) string { return fmt.Sprintf("%s/ipfs/%s", multiaddress.String(), peerID.String()) } - -// ExtractPeersPublicKeys returns a list of operator public keys based on the -// provided list of peer addresses. Peer addresses must be in the format: -// /ipfs/ -func ExtractPeersPublicKeys(peerAddresses []string) ([]*operator.PublicKey, error) { - peerInfos, err := extractMultiAddrFromPeers(peerAddresses) - if err != nil { - return nil, fmt.Errorf( - "failed to extract multiaddress from peer addresses: [%v]", - err, - ) - } - - peersPublicKeys := make([]*operator.PublicKey, 0, len(peerInfos)) - - for _, peerInfo := range peerInfos { - peerNetworkPublicKey, err := peerInfo.ID.ExtractPublicKey() - if err != nil { - return nil, fmt.Errorf( - "failed to extract network public key for peer [%s]: [%v]", - peerInfo.ID.String(), - err, - ) - } - - peerOperatorPublicKey, err := networkPublicKeyToOperatorPublicKey( - peerNetworkPublicKey, - ) - if err != nil { - return nil, fmt.Errorf( - "failed to convert to operator public key for peer [%s]: [%v]", - peerInfo.ID.String(), - err, - ) - } - - peersPublicKeys = append(peersPublicKeys, peerOperatorPublicKey) - } - - return peersPublicKeys, nil -} diff --git a/pkg/net/libp2p/libp2p_test.go b/pkg/net/libp2p/libp2p_test.go index 2b9ec08e25..0564a65cfe 100644 --- a/pkg/net/libp2p/libp2p_test.go +++ b/pkg/net/libp2p/libp2p_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "reflect" "sort" "strings" "testing" @@ -204,103 +203,6 @@ func TestProviderSetAnnouncedAddresses(t *testing.T) { } } -func TestExtractPeersPublicKeys_EmptyList(t *testing.T) { - peerAddresses := []string{} - peerOperatorPublicKeys, err := ExtractPeersPublicKeys(peerAddresses) - if err != nil { - t.Fatal(err) - } - - if len(peerOperatorPublicKeys) != len(peerAddresses) { - t.Errorf( - "unexpected peer operator public keys length\nexpected: %v\n"+ - "actual: %v\n", - len(peerAddresses), - len(peerOperatorPublicKeys), - ) - } -} - -func TestExtractPeersPublicKeys_CorrectPeerAddresses(t *testing.T) { - peerAddresses := []string{ - "/ip4/127.0.0.1/tcp/3919/ipfs/" + - "16Uiu2HAmNpUbaz8UptSL1aWTNnR1GmcV6Pw1kSV5xkep3N44zi3m", - "/ip4/127.0.0.1/tcp/3920/ipfs/" + - "16Uiu2HAmQA19uJUtvMp7ZGCED7maXjQZCpdkLnEGCmxPRJRCvwJt", - "/ip4/127.0.0.1/tcp/3921/ipfs/" + - "16Uiu2HAm5N75v5gmMiSaR422q6RH2QfPxWVJkRjySonG3UbnmnnQ", - } - - peerOperatorPublicKeys, err := ExtractPeersPublicKeys(peerAddresses) - if err != nil { - t.Fatal(err) - } - - if len(peerOperatorPublicKeys) != len(peerAddresses) { - t.Errorf( - "unexpected peer operator public keys length\nexpected: %v\n"+ - "actual: %v\n", - len(peerAddresses), - len(peerOperatorPublicKeys), - ) - } - - // Convert to strings for easier testing - actualPeerOperatorPublicKeys := make([]string, len(peerOperatorPublicKeys)) - for i, key := range peerOperatorPublicKeys { - actualPeerOperatorPublicKeys[i] = key.String() - } - - expectedPeerOperatorPublicKeys := []string{ - "03970308f34ba0397e4a54713c126e63b8e42effcce9766d30776f24571796c39c", - "03aadf4ef0d4836404e5f06de50b05b9273e6b6b52b8c8726dae2735882d9354dd", - "0293aaeed76b0636b1c464f1c20a2f73936c175bae01a469f89c66f0e963fdc24d", - } - - if !reflect.DeepEqual( - expectedPeerOperatorPublicKeys, - actualPeerOperatorPublicKeys, - ) { - t.Errorf( - "unexpected peer operator public keys\nexpected: %v\nactual: %v\n", - expectedPeerOperatorPublicKeys, - actualPeerOperatorPublicKeys, - ) - } -} - -func TestExtractPeersPublicKeys_IncorrectPeerAddresses(t *testing.T) { - // Make the second address too short to cause an error - peerAddresses := []string{ - "/ip4/127.0.0.1/tcp/3919/ipfs/" + - "16Uiu2HAmNpUbaz8UptSL1aWTNnR1GmcV6Pw1kSV5xkep3N44zi3m", - "/ip4/127.0.0.1/tcp/3920/ipfs/" + - "16Uiu2HAmQA19uJUtvMp7ZGCED7maXjQZCpdkLnEGCmxPRJRCvwJ", - "/ip4/127.0.0.1/tcp/3921/ipfs/" + - "16Uiu2HAm5N75v5gmMiSaR422q6RH2QfPxWVJkRjySonG3UbnmnnQ", - } - - _, err := ExtractPeersPublicKeys(peerAddresses) - - expectedError := fmt.Errorf( - "failed to extract multiaddress from peer addresses: " + - "[failed to parse multiaddr \"/ip4/127.0.0.1/tcp/3920/ipfs/" + - "16Uiu2HAmQA19uJUtvMp7ZGCED7maXjQZCpdkLnEGCmxPRJRCvwJ\": " + - "invalid value " + - "\"16Uiu2HAmQA19uJUtvMp7ZGCED7maXjQZCpdkLnEGCmxPRJRCvwJ\" " + - "for protocol p2p: failed to parse p2p addr: " + - "16Uiu2HAmQA19uJUtvMp7ZGCED7maXjQZCpdkLnEGCmxPRJRCvwJ " + - "length greater than remaining number of bytes in buffer]", - ) - if !reflect.DeepEqual(expectedError, err) { - t.Errorf( - "unexpected error\nexpected: %v\nactual: %v\n", - expectedError, - err, - ) - } -} - type testMessage struct { Sender *identity Recipient *identity From f097b5e610d21adb89754f60cee4f2f34734bf98 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Wed, 25 Mar 2026 01:27:51 -0300 Subject: [PATCH 02/22] chore: remove migration guide from release branch Operator migration guidance will be distributed separately from the code release. --- docs/network-peer-migration-guide.md | 218 --------------------------- 1 file changed, 218 deletions(-) delete mode 100644 docs/network-peer-migration-guide.md diff --git a/docs/network-peer-migration-guide.md b/docs/network-peer-migration-guide.md deleted file mode 100644 index 3d336e003d..0000000000 --- a/docs/network-peer-migration-guide.md +++ /dev/null @@ -1,218 +0,0 @@ -# Network Peer Migration Guide - -## Quick Action - -**If you have NOT manually configured `--network.peers` or `[LibP2P].Peers`**, -no action is needed. Your node uses embedded defaults that are updated -automatically with each client release. - -**If you HAVE manually configured peers**, check your configuration for any -address containing `boar.network` or `staked.cloud` and remove it. The -recommended fix is to remove the `--network.peers` / `[LibP2P].Peers` setting -entirely so your node uses the new built-in defaults. See -[Required Action](#required-action-remove-hardcoded-boar-addresses) below. - -> **Warning**: If you do nothing and your custom peer list contains only Boar -> addresses, your node will lose network connectivity when Boar infrastructure -> is decommissioned. - ---- - -## Summary of Changes - -The keep-client embedded peer list has been updated to replace Boar bootstrap -node addresses with curated operator-run peers. - -The following changes are included in this release: - -- **Boar bootstrap addresses removed** from embedded peer lists (mainnet and - testnet). The Boar infrastructure (`bst-*.boar.network`) is being - decommissioned. -- **New curated operator peers** are now embedded as defaults, hosted at - `keep-nodes.io` (mainnet) and `test.keep-nodes.io` (testnet). -- **Firewall validation strengthened**: all peers are now validated through - on-chain staking checks. Previously, embedded peer public keys bypassed - staking validation via a firewall allow-list. -- **`--network.bootstrap` flag deprecated**: the flag still functions but will - be removed in a future release. -- **Metric renamed**: `connected_bootstrap_count` has been renamed to - `connected_wellknown_peers_count`. - -Most operators do not need to take any action. If you have manually configured -`--network.peers` or `[LibP2P].Peers` in your configuration, read the next -section carefully. - ---- - -## Required Action: Remove Hardcoded Boar Addresses - -**If your node is configured with `--network.peers` (CLI flag) or -`[LibP2P].Peers` (configuration file), you must review and update your -configuration before Boar infrastructure is decommissioned.** Failure to act -will result in your node being unable to discover peers on the network. - -### How to Check if You Are Affected - -1. **Check your startup command** for the `--network.peers` flag: - ``` - --network.peers=/dns4/bst-a01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm... - ``` - -2. **Check your configuration file** (TOML, YAML, or JSON) for a `Peers` - entry under the `[LibP2P]` section: - ```toml - [LibP2P] - Peers = [ - "/dns4/bst-a01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm...", - "/dns4/bst-b01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm..." - ] - ``` - -3. **If neither is set**, you are using the embedded defaults and no action is - needed. The new operator peers are included automatically. - -### Boar Addresses to Remove - -Remove any peer address whose hostname contains `boar.network` or -`staked.cloud`. The specific Boar hostnames being decommissioned are: - -| Network | Hostname | -|---------|----------| -| Mainnet | `bst-a01.tbtc.boar.network` | -| Mainnet | `bst-b01.tbtc.boar.network` | -| Testnet | `bst-a01.test.keep.boar.network` | - -The peer ID suffix (the `/ipfs/16Uiu2HAm...` portion) varies, but the hostname -is the identifying part. Any multiaddress containing one of the hostnames above -must be removed. - -### What to Do - -**Option A (recommended):** Remove `--network.peers` / `[LibP2P].Peers` -entirely. Your node will then use the new embedded defaults, which are -maintained and updated with each client release. - -Before: -```toml -[LibP2P] - Peers = [ - "/dns4/bst-a01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm...", - "/dns4/bst-b01.tbtc.boar.network/tcp/3919/ipfs/16Uiu2HAm..." - ] -``` - -After: -```toml -[LibP2P] - # Peers removed -- the client now uses built-in operator peer defaults. -``` - -Or, if using the CLI flag, simply remove `--network.peers` from your startup -command. - -**Option B:** Replace the Boar addresses with currently active operator peer -addresses. Only use this option if you have a specific reason to maintain a -custom peer list. - -### Why This Matters - -When you set `--network.peers` (or `[LibP2P].Peers` in your configuration -file), the client uses your list exclusively and ignores the built-in defaults. - -Internally, the `resolvePeers()` function in `config/peers.go` checks whether -`LibP2P.Peers` is already populated. If it is, the function returns immediately -without loading the embedded peer list. This means manually configured peers -completely override the defaults -- there is no merging. - -If your custom peer list contains only Boar addresses, your node will be unable -to discover any peers once Boar infrastructure is decommissioned. - ---- - -## Bootstrap Flag Deprecation - -The `--network.bootstrap=true` flag is deprecated and will be removed in a -future release. - -Historically, this flag marked a node as a bootstrap/relay node, which enabled -special behaviors such as adjusted dissemination timing and self-dial skipping. -As the network has matured, dedicated bootstrap mode is no longer necessary for -standard peer discovery. - -The flag still functions and the node will log a deprecation warning when it is -used. Operators currently running with `--network.bootstrap=true` should plan -to stop using it. If you need to adjust dissemination timing directly, use the -`--network.disseminationTime` flag instead. - ---- - -## Metric Rename - -The `connected_bootstrap_count` metric has been renamed to -`connected_wellknown_peers_count`. - -The metric semantics are unchanged -- it tracks the number of currently -connected well-known peers that are embedded in the client. Only the name has -changed to more accurately reflect that these peers are curated operator nodes -rather than traditional bootstrap nodes. - -If you have Grafana dashboards, Prometheus alerts, or other monitoring that -references the old metric name, update your queries: - -| Before | After | -|--------|-------| -| `connected_bootstrap_count` | `connected_wellknown_peers_count` | - -The metric is exposed on the Client Info HTTP endpoint (port 9601 by default). - ---- - -## Technical Background - -This section provides additional context for operators who want to understand -the underlying mechanisms. - -### Embedded Peer Resolution - -The keep-client binary embeds a default peer list at compile time using Go's -`embed` package. The peer addresses are stored in the `config/_peers/` directory -with separate files for each network (mainnet, testnet). - -When the client starts, `resolvePeers()` in `config/peers.go` runs the -following logic: - -1. If `LibP2P.Peers` is already populated (via `--network.peers` flag or - configuration file), return immediately without changes. -2. If the network type is `developer` or `unknown`, log a warning and return - (no embedded defaults for these networks). -3. Otherwise, read the embedded peer list for the current network and set - `LibP2P.Peers` to those values. - -This design means that manually configured peers always take precedence over -embedded defaults. There is no merging of the two lists. - -### Firewall Allow-List Change - -Previously, the public keys of embedded peers were added to a firewall -allow-list, which allowed them to bypass the `IsRecognized()` on-chain staking -validation. This was a convenience for bootstrap infrastructure but weakened -the security model. - -With this release, the allow-list is no longer populated with embedded peer -keys. All peers -- including the embedded operator peers -- must pass staking -validation through the on-chain contracts. This ensures that only properly -staked operators can participate in the network. - -### Network Discovery Flow - -The overall peer discovery architecture follows this path: - -1. **Embedded peers** provide initial connectivity (the addresses in - `config/_peers/`). -2. **libp2p connections** are established to these well-known peers. -3. **DHT discovery** uses these initial connections to find additional peers - across the network. -4. **Full mesh connectivity** is established as more peers are discovered. - -Removing Boar addresses from the embedded list and replacing them with active -operator peers ensures that step 1 connects to reliable, staked infrastructure. From 25c1abc1a87a399a74fa369f3a3fa8471941531d Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Wed, 25 Mar 2026 01:52:35 -0300 Subject: [PATCH 03/22] feat(config): set beta staker as mainnet embedded peer Replace placeholder mainnet entries with the beta staker node (143.198.18.229:3919) as the sole embedded peer for initial testing. Testnet placeholders remain until operator coordination is complete. --- config/_peers/mainnet | 6 +----- config/peers_test.go | 10 +++------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/config/_peers/mainnet b/config/_peers/mainnet index 676524e6b9..b6487ed449 100644 --- a/config/_peers/mainnet +++ b/config/_peers/mainnet @@ -1,5 +1 @@ -/dns4/keep-operator-1.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmVUxCz2YjBpGaGirVLx6RGtHbPg5rygEWMPoUFE4bHTkr -/dns4/keep-operator-2.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAm8bLqTcGMDFaNPGPC6gxStKCnJr2DaVsMbce1ZEyaKo9S -/dns4/keep-operator-3.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmQLCwPnNmFMDQkc5hLfapGKtXPvFJQKB3rUFYa1wjVnfi -/dns4/keep-operator-4.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmTv4atEFadTVPz7BWhE3gRFMeJ5Kk4LQfgN2V8ViWYFRx -/dns4/keep-operator-5.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmPwQuywYq9qFRn8gLCtiKaDZwg2u3JQhWia7RYHRdfk1r +/ip4/143.198.18.229/tcp/3919/ipfs/16Uiu2HAmDP4Z6LCogRMictJ6deGs4DRo99A5JTz5u3CLMg7URxC6 diff --git a/config/peers_test.go b/config/peers_test.go index 9bcaa0ddf0..ae8ce1d629 100644 --- a/config/peers_test.go +++ b/config/peers_test.go @@ -18,17 +18,13 @@ func TestResolvePeers(t *testing.T) { "mainnet network": { network: network.Mainnet, expectedPeers: []string{ - "/dns4/keep-operator-1.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmVUxCz2YjBpGaGirVLx6RGtHbPg5rygEWMPoUFE4bHTkr", - "/dns4/keep-operator-2.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAm8bLqTcGMDFaNPGPC6gxStKCnJr2DaVsMbce1ZEyaKo9S", - "/dns4/keep-operator-3.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmQLCwPnNmFMDQkc5hLfapGKtXPvFJQKB3rUFYa1wjVnfi", - "/dns4/keep-operator-4.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmTv4atEFadTVPz7BWhE3gRFMeJ5Kk4LQfgN2V8ViWYFRx", - "/dns4/keep-operator-5.keep-nodes.io/tcp/3919/ipfs/16Uiu2HAmPwQuywYq9qFRn8gLCtiKaDZwg2u3JQhWia7RYHRdfk1r", + "/ip4/143.198.18.229/tcp/3919/ipfs/16Uiu2HAmDP4Z6LCogRMictJ6deGs4DRo99A5JTz5u3CLMg7URxC6", }}, "sepolia network": { network: network.Testnet, expectedPeers: []string{ - "/dns4/keep-operator-1.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL", - "/dns4/keep-operator-2.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa", + "/dns4/PLACEHOLDER-operator-1.test.example.com/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL", + "/dns4/PLACEHOLDER-operator-2.test.example.com/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa", }, }, "developer network": { From c5faea7c75a0590ad1dad07f7b1d89ddbf9d50d1 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:17:14 +0100 Subject: [PATCH 04/22] fix(ci): run Go tests across all packages gotestsum's default `./...` package pattern only applies when no args are passed after `--`. With `-- -timeout 15m`, it forwards args directly to `go test`, which defaults to `.` (root package only). The root package has no test files, so CI has been silently running 0 tests. --- .github/workflows/client.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index f719505eee..95d80c5958 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -134,7 +134,7 @@ jobs: docker run \ --workdir /go/src/github.com/keep-network/keep-core \ go-build-env \ - gotestsum -- -timeout 15m + gotestsum -- -timeout 15m ./... - name: Build Docker Runtime Image if: github.event_name != 'workflow_dispatch' From c33c21867825dfc589a12cbb181040c6c47e4b64 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:40:56 +0100 Subject: [PATCH 05/22] fix(config): correct testnet peer hostnames in test expectations Replace placeholder hostnames with actual values from config/_peers/testnet. --- config/peers_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/peers_test.go b/config/peers_test.go index ae8ce1d629..c311d0bd7f 100644 --- a/config/peers_test.go +++ b/config/peers_test.go @@ -23,8 +23,8 @@ func TestResolvePeers(t *testing.T) { "sepolia network": { network: network.Testnet, expectedPeers: []string{ - "/dns4/PLACEHOLDER-operator-1.test.example.com/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL", - "/dns4/PLACEHOLDER-operator-2.test.example.com/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa", + "/dns4/keep-operator-1.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL", + "/dns4/keep-operator-2.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa", }, }, "developer network": { From 68e8c8df5bb6b7cb88686eb9a625b8e8418d3794 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:43:51 +0100 Subject: [PATCH 06/22] refactor(clientinfo): remove over-specified metric constant tests Remove TestConnectedWellknownPeersCountMetricName and TestMetricConstants which only assert that string constants equal themselves. The compiler already ensures rename safety. Keep the callable integration test which validates the function exists and executes without panicking. --- pkg/clientinfo/metrics_test.go | 68 ---------------------------------- 1 file changed, 68 deletions(-) diff --git a/pkg/clientinfo/metrics_test.go b/pkg/clientinfo/metrics_test.go index 19082f81c7..18769a8e0d 100644 --- a/pkg/clientinfo/metrics_test.go +++ b/pkg/clientinfo/metrics_test.go @@ -10,74 +10,6 @@ import ( "github.com/keep-network/keep-core/pkg/operator" ) -// TestConnectedWellknownPeersCountMetricName verifies that the metric constant -// for well-known peers connectivity has the correct string value used by -// Prometheus for metric registration. -func TestConnectedWellknownPeersCountMetricName(t *testing.T) { - expected := "connected_wellknown_peers_count" - actual := ConnectedWellknownPeersCountMetricName - - if actual != expected { - t.Errorf( - "expected metric name %q, got %q", - expected, - actual, - ) - } -} - -// TestMetricConstants verifies that all metric name constants are defined with -// the expected non-empty string values. This ensures no accidental changes to -// metric names that would break Prometheus queries and Grafana dashboards. -func TestMetricConstants(t *testing.T) { - tests := []struct { - name string - constant string - expected string - }{ - { - name: "connected peers count", - constant: ConnectedPeersCountMetricName, - expected: "connected_peers_count", - }, - { - name: "connected wellknown peers count", - constant: ConnectedWellknownPeersCountMetricName, - expected: "connected_wellknown_peers_count", - }, - { - name: "eth connectivity", - constant: EthConnectivityMetricName, - expected: "eth_connectivity", - }, - { - name: "btc connectivity", - constant: BtcConnectivityMetricName, - expected: "btc_connectivity", - }, - { - name: "client info", - constant: ClientInfoMetricName, - expected: "client_info", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.constant != tc.expected { - t.Errorf( - "expected metric name %q, got %q", - tc.expected, - tc.constant, - ) - } - if tc.constant == "" { - t.Error("metric name constant must not be empty") - } - }) - } -} - // mockTransportIdentifier implements net.TransportIdentifier for testing. type mockTransportIdentifier struct{} From 16ea00ffba35b8764c06dfff9f0bd2b4dfbeec82 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:44:49 +0100 Subject: [PATCH 07/22] refactor(firewall): make EmptyAllowList a function instead of exported var Change EmptyAllowList from an exported mutable package-level var to an exported function returning the package-level singleton. This prevents external code from accidentally mutating the shared empty allowlist. --- cmd/start.go | 2 +- pkg/firewall/firewall.go | 12 ++++++++++-- pkg/firewall/firewall_test.go | 28 ++++++++++++++-------------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 7be632c086..4a1726977b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -202,7 +202,7 @@ func initializeNetwork( ) (net.Provider, error) { firewall := firewall.AnyApplicationPolicy( applications, - firewall.EmptyAllowList, + firewall.EmptyAllowList(), ) netProvider, err := libp2p.Connect( diff --git a/pkg/firewall/firewall.go b/pkg/firewall/firewall.go index f657870659..d1f26489d8 100644 --- a/pkg/firewall/firewall.go +++ b/pkg/firewall/firewall.go @@ -48,8 +48,16 @@ func (al *AllowList) Contains(operatorPublicKey *operator.PublicKey) bool { return al.allowedPublicKeys[operatorPublicKey.String()] } -// EmptyAllowList represents an empty firewall allowlist. -var EmptyAllowList = NewAllowList([]*operator.PublicKey{}) +// emptyAllowList is the singleton empty allowlist used in production. +// All peers must pass IsRecognized checks; no bypass is available. +var emptyAllowList = NewAllowList([]*operator.PublicKey{}) + +// EmptyAllowList returns the empty firewall allowlist. In production, this +// ensures all peers are subject to on-chain staking verification with no +// AllowList bypass. +func EmptyAllowList() *AllowList { + return emptyAllowList +} const ( // PositiveIsRecognizedCachePeriod is the time period the cache maintains diff --git a/pkg/firewall/firewall_test.go b/pkg/firewall/firewall_test.go index e05dd13e0e..8598411120 100644 --- a/pkg/firewall/firewall_test.go +++ b/pkg/firewall/firewall_test.go @@ -16,7 +16,7 @@ const cachingPeriod = time.Second func TestValidate_PeerNotRecognized_NoApplications(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -44,7 +44,7 @@ func TestValidate_PeerNotRecognized_MultipleApplications(t *testing.T) { applications: []Application{ newMockApplication(), newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -71,7 +71,7 @@ func TestValidate_PeerRecognized_FirstApplicationRecognizes(t *testing.T) { applications: []Application{ application, newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -100,7 +100,7 @@ func TestValidate_PeerRecognized_SecondApplicationRecognizes(t *testing.T) { applications: []Application{ newMockApplication(), application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -139,7 +139,7 @@ func TestValidate_PeerNotRecognized_FirstApplicationReturnedError(t *testing.T) applications: []Application{ application1, application2}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -164,7 +164,7 @@ func TestValidate_PeerRecognized_Cached(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -203,7 +203,7 @@ func TestValidate_PeerNotRecognized_CacheEmptied(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -238,7 +238,7 @@ func TestValidate_PeerNotRecognized_Cached(t *testing.T) { application := newMockApplication() policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -273,7 +273,7 @@ func TestValidate_PeerRecognized_CacheEmptied(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -338,11 +338,11 @@ func TestValidate_EmptyAllowList_RecognizedPeerAccepted(t *testing.T) { err: nil, }) - // With EmptyAllowList, a recognized peer must pass validation through + // With EmptyAllowList(), a recognized peer must pass validation through // the IsRecognized path, not through an AllowList bypass. policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -361,11 +361,11 @@ func TestValidate_EmptyAllowList_UnrecognizedPeerRejected(t *testing.T) { t.Fatal(err) } - // With EmptyAllowList, a peer not recognized by any application must + // With EmptyAllowList(), a peer not recognized by any application must // be rejected. No AllowList bypass is available. policy := &anyApplicationPolicy{ applications: []Application{newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -388,7 +388,7 @@ func TestValidate_EmptyAllowList_PreviouslyAllowlistedPeerMustPassIsRecognized(t // The peer is not recognized by the application and must be rejected. policy := &anyApplicationPolicy{ applications: []Application{newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } From 3b1aee70bd97131059a0c53faaf83b4f6b37afca Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:45:09 +0100 Subject: [PATCH 08/22] docs(clientinfo): document metric rename from connected_bootstrap_count Add a note that connected_wellknown_peers_count was previously named connected_bootstrap_count, so operators can update Prometheus queries and Grafana dashboards accordingly. --- pkg/clientinfo/metrics.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/clientinfo/metrics.go b/pkg/clientinfo/metrics.go index 9398fd48ab..91af36bcd8 100644 --- a/pkg/clientinfo/metrics.go +++ b/pkg/clientinfo/metrics.go @@ -14,6 +14,10 @@ import ( type Source func() float64 // Names under which metrics are exposed. +// +// NOTE: ConnectedWellknownPeersCountMetricName was renamed from +// "connected_bootstrap_count" in v2.6.0. Update any Prometheus queries or +// Grafana dashboards that reference the old name. const ( ConnectedPeersCountMetricName = "connected_peers_count" ConnectedWellknownPeersCountMetricName = "connected_wellknown_peers_count" From 8e7712e61911bab2165ccdaf984f76bfb207f68a Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:45:30 +0100 Subject: [PATCH 09/22] chore(cmd): add v3.0 removal timeline for deprecated bootstrap flag Specify concrete removal version so the deprecated flag does not linger indefinitely. --- cmd/flags.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/flags.go b/cmd/flags.go index 4a5916eab1..1de77bffff 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -202,11 +202,13 @@ func initBitcoinElectrumFlags(cmd *cobra.Command, cfg *config.Config) { // Initialize flags for Network configuration. func initNetworkFlags(cmd *cobra.Command, cfg *config.Config) { + // TODO: Remove in v3.0.0 along with isBootstrap() in start.go and + // the LibP2P.Bootstrap config field. cmd.Flags().BoolVar( &cfg.LibP2P.Bootstrap, "network.bootstrap", false, - "[DEPRECATED] Run the client in bootstrap mode. This flag is deprecated and will be removed in a future release.", + "[DEPRECATED: remove in v3.0] Run the client in bootstrap mode. This flag is deprecated and will be removed in v3.0.", ) cmd.Flags().StringSliceVar( From 834b63ae335957ff3a8d86e4a1ac86d302671332 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:46:16 +0100 Subject: [PATCH 10/22] docs(config): document mainnet single-peer SPOF risk Add a TODO comment noting that at least one additional mainnet peer across a different operator/ASN should be added before production rollout to avoid a single point of failure for initial peer discovery. --- config/_peers/mainnet | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/_peers/mainnet b/config/_peers/mainnet index b6487ed449..e5020c4207 100644 --- a/config/_peers/mainnet +++ b/config/_peers/mainnet @@ -1 +1,4 @@ +# TODO: Add at least one additional mainnet peer across a different +# operator/ASN before production rollout. A single peer is a SPOF for +# initial peer discovery of fresh nodes. /ip4/143.198.18.229/tcp/3919/ipfs/16Uiu2HAmDP4Z6LCogRMictJ6deGs4DRo99A5JTz5u3CLMg7URxC6 From 9508fb515d4928817993eff463f4a0c850b10638 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 11:15:09 +0100 Subject: [PATCH 11/22] fix(tbtcpg): use format string in fmt.Errorf to fix go vet error Go 1.24 vet rejects non-constant format strings in fmt.Errorf. This pre-existing issue was hidden because CI was not running tests. --- pkg/tbtcpg/internal/test/marshaling.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 2dd72dbaa0..5a1d172ee0 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -273,7 +273,7 @@ func (psts *ProposeSweepTestScenario) UnmarshalJSON(data []byte) error { // Unmarshal expected error if len(unmarshaled.ExpectedErr) > 0 { - psts.ExpectedErr = fmt.Errorf(unmarshaled.ExpectedErr) + psts.ExpectedErr = fmt.Errorf("%s", unmarshaled.ExpectedErr) } return nil From 57f535890dc7a5f3a667ebfe343751906a835124 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 26 Mar 2026 09:40:43 +0100 Subject: [PATCH 12/22] test(tbtc): stabilize coordination layer assertion --- go.mod | 1 + pkg/tbtc/node_test.go | 73 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 802a5e4a2e..3b604d831e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24 toolchain go1.24.1 + replace ( github.com/bnb-chain/tss-lib => github.com/threshold-network/tss-lib v0.0.0-20230901144531-2e712689cfbe // btcd in version v.0.23 extracted `btcd/btcec` to a separate package `btcd/btcec/v2`. diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index bedfb30995..5a907b89b4 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -344,22 +344,27 @@ func TestNode_RunCoordinationLayer(t *testing.T) { if signer.wallet.publicKey.Equal(walletPublicKey) { result, ok := map[uint64]*coordinationResult{ 900: { + window: window, proposal: &mockCoordinationProposal{ActionDepositSweep}, }, // Omit window at block 1800 to make sure the layer doesn't // crash if no result is produced. 2700: { + window: window, proposal: &mockCoordinationProposal{ActionRedemption}, }, // Put some trash value to make sure coordination windows // are distributed correctly. 2705: { + window: window, proposal: &mockCoordinationProposal{ActionMovingFunds}, }, 3600: { + window: window, proposal: &mockCoordinationProposal{ActionNoop}, }, 4500: { + window: window, proposal: &mockCoordinationProposal{ActionMovedFundsSweep}, }, }[window.coordinationBlock] @@ -405,6 +410,10 @@ loop: for { select { case result := <-processedResultsChan: + if result == nil { + continue + } + processedResults = append(processedResults, result) // Once the second-last coordination window is processed, stop the @@ -425,24 +434,68 @@ loop: 3, len(processedResults), ) - testutils.AssertStringsEqual( + + resultActionsByWindow := make(map[uint64]WalletActionType, len(processedResults)) + for _, result := range processedResults { + resultActionsByWindow[result.window.coordinationBlock] = + result.proposal.ActionType() + } + + testutils.AssertIntsEqual( t, - "first result", - ActionDepositSweep.String(), - processedResults[0].proposal.ActionType().String(), + "processed coordination windows count", + 3, + len(resultActionsByWindow), ) + + firstAction, ok := resultActionsByWindow[900] + if !ok { + t.Fatal("expected coordination result for window at block 900") + } testutils.AssertStringsEqual( t, - "second result", - ActionRedemption.String(), - processedResults[1].proposal.ActionType().String(), + "result for block 900", + ActionDepositSweep.String(), + firstAction.String(), ) + + secondAction, ok := resultActionsByWindow[2700] + if !ok { + t.Fatal("expected coordination result for window at block 2700") + } testutils.AssertStringsEqual( t, - "third result", - ActionNoop.String(), - processedResults[2].proposal.ActionType().String(), + "result for block 2700", + ActionRedemption.String(), + secondAction.String(), ) + + if _, ok := resultActionsByWindow[2705]; ok { + t.Fatal("unexpected coordination result for non-window block 2705") + } + + // Result processing is asynchronous, so by the time the test cancels the + // coordination layer after the third processed result, either the 3600 + // window or the subsequent 4500 window may already be in flight. + if thirdAction, ok := resultActionsByWindow[3600]; ok { + testutils.AssertStringsEqual( + t, + "result for block 3600", + ActionNoop.String(), + thirdAction.String(), + ) + } else { + fourthAction, ok := resultActionsByWindow[4500] + if !ok { + t.Fatal("expected coordination result for block 3600 or 4500") + } + testutils.AssertStringsEqual( + t, + "result for block 4500", + ActionMovedFundsSweep.String(), + fourthAction.String(), + ) + } } type mockCoordinationProposal struct { From 14792f6e9f14e6609ab725558208c9124cf7e7a3 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:17:14 +0100 Subject: [PATCH 13/22] fix(ci): run Go tests across all packages gotestsum's default `./...` package pattern only applies when no args are passed after `--`. With `-- -timeout 15m`, it forwards args directly to `go test`, which defaults to `.` (root package only). The root package has no test files, so CI has been silently running 0 tests. --- .github/workflows/client.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index f719505eee..95d80c5958 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -134,7 +134,7 @@ jobs: docker run \ --workdir /go/src/github.com/keep-network/keep-core \ go-build-env \ - gotestsum -- -timeout 15m + gotestsum -- -timeout 15m ./... - name: Build Docker Runtime Image if: github.event_name != 'workflow_dispatch' From 139c7e14b375bdc7653b81bcf047b1a5fe9e2e1e Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:40:56 +0100 Subject: [PATCH 14/22] fix(config): correct testnet peer hostnames in test expectations Replace placeholder hostnames with actual values from config/_peers/testnet. --- config/peers_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/peers_test.go b/config/peers_test.go index ae8ce1d629..c311d0bd7f 100644 --- a/config/peers_test.go +++ b/config/peers_test.go @@ -23,8 +23,8 @@ func TestResolvePeers(t *testing.T) { "sepolia network": { network: network.Testnet, expectedPeers: []string{ - "/dns4/PLACEHOLDER-operator-1.test.example.com/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL", - "/dns4/PLACEHOLDER-operator-2.test.example.com/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa", + "/dns4/keep-operator-1.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAmDrk2Bh4VNPUJfKRHTE2CvH9xfKzN4KFnmRJbGLkJFDqL", + "/dns4/keep-operator-2.test.keep-nodes.io/tcp/3920/ipfs/16Uiu2HAm3ex8rGzwFpWYbRreRUiX9JEYCKxp7KDMzB8RZ6fQWnMa", }, }, "developer network": { From 6b43715a109ea94adeea23928e0bbe05dcf3a3d2 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:43:51 +0100 Subject: [PATCH 15/22] refactor(clientinfo): remove over-specified metric constant tests Remove TestConnectedWellknownPeersCountMetricName and TestMetricConstants which only assert that string constants equal themselves. The compiler already ensures rename safety. Keep the callable integration test which validates the function exists and executes without panicking. --- pkg/clientinfo/metrics_test.go | 68 ---------------------------------- 1 file changed, 68 deletions(-) diff --git a/pkg/clientinfo/metrics_test.go b/pkg/clientinfo/metrics_test.go index 19082f81c7..18769a8e0d 100644 --- a/pkg/clientinfo/metrics_test.go +++ b/pkg/clientinfo/metrics_test.go @@ -10,74 +10,6 @@ import ( "github.com/keep-network/keep-core/pkg/operator" ) -// TestConnectedWellknownPeersCountMetricName verifies that the metric constant -// for well-known peers connectivity has the correct string value used by -// Prometheus for metric registration. -func TestConnectedWellknownPeersCountMetricName(t *testing.T) { - expected := "connected_wellknown_peers_count" - actual := ConnectedWellknownPeersCountMetricName - - if actual != expected { - t.Errorf( - "expected metric name %q, got %q", - expected, - actual, - ) - } -} - -// TestMetricConstants verifies that all metric name constants are defined with -// the expected non-empty string values. This ensures no accidental changes to -// metric names that would break Prometheus queries and Grafana dashboards. -func TestMetricConstants(t *testing.T) { - tests := []struct { - name string - constant string - expected string - }{ - { - name: "connected peers count", - constant: ConnectedPeersCountMetricName, - expected: "connected_peers_count", - }, - { - name: "connected wellknown peers count", - constant: ConnectedWellknownPeersCountMetricName, - expected: "connected_wellknown_peers_count", - }, - { - name: "eth connectivity", - constant: EthConnectivityMetricName, - expected: "eth_connectivity", - }, - { - name: "btc connectivity", - constant: BtcConnectivityMetricName, - expected: "btc_connectivity", - }, - { - name: "client info", - constant: ClientInfoMetricName, - expected: "client_info", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.constant != tc.expected { - t.Errorf( - "expected metric name %q, got %q", - tc.expected, - tc.constant, - ) - } - if tc.constant == "" { - t.Error("metric name constant must not be empty") - } - }) - } -} - // mockTransportIdentifier implements net.TransportIdentifier for testing. type mockTransportIdentifier struct{} From df8a47d1f0cb4449b08e816d048a573264a0b63d Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:44:49 +0100 Subject: [PATCH 16/22] refactor(firewall): make EmptyAllowList a function instead of exported var Change EmptyAllowList from an exported mutable package-level var to an exported function returning the package-level singleton. This prevents external code from accidentally mutating the shared empty allowlist. --- cmd/start.go | 2 +- pkg/firewall/firewall.go | 12 ++++++++++-- pkg/firewall/firewall_test.go | 28 ++++++++++++++-------------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/cmd/start.go b/cmd/start.go index 7be632c086..4a1726977b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -202,7 +202,7 @@ func initializeNetwork( ) (net.Provider, error) { firewall := firewall.AnyApplicationPolicy( applications, - firewall.EmptyAllowList, + firewall.EmptyAllowList(), ) netProvider, err := libp2p.Connect( diff --git a/pkg/firewall/firewall.go b/pkg/firewall/firewall.go index f657870659..d1f26489d8 100644 --- a/pkg/firewall/firewall.go +++ b/pkg/firewall/firewall.go @@ -48,8 +48,16 @@ func (al *AllowList) Contains(operatorPublicKey *operator.PublicKey) bool { return al.allowedPublicKeys[operatorPublicKey.String()] } -// EmptyAllowList represents an empty firewall allowlist. -var EmptyAllowList = NewAllowList([]*operator.PublicKey{}) +// emptyAllowList is the singleton empty allowlist used in production. +// All peers must pass IsRecognized checks; no bypass is available. +var emptyAllowList = NewAllowList([]*operator.PublicKey{}) + +// EmptyAllowList returns the empty firewall allowlist. In production, this +// ensures all peers are subject to on-chain staking verification with no +// AllowList bypass. +func EmptyAllowList() *AllowList { + return emptyAllowList +} const ( // PositiveIsRecognizedCachePeriod is the time period the cache maintains diff --git a/pkg/firewall/firewall_test.go b/pkg/firewall/firewall_test.go index e05dd13e0e..8598411120 100644 --- a/pkg/firewall/firewall_test.go +++ b/pkg/firewall/firewall_test.go @@ -16,7 +16,7 @@ const cachingPeriod = time.Second func TestValidate_PeerNotRecognized_NoApplications(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -44,7 +44,7 @@ func TestValidate_PeerNotRecognized_MultipleApplications(t *testing.T) { applications: []Application{ newMockApplication(), newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -71,7 +71,7 @@ func TestValidate_PeerRecognized_FirstApplicationRecognizes(t *testing.T) { applications: []Application{ application, newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -100,7 +100,7 @@ func TestValidate_PeerRecognized_SecondApplicationRecognizes(t *testing.T) { applications: []Application{ newMockApplication(), application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -139,7 +139,7 @@ func TestValidate_PeerNotRecognized_FirstApplicationReturnedError(t *testing.T) applications: []Application{ application1, application2}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -164,7 +164,7 @@ func TestValidate_PeerRecognized_Cached(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -203,7 +203,7 @@ func TestValidate_PeerNotRecognized_CacheEmptied(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -238,7 +238,7 @@ func TestValidate_PeerNotRecognized_Cached(t *testing.T) { application := newMockApplication() policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -273,7 +273,7 @@ func TestValidate_PeerRecognized_CacheEmptied(t *testing.T) { policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -338,11 +338,11 @@ func TestValidate_EmptyAllowList_RecognizedPeerAccepted(t *testing.T) { err: nil, }) - // With EmptyAllowList, a recognized peer must pass validation through + // With EmptyAllowList(), a recognized peer must pass validation through // the IsRecognized path, not through an AllowList bypass. policy := &anyApplicationPolicy{ applications: []Application{application}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -361,11 +361,11 @@ func TestValidate_EmptyAllowList_UnrecognizedPeerRejected(t *testing.T) { t.Fatal(err) } - // With EmptyAllowList, a peer not recognized by any application must + // With EmptyAllowList(), a peer not recognized by any application must // be rejected. No AllowList bypass is available. policy := &anyApplicationPolicy{ applications: []Application{newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } @@ -388,7 +388,7 @@ func TestValidate_EmptyAllowList_PreviouslyAllowlistedPeerMustPassIsRecognized(t // The peer is not recognized by the application and must be rejected. policy := &anyApplicationPolicy{ applications: []Application{newMockApplication()}, - allowList: EmptyAllowList, + allowList: EmptyAllowList(), positiveResultCache: cache.NewTimeCache(cachingPeriod), negativeResultCache: cache.NewTimeCache(cachingPeriod), } From b3ad46d6cd2e5634aa5679d946c636f94e8e67b0 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:45:09 +0100 Subject: [PATCH 17/22] docs(clientinfo): document metric rename from connected_bootstrap_count Add a note that connected_wellknown_peers_count was previously named connected_bootstrap_count, so operators can update Prometheus queries and Grafana dashboards accordingly. --- pkg/clientinfo/metrics.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/clientinfo/metrics.go b/pkg/clientinfo/metrics.go index 9398fd48ab..91af36bcd8 100644 --- a/pkg/clientinfo/metrics.go +++ b/pkg/clientinfo/metrics.go @@ -14,6 +14,10 @@ import ( type Source func() float64 // Names under which metrics are exposed. +// +// NOTE: ConnectedWellknownPeersCountMetricName was renamed from +// "connected_bootstrap_count" in v2.6.0. Update any Prometheus queries or +// Grafana dashboards that reference the old name. const ( ConnectedPeersCountMetricName = "connected_peers_count" ConnectedWellknownPeersCountMetricName = "connected_wellknown_peers_count" From ff4888213f5d6b5bbda7a3ad59b5cdb610ff1e07 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:45:30 +0100 Subject: [PATCH 18/22] chore(cmd): add v3.0 removal timeline for deprecated bootstrap flag Specify concrete removal version so the deprecated flag does not linger indefinitely. --- cmd/flags.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/flags.go b/cmd/flags.go index 4a5916eab1..1de77bffff 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -202,11 +202,13 @@ func initBitcoinElectrumFlags(cmd *cobra.Command, cfg *config.Config) { // Initialize flags for Network configuration. func initNetworkFlags(cmd *cobra.Command, cfg *config.Config) { + // TODO: Remove in v3.0.0 along with isBootstrap() in start.go and + // the LibP2P.Bootstrap config field. cmd.Flags().BoolVar( &cfg.LibP2P.Bootstrap, "network.bootstrap", false, - "[DEPRECATED] Run the client in bootstrap mode. This flag is deprecated and will be removed in a future release.", + "[DEPRECATED: remove in v3.0] Run the client in bootstrap mode. This flag is deprecated and will be removed in v3.0.", ) cmd.Flags().StringSliceVar( From 3f5307ef0bfbd18eb0e5e7d34db8491caf153eaa Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 10:46:16 +0100 Subject: [PATCH 19/22] docs(config): document mainnet single-peer SPOF risk Add a TODO comment noting that at least one additional mainnet peer across a different operator/ASN should be added before production rollout to avoid a single point of failure for initial peer discovery. --- config/_peers/mainnet | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/_peers/mainnet b/config/_peers/mainnet index b6487ed449..e5020c4207 100644 --- a/config/_peers/mainnet +++ b/config/_peers/mainnet @@ -1 +1,4 @@ +# TODO: Add at least one additional mainnet peer across a different +# operator/ASN before production rollout. A single peer is a SPOF for +# initial peer discovery of fresh nodes. /ip4/143.198.18.229/tcp/3919/ipfs/16Uiu2HAmDP4Z6LCogRMictJ6deGs4DRo99A5JTz5u3CLMg7URxC6 From 3c58b7684578d7db67bc57f2878ddcc06829347f Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Wed, 25 Mar 2026 11:15:09 +0100 Subject: [PATCH 20/22] fix(tbtcpg): use format string in fmt.Errorf to fix go vet error Go 1.24 vet rejects non-constant format strings in fmt.Errorf. This pre-existing issue was hidden because CI was not running tests. --- pkg/tbtcpg/internal/test/marshaling.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/tbtcpg/internal/test/marshaling.go b/pkg/tbtcpg/internal/test/marshaling.go index 2dd72dbaa0..5a1d172ee0 100644 --- a/pkg/tbtcpg/internal/test/marshaling.go +++ b/pkg/tbtcpg/internal/test/marshaling.go @@ -273,7 +273,7 @@ func (psts *ProposeSweepTestScenario) UnmarshalJSON(data []byte) error { // Unmarshal expected error if len(unmarshaled.ExpectedErr) > 0 { - psts.ExpectedErr = fmt.Errorf(unmarshaled.ExpectedErr) + psts.ExpectedErr = fmt.Errorf("%s", unmarshaled.ExpectedErr) } return nil From a8bdaebb95d644ec13678e79e83d49fb53c32c02 Mon Sep 17 00:00:00 2001 From: Piotr Roslaniec Date: Thu, 26 Mar 2026 09:40:43 +0100 Subject: [PATCH 21/22] test(tbtc): stabilize coordination layer assertion --- go.mod | 1 + pkg/tbtc/node_test.go | 73 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 802a5e4a2e..3b604d831e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24 toolchain go1.24.1 + replace ( github.com/bnb-chain/tss-lib => github.com/threshold-network/tss-lib v0.0.0-20230901144531-2e712689cfbe // btcd in version v.0.23 extracted `btcd/btcec` to a separate package `btcd/btcec/v2`. diff --git a/pkg/tbtc/node_test.go b/pkg/tbtc/node_test.go index bedfb30995..5a907b89b4 100644 --- a/pkg/tbtc/node_test.go +++ b/pkg/tbtc/node_test.go @@ -344,22 +344,27 @@ func TestNode_RunCoordinationLayer(t *testing.T) { if signer.wallet.publicKey.Equal(walletPublicKey) { result, ok := map[uint64]*coordinationResult{ 900: { + window: window, proposal: &mockCoordinationProposal{ActionDepositSweep}, }, // Omit window at block 1800 to make sure the layer doesn't // crash if no result is produced. 2700: { + window: window, proposal: &mockCoordinationProposal{ActionRedemption}, }, // Put some trash value to make sure coordination windows // are distributed correctly. 2705: { + window: window, proposal: &mockCoordinationProposal{ActionMovingFunds}, }, 3600: { + window: window, proposal: &mockCoordinationProposal{ActionNoop}, }, 4500: { + window: window, proposal: &mockCoordinationProposal{ActionMovedFundsSweep}, }, }[window.coordinationBlock] @@ -405,6 +410,10 @@ loop: for { select { case result := <-processedResultsChan: + if result == nil { + continue + } + processedResults = append(processedResults, result) // Once the second-last coordination window is processed, stop the @@ -425,24 +434,68 @@ loop: 3, len(processedResults), ) - testutils.AssertStringsEqual( + + resultActionsByWindow := make(map[uint64]WalletActionType, len(processedResults)) + for _, result := range processedResults { + resultActionsByWindow[result.window.coordinationBlock] = + result.proposal.ActionType() + } + + testutils.AssertIntsEqual( t, - "first result", - ActionDepositSweep.String(), - processedResults[0].proposal.ActionType().String(), + "processed coordination windows count", + 3, + len(resultActionsByWindow), ) + + firstAction, ok := resultActionsByWindow[900] + if !ok { + t.Fatal("expected coordination result for window at block 900") + } testutils.AssertStringsEqual( t, - "second result", - ActionRedemption.String(), - processedResults[1].proposal.ActionType().String(), + "result for block 900", + ActionDepositSweep.String(), + firstAction.String(), ) + + secondAction, ok := resultActionsByWindow[2700] + if !ok { + t.Fatal("expected coordination result for window at block 2700") + } testutils.AssertStringsEqual( t, - "third result", - ActionNoop.String(), - processedResults[2].proposal.ActionType().String(), + "result for block 2700", + ActionRedemption.String(), + secondAction.String(), ) + + if _, ok := resultActionsByWindow[2705]; ok { + t.Fatal("unexpected coordination result for non-window block 2705") + } + + // Result processing is asynchronous, so by the time the test cancels the + // coordination layer after the third processed result, either the 3600 + // window or the subsequent 4500 window may already be in flight. + if thirdAction, ok := resultActionsByWindow[3600]; ok { + testutils.AssertStringsEqual( + t, + "result for block 3600", + ActionNoop.String(), + thirdAction.String(), + ) + } else { + fourthAction, ok := resultActionsByWindow[4500] + if !ok { + t.Fatal("expected coordination result for block 3600 or 4500") + } + testutils.AssertStringsEqual( + t, + "result for block 4500", + ActionMovedFundsSweep.String(), + fourthAction.String(), + ) + } } type mockCoordinationProposal struct { From 254589717216b57787420be37be33947d399dd02 Mon Sep 17 00:00:00 2001 From: Leonardo Saturnino Date: Thu, 2 Apr 2026 21:37:59 -0300 Subject: [PATCH 22/22] fix(chain/local_v1): prevent double-close panic in block counter watcher The count() loop could call close(watcher.channel) on consecutive ticks before the WatchBlocks cleanup goroutine removed the cancelled watcher from the list, causing a "close of closed channel" panic. Use sync.Once to guarantee the channel is closed exactly once. --- pkg/chain/local_v1/blockcounter.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/chain/local_v1/blockcounter.go b/pkg/chain/local_v1/blockcounter.go index 69939b29f0..240039589f 100644 --- a/pkg/chain/local_v1/blockcounter.go +++ b/pkg/chain/local_v1/blockcounter.go @@ -16,8 +16,9 @@ type localBlockCounter struct { } type watcher struct { - ctx context.Context - channel chan uint64 + ctx context.Context + channel chan uint64 + closeOnce sync.Once } var defaultBlockTime = 500 * time.Millisecond @@ -120,7 +121,7 @@ func (lbc *localBlockCounter) count(blockTime ...time.Duration) { for _, watcher := range watchers { if watcher.ctx.Err() != nil { - close(watcher.channel) + watcher.closeOnce.Do(func() { close(watcher.channel) }) continue }