@bsv/overlay 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +226 -106
  2. package/dist/cjs/mod.js +6 -1
  3. package/dist/cjs/mod.js.map +1 -1
  4. package/dist/cjs/package.json +21 -11
  5. package/dist/cjs/src/BASM.js +92 -0
  6. package/dist/cjs/src/BASM.js.map +1 -0
  7. package/dist/cjs/src/BASMRemote.js +43 -0
  8. package/dist/cjs/src/BASMRemote.js.map +1 -0
  9. package/dist/cjs/src/Engine.js +649 -47
  10. package/dist/cjs/src/Engine.js.map +1 -1
  11. package/dist/cjs/src/GASP/OverlayGASPRemote.js +2 -2
  12. package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
  13. package/dist/cjs/src/GASP/OverlayGASPStorage.js +18 -25
  14. package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
  15. package/dist/cjs/src/storage/knex/KnexStorage.js +329 -12
  16. package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
  17. package/dist/cjs/src/storage/knex/all-migrations.js +3 -1
  18. package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
  19. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js +2 -24
  20. package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
  21. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js +114 -0
  22. package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -0
  23. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  24. package/dist/esm/mod.js +1 -0
  25. package/dist/esm/mod.js.map +1 -1
  26. package/dist/esm/src/BASM.js +85 -0
  27. package/dist/esm/src/BASM.js.map +1 -0
  28. package/dist/esm/src/BASMRemote.js +42 -0
  29. package/dist/esm/src/BASMRemote.js.map +1 -0
  30. package/dist/esm/src/Engine.js +637 -46
  31. package/dist/esm/src/Engine.js.map +1 -1
  32. package/dist/esm/src/GASP/OverlayGASPRemote.js +2 -2
  33. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
  34. package/dist/esm/src/GASP/OverlayGASPStorage.js +18 -25
  35. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
  36. package/dist/esm/src/storage/knex/KnexStorage.js +323 -12
  37. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  38. package/dist/esm/src/storage/knex/all-migrations.js +3 -1
  39. package/dist/esm/src/storage/knex/all-migrations.js.map +1 -1
  40. package/dist/esm/src/storage/knex/migrations/2025-05-28-001-enlarge.js +2 -24
  41. package/dist/esm/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
  42. package/dist/esm/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js +109 -0
  43. package/dist/esm/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -0
  44. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  45. package/dist/types/mod.d.ts +2 -0
  46. package/dist/types/mod.d.ts.map +1 -1
  47. package/dist/types/src/BASM.d.ts +110 -0
  48. package/dist/types/src/BASM.d.ts.map +1 -0
  49. package/dist/types/src/BASMRemote.d.ts +14 -0
  50. package/dist/types/src/BASMRemote.d.ts.map +1 -0
  51. package/dist/types/src/Engine.d.ts +78 -1
  52. package/dist/types/src/Engine.d.ts.map +1 -1
  53. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +1 -1
  54. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  55. package/dist/types/src/LookupService.d.ts.map +1 -1
  56. package/dist/types/src/storage/Storage.d.ts +118 -0
  57. package/dist/types/src/storage/Storage.d.ts.map +1 -1
  58. package/dist/types/src/storage/knex/KnexStorage.d.ts +38 -6
  59. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  60. package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
  61. package/dist/types/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -1
  62. package/dist/types/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
  63. package/dist/types/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
  64. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  65. package/docs/API.md +72 -580
  66. package/docs/BRC-136-BASM.md +285 -0
  67. package/docs/README.md +2 -0
  68. package/docs/Synchronization.md +10 -1
  69. package/docs/examples/README.md +2 -2
  70. package/docs/examples/gs-wip.md +68 -76
  71. package/mod.ts +25 -1
  72. package/package.json +33 -23
  73. package/src/BASM.ts +208 -0
  74. package/src/BASMRemote.ts +54 -0
  75. package/src/Engine.ts +762 -47
  76. package/src/GASP/OverlayGASPRemote.ts +2 -2
  77. package/src/GASP/OverlayGASPStorage.ts +17 -22
  78. package/src/LookupService.ts +2 -1
  79. package/src/__tests/BASM.test.ts +44 -0
  80. package/src/__tests/BASMChain.test.ts +290 -0
  81. package/src/__tests/Engine.test.ts +3 -4
  82. package/src/storage/Storage.ts +127 -0
  83. package/src/storage/knex/KnexStorage.ts +376 -14
  84. package/src/storage/knex/all-migrations.ts +3 -1
  85. package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +2 -22
  86. package/src/storage/knex/migrations/2026-05-29-001-brc136-basm.ts +123 -0
@@ -30,7 +30,7 @@ export class OverlayGASPRemote implements GASPRemote {
30
30
 
31
31
  // Validate and return the response in the correct format
32
32
  if (!Array.isArray(result.UTXOList) || typeof result.since !== 'number') {
33
- throw new Error('Invalid response format')
33
+ throw new TypeError('Invalid response format')
34
34
  }
35
35
 
36
36
  return {
@@ -77,7 +77,7 @@ export class OverlayGASPRemote implements GASPRemote {
77
77
 
78
78
  // Validate and return the response in the correct format
79
79
  if (typeof result.graphID !== 'string' || typeof result.rawTx !== 'string' || typeof result.outputIndex !== 'number') {
80
- throw new Error('Invalid response format')
80
+ throw new TypeError('Invalid response format')
81
81
  }
82
82
 
83
83
  const gaspNode: GASPNode = {
@@ -14,7 +14,7 @@ export interface GraphNode {
14
14
  proof?: string
15
15
  txMetadata?: string
16
16
  outputMetadata?: string
17
- inputs?: Record<string, { hash: string }> | undefined
17
+ inputs?: Record<string, { hash: string }>
18
18
  children: GraphNode[]
19
19
  parent?: GraphNode
20
20
  }
@@ -133,24 +133,20 @@ export class OverlayGASPStorage implements GASPStorage {
133
133
  typeof tx.txMetadata === 'string' ? Utils.toArray(tx.txMetadata) : undefined,
134
134
  'historical-tx'
135
135
  )
136
- if (admittanceResult.outputsToAdmit.includes(tx.outputIndex)) {
137
- // The transaction is admissible, no further inputs are needed
138
- } else {
136
+ if (!admittanceResult.outputsToAdmit.includes(tx.outputIndex) && this.engine.managers[this.topic] !== undefined && typeof this.engine.managers[this.topic].identifyNeededInputs === 'function') {
139
137
  // The transaction is not admissible, get inputs needed for further verification
140
138
  // TopicManagers should implement a function to identify which inputs are needed.
141
- if (this.engine.managers[this.topic] !== undefined && typeof this.engine.managers[this.topic].identifyNeededInputs === 'function') {
142
- try {
143
- const neededInputs = await this.engine.managers[this.topic].identifyNeededInputs?.(parsedTx.toBEEF()) ?? []
144
- for (const input of neededInputs) {
145
- response.requestedInputs[`${input.txid}.${input.outputIndex}`] = {
146
- metadata: false
147
- }
139
+ try {
140
+ const neededInputs = await this.engine.managers[this.topic].identifyNeededInputs?.(parsedTx.toBEEF()) ?? []
141
+ for (const input of neededInputs) {
142
+ response.requestedInputs[`${input.txid}.${input.outputIndex}`] = {
143
+ metadata: false
148
144
  }
149
- return await this.stripAlreadyKnownInputs(response)
150
- } catch (e) {
151
- console.error(`An error occurred when identifying needed inputs for transaction: ${parsedTx.id('hex')}.${tx.outputIndex}!`)
152
- // Cut off the graph in case of an error here.
153
145
  }
146
+ return await this.stripAlreadyKnownInputs(response)
147
+ } catch (e) {
148
+ console.error(`An error occurred when identifying needed inputs for transaction: ${parsedTx.id('hex')}.${tx.outputIndex}: ${e}`)
149
+ // Cut off the graph in case of an error here.
154
150
  }
155
151
  // By default, if the topic manager isn't able to stipulate needed inputs, only the inputs necessary for SPV are requested.
156
152
  }
@@ -162,7 +158,7 @@ export class OverlayGASPStorage implements GASPStorage {
162
158
  * Also terminates graphs if the response would be empty.
163
159
  */
164
160
  private async stripAlreadyKnownInputs (response: GASPNodeResponse | undefined): Promise<GASPNodeResponse | undefined> {
165
- if (typeof response === 'undefined') {
161
+ if (response === undefined) {
166
162
  return response
167
163
  }
168
164
  for (const inputNodeId of Object.keys(response.requestedInputs)) {
@@ -217,14 +213,13 @@ export class OverlayGASPStorage implements GASPStorage {
217
213
  // Find the parent node based on spentBy
218
214
  const parentNode = this.temporaryGraphNodeRefs[spentBy]
219
215
 
220
- if (parentNode !== undefined) {
221
- // Set parent-child relationship
222
- parentNode.children.push(newGraphNode)
223
- newGraphNode.parent = parentNode
224
- this.temporaryGraphNodeRefs[`${newGraphNode.txid}.${newGraphNode.outputIndex}`] = newGraphNode
225
- } else {
216
+ if (parentNode === undefined) {
226
217
  throw new Error(`Parent node with GraphID ${spentBy} not found`)
227
218
  }
219
+ // Set parent-child relationship
220
+ parentNode.children.push(newGraphNode)
221
+ newGraphNode.parent = parentNode
222
+ this.temporaryGraphNodeRefs[`${newGraphNode.txid}.${newGraphNode.outputIndex}`] = newGraphNode
228
223
  }
229
224
  }
230
225
 
@@ -1,5 +1,6 @@
1
1
  import { LookupFormula } from './LookupFormula.js'
2
- import { Script, LookupQuestion, LookupAnswer } from '@bsv/sdk'
2
+ import { Script, LookupQuestion } from '@bsv/sdk'
3
+
3
4
 
4
5
  /* ---------------------------------------------------------------------------
5
6
  * Modes a Lookup Service may request from the Overlay Services Engine
@@ -0,0 +1,44 @@
1
+ import { computeBasmRoot, computeTac } from '../BASM'
2
+
3
+ const ZERO = '0000000000000000000000000000000000000000000000000000000000000000'
4
+ const TXID_1 = '0101010101010101010101010101010101010101010101010101010101010101'
5
+ const TXID_2 = '0202020202020202020202020202020202020202020202020202020202020202'
6
+ const TXID_3 = '0303030303030303030303030303030303030303030303030303030303030303'
7
+ const BLOCK_HASH = '0404040404040404040404040404040404040404040404040404040404040404'
8
+
9
+ describe('BRC-136 BASM helpers', () => {
10
+ it('computes the empty BASM root as 32 zero bytes', () => {
11
+ expect(computeBasmRoot([])).toBe(ZERO)
12
+ })
13
+
14
+ it('uses the single admitted txid as the BASM root', () => {
15
+ expect(computeBasmRoot([TXID_1])).toBe(TXID_1)
16
+ })
17
+
18
+ it('hashes two txids in internal byte order and returns display hex', () => {
19
+ expect(computeBasmRoot([TXID_1, TXID_2])).toBe(
20
+ 'b4100303b9e99ada4b479b0bb93d9b549cb057a1c4be08896bc982debe20ce39'
21
+ )
22
+ })
23
+
24
+ it('duplicates the odd leaf in multi-layer BASM roots', () => {
25
+ expect(computeBasmRoot([TXID_1, TXID_2, TXID_3])).toBe(
26
+ 'acbd47d5022a6c5e954ad677df8ec221c893f871889826df53f0f1ad3f023e22'
27
+ )
28
+ })
29
+
30
+ it('orders admitted refs by blockIndex instead of txid', () => {
31
+ const root = computeBasmRoot([
32
+ { txid: TXID_2, blockIndex: 20 },
33
+ { txid: TXID_1, blockIndex: 10 }
34
+ ])
35
+ expect(root).toBe('b4100303b9e99ada4b479b0bb93d9b549cb057a1c4be08896bc982debe20ce39')
36
+ })
37
+
38
+ it('chains TAC in internal byte order', () => {
39
+ const root = computeBasmRoot([TXID_1, TXID_2, TXID_3])
40
+ expect(computeTac(ZERO, BLOCK_HASH, root)).toBe(
41
+ 'd0d3e770802c7c28ca1da59cc92263d59425f00e48d421ffebb02497b4702307'
42
+ )
43
+ })
44
+ })
@@ -0,0 +1,290 @@
1
+ // Note: references Engine from dist due to compiled code references in Engine.ts
2
+ // (matching Engine.test.ts).
3
+ import { Engine } from '../../dist/cjs/src/Engine.js'
4
+ import { computeBasmRoot, computeTac } from '../BASM'
5
+ import type { AdmittedTxRef, TopicBlockAnchor } from '../BASM'
6
+
7
+ const ZERO = '0000000000000000000000000000000000000000000000000000000000000000'
8
+ const TXID_1 = '0101010101010101010101010101010101010101010101010101010101010101'
9
+ const TXID_2 = '0202020202020202020202020202020202020202020202020202020202020202'
10
+
11
+ /** Deterministic non-zero 32-byte hex block hash for a height. */
12
+ const blockHashFor = (height: number): string => (height + 1).toString(16).padStart(64, '0')
13
+
14
+ interface AppliedRow {
15
+ txid: string
16
+ topic: string
17
+ blockHeight?: number
18
+ blockHash?: string
19
+ blockIndex?: number
20
+ merkleRoot?: string
21
+ firstSeenHeight?: number
22
+ proven: boolean
23
+ }
24
+
25
+ interface FakeStore {
26
+ anchors: Map<string, TopicBlockAnchor>
27
+ admitted?: Map<number, AdmittedTxRef[]>
28
+ applied?: AppliedRow[]
29
+ }
30
+
31
+ function makeStorage (store: FakeStore): any {
32
+ const key = (topic: string, height: number): string => `${topic}:${height}`
33
+ return {
34
+ async findTopicAnchorTip (topic: string) {
35
+ let tip: TopicBlockAnchor | undefined
36
+ for (const anchor of store.anchors.values()) {
37
+ if (anchor.topic === topic && (tip === undefined || anchor.blockHeight > tip.blockHeight)) {
38
+ tip = anchor
39
+ }
40
+ }
41
+ return tip ?? { topic, blockHeight: -1, tac: ZERO }
42
+ },
43
+ async upsertTopicBlockAnchor (anchor: TopicBlockAnchor) {
44
+ store.anchors.set(key(anchor.topic, anchor.blockHeight), { ...anchor })
45
+ },
46
+ async findTopicBlockAnchor (topic: string, height: number) {
47
+ return store.anchors.get(key(topic, height))
48
+ },
49
+ async findTopicBlockAnchors (topic: string, fromHeight: number, toHeight: number) {
50
+ const result: TopicBlockAnchor[] = []
51
+ for (const anchor of store.anchors.values()) {
52
+ if (anchor.topic === topic && anchor.blockHeight >= fromHeight && anchor.blockHeight <= toHeight) {
53
+ result.push(anchor)
54
+ }
55
+ }
56
+ return result.sort((a, b) => a.blockHeight - b.blockHeight)
57
+ },
58
+ async findAdmittedTransactionsForBlock (topic: string, height: number) {
59
+ // Prefer deriving from proven applied rows so demotion is reflected.
60
+ if (store.applied !== undefined) {
61
+ return store.applied
62
+ .filter(r => r.proven && r.topic === topic && r.blockHeight === height && r.blockIndex !== undefined)
63
+ .sort((a, b) => (a.blockIndex ?? 0) - (b.blockIndex ?? 0))
64
+ .map(r => ({ txid: r.txid, blockIndex: r.blockIndex as number }))
65
+ }
66
+ return store.admitted?.get(height) ?? []
67
+ },
68
+ async findProvenAppliedTransactionsByBlockHash (blockHash: string) {
69
+ return (store.applied ?? [])
70
+ .filter(r => r.proven && r.blockHash?.toLowerCase() === blockHash.toLowerCase())
71
+ .map(r => ({ txid: r.txid, topic: r.topic, blockHeight: r.blockHeight as number }))
72
+ },
73
+ async findProvenAppliedTransactionsInRange (fromHeight: number, toHeight: number, topic?: string) {
74
+ return (store.applied ?? [])
75
+ .filter(r => r.proven && r.blockHeight !== undefined && r.blockHeight >= fromHeight && r.blockHeight <= toHeight && (topic === undefined || r.topic === topic))
76
+ .map(r => ({ txid: r.txid, topic: r.topic, blockHeight: r.blockHeight as number, blockHash: r.blockHash, merkleRoot: r.merkleRoot }))
77
+ },
78
+ async demoteAppliedTransactionToUnproven (txid: string, topic: string) {
79
+ const row = (store.applied ?? []).find(r => r.txid === txid && r.topic === topic)
80
+ if (row !== undefined) {
81
+ row.proven = false
82
+ row.blockHeight = undefined
83
+ row.blockHash = undefined
84
+ row.blockIndex = undefined
85
+ row.merkleRoot = undefined
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ function makeEngine (
92
+ store: FakeStore,
93
+ opts: { resolver?: (h: number) => Promise<{ blockHeight: number, blockHash: string }>, currentHeight?: number, isValidRootForHeight?: (root: string, height: number) => Promise<boolean> } = {}
94
+ ): Engine {
95
+ const managers = { tm_test: { identifyAdmissibleOutputs: jest.fn(), getDocumentation: async () => '', getMetaData: async () => ({ name: 'm', shortDescription: 's' }) } } as any
96
+ const resolver = opts.resolver ?? (async (blockHeight: number) => ({ blockHeight, blockHash: blockHashFor(blockHeight) }))
97
+ return new Engine(
98
+ managers,
99
+ {},
100
+ makeStorage(store),
101
+ { isValidRootForHeight: opts.isValidRootForHeight ?? (async () => true), currentHeight: async () => opts.currentHeight ?? 105 } as any,
102
+ 'https://example.com',
103
+ undefined, undefined, undefined, undefined, undefined,
104
+ false, '[T] ', false, undefined as any, console, true,
105
+ resolver, false, 144
106
+ )
107
+ }
108
+
109
+ describe('BRC-136 BASM anchor chain continuity', () => {
110
+ it('extends the chain with empty anchors so the TAC never resets across blocks with no admitted txs', async () => {
111
+ const store: FakeStore = { anchors: new Map(), admitted: new Map() }
112
+
113
+ // Genesis: topic admits two txs at height 100.
114
+ store.admitted!.set(100, [
115
+ { txid: TXID_1, blockIndex: 0 },
116
+ { txid: TXID_2, blockIndex: 1 }
117
+ ])
118
+ const genesisRoot = computeBasmRoot(store.admitted!.get(100)!)
119
+ const genesisTac = computeTac(ZERO, blockHashFor(100), genesisRoot)
120
+ store.anchors.set('tm_test:100', {
121
+ topic: 'tm_test',
122
+ blockHeight: 100,
123
+ blockHash: blockHashFor(100),
124
+ basmRoot: genesisRoot,
125
+ admittedCount: 2,
126
+ tac: genesisTac
127
+ })
128
+
129
+ const engine = makeEngine(store)
130
+ await engine.advanceTopicAnchorChains(105)
131
+
132
+ // Every height 100..105 must have an anchor — no gaps.
133
+ for (let h = 100; h <= 105; h++) {
134
+ expect(store.anchors.get(`tm_test:${h}`)).toBeDefined()
135
+ }
136
+
137
+ // 101..105 are empty anchors (zero root, zero count) chained off genesis.
138
+ let expectedTac = genesisTac
139
+ for (let h = 101; h <= 105; h++) {
140
+ const anchor = store.anchors.get(`tm_test:${h}`)!
141
+ expect(anchor.basmRoot).toBe(ZERO)
142
+ expect(anchor.admittedCount).toBe(0)
143
+ expectedTac = computeTac(expectedTac, blockHashFor(h), ZERO)
144
+ expect(anchor.tac).toBe(expectedTac)
145
+ }
146
+
147
+ // The tip TAC is a cumulative hash that still depends on the genesis block —
148
+ // i.e. it was NOT reset to a per-block value.
149
+ const tipTac = store.anchors.get('tm_test:105')!.tac
150
+ const resetTac = computeTac(ZERO, blockHashFor(105), ZERO)
151
+ expect(tipTac).not.toBe(resetTac)
152
+ })
153
+ })
154
+
155
+ /** Replacement canonical block hash for height 101 after a reorg. */
156
+ const H101_NEW = 'aaaa000000000000000000000000000000000000000000000000000000000000'
157
+
158
+ /** Header resolver where height 101's canonical block hash is H101_NEW (post-reorg). */
159
+ const reorgResolver = async (h: number): Promise<{ blockHeight: number, blockHash: string }> =>
160
+ ({ blockHeight: h, blockHash: h === 101 ? H101_NEW : blockHashFor(h) })
161
+
162
+ /**
163
+ * Builds a topic with a proven admission at height 100 (TXID_1, stays canonical)
164
+ * and a proven admission at height 101 (TXID_2) in a block that will be orphaned.
165
+ */
166
+ function seedTwoBlockChain (): FakeStore {
167
+ const applied: AppliedRow[] = [
168
+ { txid: TXID_1, topic: 'tm_test', blockHeight: 100, blockHash: blockHashFor(100), blockIndex: 0, merkleRoot: 'm100', firstSeenHeight: 100, proven: true },
169
+ { txid: TXID_2, topic: 'tm_test', blockHeight: 101, blockHash: blockHashFor(101), blockIndex: 0, merkleRoot: 'm101', firstSeenHeight: 101, proven: true }
170
+ ]
171
+ const root100 = computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }])
172
+ const tac100 = computeTac(ZERO, blockHashFor(100), root100)
173
+ const root101 = computeBasmRoot([{ txid: TXID_2, blockIndex: 0 }])
174
+ const tac101 = computeTac(tac100, blockHashFor(101), root101)
175
+ const anchors = new Map<string, TopicBlockAnchor>()
176
+ anchors.set('tm_test:100', { topic: 'tm_test', blockHeight: 100, blockHash: blockHashFor(100), basmRoot: root100, admittedCount: 1, tac: tac100 })
177
+ anchors.set('tm_test:101', { topic: 'tm_test', blockHeight: 101, blockHash: blockHashFor(101), basmRoot: root101, admittedCount: 1, tac: tac101 })
178
+ return { anchors, applied }
179
+ }
180
+
181
+ describe('BRC-136 BASM reorg handling', () => {
182
+ it('demotes an orphaned-block tx to unproven and rebuilds the anchor over the canonical hash', async () => {
183
+ const store = seedTwoBlockChain()
184
+ // Reorg: height 101's block (blockHashFor(101)) is orphaned; the replacement
185
+ // canonical block at 101 (H101_NEW) does not re-include TXID_2.
186
+ const engine = makeEngine(store, { resolver: reorgResolver, currentHeight: 101 })
187
+
188
+ await (engine as any).handleReorg({
189
+ orphanedBlockHashes: [blockHashFor(101)],
190
+ rebuildFromHeight: 101,
191
+ newTipHeight: 101
192
+ })
193
+
194
+ // TXID_2 is demoted: no longer proven, block metadata cleared.
195
+ const row = store.applied!.find(r => r.txid === TXID_2)!
196
+ expect(row.proven).toBe(false)
197
+ expect(row.blockHeight).toBeUndefined()
198
+ // It survives as a receipt record (firstSeenHeight retained).
199
+ expect(row.firstSeenHeight).toBe(101)
200
+
201
+ // Anchor at 101 rebuilt: empty admitted set, canonical hash, recomputed TAC.
202
+ const anchor101 = store.anchors.get('tm_test:101')!
203
+ expect(anchor101.admittedCount).toBe(0)
204
+ expect(anchor101.basmRoot).toBe(ZERO)
205
+ expect(anchor101.blockHash).toBe(H101_NEW)
206
+ const tac100 = store.anchors.get('tm_test:100')!.tac
207
+ expect(anchor101.tac).toBe(computeTac(tac100, H101_NEW, ZERO))
208
+
209
+ // Height 100 is untouched.
210
+ const root100 = computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }])
211
+ expect(store.anchors.get('tm_test:100')!.basmRoot).toBe(root100)
212
+ })
213
+
214
+ it('is idempotent: orphaned hashes matching no proven rows leave the chain unchanged', async () => {
215
+ const store = seedTwoBlockChain()
216
+ const engine = makeEngine(store, { currentHeight: 101 })
217
+ const before = store.anchors.get('tm_test:101')!.tac
218
+
219
+ await (engine as any).handleReorg({
220
+ orphanedBlockHashes: ['ffff000000000000000000000000000000000000000000000000000000000000'],
221
+ rebuildFromHeight: 101,
222
+ newTipHeight: 101
223
+ })
224
+
225
+ expect(store.applied!.find(r => r.txid === TXID_2)!.proven).toBe(true)
226
+ expect(store.anchors.get('tm_test:101')!.tac).toBe(before)
227
+ })
228
+
229
+ it('re-proving a demoted tx at a new height restores it to the admitted set', async () => {
230
+ const store = seedTwoBlockChain()
231
+ const engine = makeEngine(store, { resolver: reorgResolver, currentHeight: 101 })
232
+
233
+ await (engine as any).handleReorg({
234
+ orphanedBlockHashes: [blockHashFor(101)],
235
+ rebuildFromHeight: 101,
236
+ newTipHeight: 101
237
+ })
238
+ expect(store.anchors.get('tm_test:101')!.basmRoot).toBe(ZERO)
239
+
240
+ // TXID_2 re-mined into the new canonical block at 101.
241
+ const row = store.applied!.find(r => r.txid === TXID_2)!
242
+ row.proven = true
243
+ row.blockHeight = 101
244
+ row.blockHash = H101_NEW
245
+ row.blockIndex = 0
246
+ row.merkleRoot = 'm101new'
247
+
248
+ await (engine as any).handleReorg({
249
+ orphanedBlockHashes: [],
250
+ rebuildFromHeight: 101,
251
+ newTipHeight: 101
252
+ })
253
+
254
+ const anchor101 = store.anchors.get('tm_test:101')!
255
+ expect(anchor101.admittedCount).toBe(1)
256
+ expect(anchor101.basmRoot).toBe(computeBasmRoot([{ txid: TXID_2, blockIndex: 0 }]))
257
+ expect(anchor101.blockHash).toBe(H101_NEW)
258
+ })
259
+ })
260
+
261
+ describe('BRC-136 BASM reorg revalidation sweep', () => {
262
+ it('detects a stale proof root within the window and reorgs the affected height', async () => {
263
+ const store = seedTwoBlockChain()
264
+ // Height 101's proof root no longer validates (block orphaned); canonical
265
+ // header at 101 is now H101_NEW.
266
+ const isValidRootForHeight = async (root: string, height: number): Promise<boolean> => !(height === 101 && root === 'm101')
267
+ const engine = makeEngine(store, { resolver: reorgResolver, currentHeight: 101, isValidRootForHeight })
268
+
269
+ await (engine as any).revalidateRecentAnchors(3)
270
+
271
+ expect(store.applied!.find(r => r.txid === TXID_2)!.proven).toBe(false)
272
+ const anchor101 = store.anchors.get('tm_test:101')!
273
+ expect(anchor101.basmRoot).toBe(ZERO)
274
+ expect(anchor101.blockHash).toBe(H101_NEW)
275
+ // Height 100 still valid and untouched.
276
+ expect(store.applied!.find(r => r.txid === TXID_1)!.proven).toBe(true)
277
+ expect(store.anchors.get('tm_test:100')!.basmRoot).toBe(computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }]))
278
+ })
279
+
280
+ it('leaves the chain unchanged when every proof in the window still validates', async () => {
281
+ const store = seedTwoBlockChain()
282
+ const engine = makeEngine(store, { currentHeight: 101, isValidRootForHeight: async () => true })
283
+ const before = store.anchors.get('tm_test:101')!.tac
284
+
285
+ await (engine as any).revalidateRecentAnchors(3)
286
+
287
+ expect(store.applied!.find(r => r.txid === TXID_2)!.proven).toBe(true)
288
+ expect(store.anchors.get('tm_test:101')!.tac).toBe(before)
289
+ })
290
+ })
@@ -470,7 +470,6 @@ describe('BSV Overlay Services Engine', () => {
470
470
  beef: exampleBeef,
471
471
  topics: ['Hello']
472
472
  })
473
- expect(mockStorageEngine.doesAppliedTransactionExist).toReturnWith(Promise.resolve(true))
474
473
  expect(mockStorageEngine.findOutput).not.toHaveBeenCalled()
475
474
  expect(mockStorageEngine.markUTXOAsSpent).not.toHaveBeenCalled()
476
475
  expect(mockStorageEngine.insertOutput).not.toHaveBeenCalled()
@@ -758,10 +757,10 @@ describe('BSV Overlay Services Engine', () => {
758
757
  })
759
758
 
760
759
  // Test the tx is inserted
761
- expect(mockStorageEngine.insertAppliedTransaction).toHaveBeenCalledWith({
760
+ expect(mockStorageEngine.insertAppliedTransaction).toHaveBeenCalledWith(expect.objectContaining({
762
761
  txid: exampleTXID,
763
762
  topic: 'Hello'
764
- })
763
+ }))
765
764
  })
766
765
  it('Returns a correct set of admitted topics and outputs', async () => {
767
766
  // Mock findUTXO to return a UTXO
@@ -1010,7 +1009,7 @@ describe('BSV Overlay Services Engine', () => {
1010
1009
  mockOutput.outputIndex,
1011
1010
  0
1012
1011
  )
1013
- expect(mockedHistorySelector).toReturnWith(Promise.resolve(true))
1012
+ expect(mockedHistorySelector).toHaveBeenCalled()
1014
1013
  })
1015
1014
  it('Returns undefined if history should not be traversed', async () => {
1016
1015
  const mockedHistorySelector = jest.fn(async (beef, outputIndex, currentDepth) => {
@@ -1,4 +1,10 @@
1
1
  import type { Output } from '../Output.js'
2
+ import type {
3
+ AdmittedTxRef,
4
+ RawTransactionRecord,
5
+ TopicAnchorTip,
6
+ TopicBlockAnchor
7
+ } from '../BASM.js'
2
8
 
3
9
  /**
4
10
  * Represents a transaction that has been applied to a topic.
@@ -8,6 +14,45 @@ export interface AppliedTransaction {
8
14
  txid: string
9
15
  /** Output index of the applied transaction */
10
16
  topic: string
17
+ /** Block height of the transaction proof, when known */
18
+ blockHeight?: number
19
+ /** Block hash for the proven transaction's block, when known */
20
+ blockHash?: string
21
+ /** Transaction's canonical in-block index, when known */
22
+ blockIndex?: number
23
+ /** Merkle root computed from the direct proof, when known */
24
+ merkleRoot?: string
25
+ /** Chain height when this transaction was first seen by this overlay */
26
+ firstSeenHeight?: number
27
+ /** Whether this applied transaction has a direct proof of inclusion */
28
+ proven?: boolean
29
+ }
30
+
31
+ export interface StoredTransactionRecord {
32
+ txid: string
33
+ beef?: number[]
34
+ rawTx?: number[]
35
+ merklePath?: number[]
36
+ blockHeight?: number
37
+ blockHash?: string
38
+ blockIndex?: number
39
+ merkleRoot?: string
40
+ }
41
+
42
+ export interface AppliedTransactionProofUpdate {
43
+ txid: string
44
+ topic: string
45
+ blockHeight: number
46
+ blockHash?: string
47
+ blockIndex?: number
48
+ merkleRoot?: string
49
+ }
50
+
51
+ export interface UnprovenAppliedTransactionCandidate {
52
+ txid: string
53
+ topic: string
54
+ firstSeenHeight?: number
55
+ outputs: Array<{ txid: string, outputIndex: number }>
11
56
  }
12
57
 
13
58
  /**
@@ -102,6 +147,17 @@ export interface Storage {
102
147
  */
103
148
  updateOutputBlockHeight?: (txid: string, outputIndex: number, topic: string, blockHeight: number) => Promise<void>
104
149
 
150
+ /**
151
+ * Upserts transaction-level data used for compact BEEF, raw-tx BASM fetches,
152
+ * and direct proof retrieval.
153
+ */
154
+ upsertTransactionRecord?: (record: StoredTransactionRecord) => Promise<void>
155
+
156
+ /**
157
+ * Updates direct proof metadata for an applied topic transaction.
158
+ */
159
+ updateAppliedTransactionProof?: (record: AppliedTransactionProofUpdate) => Promise<void>
160
+
105
161
  /**
106
162
  * Inserts record of the applied transaction
107
163
  * @param tx — The transaction to insert
@@ -115,6 +171,77 @@ export interface Storage {
115
171
  */
116
172
  doesAppliedTransactionExist: (tx: AppliedTransaction) => Promise<boolean>
117
173
 
174
+ /**
175
+ * Returns proven topic transactions admitted in one block, in canonical block order.
176
+ */
177
+ findAdmittedTransactionsForBlock?: (topic: string, blockHeight: number, blockHash?: string) => Promise<AdmittedTxRef[]>
178
+
179
+ /**
180
+ * Persists or replaces a topic block anchor for one height.
181
+ */
182
+ upsertTopicBlockAnchor?: (anchor: TopicBlockAnchor) => Promise<void>
183
+
184
+ /**
185
+ * Returns one topic block anchor.
186
+ */
187
+ findTopicBlockAnchor?: (topic: string, blockHeight: number, blockHash?: string) => Promise<TopicBlockAnchor | undefined>
188
+
189
+ /**
190
+ * Returns topic block anchors across a closed height range.
191
+ */
192
+ findTopicBlockAnchors?: (topic: string, fromHeight: number, toHeight: number) => Promise<TopicBlockAnchor[]>
193
+
194
+ /**
195
+ * Returns the latest known topic anchor tip.
196
+ */
197
+ findTopicAnchorTip?: (topic: string) => Promise<TopicAnchorTip | undefined>
198
+
199
+ /**
200
+ * Returns raw transactions for the requested txids.
201
+ */
202
+ findRawTransactions?: (txids: string[]) => Promise<RawTransactionRecord[]>
203
+
204
+ /**
205
+ * Returns direct Merkle paths for the requested txids.
206
+ */
207
+ findTransactionMerklePaths?: (txids: string[]) => Promise<Array<{
208
+ txid: string
209
+ merklePath: string
210
+ blockHeight?: number
211
+ blockHash?: string
212
+ blockIndex?: number
213
+ merkleRoot?: string
214
+ }>>
215
+
216
+ /**
217
+ * Finds topic-applied transactions with no direct proof old enough to evict.
218
+ */
219
+ findUnprovenAppliedTransactions?: (cutoffHeight: number, topic?: string) => Promise<UnprovenAppliedTransactionCandidate[]>
220
+
221
+ /**
222
+ * Deletes a topic-applied transaction record.
223
+ */
224
+ deleteAppliedTransaction?: (txid: string, topic: string) => Promise<void>
225
+
226
+ /**
227
+ * Returns proven topic-applied transactions whose proof anchors to the given
228
+ * block hash. Used to demote admissions when that block is reorged out.
229
+ */
230
+ findProvenAppliedTransactionsByBlockHash?: (blockHash: string) => Promise<Array<{ txid: string, topic: string, blockHeight: number }>>
231
+
232
+ /**
233
+ * Returns proven topic-applied transactions in a closed block-height range,
234
+ * including the proof's block hash and merkle root for revalidation sweeps.
235
+ */
236
+ findProvenAppliedTransactionsInRange?: (fromHeight: number, toHeight: number, topic?: string) => Promise<Array<{ txid: string, topic: string, blockHeight: number, blockHash?: string, merkleRoot?: string }>>
237
+
238
+ /**
239
+ * Demotes a proven applied transaction back to unproven: clears the block
240
+ * proof metadata and `proven` flag while retaining `firstSeenHeight`, so the
241
+ * "received" record survives until re-proof or unproven eviction.
242
+ */
243
+ demoteAppliedTransactionToUnproven?: (txid: string, topic: string) => Promise<void>
244
+
118
245
  /**
119
246
  * Updates the last interaction score for a given host and topic
120
247
  * @param host — The host identifier