@bsv/overlay 2.1.0 → 2.2.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.
@@ -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.1.0",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Overlay Services Engine",
6
6
  "main": "dist/cjs/mod.js",
@@ -55,7 +55,7 @@
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",
@@ -65,11 +65,11 @@
65
65
  "@bsv/sdk": "^2.1.3"
66
66
  },
67
67
  "dependencies": {
68
- "@bsv/gasp": "^1.2.4",
68
+ "@bsv/gasp": "^1.3.0",
69
69
  "knex": "^3.2.10"
70
70
  },
71
71
  "peerDependencies": {
72
- "@bsv/sdk": "^2"
72
+ "@bsv/sdk": "^2.1.6"
73
73
  },
74
74
  "peerDependenciesMeta": {
75
75
  "@bsv/sdk": {
package/src/Engine.ts CHANGED
@@ -593,8 +593,26 @@ export class Engine {
593
593
  // ===================================================================
594
594
  // PHASE 2: BROADCAST (before any mutations)
595
595
  // ===================================================================
596
+ // Only broadcast when at least one topic actually accepted the
597
+ // transaction. For a non-failed topic, acceptance means: previously
598
+ // accepted (dupe / client retry), outputs admitted, coins retained, or
599
+ // previously-admitted coins consumed (e.g. a consume-only deletion such
600
+ // as a KVStore remove, even one that retains nothing). A topic manager
601
+ // REJECTS by throwing from identifyAdmissibleOutputs (tracked in
602
+ // failedTopics). A transaction every topic rejected must never reach the
603
+ // network: submitters treat an empty STEAK as a rejection and
604
+ // abort/release their held inputs, so broadcasting it anyway would
605
+ // desync their wallets from the chain.
606
+ const anyTopicAccepted = validations.some(v =>
607
+ !failedTopics.has(v.topic) && (
608
+ v.isDupe ||
609
+ v.admissibleOutputs.outputsToAdmit.length > 0 ||
610
+ v.admissibleOutputs.coinsToRetain.length > 0 ||
611
+ v.previousCoins.length > 0
612
+ )
613
+ )
596
614
  this.startTime(`broadcast_${txid.substring(0, 10)}`)
597
- if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
615
+ if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
598
616
  try {
599
617
  let response: BroadcastResponse | BroadcastFailure
600
618
  if (tx.merklePath !== undefined) {
@@ -1595,6 +1613,135 @@ export class Engine {
1595
1613
  }
1596
1614
  }
1597
1615
 
1616
+ async refreshUnprovenTransactionProofs(options: {
1617
+ topic?: string
1618
+ thresholdBlocks?: number
1619
+ proofProvider: (txid: string) => Promise<{ merklePath: MerklePath, blockHeight?: number } | undefined>
1620
+ }): Promise<{
1621
+ cutoffHeight: number
1622
+ candidates: number
1623
+ refreshedTransactions: number
1624
+ missingProofs: number
1625
+ failedProofs: number
1626
+ failures: Array<{ txid: string, error: string }>
1627
+ }> {
1628
+ if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1629
+ throw new TypeError('Storage does not support unproven transaction lookup')
1630
+ }
1631
+ if (this.chainTracker === 'scripts only') {
1632
+ throw new Error('Unproven proof refresh requires a ChainTracker to determine block age')
1633
+ }
1634
+
1635
+ const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks
1636
+ const currentHeight = await this.chainTracker.currentHeight()
1637
+ const cutoffHeight = currentHeight - thresholdBlocks
1638
+ const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic)
1639
+ const txids = [...new Set(candidates.map(candidate => candidate.txid))]
1640
+ let refreshedTransactions = 0
1641
+ let missingProofs = 0
1642
+ let failedProofs = 0
1643
+ const failures: Array<{ txid: string, error: string }> = []
1644
+
1645
+ for (const txid of txids) {
1646
+ try {
1647
+ const proof = await options.proofProvider(txid)
1648
+ if (proof === undefined) {
1649
+ missingProofs++
1650
+ continue
1651
+ }
1652
+ await this.handleNewMerkleProof(txid, proof.merklePath, proof.blockHeight)
1653
+ refreshedTransactions++
1654
+ } catch (error) {
1655
+ failedProofs++
1656
+ failures.push({
1657
+ txid,
1658
+ error: error instanceof Error ? error.message : String(error)
1659
+ })
1660
+ }
1661
+ }
1662
+
1663
+ return {
1664
+ cutoffHeight,
1665
+ candidates: candidates.length,
1666
+ refreshedTransactions,
1667
+ missingProofs,
1668
+ failedProofs,
1669
+ failures
1670
+ }
1671
+ }
1672
+
1673
+ async maintainUnprovenTransactions(options: {
1674
+ topic?: string
1675
+ thresholdBlocks?: number
1676
+ proofProvider: (txid: string) => Promise<{ merklePath: MerklePath, blockHeight?: number } | undefined>
1677
+ }): Promise<{
1678
+ refresh: {
1679
+ cutoffHeight: number
1680
+ candidates: number
1681
+ refreshedTransactions: number
1682
+ missingProofs: number
1683
+ failedProofs: number
1684
+ failures: Array<{ txid: string, error: string }>
1685
+ }
1686
+ eviction: {
1687
+ cutoffHeight: number
1688
+ candidates: number
1689
+ evictedTransactions: number
1690
+ evictedOutputs: number
1691
+ }
1692
+ }> {
1693
+ const refresh = await this.refreshUnprovenTransactionProofs(options)
1694
+ const eviction = await this.evictUnprovenTransactions({
1695
+ topic: options.topic,
1696
+ thresholdBlocks: options.thresholdBlocks
1697
+ })
1698
+ return { refresh, eviction }
1699
+ }
1700
+
1701
+ async evictAppliedTransaction(txid: string, options: {
1702
+ topic?: string
1703
+ reason?: string
1704
+ } = {}): Promise<{
1705
+ txid: string
1706
+ reason?: string
1707
+ evictedTransactions: number
1708
+ evictedOutputs: number
1709
+ }> {
1710
+ if (typeof this.storage.deleteAppliedTransaction !== 'function') {
1711
+ throw new TypeError('Storage does not support applied transaction eviction')
1712
+ }
1713
+
1714
+ const outputs = await this.storage.findOutputsForTransaction(txid)
1715
+ const filtered = options.topic === undefined
1716
+ ? outputs
1717
+ : outputs.filter(output => output.topic === options.topic)
1718
+ const topics = [...new Set(filtered.map(output => output.topic))]
1719
+ let evictedOutputs = 0
1720
+
1721
+ for (const output of filtered) {
1722
+ for (const service of Object.values(this.lookupServices)) {
1723
+ try {
1724
+ await service.outputEvicted(output.txid, output.outputIndex)
1725
+ } catch (error) {
1726
+ this.logger.debug(`outputEvicted notification failed for ${output.txid}.${output.outputIndex}: ${error}`)
1727
+ }
1728
+ }
1729
+ await this.storage.deleteOutput(output.txid, output.outputIndex, output.topic)
1730
+ evictedOutputs++
1731
+ }
1732
+
1733
+ for (const topic of topics) {
1734
+ await this.storage.deleteAppliedTransaction(txid, topic)
1735
+ }
1736
+
1737
+ return {
1738
+ txid,
1739
+ reason: options.reason,
1740
+ evictedTransactions: topics.length,
1741
+ evictedOutputs
1742
+ }
1743
+ }
1744
+
1598
1745
  /**
1599
1746
  * Given a GASP request, create an initial response.
1600
1747
  *
@@ -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 },
@@ -440,6 +504,88 @@ describe('BSV Overlay Services Engine', () => {
440
504
  topics: ['Hello']
441
505
  })).rejects.toHaveProperty('message', 'Invalid merkle path for transaction 3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac')
442
506
  })
507
+ describe('Broadcast gating (PHASE 2)', () => {
508
+ const makeBroadcaster = (): { broadcast: jest.Mock } => ({
509
+ broadcast: jest.fn(async () => ({ status: 'success', txid: exampleTXID, message: 'ok' }))
510
+ })
511
+ const makeEngine = (broadcaster: any): Engine => new Engine(
512
+ { Hello: mockTopicManager },
513
+ {},
514
+ mockStorageEngine,
515
+ mockChainTracker,
516
+ undefined,
517
+ undefined,
518
+ undefined,
519
+ broadcaster
520
+ )
521
+
522
+ it('broadcasts when at least one topic admits outputs', async () => {
523
+ const broadcaster = makeBroadcaster()
524
+ const engine = makeEngine(broadcaster)
525
+ await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
526
+ expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
527
+ })
528
+
529
+ it('never broadcasts a transaction every topic manager rejected', async () => {
530
+ mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({
531
+ outputsToAdmit: [],
532
+ coinsToRetain: []
533
+ }))
534
+ const broadcaster = makeBroadcaster()
535
+ const engine = makeEngine(broadcaster)
536
+ await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
537
+ expect(broadcaster.broadcast).not.toHaveBeenCalled()
538
+ })
539
+
540
+ it('never broadcasts when topic validation throws, even with tracked coins consumed', async () => {
541
+ // The rejected tx spends previously-admitted coins — a throw must
542
+ // still gate the broadcast (this is the rejected-transfer shape).
543
+ mockStorageEngine.findOutput = jest.fn(async () => mockOutput)
544
+ mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => {
545
+ throw new Error('rule violation')
546
+ })
547
+ const broadcaster = makeBroadcaster()
548
+ const engine = makeEngine(broadcaster)
549
+ await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
550
+ expect(broadcaster.broadcast).not.toHaveBeenCalled()
551
+ })
552
+
553
+ it('broadcasts a consume-only transaction that retains nothing (history purge)', async () => {
554
+ // e.g. a KVStore remove that also purges history: previously-admitted
555
+ // coins are consumed, nothing admitted, nothing retained. That is an
556
+ // acceptance, not a rejection (rejection = throw).
557
+ mockStorageEngine.findOutput = jest.fn(async () => mockOutput)
558
+ mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({
559
+ outputsToAdmit: [],
560
+ coinsToRetain: []
561
+ }))
562
+ const broadcaster = makeBroadcaster()
563
+ const engine = makeEngine(broadcaster)
564
+ await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
565
+ expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
566
+ })
567
+
568
+ it('still broadcasts a duplicate (previously accepted) transaction', async () => {
569
+ mockStorageEngine.doesAppliedTransactionExist = jest.fn(async () => true)
570
+ const broadcaster = makeBroadcaster()
571
+ const engine = makeEngine(broadcaster)
572
+ await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
573
+ expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
574
+ })
575
+
576
+ it('broadcasts when outputs are consumed but none admitted (coinsToRetain only)', async () => {
577
+ mockStorageEngine.findOutput = jest.fn(async () => mockOutput)
578
+ mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({
579
+ outputsToAdmit: [],
580
+ coinsToRetain: [0]
581
+ }))
582
+ const broadcaster = makeBroadcaster()
583
+ const engine = makeEngine(broadcaster)
584
+ await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
585
+ expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
586
+ })
587
+ })
588
+
443
589
  describe('For each topic being processed', () => {
444
590
  it('Checks for duplicate transactions', async () => {
445
591
  const engine = new Engine(