import { createHash, createPublicKey, verify } from 'node:crypto'; import { z } from 'zod'; import { NETWORK, CONSENSUS, MAX_TARGET, headerMessage, blockClaimMessage, nftTransferMessage, nftTradeMessage, statsSnapshotTuple, type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type NftStateLayer, isNftTrade, type NftStateView, type NftTrade, type TestnetTransaction, type MiningTemplate } from '../../shared/testnet.ts'; import type { Article } from '../../shared/protocol.ts'; import { deriveStats, type MintStatsSnapshot } from '../../shared/enrichment.ts'; import type { PowEngine } from './randomx.ts'; import { NETWORK_GENESIS_TIME } from '../../shared/network.ts'; export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex'); export { MAX_TARGET }; // Consensus manifest: an independently running node derives exactly the same genesis. export const GENESIS_TIME = NETWORK_GENESIS_TIME; export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET])); export const GENESIS_HASH = sha256(`${NETWORK}:genesis:${CHAIN_ID}`); export const INITIAL_KEY = sha256(`${NETWORK}:initial-key:${CHAIN_ID}`); const hex = z.string().regex(/^[a-f0-9]{64}$/); const key = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256); const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88); const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); export const transferSchema = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), cardHash: hex, from: hex, to: hex, sequence: integer, publicKey: key, signature }).strict(); const tradeLegSchema = z.object({ cardHash: hex, sequence: integer }).strict(); export const tradeSchema = z.object({ type: z.literal('trade'), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), proposer: hex, counterparty: hex, offered: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards), requested: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards), proposerPublicKey: key, proposerSignature: signature, counterpartyPublicKey: key, counterpartySignature: signature }).strict().superRefine((trade, ctx) => { const offered = new Set(trade.offered.map(leg => leg.cardHash)); const requested = new Set(trade.requested.map(leg => leg.cardHash)); if (trade.proposer === trade.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' }); if (offered.size !== trade.offered.length || requested.size !== trade.requested.length || trade.requested.some(leg => offered.has(leg.cardHash))) { ctx.addIssue({ code: 'custom', message: 'Trade cards must be unique and cannot appear on both sides' }); } if (!verifySignature(trade.proposerPublicKey, trade.proposer, nftTradeMessage(trade), trade.proposerSignature) || !verifySignature(trade.counterpartyPublicKey, trade.counterparty, nftTradeMessage(trade), trade.counterpartySignature)) { ctx.addIssue({ code: 'custom', message: 'Trade requires valid signatures from both parties' }); } }); export const transactionSchema = z.union([transferSchema, tradeSchema]); const articleSchema = z.object({ pageId: integer.positive(), title: z.string().trim().min(1).max(200), extract: z.string().trim().min(1).max(1000), image: z.string().url().max(1500), url: z.string().url().max(500), revisionId: integer.positive(), source: z.literal('live'), category: z.string().trim().min(1).max(50) }).strict().superRefine((article, ctx) => { if (article.url !== `https://en.wikipedia.org/?curid=${article.pageId}`) { ctx.addIssue({ code: 'custom', message: 'Article URL must identify its English Wikipedia page', path: ['url'] }); } try { const image = new URL(article.image); if (image.protocol !== 'https:' || !['https://upload.wikimedia.org', 'https://thumb.wikimedia.org'].includes(image.origin) || image.username || image.password || image.hash) { ctx.addIssue({ code: 'custom', message: 'Article image must use a Wikimedia HTTPS host', path: ['image'] }); } } catch { ctx.addIssue({ code: 'custom', message: 'Invalid article image URL', path: ['image'] }); } }); const monthSchema = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/); const statsSnapshotSchema = z.object({ capturedAt: z.string().datetime(), revisionId: integer.positive(), pageBytes: integer, languageCount: integer, languagesTruncated: z.boolean(), pageviews: z.object({ status: z.enum(['available', 'unavailable']), months: z.array(z.object({ month: monthSchema, views: integer }).strict()).max(6), latestMonth: monthSchema, total: integer.nullable(), dailyAverage: z.number().min(0).nullable(), trendPercent: z.number().finite().nullable() }).strict(), stats: z.object({ attention: z.number().int().min(0).max(100).nullable(), depth: z.number().int().min(0).max(100), reach: z.number().int().min(0).max(100) }).strict() }).strict() .superRefine((snapshot, ctx) => { const views = snapshot.pageviews; if (views.status === 'available') { if (views.months.length !== 6 || views.months[5]?.month !== views.latestMonth) { ctx.addIssue({ code: 'custom', message: 'Available pageviews require six months ending at latestMonth', path: ['pageviews', 'months'] }); } else { const total = views.months.reduce((sum, item) => sum + item.views, 0); const latest = views.months[5].views; const prior = views.months[4].views; const [year, month] = views.latestMonth.split('-').map(Number); const days = new Date(Date.UTC(year, month, 0)).getUTCDate(); const trend = prior > 0 ? (latest - prior) / prior * 100 : null; if (!Number.isSafeInteger(total) || views.total !== total || views.dailyAverage !== latest / days || views.trendPercent !== trend) { ctx.addIssue({ code: 'custom', message: 'Pageview summaries do not match the monthly values', path: ['pageviews'] }); } } } else if (views.months.length !== 0 || views.total !== null || views.dailyAverage !== null || views.trendPercent !== null) { ctx.addIssue({ code: 'custom', message: 'Unavailable pageviews must not include invented counts', path: ['pageviews'] }); } const monthlyViews = views.status === 'available' ? views.months[5]?.views ?? null : null; const expected = deriveStats(snapshot.pageBytes, snapshot.languageCount, monthlyViews); if (snapshot.stats.attention !== expected.attention || snapshot.stats.depth !== expected.depth || snapshot.stats.reach !== expected.reach) { ctx.addIssue({ code: 'custom', message: 'Derived statistics do not match the page metrics', path: ['stats'] }); } }); export const blockSchema = z.object({ version: z.literal(6), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID), height: integer, previousHash: hex, createdAt: integer, article: articleSchema, statsSnapshot: statsSnapshotSchema, owner: hex, publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex, hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transactionSchema).max(CONSENSUS.maxTransactions) }).strict(); export function address(publicKey: string): string { return sha256(Buffer.from(publicKey, 'base64')); } export function verifySignature(publicKey: string, owner: string, message: string, sig: string): boolean { try { const bytes = Buffer.from(publicKey, 'base64'); if (bytes.toString('base64') !== publicKey || address(publicKey) !== owner) return false; const k = createPublicKey({ key: bytes, type: 'spki', format: 'der' }); if (k.asymmetricKeyType !== 'ec' || k.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return false; if (!k.export({ format: 'der', type: 'spki' }).equals(bytes)) return false; return verify('sha256', Buffer.from(message), { key: k, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig, 'base64')); } catch { return false; } } export function transferId(t: NftTransfer): string { // Exclude malleable ECDSA signature bytes from transaction identity. return sha256(nftTransferMessage(t)); } export function transactionId(transaction: TestnetTransaction): string { if (isNftTrade(transaction)) return sha256(nftTradeMessage(transaction)); return transferId(transaction); } export function merkleRoot(leaves: string[]): string { if (!leaves.length) return sha256(`${NETWORK}:empty`); let layer = leaves.map(leaf => sha256(Buffer.concat([Buffer.from([0]), Buffer.from(leaf,'hex')]))); while (layer.length > 1) { const next: string[] = []; for (let i = 0; i < layer.length; i += 2) next.push(sha256(Buffer.concat([Buffer.from([1]), Buffer.from(layer[i], 'hex'), Buffer.from(layer[i + 1] ?? layer[i], 'hex')]))); layer = next; } // Commit the count as well: odd-leaf padding must not make [a,b,c] equal [a,b,c,c]. return sha256(JSON.stringify([NETWORK,'merkle',leaves.length,layer[0]])); } export function materializeState(state: NftStateView): NftState { const layers: NftState[] = []; let cursor: NftStateView = state; while (isStateLayer(cursor)) { layers.push(cursor.changes); cursor = cursor.parent; } const result: NftState = Object.assign(Object.create(null) as NftState, cursor as NftState); for (let i = layers.length - 1; i >= 0; i--) Object.assign(result, layers[i]); return result; } function stateEntry(state: NftStateView, id: string): NftState[string] | undefined { let cursor: NftStateView = state; while (isStateLayer(cursor)) { if (Object.hasOwn(cursor.changes, id)) return cursor.changes[id]; cursor = cursor.parent; } return (cursor as NftState)[id]; } function isStateLayer(state: NftStateView): state is NftStateLayer { return (state as NftStateLayer).kind === 'layer'; } export function stateRoot(state: NftStateView): string { const materialized = materializeState(state); return merkleRoot(Object.keys(materialized).sort().map(id => { const n = materialized[id]; return sha256(JSON.stringify([id, n.owner, n.sequence, n.mintedHeight, n.pageId])); })); } export function mintId(h: Pick): string { return sha256(JSON.stringify([NETWORK, CHAIN_ID, 'mint', h.previousHash, h.height, h.owner, h.article.pageId, statsSnapshotTuple(h.statsSnapshot)])); } export function miningBlob(header: BlockHeader): Buffer { const nonce = Buffer.alloc(8); nonce.writeBigUInt64LE(BigInt(header.nonce)); return Buffer.concat([Buffer.from(sha256(headerMessage(header)), 'hex'), nonce]); } export function workFor(target: string): bigint { return (1n << 256n) / (BigInt(`0x${target}`) + 1n); } export function expectedKey(ancestors: TestnetBlock[], height: number): string { const keyHeight = Math.floor(Math.max(0, height - CONSENSUS.epochDelay) / CONSENSUS.epochBlocks) * CONSENSUS.epochBlocks; return keyHeight === 0 ? INITIAL_KEY : ancestors[keyHeight - 1].hash; } export function expectedTarget(ancestors: TestnetBlock[]): string { if (!ancestors.length) return MAX_TARGET; const parent = ancestors.at(-1)!; if (ancestors.length % CONSENSUS.retargetInterval !== 0) return parent.target; const start = ancestors[ancestors.length - CONSENSUS.retargetInterval]; const expected = (CONSENSUS.retargetInterval - 1) * CONSENSUS.targetSeconds; const elapsed = Math.max(Math.floor(expected / 4), Math.min(expected * 4, parent.createdAt - start.createdAt)); const value = BigInt(`0x${parent.target}`) * BigInt(elapsed) / BigInt(expected); return (value < 1n ? 1n : value > BigInt(`0x${MAX_TARGET}`) ? BigInt(`0x${MAX_TARGET}`) : value).toString(16).padStart(64, '0'); } export function medianTime(ancestors: TestnetBlock[]): number { const values = ancestors.slice(-CONSENSUS.medianWindow).map(b => b.createdAt).sort((a,b) => a-b); return values.length ? values[Math.floor(values.length / 2)] : GENESIS_TIME; } export function applyTransfer(state: NftState, tx: NftTransfer, height: number): void { if (!transferSchema.safeParse(tx).success) throw new Error('Invalid transfer encoding/network'); const nft = state[tx.cardHash]; if (!nft || nft.owner !== tx.from || tx.from === tx.to || nft.sequence !== tx.sequence) throw new Error('Invalid NFT owner/sequence'); if (height - nft.mintedHeight < CONSENSUS.coinbaseMaturity) throw new Error('NFT coinbase is immature'); if (!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Invalid transfer signature'); state[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 }; } export function applyTransferOverlay(state: NftStateView, tx: NftTransfer, height: number): NftStateLayer { if (!transferSchema.safeParse(tx).success) throw new Error('Invalid transfer encoding/network'); const nft = stateEntry(state, tx.cardHash); if (!nft || nft.owner !== tx.from || tx.from === tx.to || nft.sequence !== tx.sequence) { throw new Error('Invalid NFT owner/sequence'); } if (height - nft.mintedHeight < CONSENSUS.coinbaseMaturity) throw new Error('NFT coinbase is immature'); if (!verifySignature(tx.publicKey, tx.from, nftTransferMessage(tx), tx.signature)) throw new Error('Invalid transfer signature'); const changes: NftState = Object.create(null) as NftState; changes[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 }; return { kind: 'layer', parent: state, changes }; } export function applyTradeOverlay(state: NftStateView, trade: NftTrade, height: number): NftStateLayer { const parsed = tradeSchema.parse(trade); const allLegs = [...parsed.offered, ...parsed.requested]; const current = allLegs.map(leg => stateEntry(state, leg.cardHash)); if (current.some((entry, index) => !entry || entry.sequence !== allLegs[index].sequence || height - entry.mintedHeight < CONSENSUS.coinbaseMaturity)) { throw new Error('Trade contains missing, stale, or immature NFTs'); } if (current.slice(0, parsed.offered.length).some(entry => entry!.owner !== parsed.proposer) || current.slice(parsed.offered.length).some(entry => entry!.owner !== parsed.counterparty)) { throw new Error('Trade NFT owners do not match the signed parties'); } const changes: NftState = Object.create(null) as NftState; parsed.offered.forEach((leg, index) => { const nft = current[index]!; changes[leg.cardHash] = { ...nft, owner: parsed.counterparty, sequence: nft.sequence + 1 }; }); parsed.requested.forEach((leg, index) => { const nft = current[parsed.offered.length + index]!; changes[leg.cardHash] = { ...nft, owner: parsed.proposer, sequence: nft.sequence + 1 }; }); return { kind: 'layer', parent: state, changes }; } export function applyTransactionOverlay(state: NftStateView, transaction: TestnetTransaction, height: number): NftStateLayer { return isNftTrade(transaction) ? applyTradeOverlay(state, transaction, height) : applyTransferOverlay(state, transaction, height); } export function transition(parent: NftStateView, header: BlockHeader, txs: TestnetTransaction[]): NftStateLayer { let next = parent; const ids = new Set(); for (const transaction of txs) { const id = transactionId(transaction); if (ids.has(id)) throw new Error('Duplicate transfer'); ids.add(id); next = applyTransactionOverlay(next, transaction, header.height); } const id = mintId(header); if (stateEntry(next, id)) throw new Error('Duplicate NFT issuance'); const changes: NftState = Object.create(null) as NftState; changes[id] = { owner: header.owner, sequence: 0, mintedHeight: header.height, pageId: header.article.pageId }; return { kind: 'layer', parent: next, changes }; } export function makeTemplate(ancestors: TestnetBlock[], state: NftStateView, owner: string, publicKey: string, transactions: TestnetTransaction[], article: Article, statsSnapshot: MintStatsSnapshot, now = Math.floor(Date.now()/1000)): MiningTemplate { if (!hex.safeParse(owner).success || address(publicKey) !== owner) throw new Error('Invalid miner identity'); const parsedArticle = articleSchema.parse(article); const parsedStats = statsSnapshotSchema.parse(statsSnapshot); const previousHash = ancestors.at(-1)?.hash ?? GENESIS_HASH; const header: BlockHeader = { version: 6, network: NETWORK, chainId: CHAIN_ID, height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1), article: parsedArticle, statsSnapshot: parsedStats, owner, publicKey, nonce: 0, target: expectedTarget(ancestors), key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transactionId)), stateRoot: '' }; header.stateRoot = stateRoot(transition(state, header, transactions)); return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 }; } export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], parentState: NftStateView, pow: PowEngine, now = Math.floor(Date.now()/1000), allowHistoricalFutureTimestamp = false): Promise<{block:TestnetBlock;state:NftStateView}> { if (Buffer.byteLength(JSON.stringify(raw)) > CONSENSUS.maxBlockBytes) throw new Error('Block size limit exceeded'); const b = blockSchema.parse(raw); if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height'); if (b.createdAt <= medianTime(ancestors) || (!allowHistoricalFutureTimestamp && b.createdAt > now + CONSENSUS.maxFutureSeconds)) { throw new Error('Invalid block timestamp'); } if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key'); if (b.transactionsRoot !== merkleRoot(b.transactions.map(transactionId))) throw new Error('Invalid transaction root'); if (b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment'); const blob = miningBlob(b); if (b.hash !== sha256(blob)) throw new Error('Invalid block ID'); if (!verifySignature(b.publicKey, b.owner, blockClaimMessage(b.hash), b.signature)) throw new Error('Invalid miner signature'); if (BigInt(`0x${b.powHash}`) > BigInt(`0x${b.target}`)) throw new Error('Insufficient work'); if (await pow.hash(b.key, blob) !== b.powHash) throw new Error('Invalid RandomX proof'); // Verify native work before materializing/sorting the complete NFT state. A malformed // low-work block must not force O(total NFTs) commitment work on the request path. const next = transition(parentState, b, b.transactions); if (b.stateRoot !== stateRoot(next)) throw new Error('Invalid NFT state commitment'); return { block: b, state: next }; }