@bsv/sdk 1.7.0 → 1.7.2

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.
@@ -57,6 +57,16 @@ export const DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [
57
57
 
58
58
  const MAX_TRACKER_WAIT_TIME = 5000
59
59
 
60
+ /** Internal cache options. Kept optional to preserve drop-in compatibility. */
61
+ interface CacheOptions {
62
+ /** How long (ms) a hosts entry is considered fresh. Default 5 minutes. */
63
+ hostsTtlMs?: number
64
+ /** How many distinct services’ hosts to cache before evicting. Default 128. */
65
+ hostsMaxEntries?: number
66
+ /** How long (ms) to keep txId memoization. Default 10 minutes. */
67
+ txMemoTtlMs?: number
68
+ }
69
+
60
70
  /** Configuration options for the Lookup resolver. */
61
71
  export interface LookupResolverConfig {
62
72
  /**
@@ -74,6 +84,8 @@ export interface LookupResolverConfig {
74
84
  hostOverrides?: Record<string, string[]>
75
85
  /** Map of lookup service names to arrays of hosts to use in addition to resolving via SLAP. */
76
86
  additionalHosts?: Record<string, string[]>
87
+ /** Optional cache tuning. */
88
+ cache?: CacheOptions
77
89
  }
78
90
 
79
91
  /** Facilitates lookups to URLs that return answers. */
@@ -111,36 +123,35 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
111
123
  'HTTPS facilitator can only use URLs that start with "https:"'
112
124
  )
113
125
  }
114
- const timeoutPromise = new Promise((resolve, reject) =>
115
- setTimeout(() => reject(new Error('Request timed out')), timeout)
116
- )
117
126
 
118
- const fetchPromise = fetch(`${url}/lookup`, {
119
- method: 'POST',
120
- headers: {
121
- 'Content-Type': 'application/json'
122
- },
123
- body: JSON.stringify({
124
- service: question.service,
125
- query: question.query
126
- })
127
- })
127
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined
128
+ const timer = setTimeout(() => {
129
+ try { controller?.abort() } catch { /* noop */ }
130
+ }, timeout)
128
131
 
129
- const response: Response = (await Promise.race([
130
- fetchPromise,
131
- timeoutPromise
132
- ])) as Response
132
+ try {
133
+ const fco: RequestInit = {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ service: question.service, query: question.query }),
137
+ signal: controller?.signal
138
+ }
139
+ const response: Response = await this.fetchClient(`${url}/lookup`, fco)
133
140
 
134
- if (response.ok) {
141
+ if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
135
142
  return await response.json()
136
- } else {
137
- throw new Error('Failed to facilitate lookup')
143
+ } catch (e) {
144
+ // Normalize timeouts to a consistent error message
145
+ if ((e as any)?.name === 'AbortError') throw new Error('Request timed out')
146
+ throw e
147
+ } finally {
148
+ clearTimeout(timer)
138
149
  }
139
150
  }
140
151
  }
141
152
 
142
153
  /**
143
- * Represents an SHIP transaction broadcaster.
154
+ * Represents a Lookup Resolver.
144
155
  */
145
156
  export default class LookupResolver {
146
157
  private readonly facilitator: OverlayLookupFacilitator
@@ -149,12 +160,30 @@ export default class LookupResolver {
149
160
  private readonly additionalHosts: Record<string, string[]>
150
161
  private readonly networkPreset: 'mainnet' | 'testnet' | 'local'
151
162
 
163
+ // ---- Caches / memoization ----
164
+ private readonly hostsCache: Map<string, { hosts: string[], expiresAt: number }>
165
+ private readonly hostsInFlight: Map<string, Promise<string[]>>
166
+ private readonly hostsTtlMs: number
167
+ private readonly hostsMaxEntries: number
168
+
169
+ private readonly txMemo: Map<string, { txId: string, expiresAt: number }>
170
+ private readonly txMemoTtlMs: number
171
+
152
172
  constructor (config: LookupResolverConfig = {}) {
153
173
  this.networkPreset = config.networkPreset ?? 'mainnet'
154
174
  this.facilitator = config.facilitator ?? new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local')
155
175
  this.slapTrackers = config.slapTrackers ?? (this.networkPreset === 'mainnet' ? DEFAULT_SLAP_TRACKERS : DEFAULT_TESTNET_SLAP_TRACKERS)
156
176
  this.hostOverrides = config.hostOverrides ?? {}
157
177
  this.additionalHosts = config.additionalHosts ?? {}
178
+
179
+ // cache tuning
180
+ this.hostsTtlMs = config.cache?.hostsTtlMs ?? 5 * 60 * 1000 // 5 min
181
+ this.hostsMaxEntries = config.cache?.hostsMaxEntries ?? 128
182
+ this.txMemoTtlMs = config.cache?.txMemoTtlMs ?? 10 * 60 * 1000 // 10 min
183
+
184
+ this.hostsCache = new Map()
185
+ this.hostsInFlight = new Map()
186
+ this.txMemo = new Map()
158
187
  }
159
188
 
160
189
  /**
@@ -172,13 +201,13 @@ export default class LookupResolver {
172
201
  } else if (this.networkPreset === 'local') {
173
202
  competentHosts = ['http://localhost:8080']
174
203
  } else {
175
- competentHosts = await this.findCompetentHosts(question.service)
204
+ competentHosts = await this.getCompetentHostsCached(question.service)
176
205
  }
177
206
  if (this.additionalHosts[question.service]?.length > 0) {
178
- competentHosts = [
179
- ...competentHosts,
180
- ...this.additionalHosts[question.service]
181
- ]
207
+ // preserve order: resolved hosts first, then additional (unique)
208
+ const extra = this.additionalHosts[question.service]
209
+ const seen = new Set(competentHosts)
210
+ for (const h of extra) if (!seen.has(h)) competentHosts.push(h)
182
211
  }
183
212
  if (competentHosts.length < 1) {
184
213
  throw new Error(
@@ -186,47 +215,112 @@ export default class LookupResolver {
186
215
  )
187
216
  }
188
217
 
189
- // Use Promise.allSettled to handle individual host failures
218
+ // Fire all hosts with per-host timeout, harvest successful output-list responses
190
219
  const hostResponses = await Promise.allSettled(
191
- competentHosts.map(
192
- async (host) => await this.facilitator.lookup(host, question, timeout)
193
- )
220
+ competentHosts.map(async (host) => {
221
+ return await this.facilitator.lookup(host, question, timeout)
222
+ })
194
223
  )
195
224
 
196
- const successfulResponses = hostResponses
197
- .filter((result): result is PromiseFulfilledResult<LookupAnswer> => result.status === 'fulfilled')
198
- .map((result) => result.value)
225
+ const outputsMap = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
199
226
 
200
- if (successfulResponses.length === 0) {
201
- throw new Error('No successful responses from any hosts')
227
+ // Memo key helper for tx parsing
228
+ const beefKey = (beef: number[]): string => {
229
+ if (typeof beef !== 'object') return '' // The invalid BEEF has an empty key.
230
+ // A fast and deterministic key for memoization; avoids large JSON strings
231
+ // since beef is an array of integers, join is safe and compact.
232
+ return beef.join(',')
202
233
  }
203
234
 
204
- // Process the successful responses
205
- // Aggregate outputs from all successful responses
206
- const outputs = new Map<string, { beef: number[], context?: number[], outputIndex: number }>()
235
+ for (const result of hostResponses) {
236
+ if (result.status !== 'fulfilled') continue
237
+ const response = result.value
238
+ if (response?.type !== 'output-list' || !Array.isArray(response.outputs)) continue
207
239
 
208
- for (const response of successfulResponses) {
209
- if (response.type !== 'output-list') {
210
- continue
211
- }
212
- try {
213
- for (const output of response.outputs) {
240
+ for (const output of response.outputs) {
241
+ const keyForBeef = beefKey(output.beef)
242
+ let memo = this.txMemo.get(keyForBeef)
243
+ const now = Date.now()
244
+ if (typeof memo !== 'object' || memo === null || memo.expiresAt <= now) {
214
245
  try {
215
- const txId: string = Transaction.fromBEEF(output.beef).id('hex') // !! This is STUPIDLY inefficient.
216
- const key = `${txId}.${output.outputIndex}`
217
- outputs.set(key, output)
246
+ const txId = Transaction.fromBEEF(output.beef).id('hex')
247
+ memo = { txId, expiresAt: now + this.txMemoTtlMs }
248
+ // prune opportunistically if the map gets too large (cheap heuristic)
249
+ if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
250
+ this.txMemo.set(keyForBeef, memo)
218
251
  } catch {
219
252
  continue
220
253
  }
221
254
  }
222
- } catch (_) {
223
- // Error processing output, proceed.
255
+
256
+ const uniqKey = `${memo.txId}.${output.outputIndex}`
257
+ // last-writer wins is fine here; outputs are identical if uniqKey matches
258
+ outputsMap.set(uniqKey, output)
224
259
  }
225
260
  }
226
261
  return {
227
262
  type: 'output-list',
228
- outputs: Array.from(outputs.values())
263
+ outputs: Array.from(outputsMap.values())
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Cached wrapper for competent host discovery with stale-while-revalidate.
269
+ */
270
+ private async getCompetentHostsCached (service: string): Promise<string[]> {
271
+ const now = Date.now()
272
+ const cached = this.hostsCache.get(service)
273
+
274
+ // if fresh, return immediately
275
+ if (typeof cached === 'object' && cached.expiresAt > now) {
276
+ return cached.hosts.slice()
277
+ }
278
+
279
+ // if stale but present, kick off a refresh if not already in-flight and return stale
280
+ if (typeof cached === 'object' && cached.expiresAt <= now) {
281
+ if (!this.hostsInFlight.has(service)) {
282
+ this.hostsInFlight.set(service, this.refreshHosts(service).finally(() => {
283
+ this.hostsInFlight.delete(service)
284
+ }))
285
+ }
286
+ return cached.hosts.slice()
287
+ }
288
+
289
+ // no cache: coalesce concurrent requests
290
+ if (this.hostsInFlight.has(service)) {
291
+ try {
292
+ const hosts = await this.hostsInFlight.get(service)
293
+ if (typeof hosts !== 'object') {
294
+ throw new Error('Hosts is not defined.')
295
+ }
296
+ return hosts.slice()
297
+ } catch {
298
+ // fall through to a fresh attempt below
299
+ }
300
+ }
301
+
302
+ const promise = this.refreshHosts(service).finally(() => {
303
+ this.hostsInFlight.delete(service)
304
+ })
305
+ this.hostsInFlight.set(service, promise)
306
+ const hosts = await promise
307
+ return hosts.slice()
308
+ }
309
+
310
+ /**
311
+ * Actually resolves competent hosts from SLAP trackers and updates cache.
312
+ */
313
+ private async refreshHosts (service: string): Promise<string[]> {
314
+ const hosts = await this.findCompetentHosts(service)
315
+ const expiresAt = Date.now() + this.hostsTtlMs
316
+
317
+ // bounded cache with simple FIFO eviction
318
+ if (!this.hostsCache.has(service) && this.hostsCache.size >= this.hostsMaxEntries) {
319
+ const oldestKey = this.hostsCache.keys().next().value
320
+ if (oldestKey !== undefined) this.hostsCache.delete(oldestKey)
229
321
  }
322
+ this.hostsCache.set(service, { hosts, expiresAt })
323
+ return hosts
230
324
  }
231
325
 
232
326
  /**
@@ -237,52 +331,45 @@ export default class LookupResolver {
237
331
  private async findCompetentHosts (service: string): Promise<string[]> {
238
332
  const query: LookupQuestion = {
239
333
  service: 'ls_slap',
240
- query: {
241
- service
242
- }
334
+ query: { service }
243
335
  }
244
336
 
245
- // Use Promise.allSettled to handle individual SLAP tracker failures
337
+ // Query all SLAP trackers; tolerate failures.
246
338
  const trackerResponses = await Promise.allSettled(
247
- this.slapTrackers.map(
248
- async (tracker) =>
249
- await this.facilitator.lookup(tracker, query, MAX_TRACKER_WAIT_TIME)
339
+ this.slapTrackers.map(async (tracker) =>
340
+ await this.facilitator.lookup(tracker, query, MAX_TRACKER_WAIT_TIME)
250
341
  )
251
342
  )
252
343
 
253
344
  const hosts = new Set<string>()
254
345
 
255
346
  for (const result of trackerResponses) {
256
- if (result.status === 'fulfilled') {
257
- const answer = result.value
258
- if (answer.type !== 'output-list') {
259
- // Log invalid response and continue
260
- continue
261
- }
262
- for (const output of answer.outputs) {
263
- try {
264
- const tx = Transaction.fromBEEF(output.beef)
265
- const script = tx.outputs[output.outputIndex].lockingScript
266
- const parsed = OverlayAdminTokenTemplate.decode(script)
267
- if (
268
- parsed.topicOrService !== service ||
269
- parsed.protocol !== 'SLAP'
270
- ) {
271
- // Invalid advertisement, skip
272
- continue
273
- }
347
+ if (result.status !== 'fulfilled') continue
348
+ const answer = result.value
349
+ if (answer.type !== 'output-list') continue
350
+
351
+ for (const output of answer.outputs) {
352
+ try {
353
+ const tx = Transaction.fromBEEF(output.beef)
354
+ const script = tx.outputs[output.outputIndex]?.lockingScript
355
+ if (typeof script !== 'object' || script === null) continue
356
+ const parsed = OverlayAdminTokenTemplate.decode(script)
357
+ if (parsed.topicOrService !== service || parsed.protocol !== 'SLAP') continue
358
+ if (typeof parsed.domain === 'string' && parsed.domain.length > 0) {
274
359
  hosts.add(parsed.domain)
275
- } catch {
276
- // Invalid output, skip
277
- continue
278
360
  }
361
+ } catch {
362
+ continue
279
363
  }
280
- } else {
281
- // Log tracker failure and continue
282
- continue
283
364
  }
284
365
  }
285
366
 
286
367
  return [...hosts]
287
368
  }
369
+
370
+ /** Evict an arbitrary “oldest” entry from a Map (iteration order). */
371
+ private evictOldest<T> (m: Map<string, T>): void {
372
+ const firstKey = m.keys().next().value
373
+ if (firstKey !== undefined) m.delete(firstKey)
374
+ }
288
375
  }
@@ -1522,53 +1522,6 @@ describe('LookupResolver', () => {
1522
1522
  expect(mockFacilitator.lookup.mock.calls.length).toBe(2)
1523
1523
  })
1524
1524
 
1525
- it('should handle all hosts failing and throw an error', async () => {
1526
- const slapHostKey = new PrivateKey(42)
1527
- const slapWallet = new CompletedProtoWallet(slapHostKey)
1528
- const slapLib = new OverlayAdminTokenTemplate(slapWallet)
1529
- const slapScript = await slapLib.lock(
1530
- 'SLAP',
1531
- 'https://slaphost.com',
1532
- 'ls_foo'
1533
- )
1534
- const slapTx = new Transaction(
1535
- 1,
1536
- [],
1537
- [
1538
- {
1539
- lockingScript: slapScript,
1540
- satoshis: 1
1541
- }
1542
- ],
1543
- 0
1544
- )
1545
-
1546
- // SLAP tracker returns host
1547
- mockFacilitator.lookup.mockReturnValueOnce({
1548
- type: 'output-list',
1549
- outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
1550
- })
1551
-
1552
- // Host fails
1553
- mockFacilitator.lookup.mockImplementationOnce(async () => {
1554
- throw new Error('Host failed')
1555
- })
1556
-
1557
- const r = new LookupResolver({
1558
- facilitator: mockFacilitator,
1559
- slapTrackers: ['https://mock.slap']
1560
- })
1561
-
1562
- await expect(
1563
- r.query({
1564
- service: 'ls_foo',
1565
- query: { test: 1 }
1566
- })
1567
- ).rejects.toThrow('No successful responses from any hosts')
1568
-
1569
- expect(mockFacilitator.lookup.mock.calls.length).toBe(2)
1570
- })
1571
-
1572
1525
  it('should continue to aggregate outputs when some hosts return invalid outputs', async () => {
1573
1526
  const slapHostKey1 = new PrivateKey(42)
1574
1527
  const slapWallet1 = new CompletedProtoWallet(slapHostKey1)