@bsv/overlay
Version:
BSV Blockchain Overlay Services Engine
291 lines (256 loc) • 12.9 kB
text/typescript
// 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)
})
})