Skip to content

Commit acae97d

Browse files
committed
feat: SnapchainConfigRegistry storage, mutators, and events
Owner-governed record of a Snapchain network's validator sets and gossip peer lists. Validator-set history is append-only; only the tip may be amended or removed. Peer lists are plain settable strings. Storage notes: - The sets array is internal with explicit accessors, because the compiler-generated getter for an array of structs with nested dynamic arrays returns only the value-type members. - Append and amend share one write path that assigns struct members individually from calldata. That sidesteps the unimplemented memory-to-storage copy for structs with nested dynamic arrays, resizes correctly on an amend, and makes it impossible for an amend to drift from an append. - Removal clears both nested arrays explicitly before popping. pop() is specified to do this, but the failure mode of getting it wrong is a later push inheriting stale contents, which would silently resurrect a removed validator. Validation is split deliberately. Enforced onchain: non-empty and bounded lists, no zero key, no duplicate key or shard id, and a non-decreasing effectiveAt. Left to the operator: Ed25519 point validity, shard-id range, quorum safety of the delta, and whether effectiveAt is actually in the future -- none of which the chain can know, and a false positive on the last two would block an emergency rotation. Peer strings are checked byte-by-byte against a two-word allowlist bitmap and rejected, not sanitized. Excluding the quote and backslash is what makes it structurally impossible for a peer string to escape the TOML literal it gets rendered into. configVersion increments by exactly one per mutation so a poller can detect change with one cheap call rather than fetching and diffing the whole document. Events carry the full payload so an indexer can reconstruct history from logs alone.
1 parent 1c210bd commit acae97d

2 files changed

Lines changed: 606 additions & 0 deletions

File tree

src/SnapchainConfigRegistry.sol

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity 0.8.29;
3+
4+
import {Ownable2Step} from "openzeppelin/contracts/access/Ownable2Step.sol";
5+
6+
import {ISnapchainConfigRegistry} from "./interfaces/ISnapchainConfigRegistry.sol";
7+
8+
/**
9+
* @title SnapchainConfigRegistry
10+
*
11+
* @notice Canonical, owner-governed record of a Snapchain network's validator sets and gossip peer
12+
* lists. Validator-set history is append-only: earlier entries are immutable, and only the
13+
* latest may be amended or removed. Peer lists are plain settable strings.
14+
*
15+
* @notice See https://github.com/farcasterxyz/contracts/blob/main/docs/snapchain-config-registry.md
16+
* for the schema and the rendered-output specification.
17+
*
18+
* @custom:security-contact security@merklemanufactory.com
19+
*/
20+
contract SnapchainConfigRegistry is ISnapchainConfigRegistry, Ownable2Step {
21+
/*//////////////////////////////////////////////////////////////
22+
CONSTANTS
23+
//////////////////////////////////////////////////////////////*/
24+
25+
/**
26+
* @inheritdoc ISnapchainConfigRegistry
27+
*/
28+
string public constant VERSION = "2026.08.04";
29+
30+
/**
31+
* @inheritdoc ISnapchainConfigRegistry
32+
*/
33+
uint256 public constant MAX_SHARD_IDS = 16;
34+
35+
/**
36+
* @inheritdoc ISnapchainConfigRegistry
37+
*/
38+
uint256 public constant MAX_VALIDATOR_PUBLIC_KEYS = 128;
39+
40+
/**
41+
* @inheritdoc ISnapchainConfigRegistry
42+
*/
43+
uint256 public constant MAX_PEER_STRING_LENGTH = 8192;
44+
45+
/**
46+
* @dev Allowlist of bytes permitted in a peer string, as a bitmap over 0x00-0x3F. Bit i is set
47+
* if byte i is allowed. Covers space, comma, hyphen, period, slash, the digits, and colon.
48+
*
49+
* Anything a multiaddr or base58 peer id can contain is here; nothing else is. In
50+
* particular the quote and backslash are excluded, which is what makes it impossible for a
51+
* peer string to escape the TOML literal it is rendered into.
52+
*/
53+
uint256 internal constant _PEER_CHARS_LO = 0x07FFF00100000000;
54+
55+
/**
56+
* @dev Allowlist bitmap over 0x40-0x7F; bit i is set if byte (0x40 + i) is allowed. Covers the
57+
* uppercase letters, underscore, and the lowercase letters.
58+
*/
59+
uint256 internal constant _PEER_CHARS_HI = 0x07FFFFFE87FFFFFE;
60+
61+
/*//////////////////////////////////////////////////////////////
62+
STORAGE
63+
//////////////////////////////////////////////////////////////*/
64+
65+
/**
66+
* @dev Validator sets in append order. Held internal with explicit accessors because the
67+
* compiler-generated getter for an array of structs with nested dynamic arrays returns
68+
* only the value-type members.
69+
*/
70+
ValidatorSet[] internal _validatorSets;
71+
72+
/**
73+
* @inheritdoc ISnapchainConfigRegistry
74+
*/
75+
uint256 public configVersion;
76+
77+
/**
78+
* @inheritdoc ISnapchainConfigRegistry
79+
*/
80+
string public bootstrapPeers;
81+
82+
/**
83+
* @inheritdoc ISnapchainConfigRegistry
84+
*/
85+
string public directPeers;
86+
87+
/*//////////////////////////////////////////////////////////////
88+
CONSTRUCTOR
89+
//////////////////////////////////////////////////////////////*/
90+
91+
/**
92+
* @notice Set the initial owner.
93+
*
94+
* @dev Deliberately takes no config. Seeding happens after deployment, which keeps the creation
95+
* code independent of the config payload so that revising seed data before launch does not
96+
* change the deployed address.
97+
*
98+
* @param _initialOwner Address of the contract owner.
99+
*/
100+
constructor(
101+
address _initialOwner
102+
) {
103+
_transferOwnership(_initialOwner);
104+
}
105+
106+
/*//////////////////////////////////////////////////////////////
107+
GETTERS
108+
//////////////////////////////////////////////////////////////*/
109+
110+
/**
111+
* @inheritdoc ISnapchainConfigRegistry
112+
*/
113+
function validatorSetCount() public view returns (uint256) {
114+
return _validatorSets.length;
115+
}
116+
117+
/**
118+
* @inheritdoc ISnapchainConfigRegistry
119+
*/
120+
function validatorSetAt(
121+
uint256 index
122+
) public view returns (ValidatorSet memory) {
123+
if (index >= _validatorSets.length) revert InvalidRange();
124+
return _validatorSets[index];
125+
}
126+
127+
/**
128+
* @inheritdoc ISnapchainConfigRegistry
129+
*/
130+
function validatorSets() external view returns (ValidatorSet[] memory) {
131+
uint256 count = _validatorSets.length;
132+
ValidatorSet[] memory sets = new ValidatorSet[](count);
133+
for (uint256 i; i < count; ++i) {
134+
sets[i] = _validatorSets[i];
135+
}
136+
return sets;
137+
}
138+
139+
/*//////////////////////////////////////////////////////////////
140+
PERMISSIONED ACTIONS
141+
//////////////////////////////////////////////////////////////*/
142+
143+
/**
144+
* @inheritdoc ISnapchainConfigRegistry
145+
*/
146+
function appendValidatorSet(
147+
uint64 effectiveAt,
148+
uint32[] calldata shardIds,
149+
bytes32[] calldata validatorPublicKeys
150+
) external onlyOwner {
151+
uint256 index = _validatorSets.length;
152+
_validatorSets.push();
153+
_writeValidatorSet(index, effectiveAt, shardIds, validatorPublicKeys);
154+
155+
emit AppendValidatorSet(index, effectiveAt, shardIds, validatorPublicKeys, ++configVersion);
156+
}
157+
158+
/**
159+
* @inheritdoc ISnapchainConfigRegistry
160+
*/
161+
function amendLatestValidatorSet(
162+
uint64 effectiveAt,
163+
uint32[] calldata shardIds,
164+
bytes32[] calldata validatorPublicKeys
165+
) external onlyOwner {
166+
uint256 count = _validatorSets.length;
167+
if (count == 0) revert NoValidatorSets();
168+
169+
uint256 index = count - 1;
170+
_writeValidatorSet(index, effectiveAt, shardIds, validatorPublicKeys);
171+
172+
emit AmendValidatorSet(index, effectiveAt, shardIds, validatorPublicKeys, ++configVersion);
173+
}
174+
175+
/**
176+
* @inheritdoc ISnapchainConfigRegistry
177+
*/
178+
function removeLatestValidatorSet() external onlyOwner {
179+
uint256 count = _validatorSets.length;
180+
if (count == 0) revert NoValidatorSets();
181+
182+
uint256 index = count - 1;
183+
184+
// pop() is specified to clear the nested dynamic arrays, but clearing them explicitly means
185+
// that getting this wrong is not possible: a later push() inheriting stale contents would
186+
// silently resurrect a removed validator.
187+
ValidatorSet storage validatorSet = _validatorSets[index];
188+
delete validatorSet.shardIds;
189+
delete validatorSet.validatorPublicKeys;
190+
_validatorSets.pop();
191+
192+
emit RemoveValidatorSet(index, ++configVersion);
193+
}
194+
195+
/**
196+
* @inheritdoc ISnapchainConfigRegistry
197+
*/
198+
function setBootstrapPeers(
199+
string calldata peers
200+
) external onlyOwner {
201+
_validatePeerString(peers);
202+
203+
emit SetBootstrapPeers(bootstrapPeers, peers, ++configVersion);
204+
205+
bootstrapPeers = peers;
206+
}
207+
208+
/**
209+
* @inheritdoc ISnapchainConfigRegistry
210+
*/
211+
function setDirectPeers(
212+
string calldata peers
213+
) external onlyOwner {
214+
_validatePeerString(peers);
215+
216+
emit SetDirectPeers(directPeers, peers, ++configVersion);
217+
218+
directPeers = peers;
219+
}
220+
221+
/*//////////////////////////////////////////////////////////////
222+
HELPERS
223+
//////////////////////////////////////////////////////////////*/
224+
225+
/**
226+
* @dev Validate and write a validator set at `index`, which must already exist.
227+
*
228+
* Append and amend share this one path, so an amend cannot drift from an append, and
229+
* amending entry N is bounded by the entries before it for free. Members are assigned
230+
* individually rather than assigning a whole struct, which sidesteps the compiler's
231+
* unimplemented memory-to-storage copy for structs with nested dynamic arrays and
232+
* correctly resizes the existing arrays on an amend.
233+
*/
234+
function _writeValidatorSet(
235+
uint256 index,
236+
uint64 effectiveAt,
237+
uint32[] calldata shardIds,
238+
bytes32[] calldata validatorPublicKeys
239+
) internal {
240+
_validateShardIds(shardIds);
241+
_validateValidatorPublicKeys(validatorPublicKeys);
242+
_validateEffectiveAt(index, effectiveAt, shardIds);
243+
244+
ValidatorSet storage validatorSet = _validatorSets[index];
245+
validatorSet.effectiveAt = effectiveAt;
246+
validatorSet.shardIds = shardIds;
247+
validatorSet.validatorPublicKeys = validatorPublicKeys;
248+
}
249+
250+
/**
251+
* @dev Require `effectiveAt` to be at or after the most recent preceding entry **for each shard
252+
* this entry governs**, considering only entries before `index`.
253+
*
254+
* The comparison is per shard because `effectiveAt` is a per-shard Snapchain block height.
255+
* Shard 0 and the message shards advance on independent counters, so two entries governing
256+
* disjoint shards carry heights on different clocks and cannot be meaningfully ordered
257+
* against each other. Bounding an entry against its immediate array predecessor regardless
258+
* of shard would make a legitimate rollout unrepresentable: with shard 0 last set at height
259+
* 50_000_000 and shard 1 currently at 30_000_000, no correct shard-1 entry could be
260+
* appended, and the only accepted values would activate the set at the wrong height.
261+
*
262+
* Entries governing disjoint shards are therefore unconstrained relative to one another.
263+
* Within a shard the bound is `>=` rather than `>`, since a rollout may legitimately land
264+
* at a height already used by a sibling entry.
265+
*
266+
* Snapchain's own scan is order-independent (see the ordering section of
267+
* docs/snapchain-config-registry.md), so this is a guard against operator error rather than
268+
* a consumer requirement. It is worth keeping: a too-low height for a shard backdates that
269+
* set over already-committed blocks, changing which keys verify historical commits and
270+
* breaking sync from genesis.
271+
*
272+
* Walks backwards from `index`, clearing each shard as its most recent governing entry is
273+
* found and stopping once every shard is resolved. In practice a shard is named by one of
274+
* the last few entries, so the scan terminates almost immediately; the worst case is bounded
275+
* by the array length on an owner-only call made a handful of times a year.
276+
*/
277+
function _validateEffectiveAt(uint256 index, uint64 effectiveAt, uint32[] calldata shardIds) internal view {
278+
uint256 shardCount = shardIds.length;
279+
uint256 unresolved = (1 << shardCount) - 1;
280+
281+
for (uint256 i = index; i != 0 && unresolved != 0;) {
282+
unchecked {
283+
--i;
284+
}
285+
ValidatorSet storage prior = _validatorSets[i];
286+
uint32[] storage priorShardIds = prior.shardIds;
287+
uint256 priorLength = priorShardIds.length;
288+
289+
for (uint256 s; s < shardCount; ++s) {
290+
if ((unresolved >> s) & 1 == 0) continue;
291+
292+
for (uint256 p; p < priorLength; ++p) {
293+
if (priorShardIds[p] != shardIds[s]) continue;
294+
if (effectiveAt < prior.effectiveAt) revert InvalidEffectiveAt();
295+
unresolved &= ~(1 << s);
296+
break;
297+
}
298+
}
299+
}
300+
}
301+
302+
/**
303+
* @dev Require a non-empty, bounded, duplicate-free list of shard ids.
304+
*/
305+
function _validateShardIds(
306+
uint32[] calldata shardIds
307+
) internal pure {
308+
uint256 length = shardIds.length;
309+
if (length == 0 || length > MAX_SHARD_IDS) revert InvalidShardIds();
310+
311+
for (uint256 i; i < length; ++i) {
312+
for (uint256 j = i + 1; j < length; ++j) {
313+
if (shardIds[i] == shardIds[j]) revert InvalidShardIds();
314+
}
315+
}
316+
}
317+
318+
/**
319+
* @dev Require a non-empty, bounded, duplicate-free list of non-zero public keys.
320+
*
321+
* A duplicate key would silently double that operator's voting weight, so this is a safety
322+
* property rather than hygiene. Ed25519 point validity is deliberately not checked: there
323+
* is no cheap onchain test, and an invalid point fails loudly at node startup.
324+
*/
325+
function _validateValidatorPublicKeys(
326+
bytes32[] calldata validatorPublicKeys
327+
) internal pure {
328+
uint256 length = validatorPublicKeys.length;
329+
if (length == 0 || length > MAX_VALIDATOR_PUBLIC_KEYS) revert InvalidValidatorPublicKeys();
330+
331+
for (uint256 i; i < length; ++i) {
332+
if (validatorPublicKeys[i] == bytes32(0)) revert InvalidValidatorPublicKeys();
333+
for (uint256 j = i + 1; j < length; ++j) {
334+
if (validatorPublicKeys[i] == validatorPublicKeys[j]) revert InvalidValidatorPublicKeys();
335+
}
336+
}
337+
}
338+
339+
/**
340+
* @dev Require every byte of a peer string to be in the allowlist. The empty string is valid:
341+
* Snapchain defaults both peer lists to empty and a seed node legitimately has none.
342+
*
343+
* Offending input is rejected rather than sanitized. Silently stripping characters would
344+
* store a value the owner did not write and hide the mistake at the moment it matters.
345+
*/
346+
function _validatePeerString(
347+
string calldata peers
348+
) internal pure {
349+
bytes calldata raw = bytes(peers);
350+
uint256 length = raw.length;
351+
if (length > MAX_PEER_STRING_LENGTH) revert InvalidPeerString();
352+
353+
for (uint256 i; i < length; ++i) {
354+
uint8 char = uint8(raw[i]);
355+
if (char > 0x7F) revert InvalidPeerString();
356+
uint256 allowed = char < 0x40 ? _PEER_CHARS_LO : _PEER_CHARS_HI;
357+
// Parenthesised for the reader, not the compiler: Solidity binds `&` tighter than
358+
// `==`, the opposite of C, so this groups correctly either way.
359+
if (((allowed >> (char & 0x3F)) & 1) == 0) revert InvalidPeerString();
360+
}
361+
}
362+
}

0 commit comments

Comments
 (0)