@bsv/overlay 0.5.2 → 0.5.4
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 +2 -2
- package/dist/cjs/src/Engine.js +142 -113
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/storage/knex/all-migrations.js +3 -1
- package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js +20 -0
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js.map +1 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/Engine.js +140 -110
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/src/storage/knex/all-migrations.js +3 -1
- package/dist/esm/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/esm/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js +16 -0
- package/dist/esm/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js.map +1 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
- package/dist/types/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts +9 -0
- package/dist/types/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts.map +1 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/docs/API.md +23 -0
- package/package.json +2 -2
- package/src/Engine.ts +163 -112
- package/src/storage/knex/all-migrations.ts +3 -3
- package/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.ts +18 -0
package/src/Engine.ts
CHANGED
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
HTTPSOverlayBroadcastFacilitator,
|
|
17
17
|
LookupResolver,
|
|
18
18
|
LookupResolverConfig,
|
|
19
|
-
OverlayBroadcastFacilitator
|
|
19
|
+
OverlayBroadcastFacilitator,
|
|
20
|
+
BroadcastResponse,
|
|
21
|
+
BroadcastFailure
|
|
20
22
|
} from '@bsv/sdk'
|
|
21
23
|
import { AdvertisementData, Advertiser } from './Advertiser.js'
|
|
22
24
|
import { GASP, GASPInitialRequest, GASPInitialResponse, GASPNode } from '@bsv/gasp'
|
|
@@ -153,8 +155,18 @@ export class Engine {
|
|
|
153
155
|
const dupeTopics = new Set<string>()
|
|
154
156
|
const failedTopics = new Set<string>()
|
|
155
157
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
+
// ===================================================================
|
|
159
|
+
// PHASE 1: VALIDATE (read-only, no mutations)
|
|
160
|
+
// ===================================================================
|
|
161
|
+
type TopicValidation = {
|
|
162
|
+
topic: string
|
|
163
|
+
isDupe: boolean
|
|
164
|
+
previousCoins: number[]
|
|
165
|
+
previousOutputs: Array<Output | null>
|
|
166
|
+
admissibleOutputs: AdmittanceInstructions
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const topicValidations = taggedBEEF.topics.map(async (topic): Promise<TopicValidation> => {
|
|
158
170
|
try {
|
|
159
171
|
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
160
172
|
throw new Error(`This server does not support this topic: ${topic}`)
|
|
@@ -167,8 +179,13 @@ export class Engine {
|
|
|
167
179
|
|
|
168
180
|
if (dupeCheck) {
|
|
169
181
|
dupeTopics.add(topic)
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
return {
|
|
183
|
+
topic,
|
|
184
|
+
isDupe: true,
|
|
185
|
+
previousCoins: [],
|
|
186
|
+
previousOutputs: [],
|
|
187
|
+
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
188
|
+
}
|
|
172
189
|
}
|
|
173
190
|
|
|
174
191
|
// Identify previous coins admitted to this specific topic
|
|
@@ -187,108 +204,69 @@ export class Engine {
|
|
|
187
204
|
})
|
|
188
205
|
|
|
189
206
|
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`)
|
|
190
|
-
const
|
|
207
|
+
const previousOutputs = await Promise.all(outputPromises)
|
|
191
208
|
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`)
|
|
192
209
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
await l.outputSpent({
|
|
202
|
-
mode: 'txid',
|
|
203
|
-
spendingTxid: txid,
|
|
204
|
-
txid: output.txid,
|
|
205
|
-
outputIndex: output.outputIndex,
|
|
206
|
-
topic
|
|
207
|
-
})
|
|
208
|
-
} else if (l.spendNotificationMode === 'script') {
|
|
209
|
-
const inputIndex = tx.inputs.findIndex(i => {
|
|
210
|
-
let realSource = i.sourceTXID
|
|
211
|
-
if (!realSource) {
|
|
212
|
-
realSource = i.sourceTransaction?.id('hex')
|
|
213
|
-
}
|
|
214
|
-
return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
|
|
215
|
-
})
|
|
216
|
-
if (inputIndex === -1) {
|
|
217
|
-
throw new Error('Could not find input index')
|
|
218
|
-
}
|
|
219
|
-
await l.outputSpent({
|
|
220
|
-
mode: 'script',
|
|
221
|
-
spendingTxid: txid,
|
|
222
|
-
inputIndex,
|
|
223
|
-
sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
|
|
224
|
-
unlockingScript: tx.inputs[inputIndex].unlockingScript!,
|
|
225
|
-
txid: output.txid,
|
|
226
|
-
outputIndex: output.outputIndex,
|
|
227
|
-
topic,
|
|
228
|
-
offChainValues
|
|
229
|
-
})
|
|
230
|
-
} else if (l.spendNotificationMode === 'whole-tx') {
|
|
231
|
-
await l.outputSpent({
|
|
232
|
-
mode: 'whole-tx',
|
|
233
|
-
spendingAtomicBEEF: tx.toAtomicBEEF(),
|
|
234
|
-
txid: output.txid,
|
|
235
|
-
outputIndex: output.outputIndex,
|
|
236
|
-
topic,
|
|
237
|
-
offChainValues
|
|
238
|
-
})
|
|
239
|
-
} else { // none
|
|
240
|
-
await l.outputSpent({
|
|
241
|
-
mode: 'none',
|
|
242
|
-
txid: output.txid,
|
|
243
|
-
outputIndex: output.outputIndex,
|
|
244
|
-
topic
|
|
245
|
-
})
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
} catch (error) {
|
|
249
|
-
this.logger.error('Error in lookup service for outputSpent:', error)
|
|
250
|
-
}
|
|
251
|
-
}))
|
|
252
|
-
} catch (error) {
|
|
253
|
-
this.logger.error('Error marking UTXO as spent:', error)
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
let admissibleOutputs: AdmittanceInstructions = { outputsToAdmit: [], coinsToRetain: [] }
|
|
259
|
-
// Determine which outputs are admissible for this topic
|
|
260
|
-
const admissibleOutputsPromise = (async () => {
|
|
261
|
-
try {
|
|
262
|
-
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
263
|
-
admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(taggedBEEF.beef, previousCoins, offChainValues)
|
|
264
|
-
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
265
|
-
} catch (_) {
|
|
266
|
-
failedTopics.add(topic)
|
|
267
|
-
steak[topic] = { outputsToAdmit: [], coinsToRetain: [] }
|
|
268
|
-
}
|
|
269
|
-
})()
|
|
210
|
+
// Determine which outputs are admissible for this topic (validation only)
|
|
211
|
+
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
212
|
+
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(
|
|
213
|
+
taggedBEEF.beef,
|
|
214
|
+
previousCoins,
|
|
215
|
+
offChainValues
|
|
216
|
+
)
|
|
217
|
+
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
270
218
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
219
|
+
return {
|
|
220
|
+
topic,
|
|
221
|
+
isDupe: false,
|
|
222
|
+
previousCoins,
|
|
223
|
+
previousOutputs,
|
|
224
|
+
admissibleOutputs
|
|
225
|
+
}
|
|
275
226
|
} catch (error) {
|
|
276
|
-
this.logger.error('Error
|
|
227
|
+
this.logger.error('Error validating topic during submit:', error)
|
|
277
228
|
failedTopics.add(topic)
|
|
278
|
-
|
|
229
|
+
return {
|
|
230
|
+
topic,
|
|
231
|
+
isDupe: false,
|
|
232
|
+
previousCoins: [],
|
|
233
|
+
previousOutputs: [],
|
|
234
|
+
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
235
|
+
}
|
|
279
236
|
}
|
|
280
237
|
})
|
|
281
238
|
|
|
282
|
-
await Promise.all(
|
|
239
|
+
const validations = await Promise.all(topicValidations)
|
|
240
|
+
|
|
241
|
+
// Build preliminary STEAK from validation results
|
|
242
|
+
for (const validation of validations) {
|
|
243
|
+
steak[validation.topic] = validation.admissibleOutputs
|
|
244
|
+
}
|
|
283
245
|
|
|
284
|
-
//
|
|
246
|
+
// ===================================================================
|
|
247
|
+
// PHASE 2: BROADCAST (before any mutations)
|
|
248
|
+
// ===================================================================
|
|
285
249
|
this.startTime(`broadcast_${txid.substring(0, 10)}`)
|
|
286
250
|
if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
|
|
287
251
|
try {
|
|
288
|
-
|
|
252
|
+
let response: BroadcastResponse | BroadcastFailure
|
|
253
|
+
if (tx.merklePath !== undefined) {
|
|
254
|
+
// tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
|
|
255
|
+
const txid = tx.id('hex')
|
|
256
|
+
const mp = tx.merklePath
|
|
257
|
+
const leaf = mp.path[0].find(leaf => leaf.hash === txid)
|
|
258
|
+
const r: BroadcastResponse = {
|
|
259
|
+
status: 'success',
|
|
260
|
+
txid: tx.id('hex'),
|
|
261
|
+
message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`,
|
|
262
|
+
}
|
|
263
|
+
response = r
|
|
264
|
+
} else {
|
|
265
|
+
response = await this.broadcaster.broadcast(tx)
|
|
266
|
+
}
|
|
289
267
|
if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
|
|
290
268
|
const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`)
|
|
291
|
-
|
|
269
|
+
; (e as any).more = response.more
|
|
292
270
|
throw e
|
|
293
271
|
}
|
|
294
272
|
} catch (error) {
|
|
@@ -300,13 +278,95 @@ export class Engine {
|
|
|
300
278
|
}
|
|
301
279
|
this.endTime(`broadcast_${txid.substring(0, 10)}`)
|
|
302
280
|
|
|
303
|
-
// Call the callback function if it is provided (
|
|
281
|
+
// Call the callback function with STEAK if it is provided (before storage mutations)
|
|
304
282
|
if (onSteakReady !== undefined) {
|
|
305
283
|
onSteakReady(steak)
|
|
306
284
|
}
|
|
307
285
|
|
|
308
|
-
//
|
|
309
|
-
|
|
286
|
+
// ===================================================================
|
|
287
|
+
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
|
|
288
|
+
// ===================================================================
|
|
289
|
+
// Mark previous outputs as spent and notify lookup services
|
|
290
|
+
await Promise.all(validations.map(async (validation) => {
|
|
291
|
+
if (validation.isDupe || failedTopics.has(validation.topic)) {
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const topic = validation.topic
|
|
296
|
+
const previousOutputs = validation.previousOutputs
|
|
297
|
+
|
|
298
|
+
// Mark all previous outputs as spent
|
|
299
|
+
const markSpentPromises = previousOutputs.map(async (output) => {
|
|
300
|
+
if (output !== undefined && output !== null) {
|
|
301
|
+
try {
|
|
302
|
+
await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic)
|
|
303
|
+
await Promise.all(Object.values(this.lookupServices).map(async l => {
|
|
304
|
+
try {
|
|
305
|
+
if (typeof l.outputSpent === 'function') {
|
|
306
|
+
if (l.spendNotificationMode === 'txid') {
|
|
307
|
+
await l.outputSpent({
|
|
308
|
+
mode: 'txid',
|
|
309
|
+
spendingTxid: txid,
|
|
310
|
+
txid: output.txid,
|
|
311
|
+
outputIndex: output.outputIndex,
|
|
312
|
+
topic
|
|
313
|
+
})
|
|
314
|
+
} else if (l.spendNotificationMode === 'script') {
|
|
315
|
+
const inputIndex = tx.inputs.findIndex(i => {
|
|
316
|
+
let realSource = i.sourceTXID
|
|
317
|
+
if (!realSource) {
|
|
318
|
+
realSource = i.sourceTransaction?.id('hex')
|
|
319
|
+
}
|
|
320
|
+
return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
|
|
321
|
+
})
|
|
322
|
+
if (inputIndex === -1) {
|
|
323
|
+
throw new Error('Could not find input index')
|
|
324
|
+
}
|
|
325
|
+
await l.outputSpent({
|
|
326
|
+
mode: 'script',
|
|
327
|
+
spendingTxid: txid,
|
|
328
|
+
inputIndex,
|
|
329
|
+
sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
|
|
330
|
+
unlockingScript: tx.inputs[inputIndex].unlockingScript!,
|
|
331
|
+
txid: output.txid,
|
|
332
|
+
outputIndex: output.outputIndex,
|
|
333
|
+
topic,
|
|
334
|
+
offChainValues
|
|
335
|
+
})
|
|
336
|
+
} else if (l.spendNotificationMode === 'whole-tx') {
|
|
337
|
+
await l.outputSpent({
|
|
338
|
+
mode: 'whole-tx',
|
|
339
|
+
spendingAtomicBEEF: tx.toAtomicBEEF(),
|
|
340
|
+
txid: output.txid,
|
|
341
|
+
outputIndex: output.outputIndex,
|
|
342
|
+
topic,
|
|
343
|
+
offChainValues
|
|
344
|
+
})
|
|
345
|
+
} else { // none
|
|
346
|
+
await l.outputSpent({
|
|
347
|
+
mode: 'none',
|
|
348
|
+
txid: output.txid,
|
|
349
|
+
outputIndex: output.outputIndex,
|
|
350
|
+
topic
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
this.logger.error('Error in lookup service for outputSpent:', error)
|
|
356
|
+
}
|
|
357
|
+
}))
|
|
358
|
+
} catch (error) {
|
|
359
|
+
this.logger.error('Error marking UTXO as spent:', error)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
await Promise.all(markSpentPromises)
|
|
365
|
+
}))
|
|
366
|
+
|
|
367
|
+
// Continue with storage updates and lookup service notifications
|
|
368
|
+
for (const validation of validations) {
|
|
369
|
+
const topic = validation.topic
|
|
310
370
|
if (dupeTopics.has(topic)) {
|
|
311
371
|
continue
|
|
312
372
|
}
|
|
@@ -327,19 +387,10 @@ export class Engine {
|
|
|
327
387
|
inputIndex: number
|
|
328
388
|
}> = []
|
|
329
389
|
|
|
330
|
-
|
|
331
|
-
const previousCoins
|
|
332
|
-
await Promise.all(tx.inputs.map(async (input, i) => {
|
|
333
|
-
const previousTXID = input.sourceTXID !== undefined ? input.sourceTXID : input.sourceTransaction?.id('hex')
|
|
334
|
-
if (previousTXID !== undefined) {
|
|
335
|
-
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic)
|
|
336
|
-
if (output !== undefined && output !== null) {
|
|
337
|
-
previousCoins.push(i)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}))
|
|
390
|
+
// Use previousCoins from validation
|
|
391
|
+
const previousCoins = validation.previousCoins
|
|
341
392
|
|
|
342
|
-
|
|
393
|
+
// For each of the previous UTXOs for this topic, if the UTXO was not included in the list of UTXOs identified for retention, then it will be marked as stale.
|
|
343
394
|
for (const inputIndex of previousCoins) {
|
|
344
395
|
const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex')
|
|
345
396
|
if (typeof previousTXID !== 'string') continue
|
|
@@ -358,7 +409,7 @@ export class Engine {
|
|
|
358
409
|
}
|
|
359
410
|
}
|
|
360
411
|
|
|
361
|
-
|
|
412
|
+
// Remove stale outputs recursively
|
|
362
413
|
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
|
|
363
414
|
await Promise.all(outputsToMarkStale.map(async coin => {
|
|
364
415
|
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic)
|
|
@@ -368,10 +419,10 @@ export class Engine {
|
|
|
368
419
|
}))
|
|
369
420
|
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
|
|
370
421
|
|
|
371
|
-
|
|
422
|
+
// Update the STEAK to indicate which coins were removed
|
|
372
423
|
steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex)
|
|
373
424
|
|
|
374
|
-
|
|
425
|
+
// Handle admittance and notification of incoming UTXOs
|
|
375
426
|
const newUTXOs: Array<{ txid: string, outputIndex: number }> = []
|
|
376
427
|
await Promise.all(outputsToAdmit.map(async outputIndex => {
|
|
377
428
|
if (typeof tx.outputs[outputIndex].satoshis !== 'number') return
|
|
@@ -427,7 +478,7 @@ export class Engine {
|
|
|
427
478
|
}))
|
|
428
479
|
|
|
429
480
|
this.startTime(`outputConsumed_${txid.substring(0, 10)}`)
|
|
430
|
-
|
|
481
|
+
// Update each output consumed to know who consumed it and insert applied transaction in parallel
|
|
431
482
|
await Promise.all([
|
|
432
483
|
...outputsConsumed.map(async output => {
|
|
433
484
|
const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic)
|
|
@@ -6,8 +6,7 @@ import { up as addedIndexesUp, down as addedIndexesDown } from './migrations/202
|
|
|
6
6
|
import { up as enlargeUp, down as enlargeDown } from './migrations/2025-05-28-001-enlarge.js'
|
|
7
7
|
import { up as gaspPaginationSupportUp, down as gaspPaginationSupportDown } from './migrations/2025-06-25-001-gasp-pagination-support.js'
|
|
8
8
|
import { up as fixScoreColumnTypeUp, down as fixScoreColumnTypeDown } from './migrations/2025-07-22-001-fix-score-column-type.js'
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { up as utxoLookupIndexUp, down as utxoLookupIndexDown } from './migrations/2025-11-11-001-utxo-lookup-index.js'
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* An array of all migrations, in order.
|
|
@@ -24,7 +23,8 @@ const allMigrations: Migration[] = [
|
|
|
24
23
|
{ up: addedIndexesUp, down: addedIndexesDown },
|
|
25
24
|
{ up: enlargeUp, down: enlargeDown },
|
|
26
25
|
{ up: gaspPaginationSupportUp, down: gaspPaginationSupportDown },
|
|
27
|
-
{ up: fixScoreColumnTypeUp, down: fixScoreColumnTypeDown }
|
|
26
|
+
{ up: fixScoreColumnTypeUp, down: fixScoreColumnTypeDown },
|
|
27
|
+
{ up: utxoLookupIndexUp, down: utxoLookupIndexDown }
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
export default allMigrations
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Knex } from 'knex'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adds optimized index for findUTXOsForTopic queries.
|
|
5
|
+
* This query pattern is: WHERE topic = ? AND spent = false ORDER BY score
|
|
6
|
+
* The composite index (topic, spent, score) enables efficient range scans.
|
|
7
|
+
*/
|
|
8
|
+
export async function up (knex: Knex): Promise<void> {
|
|
9
|
+
await knex.schema.table('outputs', function (table) {
|
|
10
|
+
table.index(['topic', 'spent', 'score'], 'idx_outputs_topic_spent_score')
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function down (knex: Knex): Promise<void> {
|
|
15
|
+
await knex.schema.table('outputs', function (table) {
|
|
16
|
+
table.dropIndex(['topic', 'spent', 'score'], 'idx_outputs_topic_spent_score')
|
|
17
|
+
})
|
|
18
|
+
}
|