UNPKG

@bsv/overlay

Version:
291 lines (256 loc) 12.9 kB
// Note: references Engine from dist due to compiled code references in Engine.ts // (matching Engine.test.ts). import { Engine } from '../../dist/cjs/src/Engine.js' import { computeBasmRoot, computeTac } from '../BASM' import type { AdmittedTxRef, TopicBlockAnchor } from '../BASM' const ZERO = '0000000000000000000000000000000000000000000000000000000000000000' const TXID_1 = '0101010101010101010101010101010101010101010101010101010101010101' const TXID_2 = '0202020202020202020202020202020202020202020202020202020202020202' /** Deterministic non-zero 32-byte hex block hash for a height. */ const blockHashFor = (height: number): string => (height + 1).toString(16).padStart(64, '0') interface AppliedRow { txid: string topic: string blockHeight?: number blockHash?: string blockIndex?: number merkleRoot?: string firstSeenHeight?: number proven: boolean } interface FakeStore { anchors: Map<string, TopicBlockAnchor> admitted?: Map<number, AdmittedTxRef[]> applied?: AppliedRow[] } function makeStorage (store: FakeStore): any { const key = (topic: string, height: number): string => `${topic}:${height}` return { async findTopicAnchorTip (topic: string) { let tip: TopicBlockAnchor | undefined for (const anchor of store.anchors.values()) { if (anchor.topic === topic && (tip === undefined || anchor.blockHeight > tip.blockHeight)) { tip = anchor } } return tip ?? { topic, blockHeight: -1, tac: ZERO } }, async upsertTopicBlockAnchor (anchor: TopicBlockAnchor) { store.anchors.set(key(anchor.topic, anchor.blockHeight), { ...anchor }) }, async findTopicBlockAnchor (topic: string, height: number) { return store.anchors.get(key(topic, height)) }, async findTopicBlockAnchors (topic: string, fromHeight: number, toHeight: number) { const result: TopicBlockAnchor[] = [] for (const anchor of store.anchors.values()) { if (anchor.topic === topic && anchor.blockHeight >= fromHeight && anchor.blockHeight <= toHeight) { result.push(anchor) } } return result.sort((a, b) => a.blockHeight - b.blockHeight) }, async findAdmittedTransactionsForBlock (topic: string, height: number) { // Prefer deriving from proven applied rows so demotion is reflected. if (store.applied !== undefined) { return store.applied .filter(r => r.proven && r.topic === topic && r.blockHeight === height && r.blockIndex !== undefined) .sort((a, b) => (a.blockIndex ?? 0) - (b.blockIndex ?? 0)) .map(r => ({ txid: r.txid, blockIndex: r.blockIndex as number })) } return store.admitted?.get(height) ?? [] }, async findProvenAppliedTransactionsByBlockHash (blockHash: string) { return (store.applied ?? []) .filter(r => r.proven && r.blockHash?.toLowerCase() === blockHash.toLowerCase()) .map(r => ({ txid: r.txid, topic: r.topic, blockHeight: r.blockHeight as number })) }, async findProvenAppliedTransactionsInRange (fromHeight: number, toHeight: number, topic?: string) { return (store.applied ?? []) .filter(r => r.proven && r.blockHeight !== undefined && r.blockHeight >= fromHeight && r.blockHeight <= toHeight && (topic === undefined || r.topic === topic)) .map(r => ({ txid: r.txid, topic: r.topic, blockHeight: r.blockHeight as number, blockHash: r.blockHash, merkleRoot: r.merkleRoot })) }, async demoteAppliedTransactionToUnproven (txid: string, topic: string) { const row = (store.applied ?? []).find(r => r.txid === txid && r.topic === topic) if (row !== undefined) { row.proven = false row.blockHeight = undefined row.blockHash = undefined row.blockIndex = undefined row.merkleRoot = undefined } } } } function makeEngine ( store: FakeStore, opts: { resolver?: (h: number) => Promise<{ blockHeight: number, blockHash: string }>, currentHeight?: number, isValidRootForHeight?: (root: string, height: number) => Promise<boolean> } = {} ): Engine { const managers = { tm_test: { identifyAdmissibleOutputs: jest.fn(), getDocumentation: async () => '', getMetaData: async () => ({ name: 'm', shortDescription: 's' }) } } as any const resolver = opts.resolver ?? (async (blockHeight: number) => ({ blockHeight, blockHash: blockHashFor(blockHeight) })) return new Engine( managers, {}, makeStorage(store), { isValidRootForHeight: opts.isValidRootForHeight ?? (async () => true), currentHeight: async () => opts.currentHeight ?? 105 } as any, 'https://example.com', undefined, undefined, undefined, undefined, undefined, false, '[T] ', false, undefined as any, console, true, resolver, false, 144 ) } describe('BRC-136 BASM anchor chain continuity', () => { it('extends the chain with empty anchors so the TAC never resets across blocks with no admitted txs', async () => { const store: FakeStore = { anchors: new Map(), admitted: new Map() } // Genesis: topic admits two txs at height 100. store.admitted!.set(100, [ { txid: TXID_1, blockIndex: 0 }, { txid: TXID_2, blockIndex: 1 } ]) const genesisRoot = computeBasmRoot(store.admitted!.get(100)!) const genesisTac = computeTac(ZERO, blockHashFor(100), genesisRoot) store.anchors.set('tm_test:100', { topic: 'tm_test', blockHeight: 100, blockHash: blockHashFor(100), basmRoot: genesisRoot, admittedCount: 2, tac: genesisTac }) const engine = makeEngine(store) await engine.advanceTopicAnchorChains(105) // Every height 100..105 must have an anchor — no gaps. for (let h = 100; h <= 105; h++) { expect(store.anchors.get(`tm_test:${h}`)).toBeDefined() } // 101..105 are empty anchors (zero root, zero count) chained off genesis. let expectedTac = genesisTac for (let h = 101; h <= 105; h++) { const anchor = store.anchors.get(`tm_test:${h}`)! expect(anchor.basmRoot).toBe(ZERO) expect(anchor.admittedCount).toBe(0) expectedTac = computeTac(expectedTac, blockHashFor(h), ZERO) expect(anchor.tac).toBe(expectedTac) } // The tip TAC is a cumulative hash that still depends on the genesis block — // i.e. it was NOT reset to a per-block value. const tipTac = store.anchors.get('tm_test:105')!.tac const resetTac = computeTac(ZERO, blockHashFor(105), ZERO) expect(tipTac).not.toBe(resetTac) }) }) /** Replacement canonical block hash for height 101 after a reorg. */ const H101_NEW = 'aaaa000000000000000000000000000000000000000000000000000000000000' /** Header resolver where height 101's canonical block hash is H101_NEW (post-reorg). */ const reorgResolver = async (h: number): Promise<{ blockHeight: number, blockHash: string }> => ({ blockHeight: h, blockHash: h === 101 ? H101_NEW : blockHashFor(h) }) /** * Builds a topic with a proven admission at height 100 (TXID_1, stays canonical) * and a proven admission at height 101 (TXID_2) in a block that will be orphaned. */ function seedTwoBlockChain (): FakeStore { const applied: AppliedRow[] = [ { txid: TXID_1, topic: 'tm_test', blockHeight: 100, blockHash: blockHashFor(100), blockIndex: 0, merkleRoot: 'm100', firstSeenHeight: 100, proven: true }, { txid: TXID_2, topic: 'tm_test', blockHeight: 101, blockHash: blockHashFor(101), blockIndex: 0, merkleRoot: 'm101', firstSeenHeight: 101, proven: true } ] const root100 = computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }]) const tac100 = computeTac(ZERO, blockHashFor(100), root100) const root101 = computeBasmRoot([{ txid: TXID_2, blockIndex: 0 }]) const tac101 = computeTac(tac100, blockHashFor(101), root101) const anchors = new Map<string, TopicBlockAnchor>() anchors.set('tm_test:100', { topic: 'tm_test', blockHeight: 100, blockHash: blockHashFor(100), basmRoot: root100, admittedCount: 1, tac: tac100 }) anchors.set('tm_test:101', { topic: 'tm_test', blockHeight: 101, blockHash: blockHashFor(101), basmRoot: root101, admittedCount: 1, tac: tac101 }) return { anchors, applied } } describe('BRC-136 BASM reorg handling', () => { it('demotes an orphaned-block tx to unproven and rebuilds the anchor over the canonical hash', async () => { const store = seedTwoBlockChain() // Reorg: height 101's block (blockHashFor(101)) is orphaned; the replacement // canonical block at 101 (H101_NEW) does not re-include TXID_2. const engine = makeEngine(store, { resolver: reorgResolver, currentHeight: 101 }) await (engine as any).handleReorg({ orphanedBlockHashes: [blockHashFor(101)], rebuildFromHeight: 101, newTipHeight: 101 }) // TXID_2 is demoted: no longer proven, block metadata cleared. const row = store.applied!.find(r => r.txid === TXID_2)! expect(row.proven).toBe(false) expect(row.blockHeight).toBeUndefined() // It survives as a receipt record (firstSeenHeight retained). expect(row.firstSeenHeight).toBe(101) // Anchor at 101 rebuilt: empty admitted set, canonical hash, recomputed TAC. const anchor101 = store.anchors.get('tm_test:101')! expect(anchor101.admittedCount).toBe(0) expect(anchor101.basmRoot).toBe(ZERO) expect(anchor101.blockHash).toBe(H101_NEW) const tac100 = store.anchors.get('tm_test:100')!.tac expect(anchor101.tac).toBe(computeTac(tac100, H101_NEW, ZERO)) // Height 100 is untouched. const root100 = computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }]) expect(store.anchors.get('tm_test:100')!.basmRoot).toBe(root100) }) it('is idempotent: orphaned hashes matching no proven rows leave the chain unchanged', async () => { const store = seedTwoBlockChain() const engine = makeEngine(store, { currentHeight: 101 }) const before = store.anchors.get('tm_test:101')!.tac await (engine as any).handleReorg({ orphanedBlockHashes: ['ffff000000000000000000000000000000000000000000000000000000000000'], rebuildFromHeight: 101, newTipHeight: 101 }) expect(store.applied!.find(r => r.txid === TXID_2)!.proven).toBe(true) expect(store.anchors.get('tm_test:101')!.tac).toBe(before) }) it('re-proving a demoted tx at a new height restores it to the admitted set', async () => { const store = seedTwoBlockChain() const engine = makeEngine(store, { resolver: reorgResolver, currentHeight: 101 }) await (engine as any).handleReorg({ orphanedBlockHashes: [blockHashFor(101)], rebuildFromHeight: 101, newTipHeight: 101 }) expect(store.anchors.get('tm_test:101')!.basmRoot).toBe(ZERO) // TXID_2 re-mined into the new canonical block at 101. const row = store.applied!.find(r => r.txid === TXID_2)! row.proven = true row.blockHeight = 101 row.blockHash = H101_NEW row.blockIndex = 0 row.merkleRoot = 'm101new' await (engine as any).handleReorg({ orphanedBlockHashes: [], rebuildFromHeight: 101, newTipHeight: 101 }) const anchor101 = store.anchors.get('tm_test:101')! expect(anchor101.admittedCount).toBe(1) expect(anchor101.basmRoot).toBe(computeBasmRoot([{ txid: TXID_2, blockIndex: 0 }])) expect(anchor101.blockHash).toBe(H101_NEW) }) }) describe('BRC-136 BASM reorg revalidation sweep', () => { it('detects a stale proof root within the window and reorgs the affected height', async () => { const store = seedTwoBlockChain() // Height 101's proof root no longer validates (block orphaned); canonical // header at 101 is now H101_NEW. const isValidRootForHeight = async (root: string, height: number): Promise<boolean> => !(height === 101 && root === 'm101') const engine = makeEngine(store, { resolver: reorgResolver, currentHeight: 101, isValidRootForHeight }) await (engine as any).revalidateRecentAnchors(3) expect(store.applied!.find(r => r.txid === TXID_2)!.proven).toBe(false) const anchor101 = store.anchors.get('tm_test:101')! expect(anchor101.basmRoot).toBe(ZERO) expect(anchor101.blockHash).toBe(H101_NEW) // Height 100 still valid and untouched. expect(store.applied!.find(r => r.txid === TXID_1)!.proven).toBe(true) expect(store.anchors.get('tm_test:100')!.basmRoot).toBe(computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }])) }) it('leaves the chain unchanged when every proof in the window still validates', async () => { const store = seedTwoBlockChain() const engine = makeEngine(store, { currentHeight: 101, isValidRootForHeight: async () => true }) const before = store.anchors.get('tm_test:101')!.tac await (engine as any).revalidateRecentAnchors(3) expect(store.applied!.find(r => r.txid === TXID_2)!.proven).toBe(true) expect(store.anchors.get('tm_test:101')!.tac).toBe(before) }) })