@bsv/overlay 2.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/overlay",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Overlay Services Engine",
6
6
  "main": "dist/cjs/mod.js",
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) {
@@ -504,6 +504,88 @@ describe('BSV Overlay Services Engine', () => {
504
504
  topics: ['Hello']
505
505
  })).rejects.toHaveProperty('message', 'Invalid merkle path for transaction 3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac')
506
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
+
507
589
  describe('For each topic being processed', () => {
508
590
  it('Checks for duplicate transactions', async () => {
509
591
  const engine = new Engine(