@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.
@@ -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 is
188
- Arcade, which wraps go-chaintracks; its reorg SSE is the source of truth:
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
- - **`GET /v2/reorg/stream`** emits `data: <JSON>\n\n` frames (no `event:`/`id:`
191
- lines; `: keepalive` comments between events). Each frame is a `ReorgEvent`:
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 all SPV verification.
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 existing unproven lifecycle: if it is
250
- re-mined, Arcade/ARC re-notifies `/arc-ingest` → `handleNewMerkleProof` re-proves
251
- it at its new height and the anchor includes it again; if it is never re-mined,
252
- `evictUnprovenTransactions` removes it after the threshold. The "we received it"
253
- record survives until one of those resolves.
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.configureReorgStream('https://arcade.example/v2/reorg/stream', 3)
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
- - `reorgStreamUrl` — when set, the SSE adapter reconciles reorgs in real time.
262
- - `reorgScanDepth` — sweep depth from tip (default `3`).
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` re-proves it and
285
- the anchor re-includes it; otherwise confirm it is eventually evicted as unproven.
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.0.4",
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": "^25.9.3",
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.2.3",
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 },