@bsv/overlay 2.0.4 → 2.1.1
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/dist/cjs/package.json +13 -5
- package/dist/cjs/src/Engine.js +84 -0
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/Engine.js +83 -0
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/Engine.d.ts +53 -0
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/docs/BRC-136-BASM.md +58 -16
- package/package.json +13 -5
- package/src/Engine.ts +129 -0
- package/src/__tests/Engine.test.ts +64 -0
package/docs/BRC-136-BASM.md
CHANGED
|
@@ -184,11 +184,16 @@ that observed the reorg. The engine reconciles this automatically.
|
|
|
184
184
|
|
|
185
185
|
### Chaintracks is the reorg authority
|
|
186
186
|
|
|
187
|
-
Reorg detection is **not** reinvented here. The production chain tracker
|
|
188
|
-
|
|
187
|
+
Reorg detection is **not** reinvented here. The production chain tracker should
|
|
188
|
+
be a go-chaintracks compatible service. Arcade exposes go-chaintracks under
|
|
189
|
+
`/chaintracks/v2`, and a standalone go-chaintracks deployment may expose the same
|
|
190
|
+
API at `/v2`:
|
|
189
191
|
|
|
190
|
-
-
|
|
191
|
-
|
|
192
|
+
- **Arcade-mounted Chaintracks:** `GET /chaintracks/v2/reorg/stream`
|
|
193
|
+
- **Standalone go-chaintracks:** `GET /v2/reorg/stream`
|
|
194
|
+
|
|
195
|
+
The stream emits `data: <JSON>\n\n` frames (no `event:`/`id:` lines;
|
|
196
|
+
`: keepalive` comments between events). Each frame is a `ReorgEvent`:
|
|
192
197
|
|
|
193
198
|
```jsonc
|
|
194
199
|
{
|
|
@@ -199,8 +204,8 @@ Arcade, which wraps go-chaintracks; its reorg SSE is the source of truth:
|
|
|
199
204
|
}
|
|
200
205
|
```
|
|
201
206
|
|
|
202
|
-
- The same go-chaintracks service answers the merkle-root checks
|
|
203
|
-
(`isValidRootForHeight`) the engine already trusts for
|
|
207
|
+
- The same go-chaintracks-compatible service answers the merkle-root checks
|
|
208
|
+
(`isValidRootForHeight`) the engine already trusts for SPV verification.
|
|
204
209
|
|
|
205
210
|
`packages/overlays/overlay-express/src/ReorgStream.ts` consumes this stream and
|
|
206
211
|
maps each event to the engine: `orphanedHashes` → blocks to reconcile,
|
|
@@ -246,20 +251,53 @@ Reorg recovery and lookup-layer removal are independent and do not interfere:
|
|
|
246
251
|
- Reorg demotion touches only the admitted set (proven → unproven) via the chain
|
|
247
252
|
tracker; it never consults the ban list or lookup index.
|
|
248
253
|
|
|
249
|
-
A demoted transaction follows the engine's
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
A demoted transaction follows the engine's unproven lifecycle. The preferred
|
|
255
|
+
maintenance path is refresh-before-evict:
|
|
256
|
+
|
|
257
|
+
1. If the transaction is re-mined and a provider calls `/arc-ingest` with a
|
|
258
|
+
proof, `handleNewMerkleProof` re-proves it at its new height and the anchor
|
|
259
|
+
includes it again.
|
|
260
|
+
2. If no callback arrives, `refreshUnprovenTransactionProofs` asks configured
|
|
261
|
+
proof providers such as Arcade for a fresh proof.
|
|
262
|
+
3. `maintainUnprovenTransactions` refreshes proofs first, then calls
|
|
263
|
+
`evictUnprovenTransactions` for rows that are still unproven past the
|
|
264
|
+
configured threshold.
|
|
265
|
+
|
|
266
|
+
The "we received it" record survives until a proof, a terminal provider
|
|
267
|
+
invalidation, or age-based eviction resolves it.
|
|
268
|
+
|
|
269
|
+
### Provider invalidation and double spends
|
|
270
|
+
|
|
271
|
+
Provider callbacks can also report terminal rejection. When `/arc-ingest`
|
|
272
|
+
classifies a callback as double spend or another terminal invalid outcome, the
|
|
273
|
+
Express layer evicts the applied transaction immediately through
|
|
274
|
+
`Engine.evictAppliedTransaction`. This removes the transaction from the admitted
|
|
275
|
+
set and notifies lookup services through their `outputEvicted` path. It is more
|
|
276
|
+
important to stop serving rejected data than to wait for the normal unproven
|
|
277
|
+
eviction threshold.
|
|
254
278
|
|
|
255
279
|
### Configuration
|
|
256
280
|
|
|
257
281
|
```ts
|
|
258
|
-
server.
|
|
282
|
+
server.configureChaintracks('https://arcade.example', {
|
|
283
|
+
apiPrefix: '/chaintracks/v2',
|
|
284
|
+
reorgStream: true,
|
|
285
|
+
scanDepth: 3
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
server.configureEnableBASMSync(true)
|
|
289
|
+
server.configureUnprovenMaintenance({
|
|
290
|
+
thresholdBlocks: 144,
|
|
291
|
+
intervalMs: 60 * 60 * 1000
|
|
292
|
+
})
|
|
259
293
|
```
|
|
260
294
|
|
|
261
|
-
- `
|
|
262
|
-
-
|
|
295
|
+
- `apiPrefix` — `/chaintracks/v2` for Arcade-mounted Chaintracks; `/v2` for many
|
|
296
|
+
standalone go-chaintracks deployments.
|
|
297
|
+
- `reorgStream` — when enabled, the SSE adapter reconciles reorgs in real time.
|
|
298
|
+
- `scanDepth` — sweep depth from tip (default `3`).
|
|
299
|
+
- `thresholdBlocks` — how old an unproven row must be before maintenance tries
|
|
300
|
+
proof refresh and eviction.
|
|
263
301
|
- The block poll (`basmBlockPollIntervalMs`) runs the sweep as a fallback even
|
|
264
302
|
when no stream is configured.
|
|
265
303
|
|
|
@@ -281,5 +319,9 @@ token, yet can still prove it was received and admitted.
|
|
|
281
319
|
`firstSeenHeight` retained) and the affected `topic_block_anchors` rows now
|
|
282
320
|
carry the canonical block hashes with a recomputed `tac`.
|
|
283
321
|
5. Confirm `TAC(topic, tip)` reconverges with peers that observed the same reorg.
|
|
284
|
-
6. If the orphaned transaction is re-mined, confirm `/arc-ingest`
|
|
285
|
-
|
|
322
|
+
6. If the orphaned transaction is re-mined, confirm `/arc-ingest` or
|
|
323
|
+
`refreshUnprovenTransactionProofs` re-proves it and the anchor re-includes it.
|
|
324
|
+
Otherwise confirm `maintainUnprovenTransactions` eventually evicts it as
|
|
325
|
+
unproven.
|
|
326
|
+
7. If a provider reports a double spend, confirm the applied transaction is
|
|
327
|
+
evicted immediately and lookup services no longer return it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bsv/overlay",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "BSV Blockchain Overlay Services Engine",
|
|
6
6
|
"main": "dist/cjs/mod.js",
|
|
@@ -55,19 +55,27 @@
|
|
|
55
55
|
"homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay#readme",
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/jest": "^30.0.0",
|
|
58
|
-
"@types/node": "^
|
|
58
|
+
"@types/node": "^26.0.0",
|
|
59
59
|
"jest": "^30.4.2",
|
|
60
60
|
"ts-jest": "^29.4.11",
|
|
61
61
|
"ts-standard": "^12.0.2",
|
|
62
62
|
"ts2md": "^0.2.0",
|
|
63
63
|
"tsconfig-to-dual-package": "^1.2.0",
|
|
64
|
-
"typescript": "^6.0.3"
|
|
64
|
+
"typescript": "^6.0.3",
|
|
65
|
+
"@bsv/sdk": "^2.1.3"
|
|
65
66
|
},
|
|
66
67
|
"dependencies": {
|
|
67
|
-
"@bsv/gasp": "^1.
|
|
68
|
-
"@bsv/sdk": "^2.1.4",
|
|
68
|
+
"@bsv/gasp": "^1.3.0",
|
|
69
69
|
"knex": "^3.2.10"
|
|
70
70
|
},
|
|
71
|
+
"peerDependencies": {
|
|
72
|
+
"@bsv/sdk": "^2.1.6"
|
|
73
|
+
},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"@bsv/sdk": {
|
|
76
|
+
"optional": false
|
|
77
|
+
}
|
|
78
|
+
},
|
|
71
79
|
"scripts": {
|
|
72
80
|
"test": "npm run build && jest",
|
|
73
81
|
"test:watch": "npm run build && jest --watch",
|
package/src/Engine.ts
CHANGED
|
@@ -1595,6 +1595,135 @@ export class Engine {
|
|
|
1595
1595
|
}
|
|
1596
1596
|
}
|
|
1597
1597
|
|
|
1598
|
+
async refreshUnprovenTransactionProofs(options: {
|
|
1599
|
+
topic?: string
|
|
1600
|
+
thresholdBlocks?: number
|
|
1601
|
+
proofProvider: (txid: string) => Promise<{ merklePath: MerklePath, blockHeight?: number } | undefined>
|
|
1602
|
+
}): Promise<{
|
|
1603
|
+
cutoffHeight: number
|
|
1604
|
+
candidates: number
|
|
1605
|
+
refreshedTransactions: number
|
|
1606
|
+
missingProofs: number
|
|
1607
|
+
failedProofs: number
|
|
1608
|
+
failures: Array<{ txid: string, error: string }>
|
|
1609
|
+
}> {
|
|
1610
|
+
if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
|
|
1611
|
+
throw new TypeError('Storage does not support unproven transaction lookup')
|
|
1612
|
+
}
|
|
1613
|
+
if (this.chainTracker === 'scripts only') {
|
|
1614
|
+
throw new Error('Unproven proof refresh requires a ChainTracker to determine block age')
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks
|
|
1618
|
+
const currentHeight = await this.chainTracker.currentHeight()
|
|
1619
|
+
const cutoffHeight = currentHeight - thresholdBlocks
|
|
1620
|
+
const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic)
|
|
1621
|
+
const txids = [...new Set(candidates.map(candidate => candidate.txid))]
|
|
1622
|
+
let refreshedTransactions = 0
|
|
1623
|
+
let missingProofs = 0
|
|
1624
|
+
let failedProofs = 0
|
|
1625
|
+
const failures: Array<{ txid: string, error: string }> = []
|
|
1626
|
+
|
|
1627
|
+
for (const txid of txids) {
|
|
1628
|
+
try {
|
|
1629
|
+
const proof = await options.proofProvider(txid)
|
|
1630
|
+
if (proof === undefined) {
|
|
1631
|
+
missingProofs++
|
|
1632
|
+
continue
|
|
1633
|
+
}
|
|
1634
|
+
await this.handleNewMerkleProof(txid, proof.merklePath, proof.blockHeight)
|
|
1635
|
+
refreshedTransactions++
|
|
1636
|
+
} catch (error) {
|
|
1637
|
+
failedProofs++
|
|
1638
|
+
failures.push({
|
|
1639
|
+
txid,
|
|
1640
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1641
|
+
})
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
return {
|
|
1646
|
+
cutoffHeight,
|
|
1647
|
+
candidates: candidates.length,
|
|
1648
|
+
refreshedTransactions,
|
|
1649
|
+
missingProofs,
|
|
1650
|
+
failedProofs,
|
|
1651
|
+
failures
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
async maintainUnprovenTransactions(options: {
|
|
1656
|
+
topic?: string
|
|
1657
|
+
thresholdBlocks?: number
|
|
1658
|
+
proofProvider: (txid: string) => Promise<{ merklePath: MerklePath, blockHeight?: number } | undefined>
|
|
1659
|
+
}): Promise<{
|
|
1660
|
+
refresh: {
|
|
1661
|
+
cutoffHeight: number
|
|
1662
|
+
candidates: number
|
|
1663
|
+
refreshedTransactions: number
|
|
1664
|
+
missingProofs: number
|
|
1665
|
+
failedProofs: number
|
|
1666
|
+
failures: Array<{ txid: string, error: string }>
|
|
1667
|
+
}
|
|
1668
|
+
eviction: {
|
|
1669
|
+
cutoffHeight: number
|
|
1670
|
+
candidates: number
|
|
1671
|
+
evictedTransactions: number
|
|
1672
|
+
evictedOutputs: number
|
|
1673
|
+
}
|
|
1674
|
+
}> {
|
|
1675
|
+
const refresh = await this.refreshUnprovenTransactionProofs(options)
|
|
1676
|
+
const eviction = await this.evictUnprovenTransactions({
|
|
1677
|
+
topic: options.topic,
|
|
1678
|
+
thresholdBlocks: options.thresholdBlocks
|
|
1679
|
+
})
|
|
1680
|
+
return { refresh, eviction }
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async evictAppliedTransaction(txid: string, options: {
|
|
1684
|
+
topic?: string
|
|
1685
|
+
reason?: string
|
|
1686
|
+
} = {}): Promise<{
|
|
1687
|
+
txid: string
|
|
1688
|
+
reason?: string
|
|
1689
|
+
evictedTransactions: number
|
|
1690
|
+
evictedOutputs: number
|
|
1691
|
+
}> {
|
|
1692
|
+
if (typeof this.storage.deleteAppliedTransaction !== 'function') {
|
|
1693
|
+
throw new TypeError('Storage does not support applied transaction eviction')
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const outputs = await this.storage.findOutputsForTransaction(txid)
|
|
1697
|
+
const filtered = options.topic === undefined
|
|
1698
|
+
? outputs
|
|
1699
|
+
: outputs.filter(output => output.topic === options.topic)
|
|
1700
|
+
const topics = [...new Set(filtered.map(output => output.topic))]
|
|
1701
|
+
let evictedOutputs = 0
|
|
1702
|
+
|
|
1703
|
+
for (const output of filtered) {
|
|
1704
|
+
for (const service of Object.values(this.lookupServices)) {
|
|
1705
|
+
try {
|
|
1706
|
+
await service.outputEvicted(output.txid, output.outputIndex)
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
this.logger.debug(`outputEvicted notification failed for ${output.txid}.${output.outputIndex}: ${error}`)
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
await this.storage.deleteOutput(output.txid, output.outputIndex, output.topic)
|
|
1712
|
+
evictedOutputs++
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
for (const topic of topics) {
|
|
1716
|
+
await this.storage.deleteAppliedTransaction(txid, topic)
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
return {
|
|
1720
|
+
txid,
|
|
1721
|
+
reason: options.reason,
|
|
1722
|
+
evictedTransactions: topics.length,
|
|
1723
|
+
evictedOutputs
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1598
1727
|
/**
|
|
1599
1728
|
* Given a GASP request, create an initial response.
|
|
1600
1729
|
*
|
|
@@ -167,6 +167,70 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
167
167
|
}
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
+
it('refreshes old unproven transaction proofs before eviction', async () => {
|
|
171
|
+
const storage = {
|
|
172
|
+
...mockStorageEngine,
|
|
173
|
+
findUnprovenAppliedTransactions: jest.fn(async () => [
|
|
174
|
+
{
|
|
175
|
+
txid: exampleTXID,
|
|
176
|
+
topic: 'Hello',
|
|
177
|
+
firstSeenHeight: 799000,
|
|
178
|
+
outputs: [{ txid: exampleTXID, outputIndex: 0 }]
|
|
179
|
+
}
|
|
180
|
+
])
|
|
181
|
+
}
|
|
182
|
+
const engine = new Engine(
|
|
183
|
+
{ tm_helloworld: mockTopicManager },
|
|
184
|
+
{ ls_helloworld: mockLookupService },
|
|
185
|
+
storage,
|
|
186
|
+
mockChainTracker,
|
|
187
|
+
'https://example.com'
|
|
188
|
+
)
|
|
189
|
+
const merklePath = {} as any
|
|
190
|
+
;(engine as any).handleNewMerkleProof = jest.fn(async () => undefined)
|
|
191
|
+
|
|
192
|
+
const report = await engine.refreshUnprovenTransactionProofs({
|
|
193
|
+
thresholdBlocks: 144,
|
|
194
|
+
proofProvider: jest.fn(async () => ({ merklePath, blockHeight: 799900 }))
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
expect(report.refreshedTransactions).toBe(1)
|
|
198
|
+
expect(report.missingProofs).toBe(0)
|
|
199
|
+
expect((engine as any).handleNewMerkleProof).toHaveBeenCalledWith(exampleTXID, merklePath, 799900)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('evicts provider-invalidated applied transactions', async () => {
|
|
203
|
+
const deleteAppliedTransaction = jest.fn(async () => undefined)
|
|
204
|
+
const deleteOutput = jest.fn(async () => undefined)
|
|
205
|
+
const engine = new Engine(
|
|
206
|
+
{ tm_helloworld: mockTopicManager },
|
|
207
|
+
{ ls_helloworld: mockLookupService },
|
|
208
|
+
{
|
|
209
|
+
...mockStorageEngine,
|
|
210
|
+
findOutputsForTransaction: jest.fn(async () => [
|
|
211
|
+
{
|
|
212
|
+
...mockOutput,
|
|
213
|
+
topic: 'Hello'
|
|
214
|
+
}
|
|
215
|
+
]),
|
|
216
|
+
deleteOutput,
|
|
217
|
+
deleteAppliedTransaction
|
|
218
|
+
},
|
|
219
|
+
mockChainTracker,
|
|
220
|
+
'https://example.com'
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
const report = await engine.evictAppliedTransaction(exampleTXID, {
|
|
224
|
+
reason: 'DOUBLE_SPEND_ATTEMPTED'
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
expect(report.evictedTransactions).toBe(1)
|
|
228
|
+
expect(report.evictedOutputs).toBe(1)
|
|
229
|
+
expect(mockLookupService.outputEvicted).toHaveBeenCalledWith(exampleTXID, 0)
|
|
230
|
+
expect(deleteOutput).toHaveBeenCalledWith(exampleTXID, 0, 'Hello')
|
|
231
|
+
expect(deleteAppliedTransaction).toHaveBeenCalledWith(exampleTXID, 'Hello')
|
|
232
|
+
})
|
|
233
|
+
|
|
170
234
|
it('Uses SHIP sync configuration by default if no syncConfiguration was provided', () => {
|
|
171
235
|
const engine = new Engine(
|
|
172
236
|
{ tm_helloworld: mockTopicManager },
|