import type { DatabaseSync } from 'node:sqlite'; import { randomUUID } from 'node:crypto'; import { isNftTrade, transactionCardIds, type NftState, type TestnetBlock } from '../../shared/testnet.ts'; import { GENESIS_HASH, sha256, transactionId } from './consensus.ts'; import { RpcError } from '../resources.ts'; export type CanonicalEvent = { sequence: number; type: 'connect' | 'disconnect'; height: number; hash: string; previousHash: string; transactionIds: string[]; eventHash: string }; export type CanonicalEvents = { streamId: string; previousAnchor: string; anchor: string; events: CanonicalEvent[]; cursor: number; head: number; caughtUp: boolean }; type NftRow = { token: string; owner: string; sequence: number; minted_height: number; block_hash: string }; export type IndexedNft = { block: TestnetBlock; owner: string; sequence: number; confirmations: number }; /** Local derived indexes, NOT consensus. Writes share the ledger's block/pending transaction. */ export class CanonicalIndex { private readonly db: DatabaseSync; private streamId = ''; constructor(db: DatabaseSync) { this.db = db; } initialize(chain: TestnetBlock[], state: NftState): void { this.db.exec('BEGIN IMMEDIATE'); try { this.db.exec(`CREATE TABLE IF NOT EXISTS canonical_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS canonical_blocks(height INTEGER PRIMARY KEY,hash TEXT NOT NULL UNIQUE REFERENCES blocks(hash)); CREATE TABLE IF NOT EXISTS canonical_nfts(token TEXT PRIMARY KEY,owner TEXT NOT NULL,sequence INTEGER NOT NULL, minted_height INTEGER NOT NULL,block_hash TEXT NOT NULL REFERENCES blocks(hash)); CREATE INDEX IF NOT EXISTS canonical_owner ON canonical_nfts(owner,minted_height,token); CREATE TABLE IF NOT EXISTS canonical_transactions(id TEXT PRIMARY KEY,height INTEGER NOT NULL,block_hash TEXT NOT NULL); CREATE INDEX IF NOT EXISTS canonical_tx_height ON canonical_transactions(height); CREATE TABLE IF NOT EXISTS canonical_undo(height INTEGER PRIMARY KEY,data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS canonical_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,type TEXT NOT NULL, height INTEGER NOT NULL,hash TEXT NOT NULL,previous_hash TEXT NOT NULL,transactions_json TEXT NOT NULL, previous_event_hash TEXT NOT NULL,event_hash TEXT NOT NULL); CREATE TRIGGER IF NOT EXISTS canonical_events_no_update BEFORE UPDATE ON canonical_events BEGIN SELECT RAISE(ABORT,'Immutable canonical event'); END; CREATE TRIGGER IF NOT EXISTS canonical_events_no_delete BEFORE DELETE ON canonical_events BEGIN SELECT RAISE(ABORT,'Immutable canonical event'); END;`); const version = this.db.prepare("SELECT value FROM canonical_meta WHERE key='version'").get() as { value: string } | undefined; if (!version) { // Additive backfill only after Ledger.open has independently validated all persisted proofs/state. const count = this.db.prepare('SELECT COUNT(*) AS n FROM canonical_events').get() as { n: number }; if (count.n || (this.db.prepare('SELECT COUNT(*) AS n FROM canonical_blocks').get() as { n: number }).n) { throw new Error('Partial canonical index; offline operator review required'); } this.streamId = randomUUID(); this.db.prepare('INSERT INTO canonical_meta VALUES(?,?)').run('stream_id', this.streamId); for (const block of chain) this.connect(block); this.db.prepare('INSERT INTO canonical_meta VALUES(?,?)').run('version', '1'); } else if (version.value !== '1') throw new Error('Canonical index version mismatch'); else { const row = this.db.prepare("SELECT value FROM canonical_meta WHERE key='stream_id'").get() as { value: string } | undefined; if (!row || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(row.value)) throw new Error('Canonical stream identity missing'); this.streamId = row.value; } this.verify(chain, state); this.db.exec('COMMIT'); } catch (error) { this.db.exec('ROLLBACK'); throw error; } } private verify(chain: TestnetBlock[], state: NftState): void { const blocks = this.db.prepare('SELECT height,hash FROM canonical_blocks ORDER BY height').all() as { height: number; hash: string }[]; if (blocks.length !== chain.length || blocks.some((b, i) => b.height !== chain[i].height || b.hash !== chain[i].hash)) { throw new Error('Canonical block index mismatch'); } const nfts = this.db.prepare('SELECT * FROM canonical_nfts').all() as NftRow[]; const expected = new Map(chain.map(b => [b.tokenId, b])); if (nfts.length !== expected.size) throw new Error('Canonical NFT index mismatch'); for (const row of nfts) { const nft = state[row.token], block = expected.get(row.token); if (!nft || !block || row.owner !== nft.owner || row.sequence !== nft.sequence || row.minted_height !== block.height || row.block_hash !== block.hash) throw new Error('Canonical ownership index mismatch'); } const transactions = this.db.prepare('SELECT id,height,block_hash FROM canonical_transactions').all() as { id: string; height: number; block_hash: string }[]; const expectedTxs = new Map(chain.flatMap(b => b.transactions.map(tx => [transactionId(tx), b] as const))); if (transactions.length !== expectedTxs.size || transactions.some(row => { const block = expectedTxs.get(row.id); return !block || row.height !== block.height || row.block_hash !== block.hash; })) throw new Error('Canonical transaction index mismatch'); const undo = this.db.prepare('SELECT COUNT(*) AS n FROM canonical_undo').get() as { n: number }; if (undo.n !== chain.length) throw new Error('Canonical undo index mismatch'); // A cursor is only useful if history has no gaps and describes the same validated DAG transitions. const stack: string[] = []; let sequence = 0, anchor = GENESIS_HASH; for (const raw of this.db.prepare(`SELECT e.*,b.block_json FROM canonical_events e LEFT JOIN blocks b ON b.hash=e.hash ORDER BY e.sequence`).iterate() as Iterable<{ sequence: number; type: string; height: number; hash: string; previous_hash: string; transactions_json: string; block_json: string | null; previous_event_hash: string; event_hash: string }>) { const block = raw.block_json ? JSON.parse(raw.block_json) as TestnetBlock : undefined; if (raw.sequence !== ++sequence || !block || block.height !== raw.height || block.previousHash !== raw.previous_hash || raw.transactions_json !== JSON.stringify(block.transactions.map(transactionId))) throw new Error('Canonical event integrity mismatch'); if (raw.previous_event_hash !== anchor || raw.event_hash !== this.eventHash(raw.sequence, raw.type, block, anchor)) throw new Error('Canonical event anchor mismatch'); anchor = raw.event_hash; if (raw.type === 'connect') { if (raw.height !== stack.length + 1 || raw.previous_hash !== (stack.at(-1) ?? GENESIS_HASH)) throw new Error('Canonical event connect mismatch'); stack.push(raw.hash); } else if (raw.type === 'disconnect') { if (raw.height !== stack.length || stack.pop() !== raw.hash) throw new Error('Canonical event disconnect mismatch'); } else throw new Error('Unknown canonical event'); } if (stack.length !== chain.length || stack.some((hash, i) => hash !== chain[i].hash)) throw new Error('Canonical event tip mismatch'); } private event(type: CanonicalEvent['type'], block: TestnetBlock) { const prior = this.db.prepare('SELECT sequence,event_hash FROM canonical_events ORDER BY sequence DESC LIMIT 1').get() as { sequence: number; event_hash: string } | undefined; const sequence = (prior?.sequence ?? 0) + 1, anchor = prior?.event_hash ?? GENESIS_HASH; this.db.prepare('INSERT INTO canonical_events(sequence,type,height,hash,previous_hash,transactions_json,previous_event_hash,event_hash) VALUES(?,?,?,?,?,?,?,?)') .run(sequence, type, block.height, block.hash, block.previousHash, JSON.stringify(block.transactions.map(transactionId)), anchor, this.eventHash(sequence, type, block, anchor)); } private eventHash(sequence: number, type: string, block: TestnetBlock, previousAnchor: string): string { return sha256(JSON.stringify(['wikideck-canonical-event-v1', this.streamId, sequence, type, block.height, block.hash, block.previousHash, block.transactions.map(transactionId), previousAnchor])); } connect(block: TestnetBlock): void { const tokens = new Set([block.tokenId, ...block.transactions.flatMap(transactionCardIds)]); const undo = [...tokens].map(token => [token, this.db.prepare('SELECT * FROM canonical_nfts WHERE token=?').get(token) ?? null]); this.db.prepare('INSERT INTO canonical_undo VALUES(?,?)').run(block.height, JSON.stringify(undo)); this.db.prepare('INSERT INTO canonical_blocks VALUES(?,?)').run(block.height, block.hash); this.db.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)').run(block.tokenId, block.owner, 0, block.height, block.hash); const update = this.db.prepare('UPDATE canonical_nfts SET owner=?,sequence=sequence+1 WHERE token=?'); for (const tx of block.transactions) { if (isNftTrade(tx)) { for (const leg of tx.offered) update.run(tx.counterparty, leg.cardHash); for (const leg of tx.requested) update.run(tx.proposer, leg.cardHash); } else update.run(tx.to, tx.cardHash); this.db.prepare('INSERT INTO canonical_transactions VALUES(?,?,?)').run(transactionId(tx), block.height, block.hash); } this.event('connect', block); } disconnect(block: TestnetBlock): void { const row = this.db.prepare('SELECT data FROM canonical_undo WHERE height=?').get(block.height) as { data: string } | undefined; if (!row) throw new Error('Missing canonical undo record'); for (const [token, prior] of JSON.parse(row.data) as [string, NftRow | null][]) { this.db.prepare('DELETE FROM canonical_nfts WHERE token=?').run(token); if (prior) this.db.prepare('INSERT INTO canonical_nfts VALUES(?,?,?,?,?)') .run(prior.token, prior.owner, prior.sequence, prior.minted_height, prior.block_hash); } this.db.prepare('DELETE FROM canonical_transactions WHERE height=?').run(block.height); this.db.prepare('DELETE FROM canonical_blocks WHERE height=?').run(block.height); this.db.prepare('DELETE FROM canonical_undo WHERE height=?').run(block.height); this.event('disconnect', block); } head(): number { return (this.db.prepare('SELECT COALESCE(MAX(sequence),0) AS n FROM canonical_events').get() as { n: number }).n; } events(after: number, limit = 64): CanonicalEvents { const head = this.head(); if (!Number.isSafeInteger(after) || after < 0 || after > head || !Number.isSafeInteger(limit) || limit < 1 || limit > 64) { throw new RpcError(409, 'Invalid canonical event cursor; restore/reconcile local consumer state.'); } const rows = this.db.prepare('SELECT * FROM canonical_events WHERE sequence>? ORDER BY sequence LIMIT ?').all(after, limit) as { sequence: number; type: CanonicalEvent['type']; height: number; hash: string; previous_hash: string; transactions_json: string; event_hash: string }[]; if (rows.some((r, i) => r.sequence !== after + i + 1)) throw new Error('Canonical event history gap'); const events = rows.map(r => ({ sequence: r.sequence, type: r.type, height: r.height, hash: r.hash, previousHash: r.previous_hash, transactionIds: JSON.parse(r.transactions_json) as string[], eventHash: r.event_hash })); const cursor = events.at(-1)?.sequence ?? after; if (cursor < head && !events.length) throw new Error('Missing canonical event history'); const previousAnchor = after === 0 ? GENESIS_HASH : (this.db.prepare('SELECT event_hash FROM canonical_events WHERE sequence=?').get(after) as { event_hash: string } | undefined)?.event_hash; if (!previousAnchor) throw new Error('Missing canonical cursor anchor'); return { streamId: this.streamId, previousAnchor, anchor: events.at(-1)?.eventHash ?? previousAnchor, events, cursor, head, caughtUp: cursor === head }; } nft(token: string, height: number): IndexedNft | undefined { const row = this.db.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n JOIN blocks b ON b.hash=n.block_hash WHERE n.token=?`).get(token) as { owner: string; sequence: number; minted_height: number; block_json: string } | undefined; return row ? { block: JSON.parse(row.block_json), owner: row.owner, sequence: row.sequence, confirmations: height - row.minted_height + 1 } : undefined; } transaction(id: string): { height: number; blockHash: string } | undefined { const row = this.db.prepare('SELECT height,block_hash FROM canonical_transactions WHERE id=?').get(id) as { height: number; block_hash: string } | undefined; return row ? { height: row.height, blockHash: row.block_hash } : undefined; } deck(owner: string, offset: number, limit: number, height: number) { const total = (this.db.prepare('SELECT COUNT(*) AS n FROM canonical_nfts WHERE owner=?').get(owner) as { n: number }).n; const rows = this.db.prepare(`SELECT n.owner,n.sequence,n.minted_height,b.block_json FROM canonical_nfts n JOIN blocks b ON b.hash=n.block_hash WHERE n.owner=? ORDER BY n.minted_height,n.token LIMIT ? OFFSET ?`) .all(owner, limit, offset) as { owner: string; sequence: number; minted_height: number; block_json: string }[]; return { total, nfts: rows.map(r => ({ block: JSON.parse(r.block_json) as TestnetBlock, sequence: r.sequence, confirmations: height - r.minted_height + 1 })) }; } tip(): string { return (this.db.prepare('SELECT hash FROM canonical_blocks ORDER BY height DESC LIMIT 1').get() as { hash: string } | undefined)?.hash ?? GENESIS_HASH; } }