142 lines
9.8 KiB
TypeScript
142 lines
9.8 KiB
TypeScript
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
import { z } from 'zod';
|
|
import { STARTER_ARTICLES } from '../../shared/catalog.ts';
|
|
import { NETWORK, CONSENSUS, articleTuple, headerMessage, blockClaimMessage, nftTransferMessage,
|
|
type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type MiningTemplate } from '../../shared/testnet.ts';
|
|
import type { PowEngine } from './randomx.ts';
|
|
|
|
export const sha256 = (data: string | Uint8Array) => createHash('sha256').update(data).digest('hex');
|
|
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
|
|
// Consensus manifest: an independently running node derives exactly the same genesis.
|
|
export const GENESIS_TIME = 1_792_411_200;
|
|
export const CHAIN_ID = sha256(JSON.stringify([NETWORK, CONSENSUS, GENESIS_TIME, MAX_TARGET,
|
|
STARTER_ARTICLES.map(articleTuple)]));
|
|
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 articleSchema = z.object({ pageId: integer, title: z.string().max(200), extract: z.string().max(1000),
|
|
image: z.string().max(1000).optional(), url: z.string().max(500), revisionId: integer,
|
|
source: z.enum(['live', 'offline']), category: z.string().max(50) }).strict();
|
|
export const blockSchema = z.object({ version: z.literal(2), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
|
|
height: integer, previousHash: hex, createdAt: integer, article: articleSchema, owner: hex,
|
|
publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex,
|
|
hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transferSchema).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 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 stateRoot(state: NftState): string {
|
|
return merkleRoot(Object.keys(state).sort().map(id => {
|
|
const n = state[id]; return sha256(JSON.stringify([id, n.owner, n.sequence, n.mintedHeight, n.pageId]));
|
|
}));
|
|
}
|
|
export function mintId(h: Pick<BlockHeader, 'previousHash' | 'height' | 'owner' | 'article'>): string {
|
|
return sha256(JSON.stringify([NETWORK, CHAIN_ID, 'mint', h.previousHash, h.height, h.owner, h.article.pageId]));
|
|
}
|
|
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 selectArticle(previousHash: string) {
|
|
const index = Number(BigInt(`0x${sha256(`${CHAIN_ID}:article:${previousHash}`)}`) % BigInt(STARTER_ARTICLES.length));
|
|
return { ...STARTER_ARTICLES[index] };
|
|
}
|
|
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 transition(parent: NftState, header: BlockHeader, txs: NftTransfer[]): NftState {
|
|
const next = structuredClone(parent);
|
|
const ids = new Set<string>();
|
|
for (const tx of txs) {
|
|
const id = transferId(tx);
|
|
if (ids.has(id)) throw new Error('Duplicate transfer');
|
|
ids.add(id); applyTransfer(next, tx, header.height);
|
|
}
|
|
const id = mintId(header);
|
|
if (next[id]) throw new Error('Duplicate NFT issuance');
|
|
next[id] = { owner: header.owner, sequence: 0, mintedHeight: header.height, pageId: header.article.pageId };
|
|
return next;
|
|
}
|
|
export function makeTemplate(ancestors: TestnetBlock[], state: NftState, owner: string,
|
|
publicKey: string, transactions: NftTransfer[], now = Math.floor(Date.now()/1000)): MiningTemplate {
|
|
if (!hex.safeParse(owner).success || address(publicKey) !== owner) throw new Error('Invalid miner identity');
|
|
const previousHash = ancestors.at(-1)?.hash ?? GENESIS_HASH;
|
|
const header: BlockHeader = { version: 2, network: NETWORK, chainId: CHAIN_ID,
|
|
height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1),
|
|
article: selectArticle(previousHash), owner, publicKey, nonce: 0, target: expectedTarget(ancestors),
|
|
key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transferId)), 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: NftState,
|
|
pow: PowEngine, now = Math.floor(Date.now()/1000)): Promise<{block:TestnetBlock;state:NftState}> {
|
|
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) || 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 (JSON.stringify(articleTuple(b.article)) !== JSON.stringify(articleTuple(selectArticle(b.previousHash)))) throw new Error('Article is not in the consensus catalog');
|
|
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transferId))) throw new Error('Invalid transaction root');
|
|
const next = transition(parentState, b, b.transactions);
|
|
if (b.stateRoot !== stateRoot(next) || 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');
|
|
return { block: b, state: next };
|
|
} |