Skip to content
51 changes: 51 additions & 0 deletions app/seeds/seeds.go
Original file line number Diff line number Diff line change
@@ -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/<cell>/<chain>/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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Naming asymmetry worth a second look: seed-1/seed-2 carry an explicit cell suffix (prod-euw1, prod-use2) while seed-0 is bare prod, so the doc comment's "one per cell (eu-central-1, eu-west-1, us-east-2)" is only readable as three cells if you already know prod == eu-central-1. Not something the tests can catch (host uniqueness passes either way), and these strings are one-way once released — worth confirming against clusters/<cell>/<chain>/seeds/ that the unsuffixed name really is the eu-central-1 cell for both chains.

"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",
},
}
Comment thread
cursor[bot] marked this conversation as resolved.

// 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], ",")
}
57 changes: 57 additions & 0 deletions app/seeds/seeds_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//go:build integration

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] (Also raised by Codex.) Nothing invokes go test -tags=integration ./app/seeds/... — not .github/workflows/, not the Makefile, not integration-test-matrix.json (which uses the unrelated yaml_integration tag).

Worse than just not running: .golangci.yml sets build-tags: [codeanalysis] and tests: false, and go build / go vet skip tagged files by default, so this file is never even compile-checked in CI. It can break against a config API change and stay green indefinitely.

A scheduled workflow (these assert live endpoint health, so cron fits better than per-PR) plus a make target would give the reachability check a way to actually fire. At minimum, add it to a compile-only step (go vet -tags=integration ./app/seeds/...) so it can't rot silently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and I went and checked: no, integration is a new tag. The repo currently uses norace, linux, inprocess, rocksdb, dummy, cgo, darwin, mock_balances, ledger, littdb_wip, windows, and the existing integration suite runs under a different one, yaml_integration, driven by .github/workflows/integration-test-matrix.json.

I looked at reusing that tag and decided against it: that suite is docker based (docker exec sei-node-0 ...) against a locally spun chain, whereas this one dials live public endpoints. Folding them together means either the docker matrix starts making external calls, or this gets dragged into a container lifecycle it does not need.

So for now I have gone the honest route rather than the half wired one: the file header now says plainly that nothing runs or compile checks it and that it is an on demand tool rather than coverage, and I have raised a follow up to add a scheduled (cron plus workflow_dispatch) job.

Scheduled rather than a PR gate, since it depends on live external infrastructure and would otherwise block unrelated PRs whenever a seed hiccups or CI egress is flaky. Sequencing it after the two silent seeds are fixed as well, because wiring it today means the job is red on its first run, which is the quickest way to train everyone to ignore it. Happy to bring it into this PR instead if you would rather it did not land separately!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is integration an existing tag used elewhere? If yes and CI is already hooked up to running them then great.

If not, i recommend to:

  • pick an existing tag to save yourself from having to set up a CI for it, unless there is a really good reason that I might be missing for keeping it separate.
  • at the very least this PR should hook these tests to some CI job that runs them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, and I went and checked: no, integration is a new tag. The repo currently uses norace, linux, inprocess, rocksdb, dummy, cgo, darwin, mock_balances, ledger, littdb_wip, windows, and the existing integration suite runs under a different one, yaml_integration, driven by .github/workflows/integration-test-matrix.json.

I looked at reusing that tag and decided against it: that suite is docker based (docker exec sei-node-0 ...) against a locally spun chain, whereas this one dials live public endpoints. Folding them together means either the docker matrix starts making external calls, or this gets dragged into a container lifecycle it does not need.

So for now I have gone the honest route rather than the half wired one: the file header now says plainly that nothing runs or compile checks it and that it is an on demand tool rather than coverage, and I have raised a follow up to add a scheduled (cron plus workflow_dispatch) job.

Scheduled rather than a PR gate, since it depends on live external infrastructure and would otherwise block unrelated PRs whenever a seed hiccups or CI egress is flaky. Sequencing it after the two silent seeds are fixed as well, because wiring it today means the job is red on its first run, which is the quickest way to train everyone to ignore it. Happy to bring it into this PR instead if you would rather it did not land separately!


// 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)
})
}
}
}
96 changes: 96 additions & 0 deletions app/seeds/seeds_test.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
monty-sei marked this conversation as resolved.

// 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)
}
}
126 changes: 126 additions & 0 deletions cmd/seid/cmd/bootstrap_peers_test.go
Original file line number Diff line number Diff line change
@@ -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")))
}
15 changes: 14 additions & 1 deletion cmd/seid/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Good fix to the help text. Since the drive-by is specifically about the mismatch between "if left blank will use sei" and the code's behavior, the other half is still there: line 120 panic("chain-id is required, ...") prints a Go stack trace for what is plain operator error. RunE already returns errors for the invalid-mode case a few lines up — return fmt.Errorf("chain-id is required, please set using --chain-id") would match that and give a clean message. Fine to leave for a separate PR.

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
Expand Down
20 changes: 20 additions & 0 deletions sei-tendermint/config/node_address.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package config

import "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p"
Comment thread
monty-sei marked this conversation as resolved.

// 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
Comment thread
monty-sei marked this conversation as resolved.

// 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)
}
Loading