|
| 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