@bsv/overlay 2.2.1 → 2.2.7
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/LICENSE.txt +57 -27
- package/README.md +102 -269
- package/dist/cjs/mod.d.ts +15 -0
- package/dist/cjs/mod.d.ts.map +1 -0
- package/dist/cjs/mod.js +4 -1
- package/dist/cjs/mod.js.map +1 -1
- package/dist/cjs/package.json +41 -13
- package/dist/cjs/src/Advertisement.d.ts +9 -0
- package/dist/cjs/src/Advertisement.d.ts.map +1 -0
- package/dist/cjs/src/Advertiser.d.ts +36 -0
- package/dist/cjs/src/Advertiser.d.ts.map +1 -0
- package/dist/cjs/src/BASM.d.ts +110 -0
- package/dist/cjs/src/BASM.d.ts.map +1 -0
- package/dist/cjs/src/BASM.js.map +1 -1
- package/dist/cjs/src/BASMRemote.d.ts +14 -0
- package/dist/cjs/src/BASMRemote.d.ts.map +1 -0
- package/dist/cjs/src/BASMRemote.js.map +1 -1
- package/dist/cjs/src/Engine.d.ts +377 -0
- package/dist/cjs/src/Engine.d.ts.map +1 -0
- package/dist/cjs/src/Engine.js +416 -417
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts +24 -0
- package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts.map +1 -0
- package/dist/cjs/src/GASP/OverlayGASPRemote.js +7 -10
- package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts +103 -0
- package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts.map +1 -0
- package/dist/cjs/src/GASP/OverlayGASPStorage.js +32 -30
- package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/cjs/src/LookupFormula.d.ts +29 -0
- package/dist/cjs/src/LookupFormula.d.ts.map +1 -0
- package/dist/cjs/src/LookupService.d.ts +85 -0
- package/dist/cjs/src/LookupService.d.ts.map +1 -0
- package/dist/cjs/src/Output.d.ts +32 -0
- package/dist/cjs/src/Output.d.ts.map +1 -0
- package/dist/cjs/src/SafeLog.d.ts +14 -0
- package/dist/cjs/src/SafeLog.d.ts.map +1 -0
- package/dist/cjs/src/SafeLog.js +44 -0
- package/dist/cjs/src/SafeLog.js.map +1 -0
- package/{src/SyncConfiguration.ts → dist/cjs/src/SyncConfiguration.d.ts} +2 -1
- package/dist/cjs/src/SyncConfiguration.d.ts.map +1 -0
- package/dist/cjs/src/TopicManager.d.ts +35 -0
- package/dist/cjs/src/TopicManager.d.ts.map +1 -0
- package/dist/cjs/src/storage/Storage.d.ts +237 -0
- package/dist/cjs/src/storage/Storage.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/KnexStorage.d.ts +69 -0
- package/dist/cjs/src/storage/knex/KnexStorage.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/KnexStorage.js +7 -10
- package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/cjs/src/storage/knex/all-migrations.d.ts +11 -0
- package/dist/cjs/src/storage/knex/all-migrations.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-05-18-001-initial.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-10-001-block-height.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-17-001-transactions.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2024-07-18-001-indexes.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-05-28-001-enlarge.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts +9 -0
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2026-05-29-001-brc136-basm.js.map +1 -1
- package/dist/esm/mod.js +2 -1
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/src/BASMRemote.js.map +1 -1
- package/dist/esm/src/Engine.js +394 -395
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/src/GASP/OverlayGASPRemote.js +2 -2
- package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
- package/dist/esm/src/GASP/OverlayGASPStorage.js +21 -18
- package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/esm/src/SafeLog.js +40 -0
- package/dist/esm/src/SafeLog.js.map +1 -0
- package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/types/mod.d.ts +5 -4
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/src/BASMRemote.d.ts.map +1 -1
- package/dist/types/src/Engine.d.ts +15 -0
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts +2 -2
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts +3 -1
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
- package/dist/types/src/SafeLog.d.ts +14 -0
- package/dist/types/src/SafeLog.d.ts.map +1 -0
- package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
- package/package.json +85 -28
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +0 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +0 -1
- package/dist/types/tsconfig.types.tsbuildinfo +0 -1
- package/docs/API.md +0 -944
- package/docs/BRC-136-BASM.md +0 -327
- package/docs/README.md +0 -16
- package/docs/Synchronization.md +0 -212
- package/docs/concepts/00-overview.md +0 -85
- package/docs/concepts/01-best-practices.md +0 -202
- package/docs/concepts/02-query-performance.md +0 -345
- package/docs/concepts/03-database-monitoring.md +0 -211
- package/docs/concepts/04-pagination-example.md +0 -186
- package/docs/concepts/05-recommendations-summary.md +0 -158
- package/docs/concepts/README.md +0 -20
- package/docs/examples/README.md +0 -15
- package/docs/examples/gs-wip.md +0 -111
- package/docs/internal/README.md +0 -11
- package/mod.ts +0 -40
- package/src/Advertisement.ts +0 -8
- package/src/Advertiser.ts +0 -41
- package/src/BASM.ts +0 -208
- package/src/BASMRemote.ts +0 -54
- package/src/Engine.ts +0 -2194
- package/src/GASP/OverlayGASPRemote.ts +0 -108
- package/src/GASP/OverlayGASPStorage.ts +0 -384
- package/src/LookupFormula.ts +0 -31
- package/src/LookupService.ts +0 -136
- package/src/Output.ts +0 -31
- package/src/TopicManager.ts +0 -40
- package/src/__tests/BASM.test.ts +0 -44
- package/src/__tests/BASMChain.test.ts +0 -295
- package/src/__tests/Engine.test.ts +0 -1381
- package/src/__tests/OverlayGASPRemote.test.ts +0 -135
- package/src/__tests/OverlayGASPStorage.test.ts +0 -205
- package/src/storage/Storage.ts +0 -260
- package/src/storage/knex/KnexStorage.ts +0 -651
- package/src/storage/knex/all-migrations.ts +0 -32
- package/src/storage/knex/migrations/2024-05-18-001-initial.ts +0 -30
- package/src/storage/knex/migrations/2024-07-10-001-block-height.ts +0 -15
- package/src/storage/knex/migrations/2024-07-17-001-transactions.ts +0 -64
- package/src/storage/knex/migrations/2024-07-18-001-indexes.ts +0 -30
- package/src/storage/knex/migrations/2025-05-28-001-enlarge.ts +0 -42
- package/src/storage/knex/migrations/2025-06-25-001-gasp-pagination-support.ts +0 -46
- package/src/storage/knex/migrations/2025-07-22-001-fix-score-column-type.ts +0 -24
- package/src/storage/knex/migrations/2025-11-11-001-utxo-lookup-index.ts +0 -18
- package/src/storage/knex/migrations/2026-05-29-001-brc136-basm.ts +0 -123
package/src/Engine.ts
DELETED
|
@@ -1,2194 +0,0 @@
|
|
|
1
|
-
import { TopicManager } from './TopicManager.js'
|
|
2
|
-
import { LookupService } from './LookupService.js'
|
|
3
|
-
import { Storage } from './storage/Storage.js'
|
|
4
|
-
import type { Output } from './Output.js'
|
|
5
|
-
import {
|
|
6
|
-
Transaction,
|
|
7
|
-
ChainTracker,
|
|
8
|
-
MerklePath,
|
|
9
|
-
Broadcaster,
|
|
10
|
-
isBroadcastFailure,
|
|
11
|
-
TaggedBEEF, STEAK,
|
|
12
|
-
LookupQuestion,
|
|
13
|
-
LookupAnswer,
|
|
14
|
-
AdmittanceInstructions,
|
|
15
|
-
SHIPBroadcaster,
|
|
16
|
-
HTTPSOverlayBroadcastFacilitator,
|
|
17
|
-
LookupResolver,
|
|
18
|
-
LookupResolverConfig,
|
|
19
|
-
OverlayBroadcastFacilitator,
|
|
20
|
-
BroadcastResponse,
|
|
21
|
-
BroadcastFailure
|
|
22
|
-
} from '@bsv/sdk'
|
|
23
|
-
import { AdvertisementData, Advertiser } from './Advertiser.js'
|
|
24
|
-
import { GASP, GASPInitialRequest, GASPInitialResponse, GASPNode } from '@bsv/gasp'
|
|
25
|
-
import { SyncConfiguration } from './SyncConfiguration.js'
|
|
26
|
-
import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js'
|
|
27
|
-
import { OverlayGASPStorage } from './GASP/OverlayGASPStorage.js'
|
|
28
|
-
import {
|
|
29
|
-
BASM_ZERO_HASH,
|
|
30
|
-
type AdmittedListResponse,
|
|
31
|
-
type BASMPeerSyncReport,
|
|
32
|
-
type CompoundMerklePathResponse,
|
|
33
|
-
type RawTransactionResponse,
|
|
34
|
-
type ReorgReport,
|
|
35
|
-
type TopicAnchorHeaderResolver,
|
|
36
|
-
type TopicAnchorRangeResponse,
|
|
37
|
-
type TopicAnchorTip,
|
|
38
|
-
type TopicBlockAnchor,
|
|
39
|
-
computeBasmRoot,
|
|
40
|
-
computeTac,
|
|
41
|
-
extractMerkleProofMetadata
|
|
42
|
-
} from './BASM.js'
|
|
43
|
-
import { BASMRemote } from './BASMRemote.js'
|
|
44
|
-
|
|
45
|
-
const DEFAULT_GASP_SYNC_LIMIT = 10000
|
|
46
|
-
const DEFAULT_BASM_RANGE_LIMIT = 1024
|
|
47
|
-
|
|
48
|
-
type UTXOHistoryHydrationContext = {
|
|
49
|
-
outputCache: Map<string, Promise<Output | null>>
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
type HydratedUTXOHistoryNode = {
|
|
53
|
-
output: Output
|
|
54
|
-
transaction: Transaction
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* An engine for running BSV Overlay Services (topic managers and lookup services).
|
|
59
|
-
*/
|
|
60
|
-
export class Engine {
|
|
61
|
-
/**
|
|
62
|
-
* Creates a new Overlay Services Engine
|
|
63
|
-
* @param {[key: string]: TopicManager} managers - manages topic admittance
|
|
64
|
-
* @param {[key: string]: LookupService} lookupServices - manages UTXO lookups
|
|
65
|
-
* @param {Storage} storage - for interacting with internally-managed persistent data
|
|
66
|
-
* @param {ChainTracker | 'scripts only'} chainTracker - Verifies SPV data associated with transactions
|
|
67
|
-
* @param {string} [hostingURL] - The URL this engine is hosted at. Required if going to support peer-discovery with an advertiser.
|
|
68
|
-
* @param {Broadcaster} [Broadcaster] - broadcaster used for broadcasting the incoming transaction
|
|
69
|
-
* @param {Advertiser} [Advertiser] - handles SHIP and SLAP advertisements for peer-discovery
|
|
70
|
-
* @param {string[]} shipTrackers - SHIP domains we know to bootstrap the system
|
|
71
|
-
* @param {string[]} slapTrackers - SLAP domains we know to bootstrap the system
|
|
72
|
-
* @param {SyncConfiguration} syncConfiguration — Configuration object describing historical synchronization of topics.
|
|
73
|
-
* @param {boolean} logTime - Enables / disables the timing logs for various operations in the Overlay submit route.
|
|
74
|
-
* @param {string} logPrefix - Supports overriding the log prefix with a custom string.
|
|
75
|
-
* @param {boolean} throwOnBroadcastFailure - Enables / disables throwing an error when a transaction broadcast failure is detected.
|
|
76
|
-
* @param {OverlayBroadcastFacilitator} overlayBroadcastFacilitator - Facilitator for propagation to other Overlay Services.
|
|
77
|
-
* @param {typeof console} logger - The place where log entries are written.
|
|
78
|
-
* @param {boolean} suppressDefaultSyncAdvertisements - Whether to suppress the default (SHIP/SLAP) sync advertisements.
|
|
79
|
-
* @param {TopicAnchorHeaderResolver} topicAnchorHeaderResolver - Resolves block hashes for BASM anchors.
|
|
80
|
-
* @param {boolean} basmSyncEnabled - Whether BASM sync should run automatically.
|
|
81
|
-
* @param {number} unprovenEvictionBlocks - Default block age for opt-in unproven state eviction.
|
|
82
|
-
*/
|
|
83
|
-
constructor(
|
|
84
|
-
public managers: { [key: string]: TopicManager },
|
|
85
|
-
public lookupServices: { [key: string]: LookupService },
|
|
86
|
-
public storage: Storage,
|
|
87
|
-
public chainTracker: ChainTracker | 'scripts only',
|
|
88
|
-
public hostingURL?: string,
|
|
89
|
-
public shipTrackers?: string[],
|
|
90
|
-
public slapTrackers?: string[],
|
|
91
|
-
public broadcaster?: Broadcaster,
|
|
92
|
-
public advertiser?: Advertiser,
|
|
93
|
-
public syncConfiguration?: SyncConfiguration,
|
|
94
|
-
public logTime = false,
|
|
95
|
-
public logPrefix = '[OVERLAY_ENGINE] ',
|
|
96
|
-
public throwOnBroadcastFailure = false,
|
|
97
|
-
public overlayBroadcastFacilitator: OverlayBroadcastFacilitator = new HTTPSOverlayBroadcastFacilitator(),
|
|
98
|
-
public logger: typeof console = console,
|
|
99
|
-
public suppressDefaultSyncAdvertisements = true,
|
|
100
|
-
public topicAnchorHeaderResolver?: TopicAnchorHeaderResolver,
|
|
101
|
-
public basmSyncEnabled = false,
|
|
102
|
-
public unprovenEvictionBlocks = 144
|
|
103
|
-
) {
|
|
104
|
-
// To encourage synchronization of overlay services, the SHIP sync strategy is used by default for all overlay topics, except for 'tm_ship' and 'tm_slap'.
|
|
105
|
-
// For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
|
|
106
|
-
if (syncConfiguration === undefined) {
|
|
107
|
-
this.syncConfiguration = {}
|
|
108
|
-
} else {
|
|
109
|
-
this.syncConfiguration = syncConfiguration
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
for (const managerName of Object.keys(managers)) {
|
|
113
|
-
if (managerName === 'tm_ship' && this.shipTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
|
|
114
|
-
// Combine tm_ship trackers with preexisting entries if any
|
|
115
|
-
const combinedSet = new Set([
|
|
116
|
-
...(Array.isArray(this.syncConfiguration[managerName]) ? this.syncConfiguration[managerName] : []),
|
|
117
|
-
...this.shipTrackers
|
|
118
|
-
])
|
|
119
|
-
this.syncConfiguration[managerName] = Array.from(combinedSet)
|
|
120
|
-
} else if (managerName === 'tm_slap' && this.slapTrackers !== undefined && this.syncConfiguration[managerName] !== false) {
|
|
121
|
-
// Combine tm_slap trackers with preexisting entries if any
|
|
122
|
-
const combinedSet = new Set([
|
|
123
|
-
...(Array.isArray(this.syncConfiguration[managerName]) ? this.syncConfiguration[managerName] : []),
|
|
124
|
-
...this.slapTrackers
|
|
125
|
-
])
|
|
126
|
-
this.syncConfiguration[managerName] = Array.from(combinedSet)
|
|
127
|
-
} else {
|
|
128
|
-
// Set undefined managers to 'SHIP' by default
|
|
129
|
-
this.syncConfiguration[managerName] ??= 'SHIP'
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Helper functions for logging timings
|
|
135
|
-
private startTime(label: string): void {
|
|
136
|
-
if (this.logTime) {
|
|
137
|
-
this.logger.time(`${this.logPrefix} ${label}`)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private endTime(label: string): void {
|
|
142
|
-
if (this.logTime) {
|
|
143
|
-
this.logger.timeEnd(`${this.logPrefix} ${label}`)
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private async currentHeightOrUndefined(): Promise<number | undefined> {
|
|
148
|
-
if (this.chainTracker === 'scripts only') {
|
|
149
|
-
return undefined
|
|
150
|
-
}
|
|
151
|
-
try {
|
|
152
|
-
return await this.chainTracker.currentHeight()
|
|
153
|
-
} catch (error) {
|
|
154
|
-
this.logger.warn(`Unable to resolve current chain height for overlay metadata: ${error instanceof Error ? error.message : String(error)}`)
|
|
155
|
-
return undefined
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private async resolveBlockHash(blockHeight: number, merkleRoot?: string): Promise<string | undefined> {
|
|
160
|
-
try {
|
|
161
|
-
const header = await this.topicAnchorHeaderResolver?.(blockHeight)
|
|
162
|
-
if (header === undefined) {
|
|
163
|
-
return undefined
|
|
164
|
-
}
|
|
165
|
-
if (header.merkleRoot !== undefined && merkleRoot !== undefined && header.merkleRoot !== merkleRoot) {
|
|
166
|
-
throw new Error(`Header merkle root ${header.merkleRoot} does not match proof root ${merkleRoot} at height ${blockHeight}`)
|
|
167
|
-
}
|
|
168
|
-
return header.blockHash
|
|
169
|
-
} catch (error) {
|
|
170
|
-
this.logger.warn(`Unable to resolve BASM block hash for height ${blockHeight}: ${error instanceof Error ? error.message : String(error)}`)
|
|
171
|
-
return undefined
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
private compactBEEFForStorage(tx: Transaction, originalBEEF: number[]): number[] {
|
|
176
|
-
return tx.merklePath === undefined ? originalBEEF : tx.toAtomicBEEF()
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private async recordTransactionData(tx: Transaction, beef: number[], blockHash?: string): Promise<void> {
|
|
180
|
-
if (typeof this.storage.upsertTransactionRecord !== 'function') {
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const txid = tx.id('hex')
|
|
185
|
-
const metadata = extractMerkleProofMetadata(txid, tx.merklePath)
|
|
186
|
-
await this.storage.upsertTransactionRecord({
|
|
187
|
-
txid,
|
|
188
|
-
beef: this.compactBEEFForStorage(tx, beef),
|
|
189
|
-
rawTx: Array.from(tx.toBinary()),
|
|
190
|
-
merklePath: tx.merklePath?.toBinary(),
|
|
191
|
-
blockHeight: metadata?.blockHeight,
|
|
192
|
-
blockHash,
|
|
193
|
-
blockIndex: metadata?.blockIndex,
|
|
194
|
-
merkleRoot: metadata?.merkleRoot
|
|
195
|
-
})
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
private async buildAppliedTransactionRecord(tx: Transaction): Promise<{
|
|
199
|
-
blockHeight?: number
|
|
200
|
-
blockHash?: string
|
|
201
|
-
blockIndex?: number
|
|
202
|
-
merkleRoot?: string
|
|
203
|
-
firstSeenHeight?: number
|
|
204
|
-
proven: boolean
|
|
205
|
-
}> {
|
|
206
|
-
const txid = tx.id('hex')
|
|
207
|
-
const metadata = extractMerkleProofMetadata(txid, tx.merklePath)
|
|
208
|
-
const [firstSeenHeight, blockHash] = await Promise.all([
|
|
209
|
-
this.currentHeightOrUndefined(),
|
|
210
|
-
metadata === undefined ? undefined : this.resolveBlockHash(metadata.blockHeight, metadata.merkleRoot)
|
|
211
|
-
])
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
blockHeight: metadata?.blockHeight,
|
|
215
|
-
blockHash,
|
|
216
|
-
blockIndex: metadata?.blockIndex,
|
|
217
|
-
merkleRoot: metadata?.merkleRoot,
|
|
218
|
-
firstSeenHeight: firstSeenHeight ?? metadata?.blockHeight,
|
|
219
|
-
proven: metadata !== undefined
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private async recomputeTopicBlockAnchor(topic: string, blockHeight: number, blockHash?: string): Promise<TopicBlockAnchor | undefined> {
|
|
224
|
-
if (
|
|
225
|
-
typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
|
|
226
|
-
typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
|
|
227
|
-
typeof this.storage.findTopicBlockAnchor !== 'function'
|
|
228
|
-
) {
|
|
229
|
-
return undefined
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const anchorBlockHash = blockHash ?? (await this.storage.findTopicBlockAnchor(topic, blockHeight))?.blockHash
|
|
233
|
-
if (anchorBlockHash === undefined) {
|
|
234
|
-
return undefined
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// BRC-136 per-block completeness: establish the chain's genesis at the first
|
|
238
|
-
// admitted height, then keep every height from there to the tip contiguous so
|
|
239
|
-
// the cumulative TAC never resets across blocks with no admitted transactions.
|
|
240
|
-
// We rebuild [fromHeight, toHeight] rather than only the touched height so that
|
|
241
|
-
// an out-of-order proof (older height arriving after a newer one) can never
|
|
242
|
-
// leave a gap that silently breaks the chain.
|
|
243
|
-
const tip = await this.storage.findTopicAnchorTip?.(topic)
|
|
244
|
-
const tipHeight = tip !== undefined && tip.blockHeight >= 0 ? tip.blockHeight : undefined
|
|
245
|
-
const fromHeight = tipHeight === undefined ? blockHeight : Math.min(blockHeight, tipHeight + 1)
|
|
246
|
-
const toHeight = tipHeight === undefined ? blockHeight : Math.max(blockHeight, tipHeight)
|
|
247
|
-
|
|
248
|
-
await this.rebuildTopicAnchorChain(topic, fromHeight, toHeight, new Map([[blockHeight, anchorBlockHash]]))
|
|
249
|
-
return await this.storage.findTopicBlockAnchor(topic, blockHeight)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Extends every configured topic's anchor chain forward with empty Topic Block
|
|
254
|
-
* Anchors (basmRoot = zero hash, admittedCount = 0) up to `toHeight`, so the
|
|
255
|
-
* cumulative TAC advances on every block even when a topic admits nothing —
|
|
256
|
-
* this is what lets a peer authoritatively confirm "this block contained no
|
|
257
|
-
* transactions for this topic". Chains with no first admission yet are left
|
|
258
|
-
* unstarted (genesis is the topic's first admitted height).
|
|
259
|
-
*/
|
|
260
|
-
async advanceTopicAnchorChains(toHeight?: number): Promise<void> {
|
|
261
|
-
if (
|
|
262
|
-
typeof this.storage.findTopicAnchorTip !== 'function' ||
|
|
263
|
-
typeof this.storage.upsertTopicBlockAnchor !== 'function'
|
|
264
|
-
) {
|
|
265
|
-
return
|
|
266
|
-
}
|
|
267
|
-
const targetHeight = toHeight ?? await this.currentHeightOrUndefined()
|
|
268
|
-
if (targetHeight === undefined) {
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
for (const topic of Object.keys(this.managers)) {
|
|
272
|
-
const tip = await this.storage.findTopicAnchorTip(topic)
|
|
273
|
-
if (tip === undefined || tip.blockHeight < 0 || tip.blockHeight >= targetHeight) {
|
|
274
|
-
continue
|
|
275
|
-
}
|
|
276
|
-
await this.rebuildTopicAnchorChain(topic, tip.blockHeight + 1, targetHeight)
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Rebuilds a contiguous slice of a topic's anchor chain over [fromHeight,
|
|
282
|
-
* toHeight]. Each height uses its admitted transactions (empty -> zero basmRoot)
|
|
283
|
-
* and chains the cumulative TAC from the prior height. Missing heights are
|
|
284
|
-
* filled rather than skipped, so the chain stays gap-free. If a block hash
|
|
285
|
-
* cannot be resolved for some height the extension halts there to preserve
|
|
286
|
-
* contiguity instead of leaving a hole.
|
|
287
|
-
*/
|
|
288
|
-
private async rebuildTopicAnchorChain(
|
|
289
|
-
topic: string,
|
|
290
|
-
fromHeight: number,
|
|
291
|
-
toHeight: number,
|
|
292
|
-
blockHashHints: Map<number, string> = new Map(),
|
|
293
|
-
forceResolve = false
|
|
294
|
-
): Promise<void> {
|
|
295
|
-
if (
|
|
296
|
-
typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
|
|
297
|
-
typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
|
|
298
|
-
typeof this.storage.findTopicBlockAnchor !== 'function' ||
|
|
299
|
-
toHeight < fromHeight
|
|
300
|
-
) {
|
|
301
|
-
return
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
|
|
305
|
-
// Bound the work per pass; the next trigger resumes from the new tip.
|
|
306
|
-
this.logger.warn(`[BASM] capping anchor chain extension for "${topic}" at ${DEFAULT_BASM_RANGE_LIMIT} blocks (requested ${fromHeight}..${toHeight}); will continue on the next pass`)
|
|
307
|
-
toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const previousAnchor = fromHeight > 0
|
|
311
|
-
? await this.storage.findTopicBlockAnchor(topic, fromHeight - 1)
|
|
312
|
-
: undefined
|
|
313
|
-
let prevTac = previousAnchor?.tac ?? BASM_ZERO_HASH
|
|
314
|
-
|
|
315
|
-
for (let height = fromHeight; height <= toHeight; height++) {
|
|
316
|
-
const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, height)
|
|
317
|
-
const existing = await this.storage.findTopicBlockAnchor(topic, height)
|
|
318
|
-
// On a reorg rebuild the existing anchor's block hash is stale, so force
|
|
319
|
-
// canonical re-resolution from the header resolver instead of reusing it.
|
|
320
|
-
const blockHash = blockHashHints.get(height) ?? (forceResolve ? undefined : existing?.blockHash) ?? await this.resolveBlockHash(height)
|
|
321
|
-
if (blockHash === undefined) {
|
|
322
|
-
this.logger.warn(`[BASM] unable to resolve block hash for "${topic}" at height ${height}; halting chain extension`)
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const basmRoot = computeBasmRoot(admitted)
|
|
327
|
-
const tac = computeTac(prevTac, blockHash, basmRoot)
|
|
328
|
-
await this.storage.upsertTopicBlockAnchor({
|
|
329
|
-
topic,
|
|
330
|
-
blockHeight: height,
|
|
331
|
-
blockHash,
|
|
332
|
-
basmRoot,
|
|
333
|
-
admittedCount: admitted.length,
|
|
334
|
-
tac
|
|
335
|
-
})
|
|
336
|
-
prevTac = tac
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Reconciles BASM anchors with a blockchain reorganization reported by the
|
|
342
|
-
* chain tracker (e.g. go-chaintracks `/v2/reorg/stream`). Proven topic
|
|
343
|
-
* transactions whose block was orphaned are demoted to unproven so they leave
|
|
344
|
-
* the admitted set, then every topic anchor chain intersecting the affected
|
|
345
|
-
* height range is rebuilt over the canonical block hashes. A reorg changes the
|
|
346
|
-
* canonical block hash for the affected heights, so topics with no demoted
|
|
347
|
-
* transaction are rebuilt too. Idempotent: a clean window demotes nothing and
|
|
348
|
-
* reproduces an identical TAC, so this is safe to invoke on every reorg event,
|
|
349
|
-
* SSE reconnect, and poll.
|
|
350
|
-
*/
|
|
351
|
-
async handleReorg(input: {
|
|
352
|
-
orphanedBlockHashes: string[]
|
|
353
|
-
rebuildFromHeight: number
|
|
354
|
-
newTipHeight: number
|
|
355
|
-
}): Promise<ReorgReport> {
|
|
356
|
-
const report: ReorgReport = { perTopic: [] }
|
|
357
|
-
if (
|
|
358
|
-
typeof this.storage.findProvenAppliedTransactionsByBlockHash !== 'function' ||
|
|
359
|
-
typeof this.storage.demoteAppliedTransactionToUnproven !== 'function' ||
|
|
360
|
-
typeof this.storage.findTopicBlockAnchors !== 'function' ||
|
|
361
|
-
typeof this.storage.upsertTopicBlockAnchor !== 'function'
|
|
362
|
-
) {
|
|
363
|
-
return report
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// 1) Demote proven admissions whose block was orphaned. Hashes are
|
|
367
|
-
// normalized to lower-case display hex to match stored block hashes
|
|
368
|
-
// (go-sdk chainhash.Hash marshals as reversed display hex).
|
|
369
|
-
const demotedByTopic = new Map<string, string[]>()
|
|
370
|
-
for (const rawHash of input.orphanedBlockHashes) {
|
|
371
|
-
const blockHash = rawHash.toLowerCase()
|
|
372
|
-
const rows = await this.storage.findProvenAppliedTransactionsByBlockHash(blockHash)
|
|
373
|
-
for (const row of rows) {
|
|
374
|
-
await this.storage.demoteAppliedTransactionToUnproven(row.txid, row.topic)
|
|
375
|
-
const list = demotedByTopic.get(row.topic) ?? []
|
|
376
|
-
list.push(row.txid)
|
|
377
|
-
demotedByTopic.set(row.topic, list)
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// 2) Rebuild every topic anchor chain that intersects the reorged range,
|
|
382
|
-
// forcing canonical block-hash re-resolution so stale hashes are replaced.
|
|
383
|
-
for (const topic of Object.keys(this.managers)) {
|
|
384
|
-
const existing = await this.storage.findTopicBlockAnchors(topic, input.rebuildFromHeight, input.newTipHeight)
|
|
385
|
-
if (existing.length === 0) {
|
|
386
|
-
continue
|
|
387
|
-
}
|
|
388
|
-
const startHeight = Math.min(...existing.map(anchor => anchor.blockHeight))
|
|
389
|
-
await this.rebuildTopicAnchorChain(topic, startHeight, input.newTipHeight, new Map(), true)
|
|
390
|
-
report.perTopic.push({
|
|
391
|
-
topic,
|
|
392
|
-
demotedTxids: demotedByTopic.get(topic) ?? [],
|
|
393
|
-
rebuiltFrom: startHeight,
|
|
394
|
-
rebuiltTo: input.newTipHeight
|
|
395
|
-
})
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
return report
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Revalidation sweep: the reorg fallback for chain trackers without a reorg
|
|
403
|
-
* event stream, and the catch-up step on every reorg-SSE (re)connect (the
|
|
404
|
-
* go-chaintracks reorg stream carries no event ids, so a reconnect cannot
|
|
405
|
-
* replay events missed while disconnected). Scans proven applied transactions
|
|
406
|
-
* in `[tip - depth + 1, tip]`; any whose proof root no longer validates against
|
|
407
|
-
* the chain tracker, or whose block hash diverges from the canonical header, is
|
|
408
|
-
* treated as orphaned and reconciled via {@link handleReorg}.
|
|
409
|
-
*/
|
|
410
|
-
private async isProvenAnchorStale(
|
|
411
|
-
row: { txid: string, blockHeight: number, blockHash?: string, merkleRoot?: string },
|
|
412
|
-
chainTracker: ChainTracker
|
|
413
|
-
): Promise<boolean | undefined> {
|
|
414
|
-
if (row.merkleRoot !== undefined) {
|
|
415
|
-
try {
|
|
416
|
-
if (!(await chainTracker.isValidRootForHeight(row.merkleRoot, row.blockHeight))) {
|
|
417
|
-
return true
|
|
418
|
-
}
|
|
419
|
-
} catch (error) {
|
|
420
|
-
this.logger.warn(`[BASM] root validation failed for ${row.txid} at height ${row.blockHeight}: ${error instanceof Error ? error.message : String(error)}`)
|
|
421
|
-
return undefined
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const canonical = await this.resolveBlockHash(row.blockHeight)
|
|
426
|
-
return canonical !== undefined && canonical.toLowerCase() !== row.blockHash?.toLowerCase()
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async revalidateRecentAnchors(depth = 3): Promise<ReorgReport | undefined> {
|
|
430
|
-
const chainTracker = this.chainTracker
|
|
431
|
-
if (chainTracker === 'scripts only') {
|
|
432
|
-
this.logger.warn('[BASM] revalidation sweep requires a ChainTracker; skipping')
|
|
433
|
-
return undefined
|
|
434
|
-
}
|
|
435
|
-
if (typeof this.storage.findProvenAppliedTransactionsInRange !== 'function') {
|
|
436
|
-
return undefined
|
|
437
|
-
}
|
|
438
|
-
const tip = await this.currentHeightOrUndefined()
|
|
439
|
-
if (tip === undefined) {
|
|
440
|
-
return undefined
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const fromHeight = Math.max(0, tip - depth + 1)
|
|
444
|
-
const rows = await this.storage.findProvenAppliedTransactionsInRange(fromHeight, tip)
|
|
445
|
-
const orphaned = new Set<string>()
|
|
446
|
-
let minAffected = Number.POSITIVE_INFINITY
|
|
447
|
-
|
|
448
|
-
for (const row of rows) {
|
|
449
|
-
if (row.blockHash === undefined) {
|
|
450
|
-
continue
|
|
451
|
-
}
|
|
452
|
-
const stale = await this.isProvenAnchorStale(row, chainTracker)
|
|
453
|
-
if (stale === true) {
|
|
454
|
-
orphaned.add(row.blockHash.toLowerCase())
|
|
455
|
-
minAffected = Math.min(minAffected, row.blockHeight)
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (orphaned.size === 0) {
|
|
460
|
-
return { perTopic: [] }
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
return await this.handleReorg({
|
|
464
|
-
orphanedBlockHashes: Array.from(orphaned),
|
|
465
|
-
rebuildFromHeight: minAffected,
|
|
466
|
-
newTipHeight: tip
|
|
467
|
-
})
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Submits a transaction for processing by Overlay Services.
|
|
472
|
-
* @param {TaggedBEEF} taggedBEEF - The transaction to process
|
|
473
|
-
* @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
|
|
474
|
-
* @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
|
|
475
|
-
* @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
|
|
476
|
-
*
|
|
477
|
-
* The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
|
|
478
|
-
*
|
|
479
|
-
* @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
|
|
480
|
-
*/
|
|
481
|
-
async submit(taggedBEEF: TaggedBEEF, onSteakReady?: (steak: STEAK) => void, mode: 'historical-tx' | 'current-tx' | 'historical-tx-no-spv' = 'current-tx', offChainValues?: number[]): Promise<STEAK> {
|
|
482
|
-
for (const t of taggedBEEF.topics) {
|
|
483
|
-
if (this.managers[t] === undefined || this.managers[t] === null) {
|
|
484
|
-
throw new Error(`This server does not support this topic: ${t}`)
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// Validate the transaction SPV information
|
|
489
|
-
const tx = Transaction.fromBEEF(taggedBEEF.beef)
|
|
490
|
-
const txid = tx.id('hex')
|
|
491
|
-
|
|
492
|
-
this.startTime(`submit_${txid}`)
|
|
493
|
-
if (mode !== 'historical-tx-no-spv') {
|
|
494
|
-
this.startTime(`chainTracker_${txid.substring(0, 10)}`)
|
|
495
|
-
const txValid = await tx.verify(this.chainTracker)
|
|
496
|
-
if (!txValid) throw new Error('Unable to verify SPV information.')
|
|
497
|
-
this.endTime(`chainTracker_${txid.substring(0, 10)}`)
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const steak: STEAK = {}
|
|
501
|
-
const dupeTopics = new Set<string>()
|
|
502
|
-
const failedTopics = new Set<string>()
|
|
503
|
-
|
|
504
|
-
// ===================================================================
|
|
505
|
-
// PHASE 1: VALIDATE (read-only, no mutations)
|
|
506
|
-
// ===================================================================
|
|
507
|
-
type TopicValidation = {
|
|
508
|
-
topic: string
|
|
509
|
-
isDupe: boolean
|
|
510
|
-
previousCoins: number[]
|
|
511
|
-
previousOutputs: Array<Output | null>
|
|
512
|
-
admissibleOutputs: AdmittanceInstructions
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
const topicValidations = taggedBEEF.topics.map(async (topic): Promise<TopicValidation> => {
|
|
516
|
-
try {
|
|
517
|
-
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
518
|
-
throw new Error(`This server does not support this topic: ${topic}`)
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Check for duplicate transactions
|
|
522
|
-
this.startTime(`dupCheck_${txid.substring(0, 10)}`)
|
|
523
|
-
const dupeCheck = await this.storage.doesAppliedTransactionExist({ txid, topic })
|
|
524
|
-
this.endTime(`dupCheck_${txid.substring(0, 10)}`)
|
|
525
|
-
|
|
526
|
-
if (dupeCheck) {
|
|
527
|
-
dupeTopics.add(topic)
|
|
528
|
-
return {
|
|
529
|
-
topic,
|
|
530
|
-
isDupe: true,
|
|
531
|
-
previousCoins: [],
|
|
532
|
-
previousOutputs: [],
|
|
533
|
-
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// Identify previous coins admitted to this specific topic
|
|
538
|
-
const previousCoins: number[] = []
|
|
539
|
-
const outputPromises = tx.inputs.map(async (input, i) => {
|
|
540
|
-
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex')
|
|
541
|
-
if (previousTXID !== undefined) {
|
|
542
|
-
// Check if the previous output was admitted to this specific topic
|
|
543
|
-
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic)
|
|
544
|
-
if (output !== undefined && output !== null) {
|
|
545
|
-
previousCoins.push(i)
|
|
546
|
-
return output
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
return null
|
|
550
|
-
})
|
|
551
|
-
|
|
552
|
-
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`)
|
|
553
|
-
const previousOutputs = await Promise.all(outputPromises)
|
|
554
|
-
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`)
|
|
555
|
-
|
|
556
|
-
// Determine which outputs are admissible for this topic (validation only)
|
|
557
|
-
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
558
|
-
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(
|
|
559
|
-
taggedBEEF.beef,
|
|
560
|
-
previousCoins,
|
|
561
|
-
offChainValues,
|
|
562
|
-
mode
|
|
563
|
-
)
|
|
564
|
-
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`)
|
|
565
|
-
|
|
566
|
-
return {
|
|
567
|
-
topic,
|
|
568
|
-
isDupe: false,
|
|
569
|
-
previousCoins,
|
|
570
|
-
previousOutputs,
|
|
571
|
-
admissibleOutputs
|
|
572
|
-
}
|
|
573
|
-
} catch (error) {
|
|
574
|
-
this.logger.error('Error validating topic during submit:', error)
|
|
575
|
-
failedTopics.add(topic)
|
|
576
|
-
return {
|
|
577
|
-
topic,
|
|
578
|
-
isDupe: false,
|
|
579
|
-
previousCoins: [],
|
|
580
|
-
previousOutputs: [],
|
|
581
|
-
admissibleOutputs: { outputsToAdmit: [], coinsToRetain: [] }
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
})
|
|
585
|
-
|
|
586
|
-
const validations = await Promise.all(topicValidations)
|
|
587
|
-
|
|
588
|
-
// Build preliminary STEAK from validation results
|
|
589
|
-
for (const validation of validations) {
|
|
590
|
-
steak[validation.topic] = validation.admissibleOutputs
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// ===================================================================
|
|
594
|
-
// PHASE 2: BROADCAST (before any mutations)
|
|
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
|
-
)
|
|
614
|
-
this.startTime(`broadcast_${txid.substring(0, 10)}`)
|
|
615
|
-
if (mode !== 'historical-tx' && this.broadcaster !== undefined && anyTopicAccepted) {
|
|
616
|
-
try {
|
|
617
|
-
let response: BroadcastResponse | BroadcastFailure
|
|
618
|
-
if (tx.merklePath !== undefined) {
|
|
619
|
-
// tx has been verified, thus if there is a merklePath, the transaction is already on-chain...skip broadcast.
|
|
620
|
-
const txid = tx.id('hex')
|
|
621
|
-
const mp = tx.merklePath
|
|
622
|
-
const leaf = mp.path[0].find(leaf => leaf.hash === txid)
|
|
623
|
-
const r: BroadcastResponse = {
|
|
624
|
-
status: 'success',
|
|
625
|
-
txid: tx.id('hex'),
|
|
626
|
-
message: `In block at height ${mp.blockHeight} index ${leaf?.offset}`,
|
|
627
|
-
}
|
|
628
|
-
response = r
|
|
629
|
-
} else {
|
|
630
|
-
response = await this.broadcaster.broadcast(tx)
|
|
631
|
-
}
|
|
632
|
-
if (isBroadcastFailure(response) && this.throwOnBroadcastFailure) {
|
|
633
|
-
const e = new Error(`Failed to broadcast transaction! Error: ${response.description}`)
|
|
634
|
-
; (e as any).more = response.more
|
|
635
|
-
throw e
|
|
636
|
-
}
|
|
637
|
-
} catch (error) {
|
|
638
|
-
if (this.throwOnBroadcastFailure) {
|
|
639
|
-
throw error
|
|
640
|
-
}
|
|
641
|
-
this.logger.error('Error broadcasting transaction:', error)
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
this.endTime(`broadcast_${txid.substring(0, 10)}`)
|
|
645
|
-
|
|
646
|
-
// Call the callback function with STEAK if it is provided (before storage mutations)
|
|
647
|
-
if (onSteakReady !== undefined) {
|
|
648
|
-
onSteakReady(steak)
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// ===================================================================
|
|
652
|
-
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
|
|
653
|
-
// ===================================================================
|
|
654
|
-
// Mark previous outputs as spent and notify lookup services
|
|
655
|
-
await Promise.all(validations.map(async (validation) => {
|
|
656
|
-
if (validation.isDupe || failedTopics.has(validation.topic)) {
|
|
657
|
-
return
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const topic = validation.topic
|
|
661
|
-
const previousOutputs = validation.previousOutputs
|
|
662
|
-
|
|
663
|
-
// Mark all previous outputs as spent
|
|
664
|
-
const markSpentPromises = previousOutputs.map(async (output) => {
|
|
665
|
-
if (output !== undefined && output !== null) {
|
|
666
|
-
try {
|
|
667
|
-
await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic)
|
|
668
|
-
await Promise.all(Object.values(this.lookupServices).map(async l => {
|
|
669
|
-
try {
|
|
670
|
-
if (typeof l.outputSpent === 'function') {
|
|
671
|
-
if (l.spendNotificationMode === 'txid') {
|
|
672
|
-
await l.outputSpent({
|
|
673
|
-
mode: 'txid',
|
|
674
|
-
spendingTxid: txid,
|
|
675
|
-
txid: output.txid,
|
|
676
|
-
outputIndex: output.outputIndex,
|
|
677
|
-
topic
|
|
678
|
-
})
|
|
679
|
-
} else if (l.spendNotificationMode === 'script') {
|
|
680
|
-
const inputIndex = tx.inputs.findIndex(i => {
|
|
681
|
-
let realSource = i.sourceTXID
|
|
682
|
-
if (!realSource) {
|
|
683
|
-
realSource = i.sourceTransaction?.id('hex')
|
|
684
|
-
}
|
|
685
|
-
return realSource === output.txid && i.sourceOutputIndex === output.outputIndex
|
|
686
|
-
})
|
|
687
|
-
if (inputIndex === -1) {
|
|
688
|
-
throw new Error('Could not find input index')
|
|
689
|
-
}
|
|
690
|
-
await l.outputSpent({
|
|
691
|
-
mode: 'script',
|
|
692
|
-
spendingTxid: txid,
|
|
693
|
-
inputIndex,
|
|
694
|
-
sequenceNumber: tx.inputs[inputIndex].sequence ?? 0xffffffff,
|
|
695
|
-
unlockingScript: tx.inputs[inputIndex].unlockingScript!,
|
|
696
|
-
txid: output.txid,
|
|
697
|
-
outputIndex: output.outputIndex,
|
|
698
|
-
topic,
|
|
699
|
-
offChainValues
|
|
700
|
-
})
|
|
701
|
-
} else if (l.spendNotificationMode === 'whole-tx') {
|
|
702
|
-
await l.outputSpent({
|
|
703
|
-
mode: 'whole-tx',
|
|
704
|
-
spendingAtomicBEEF: tx.toAtomicBEEF(),
|
|
705
|
-
txid: output.txid,
|
|
706
|
-
outputIndex: output.outputIndex,
|
|
707
|
-
topic,
|
|
708
|
-
offChainValues
|
|
709
|
-
})
|
|
710
|
-
} else { // none
|
|
711
|
-
await l.outputSpent({
|
|
712
|
-
mode: 'none',
|
|
713
|
-
txid: output.txid,
|
|
714
|
-
outputIndex: output.outputIndex,
|
|
715
|
-
topic
|
|
716
|
-
})
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
} catch (error) {
|
|
720
|
-
this.logger.error('Error in lookup service for outputSpent:', error)
|
|
721
|
-
}
|
|
722
|
-
}))
|
|
723
|
-
} catch (error) {
|
|
724
|
-
this.logger.error('Error marking UTXO as spent:', error)
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
})
|
|
728
|
-
|
|
729
|
-
await Promise.all(markSpentPromises)
|
|
730
|
-
}))
|
|
731
|
-
|
|
732
|
-
// Continue with storage updates and lookup service notifications
|
|
733
|
-
for (const validation of validations) {
|
|
734
|
-
const topic = validation.topic
|
|
735
|
-
if (dupeTopics.has(topic)) {
|
|
736
|
-
continue
|
|
737
|
-
}
|
|
738
|
-
if (failedTopics.has(topic)) {
|
|
739
|
-
continue
|
|
740
|
-
}
|
|
741
|
-
try {
|
|
742
|
-
const admissibleOutputs = steak[topic]
|
|
743
|
-
const outputsToAdmit: number[] = admissibleOutputs.outputsToAdmit
|
|
744
|
-
const outputsConsumed: Array<{
|
|
745
|
-
txid: string
|
|
746
|
-
outputIndex: number
|
|
747
|
-
}> = []
|
|
748
|
-
|
|
749
|
-
const outputsToMarkStale: Array<{
|
|
750
|
-
txid: string
|
|
751
|
-
previousOutputIndex: number
|
|
752
|
-
inputIndex: number
|
|
753
|
-
}> = []
|
|
754
|
-
|
|
755
|
-
// Use previousCoins from validation
|
|
756
|
-
const previousCoins = validation.previousCoins
|
|
757
|
-
|
|
758
|
-
// 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.
|
|
759
|
-
for (const inputIndex of previousCoins) {
|
|
760
|
-
const previousTXID = tx.inputs[inputIndex].sourceTXID ?? tx.inputs[inputIndex].sourceTransaction?.id('hex')
|
|
761
|
-
if (typeof previousTXID !== 'string') continue
|
|
762
|
-
const previousOutputIndex = tx.inputs[inputIndex].sourceOutputIndex
|
|
763
|
-
if (admissibleOutputs.coinsToRetain.includes(inputIndex)) {
|
|
764
|
-
outputsConsumed.push({
|
|
765
|
-
txid: previousTXID,
|
|
766
|
-
outputIndex: previousOutputIndex
|
|
767
|
-
})
|
|
768
|
-
} else {
|
|
769
|
-
outputsToMarkStale.push({
|
|
770
|
-
txid: previousTXID,
|
|
771
|
-
previousOutputIndex,
|
|
772
|
-
inputIndex
|
|
773
|
-
})
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Remove stale outputs recursively
|
|
778
|
-
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
|
|
779
|
-
await Promise.all(outputsToMarkStale.map(async coin => {
|
|
780
|
-
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic)
|
|
781
|
-
if (output !== undefined && output !== null) {
|
|
782
|
-
await this.deleteUTXODeep(output)
|
|
783
|
-
}
|
|
784
|
-
}))
|
|
785
|
-
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`)
|
|
786
|
-
|
|
787
|
-
// Update the STEAK to indicate which coins were removed
|
|
788
|
-
steak[topic].coinsRemoved = outputsToMarkStale.map(x => x.inputIndex)
|
|
789
|
-
|
|
790
|
-
// Handle admittance and notification of incoming UTXOs
|
|
791
|
-
const newUTXOs: Array<{ txid: string, outputIndex: number }> = []
|
|
792
|
-
await Promise.all(outputsToAdmit.map(async outputIndex => {
|
|
793
|
-
if (typeof tx.outputs[outputIndex].satoshis !== 'number') return
|
|
794
|
-
this.startTime(`insertNewOutput_${txid.substring(0, 10)}`)
|
|
795
|
-
await this.storage.insertOutput({
|
|
796
|
-
txid,
|
|
797
|
-
outputIndex,
|
|
798
|
-
outputScript: tx.outputs[outputIndex].lockingScript.toBinary(),
|
|
799
|
-
satoshis: tx.outputs[outputIndex].satoshis,
|
|
800
|
-
topic,
|
|
801
|
-
spent: false,
|
|
802
|
-
beef: this.compactBEEFForStorage(tx, taggedBEEF.beef),
|
|
803
|
-
consumedBy: [],
|
|
804
|
-
outputsConsumed,
|
|
805
|
-
score: Date.now(),
|
|
806
|
-
blockHeight: extractMerkleProofMetadata(txid, tx.merklePath)?.blockHeight
|
|
807
|
-
})
|
|
808
|
-
this.endTime(`insertNewOutput_${txid.substring(0, 10)}`)
|
|
809
|
-
newUTXOs.push({ txid, outputIndex })
|
|
810
|
-
|
|
811
|
-
this.startTime(`notifyLookupService${txid.substring(0, 10)}`)
|
|
812
|
-
await Promise.all(Object.values(this.lookupServices).map(async l => {
|
|
813
|
-
try {
|
|
814
|
-
if (l.admissionMode === 'locking-script') {
|
|
815
|
-
if (
|
|
816
|
-
typeof tx.outputs[outputIndex].lockingScript !== 'object' ||
|
|
817
|
-
typeof tx.outputs[outputIndex].satoshis !== 'number'
|
|
818
|
-
) {
|
|
819
|
-
return
|
|
820
|
-
}
|
|
821
|
-
await l.outputAdmittedByTopic({
|
|
822
|
-
mode: 'locking-script',
|
|
823
|
-
txid,
|
|
824
|
-
outputIndex,
|
|
825
|
-
lockingScript: tx.outputs[outputIndex].lockingScript,
|
|
826
|
-
satoshis: tx.outputs[outputIndex].satoshis,
|
|
827
|
-
topic,
|
|
828
|
-
offChainValues
|
|
829
|
-
})
|
|
830
|
-
} else {
|
|
831
|
-
await l.outputAdmittedByTopic({
|
|
832
|
-
mode: 'whole-tx',
|
|
833
|
-
atomicBEEF: tx.toAtomicBEEF(),
|
|
834
|
-
outputIndex,
|
|
835
|
-
topic,
|
|
836
|
-
offChainValues
|
|
837
|
-
})
|
|
838
|
-
}
|
|
839
|
-
} catch (error) {
|
|
840
|
-
this.logger.error('Error in lookup service for outputAdmittedByTopic:', error)
|
|
841
|
-
}
|
|
842
|
-
}))
|
|
843
|
-
this.endTime(`notifyLookupService${txid.substring(0, 10)}`)
|
|
844
|
-
}))
|
|
845
|
-
|
|
846
|
-
this.startTime(`outputConsumed_${txid.substring(0, 10)}`)
|
|
847
|
-
// Update each output consumed to know who consumed it and insert applied transaction in parallel
|
|
848
|
-
const appliedRecord = await this.buildAppliedTransactionRecord(tx)
|
|
849
|
-
await this.recordTransactionData(tx, taggedBEEF.beef, appliedRecord.blockHash)
|
|
850
|
-
|
|
851
|
-
await Promise.all([
|
|
852
|
-
...outputsConsumed.map(async output => {
|
|
853
|
-
const outputToUpdate = await this.storage.findOutput(output.txid, output.outputIndex, topic)
|
|
854
|
-
if (outputToUpdate !== undefined && outputToUpdate !== null) {
|
|
855
|
-
const newConsumedBy = [...new Set([...newUTXOs, ...outputToUpdate.consumedBy])]
|
|
856
|
-
await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, newConsumedBy)
|
|
857
|
-
}
|
|
858
|
-
}),
|
|
859
|
-
this.storage.insertAppliedTransaction({
|
|
860
|
-
txid,
|
|
861
|
-
topic,
|
|
862
|
-
...appliedRecord
|
|
863
|
-
})
|
|
864
|
-
])
|
|
865
|
-
|
|
866
|
-
if (appliedRecord.blockHeight !== undefined && appliedRecord.blockHash !== undefined) {
|
|
867
|
-
await this.recomputeTopicBlockAnchor(topic, appliedRecord.blockHeight, appliedRecord.blockHash)
|
|
868
|
-
}
|
|
869
|
-
this.endTime(`outputConsumed_${txid.substring(0, 10)}`)
|
|
870
|
-
} catch (error) {
|
|
871
|
-
this.logger.error('Error updating storage and notifying lookup services for topic', topic, error)
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// If we don't have an advertiser or we are dealing with historical transactions, just return the steak
|
|
876
|
-
if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
|
|
877
|
-
return steak
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
this.startTime(`transactionPropagation_${txid.substring(0, 10)}`)
|
|
881
|
-
const relevantTopics = taggedBEEF.topics.filter(topic =>
|
|
882
|
-
steak[topic] !== undefined && !dupeTopics.has(topic) && (steak[topic].outputsToAdmit.length !== 0 || steak[topic].coinsRemoved?.length !== 0)
|
|
883
|
-
)
|
|
884
|
-
|
|
885
|
-
if (relevantTopics.length === 0) {
|
|
886
|
-
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`)
|
|
887
|
-
return steak
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Create a SHIPBroadcaster instance
|
|
891
|
-
let customBroadcasterConfig
|
|
892
|
-
if (Array.isArray(this.slapTrackers)) {
|
|
893
|
-
// Custom SLAP trackers warrant a custom broadcaster config
|
|
894
|
-
const resolverConfig: LookupResolverConfig = {
|
|
895
|
-
slapTrackers: this.slapTrackers
|
|
896
|
-
}
|
|
897
|
-
customBroadcasterConfig = {
|
|
898
|
-
resolver: new LookupResolver(resolverConfig)
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig)
|
|
902
|
-
|
|
903
|
-
try {
|
|
904
|
-
await shipBroadcaster.broadcast(tx)
|
|
905
|
-
} catch (error) {
|
|
906
|
-
this.logger.error('Error during propagation to other nodes:', error)
|
|
907
|
-
}
|
|
908
|
-
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`)
|
|
909
|
-
|
|
910
|
-
// Immediately return from the function without waiting for the promises to resolve.
|
|
911
|
-
return steak
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
/**
|
|
915
|
-
* Submit a lookup question to the Overlay Services Engine, and receive back a Lookup Answer
|
|
916
|
-
* @param LookupQuestion — The question to ask the Overlay Services Engine
|
|
917
|
-
* @returns The answer to the question
|
|
918
|
-
*/
|
|
919
|
-
async lookup(lookupQuestion: LookupQuestion): Promise<LookupAnswer> {
|
|
920
|
-
// Validate a lookup service for the provider is found
|
|
921
|
-
const lookupService = this.lookupServices[lookupQuestion.service]
|
|
922
|
-
if (lookupService === undefined || lookupService === null) throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`)
|
|
923
|
-
|
|
924
|
-
const lookupResult = await lookupService.lookup(lookupQuestion)
|
|
925
|
-
const hydrationContext = this.createUTXOHistoryHydrationContext()
|
|
926
|
-
await this.preloadOutputsWithBEEF(
|
|
927
|
-
lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })),
|
|
928
|
-
hydrationContext
|
|
929
|
-
)
|
|
930
|
-
const hydratedOutputs = (await Promise.all(
|
|
931
|
-
lookupResult.map(async ({ txid, outputIndex, history, context }) => {
|
|
932
|
-
const UTXO = await this.loadOutputWithBEEF(txid, outputIndex, hydrationContext)
|
|
933
|
-
if (UTXO === null) {
|
|
934
|
-
return null
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
// Get the history for this utxo and construct a BEEF
|
|
938
|
-
const output = await this.getUTXOHistory(UTXO, history, 0, hydrationContext)
|
|
939
|
-
if (output?.beef === undefined) {
|
|
940
|
-
return null
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
return {
|
|
944
|
-
beef: output.beef,
|
|
945
|
-
outputIndex: output.outputIndex,
|
|
946
|
-
context
|
|
947
|
-
}
|
|
948
|
-
})
|
|
949
|
-
))
|
|
950
|
-
.filter((output): output is { beef: number[], outputIndex: number, context: number[] | undefined } => output !== null)
|
|
951
|
-
.map(({ beef, outputIndex, context }) => (
|
|
952
|
-
context === undefined
|
|
953
|
-
? { beef, outputIndex }
|
|
954
|
-
: { beef, outputIndex, context }
|
|
955
|
-
))
|
|
956
|
-
return {
|
|
957
|
-
type: 'output-list',
|
|
958
|
-
outputs: hydratedOutputs
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
private createUTXOHistoryHydrationContext(): UTXOHistoryHydrationContext {
|
|
963
|
-
return {
|
|
964
|
-
outputCache: new Map<string, Promise<Output | null>>()
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
private toOutputCacheKey(txid: string, outputIndex: number): string {
|
|
969
|
-
return `${txid}:${outputIndex}`
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
private async preloadOutputsWithBEEF(
|
|
973
|
-
outpoints: Array<{ txid: string, outputIndex: number }>,
|
|
974
|
-
context: UTXOHistoryHydrationContext
|
|
975
|
-
): Promise<void> {
|
|
976
|
-
if (outpoints.length === 0) {
|
|
977
|
-
return
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
const deduped: Array<{ txid: string, outputIndex: number }> = []
|
|
981
|
-
const seen = new Set<string>()
|
|
982
|
-
|
|
983
|
-
for (const outpoint of outpoints) {
|
|
984
|
-
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
|
|
985
|
-
if (seen.has(cacheKey)) {
|
|
986
|
-
continue
|
|
987
|
-
}
|
|
988
|
-
seen.add(cacheKey)
|
|
989
|
-
if (!context.outputCache.has(cacheKey)) {
|
|
990
|
-
deduped.push(outpoint)
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
if (deduped.length === 0) {
|
|
995
|
-
return
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
const findOutputsByOutpoints = this.storage.findOutputsByOutpoints
|
|
999
|
-
if (typeof findOutputsByOutpoints === 'function') {
|
|
1000
|
-
const outputs = await findOutputsByOutpoints.call(this.storage, deduped, true)
|
|
1001
|
-
const outputsByKey = new Map<string, Output>()
|
|
1002
|
-
for (const output of outputs) {
|
|
1003
|
-
outputsByKey.set(this.toOutputCacheKey(output.txid, output.outputIndex), output)
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
for (const outpoint of deduped) {
|
|
1007
|
-
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
|
|
1008
|
-
context.outputCache.set(cacheKey, Promise.resolve(outputsByKey.get(cacheKey) ?? null))
|
|
1009
|
-
}
|
|
1010
|
-
return
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
for (const outpoint of deduped) {
|
|
1014
|
-
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)
|
|
1015
|
-
context.outputCache.set(
|
|
1016
|
-
cacheKey,
|
|
1017
|
-
this.storage.findOutput(outpoint.txid, outpoint.outputIndex, undefined, undefined, true)
|
|
1018
|
-
)
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
private async loadOutputWithBEEF(
|
|
1023
|
-
txid: string,
|
|
1024
|
-
outputIndex: number,
|
|
1025
|
-
context: UTXOHistoryHydrationContext
|
|
1026
|
-
): Promise<Output | null> {
|
|
1027
|
-
const cacheKey = this.toOutputCacheKey(txid, outputIndex)
|
|
1028
|
-
let cached = context.outputCache.get(cacheKey)
|
|
1029
|
-
if (cached === undefined) {
|
|
1030
|
-
cached = this.storage.findOutput(txid, outputIndex, undefined, undefined, true)
|
|
1031
|
-
context.outputCache.set(cacheKey, cached)
|
|
1032
|
-
}
|
|
1033
|
-
const output = await cached
|
|
1034
|
-
return output ?? null
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
private async hydrateUTXOHistoryNode(
|
|
1038
|
-
output: Output,
|
|
1039
|
-
historySelector: ((beef: number[], outputIndex: number, currentDepth: number) => Promise<boolean>) | number,
|
|
1040
|
-
currentDepth: number,
|
|
1041
|
-
context: UTXOHistoryHydrationContext
|
|
1042
|
-
): Promise<HydratedUTXOHistoryNode | undefined> {
|
|
1043
|
-
if (output.beef === undefined) {
|
|
1044
|
-
throw new Error('Output must have associated transaction BEEF!')
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
let shouldTraverseHistory: boolean
|
|
1048
|
-
if (typeof historySelector === 'number') {
|
|
1049
|
-
shouldTraverseHistory = currentDepth <= historySelector
|
|
1050
|
-
} else {
|
|
1051
|
-
shouldTraverseHistory = await historySelector(output.beef, output.outputIndex, currentDepth)
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
if (shouldTraverseHistory === false) {
|
|
1055
|
-
return undefined
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
await this.preloadOutputsWithBEEF(output.outputsConsumed, context)
|
|
1059
|
-
|
|
1060
|
-
const childNodes = (await Promise.all(
|
|
1061
|
-
output.outputsConsumed.map(async (outputIdentifier) => {
|
|
1062
|
-
const childOutput = await this.loadOutputWithBEEF(outputIdentifier.txid, outputIdentifier.outputIndex, context)
|
|
1063
|
-
if (childOutput === null) {
|
|
1064
|
-
return undefined
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
return await this.hydrateUTXOHistoryNode(childOutput, historySelector, currentDepth + 1, context)
|
|
1068
|
-
})
|
|
1069
|
-
)).filter((node): node is HydratedUTXOHistoryNode => node !== undefined)
|
|
1070
|
-
|
|
1071
|
-
const tx = Transaction.fromBEEF(output.beef)
|
|
1072
|
-
const inputIndexBySource = new Map<string, number>()
|
|
1073
|
-
tx.inputs.forEach((candidateInput, index) => {
|
|
1074
|
-
const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
|
|
1075
|
-
? candidateInput.sourceTXID
|
|
1076
|
-
: candidateInput.sourceTransaction?.id('hex')
|
|
1077
|
-
|
|
1078
|
-
if (sourceTXID === undefined) {
|
|
1079
|
-
return
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
inputIndexBySource.set(`${sourceTXID}:${candidateInput.sourceOutputIndex}`, index)
|
|
1083
|
-
})
|
|
1084
|
-
|
|
1085
|
-
for (const child of childNodes) {
|
|
1086
|
-
const inputIndex = inputIndexBySource.get(`${child.output.txid}:${child.output.outputIndex}`)
|
|
1087
|
-
|
|
1088
|
-
if (inputIndex === -1 || inputIndex == null) {
|
|
1089
|
-
continue
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
const targetInput = tx.inputs[inputIndex]
|
|
1093
|
-
if (!targetInput) {
|
|
1094
|
-
this.logger.error(`Input at index ${inputIndex} is undefined, but findIndex found it. Possible sparse array from BEEF parsing.`)
|
|
1095
|
-
continue
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
targetInput.sourceTransaction = child.transaction
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
return { output, transaction: tx }
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
/**
|
|
1105
|
-
* Ensures alignment between the current SHIP/SLAP advertisements and the
|
|
1106
|
-
* configured Topic Managers and Lookup Services in the engine.
|
|
1107
|
-
*
|
|
1108
|
-
* This method performs the following actions:
|
|
1109
|
-
* 1. Retrieves the current configuration of topics and services.
|
|
1110
|
-
* 2. Fetches the existing SHIP advertisements for each configured topic.
|
|
1111
|
-
* 3. Fetches the existing SLAP advertisements for each configured service.
|
|
1112
|
-
* 4. Compares the current configuration with the fetched advertisements to determine which advertisements
|
|
1113
|
-
* need to be created or revoked.
|
|
1114
|
-
* 5. Creates new SHIP/SLAP advertisements if they do not exist for the configured topics/services.
|
|
1115
|
-
* 6. Revokes existing SHIP/SLAP advertisements if they are no longer required based on the current configuration.
|
|
1116
|
-
*
|
|
1117
|
-
* The function uses the `Advertiser` methods to create or revoke advertisements and ensures the updates are
|
|
1118
|
-
* submitted to the SHIP/SLAP overlay networks using the engine's `submit()` method.
|
|
1119
|
-
*
|
|
1120
|
-
* @throws Will throw an error if there are issues during the advertisement synchronization process.
|
|
1121
|
-
* @returns {Promise<void>} A promise that resolves when the synchronization process is complete.
|
|
1122
|
-
*/
|
|
1123
|
-
async syncAdvertisements(): Promise<void> {
|
|
1124
|
-
if (
|
|
1125
|
-
this.advertiser === undefined ||
|
|
1126
|
-
typeof this.hostingURL !== 'string' ||
|
|
1127
|
-
this.hostingURL.length < 1 ||
|
|
1128
|
-
!this.isValidUrl(this.hostingURL)
|
|
1129
|
-
) {
|
|
1130
|
-
return
|
|
1131
|
-
}
|
|
1132
|
-
const advertiser = this.advertiser
|
|
1133
|
-
|
|
1134
|
-
// Step 1: Retrieve Current Configuration
|
|
1135
|
-
let configuredTopics = Object.keys(this.managers)
|
|
1136
|
-
let configuredServices = Object.keys(this.lookupServices)
|
|
1137
|
-
|
|
1138
|
-
// Filter out default SHIP/SLAP topics/services if suppressDefaultSyncAdvertisements is true
|
|
1139
|
-
if (this.suppressDefaultSyncAdvertisements === true) {
|
|
1140
|
-
configuredTopics = configuredTopics.filter(topic => topic !== 'tm_ship' && topic !== 'tm_slap')
|
|
1141
|
-
configuredServices = configuredServices.filter(service => service !== 'ls_ship' && service !== 'ls_slap')
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// Step 2: Fetch Existing Advertisements
|
|
1145
|
-
const currentSHIPAdvertisements = await advertiser.findAllAdvertisements('SHIP')
|
|
1146
|
-
const currentSLAPAdvertisements = await advertiser.findAllAdvertisements('SLAP')
|
|
1147
|
-
|
|
1148
|
-
// Step 3: Compare and Determine Actions
|
|
1149
|
-
const requiredSHIPAdvertisements = new Set(configuredTopics)
|
|
1150
|
-
const requiredSLAPAdvertisements = new Set(configuredServices)
|
|
1151
|
-
|
|
1152
|
-
const shipsToCreate = Array.from(requiredSHIPAdvertisements).filter(topicOrService => !currentSHIPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain === this.hostingURL))
|
|
1153
|
-
const slapsToCreate = Array.from(requiredSLAPAdvertisements).filter(topicOrService => !currentSLAPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain === this.hostingURL))
|
|
1154
|
-
const shipsToRevoke = currentSHIPAdvertisements.filter(ad => !requiredSHIPAdvertisements.has(ad.topicOrService))
|
|
1155
|
-
const slapsToRevoke = currentSLAPAdvertisements.filter(ad => !requiredSLAPAdvertisements.has(ad.topicOrService))
|
|
1156
|
-
|
|
1157
|
-
// Create needed SHIP/SLAP advertisements
|
|
1158
|
-
try {
|
|
1159
|
-
if (shipsToCreate.length > 0 || slapsToCreate.length > 0) {
|
|
1160
|
-
const advertisementData: AdvertisementData[] = [
|
|
1161
|
-
...shipsToCreate.map(topic => ({
|
|
1162
|
-
protocol: 'SHIP' as const,
|
|
1163
|
-
topicOrServiceName: topic
|
|
1164
|
-
})),
|
|
1165
|
-
...slapsToCreate.map(service => ({
|
|
1166
|
-
protocol: 'SLAP' as const,
|
|
1167
|
-
topicOrServiceName: service
|
|
1168
|
-
}))
|
|
1169
|
-
]
|
|
1170
|
-
const taggedBEEF = await advertiser.createAdvertisements(advertisementData)
|
|
1171
|
-
await this.submit(taggedBEEF)
|
|
1172
|
-
}
|
|
1173
|
-
} catch (error) {
|
|
1174
|
-
this.logger.error('Failed to create SHIP advertisement:', error)
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// Revoke all advertisements to revoke
|
|
1178
|
-
try {
|
|
1179
|
-
if (shipsToRevoke.length > 0 || slapsToRevoke.length > 0) {
|
|
1180
|
-
const taggedBEEF = await advertiser.revokeAdvertisements([...shipsToRevoke, ...slapsToRevoke])
|
|
1181
|
-
await this.submit(taggedBEEF)
|
|
1182
|
-
}
|
|
1183
|
-
} catch (error) {
|
|
1184
|
-
this.logger.error('Failed to revoke SHIP/SLAP advertisements:', error)
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/**
|
|
1189
|
-
* This method goes through each topic that we support syncing and attempts to sync with each endpoint
|
|
1190
|
-
* associated with that topic. If the sync configuration is 'SHIP', it will sync to all peers that support
|
|
1191
|
-
* the topic.
|
|
1192
|
-
*
|
|
1193
|
-
* @throws Error if the overlay service engine is not configured for topical synchronization.
|
|
1194
|
-
*/
|
|
1195
|
-
async startGASPSync(): Promise<void> {
|
|
1196
|
-
if (this.syncConfiguration === undefined) {
|
|
1197
|
-
throw new Error('Overlay Service Engine not configured for topical synchronization!')
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
for (const topic of Object.keys(this.syncConfiguration)) {
|
|
1201
|
-
// Make sure syncEndpoints is an array or SHIP
|
|
1202
|
-
let syncEndpoints: string[] | string | false = this.syncConfiguration[topic]
|
|
1203
|
-
|
|
1204
|
-
// Check if this topic has been configured NOT to sync
|
|
1205
|
-
if (syncEndpoints === false) {
|
|
1206
|
-
continue
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
if (syncEndpoints === 'SHIP') {
|
|
1210
|
-
// Perform lookup and find ship advertisements to set syncEndpoints for topic
|
|
1211
|
-
const resolverConfig: LookupResolverConfig = this.slapTrackers
|
|
1212
|
-
? { slapTrackers: this.slapTrackers }
|
|
1213
|
-
: {}
|
|
1214
|
-
|
|
1215
|
-
const resolver = new LookupResolver(resolverConfig)
|
|
1216
|
-
const lookupAnswer: LookupAnswer = await resolver.query({
|
|
1217
|
-
service: 'ls_ship',
|
|
1218
|
-
query: {
|
|
1219
|
-
topics: [topic]
|
|
1220
|
-
}
|
|
1221
|
-
})
|
|
1222
|
-
|
|
1223
|
-
// Lookup will currently always return type output-list
|
|
1224
|
-
if (lookupAnswer.type === 'output-list') {
|
|
1225
|
-
const endpointSet = new Set<string>()
|
|
1226
|
-
|
|
1227
|
-
lookupAnswer.outputs.forEach(output => {
|
|
1228
|
-
try {
|
|
1229
|
-
// Parse out the advertisements using the provided parser
|
|
1230
|
-
const tx = Transaction.fromBEEF(output.beef)
|
|
1231
|
-
const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript)
|
|
1232
|
-
if (advertisement?.protocol === 'SHIP') {
|
|
1233
|
-
endpointSet.add(advertisement.domain)
|
|
1234
|
-
}
|
|
1235
|
-
} catch (error) {
|
|
1236
|
-
this.logger.error('Failed to parse advertisement output:', error)
|
|
1237
|
-
}
|
|
1238
|
-
})
|
|
1239
|
-
|
|
1240
|
-
syncEndpoints = Array.from(endpointSet)
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// Now syncEndpoints is guaranteed to be an array of strings without duplicates
|
|
1245
|
-
if (Array.isArray(syncEndpoints)) {
|
|
1246
|
-
// Remove our own hosting URL so we don't sync with ourselves
|
|
1247
|
-
syncEndpoints = syncEndpoints.filter((endpoint) => endpoint !== this.hostingURL)
|
|
1248
|
-
|
|
1249
|
-
this.logger.info(`[GASP SYNC] Will attempt to sync with ${syncEndpoints.length} peer${syncEndpoints.length === 1 ? '' : 's'}`)
|
|
1250
|
-
// Sync with each endpoint individually to avoid parallel locks and let failures be isolated
|
|
1251
|
-
for (const endpoint of syncEndpoints) {
|
|
1252
|
-
this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`)
|
|
1253
|
-
|
|
1254
|
-
try {
|
|
1255
|
-
// Read the last interaction score from storage
|
|
1256
|
-
const lastInteraction = await this.storage.getLastInteraction(endpoint, topic)
|
|
1257
|
-
|
|
1258
|
-
const gasp = new GASP(
|
|
1259
|
-
new OverlayGASPStorage(topic, this),
|
|
1260
|
-
new OverlayGASPRemote(endpoint, topic),
|
|
1261
|
-
lastInteraction,
|
|
1262
|
-
`[GASP Sync of ${topic} with ${endpoint}]`,
|
|
1263
|
-
true,
|
|
1264
|
-
true
|
|
1265
|
-
)
|
|
1266
|
-
await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT)
|
|
1267
|
-
|
|
1268
|
-
// Save the updated last interaction score
|
|
1269
|
-
if (gasp.lastInteraction > lastInteraction) {
|
|
1270
|
-
await this.storage.updateLastInteraction(endpoint, topic, gasp.lastInteraction)
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
this.logger.info(`[GASP SYNC] Sync successful for topic "${topic}" with peer "${endpoint}"`)
|
|
1274
|
-
} catch (err) {
|
|
1275
|
-
this.logger.error(
|
|
1276
|
-
`[GASP SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`,
|
|
1277
|
-
err
|
|
1278
|
-
)
|
|
1279
|
-
// Continue on to the next endpoint without throwing
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
private async resolveSyncEndpointsForTopic(topic: string): Promise<string[]> {
|
|
1287
|
-
if (this.syncConfiguration === undefined) {
|
|
1288
|
-
return []
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
let syncEndpoints: string[] | string | false = this.syncConfiguration[topic]
|
|
1292
|
-
if (syncEndpoints === false || syncEndpoints === undefined) {
|
|
1293
|
-
return []
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
if (syncEndpoints === 'SHIP') {
|
|
1297
|
-
const resolverConfig: LookupResolverConfig = this.slapTrackers
|
|
1298
|
-
? { slapTrackers: this.slapTrackers }
|
|
1299
|
-
: {}
|
|
1300
|
-
const resolver = new LookupResolver(resolverConfig)
|
|
1301
|
-
const lookupAnswer: LookupAnswer = await resolver.query({
|
|
1302
|
-
service: 'ls_ship',
|
|
1303
|
-
query: {
|
|
1304
|
-
topics: [topic]
|
|
1305
|
-
}
|
|
1306
|
-
})
|
|
1307
|
-
|
|
1308
|
-
const endpointSet = new Set<string>()
|
|
1309
|
-
if (lookupAnswer.type === 'output-list') {
|
|
1310
|
-
lookupAnswer.outputs.forEach(output => {
|
|
1311
|
-
try {
|
|
1312
|
-
const tx = Transaction.fromBEEF(output.beef)
|
|
1313
|
-
const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript)
|
|
1314
|
-
if (advertisement?.protocol === 'SHIP') {
|
|
1315
|
-
endpointSet.add(advertisement.domain)
|
|
1316
|
-
}
|
|
1317
|
-
} catch (error) {
|
|
1318
|
-
this.logger.error('Failed to parse BASM advertisement output:', error)
|
|
1319
|
-
}
|
|
1320
|
-
})
|
|
1321
|
-
}
|
|
1322
|
-
syncEndpoints = Array.from(endpointSet)
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
if (!Array.isArray(syncEndpoints)) {
|
|
1326
|
-
return []
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
return syncEndpoints.filter(endpoint => endpoint !== this.hostingURL)
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
async provideTopicAnchorTip(topic: string): Promise<TopicAnchorTip> {
|
|
1333
|
-
const tip = await this.storage.findTopicAnchorTip?.(topic)
|
|
1334
|
-
return tip ?? {
|
|
1335
|
-
topic,
|
|
1336
|
-
blockHeight: -1,
|
|
1337
|
-
tac: BASM_ZERO_HASH
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
async provideTopicAnchorRange(topic: string, fromHeight: number, toHeight: number): Promise<TopicAnchorRangeResponse> {
|
|
1342
|
-
if (typeof this.storage.findTopicBlockAnchors !== 'function') {
|
|
1343
|
-
throw new TypeError('Storage does not support BASM topic anchor ranges')
|
|
1344
|
-
}
|
|
1345
|
-
if (!Number.isInteger(fromHeight) || !Number.isInteger(toHeight) || fromHeight < 0 || toHeight < fromHeight) {
|
|
1346
|
-
throw new Error('Invalid topic anchor range')
|
|
1347
|
-
}
|
|
1348
|
-
if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
|
|
1349
|
-
throw new Error(`Topic anchor range is capped at ${DEFAULT_BASM_RANGE_LIMIT} heights`)
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
return {
|
|
1353
|
-
topic,
|
|
1354
|
-
anchors: await this.storage.findTopicBlockAnchors(topic, fromHeight, toHeight)
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
async provideAdmittedList(topic: string, blockHeight: number, blockHash?: string): Promise<AdmittedListResponse> {
|
|
1359
|
-
if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function') {
|
|
1360
|
-
throw new TypeError('Storage does not support BASM admitted lists')
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
return {
|
|
1364
|
-
topic,
|
|
1365
|
-
blockHeight,
|
|
1366
|
-
blockHash,
|
|
1367
|
-
admitted: await this.storage.findAdmittedTransactionsForBlock(topic, blockHeight, blockHash)
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
async provideCompoundMerklePath(topic: string, blockHeight: number, txids: string[]): Promise<CompoundMerklePathResponse> {
|
|
1372
|
-
if (typeof this.storage.findTransactionMerklePaths !== 'function') {
|
|
1373
|
-
throw new TypeError('Storage does not support direct Merkle path lookup')
|
|
1374
|
-
}
|
|
1375
|
-
if (txids.length === 0) {
|
|
1376
|
-
throw new Error('At least one txid is required')
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
const admitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, blockHeight)
|
|
1380
|
-
if (admitted !== undefined) {
|
|
1381
|
-
const admittedSet = new Set(admitted.map(item => item.txid))
|
|
1382
|
-
const missingAdmissions = txids.filter(txid => !admittedSet.has(txid))
|
|
1383
|
-
if (missingAdmissions.length > 0) {
|
|
1384
|
-
throw new Error(`Requested txids are not admitted to topic ${topic} at height ${blockHeight}: ${missingAdmissions.join(',')}`)
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
const proofs = await this.storage.findTransactionMerklePaths(txids)
|
|
1389
|
-
const proofByTxid = new Map(proofs.map(proof => [proof.txid, proof]))
|
|
1390
|
-
const missing = txids.filter(txid => !proofByTxid.has(txid))
|
|
1391
|
-
if (missing.length > 0) {
|
|
1392
|
-
throw new Error(`No direct Merkle path found for txids: ${missing.join(',')}`)
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
let compound: MerklePath | undefined
|
|
1396
|
-
for (const txid of txids) {
|
|
1397
|
-
const proof = proofByTxid.get(txid)
|
|
1398
|
-
if (proof === undefined) continue
|
|
1399
|
-
const path = MerklePath.fromHex(proof.merklePath)
|
|
1400
|
-
if (path.blockHeight !== blockHeight) {
|
|
1401
|
-
throw new Error(`Merkle path for ${txid} is at height ${path.blockHeight}, expected ${blockHeight}`)
|
|
1402
|
-
}
|
|
1403
|
-
if (compound === undefined) {
|
|
1404
|
-
compound = path
|
|
1405
|
-
} else {
|
|
1406
|
-
compound.combine(path)
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
if (compound === undefined) {
|
|
1411
|
-
throw new Error('Unable to build compound Merkle path')
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
return {
|
|
1415
|
-
topic,
|
|
1416
|
-
blockHeight,
|
|
1417
|
-
txids,
|
|
1418
|
-
merklePath: compound.toHex()
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
async provideRawTransactions(txids: string[]): Promise<RawTransactionResponse> {
|
|
1423
|
-
if (typeof this.storage.findRawTransactions !== 'function') {
|
|
1424
|
-
throw new TypeError('Storage does not support raw transaction lookup')
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
const transactions = await this.storage.findRawTransactions(txids)
|
|
1428
|
-
const found = new Set(transactions.map(tx => tx.txid))
|
|
1429
|
-
return {
|
|
1430
|
-
transactions,
|
|
1431
|
-
missing: txids.filter(txid => !found.has(txid))
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
async startBASMSync(): Promise<BASMPeerSyncReport[]> {
|
|
1436
|
-
if (this.syncConfiguration === undefined) {
|
|
1437
|
-
throw new Error('Overlay Service Engine not configured for topical synchronization!')
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
const reports: BASMPeerSyncReport[] = []
|
|
1441
|
-
for (const topic of Object.keys(this.syncConfiguration)) {
|
|
1442
|
-
const endpoints = await this.resolveSyncEndpointsForTopic(topic)
|
|
1443
|
-
for (const endpoint of endpoints) {
|
|
1444
|
-
reports.push(await this.reconcileBASMWithPeer(topic, endpoint))
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
return reports
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
private async reconcileBASMWithPeer(topic: string, endpoint: string): Promise<BASMPeerSyncReport> {
|
|
1452
|
-
const report: BASMPeerSyncReport = {
|
|
1453
|
-
topic,
|
|
1454
|
-
endpoint,
|
|
1455
|
-
status: 'skipped',
|
|
1456
|
-
checkedHeights: [],
|
|
1457
|
-
missingTxids: [],
|
|
1458
|
-
fetchedTxCount: 0
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
try {
|
|
1462
|
-
const remote = new BASMRemote(endpoint, topic)
|
|
1463
|
-
const [localTip, remoteTip] = await Promise.all([
|
|
1464
|
-
this.provideTopicAnchorTip(topic),
|
|
1465
|
-
remote.requestTopicAnchorTip()
|
|
1466
|
-
])
|
|
1467
|
-
report.localTip = localTip
|
|
1468
|
-
report.remoteTip = remoteTip
|
|
1469
|
-
|
|
1470
|
-
if (localTip.blockHeight >= remoteTip.blockHeight) {
|
|
1471
|
-
report.status = localTip.tac === remoteTip.tac && localTip.blockHeight === remoteTip.blockHeight ? 'matched' : 'diverged'
|
|
1472
|
-
report.message = report.status === 'matched'
|
|
1473
|
-
? 'Topic anchor tips match'
|
|
1474
|
-
: 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation'
|
|
1475
|
-
return report
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
const fromHeight = Math.max(localTip.blockHeight + 1, remoteTip.blockHeight - DEFAULT_BASM_RANGE_LIMIT + 1, 0)
|
|
1479
|
-
const range = await remote.requestTopicAnchorRange(fromHeight, remoteTip.blockHeight)
|
|
1480
|
-
for (const remoteAnchor of range.anchors) {
|
|
1481
|
-
await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report)
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
const refreshedTip = await this.provideTopicAnchorTip(topic)
|
|
1485
|
-
report.localTip = refreshedTip
|
|
1486
|
-
report.status = refreshedTip.blockHeight >= remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac ? 'matched' : 'advanced'
|
|
1487
|
-
return report
|
|
1488
|
-
} catch (error) {
|
|
1489
|
-
report.status = 'error'
|
|
1490
|
-
report.message = error instanceof Error ? error.message : String(error)
|
|
1491
|
-
this.logger.error(`[BASM SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error)
|
|
1492
|
-
return report
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
private async reconcileRemoteAnchor(
|
|
1497
|
-
topic: string,
|
|
1498
|
-
remote: BASMRemote,
|
|
1499
|
-
remoteAnchor: TopicBlockAnchor,
|
|
1500
|
-
report: BASMPeerSyncReport
|
|
1501
|
-
): Promise<void> {
|
|
1502
|
-
report.checkedHeights.push(remoteAnchor.blockHeight)
|
|
1503
|
-
const localAnchor = await this.storage.findTopicBlockAnchor?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash)
|
|
1504
|
-
if (localAnchor?.tac === remoteAnchor.tac) {
|
|
1505
|
-
return
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
const admittedResponse = await remote.requestAdmittedList(remoteAnchor.blockHeight, remoteAnchor.blockHash)
|
|
1509
|
-
const remoteBasmRoot = computeBasmRoot(admittedResponse.admitted)
|
|
1510
|
-
if (
|
|
1511
|
-
remoteBasmRoot !== remoteAnchor.basmRoot ||
|
|
1512
|
-
admittedResponse.admitted.length !== remoteAnchor.admittedCount
|
|
1513
|
-
) {
|
|
1514
|
-
throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`)
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
const localAdmitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash) ?? []
|
|
1518
|
-
const localTxids = new Set(localAdmitted.map(item => item.txid))
|
|
1519
|
-
const missingTxids = admittedResponse.admitted
|
|
1520
|
-
.map(item => item.txid)
|
|
1521
|
-
.filter(txid => !localTxids.has(txid))
|
|
1522
|
-
|
|
1523
|
-
report.missingTxids.push(...missingTxids)
|
|
1524
|
-
if (missingTxids.length === 0) {
|
|
1525
|
-
report.status = 'diverged'
|
|
1526
|
-
return
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, missingTxids)
|
|
1530
|
-
report.fetchedTxCount += missingTxids.length
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
private async fetchBASMMissingTransactions(
|
|
1534
|
-
remote: BASMRemote,
|
|
1535
|
-
topic: string,
|
|
1536
|
-
anchor: TopicBlockAnchor,
|
|
1537
|
-
txids: string[]
|
|
1538
|
-
): Promise<void> {
|
|
1539
|
-
if (this.chainTracker === 'scripts only') {
|
|
1540
|
-
throw new Error('BASM reconciliation requires a ChainTracker capable of validating BUMP proofs')
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
const proofResponse = await remote.requestCompoundMerklePath(anchor.blockHeight, txids)
|
|
1544
|
-
const compoundPath = MerklePath.fromHex(proofResponse.merklePath)
|
|
1545
|
-
for (const txid of txids) {
|
|
1546
|
-
const valid = await compoundPath.verify(txid, this.chainTracker)
|
|
1547
|
-
if (!valid) {
|
|
1548
|
-
throw new Error(`Peer supplied invalid compound Merkle path for ${txid} at height ${anchor.blockHeight}`)
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
const rawResponse = await remote.requestRawTransactions(txids)
|
|
1553
|
-
if (rawResponse.missing.length > 0) {
|
|
1554
|
-
throw new Error(`Peer did not return raw transactions for txids: ${rawResponse.missing.join(',')}`)
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
for (const record of rawResponse.transactions) {
|
|
1558
|
-
const tx = Transaction.fromHex(record.rawTx)
|
|
1559
|
-
if (tx.id('hex') !== record.txid) {
|
|
1560
|
-
throw new Error(`Raw transaction txid mismatch: expected ${record.txid}, got ${tx.id('hex')}`)
|
|
1561
|
-
}
|
|
1562
|
-
try {
|
|
1563
|
-
tx.merklePath = compoundPath.extract([record.txid])
|
|
1564
|
-
} catch {
|
|
1565
|
-
tx.merklePath = compoundPath
|
|
1566
|
-
}
|
|
1567
|
-
await this.submit({ beef: tx.toBEEF(), topics: [topic] }, undefined, 'historical-tx')
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
async evictUnprovenTransactions(options: {
|
|
1572
|
-
topic?: string
|
|
1573
|
-
thresholdBlocks?: number
|
|
1574
|
-
} = {}): Promise<{
|
|
1575
|
-
cutoffHeight: number
|
|
1576
|
-
candidates: number
|
|
1577
|
-
evictedTransactions: number
|
|
1578
|
-
evictedOutputs: number
|
|
1579
|
-
}> {
|
|
1580
|
-
if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
|
|
1581
|
-
throw new TypeError('Storage does not support unproven transaction eviction')
|
|
1582
|
-
}
|
|
1583
|
-
if (this.chainTracker === 'scripts only') {
|
|
1584
|
-
throw new Error('Unproven eviction requires a ChainTracker to determine block age')
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks
|
|
1588
|
-
const currentHeight = await this.chainTracker.currentHeight()
|
|
1589
|
-
const cutoffHeight = currentHeight - thresholdBlocks
|
|
1590
|
-
const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic)
|
|
1591
|
-
let evictedOutputs = 0
|
|
1592
|
-
|
|
1593
|
-
for (const candidate of candidates) {
|
|
1594
|
-
for (const output of candidate.outputs) {
|
|
1595
|
-
for (const service of Object.values(this.lookupServices)) {
|
|
1596
|
-
try {
|
|
1597
|
-
await service.outputEvicted(output.txid, output.outputIndex)
|
|
1598
|
-
} catch (error) {
|
|
1599
|
-
this.logger.debug(`outputEvicted notification failed for ${output.txid}.${output.outputIndex}: ${error}`)
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic)
|
|
1603
|
-
evictedOutputs++
|
|
1604
|
-
}
|
|
1605
|
-
await this.storage.deleteAppliedTransaction?.(candidate.txid, candidate.topic)
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
return {
|
|
1609
|
-
cutoffHeight,
|
|
1610
|
-
candidates: candidates.length,
|
|
1611
|
-
evictedTransactions: candidates.length,
|
|
1612
|
-
evictedOutputs
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
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
|
-
|
|
1745
|
-
/**
|
|
1746
|
-
* Given a GASP request, create an initial response.
|
|
1747
|
-
*
|
|
1748
|
-
* This method processes an initial synchronization request by finding the relevant UTXOs for the given topic
|
|
1749
|
-
* since the provided block height in the request. It constructs a response that includes a list of these UTXOs
|
|
1750
|
-
* and the min block height from the initial request.
|
|
1751
|
-
*
|
|
1752
|
-
* @param initialRequest - The GASP initial request containing the version and the block height since the last sync.
|
|
1753
|
-
* @param topic - The topic for which UTXOs are being requested.
|
|
1754
|
-
* @returns A promise that resolves to a GASPInitialResponse containing the list of UTXOs and the provided min block height.
|
|
1755
|
-
*/
|
|
1756
|
-
async provideForeignSyncResponse(initialRequest: GASPInitialRequest, topic: string): Promise<GASPInitialResponse> {
|
|
1757
|
-
const outputs = await this.storage.findUTXOsForTopic(topic, initialRequest.since, initialRequest.limit)
|
|
1758
|
-
|
|
1759
|
-
return {
|
|
1760
|
-
UTXOList: outputs.map(output => ({
|
|
1761
|
-
txid: output.txid,
|
|
1762
|
-
outputIndex: output.outputIndex,
|
|
1763
|
-
score: output.score ?? 0
|
|
1764
|
-
})),
|
|
1765
|
-
since: initialRequest.since
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
|
|
1769
|
-
/**
|
|
1770
|
-
* Provides a GASPNode for the given graphID, transaction ID, and output index.
|
|
1771
|
-
*
|
|
1772
|
-
* @param graphID - The identifier for the graph to which this node belongs (in the format txid.outputIndex).
|
|
1773
|
-
* @param txid - The transaction ID for the requested output from somewhere within the graph's history.
|
|
1774
|
-
* @param outputIndex - The index of the output in the transaction.
|
|
1775
|
-
* @returns A promise that resolves to a GASPNode containing the raw transaction and other optional data.
|
|
1776
|
-
* @throws An error if no output is found for the given transaction ID and output index.
|
|
1777
|
-
*/
|
|
1778
|
-
async provideForeignGASPNode(graphID: string, txid: string, outputIndex: number): Promise<GASPNode> {
|
|
1779
|
-
const hydrator = async (output: Output | null): Promise<GASPNode> => {
|
|
1780
|
-
if (output?.beef === undefined) {
|
|
1781
|
-
throw new Error('No matching output found!')
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
const rootTx = Transaction.fromBEEF(output.beef)
|
|
1785
|
-
let correctTx: Transaction | undefined
|
|
1786
|
-
|
|
1787
|
-
const searchInput = (tx: Transaction): void => {
|
|
1788
|
-
if (tx.id('hex') === txid) {
|
|
1789
|
-
correctTx = tx
|
|
1790
|
-
} else {
|
|
1791
|
-
// For each input, look it up and recurse.
|
|
1792
|
-
for (const input of tx.inputs) {
|
|
1793
|
-
// We should always have a source transaction
|
|
1794
|
-
if (input.sourceTransaction === undefined) {
|
|
1795
|
-
throw new Error('Incomplete SPV data!')
|
|
1796
|
-
} else {
|
|
1797
|
-
searchInput(input.sourceTransaction)
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
searchInput(rootTx)
|
|
1804
|
-
|
|
1805
|
-
if (correctTx === undefined) {
|
|
1806
|
-
// Recursively try to find a matching output
|
|
1807
|
-
let foundNode: GASPNode | undefined
|
|
1808
|
-
for (const currentOutput of output.outputsConsumed) {
|
|
1809
|
-
try {
|
|
1810
|
-
const outputFound = await this.storage.findOutput(currentOutput.txid, currentOutput.outputIndex, undefined, undefined, true)
|
|
1811
|
-
foundNode = await hydrator(outputFound)
|
|
1812
|
-
break
|
|
1813
|
-
} catch (error) {
|
|
1814
|
-
// Best-effort: output may not be found or hydration fails for this candidate; try the next one
|
|
1815
|
-
this.logger.debug(`Unable to hydrate output ${currentOutput.txid}.${currentOutput.outputIndex}: ${error}`)
|
|
1816
|
-
continue
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
if (foundNode !== undefined) {
|
|
1820
|
-
return foundNode
|
|
1821
|
-
}
|
|
1822
|
-
} else {
|
|
1823
|
-
const rawTx = correctTx.toHex()
|
|
1824
|
-
const node: GASPNode = {
|
|
1825
|
-
rawTx,
|
|
1826
|
-
graphID,
|
|
1827
|
-
outputIndex
|
|
1828
|
-
}
|
|
1829
|
-
if (correctTx.merklePath !== undefined) {
|
|
1830
|
-
node.proof = correctTx.merklePath.toHex()
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
return node
|
|
1834
|
-
}
|
|
1835
|
-
throw new Error('Unable to find output associated with your request!')
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
const [rootTxid, rootOutputIndex] = graphID.split('.')
|
|
1839
|
-
const output = await this.storage.findOutput(rootTxid, Number(rootOutputIndex), undefined, undefined, true)
|
|
1840
|
-
return await hydrator(output)
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
/**
|
|
1844
|
-
* Traverse and return the history of a UTXO.
|
|
1845
|
-
*
|
|
1846
|
-
* This method traverses the history of a given Unspent Transaction Output (UTXO) and returns
|
|
1847
|
-
* its historical data based on the provided history selector and current depth.
|
|
1848
|
-
*
|
|
1849
|
-
* @param output - The UTXO to traverse the history for.
|
|
1850
|
-
* @param historySelector - Optionally directs the history traversal:
|
|
1851
|
-
* - If a number, denotes how many previous spends (in terms of chain depth) to include.
|
|
1852
|
-
* - If a function, accepts a BEEF-formatted transaction, an output index, and the current depth as parameters,
|
|
1853
|
-
* returning a promise that resolves to a boolean indicating whether to include the output in the history.
|
|
1854
|
-
* @param {number} [currentDepth=0] - The current depth of the traversal relative to the top-level UTXO.
|
|
1855
|
-
*
|
|
1856
|
-
* @returns {Promise<Output | undefined>} - A promise that resolves to the output history if found, or undefined if not.
|
|
1857
|
-
*/
|
|
1858
|
-
async getUTXOHistory(
|
|
1859
|
-
output: Output,
|
|
1860
|
-
historySelector?: ((beef: number[], outputIndex: number, currentDepth: number) => Promise<boolean>) | number,
|
|
1861
|
-
currentDepth = 0,
|
|
1862
|
-
context: UTXOHistoryHydrationContext = this.createUTXOHistoryHydrationContext()
|
|
1863
|
-
): Promise<Output | undefined> {
|
|
1864
|
-
// If we have an output but no history selector, just return the output.
|
|
1865
|
-
if (historySelector === undefined) {
|
|
1866
|
-
return output
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
try {
|
|
1870
|
-
if (output.beef === undefined) {
|
|
1871
|
-
throw new Error('Output must have associated transaction BEEF!')
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
const hydratedNode = await this.hydrateUTXOHistoryNode(output, historySelector, currentDepth, context)
|
|
1875
|
-
if (hydratedNode === undefined) {
|
|
1876
|
-
return undefined
|
|
1877
|
-
}
|
|
1878
|
-
|
|
1879
|
-
return {
|
|
1880
|
-
...hydratedNode.output,
|
|
1881
|
-
beef: hydratedNode.transaction.toBEEF()
|
|
1882
|
-
}
|
|
1883
|
-
} catch (e) {
|
|
1884
|
-
// Handle any errors that occurred
|
|
1885
|
-
// Note: Test this!
|
|
1886
|
-
this.logger.error(`Error retrieving UTXO history: ${e} `)
|
|
1887
|
-
// return []
|
|
1888
|
-
throw new Error(`Error retrieving UTXO history: ${e} `)
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
/**
|
|
1893
|
-
* Delete a UTXO and all stale consumed inputs.
|
|
1894
|
-
* @param output - The UTXO to be deleted.
|
|
1895
|
-
* @returns {Promise<void>} - A promise that resolves when the deletion process is complete.
|
|
1896
|
-
*/
|
|
1897
|
-
private async deleteUTXODeep(output: Output): Promise<void> {
|
|
1898
|
-
try {
|
|
1899
|
-
// Delete the current output IFF there are no references to it
|
|
1900
|
-
if (output.consumedBy.length === 0) {
|
|
1901
|
-
await this.storage.deleteOutput(output.txid, output.outputIndex, output.topic)
|
|
1902
|
-
|
|
1903
|
-
// Notify the lookup services of the UTXO being deleted
|
|
1904
|
-
for (const l of Object.values(this.lookupServices)) {
|
|
1905
|
-
try {
|
|
1906
|
-
await l.outputNoLongerRetainedInHistory?.(
|
|
1907
|
-
output.txid,
|
|
1908
|
-
output.outputIndex,
|
|
1909
|
-
output.topic
|
|
1910
|
-
)
|
|
1911
|
-
} catch (e) {
|
|
1912
|
-
// Best-effort notification; lookup service failure must not abort UTXO deletion
|
|
1913
|
-
this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${output.txid}.${output.outputIndex}: ${e}`)
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
// If there are no more consumed utxos, return
|
|
1919
|
-
if (output.outputsConsumed.length === 0) {
|
|
1920
|
-
return
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
// Delete any stale outputs that were consumed as inputs
|
|
1924
|
-
await Promise.all(output.outputsConsumed.map(async (outputIdentifier) => {
|
|
1925
|
-
const staleOutput = await this.storage.findOutput(outputIdentifier.txid, outputIdentifier.outputIndex, output.topic)
|
|
1926
|
-
|
|
1927
|
-
// Make sure an output was found
|
|
1928
|
-
if (staleOutput === null || staleOutput === undefined) {
|
|
1929
|
-
return undefined
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
// Parse out the existing data, then concat the new outputs with no duplicates
|
|
1933
|
-
if (staleOutput.consumedBy.length !== 0) {
|
|
1934
|
-
staleOutput.consumedBy = staleOutput.consumedBy.filter(x => x.txid !== output.txid && x.outputIndex !== output.outputIndex)
|
|
1935
|
-
// Update with the new consumedBy data
|
|
1936
|
-
await this.storage.updateConsumedBy(outputIdentifier.txid, outputIdentifier.outputIndex, output.topic, staleOutput.consumedBy)
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
// Find previousUTXO history
|
|
1940
|
-
return await this.deleteUTXODeep(staleOutput)
|
|
1941
|
-
}))
|
|
1942
|
-
} catch (error) {
|
|
1943
|
-
throw new Error(`Failed to delete all stale outputs: ${error as string} `)
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
/**
|
|
1948
|
-
* Given a new transaction proof (txid, proof),
|
|
1949
|
-
* update tx.merklePath if appropriate,
|
|
1950
|
-
* and if not, recurse through all input sourceTransactions.
|
|
1951
|
-
*
|
|
1952
|
-
* @param tx transaction which may benefit from new proof.
|
|
1953
|
-
* @param txid BE hex string double hash of transaction proven by proof.
|
|
1954
|
-
* @param proof for txid
|
|
1955
|
-
*/
|
|
1956
|
-
private updateInputProofs(tx: Transaction, txid: string, proof: MerklePath): void {
|
|
1957
|
-
if (tx.id('hex') === txid) {
|
|
1958
|
-
// Update the merkle path to handle potential reorgs
|
|
1959
|
-
tx.merklePath = proof
|
|
1960
|
-
return
|
|
1961
|
-
}
|
|
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)
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
/**
|
|
1975
|
-
* Recursively updates beefs (merkle proofs) of this output and its consumedBy lineage.
|
|
1976
|
-
*
|
|
1977
|
-
* @param output - An output derived from txid which may benefit from new proof.
|
|
1978
|
-
* @param txid - The txid for which proof is a valid merkle path.
|
|
1979
|
-
* @param proof - The merklePath proving txid is a mined transaction hash
|
|
1980
|
-
*/
|
|
1981
|
-
private async updateMerkleProof(output: Output, txid: string, proof: MerklePath): Promise<void> {
|
|
1982
|
-
if (output.beef === undefined) {
|
|
1983
|
-
throw new Error('Output must have associated transaction BEEF!')
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
const tx = Transaction.fromBEEF(output.beef)
|
|
1987
|
-
// Update this transaction, or recursively update the matching source
|
|
1988
|
-
// transaction. This also persists replacement proofs after a reorg.
|
|
1989
|
-
this.updateInputProofs(tx, txid, proof)
|
|
1990
|
-
|
|
1991
|
-
// Update the output's BEEF in the storage DB
|
|
1992
|
-
await this.storage.updateTransactionBEEF(output.txid, tx.toBEEF())
|
|
1993
|
-
|
|
1994
|
-
// Recursively update the consumedBy outputs
|
|
1995
|
-
for (const consumingOutput of output.consumedBy) {
|
|
1996
|
-
const consumedOutputs = await this.storage.findOutputsForTransaction(consumingOutput.txid, true)
|
|
1997
|
-
for (const consumedOutput of consumedOutputs) {
|
|
1998
|
-
await this.updateMerkleProof(consumedOutput, txid, proof)
|
|
1999
|
-
}
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
/**
|
|
2004
|
-
* Recursively prune UTXOs when an incoming Merkle Proof is received.
|
|
2005
|
-
*
|
|
2006
|
-
* @param txid - Transaction ID of the associated outputs to prune.
|
|
2007
|
-
* @param proof - Merkle proof containing the Merkle path and other relevant data to verify the transaction.
|
|
2008
|
-
* @param blockHeight - The block height associated with the incoming merkle proof.
|
|
2009
|
-
*/
|
|
2010
|
-
async handleNewMerkleProof(txid: string, proof: MerklePath, blockHeight?: number): Promise<void> {
|
|
2011
|
-
const outputs = await this.storage.findOutputsForTransaction(txid, true)
|
|
2012
|
-
|
|
2013
|
-
if (outputs === undefined || outputs.length === 0) {
|
|
2014
|
-
throw new Error('Could not find matching transaction outputs for proof ingest!')
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
const proofMetadata = extractMerkleProofMetadata(txid, proof)
|
|
2018
|
-
const resolvedBlockHeight = blockHeight ?? proofMetadata?.blockHeight
|
|
2019
|
-
const resolvedBlockHash = resolvedBlockHeight === undefined
|
|
2020
|
-
? undefined
|
|
2021
|
-
: await this.resolveBlockHash(resolvedBlockHeight, proofMetadata?.merkleRoot)
|
|
2022
|
-
|
|
2023
|
-
for (const output of outputs) {
|
|
2024
|
-
await this.updateMerkleProof(output, txid, proof)
|
|
2025
|
-
|
|
2026
|
-
// Add the associated blockHeight
|
|
2027
|
-
if (resolvedBlockHeight !== undefined) {
|
|
2028
|
-
output.blockHeight = resolvedBlockHeight
|
|
2029
|
-
await this.storage.updateOutputBlockHeight?.(output.txid, output.outputIndex, output.topic, resolvedBlockHeight)
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
if (output.beef !== undefined) {
|
|
2033
|
-
const tx = Transaction.fromBEEF(output.beef)
|
|
2034
|
-
this.updateInputProofs(tx, txid, proof)
|
|
2035
|
-
await this.recordTransactionData(tx, tx.toBEEF(), resolvedBlockHash)
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
if (resolvedBlockHeight !== undefined) {
|
|
2039
|
-
await this.storage.updateAppliedTransactionProof?.({
|
|
2040
|
-
txid,
|
|
2041
|
-
topic: output.topic,
|
|
2042
|
-
blockHeight: resolvedBlockHeight,
|
|
2043
|
-
blockHash: resolvedBlockHash,
|
|
2044
|
-
blockIndex: proofMetadata?.blockIndex,
|
|
2045
|
-
merkleRoot: proofMetadata?.merkleRoot
|
|
2046
|
-
})
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
if (resolvedBlockHeight !== undefined && resolvedBlockHash !== undefined) {
|
|
2050
|
-
await this.recomputeTopicBlockAnchor(output.topic, resolvedBlockHeight, resolvedBlockHash)
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
/**
|
|
2056
|
-
* Find a list of supported topic managers
|
|
2057
|
-
* @public
|
|
2058
|
-
* @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported topic managers and their metadata
|
|
2059
|
-
*/
|
|
2060
|
-
async listTopicManagers(): Promise<Record<string, {
|
|
2061
|
-
name: string
|
|
2062
|
-
shortDescription: string
|
|
2063
|
-
iconURL?: string
|
|
2064
|
-
version?: string
|
|
2065
|
-
informationURL?: string
|
|
2066
|
-
}>> {
|
|
2067
|
-
const result: Record<string, {
|
|
2068
|
-
name: string
|
|
2069
|
-
shortDescription: string
|
|
2070
|
-
iconURL?: string
|
|
2071
|
-
version?: string
|
|
2072
|
-
informationURL?: string
|
|
2073
|
-
}> = {}
|
|
2074
|
-
for (const t in this.managers) {
|
|
2075
|
-
try {
|
|
2076
|
-
result[t] = await this.managers[t].getMetaData()
|
|
2077
|
-
} catch (e) {
|
|
2078
|
-
this.logger.warn(`Unable to get metadata for topic manager: ${t}: ${e}`)
|
|
2079
|
-
result[t] = {
|
|
2080
|
-
name: t,
|
|
2081
|
-
shortDescription: 'No topical tagline.'
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
return result
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
/**
|
|
2089
|
-
* Find a list of supported lookup services
|
|
2090
|
-
* @public
|
|
2091
|
-
* @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported lookup services and their metadata
|
|
2092
|
-
*/
|
|
2093
|
-
async listLookupServiceProviders(): Promise<Record<string, {
|
|
2094
|
-
name: string
|
|
2095
|
-
shortDescription: string
|
|
2096
|
-
iconURL?: string
|
|
2097
|
-
version?: string
|
|
2098
|
-
informationURL?: string
|
|
2099
|
-
}>> {
|
|
2100
|
-
const result: Record<string, {
|
|
2101
|
-
name: string
|
|
2102
|
-
shortDescription: string
|
|
2103
|
-
iconURL?: string
|
|
2104
|
-
version?: string
|
|
2105
|
-
informationURL?: string
|
|
2106
|
-
}> = {}
|
|
2107
|
-
for (const ls in this.lookupServices) {
|
|
2108
|
-
try {
|
|
2109
|
-
result[ls] = await this.lookupServices[ls].getMetaData()
|
|
2110
|
-
} catch (e) {
|
|
2111
|
-
this.logger.warn(`Unable to get metadata for lookup service: ${ls}: ${e}`)
|
|
2112
|
-
result[ls] = {
|
|
2113
|
-
name: ls,
|
|
2114
|
-
shortDescription: 'No lookup service tagline.'
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
}
|
|
2118
|
-
return result
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
/**
|
|
2122
|
-
* Run a query to get the documentation for a particular topic manager
|
|
2123
|
-
* @public
|
|
2124
|
-
* @returns {Promise<string>} - the documentation for the topic manager
|
|
2125
|
-
*/
|
|
2126
|
-
async getDocumentationForTopicManager(manager: any): Promise<string> {
|
|
2127
|
-
const documentation = await this.managers[manager]?.getDocumentation?.()
|
|
2128
|
-
return documentation ?? 'No documentation found!'
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
/**
|
|
2132
|
-
* Run a query to get the documentation for a particular lookup service
|
|
2133
|
-
* @public
|
|
2134
|
-
* @returns {Promise<string>} - the documentation for the lookup service
|
|
2135
|
-
*/
|
|
2136
|
-
async getDocumentationForLookupServiceProvider(provider: any): Promise<string> {
|
|
2137
|
-
const documentation = await this.lookupServices[provider]?.getDocumentation?.()
|
|
2138
|
-
return documentation ?? 'No documentation found!'
|
|
2139
|
-
}
|
|
2140
|
-
|
|
2141
|
-
/**
|
|
2142
|
-
* Validates a URL to ensure it does not match disallowed patterns:
|
|
2143
|
-
* - Contains "http:" protocol
|
|
2144
|
-
* - Contains "localhost" (with or without a port)
|
|
2145
|
-
* - Internal or non-routable IP addresses (e.g., 192.168.x.x, 10.x.x.x, 172.16.x.x to 172.31.x.x)
|
|
2146
|
-
* - Non-routable IPs like 127.x.x.x, 0.0.0.0, or IPv6 loopback (::1)
|
|
2147
|
-
*
|
|
2148
|
-
* @param url - The URL string to validate
|
|
2149
|
-
* @returns {boolean} - Returns `false` if the URL violates any of the conditions `true` otherwise
|
|
2150
|
-
*/
|
|
2151
|
-
private isValidUrl(url: string): boolean {
|
|
2152
|
-
try {
|
|
2153
|
-
const parsedUrl = new URL(url)
|
|
2154
|
-
|
|
2155
|
-
// Disallow http:
|
|
2156
|
-
if (parsedUrl.protocol === 'http:') {
|
|
2157
|
-
return false
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
// Disallow localhost with or without a port
|
|
2161
|
-
if (/^localhost(:\d+)?$/i.test(parsedUrl.hostname)) {
|
|
2162
|
-
return false
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
// Disallow internal and non-routable IP addresses
|
|
2166
|
-
const ipAddress = parsedUrl.hostname
|
|
2167
|
-
|
|
2168
|
-
// Regex for non-routable IPv4 IPs
|
|
2169
|
-
const nonRoutableIpv4Patterns = [
|
|
2170
|
-
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback IPs
|
|
2171
|
-
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // 10.x.x.x private IPs
|
|
2172
|
-
/^192\.168\.\d{1,3}\.\d{1,3}$/, // 192.168.x.x private IPs
|
|
2173
|
-
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, // 172.16.x.x to 172.31.x.x private IPs
|
|
2174
|
-
/^0\.0\.0\.0$/ // Non-routable address
|
|
2175
|
-
]
|
|
2176
|
-
|
|
2177
|
-
// Check for IPv4 matches
|
|
2178
|
-
if (nonRoutableIpv4Patterns.some((pattern) => pattern.test(ipAddress))) {
|
|
2179
|
-
return false
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
|
-
// Check for non-routable IPv6 addresses explicitly
|
|
2183
|
-
if (ipAddress === '[::1]') {
|
|
2184
|
-
return false
|
|
2185
|
-
}
|
|
2186
|
-
|
|
2187
|
-
// If none of the disallowed conditions matched, the URL is valid
|
|
2188
|
-
return true
|
|
2189
|
-
} catch (_e) {
|
|
2190
|
-
// URL constructor throws on malformed input — not a valid URL, return false
|
|
2191
|
-
return false
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
}
|