@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/dist/cjs/package.json +1 -1
- package/dist/cjs/src/Engine.js +15 -1
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/Engine.js +15 -1
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/Engine.ts +19 -1
- package/src/__tests/Engine.test.ts +82 -0
package/package.json
CHANGED
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(
|