@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.
- package/dist/cjs/package.json +4 -4
- package/dist/cjs/src/Engine.js +99 -1
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/Engine.js +98 -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 +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 +4 -4
- package/src/Engine.ts +148 -1
- package/src/__tests/Engine.test.ts +146 -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.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": "^
|
|
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.
|
|
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(
|