@bsv/overlay 2.1.1 → 2.2.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.
- package/README.md +4 -1
- package/dist/cjs/package.json +10 -10
- package/dist/cjs/src/Engine.js +29 -20
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPStorage.js +2 -1
- package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js +36 -12
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/Engine.js +29 -20
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/src/GASP/OverlayGASPStorage.js +2 -1
- package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/esm/src/storage/knex/migrations/2024-07-17-001-transactions.js +36 -12
- package/dist/esm/src/storage/knex/migrations/2024-07-17-001-transactions.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/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/Engine.ts +32 -19
- package/src/GASP/OverlayGASPStorage.ts +2 -1
- package/src/__tests/BASMChain.test.ts +26 -21
- package/src/__tests/Engine.test.ts +124 -6
- package/src/__tests/OverlayGASPStorage.test.ts +34 -0
- package/src/storage/knex/migrations/2024-07-17-001-transactions.ts +36 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bsv/overlay",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "BSV Blockchain Overlay Services Engine",
|
|
6
6
|
"main": "dist/cjs/mod.js",
|
|
@@ -55,18 +55,18 @@
|
|
|
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": "^26.
|
|
58
|
+
"@types/node": "^26.1.1",
|
|
59
59
|
"jest": "^30.4.2",
|
|
60
|
-
"ts-jest": "^29.4.
|
|
61
|
-
"
|
|
62
|
-
"ts2md": "^0.2.0",
|
|
60
|
+
"ts-jest": "^29.4.12",
|
|
61
|
+
"ts2md": "^0.2.8",
|
|
63
62
|
"tsconfig-to-dual-package": "^1.2.0",
|
|
64
63
|
"typescript": "^6.0.3",
|
|
65
|
-
"
|
|
64
|
+
"oxlint": "^1.75.0",
|
|
65
|
+
"@bsv/sdk": "^2.2.0"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"
|
|
69
|
-
"
|
|
68
|
+
"knex": "^3.3.0",
|
|
69
|
+
"@bsv/gasp": "^1.3.1"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
72
|
"@bsv/sdk": "^2.1.6"
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
"test": "npm run build && jest",
|
|
81
81
|
"test:watch": "npm run build && jest --watch",
|
|
82
82
|
"test:coverage": "npm run build && jest --coverage",
|
|
83
|
-
"lint": "
|
|
84
|
-
"lint:ci": "
|
|
83
|
+
"lint": "oxlint src",
|
|
84
|
+
"lint:ci": "oxlint src",
|
|
85
85
|
"build": "tsc -b && tsconfig-to-dual-package tsconfig.cjs.json",
|
|
86
86
|
"dev": "tsc -b -w",
|
|
87
87
|
"prepublish": "npm run build",
|
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) {
|
|
@@ -901,7 +919,7 @@ export class Engine {
|
|
|
901
919
|
async lookup(lookupQuestion: LookupQuestion): Promise<LookupAnswer> {
|
|
902
920
|
// Validate a lookup service for the provider is found
|
|
903
921
|
const lookupService = this.lookupServices[lookupQuestion.service]
|
|
904
|
-
if (lookupService === undefined || lookupService === null) throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}
|
|
922
|
+
if (lookupService === undefined || lookupService === null) throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`)
|
|
905
923
|
|
|
906
924
|
const lookupResult = await lookupService.lookup(lookupQuestion)
|
|
907
925
|
const hydrationContext = this.createUTXOHistoryHydrationContext()
|
|
@@ -1936,20 +1954,20 @@ export class Engine {
|
|
|
1936
1954
|
* @param proof for txid
|
|
1937
1955
|
*/
|
|
1938
1956
|
private updateInputProofs(tx: Transaction, txid: string, proof: MerklePath): void {
|
|
1939
|
-
if (tx.
|
|
1957
|
+
if (tx.id('hex') === txid) {
|
|
1940
1958
|
// Update the merkle path to handle potential reorgs
|
|
1941
1959
|
tx.merklePath = proof
|
|
1942
1960
|
return
|
|
1943
1961
|
}
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1962
|
+
// A mined transaction's source graph is no longer part of its BEEF. Do not
|
|
1963
|
+
// replace an unrelated transaction's proof with the proof for an ancestor.
|
|
1964
|
+
if (tx.merklePath !== undefined) return
|
|
1965
|
+
|
|
1966
|
+
for (const input of tx.inputs) {
|
|
1967
|
+
// All inputs must have sourceTransactions
|
|
1968
|
+
const stx = input.sourceTransaction
|
|
1969
|
+
if (typeof stx !== 'object') continue
|
|
1970
|
+
this.updateInputProofs(stx, txid, proof)
|
|
1953
1971
|
}
|
|
1954
1972
|
}
|
|
1955
1973
|
|
|
@@ -1966,13 +1984,8 @@ export class Engine {
|
|
|
1966
1984
|
}
|
|
1967
1985
|
|
|
1968
1986
|
const tx = Transaction.fromBEEF(output.beef)
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
tx.merklePath = proof
|
|
1972
|
-
return
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
// recursively update all sourceTransactions proven by (txid,proof)
|
|
1987
|
+
// Update this transaction, or recursively update the matching source
|
|
1988
|
+
// transaction. This also persists replacement proofs after a reorg.
|
|
1976
1989
|
this.updateInputProofs(tx, txid, proof)
|
|
1977
1990
|
|
|
1978
1991
|
// Update the output's BEEF in the storage DB
|
|
@@ -145,7 +145,8 @@ export class OverlayGASPStorage implements GASPStorage {
|
|
|
145
145
|
}
|
|
146
146
|
return await this.stripAlreadyKnownInputs(response)
|
|
147
147
|
} catch (e) {
|
|
148
|
-
|
|
148
|
+
const message = e instanceof Error ? e.message : String(e)
|
|
149
|
+
console.error(`An error occurred when identifying needed inputs for transaction: ${parsedTx.id('hex')}.${tx.outputIndex}: ${message}`)
|
|
149
150
|
// Cut off the graph in case of an error here.
|
|
150
151
|
}
|
|
151
152
|
// By default, if the topic manager isn't able to stipulate needed inputs, only the inputs necessary for SPV are requested.
|
|
@@ -28,6 +28,11 @@ interface FakeStore {
|
|
|
28
28
|
applied?: AppliedRow[]
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function must<T> (value: T | undefined, message: string): T {
|
|
32
|
+
if (value === undefined) throw new Error(message)
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
function makeStorage (store: FakeStore): any {
|
|
32
37
|
const key = (topic: string, height: number): string => `${topic}:${height}`
|
|
33
38
|
return {
|
|
@@ -111,11 +116,11 @@ describe('BRC-136 BASM anchor chain continuity', () => {
|
|
|
111
116
|
const store: FakeStore = { anchors: new Map(), admitted: new Map() }
|
|
112
117
|
|
|
113
118
|
// Genesis: topic admits two txs at height 100.
|
|
114
|
-
store.admitted
|
|
119
|
+
must(store.admitted, 'admitted transaction map').set(100, [
|
|
115
120
|
{ txid: TXID_1, blockIndex: 0 },
|
|
116
121
|
{ txid: TXID_2, blockIndex: 1 }
|
|
117
122
|
])
|
|
118
|
-
const genesisRoot = computeBasmRoot(store.admitted
|
|
123
|
+
const genesisRoot = computeBasmRoot(must(must(store.admitted, 'admitted transaction map').get(100), 'genesis admissions'))
|
|
119
124
|
const genesisTac = computeTac(ZERO, blockHashFor(100), genesisRoot)
|
|
120
125
|
store.anchors.set('tm_test:100', {
|
|
121
126
|
topic: 'tm_test',
|
|
@@ -137,7 +142,7 @@ describe('BRC-136 BASM anchor chain continuity', () => {
|
|
|
137
142
|
// 101..105 are empty anchors (zero root, zero count) chained off genesis.
|
|
138
143
|
let expectedTac = genesisTac
|
|
139
144
|
for (let h = 101; h <= 105; h++) {
|
|
140
|
-
const anchor = store.anchors.get(`tm_test:${h}`)
|
|
145
|
+
const anchor = must(store.anchors.get(`tm_test:${h}`), `anchor ${h}`)
|
|
141
146
|
expect(anchor.basmRoot).toBe(ZERO)
|
|
142
147
|
expect(anchor.admittedCount).toBe(0)
|
|
143
148
|
expectedTac = computeTac(expectedTac, blockHashFor(h), ZERO)
|
|
@@ -146,7 +151,7 @@ describe('BRC-136 BASM anchor chain continuity', () => {
|
|
|
146
151
|
|
|
147
152
|
// The tip TAC is a cumulative hash that still depends on the genesis block —
|
|
148
153
|
// i.e. it was NOT reset to a per-block value.
|
|
149
|
-
const tipTac = store.anchors.get('tm_test:105')
|
|
154
|
+
const tipTac = must(store.anchors.get('tm_test:105'), 'tip anchor').tac
|
|
150
155
|
const resetTac = computeTac(ZERO, blockHashFor(105), ZERO)
|
|
151
156
|
expect(tipTac).not.toBe(resetTac)
|
|
152
157
|
})
|
|
@@ -192,29 +197,29 @@ describe('BRC-136 BASM reorg handling', () => {
|
|
|
192
197
|
})
|
|
193
198
|
|
|
194
199
|
// TXID_2 is demoted: no longer proven, block metadata cleared.
|
|
195
|
-
const row = store.applied
|
|
200
|
+
const row = must(store.applied?.find(r => r.txid === TXID_2), 'orphaned applied row')
|
|
196
201
|
expect(row.proven).toBe(false)
|
|
197
202
|
expect(row.blockHeight).toBeUndefined()
|
|
198
203
|
// It survives as a receipt record (firstSeenHeight retained).
|
|
199
204
|
expect(row.firstSeenHeight).toBe(101)
|
|
200
205
|
|
|
201
206
|
// Anchor at 101 rebuilt: empty admitted set, canonical hash, recomputed TAC.
|
|
202
|
-
const anchor101 = store.anchors.get('tm_test:101')
|
|
207
|
+
const anchor101 = must(store.anchors.get('tm_test:101'), 'anchor 101')
|
|
203
208
|
expect(anchor101.admittedCount).toBe(0)
|
|
204
209
|
expect(anchor101.basmRoot).toBe(ZERO)
|
|
205
210
|
expect(anchor101.blockHash).toBe(H101_NEW)
|
|
206
|
-
const tac100 = store.anchors.get('tm_test:100')
|
|
211
|
+
const tac100 = must(store.anchors.get('tm_test:100'), 'anchor 100').tac
|
|
207
212
|
expect(anchor101.tac).toBe(computeTac(tac100, H101_NEW, ZERO))
|
|
208
213
|
|
|
209
214
|
// Height 100 is untouched.
|
|
210
215
|
const root100 = computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }])
|
|
211
|
-
expect(store.anchors.get('tm_test:100')
|
|
216
|
+
expect(must(store.anchors.get('tm_test:100'), 'anchor 100').basmRoot).toBe(root100)
|
|
212
217
|
})
|
|
213
218
|
|
|
214
219
|
it('is idempotent: orphaned hashes matching no proven rows leave the chain unchanged', async () => {
|
|
215
220
|
const store = seedTwoBlockChain()
|
|
216
221
|
const engine = makeEngine(store, { currentHeight: 101 })
|
|
217
|
-
const before = store.anchors.get('tm_test:101')
|
|
222
|
+
const before = must(store.anchors.get('tm_test:101'), 'anchor 101').tac
|
|
218
223
|
|
|
219
224
|
await (engine as any).handleReorg({
|
|
220
225
|
orphanedBlockHashes: ['ffff000000000000000000000000000000000000000000000000000000000000'],
|
|
@@ -222,8 +227,8 @@ describe('BRC-136 BASM reorg handling', () => {
|
|
|
222
227
|
newTipHeight: 101
|
|
223
228
|
})
|
|
224
229
|
|
|
225
|
-
expect(store.applied
|
|
226
|
-
expect(store.anchors.get('tm_test:101')
|
|
230
|
+
expect(must(store.applied?.find(r => r.txid === TXID_2), 'applied row 2').proven).toBe(true)
|
|
231
|
+
expect(must(store.anchors.get('tm_test:101'), 'anchor 101').tac).toBe(before)
|
|
227
232
|
})
|
|
228
233
|
|
|
229
234
|
it('re-proving a demoted tx at a new height restores it to the admitted set', async () => {
|
|
@@ -235,10 +240,10 @@ describe('BRC-136 BASM reorg handling', () => {
|
|
|
235
240
|
rebuildFromHeight: 101,
|
|
236
241
|
newTipHeight: 101
|
|
237
242
|
})
|
|
238
|
-
expect(store.anchors.get('tm_test:101')
|
|
243
|
+
expect(must(store.anchors.get('tm_test:101'), 'anchor 101').basmRoot).toBe(ZERO)
|
|
239
244
|
|
|
240
245
|
// TXID_2 re-mined into the new canonical block at 101.
|
|
241
|
-
const row = store.applied
|
|
246
|
+
const row = must(store.applied?.find(r => r.txid === TXID_2), 'applied row 2')
|
|
242
247
|
row.proven = true
|
|
243
248
|
row.blockHeight = 101
|
|
244
249
|
row.blockHash = H101_NEW
|
|
@@ -251,7 +256,7 @@ describe('BRC-136 BASM reorg handling', () => {
|
|
|
251
256
|
newTipHeight: 101
|
|
252
257
|
})
|
|
253
258
|
|
|
254
|
-
const anchor101 = store.anchors.get('tm_test:101')
|
|
259
|
+
const anchor101 = must(store.anchors.get('tm_test:101'), 'anchor 101')
|
|
255
260
|
expect(anchor101.admittedCount).toBe(1)
|
|
256
261
|
expect(anchor101.basmRoot).toBe(computeBasmRoot([{ txid: TXID_2, blockIndex: 0 }]))
|
|
257
262
|
expect(anchor101.blockHash).toBe(H101_NEW)
|
|
@@ -268,23 +273,23 @@ describe('BRC-136 BASM reorg revalidation sweep', () => {
|
|
|
268
273
|
|
|
269
274
|
await (engine as any).revalidateRecentAnchors(3)
|
|
270
275
|
|
|
271
|
-
expect(store.applied
|
|
272
|
-
const anchor101 = store.anchors.get('tm_test:101')
|
|
276
|
+
expect(must(store.applied?.find(r => r.txid === TXID_2), 'applied row 2').proven).toBe(false)
|
|
277
|
+
const anchor101 = must(store.anchors.get('tm_test:101'), 'anchor 101')
|
|
273
278
|
expect(anchor101.basmRoot).toBe(ZERO)
|
|
274
279
|
expect(anchor101.blockHash).toBe(H101_NEW)
|
|
275
280
|
// Height 100 still valid and untouched.
|
|
276
|
-
expect(store.applied
|
|
277
|
-
expect(store.anchors.get('tm_test:100')
|
|
281
|
+
expect(must(store.applied?.find(r => r.txid === TXID_1), 'applied row 1').proven).toBe(true)
|
|
282
|
+
expect(must(store.anchors.get('tm_test:100'), 'anchor 100').basmRoot).toBe(computeBasmRoot([{ txid: TXID_1, blockIndex: 0 }]))
|
|
278
283
|
})
|
|
279
284
|
|
|
280
285
|
it('leaves the chain unchanged when every proof in the window still validates', async () => {
|
|
281
286
|
const store = seedTwoBlockChain()
|
|
282
287
|
const engine = makeEngine(store, { currentHeight: 101, isValidRootForHeight: async () => true })
|
|
283
|
-
const before = store.anchors.get('tm_test:101')
|
|
288
|
+
const before = must(store.anchors.get('tm_test:101'), 'anchor 101').tac
|
|
284
289
|
|
|
285
290
|
await (engine as any).revalidateRecentAnchors(3)
|
|
286
291
|
|
|
287
|
-
expect(store.applied
|
|
288
|
-
expect(store.anchors.get('tm_test:101')
|
|
292
|
+
expect(must(store.applied?.find(r => r.txid === TXID_2), 'applied row 2').proven).toBe(true)
|
|
293
|
+
expect(must(store.anchors.get('tm_test:101'), 'anchor 101').tac).toBe(before)
|
|
289
294
|
})
|
|
290
295
|
})
|
|
@@ -309,6 +309,37 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
309
309
|
})
|
|
310
310
|
})
|
|
311
311
|
describe('handleNewMerkleProof tests', () => {
|
|
312
|
+
it('persists a replacement proof when the stored BEEF is already mined', async () => {
|
|
313
|
+
const tx = Transaction.fromHexBEEF(beef37abad0)
|
|
314
|
+
const txid = tx.id('hex')
|
|
315
|
+
const output: Output = {
|
|
316
|
+
txid,
|
|
317
|
+
outputIndex: 0,
|
|
318
|
+
outputScript: tx.outputs[0].lockingScript.toBinary(),
|
|
319
|
+
topic: 'hello',
|
|
320
|
+
satoshis: tx.outputs[0].satoshis,
|
|
321
|
+
beef: tx.toBEEF(),
|
|
322
|
+
spent: false,
|
|
323
|
+
outputsConsumed: [],
|
|
324
|
+
consumedBy: [],
|
|
325
|
+
score: Date.now()
|
|
326
|
+
}
|
|
327
|
+
const updateTransactionBEEF = jest.fn(async () => undefined)
|
|
328
|
+
mockStorageEngine.findOutputsForTransaction = jest.fn(async () => [output])
|
|
329
|
+
mockStorageEngine.updateTransactionBEEF = updateTransactionBEEF
|
|
330
|
+
const engine = new Engine(
|
|
331
|
+
{ Hello: mockTopicManager },
|
|
332
|
+
{ Hello: mockLookupService },
|
|
333
|
+
mockStorageEngine,
|
|
334
|
+
mockChainTracker
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if (tx.merklePath === undefined) throw new Error('improper test setup')
|
|
338
|
+
await engine.handleNewMerkleProof(txid, tx.merklePath)
|
|
339
|
+
|
|
340
|
+
expect(updateTransactionBEEF).toHaveBeenCalledWith(txid, tx.toBEEF())
|
|
341
|
+
})
|
|
342
|
+
|
|
312
343
|
it('0 simple proof', async () => {
|
|
313
344
|
const beef = beef27c8f1
|
|
314
345
|
const txid = txid27c8f
|
|
@@ -423,7 +454,12 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
423
454
|
|
|
424
455
|
const mp37abad = Transaction.fromHexBEEF(beef37abad0).merklePath
|
|
425
456
|
await engine.handleNewMerkleProof(txid37abad, mp37abad)
|
|
426
|
-
expect(
|
|
457
|
+
expect(newBEEF[`${txid37abad}`]).toBe(beef37abad0)
|
|
458
|
+
expect(newBEEF[`${txid877734}`].length).toBeGreaterThan(beef8777340.length)
|
|
459
|
+
expect(newBEEF[`${txid509f5e}`].length).toBeGreaterThan(beef509f5e0.length)
|
|
460
|
+
expect(newBEEF[`${txid942620}`].length).toBeGreaterThan(beef9426200.length)
|
|
461
|
+
expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d1820.length)
|
|
462
|
+
expect(Object.keys(newBEEF)).toHaveLength(5)
|
|
427
463
|
|
|
428
464
|
const mp877734 = Transaction.fromHexBEEF(beef8777340).merklePath
|
|
429
465
|
await engine.handleNewMerkleProof(txid877734, mp877734)
|
|
@@ -431,7 +467,7 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
431
467
|
expect(newBEEF[`${txid509f5e}`].length).toBeGreaterThan(beef509f5e0.length)
|
|
432
468
|
expect(newBEEF[`${txid942620}`].length).toBeGreaterThan(beef9426200.length)
|
|
433
469
|
expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d1820.length)
|
|
434
|
-
expect(Object.keys(newBEEF)
|
|
470
|
+
expect(Object.keys(newBEEF)).toHaveLength(5)
|
|
435
471
|
|
|
436
472
|
const mp509f5e = Transaction.fromHexBEEF(beef509f5e0).merklePath
|
|
437
473
|
await engine.handleNewMerkleProof(txid509f5e, mp509f5e)
|
|
@@ -439,7 +475,7 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
439
475
|
expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e0)
|
|
440
476
|
expect(newBEEF[`${txid942620}`].length).toBeGreaterThan(beef9426200.length)
|
|
441
477
|
expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d1820.length)
|
|
442
|
-
expect(Object.keys(newBEEF)
|
|
478
|
+
expect(Object.keys(newBEEF)).toHaveLength(5)
|
|
443
479
|
|
|
444
480
|
const mp942620 = Transaction.fromHexBEEF(beef9426200).merklePath
|
|
445
481
|
await engine.handleNewMerkleProof(txid942620, mp942620)
|
|
@@ -447,7 +483,7 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
447
483
|
expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e0)
|
|
448
484
|
expect(newBEEF[`${txid942620}`]).toBe(beef9426200)
|
|
449
485
|
expect(newBEEF[`${txid17d182}`].length).toBeGreaterThan(beef17d1820.length)
|
|
450
|
-
expect(Object.keys(newBEEF)
|
|
486
|
+
expect(Object.keys(newBEEF)).toHaveLength(5)
|
|
451
487
|
|
|
452
488
|
const mp17d182 = Transaction.fromHexBEEF(beef17d1820).merklePath
|
|
453
489
|
await engine.handleNewMerkleProof(txid17d182, mp17d182)
|
|
@@ -455,7 +491,7 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
455
491
|
expect(newBEEF[`${txid509f5e}`]).toBe(beef509f5e0)
|
|
456
492
|
expect(newBEEF[`${txid942620}`]).toBe(beef9426200)
|
|
457
493
|
expect(newBEEF[`${txid17d182}`]).toBe(beef17d1820)
|
|
458
|
-
expect(Object.keys(newBEEF)
|
|
494
|
+
expect(Object.keys(newBEEF)).toHaveLength(5)
|
|
459
495
|
})
|
|
460
496
|
})
|
|
461
497
|
|
|
@@ -504,6 +540,88 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
504
540
|
topics: ['Hello']
|
|
505
541
|
})).rejects.toHaveProperty('message', 'Invalid merkle path for transaction 3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac')
|
|
506
542
|
})
|
|
543
|
+
describe('Broadcast gating (PHASE 2)', () => {
|
|
544
|
+
const makeBroadcaster = (): { broadcast: jest.Mock } => ({
|
|
545
|
+
broadcast: jest.fn(async () => ({ status: 'success', txid: exampleTXID, message: 'ok' }))
|
|
546
|
+
})
|
|
547
|
+
const makeEngine = (broadcaster: any): Engine => new Engine(
|
|
548
|
+
{ Hello: mockTopicManager },
|
|
549
|
+
{},
|
|
550
|
+
mockStorageEngine,
|
|
551
|
+
mockChainTracker,
|
|
552
|
+
undefined,
|
|
553
|
+
undefined,
|
|
554
|
+
undefined,
|
|
555
|
+
broadcaster
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
it('broadcasts when at least one topic admits outputs', async () => {
|
|
559
|
+
const broadcaster = makeBroadcaster()
|
|
560
|
+
const engine = makeEngine(broadcaster)
|
|
561
|
+
await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
|
|
562
|
+
expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('never broadcasts a transaction every topic manager rejected', async () => {
|
|
566
|
+
mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({
|
|
567
|
+
outputsToAdmit: [],
|
|
568
|
+
coinsToRetain: []
|
|
569
|
+
}))
|
|
570
|
+
const broadcaster = makeBroadcaster()
|
|
571
|
+
const engine = makeEngine(broadcaster)
|
|
572
|
+
await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
|
|
573
|
+
expect(broadcaster.broadcast).not.toHaveBeenCalled()
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('never broadcasts when topic validation throws, even with tracked coins consumed', async () => {
|
|
577
|
+
// The rejected tx spends previously-admitted coins — a throw must
|
|
578
|
+
// still gate the broadcast (this is the rejected-transfer shape).
|
|
579
|
+
mockStorageEngine.findOutput = jest.fn(async () => mockOutput)
|
|
580
|
+
mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => {
|
|
581
|
+
throw new Error('rule violation')
|
|
582
|
+
})
|
|
583
|
+
const broadcaster = makeBroadcaster()
|
|
584
|
+
const engine = makeEngine(broadcaster)
|
|
585
|
+
await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
|
|
586
|
+
expect(broadcaster.broadcast).not.toHaveBeenCalled()
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('broadcasts a consume-only transaction that retains nothing (history purge)', async () => {
|
|
590
|
+
// e.g. a KVStore remove that also purges history: previously-admitted
|
|
591
|
+
// coins are consumed, nothing admitted, nothing retained. That is an
|
|
592
|
+
// acceptance, not a rejection (rejection = throw).
|
|
593
|
+
mockStorageEngine.findOutput = jest.fn(async () => mockOutput)
|
|
594
|
+
mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({
|
|
595
|
+
outputsToAdmit: [],
|
|
596
|
+
coinsToRetain: []
|
|
597
|
+
}))
|
|
598
|
+
const broadcaster = makeBroadcaster()
|
|
599
|
+
const engine = makeEngine(broadcaster)
|
|
600
|
+
await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
|
|
601
|
+
expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('still broadcasts a duplicate (previously accepted) transaction', async () => {
|
|
605
|
+
mockStorageEngine.doesAppliedTransactionExist = jest.fn(async () => true)
|
|
606
|
+
const broadcaster = makeBroadcaster()
|
|
607
|
+
const engine = makeEngine(broadcaster)
|
|
608
|
+
await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
|
|
609
|
+
expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('broadcasts when outputs are consumed but none admitted (coinsToRetain only)', async () => {
|
|
613
|
+
mockStorageEngine.findOutput = jest.fn(async () => mockOutput)
|
|
614
|
+
mockTopicManager.identifyAdmissibleOutputs = jest.fn(async () => ({
|
|
615
|
+
outputsToAdmit: [],
|
|
616
|
+
coinsToRetain: [0]
|
|
617
|
+
}))
|
|
618
|
+
const broadcaster = makeBroadcaster()
|
|
619
|
+
const engine = makeEngine(broadcaster)
|
|
620
|
+
await engine.submit({ beef: exampleBeef, topics: ['Hello'] })
|
|
621
|
+
expect(broadcaster.broadcast).toHaveBeenCalledTimes(1)
|
|
622
|
+
})
|
|
623
|
+
})
|
|
624
|
+
|
|
507
625
|
describe('For each topic being processed', () => {
|
|
508
626
|
it('Checks for duplicate transactions', async () => {
|
|
509
627
|
const engine = new Engine(
|
|
@@ -881,7 +999,7 @@ describe('BSV Overlay Services Engine', () => {
|
|
|
881
999
|
await expect(engine.lookup({
|
|
882
1000
|
service: 'HelloWorld',
|
|
883
1001
|
query: { name: 'Bob' }
|
|
884
|
-
})).rejects.toThrow()
|
|
1002
|
+
})).rejects.toThrow('Lookup service not found for provider: HelloWorld')
|
|
885
1003
|
})
|
|
886
1004
|
it('Calls the lookup function from the lookup service', async () => {
|
|
887
1005
|
// TODO: Make the default storage engine return something...?
|
|
@@ -125,6 +125,40 @@ describe('OverlayGASPStorage', () => {
|
|
|
125
125
|
requestedInputs: { 'neededTxid.1': { metadata: false } }
|
|
126
126
|
})
|
|
127
127
|
})
|
|
128
|
+
|
|
129
|
+
it.each([
|
|
130
|
+
[new Error('manager failed'), 'manager failed'],
|
|
131
|
+
['manager failed as a string', 'manager failed as a string']
|
|
132
|
+
])('terminates the graph and logs normalized identifyNeededInputs errors', async (failure, message) => {
|
|
133
|
+
const mockTx: GASPNode = {
|
|
134
|
+
rawTx: '001122',
|
|
135
|
+
proof: 'someproof',
|
|
136
|
+
graphID: 'txid123.0',
|
|
137
|
+
outputIndex: 0
|
|
138
|
+
}
|
|
139
|
+
const parsedTx = {
|
|
140
|
+
inputs: [],
|
|
141
|
+
toBEEF: jest.fn().mockReturnValue([1, 2, 3]),
|
|
142
|
+
id: jest.fn().mockReturnValue('txid123'),
|
|
143
|
+
merklePath: {}
|
|
144
|
+
}
|
|
145
|
+
Transaction.fromHex = jest.fn().mockReturnValue(parsedTx)
|
|
146
|
+
MerklePath.fromHex = jest.fn().mockReturnValue(parsedTx.merklePath)
|
|
147
|
+
mockEngine.managers['test-topic'] = {
|
|
148
|
+
identifyAdmissibleOutputs: jest.fn().mockResolvedValue({
|
|
149
|
+
outputsToAdmit: []
|
|
150
|
+
}),
|
|
151
|
+
identifyNeededInputs: jest.fn().mockRejectedValue(failure)
|
|
152
|
+
}
|
|
153
|
+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
154
|
+
|
|
155
|
+
await expect(overlayStorage.findNeededInputs(mockTx)).resolves.toBeUndefined()
|
|
156
|
+
|
|
157
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
158
|
+
`An error occurred when identifying needed inputs for transaction: txid123.0: ${message}`
|
|
159
|
+
)
|
|
160
|
+
consoleError.mockRestore()
|
|
161
|
+
})
|
|
128
162
|
})
|
|
129
163
|
|
|
130
164
|
describe('discardGraph', () => {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Knex } from 'knex'
|
|
2
2
|
|
|
3
3
|
export async function up (knex: Knex): Promise<void> {
|
|
4
|
+
const client = (knex.client.config.client || '').toLowerCase()
|
|
5
|
+
|
|
4
6
|
// Create the new transactions table
|
|
5
7
|
await knex.schema.createTable('transactions', table => {
|
|
6
8
|
table.increments()
|
|
@@ -9,12 +11,23 @@ export async function up (knex: Knex): Promise<void> {
|
|
|
9
11
|
})
|
|
10
12
|
|
|
11
13
|
// Move data from outputs table to the new transactions table and ensure deduplication
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
if (client.startsWith('mysql')) {
|
|
15
|
+
await knex.raw(`
|
|
16
|
+
INSERT IGNORE INTO transactions (txid, beef)
|
|
17
|
+
SELECT txid, beef
|
|
18
|
+
FROM outputs
|
|
19
|
+
WHERE beef IS NOT NULL
|
|
20
|
+
`)
|
|
21
|
+
} else {
|
|
22
|
+
// SQLite / PostgreSQL: INSERT IGNORE is MySQL-only syntax
|
|
23
|
+
await knex.raw(`
|
|
24
|
+
INSERT INTO transactions (txid, beef)
|
|
25
|
+
SELECT txid, beef
|
|
26
|
+
FROM outputs
|
|
27
|
+
WHERE beef IS NOT NULL
|
|
28
|
+
ON CONFLICT (txid) DO NOTHING
|
|
29
|
+
`)
|
|
30
|
+
}
|
|
18
31
|
|
|
19
32
|
// Drop the beef column from the outputs table
|
|
20
33
|
await knex.schema.table('outputs', table => {
|
|
@@ -23,17 +36,28 @@ export async function up (knex: Knex): Promise<void> {
|
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
export async function down (knex: Knex): Promise<void> {
|
|
39
|
+
const client = (knex.client.config.client || '').toLowerCase()
|
|
40
|
+
|
|
26
41
|
// Add the beef column back to the outputs table
|
|
27
42
|
await knex.schema.table('outputs', table => {
|
|
28
43
|
table.binary('beef')
|
|
29
44
|
})
|
|
30
45
|
|
|
31
|
-
// Move data back from the transactions table to the outputs table
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
// Move data back from the transactions table to the outputs table
|
|
47
|
+
if (client.startsWith('mysql')) {
|
|
48
|
+
await knex.raw(`
|
|
49
|
+
UPDATE outputs
|
|
50
|
+
JOIN transactions ON outputs.txid = transactions.txid
|
|
51
|
+
SET outputs.beef = transactions.beef
|
|
52
|
+
`)
|
|
53
|
+
} else {
|
|
54
|
+
// SQLite / PostgreSQL: UPDATE ... JOIN is MySQL-only syntax
|
|
55
|
+
await knex.raw(`
|
|
56
|
+
UPDATE outputs
|
|
57
|
+
SET beef = (SELECT beef FROM transactions WHERE transactions.txid = outputs.txid)
|
|
58
|
+
WHERE txid IN (SELECT txid FROM transactions)
|
|
59
|
+
`)
|
|
60
|
+
}
|
|
37
61
|
|
|
38
62
|
// Drop the transactions table
|
|
39
63
|
await knex.schema.dropTable('transactions')
|