@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/identity/IdentityClient.js +22 -6
- package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
- package/dist/cjs/src/overlay-tools/LookupResolver.js +153 -75
- package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/identity/IdentityClient.js +22 -6
- package/dist/esm/src/identity/IdentityClient.js.map +1 -1
- package/dist/esm/src/overlay-tools/LookupResolver.js +160 -75
- package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/identity/IdentityClient.d.ts +4 -2
- package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
- package/dist/types/src/overlay-tools/LookupResolver.d.ts +29 -1
- package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +3 -3
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/identity.md +8 -4
- package/docs/reference/overlay-tools.md +10 -1
- package/docs/reference/storage.md +1 -1
- package/package.json +1 -1
- package/src/identity/IdentityClient.ts +32 -6
- package/src/identity/__tests/IdentityClient.test.ts +172 -45
- package/src/overlay-tools/LookupResolver.ts +168 -81
- package/src/overlay-tools/__tests/LookupResolver.test.ts +0 -47
|
@@ -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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
141
|
+
if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
|
|
135
142
|
return await response.json()
|
|
136
|
-
}
|
|
137
|
-
|
|
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
|
|
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.
|
|
204
|
+
competentHosts = await this.getCompetentHostsCached(question.service)
|
|
176
205
|
}
|
|
177
206
|
if (this.additionalHosts[question.service]?.length > 0) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
218
|
+
// Fire all hosts with per-host timeout, harvest successful output-list responses
|
|
190
219
|
const hostResponses = await Promise.allSettled(
|
|
191
|
-
competentHosts.map(
|
|
192
|
-
|
|
193
|
-
)
|
|
220
|
+
competentHosts.map(async (host) => {
|
|
221
|
+
return await this.facilitator.lookup(host, question, timeout)
|
|
222
|
+
})
|
|
194
223
|
)
|
|
195
224
|
|
|
196
|
-
const
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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(
|
|
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
|
-
//
|
|
337
|
+
// Query all SLAP trackers; tolerate failures.
|
|
246
338
|
const trackerResponses = await Promise.allSettled(
|
|
247
|
-
this.slapTrackers.map(
|
|
248
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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)
|