@bsv/overlay 0.5.2 → 0.5.3
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 +126 -112
- 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 +124 -109
- 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 +145 -110
- 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
|
@@ -153,8 +153,18 @@ export class Engine {
|
|
|
153
153
|
const dupeTopics = new Set<string>()
|
|
154
154
|
const failedTopics = new Set<string>()
|
|
155
155
|
|
|
156
|
-
//
|
|
157
|
-
|
|
156
|
+
// ===================================================================
|
|
157
|
+
// PHASE 1: VALIDATE (read-only, no mutations)
|
|
158
|
+
// ===================================================================
|
|
159
|
+
type TopicValidation = {
|
|
160
|
+
topic: string
|
|
161
|
+
isDupe: boolean
|
|
162
|
+
previousCoins: number[]
|
|
163
|
+
previousOutputs: Array<Output | null>
|
|
164
|
+
admissibleOutputs: AdmittanceInstructions
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const topicValidations = taggedBEEF.topics.map(async (topic): Promise<TopicValidation> => {
|
|
158
168
|
try {
|
|
159
169
|
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
160
170
|
throw new Error(`This server does not support this topic: ${topic}`)
|
|
@@ -167,8 +177,13 @@ export class Engine {
|
|
|
167
177
|
|
|
168
178
|
if (dupeCheck) {
|
|
169
179
|
dupeTopics.add(topic)
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
return {
|
|
181
|
+
topic,
|
|
182
|
+
isDupe: true,
|
|
183
|
+
previousCoins: [],
|
|
184
|
+
previousOutputs: [],
|
|
185
|
+
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
186
|
+
}
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
// Identify previous coins admitted to this specific topic
|
|
@@ -187,108 +202,55 @@ export class Engine {
|
|
|
187
202
|
})
|
|
188
203
|
|
|
189
204
|
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`)
|
|
190
|
-
const
|
|
205
|
+
const previousOutputs = await Promise.all(outputPromises)
|
|
191
206
|
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`)
|
|
192
207
|
|
|
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
|
-
})()
|
|
208
|
+
// Determine which outputs are admissible for this topic (validation only)
|
|
209
|
+
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
210
|
+
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(
|
|
211
|
+
taggedBEEF.beef,
|
|
212
|
+
previousCoins,
|
|
213
|
+
offChainValues
|
|
214
|
+
)
|
|
215
|
+
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
270
216
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
217
|
+
return {
|
|
218
|
+
topic,
|
|
219
|
+
isDupe: false,
|
|
220
|
+
previousCoins,
|
|
221
|
+
previousOutputs,
|
|
222
|
+
admissibleOutputs
|
|
223
|
+
}
|
|
275
224
|
} catch (error) {
|
|
276
|
-
this.logger.error('Error
|
|
225
|
+
this.logger.error('Error validating topic during submit:', error)
|
|
277
226
|
failedTopics.add(topic)
|
|
278
|
-
|
|
227
|
+
return {
|
|
228
|
+
topic,
|
|
229
|
+
isDupe: false,
|
|
230
|
+
previousCoins: [],
|
|
231
|
+
previousOutputs: [],
|
|
232
|
+
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
233
|
+
}
|
|
279
234
|
}
|
|
280
235
|
})
|
|
281
236
|
|
|
282
|
-
await Promise.all(
|
|
237
|
+
const validations = await Promise.all(topicValidations)
|
|
283
238
|
|
|
284
|
-
//
|
|
239
|
+
// Build preliminary STEAK from validation results
|
|
240
|
+
for (const validation of validations) {
|
|
241
|
+
steak[validation.topic] = validation.admissibleOutputs
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ===================================================================
|
|
245
|
+
// PHASE 2: BROADCAST (before any mutations)
|
|
246
|
+
// ===================================================================
|
|
285
247
|
this.startTime(`broadcast_${txid.substring(0, 10)}`)
|
|
286
248
|
if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
|
|
287
249
|
try {
|
|
288
250
|
const response = await this.broadcaster.broadcast(tx)
|
|
289
251
|
if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
|
|
290
252
|
const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`)
|
|
291
|
-
|
|
253
|
+
; (e as any).more = response.more
|
|
292
254
|
throw e
|
|
293
255
|
}
|
|
294
256
|
} catch (error) {
|
|
@@ -300,13 +262,95 @@ export class Engine {
|
|
|
300
262
|
}
|
|
301
263
|
this.endTime(`broadcast_${txid.substring(0, 10)}`)
|
|
302
264
|
|
|
303
|
-
// Call the callback function if it is provided (
|
|
265
|
+
// Call the callback function with STEAK if it is provided (before storage mutations)
|
|
304
266
|
if (onSteakReady !== undefined) {
|
|
305
267
|
onSteakReady(steak)
|
|
306
268
|
}
|
|
307
269
|
|
|
308
|
-
//
|
|
309
|
-
|
|
270
|
+
// ===================================================================
|
|
271
|
+
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
|
|
272
|
+
// ===================================================================
|
|
273
|
+
// Mark previous outputs as spent and notify lookup services
|
|
274
|
+
await Promise.all(validations.map(async (validation) => {
|
|
275
|
+
if (validation.isDupe || failedTopics.has(validation.topic)) {
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const topic = validation.topic
|
|
280
|
+
const previousOutputs = validation.previousOutputs
|
|
281
|
+
|
|
282
|
+
// Mark all previous outputs as spent
|
|
283
|
+
const markSpentPromises = previousOutputs.map(async (output) => {
|
|
284
|
+
if (output !== undefined && output !== null) {
|
|
285
|
+
try {
|
|
286
|
+
await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic)
|
|
287
|
+
await Promise.all(Object.values(this.lookupServices).map(async l => {
|
|
288
|
+
try {
|
|
289
|
+
if (typeof l.outputSpent === 'function') {
|
|
290
|
+
if (l.spendNotificationMode === 'txid') {
|
|
291
|
+
await l.outputSpent({
|
|
292
|
+
mode: 'txid',
|
|
293
|
+
spendingTxid: txid,
|
|
294
|
+
txid: output.txid,
|
|
295
|
+
outputIndex: output.outputIndex,
|
|
296
|
+
topic
|
|
297
|
+
})
|
|
298
|
+
} else if (l.spendNotificationMode === 'script') {
|
|
299
|
+
const inputIndex = tx.inputs.findIndex(i => {
|
|
300
|
+
let realSource = i.sourceTXID
|
|
301
|
+
if (!realSource) {
|
|
302
|
+
realSource = i.sourceTransaction?.id('hex')
|
|
303
|
+
}
|
|
304
|
+
return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
|
|
305
|
+
})
|
|
306
|
+
if (inputIndex === -1) {
|
|
307
|
+
throw new Error('Could not find input index')
|
|
308
|
+
}
|
|
309
|
+
await l.outputSpent({
|
|
310
|
+
mode: 'script',
|
|
311
|
+
spendingTxid: txid,
|
|
312
|
+
inputIndex,
|
|
313
|
+
sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
|
|
314
|
+
unlockingScript: tx.inputs[inputIndex].unlockingScript!,
|
|
315
|
+
txid: output.txid,
|
|
316
|
+
outputIndex: output.outputIndex,
|
|
317
|
+
topic,
|
|
318
|
+
offChainValues
|
|
319
|
+
})
|
|
320
|
+
} else if (l.spendNotificationMode === 'whole-tx') {
|
|
321
|
+
await l.outputSpent({
|
|
322
|
+
mode: 'whole-tx',
|
|
323
|
+
spendingAtomicBEEF: tx.toAtomicBEEF(),
|
|
324
|
+
txid: output.txid,
|
|
325
|
+
outputIndex: output.outputIndex,
|
|
326
|
+
topic,
|
|
327
|
+
offChainValues
|
|
328
|
+
})
|
|
329
|
+
} else { // none
|
|
330
|
+
await l.outputSpent({
|
|
331
|
+
mode: 'none',
|
|
332
|
+
txid: output.txid,
|
|
333
|
+
outputIndex: output.outputIndex,
|
|
334
|
+
topic
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
this.logger.error('Error in lookup service for outputSpent:', error)
|
|
340
|
+
}
|
|
341
|
+
}))
|
|
342
|
+
} catch (error) {
|
|
343
|
+
this.logger.error('Error marking UTXO as spent:', error)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
await Promise.all(markSpentPromises)
|
|
349
|
+
}))
|
|
350
|
+
|
|
351
|
+
// Continue with storage updates and lookup service notifications
|
|
352
|
+
for (const validation of validations) {
|
|
353
|
+
const topic = validation.topic
|
|
310
354
|
if (dupeTopics.has(topic)) {
|
|
311
355
|
continue
|
|
312
356
|
}
|
|
@@ -327,19 +371,10 @@ export class Engine {
|
|
|
327
371
|
inputIndex: number
|
|
328
372
|
}> = []
|
|
329
373
|
|
|
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
|
-
}))
|
|
374
|
+
// Use previousCoins from validation
|
|
375
|
+
const previousCoins = validation.previousCoins
|
|
341
376
|
|
|
342
|
-
|
|
377
|
+
// 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
378
|
for (const inputIndex of previousCoins) {
|
|
344
379
|
const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex')
|
|
345
380
|
if (typeof previousTXID !== 'string') continue
|
|
@@ -358,7 +393,7 @@ export class Engine {
|
|
|
358
393
|
}
|
|
359
394
|
}
|
|
360
395
|
|
|
361
|
-
|
|
396
|
+
// Remove stale outputs recursively
|
|
362
397
|
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
|
|
363
398
|
await Promise.all(outputsToMarkStale.map(async coin => {
|
|
364
399
|
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic)
|
|
@@ -368,10 +403,10 @@ export class Engine {
|
|
|
368
403
|
}))
|
|
369
404
|
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
|
|
370
405
|
|
|
371
|
-
|
|
406
|
+
// Update the STEAK to indicate which coins were removed
|
|
372
407
|
steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex)
|
|
373
408
|
|
|
374
|
-
|
|
409
|
+
// Handle admittance and notification of incoming UTXOs
|
|
375
410
|
const newUTXOs: Array<{ txid: string, outputIndex: number }> = []
|
|
376
411
|
await Promise.all(outputsToAdmit.map(async outputIndex => {
|
|
377
412
|
if (typeof tx.outputs[outputIndex].satoshis !== 'number') return
|
|
@@ -427,7 +462,7 @@ export class Engine {
|
|
|
427
462
|
}))
|
|
428
463
|
|
|
429
464
|
this.startTime(`outputConsumed_${txid.substring(0, 10)}`)
|
|
430
|
-
|
|
465
|
+
// Update each output consumed to know who consumed it and insert applied transaction in parallel
|
|
431
466
|
await Promise.all([
|
|
432
467
|
...outputsConsumed.map(async output => {
|
|
433
468
|
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
|
+
}
|