-
Notifications
You must be signed in to change notification settings - Fork 886
feat(seeds): ship Sei Labs seeds as the default bootstrap-peers #3885
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4c49b2b
f75f72e
ccc9849
a29c05b
ae5bfc5
83dcfc2
6666ff7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| "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", | ||
| }, | ||
| } | ||
|
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], ",") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| //go:build integration | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] (Also raised by Codex.) Nothing invokes Worse than just not running: A scheduled workflow (these assert live endpoint health, so cron fits better than per-PR) plus a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question, and I went and checked: no, I looked at reusing that tag and decided against it: that suite is docker based ( 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!
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is If not, i recommend to:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question, and I went and checked: no, I looked at reusing that tag and decided against it: that suite is docker based ( 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) | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| 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) | ||
|
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) | ||
| } | ||
| } | ||
| 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"))) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
|
|
||
| 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" | ||
|
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 | ||
|
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) | ||
| } | ||
There was a problem hiding this comment.
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 bareprod, 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 knowprod== eu-central-1. Not something the tests can catch (host uniqueness passes either way), and these strings are one-way once released — worth confirming againstclusters/<cell>/<chain>/seeds/that the unsuffixed name really is the eu-central-1 cell for both chains.