@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.
- package/README.md +226 -106
- package/dist/cjs/mod.js +6 -1
- package/dist/cjs/mod.js.map +1 -1
- package/dist/cjs/package.json +21 -11
- package/dist/cjs/src/BASM.js +92 -0
- package/dist/cjs/src/BASM.js.map +1 -0
- package/dist/cjs/src/BASMRemote.js +43 -0
- package/dist/cjs/src/BASMRemote.js.map +1 -0
- package/dist/cjs/src/Engine.js +649 -47
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPRemote.js +2 -2
- package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPStorage.js +18 -25
- package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/cjs/src/storage/knex/KnexStorage.js +329 -12
- package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/cjs/src/storage/knex/all-migrations.js +3 -1
- package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js +2 -24
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js +114 -0
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/mod.js +1 -0
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/src/BASM.js +85 -0
- package/dist/esm/src/BASM.js.map +1 -0
- package/dist/esm/src/BASMRemote.js +42 -0
- package/dist/esm/src/BASMRemote.js.map +1 -0
- package/dist/esm/src/Engine.js +637 -46
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/src/GASP/OverlayGASPRemote.js +2 -2
- package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
- package/dist/esm/src/GASP/OverlayGASPStorage.js +18 -25
- package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/esm/src/storage/knex/KnexStorage.js +323 -12
- package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/esm/src/storage/knex/all-migrations.js +3 -1
- package/dist/esm/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/esm/src/storage/knex/migrations/2025-05-28-001-enlarge.js +2 -24
- package/dist/esm/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
- package/dist/esm/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js +109 -0
- package/dist/esm/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/mod.d.ts +2 -0
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/src/BASM.d.ts +110 -0
- package/dist/types/src/BASM.d.ts.map +1 -0
- package/dist/types/src/BASMRemote.d.ts +14 -0
- package/dist/types/src/BASMRemote.d.ts.map +1 -0
- package/dist/types/src/Engine.d.ts +78 -1
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts +1 -1
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
- package/dist/types/src/LookupService.d.ts.map +1 -1
- package/dist/types/src/storage/Storage.d.ts +118 -0
- package/dist/types/src/storage/Storage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/KnexStorage.d.ts +38 -6
- package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
- package/dist/types/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -1
- package/dist/types/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
- package/dist/types/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/docs/API.md +72 -580
- package/docs/BRC-136-BASM.md +285 -0
- package/docs/README.md +2 -0
- package/docs/Synchronization.md +10 -1
- package/docs/examples/README.md +2 -2
- package/docs/examples/gs-wip.md +68 -76
- package/mod.ts +25 -1
- package/package.json +33 -23
- package/src/BASM.ts +208 -0
- package/src/BASMRemote.ts +54 -0
- package/src/Engine.ts +762 -47
- package/src/GASP/OverlayGASPRemote.ts +2 -2
- package/src/GASP/OverlayGASPStorage.ts +17 -22
- package/src/LookupService.ts +2 -1
- package/src/__tests/BASM.test.ts +44 -0
- package/src/__tests/BASMChain.test.ts +290 -0
- package/src/__tests/Engine.test.ts +3 -4
- package/src/storage/Storage.ts +127 -0
- package/src/storage/knex/KnexStorage.ts +376 -14
- package/src/storage/knex/all-migrations.ts +3 -1
- package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +2 -22
- 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
|
|
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
|
|
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 }>
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 (
|
|
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
|
|
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
|
|
package/src/LookupService.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LookupFormula } from './LookupFormula.js'
|
|
2
|
-
import { Script, LookupQuestion
|
|
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).
|
|
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) => {
|
package/src/storage/Storage.ts
CHANGED
|
@@ -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
|