Skip to content

Commit 3fdd301

Browse files
committed
feat: render registry state as TOML via configToml()
Adds the rendering layer: configToml() as the canonical getter, plus validatorSetsToml(start, end) and peersToml() underneath it. Assembly is two-level. Each validator set is built on its own and the entries are joined into the result exactly once, through a helper that sizes the output up front and mcopy's each part in. A flat one-concat-per-line loop would be quadratic in output bytes and the EVM's quadratic memory term compounds it; today's ten entries would still work either way, but at around a hundred the difference is the getter working versus exceeding every public eth_call gas cap. Since the list grows by one entry per shard per rotation, that is a few years out, and it is not fixable after deployment without a migration. Pagination is the second half of the same insurance. Each rendered block carries its own trailing blank line rather than sitting between separators, which is what makes any split compose back into exactly the unpaginated document. Key lines are written directly into a pre-sized buffer in assembly. The straightforward version -- allocate a string per key, fill it with 64 bounds-checked single-byte writes, then concat it in -- costs roughly 3.5x more across the whole document, which at scale is the difference between 11M and 3M gas for the same output bytes. That is measured, not estimated: the accompanying gas guard failed at 10M before this and passes at 3.2M after, with the golden output unchanged.
1 parent 81154f9 commit 3fdd301

2 files changed

Lines changed: 208 additions & 1 deletion

File tree

src/SnapchainConfigRegistry.sol

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pragma solidity 0.8.29;
33

44
import {Ownable2Step} from "openzeppelin/contracts/access/Ownable2Step.sol";
5+
import {Strings} from "openzeppelin/contracts/utils/Strings.sol";
56

67
import {ISnapchainConfigRegistry} from "./interfaces/ISnapchainConfigRegistry.sol";
78

@@ -58,6 +59,18 @@ contract SnapchainConfigRegistry is ISnapchainConfigRegistry, Ownable2Step {
5859
*/
5960
uint256 internal constant _PEER_CHARS_HI = 0x07FFFFFE87FFFFFE;
6061

62+
/**
63+
* @dev Lowercase hex alphabet, left-aligned in a word so that a nibble indexes it directly via
64+
* the `byte` opcode, which counts from the most significant byte.
65+
*/
66+
uint256 internal constant _HEX_SYMBOLS_ALIGNED = 0x3031323334353637383961626364656600000000000000000000000000000000;
67+
68+
/**
69+
* @dev Bytes in one rendered key line: two spaces, a quote, 64 hex characters, a quote, a
70+
* comma, and a newline.
71+
*/
72+
uint256 internal constant _KEY_LINE_LENGTH = 70;
73+
6174
/*//////////////////////////////////////////////////////////////
6275
STORAGE
6376
//////////////////////////////////////////////////////////////*/
@@ -136,6 +149,31 @@ contract SnapchainConfigRegistry is ISnapchainConfigRegistry, Ownable2Step {
136149
return sets;
137150
}
138151

152+
/*//////////////////////////////////////////////////////////////
153+
TOML RENDERING
154+
//////////////////////////////////////////////////////////////*/
155+
156+
/**
157+
* @inheritdoc ISnapchainConfigRegistry
158+
*/
159+
function configToml() external view returns (string memory) {
160+
return string(_rangeToml(0, _validatorSets.length, true));
161+
}
162+
163+
/**
164+
* @inheritdoc ISnapchainConfigRegistry
165+
*/
166+
function validatorSetsToml(uint256 start, uint256 end) external view returns (string memory) {
167+
return string(_rangeToml(start, end, false));
168+
}
169+
170+
/**
171+
* @inheritdoc ISnapchainConfigRegistry
172+
*/
173+
function peersToml() public view returns (string memory) {
174+
return string(_peersToml());
175+
}
176+
139177
/*//////////////////////////////////////////////////////////////
140178
PERMISSIONED ACTIONS
141179
//////////////////////////////////////////////////////////////*/
@@ -219,7 +257,148 @@ contract SnapchainConfigRegistry is ISnapchainConfigRegistry, Ownable2Step {
219257
}
220258

221259
/*//////////////////////////////////////////////////////////////
222-
HELPERS
260+
RENDERING HELPERS
261+
//////////////////////////////////////////////////////////////*/
262+
263+
/**
264+
* @dev Render validator sets `[start, end)`, optionally followed by the `[gossip]` table.
265+
*
266+
* Assembly is two-level and deliberately so. Each entry is built on its own, and the
267+
* entries are joined into the result exactly once. A flat one-concat-per-line loop would
268+
* be quadratic in output bytes, and the EVM's quadratic memory term compounds it: at a
269+
* hundred entries that difference is the getter working versus exceeding every public
270+
* eth_call gas cap.
271+
*/
272+
function _rangeToml(uint256 start, uint256 end, bool includePeers) internal view returns (bytes memory) {
273+
if (start > end || end > _validatorSets.length) revert InvalidRange();
274+
275+
uint256 count = end - start;
276+
bytes[] memory parts = new bytes[](includePeers ? count + 1 : count);
277+
for (uint256 i; i < count; ++i) {
278+
parts[i] = _validatorSetToml(_validatorSets[start + i]);
279+
}
280+
if (includePeers) parts[count] = _peersToml();
281+
282+
return _join(parts);
283+
}
284+
285+
/**
286+
* @dev Render one validator set as a `[[consensus.validator_sets]]` block.
287+
*
288+
* The block carries its own trailing blank line rather than sitting between separators.
289+
* That is what makes paginated ranges concatenate into exactly the unpaginated document.
290+
*/
291+
function _validatorSetToml(
292+
ValidatorSet storage validatorSet
293+
) internal view returns (bytes memory) {
294+
uint32[] storage shardIds = validatorSet.shardIds;
295+
bytes32[] storage publicKeys = validatorSet.validatorPublicKeys;
296+
297+
// Bounded at MAX_SHARD_IDS, so the quadratic term here is irrelevant.
298+
bytes memory shardList = bytes(Strings.toString(shardIds[0]));
299+
for (uint256 i = 1; i < shardIds.length; ++i) {
300+
shardList = bytes.concat(shardList, ", ", bytes(Strings.toString(shardIds[i])));
301+
}
302+
303+
uint256 keyCount = publicKeys.length;
304+
bytes memory keyLines = new bytes(keyCount * _KEY_LINE_LENGTH);
305+
for (uint256 i; i < keyCount; ++i) {
306+
_writeKeyLine(keyLines, i * _KEY_LINE_LENGTH, publicKeys[i]);
307+
}
308+
309+
return bytes.concat(
310+
"[[consensus.validator_sets]]\n",
311+
"effective_at = ",
312+
bytes(Strings.toString(validatorSet.effectiveAt)),
313+
"\n",
314+
"shard_ids = [",
315+
shardList,
316+
"]\n",
317+
"validator_public_keys = [\n",
318+
keyLines,
319+
"]\n\n"
320+
);
321+
}
322+
323+
/**
324+
* @dev Render the `[gossip]` table. Both values are safe to interpolate unescaped because the
325+
* setters reject every byte that could close the string literal.
326+
*/
327+
function _peersToml() internal view returns (bytes memory) {
328+
return bytes.concat(
329+
"[gossip]\n",
330+
"bootstrap_peers = \"",
331+
bytes(bootstrapPeers),
332+
"\"\n",
333+
"direct_peers = \"",
334+
bytes(directPeers),
335+
"\"\n"
336+
);
337+
}
338+
339+
/**
340+
* @dev Concatenate `parts` into one buffer with a single allocation and one copy per part.
341+
*/
342+
function _join(
343+
bytes[] memory parts
344+
) internal pure returns (bytes memory) {
345+
uint256 total;
346+
for (uint256 i; i < parts.length; ++i) {
347+
total += parts[i].length;
348+
}
349+
350+
bytes memory out = new bytes(total);
351+
uint256 offset;
352+
for (uint256 i; i < parts.length; ++i) {
353+
bytes memory part = parts[i];
354+
uint256 length = part.length;
355+
assembly ("memory-safe") {
356+
mcopy(add(add(out, 0x20), offset), add(part, 0x20), length)
357+
}
358+
offset += length;
359+
}
360+
return out;
361+
}
362+
363+
/**
364+
* @dev Write one ` "<64 hex chars>",\n` line into `out` at `offset`.
365+
*
366+
* Written straight into the caller's buffer rather than returned as a fresh string, and in
367+
* assembly rather than through indexed writes to a `bytes memory`. Both matter: this runs
368+
* once per key per call, and the Solidity version -- an allocation, 64 bounds-checked
369+
* single-byte writes, then a concat that copies the result again -- costs roughly an order
370+
* of magnitude more. That difference is what decides whether the getter fits inside a
371+
* public RPC's eth_call gas cap once the history grows.
372+
*/
373+
function _writeKeyLine(bytes memory out, uint256 offset, bytes32 key) internal pure {
374+
assembly ("memory-safe") {
375+
let ptr := add(add(out, 0x20), offset)
376+
377+
// ` "`
378+
mstore8(ptr, 0x20)
379+
mstore8(add(ptr, 1), 0x20)
380+
mstore8(add(ptr, 2), 0x22)
381+
382+
// byte(n, symbols) indexes from the most significant byte, so the alphabet is
383+
// left-aligned in the word and a nibble indexes it directly.
384+
let symbols := _HEX_SYMBOLS_ALIGNED
385+
let hexPtr := add(ptr, 3)
386+
for { let i := 0 } lt(i, 32) { i := add(i, 1) } {
387+
let word := byte(i, key)
388+
let pos := add(hexPtr, mul(i, 2))
389+
mstore8(pos, byte(shr(4, word), symbols))
390+
mstore8(add(pos, 1), byte(and(word, 0x0F), symbols))
391+
}
392+
393+
// `",\n`
394+
mstore8(add(ptr, 67), 0x22)
395+
mstore8(add(ptr, 68), 0x2C)
396+
mstore8(add(ptr, 69), 0x0A)
397+
}
398+
}
399+
400+
/*//////////////////////////////////////////////////////////////
401+
MUTATION HELPERS
223402
//////////////////////////////////////////////////////////////*/
224403

225404
/**

src/interfaces/ISnapchainConfigRegistry.sol

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,34 @@ interface ISnapchainConfigRegistry {
182182
*/
183183
function validatorSets() external view returns (ValidatorSet[] memory);
184184

185+
/*//////////////////////////////////////////////////////////////
186+
TOML RENDERING
187+
//////////////////////////////////////////////////////////////*/
188+
189+
/**
190+
* @notice Render the whole registry as a TOML fragment ready to merge into a node's config.
191+
* @dev The canonical getter, and what every normal client calls. Equal to
192+
* `validatorSetsToml(0, validatorSetCount())` followed by `peersToml()`. See
193+
* docs/snapchain-config-registry.md for the exact output grammar, which is part of this
194+
* contract's public API.
195+
*/
196+
function configToml() external view returns (string memory);
197+
198+
/**
199+
* @notice Render a half-open range of validator sets as TOML.
200+
* @dev An escape hatch for the day the whole document outgrows a public RPC's eth_call gas cap.
201+
* Ranges compose exactly: for any k in [start, end],
202+
* `validatorSetsToml(start, k) + validatorSetsToml(k, end) == validatorSetsToml(start, end)`.
203+
* @param start First index, inclusive.
204+
* @param end Last index, exclusive.
205+
*/
206+
function validatorSetsToml(uint256 start, uint256 end) external view returns (string memory);
207+
208+
/**
209+
* @notice Render the gossip peer lists as a TOML `[gossip]` table.
210+
*/
211+
function peersToml() external view returns (string memory);
212+
185213
/*//////////////////////////////////////////////////////////////
186214
PERMISSIONED ACTIONS
187215
//////////////////////////////////////////////////////////////*/

0 commit comments

Comments
 (0)