diff --git a/app/seeds/seeds.go b/app/seeds/seeds.go new file mode 100644 index 0000000000..f3f68e6d5f --- /dev/null +++ b/app/seeds/seeds.go @@ -0,0 +1,51 @@ +// Package seeds ships the Sei Labs operated P2P seed nodes for the public Sei +// networks, so a freshly initialised node bootstraps peer discovery with no +// operator configuration. +// +// Seeds are dialled to populate the address book via the PEX reactor and may +// then be dropped, which is why they belong in `bootstrap-peers` rather than +// `persistent-peers` — an operator should not hold connections open against +// them indefinitely. +package seeds + +import "strings" + +// chainSeeds maps a well-known chain-id to its Sei Labs seed nodes, each in +// CometBFT's `NodeID@host:port` form. Three per network, one per cell, so +// losing a region does not cost bootstrap capability. The cell is encoded in +// the hostname: unsuffixed `prod` is eu-central-1, `prod-euw1` is eu-west-1, +// and `prod-use2` is us-east-2. +// +// PERMANENCE: these strings ship inside released binaries and operators pin +// them. The secret-connection handshake verifies the NodeID, so a changed ID +// is a rejected dial, not a degraded one — and a release already in the wild +// cannot be recalled. Retiring an address therefore means keeping it dialable +// until every release carrying it is out of use. Treat edits here as one-way. +// +// arctic-1 is deliberately absent. It is a devnet: it has no Cosmos +// chain-registry entry, so it is not an operator-facing network, and a devnet +// is the most likely to be reset or re-keyed — exactly the case where baking a +// permanent address into a binary is wrong. Devnet users set bootstrap-peers +// explicitly. +// +// Source of truth: clusters///seeds/seed-N/seed-N.yaml in +// sei-protocol/platform (the SeiNode's externalAddress plus its NodeID). +var chainSeeds = map[string][]string{ + "pacific-1": { + "0cd5f57c249b5aca815710338e1fe7a14797585d@seed-0-p2p.pacific-1.prod.platform.sei.io:26656", + "f0f057f1593d28bec11591cf146bd223e0be1866@seed-1-p2p.pacific-1.prod-euw1.platform.sei.io:26656", + "8e28f62368a1ceae0102645db8584b218650930d@seed-2-p2p.pacific-1.prod-use2.platform.sei.io:26656", + }, + "atlantic-2": { + "362f934ead3654fca9cafdac63b52b47b2f9a95e@seed-0-p2p.atlantic-2.prod.platform.sei.io:26656", + "1f55cd51183d3a6cad8a3667b91d08d0338bd52e@seed-1-p2p.atlantic-2.prod-euw1.platform.sei.io:26656", + "7152be2e4c1a057d2b2467723058c5f0ec790472@seed-2-p2p.atlantic-2.prod-use2.platform.sei.io:26656", + }, +} + +// BootstrapPeers returns the Sei Labs seeds for a chain as the comma-separated +// value CometBFT's `bootstrap-peers` expects, or "" when the chain-id is not +// recognised (private and local chains included). +func BootstrapPeers(chainID string) string { + return strings.Join(chainSeeds[chainID], ",") +} diff --git a/app/seeds/seeds_integration_test.go b/app/seeds/seeds_integration_test.go new file mode 100644 index 0000000000..89f2d52126 --- /dev/null +++ b/app/seeds/seeds_integration_test.go @@ -0,0 +1,57 @@ +//go:build integration + +// Reachability checks against the live published seed endpoints, build-tagged +// off by default because they make real network calls: +// +// go test -tags=integration ./app/seeds/... +// +// NOT WIRED TO CI. Nothing runs this file and nothing compile-checks it: the +// `integration` tag is used nowhere else, and go vet and golangci-lint both +// skip tagged files. Treat it as an on-demand tool, not as coverage. A +// scheduled job is tracked separately, and should land after the seeds it +// dials are all healthy, so its first run is green rather than red. +package seeds + +import ( + "net" + "strconv" + "testing" + "time" + + "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/stretchr/testify/require" +) + +const dialTimeout = 10 * time.Second + +// A seed that is reachable at the TCP layer but never speaks is the failure +// mode this test exists for: the listener accepts, the pod reports Ready, seed +// mode publishes no metrics, and inbound is silently closed. Only the bytes on +// the wire distinguish that from a healthy seed, so assert them. +// +// A conforming node sends its ephemeral-key preface immediately on connect +// without waiting for the dialer, so a seed that sends nothing is broken +// regardless of why. Everything that can be checked without a network lives in +// seeds_test.go, which runs by default. +func TestSeedsAreReachableAndSpeakP2P(t *testing.T) { + for chainID, addrs := range chainSeeds { + for _, entry := range addrs { + addr, err := config.ParseNodeAddress(entry) + require.NoErrorf(t, err, "%s: %q", chainID, entry) + + hostPort := net.JoinHostPort(addr.Hostname, strconv.Itoa(int(addr.Port))) + t.Run(chainID+"/"+addr.Hostname, func(t *testing.T) { + conn, err := net.DialTimeout("tcp", hostPort, dialTimeout) + require.NoErrorf(t, err, "could not connect to %s", hostPort) + defer conn.Close() + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(dialTimeout))) + buf := make([]byte, 64) + n, err := conn.Read(buf) + require.NoErrorf(t, err, + "%s accepted the connection but sent nothing: inbound P2P is closed even though the listener is up", hostPort) + require.NotZerof(t, n, "%s sent an empty preface", hostPort) + }) + } + } +} diff --git a/app/seeds/seeds_test.go b/app/seeds/seeds_test.go new file mode 100644 index 0000000000..de98b17465 --- /dev/null +++ b/app/seeds/seeds_test.go @@ -0,0 +1,96 @@ +package seeds + +import ( + "strings" + "testing" + + "github.com/sei-protocol/sei-chain/app/genesis" + "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/stretchr/testify/require" +) + +const ( + pacific = "pacific-1" + atlantic = "atlantic-2" + arctic = "arctic-1" +) + +// Addresses are parsed with the same parser the router uses when it dials, so +// this cannot drift from what p2p actually accepts. What it protects against is +// a typo in the table above: the NodeID is verified during the +// secret-connection handshake, so a wrong one is a rejected dial rather than a +// degraded connection, and seed mode serves no metrics to notice it by. +// +// Uniqueness is asserted across the whole table rather than per chain: the +// likeliest copy/paste error when adding a network is a pacific-1 entry landing +// in the atlantic-2 block, which a per-chain check cannot see. +func TestSeedAddressesParseAndAreUnique(t *testing.T) { + seenID := map[string]string{} + seenHost := map[string]string{} + + for chainID, addrs := range chainSeeds { + require.NotEmptyf(t, addrs, "%s has no seeds", chainID) + + for _, entry := range addrs { + addr, err := config.ParseNodeAddress(entry) + require.NoErrorf(t, err, "%s: %q", chainID, entry) + + // Round-trip rather than parse alone. ParseNodeAddress substitutes + // 26657 for a missing port, so a dropped ":26656" parses clean and + // would ship pointing at the RPC port; re-rendering catches that, + // and any other silent normalisation, without pinning a port number + // as though the protocol required one. + require.Equalf(t, entry, strings.TrimPrefix(addr.String(), "mconn://"), + "%s: %q does not survive a parse round-trip", chainID, entry) + + // Seeds publish DNS names, not bare hosts, so the address outlives + // any IP change behind it. + require.Containsf(t, addr.Hostname, ".", + "%s: %q should publish a DNS name", chainID, entry) + + id := string(addr.NodeID) + require.NotContainsf(t, seenID, id, + "NodeID %s appears in both %s and %s", id, seenID[id], chainID) + seenID[id] = chainID + + require.NotContainsf(t, seenHost, addr.Hostname, + "host %s appears in both %s and %s", addr.Hostname, seenHost[addr.Hostname], chainID) + seenHost[addr.Hostname] = chainID + } + } +} + +// Every chain we ship seeds for must also be a chain seid can initialise, or +// the entry is a typo that would silently never apply. The converse is not +// asserted: arctic-1 is intentionally well-known for genesis but has no seeds. +func TestSeedChainsAreWellKnown(t *testing.T) { + for chainID := range chainSeeds { + require.Truef(t, genesis.IsWellKnown(chainID), + "chain %q has seeds but is not a well-known chain (typo?)", chainID) + } +} + +func TestArcticIsDeliberatelyExcluded(t *testing.T) { + require.Empty(t, BootstrapPeers(arctic), "arctic-1 is a devnet and must not ship seeds") + // Guard the premise of the exclusion: arctic-1 is still initialisable. + require.True(t, genesis.IsWellKnown(arctic)) +} + +func TestBootstrapPeers(t *testing.T) { + for _, chainID := range []string{pacific, atlantic} { + got := BootstrapPeers(chainID) + require.NotEmptyf(t, got, "%s should ship seeds", chainID) + // Round-trip the rendered value through the parser the way seid does, + // so the joined form is asserted rather than just the table entries. + for _, entry := range strings.Split(got, ",") { + _, err := config.ParseNodeAddress(entry) + require.NoErrorf(t, err, "%s: %q", chainID, entry) + } + } + + // Exact match only — a chain-id we do not recognise must contribute nothing, + // so private and local chains are unaffected. + for _, unknown := range []string{"", "unknown-1", "Pacific-1", pacific + " "} { + require.Emptyf(t, BootstrapPeers(unknown), "chain %q should ship no seeds", unknown) + } +} diff --git a/cmd/seid/cmd/bootstrap_peers_test.go b/cmd/seid/cmd/bootstrap_peers_test.go new file mode 100644 index 0000000000..7524023247 --- /dev/null +++ b/cmd/seid/cmd/bootstrap_peers_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sei-protocol/sei-chain/app" + "github.com/sei-protocol/sei-chain/app/seeds" + "github.com/sei-protocol/sei-chain/sei-cosmos/client" + tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/stretchr/testify/require" +) + +// Covers the init-time wiring itself, not just the seed data. Without this, +// removing the applyDefaultBootstrapPeers call from InitCmd leaves every test +// in the tree passing. +func TestApplyDefaultBootstrapPeers(t *testing.T) { + tests := []struct { + name string + chainID string + want string + }{ + {"mainnet gets seeds", "pacific-1", seeds.BootstrapPeers("pacific-1")}, + {"testnet gets seeds", "atlantic-2", seeds.BootstrapPeers("atlantic-2")}, + // arctic-1 is a devnet and deliberately ships no seeds. + {"devnet gets none", "arctic-1", ""}, + {"unknown chain gets none", "my-private-chain", ""}, + {"empty chain-id gets none", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tmcfg.DefaultConfig() + applyDefaultBootstrapPeers(cfg, tt.chainID) + require.Equal(t, tt.want, cfg.P2P.BootstrapPeers) + }) + } +} + +// What the wiring owes is the seed list for the chain, whole and unaltered. +// How many addresses that is, and whether they are well formed, belongs to +// app/seeds, where the table and its per-cell rationale live. +func TestApplyDefaultBootstrapPeersPopulatesPublicNetworks(t *testing.T) { + for _, chainID := range []string{"pacific-1", "atlantic-2"} { + cfg := tmcfg.DefaultConfig() + applyDefaultBootstrapPeers(cfg, chainID) + require.NotEmptyf(t, cfg.P2P.BootstrapPeers, "%s should ship seeds", chainID) + require.Equalf(t, seeds.BootstrapPeers(chainID), cfg.P2P.BootstrapPeers, + "%s: init must write the seed list unaltered", chainID) + } +} + +// A pre-populated value is never overwritten. Not reachable through `seid init` +// today (it has no bootstrap-peers flag), but the guard is the reason this stays +// true for any future caller. +func TestApplyDefaultBootstrapPeersPreservesExistingValue(t *testing.T) { + const existing = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef@peer.example.com:26656" + + cfg := tmcfg.DefaultConfig() + cfg.P2P.BootstrapPeers = existing + applyDefaultBootstrapPeers(cfg, "pacific-1") + + require.Equal(t, existing, cfg.P2P.BootstrapPeers, + "an existing bootstrap-peers value must never be overwritten") +} + +// runInit executes the real InitCmd against a temp home and returns the written +// config.toml. Testing the helper alone does not cover the call site — without +// this, deleting applyDefaultBootstrapPeers from RunE leaves the suite green. +func runInit(t *testing.T, chainID string) string { + t.Helper() + home := t.TempDir() + // The root command creates the home layout and client.toml before init runs. + // Standing InitCmd up directly, the test owns that scaffolding. + configDir := filepath.Join(home, "config") + require.NoError(t, os.MkdirAll(configDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "client.toml"), []byte( + "chain-id = \"\"\nkeyring-backend = \"test\"\noutput = \"text\"\nnode = \"tcp://localhost:26657\"\nbroadcast-mode = \"sync\"\n", + ), 0o644)) + + encCfg := app.MakeEncodingConfig() + clientCtx := client.Context{}.WithCodec(encCfg.Marshaler).WithHomeDir(home).WithViper("") + + cmd := InitCmd(app.ModuleBasics, home) + cmd.SetArgs([]string{"testnode", "--chain-id", chainID, "--home", home}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + ctx := context.WithValue(context.Background(), client.ClientContextKey, &clientCtx) + require.NoError(t, cmd.ExecuteContext(ctx)) + + data, err := os.ReadFile(filepath.Join(home, "config", "config.toml")) + require.NoError(t, err) + return string(data) +} + +// bootstrapPeersLine returns the rendered `bootstrap-peers = "..."` value. +func bootstrapPeersLine(t *testing.T, configToml string) string { + t.Helper() + for _, line := range strings.Split(configToml, "\n") { + if after, ok := strings.CutPrefix(strings.TrimSpace(line), "bootstrap-peers = "); ok { + return strings.Trim(after, `"`) + } + } + t.Fatal("config.toml has no bootstrap-peers line") + return "" +} + +// The end-to-end assertion: `seid init` on a public network writes the seeds. +func TestInitCmdWritesDefaultBootstrapPeers(t *testing.T) { + for _, chainID := range []string{"pacific-1", "atlantic-2"} { + t.Run(chainID, func(t *testing.T) { + got := bootstrapPeersLine(t, runInit(t, chainID)) + require.NotEmpty(t, got) + require.Equal(t, seeds.BootstrapPeers(chainID), got) + }) + } +} + +// arctic-1 is a devnet and ships no seeds, so init must leave the field empty. +func TestInitCmdLeavesDevnetBootstrapPeersEmpty(t *testing.T) { + require.Empty(t, bootstrapPeersLine(t, runInit(t, "arctic-1"))) +} diff --git a/cmd/seid/cmd/init.go b/cmd/seid/cmd/init.go index a568425513..c18141cdbd 100644 --- a/cmd/seid/cmd/init.go +++ b/cmd/seid/cmd/init.go @@ -11,6 +11,7 @@ import ( "github.com/pkg/errors" "github.com/sei-protocol/sei-chain/app/genesis" "github.com/sei-protocol/sei-chain/app/params" + "github.com/sei-protocol/sei-chain/app/seeds" tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/cli" tmos "github.com/sei-protocol/sei-chain/sei-tendermint/libs/os" @@ -119,6 +120,8 @@ For validator or seed nodes, pass --mode validator or --mode seed so RPC and P2P panic("chain-id is required, please set using --chain-id") } + applyDefaultBootstrapPeers(tmConfig, chainID) + // Get bip39 mnemonic var mnemonic string recoverFlag, _ := cmd.Flags().GetBool(FlagRecover) @@ -196,12 +199,22 @@ For validator or seed nodes, pass --mode validator or --mode seed so RPC and P2P cmd.Flags().String(cli.HomeFlag, defaultNodeHome, "node's home directory") cmd.Flags().BoolP(FlagOverwrite, "o", false, "overwrite the genesis.json and existing config files (config.toml, app.toml)") cmd.Flags().Bool(FlagRecover, false, "provide seed phrase to recover existing key instead of creating") - cmd.Flags().String(flags.FlagChainID, "", "genesis file chain-id, if left blank will use sei") + cmd.Flags().String(flags.FlagChainID, "", "chain-id to initialise for (required), e.g. pacific-1 or atlantic-2") cmd.Flags().String(FlagMode, "full", "node mode: validator, full, seed, or archive") return cmd } +// applyDefaultBootstrapPeers sets bootstrap-peers to the Sei Labs seeds for +// chainID, leaving the field unchanged when it is already set or the chain +// ships no seeds. +func applyDefaultBootstrapPeers(cfg *tmcfg.Config, chainID string) { + if cfg.P2P.BootstrapPeers != "" { + return + } + cfg.P2P.BootstrapPeers = seeds.BootstrapPeers(chainID) +} + func checkConfigOverwrite(configPath string, overwrite bool) error { if overwrite { return nil diff --git a/sei-tendermint/config/node_address.go b/sei-tendermint/config/node_address.go new file mode 100644 index 0000000000..9efaf84dbe --- /dev/null +++ b/sei-tendermint/config/node_address.go @@ -0,0 +1,20 @@ +package config + +import "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p" + +// NodeAddress is a peer address in `NodeID@host:port` form, as the p2p router +// understands it. +// +// Aliased here so callers outside the sei-tendermint tree, which cannot reach +// internal/p2p, can still name the type. +type NodeAddress = p2p.NodeAddress + +// ParseNodeAddress parses and validates a peer address, applying the same rules +// the router applies when it dials one. +// +// A missing or zero port is substituted with 26657, so a caller that requires a +// particular port must assert it separately; parsing alone will not catch an +// omitted one. +func ParseNodeAddress(address string) (NodeAddress, error) { + return p2p.ParseNodeAddress(address) +}