// The live feed contract, as code: validate(reply) -> [] or a list of one-line problems. The key lists come from // scene/feed-contract.json (the app's Rust test reads the same file). Extra keys are refused outside the listed extensions, so // "the same JSON shape" is literal: a producer that adds a field changes the contract file first. import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; export const CONTRACT = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'scene', 'feed-contract.json'), 'utf8')); const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v); const minerId = new RegExp(CONTRACT.miner_id); function keys(problems, where, obj, want, extensions = []) { if (!isObj(obj)) { problems.push(where + ' is not an object'); return false; } for (const k of want) if (!(k in obj)) problems.push(where + ' lacks ' + k); for (const k of Object.keys(obj)) if (!want.includes(k) && !extensions.includes(k)) problems.push(where + ' carries an unknown key ' + k); return true; } const numOrNull = (problems, where, v) => { if (v !== null && typeof v !== 'number') problems.push(where + ' is neither a number nor null'); }; const bool = (problems, where, v) => { if (typeof v !== 'boolean') problems.push(where + ' is not a boolean'); }; export function validate(reply) { const p = []; if (!isObj(reply)) return ['reply is not an object']; if (reply.ok !== true) return ['reply.ok is not true' + (reply.error ? ' (' + reply.error + ')' : '')]; keys(p, 'reply', reply, CONTRACT.top, CONTRACT.top_extensions); if (typeof reply.now !== 'string' || Number.isNaN(Date.parse(reply.now))) p.push('now is not an ISO 8601 string'); if (keys(p, 'state', reply.state, CONTRACT.state, CONTRACT.state_extensions)) { bool(p, 'state.stale', reply.state.stale); numOrNull(p, 'state.age_s', reply.state.age_s); numOrNull(p, 'state.height', reply.state.height); if (!Array.isArray(reply.state.blocks_per_minute)) p.push('state.blocks_per_minute is not an array'); } if (!Array.isArray(reply.blocks)) p.push('blocks is not an array'); else reply.blocks.forEach((b, i) => { const w = 'blocks[' + i + ']'; if (!keys(p, w, b, CONTRACT.block)) return; if (typeof b.hash !== 'string' || !/^[0-9a-f]{1,64}$/.test(b.hash)) p.push(w + '.hash is not a hex string'); if (typeof b.ts !== 'number' || b.ts < 1e12) p.push(w + '.ts is not milliseconds'); numOrNull(p, w + '.number', b.number); numOrNull(p, w + '.blue_score', b.blue_score); numOrNull(p, w + '.daa', b.daa); numOrNull(p, w + '.rx', b.rx); if (!Array.isArray(b.parents) || b.parents.some(x => typeof x !== 'string')) p.push(w + '.parents is not an array of strings'); bool(p, w + '.chain', b.chain); bool(p, w + '.locked', b.locked); bool(p, w + '.final', b.final); bool(p, w + '.proven', b.proven); if (b.miner !== null && !(typeof b.miner === 'string' && minerId.test(b.miner))) p.push(w + '.miner is not an 8-hex vote key id (' + JSON.stringify(b.miner) + ')'); if (!CONTRACT.color.includes(b.color)) p.push(w + '.color is not one of ' + CONTRACT.color.join(', ')); if (!Array.isArray(b.shards)) p.push(w + '.shards is not an array'); else b.shards.forEach((s, j) => { if (keys(p, w + '.shards[' + j + ']', s, CONTRACT.shard) && !CONTRACT.shard_state.includes(s.state)) p.push(w + '.shards[' + j + '].state is not one of ' + CONTRACT.shard_state.join(', ')); }); }); if (!Array.isArray(reply.miners)) p.push('miners is not an array'); else reply.miners.forEach((m, i) => { if (keys(p, 'miners[' + i + ']', m, CONTRACT.miner) && !(typeof m.id === 'string' && minerId.test(m.id))) p.push('miners[' + i + '].id is not an 8-hex id'); }); if (!Array.isArray(reply.events)) p.push('events is not an array'); else reply.events.forEach((e, i) => keys(p, 'events[' + i + ']', e, CONTRACT.event)); if (keys(p, 'finality', reply.finality, CONTRACT.finality)) { if (!Array.isArray(reply.finality.checkpoints)) p.push('finality.checkpoints is not an array'); else reply.finality.checkpoints.forEach((c, i) => { if (keys(p, 'finality.checkpoints[' + i + ']', c, CONTRACT.checkpoint) && !CONTRACT.checkpoint_state.includes(c.state)) p.push('finality.checkpoints[' + i + '].state is not pending or locked'); }); } if (isObj(reply.proving)) keys(p, 'proving', reply.proving, reply.proving.supported ? CONTRACT.proving_supported : CONTRACT.proving_unsupported); else p.push('proving is not an object'); return p; }