You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
We propose SnapchainConfigRegistry, an Ethereum contract that is the source of truth for snapchain's validator-set history and gossip peer lists. The contract renders its state as a TOML document; nodes pull it and merge it into their local config at container startup via a utility CLI (fc config pull) and a startup script shipped in the official Docker images. The snapchain node process itself is unchanged: it boots from a static file and never reads a chain at runtime. For validators running the official images, participation is default-on with a one-variable opt-out; read nodes are unaffected.
Problem
Snapchain's consensus validator sets and gossip bootstrap/direct peers are distributed as files in the snapchain GitHub repository: validators.toml is mounted and appended into config.toml by the Docker entrypoint, and peer lists are inlined in the compose files. This has three problems:
Rotation is slow and manual. Adding or rotating a validator requires a repository change, followed by every operator pulling new files and restarting by hand, coordinated out-of-band. A stale operator silently keeps signing against an old membership view.
There is no canonical, auditable history. Nodes syncing from genesis must verify historical block commit signatures against the validator set that was active at each height, so the full membership history must be preserved and distributed forever. A mutable file in a repository provides neither immutability nor an authoritative version.
Errors surface late. A malformed validator key in a hand-edited file is discovered when a node panics on boot, not when the change is made.
Specification
Relationship to the core protocol
This proposal changes operations, not the protocol. No message types, state roots, or sync or consensus behavior change. The snapchain binary gains one inert flag (--check-config, which validates a config file and exits). All chain interaction lives in the fc utility CLI and two shell scripts shipped alongside the node in the official Docker images. A node whose operator opts out, or who doesn't use the official images at all, is protocol-identical to one that participates. Reading the registry in-process (removing the script layer) is possible in a future release and would be proposed as a separate FIP.
The contract
SnapchainConfigRegistry lives in farcasterxyz/contracts (see docs/snapchain-config-registry.md there for the authoritative spec). One deployment per snapchain network:
Snapchain network
Chain
Address
Testnet
Ethereum Sepolia (chain id 11155111)
0x00000000fc51aD6eb74EAE89ba4b01b1776fBA85
Mainnet
Ethereum L1 (chain id 1)
0x00000000fc51aD6eb74EAE89ba4b01b1776fBA85 (same address via single-salt CREATE2 deploy). Activation is additionally gated on a snapchain release baking the address in (see Release)
Because the deployments share one address across chains, tooling always verifies eth_chainId before reading.
State:
An append-only array of validator sets: { effectiveAt, shardIds[], validatorPublicKeys[] } (at most 16 shard ids and 128 keys per entry; duplicate and zero keys are rejected, since a duplicate key would silently double an operator's voting weight). Only the latest entry may be amended or removed; earlier history is immutable, and every mutation emits an event, so the audit trail survives even a removed tip. The full mainnet history back to effective_at = 0 is seeded at deployment so genesis sync works.
bootstrapPeers and directPeers: owner-settable strings (bounded at 8 KiB, validated onchain against a strict character allowlist).
configVersion: a monotonic counter bumped on every mutation, including reverts, so pollers detect change with one cheap eth_call instead of fetching and diffing the document.
Read interface consumed by tooling:
function configToml() externalviewreturns (stringmemory);
function configVersion() externalviewreturns (uint256);
configToml() renders the entire state as a TOML fragment with a byte-level fixed grammar, e.g.:
Paginated getters (validatorSetsToml(start, end), peersToml()) and struct accessors exist as an escape hatch if the document ever outgrows an eth_call gas cap; pages concatenate into byte-identical output.
Write functions (appendValidatorSet, amendLatestValidatorSet, removeLatestValidatorSet, setBootstrapPeers, setDirectPeers) are owner-only; see Security Considerations.
Managed keys
The registry manages exactly three configuration keys and nothing else:
consensus.validator_sets: the validator membership history
Everything else in config.toml, including consensus.private_key, RPC URLs, and all node tuning, is untouched by the merge. Protocol/engine version activation schedules are not in scope; those remain in the node binary.
fc config tooling
Three subcommands:
fc config pull reads configToml() over plain eth_call, validates the document, and splices the three managed keys into the local config.toml, replacing them; every other key's value is preserved. The write is atomic (temp file + rename, permissions preserved, refuses to clobber concurrent edits). With --report-version (the shipped scripts always pass it), it also reads configVersion(), with both calls pinned to the same block via EIP-1898 so the recorded version is bound to the exact document merged. Other flags: --registry, --rpc-url, --config, --dry-run (secrets redacted), --accept-local-bootstrap-peers-config (see Opting out).
fc config version prints configVersion().
fc config slot is purely local; it derives the node's public key from its consensus private key and prints its index in (and the size of) the sorted, de-duplicated union of all validator keys, used to schedule staggered restarts.
Validation is fail-closed; the structural checks run before the file is touched:
Unknown keys in the registry document are an error (a registry emitting keys the tooling doesn't understand stops the sync loudly rather than passing them through).
Every validator key must hex-decode to 32 bytes and be a valid Ed25519 curve point, which catches at pull time what would otherwise be a node panic at boot. (The contract deliberately does not check point validity: there is no cheap onchain test, so that check belongs to the client.)
effective_at must be monotonically non-decreasing per shard; the first entry must be the genesis entry (effective_at = 0, covering every shard mentioned).
After merging, the result must pass snapchain --check-config before the node restarts; if it fails, the startup script restores a known-good config rather than booting on the bad merge.
RPC endpoint resolution: on mainnet, the node's existing l1_rpc_url is reused (validators already provision one for ENS verification), so no new secret or endpoint is required. On testnet, a Sepolia endpoint must be supplied explicitly; l1_rpc_url must keep pointing at Ethereum mainnet because Farcaster ENS names resolve there regardless of which snapchain network a node serves.
Startup script and watcher
The official Docker images ship two scripts wired into the compose entrypoints:
apply-onchain-config.sh runs at every container boot, after the entrypoint writes the static config.toml and before the node starts. It pulls, validates, merges, caches each success as last-known-good (at the path in ONCHAIN_CONFIG_CACHE, set by the official compose files), then starts the node. An RPC outage never keeps a validator down: on failure it falls back to the last-known-good cache (after verifying the cache belongs to this network and this node's key), then to the pre-pull static config, and refuses to boot only if no candidate config validates at all. Fallback boots increment a counter and log warnings so a stale fleet is monitorable.
onchain-config-watch.sh is a polling loop (default 300 s) spawned by the boot script. It never writes the running config; boot is the only apply path. When configVersion() exceeds its watermark, it merges onto a copy, validates it, and byte-compares against the running config; only a real change triggers a restart. Restarts are staggered: fc config slot assigns each validator a repeating wall-clock window (default 15 minutes) in which it alone may restart, so a config change rolls through the fleet one validator at a time and never costs quorum. The restart is a SIGTERM to the container's init process (PID 1), which forwards it to the node for a graceful shutdown (overridable via ONCHAIN_CONFIG_RESTART_CMD for setups without init: true); the container's restart: always re-runs the entrypoint, whose boot pull applies the change.
Rollback is version-forward: amending or reverting the registry bumps configVersion and nodes converge on the corrected document within one cycle. The local emergency escape is ONCHAIN_CONFIG_ENABLED=false plus restoring the previous cached config (kept at $ONCHAIN_CONFIG_CACHE.prev).
Opting out
Participation is optional at every level. A node that opts out behaves identically at the protocol layer; the tradeoff is that its operator is back to tracking membership changes manually.
Mechanism
Effect
Don't use the official Docker images / compose files
Entirely unaffected. Nothing in the node binary reads a chain.
ONCHAIN_CONFIG_ENABLED=false
The startup script no-ops; the local/mounted config is used verbatim and no watcher runs. Unset defaults to on. Unrecognized values refuse to boot rather than guess.
ONCHAIN_CONFIG_ACCEPT_LOCAL_BOOTSTRAP_PEERS=true
Partial opt-out: a non-empty local gossip.bootstrap_peers survives the merge (for VPC-internal addresses a public registry list can't carry). validator_sets and direct_peers remain registry-managed.
ONCHAIN_CONFIG_POLL_INTERVAL=0
Disables the watcher; registry changes apply only at the next restart.
read_node = true
Read nodes are always skipped; the registry manages validator configuration only, and read nodes keep the existing distribution path.
Rationale
Why a script, not the protocol? Wiring chain reads into the node's boot or consensus path would make an L1 RPC a runtime dependency of consensus and turn this into consensus-critical code requiring far heavier review. The script layer still delivers the actual payload (an onchain, auditable, append-only membership history that fleets converge on automatically) while remaining fully reversible and individually optional. If the mechanism proves itself, moving the read in-process is a small step that can be proposed separately, and the deployed contract is forward-compatible with it.
Why onchain rather than a versioned file (GitHub, HTTPS, DNS)? First, this proposal does not change who decides validator membership. That authority exists today behind the GitHub repository's ACLs; the registry moves it onto an auditable, append-only public record. On the distribution question itself: a repository file has mutable history, its integrity reduces to repository ACLs and CDN behavior, and "which version am I on" has no cheap, atomic answer. The contract gives immutable history and a single monotonic version counter, and it lets any party audit every membership change ever made from public chain data.
Why does the contract render TOML itself? One eth_call returns the complete document, with no offchain renderer, indexer, or intermediate format to trust or to drift out of sync. A byte-level fixed grammar makes the merge deterministic and testable: the contract's test suite pins the grammar byte-for-byte, and the snapchain repo round-trips the full mainnet history through that grammar to prove it parses to identical structures.
Why registry-wins precedence? For membership and topology, a "local override wins" rule reintroduces exactly the silent-divergence problem this proposal removes. The single carve-out (local bootstrap peers, behind an explicit flag) exists because private-network addresses cannot live in a public registry.
Why fail-open at boot? An L1 outage during a rolling restart must not take several validators down at once and cost quorum. Booting on last-known-good config, loudly, is safer than refusing to boot; the fallback counter makes a stale fleet observable rather than silent.
Why default-on? The dominant real-world failure is the stale operator who never applies a membership change, not the operator surprised by automation. Default-on with a one-variable opt-out means an operator who does nothing ends up with a current node.
Backwards Compatibility
No wire-format, storage, or consensus changes. Nodes that opt out, run older images, or build from source interoperate unchanged. The testnet compose file gains restart: always (previously absent; this is a behavior change for testnet operators, required for the restart-to-apply loop). Old images that predate the scripts boot their static config silently; they refuse only if an operator explicitly sets ONCHAIN_CONFIG_ENABLED=true, so intent to participate never silently degrades.
Security Considerations
Write authority. Registry writes are owner-only. Ownership uses OpenZeppelin Ownable2Step; both the testnet (Sepolia) and mainnet registries are currently owned by the same EOA (0xb1b46d15902d7432eb5e313694420f7c08253ff7), and the mainnet registry is expected to move to a multisig, which Ownable2Step supports without contract changes. The owner's blast radius is bounded but real: the three managed keys include consensus membership, so a malicious or compromised owner could publish an attacker validator set, and the two peer strings give the owner a gossip-topology lever over followers (bounded by the character allowlist, and with no effect on consensus membership). Mitigations: history is append-only and publicly auditable, and every mutation, including amending or removing the tip, emits an event, so an illegitimate write is permanent, attributable evidence. Every node validates documents structurally before adopting them. Adoption itself is voluntary per-operator and reversible in one environment variable. And the node's signing key never leaves the operator, so the registry can propose membership but cannot act as any validator.
RPC trust. Whoever answers a node's eth_calls decides which config document that node sees, so an untrusted RPC endpoint is a config-injection vector. Operators should point ONCHAIN_CONFIG_RPC_URL (or their l1_rpc_url) at an endpoint they trust. Tooling mitigations: eth_chainId preflight, EIP-1898 block-hash pinning with requireCanonical, response-size caps, HTTP redirects banned, and a monotonic version gate that treats a lower-than-watermark observation as a stale backend rather than a rollback.
Injection surface. The rendered document is parsed as TOML and merged into a file that also holds the node's consensus private key. Validator keys are stored as bytes32 and rendered through a fixed hex alphabet, so no operator input can escape a TOML string literal on that side. The two peer strings are the only free-text surfaces, and the contract's setters reject any byte outside a-z A-Z 0-9 . - _ / : , and space, so quotes, backslashes, and control characters can never reach the rendered document. Offending input is rejected rather than sanitized, so a mistake surfaces in the transaction that caused it. Client-side, unknown keys are rejected, the merged result is re-parsed and validated before install, and writes are atomic with secret-redacting logging throughout.
Operational caveats: membership writes should be made one at a time, waiting a full stagger cycle, because concurrent writes can shift restart windows while a validator is mid-restart; validators absent from the document share a single fallback restart window; the stagger assumes fleet clocks agree within NTP tolerances; and --check-config validates that a config loads, not that the node fully boots on it.
Release
Merged: contract + spec in farcasterxyz/contracts; fc config subcommands, --check-config, both scripts, and image/compose wiring on snapchain main (PRs #1002, #1005, #1007, #1011), shipping in the first snapchain release after v0.14.1.
Testnet: the Sepolia registry is deployed and seeded; the testnet compose defaults to a public Sepolia RPC (operators should substitute an endpoint they trust).
Mainnet: the mechanism ships dormant. The mainnet registry is deployed and seeded with the full membership history back to genesis, but its address is deliberately not baked into current releases, so mainnet pulls no-op with a warning. Shipping the release that bakes the address in is the fleet-wide activation and will be announced as such.
Future work (not part of this FIP): see the section below.
Validator operators who take no action and run the official images will begin following the registry at mainnet activation. The stock compose files ship in read-node mode and are unaffected. Operators who prefer manual control set ONCHAIN_CONFIG_ENABLED=false at any time.
Future work
Ownership by a validator multisig. Both registries are owned by a single EOA today. They should end up under a multisig whose signers are the parties actually running validators, so a membership change needs agreement from the operators it affects. Ownable2Step supports that transfer already; no contract change is required. What still needs deciding is who sits in the signer set and what threshold governs it.
A Safe app for authoring and reviewing writes. Registry mutations are hand-built transactions right now, which pairs badly with a multisig: a signer approving an appendValidatorSet is looking at calldata, not a diff. A frontend built against the Safe app interface (safe.global) could render a proposed write as a before/after against the live document, and let a proposer assemble one without touching an ABI.
Weighting is the harder half. One signer one vote is the wrong model here; a signer's weight should follow the number of validators they run, so say over membership tracks the work of keeping the network up. Safe's threshold model has no way to express that, which likely means a Zodiac module sitting between proposal and execution.
Binding validator signing keys to signer addresses. Weighted voting needs an onchain answer to "which validators does this signer run", and today there isn't one. The registry holds signing keys, the multisig holds addresses, and nothing connects them. The binding is many keys to one address, and it has to carry proof of control over each key. It could live in this registry as a key-to-operator mapping alongside the existing sets, or it could live in the module work; that depends on whether anything other than the voting logic ends up wanting to read it.
Moving the registry read in-process. Already flagged in Rationale. If the script layer holds up, having the node read the registry directly rather than through a boot script is a small change, and it would be proposed on its own.
We propose SnapchainConfigRegistry, an Ethereum contract that is the source of truth for snapchain's validator-set history and gossip peer lists. The contract renders its state as a TOML document; nodes pull it and merge it into their local config at container startup via a utility CLI (fc config pull) and a startup script shipped in the official Docker images. The snapchain node process itself is unchanged: it boots from a static file and never reads a chain at runtime. For validators running the official images, participation is default-on with a one-variable opt-out; read nodes are unaffected.
Problem
Snapchain's consensus validator sets and gossip bootstrap/direct peers are distributed as files in the snapchain GitHub repository: validators.toml is mounted and appended into config.toml by the Docker entrypoint, and peer lists are inlined in the compose files. This has three problems:
Rotation is slow and manual. Adding or rotating a validator requires a repository change, followed by every operator pulling new files and restarting by hand, coordinated out-of-band. A stale operator silently keeps signing against an old membership view.
There is no canonical, auditable history. Nodes syncing from genesis must verify historical block commit signatures against the validator set that was active at each height, so the full membership history must be preserved and distributed forever. A mutable file in a repository provides neither immutability nor an authoritative version.
Errors surface late. A malformed validator key in a hand-edited file is discovered when a node panics on boot, not when the change is made.
Specification
Relationship to the core protocol
This proposal changes operations, not the protocol. No message types, state roots, or sync or consensus behavior change. The snapchain binary gains one inert flag (--check-config, which validates a config file and exits). All chain interaction lives in the fc utility CLI and two shell scripts shipped alongside the node in the official Docker images. A node whose operator opts out, or who doesn't use the official images at all, is protocol-identical to one that participates. Reading the registry in-process (removing the script layer) is possible in a future release and would be proposed as a separate FIP.
The contract
SnapchainConfigRegistry lives in farcasterxyz/contracts (see docs/snapchain-config-registry.md there for the authoritative spec). One deployment per snapchain network:
| Mainnet | Ethereum L1 (chain id 1) | 0x00000000fc51aD6eb74EAE89ba4b01b1776fBA85 (same address via single-salt CREATE2 deploy). Activation is additionally gated on a snapchain release baking the address in (see Release) |
Because the deployments share one address across chains, tooling always verifies eth_chainId before reading.
State:
An append-only array of validator sets: { effectiveAt, shardIds[], validatorPublicKeys[] } (at most 16 shard ids and 128 keys per entry; duplicate and zero keys are rejected, since a duplicate key would silently double an operator's voting weight). Only the latest entry may be amended or removed; earlier history is immutable, and every mutation emits an event, so the audit trail survives even a removed tip. The full mainnet history back to effective_at = 0 is seeded at deployment so genesis sync works.
bootstrapPeers and directPeers: owner-settable strings (bounded at 8 KiB, validated onchain against a strict character allowlist).
configVersion: a monotonic counter bumped on every mutation, including reverts, so pollers detect change with one cheap eth_call instead of fetching and diffing the document.
Read interface consumed by tooling:
function configToml() externalviewreturns (stringmemory);
function configVersion() externalviewreturns (uint256);
configToml() renders the entire state as a TOML fragment with a byte-level fixed grammar, e.g.:
Paginated getters (validatorSetsToml(start, end), peersToml()) and struct accessors exist as an escape hatch if the document ever outgrows an eth_call gas cap; pages concatenate into byte-identical output.
Write functions (appendValidatorSet, amendLatestValidatorSet, removeLatestValidatorSet, setBootstrapPeers, setDirectPeers) are owner-only; see Security Considerations.
Managed keys
The registry manages exactly three configuration keys and nothing else:
consensus.validator_sets: the validator membership history
Everything else in config.toml, including consensus.private_key, RPC URLs, and all node tuning, is untouched by the merge. Protocol/engine version activation schedules are not in scope; those remain in the node binary.
fc config tooling
Three subcommands:
fc config pull reads configToml() over plain eth_call, validates the document, and splices the three managed keys into the local config.toml, replacing them; every other key's value is preserved. The write is atomic (temp file + rename, permissions preserved, refuses to clobber concurrent edits). With --report-version (the shipped scripts always pass it), it also reads configVersion(), with both calls pinned to the same block via EIP-1898 so the recorded version is bound to the exact document merged. Other flags: --registry, --rpc-url, --config, --dry-run (secrets redacted), --accept-local-bootstrap-peers-config (see Opting out).
fc config version prints configVersion().
fc config slot is purely local; it derives the node's public key from its consensus private key and prints its index in (and the size of) the sorted, de-duplicated union of all validator keys, used to schedule staggered restarts.
Validation is fail-closed; the structural checks run before the file is touched:
Unknown keys in the registry document are an error (a registry emitting keys the tooling doesn't understand stops the sync loudly rather than passing them through).
Every validator key must hex-decode to 32 bytes and be a valid Ed25519 curve point, which catches at pull time what would otherwise be a node panic at boot. (The contract deliberately does not check point validity: there is no cheap onchain test, so that check belongs to the client.)
effective_at must be monotonically non-decreasing per shard; the first entry must be the genesis entry (effective_at = 0, covering every shard mentioned).
After merging, the result must pass snapchain --check-config before the node restarts; if it fails, the startup script restores a known-good config rather than booting on the bad merge.
RPC endpoint resolution: on mainnet, the node's existing l1_rpc_url is reused (validators already provision one for ENS verification), so no new secret or endpoint is required. On testnet, a Sepolia endpoint must be supplied explicitly; l1_rpc_url must keep pointing at Ethereum mainnet because Farcaster ENS names resolve there regardless of which snapchain network a node serves.
Startup script and watcher
The official Docker images ship two scripts wired into the compose entrypoints:
apply-onchain-config.sh runs at every container boot, after the entrypoint writes the static config.toml and before the node starts. It pulls, validates, merges, caches each success as last-known-good (at the path in ONCHAIN_CONFIG_CACHE, set by the official compose files), then starts the node. An RPC outage never keeps a validator down: on failure it falls back to the last-known-good cache (after verifying the cache belongs to this network and this node's key), then to the pre-pull static config, and refuses to boot only if no candidate config validates at all. Fallback boots increment a counter and log warnings so a stale fleet is monitorable.
onchain-config-watch.sh is a polling loop (default 300 s) spawned by the boot script. It never writes the running config; boot is the only apply path. When configVersion() exceeds its watermark, it merges onto a copy, validates it, and byte-compares against the running config; only a real change triggers a restart. Restarts are staggered: fc config slot assigns each validator a repeating wall-clock window (default 15 minutes) in which it alone may restart, so a config change rolls through the fleet one validator at a time and never costs quorum. The restart is a SIGTERM to the container's init process (PID 1), which forwards it to the node for a graceful shutdown (overridable via ONCHAIN_CONFIG_RESTART_CMD for setups without init: true); the container's restart: always re-runs the entrypoint, whose boot pull applies the change.
Rollback is version-forward: amending or reverting the registry bumps configVersion and nodes converge on the corrected document within one cycle. The local emergency escape is ONCHAIN_CONFIG_ENABLED=false plus restoring the previous cached config (kept at $ONCHAIN_CONFIG_CACHE.prev).
Opting out
Participation is optional at every level. A node that opts out behaves identically at the protocol layer; the tradeoff is that its operator is back to tracking membership changes manually.
| Don't use the official Docker images / compose files | Entirely unaffected. Nothing in the node binary reads a chain. |
| ONCHAIN_CONFIG_ENABLED=false | The startup script no-ops; the local/mounted config is used verbatim and no watcher runs. Unset defaults to on. Unrecognized values refuse to boot rather than guess. |
| ONCHAIN_CONFIG_ACCEPT_LOCAL_BOOTSTRAP_PEERS=true | Partial opt-out: a non-empty local gossip.bootstrap_peers survives the merge (for VPC-internal addresses a public registry list can't carry). validator_sets and direct_peers remain registry-managed. |
| ONCHAIN_CONFIG_POLL_INTERVAL=0 | Disables the watcher; registry changes apply only at the next restart. |
| read_node = true | Read nodes are always skipped; the registry manages validator configuration only, and read nodes keep the existing distribution path. |
Rationale
Why a script, not the protocol? Wiring chain reads into the node's boot or consensus path would make an L1 RPC a runtime dependency of consensus and turn this into consensus-critical code requiring far heavier review. The script layer still delivers the actual payload (an onchain, auditable, append-only membership history that fleets converge on automatically) while remaining fully reversible and individually optional. If the mechanism proves itself, moving the read in-process is a small step that can be proposed separately, and the deployed contract is forward-compatible with it.
Why onchain rather than a versioned file (GitHub, HTTPS, DNS)? First, this proposal does not change who decides validator membership. That authority exists today behind the GitHub repository's ACLs; the registry moves it onto an auditable, append-only public record. On the distribution question itself: a repository file has mutable history, its integrity reduces to repository ACLs and CDN behavior, and "which version am I on" has no cheap, atomic answer. The contract gives immutable history and a single monotonic version counter, and it lets any party audit every membership change ever made from public chain data.
Why does the contract render TOML itself? One eth_call returns the complete document, with no offchain renderer, indexer, or intermediate format to trust or to drift out of sync. A byte-level fixed grammar makes the merge deterministic and testable: the contract's test suite pins the grammar byte-for-byte, and the snapchain repo round-trips the full mainnet history through that grammar to prove it parses to identical structures.
Why registry-wins precedence? For membership and topology, a "local override wins" rule reintroduces exactly the silent-divergence problem this proposal removes. The single carve-out (local bootstrap peers, behind an explicit flag) exists because private-network addresses cannot live in a public registry.
Why fail-open at boot? An L1 outage during a rolling restart must not take several validators down at once and cost quorum. Booting on last-known-good config, loudly, is safer than refusing to boot; the fallback counter makes a stale fleet observable rather than silent.
Why default-on? The dominant real-world failure is the stale operator who never applies a membership change, not the operator surprised by automation. Default-on with a one-variable opt-out means an operator who does nothing ends up with a current node.
Backwards Compatibility
No wire-format, storage, or consensus changes. Nodes that opt out, run older images, or build from source interoperate unchanged. The testnet compose file gains restart: always (previously absent; this is a behavior change for testnet operators, required for the restart-to-apply loop). Old images that predate the scripts boot their static config silently; they refuse only if an operator explicitly sets ONCHAIN_CONFIG_ENABLED=true, so intent to participate never silently degrades.
Security Considerations
Write authority. Registry writes are owner-only. Ownership uses OpenZeppelin Ownable2Step; both the testnet (Sepolia) and mainnet registries are currently owned by the same EOA (0xb1b46d15902d7432eb5e313694420f7c08253ff7), and the mainnet registry is expected to move to a multisig, which Ownable2Step supports without contract changes. The owner's blast radius is bounded but real: the three managed keys include consensus membership, so a malicious or compromised owner could publish an attacker validator set, and the two peer strings give the owner a gossip-topology lever over followers (bounded by the character allowlist, and with no effect on consensus membership). Mitigations: history is append-only and publicly auditable, and every mutation, including amending or removing the tip, emits an event, so an illegitimate write is permanent, attributable evidence. Every node validates documents structurally before adopting them. Adoption itself is voluntary per-operator and reversible in one environment variable. And the node's signing key never leaves the operator, so the registry can propose membership but cannot act as any validator.
RPC trust. Whoever answers a node's eth_calls decides which config document that node sees, so an untrusted RPC endpoint is a config-injection vector. Operators should point ONCHAIN_CONFIG_RPC_URL (or their l1_rpc_url) at an endpoint they trust. Tooling mitigations: eth_chainId preflight, EIP-1898 block-hash pinning with requireCanonical, response-size caps, HTTP redirects banned, and a monotonic version gate that treats a lower-than-watermark observation as a stale backend rather than a rollback.
Injection surface. The rendered document is parsed as TOML and merged into a file that also holds the node's consensus private key. Validator keys are stored as bytes32 and rendered through a fixed hex alphabet, so no operator input can escape a TOML string literal on that side. The two peer strings are the only free-text surfaces, and the contract's setters reject any byte outside a-z A-Z 0-9 . - _ / : , and space, so quotes, backslashes, and control characters can never reach the rendered document. Offending input is rejected rather than sanitized, so a mistake surfaces in the transaction that caused it. Client-side, unknown keys are rejected, the merged result is re-parsed and validated before install, and writes are atomic with secret-redacting logging throughout.
Operational caveats: membership writes should be made one at a time, waiting a full stagger cycle, because concurrent writes can shift restart windows while a validator is mid-restart; validators absent from the document share a single fallback restart window; the stagger assumes fleet clocks agree within NTP tolerances; and --check-config validates that a config loads, not that the node fully boots on it.
Release
Merged: contract + spec in farcasterxyz/contracts; fc config subcommands, --check-config, both scripts, and image/compose wiring on snapchain main (PRs #1002, #1005, #1007, #1011), shipping in the first snapchain release after v0.14.1.
Testnet: the Sepolia registry is deployed and seeded; the testnet compose defaults to a public Sepolia RPC (operators should substitute an endpoint they trust).
Mainnet: the mechanism ships dormant. The mainnet registry is deployed and seeded with the full membership history back to genesis, but its address is deliberately not baked into current releases, so mainnet pulls no-op with a warning. Shipping the release that bakes the address in is the fleet-wide activation and will be announced as such.
Future work (not part of this FIP): see the section below.
Validator operators who take no action and run the official images will begin following the registry at mainnet activation. The stock compose files ship in read-node mode and are unaffected. Operators who prefer manual control set ONCHAIN_CONFIG_ENABLED=false at any time.
Future work
Ownership by a validator multisig. Both registries are owned by a single EOA today. They should end up under a multisig whose signers are the parties actually running validators, so a membership change needs agreement from the operators it affects. Ownable2Step supports that transfer already; no contract change is required. What still needs deciding is who sits in the signer set and what threshold governs it.
A Safe app for authoring and reviewing writes. Registry mutations are hand-built transactions right now, which pairs badly with a multisig: a signer approving an appendValidatorSet is looking at calldata, not a diff. A frontend built against the Safe app interface (safe.global) could render a proposed write as a before/after against the live document, and let a proposer assemble one without touching an ABI.
Weighting is the harder half. One signer one vote is the wrong model here; a signer's weight should follow the number of validators they run, so say over membership tracks the work of keeping the network up. Safe's threshold model has no way to express that, which likely means a Zodiac module sitting between proposal and execution.
Binding validator signing keys to signer addresses. Weighted voting needs an onchain answer to "which validators does this signer run", and today there isn't one. The registry holds signing keys, the multisig holds addresses, and nothing connects them. The binding is many keys to one address, and it has to carry proof of control over each key. It could live in this registry as a key-to-operator mapping alongside the existing sets, or it could live in the module work; that depends on whether anything other than the voting logic ends up wanting to read it.
Moving the registry read in-process. Already flagged in Rationale. If the script layer holds up, having the node read the registry directly rather than through a boot script is a small change, and it would be proposed on its own.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
FIP: Onchain Config Registry for Snapchain
Title: Onchain Config Registry for Snapchain
Type: Implementation FIP
Author: @matt-neynar, @topocount
Abstract
We propose
SnapchainConfigRegistry, an Ethereum contract that is the source of truth for snapchain's validator-set history and gossip peer lists. The contract renders its state as a TOML document; nodes pull it and merge it into their local config at container startup via a utility CLI (fc config pull) and a startup script shipped in the official Docker images. The snapchain node process itself is unchanged: it boots from a static file and never reads a chain at runtime. For validators running the official images, participation is default-on with a one-variable opt-out; read nodes are unaffected.Problem
Snapchain's consensus validator sets and gossip bootstrap/direct peers are distributed as files in the snapchain GitHub repository:
validators.tomlis mounted and appended intoconfig.tomlby the Docker entrypoint, and peer lists are inlined in the compose files. This has three problems:Specification
Relationship to the core protocol
This proposal changes operations, not the protocol. No message types, state roots, or sync or consensus behavior change. The snapchain binary gains one inert flag (
--check-config, which validates a config file and exits). All chain interaction lives in thefcutility CLI and two shell scripts shipped alongside the node in the official Docker images. A node whose operator opts out, or who doesn't use the official images at all, is protocol-identical to one that participates. Reading the registry in-process (removing the script layer) is possible in a future release and would be proposed as a separate FIP.The contract
SnapchainConfigRegistrylives infarcasterxyz/contracts(seedocs/snapchain-config-registry.mdthere for the authoritative spec). One deployment per snapchain network:0x00000000fc51aD6eb74EAE89ba4b01b1776fBA850x00000000fc51aD6eb74EAE89ba4b01b1776fBA85(same address via single-salt CREATE2 deploy). Activation is additionally gated on a snapchain release baking the address in (see Release)Because the deployments share one address across chains, tooling always verifies
eth_chainIdbefore reading.State:
{ effectiveAt, shardIds[], validatorPublicKeys[] }(at most 16 shard ids and 128 keys per entry; duplicate and zero keys are rejected, since a duplicate key would silently double an operator's voting weight). Only the latest entry may be amended or removed; earlier history is immutable, and every mutation emits an event, so the audit trail survives even a removed tip. The full mainnet history back toeffective_at = 0is seeded at deployment so genesis sync works.bootstrapPeersanddirectPeers: owner-settable strings (bounded at 8 KiB, validated onchain against a strict character allowlist).configVersion: a monotonic counter bumped on every mutation, including reverts, so pollers detect change with one cheapeth_callinstead of fetching and diffing the document.Read interface consumed by tooling:
configToml()renders the entire state as a TOML fragment with a byte-level fixed grammar, e.g.:Paginated getters (
validatorSetsToml(start, end),peersToml()) and struct accessors exist as an escape hatch if the document ever outgrows aneth_callgas cap; pages concatenate into byte-identical output.Write functions (
appendValidatorSet,amendLatestValidatorSet,removeLatestValidatorSet,setBootstrapPeers,setDirectPeers) are owner-only; see Security Considerations.Managed keys
The registry manages exactly three configuration keys and nothing else:
consensus.validator_sets: the validator membership historygossip.bootstrap_peers: comma-separated multiaddrsgossip.direct_peers: comma-separated peer IDsEverything else in
config.toml, includingconsensus.private_key, RPC URLs, and all node tuning, is untouched by the merge. Protocol/engine version activation schedules are not in scope; those remain in the node binary.fc configtoolingThree subcommands:
fc config pullreadsconfigToml()over plaineth_call, validates the document, and splices the three managed keys into the localconfig.toml, replacing them; every other key's value is preserved. The write is atomic (temp file + rename, permissions preserved, refuses to clobber concurrent edits). With--report-version(the shipped scripts always pass it), it also readsconfigVersion(), with both calls pinned to the same block via EIP-1898 so the recorded version is bound to the exact document merged. Other flags:--registry,--rpc-url,--config,--dry-run(secrets redacted),--accept-local-bootstrap-peers-config(see Opting out).fc config versionprintsconfigVersion().fc config slotis purely local; it derives the node's public key from its consensus private key and prints its index in (and the size of) the sorted, de-duplicated union of all validator keys, used to schedule staggered restarts.Validation is fail-closed; the structural checks run before the file is touched:
effective_atmust be monotonically non-decreasing per shard; the first entry must be the genesis entry (effective_at = 0, covering every shard mentioned).snapchain --check-configbefore the node restarts; if it fails, the startup script restores a known-good config rather than booting on the bad merge.RPC endpoint resolution: on mainnet, the node's existing
l1_rpc_urlis reused (validators already provision one for ENS verification), so no new secret or endpoint is required. On testnet, a Sepolia endpoint must be supplied explicitly;l1_rpc_urlmust keep pointing at Ethereum mainnet because Farcaster ENS names resolve there regardless of which snapchain network a node serves.Startup script and watcher
The official Docker images ship two scripts wired into the compose entrypoints:
apply-onchain-config.shruns at every container boot, after the entrypoint writes the staticconfig.tomland before the node starts. It pulls, validates, merges, caches each success as last-known-good (at the path inONCHAIN_CONFIG_CACHE, set by the official compose files), then starts the node. An RPC outage never keeps a validator down: on failure it falls back to the last-known-good cache (after verifying the cache belongs to this network and this node's key), then to the pre-pull static config, and refuses to boot only if no candidate config validates at all. Fallback boots increment a counter and log warnings so a stale fleet is monitorable.onchain-config-watch.shis a polling loop (default 300 s) spawned by the boot script. It never writes the running config; boot is the only apply path. WhenconfigVersion()exceeds its watermark, it merges onto a copy, validates it, and byte-compares against the running config; only a real change triggers a restart. Restarts are staggered:fc config slotassigns each validator a repeating wall-clock window (default 15 minutes) in which it alone may restart, so a config change rolls through the fleet one validator at a time and never costs quorum. The restart is aSIGTERMto the container's init process (PID 1), which forwards it to the node for a graceful shutdown (overridable viaONCHAIN_CONFIG_RESTART_CMDfor setups withoutinit: true); the container'srestart: alwaysre-runs the entrypoint, whose boot pull applies the change.Rollback is version-forward: amending or reverting the registry bumps
configVersionand nodes converge on the corrected document within one cycle. The local emergency escape isONCHAIN_CONFIG_ENABLED=falseplus restoring the previous cached config (kept at$ONCHAIN_CONFIG_CACHE.prev).Opting out
Participation is optional at every level. A node that opts out behaves identically at the protocol layer; the tradeoff is that its operator is back to tracking membership changes manually.
ONCHAIN_CONFIG_ENABLED=falseONCHAIN_CONFIG_ACCEPT_LOCAL_BOOTSTRAP_PEERS=truegossip.bootstrap_peerssurvives the merge (for VPC-internal addresses a public registry list can't carry).validator_setsanddirect_peersremain registry-managed.ONCHAIN_CONFIG_POLL_INTERVAL=0read_node = trueRationale
Why a script, not the protocol? Wiring chain reads into the node's boot or consensus path would make an L1 RPC a runtime dependency of consensus and turn this into consensus-critical code requiring far heavier review. The script layer still delivers the actual payload (an onchain, auditable, append-only membership history that fleets converge on automatically) while remaining fully reversible and individually optional. If the mechanism proves itself, moving the read in-process is a small step that can be proposed separately, and the deployed contract is forward-compatible with it.
Why onchain rather than a versioned file (GitHub, HTTPS, DNS)? First, this proposal does not change who decides validator membership. That authority exists today behind the GitHub repository's ACLs; the registry moves it onto an auditable, append-only public record. On the distribution question itself: a repository file has mutable history, its integrity reduces to repository ACLs and CDN behavior, and "which version am I on" has no cheap, atomic answer. The contract gives immutable history and a single monotonic version counter, and it lets any party audit every membership change ever made from public chain data.
Why does the contract render TOML itself? One
eth_callreturns the complete document, with no offchain renderer, indexer, or intermediate format to trust or to drift out of sync. A byte-level fixed grammar makes the merge deterministic and testable: the contract's test suite pins the grammar byte-for-byte, and the snapchain repo round-trips the full mainnet history through that grammar to prove it parses to identical structures.Why registry-wins precedence? For membership and topology, a "local override wins" rule reintroduces exactly the silent-divergence problem this proposal removes. The single carve-out (local bootstrap peers, behind an explicit flag) exists because private-network addresses cannot live in a public registry.
Why fail-open at boot? An L1 outage during a rolling restart must not take several validators down at once and cost quorum. Booting on last-known-good config, loudly, is safer than refusing to boot; the fallback counter makes a stale fleet observable rather than silent.
Why default-on? The dominant real-world failure is the stale operator who never applies a membership change, not the operator surprised by automation. Default-on with a one-variable opt-out means an operator who does nothing ends up with a current node.
Backwards Compatibility
No wire-format, storage, or consensus changes. Nodes that opt out, run older images, or build from source interoperate unchanged. The testnet compose file gains
restart: always(previously absent; this is a behavior change for testnet operators, required for the restart-to-apply loop). Old images that predate the scripts boot their static config silently; they refuse only if an operator explicitly setsONCHAIN_CONFIG_ENABLED=true, so intent to participate never silently degrades.Security Considerations
Write authority. Registry writes are owner-only. Ownership uses OpenZeppelin
Ownable2Step; both the testnet (Sepolia) and mainnet registries are currently owned by the same EOA (0xb1b46d15902d7432eb5e313694420f7c08253ff7), and the mainnet registry is expected to move to a multisig, whichOwnable2Stepsupports without contract changes. The owner's blast radius is bounded but real: the three managed keys include consensus membership, so a malicious or compromised owner could publish an attacker validator set, and the two peer strings give the owner a gossip-topology lever over followers (bounded by the character allowlist, and with no effect on consensus membership). Mitigations: history is append-only and publicly auditable, and every mutation, including amending or removing the tip, emits an event, so an illegitimate write is permanent, attributable evidence. Every node validates documents structurally before adopting them. Adoption itself is voluntary per-operator and reversible in one environment variable. And the node's signing key never leaves the operator, so the registry can propose membership but cannot act as any validator.RPC trust. Whoever answers a node's
eth_calls decides which config document that node sees, so an untrusted RPC endpoint is a config-injection vector. Operators should pointONCHAIN_CONFIG_RPC_URL(or theirl1_rpc_url) at an endpoint they trust. Tooling mitigations:eth_chainIdpreflight, EIP-1898 block-hash pinning withrequireCanonical, response-size caps, HTTP redirects banned, and a monotonic version gate that treats a lower-than-watermark observation as a stale backend rather than a rollback.Injection surface. The rendered document is parsed as TOML and merged into a file that also holds the node's consensus private key. Validator keys are stored as
bytes32and rendered through a fixed hex alphabet, so no operator input can escape a TOML string literal on that side. The two peer strings are the only free-text surfaces, and the contract's setters reject any byte outsidea-z A-Z 0-9 . - _ / : ,and space, so quotes, backslashes, and control characters can never reach the rendered document. Offending input is rejected rather than sanitized, so a mistake surfaces in the transaction that caused it. Client-side, unknown keys are rejected, the merged result is re-parsed and validated before install, and writes are atomic with secret-redacting logging throughout.Operational caveats: membership writes should be made one at a time, waiting a full stagger cycle, because concurrent writes can shift restart windows while a validator is mid-restart; validators absent from the document share a single fallback restart window; the stagger assumes fleet clocks agree within NTP tolerances; and
--check-configvalidates that a config loads, not that the node fully boots on it.Release
farcasterxyz/contracts;fc configsubcommands,--check-config, both scripts, and image/compose wiring on snapchainmain(PRs #1002, #1005, #1007, #1011), shipping in the first snapchain release after v0.14.1.Validator operators who take no action and run the official images will begin following the registry at mainnet activation. The stock compose files ship in read-node mode and are unaffected. Operators who prefer manual control set
ONCHAIN_CONFIG_ENABLED=falseat any time.Future work
Ownership by a validator multisig. Both registries are owned by a single EOA today. They should end up under a multisig whose signers are the parties actually running validators, so a membership change needs agreement from the operators it affects.
Ownable2Stepsupports that transfer already; no contract change is required. What still needs deciding is who sits in the signer set and what threshold governs it.A Safe app for authoring and reviewing writes. Registry mutations are hand-built transactions right now, which pairs badly with a multisig: a signer approving an
appendValidatorSetis looking at calldata, not a diff. A frontend built against the Safe app interface (safe.global) could render a proposed write as a before/after against the live document, and let a proposer assemble one without touching an ABI.Weighting is the harder half. One signer one vote is the wrong model here; a signer's weight should follow the number of validators they run, so say over membership tracks the work of keeping the network up. Safe's threshold model has no way to express that, which likely means a Zodiac module sitting between proposal and execution.
Binding validator signing keys to signer addresses. Weighted voting needs an onchain answer to "which validators does this signer run", and today there isn't one. The registry holds signing keys, the multisig holds addresses, and nothing connects them. The binding is many keys to one address, and it has to carry proof of control over each key. It could live in this registry as a key-to-operator mapping alongside the existing sets, or it could live in the module work; that depends on whether anything other than the voting logic ends up wanting to read it.
Moving the registry read in-process. Already flagged in Rationale. If the script layer holds up, having the node read the registry directly rather than through a boot script is a small change, and it would be proposed on its own.
All reactions