@bsv/sdk 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/Peer.js +21 -18
- package/dist/cjs/src/auth/Peer.js.map +1 -1
- package/dist/cjs/src/auth/SessionManager.js.map +1 -1
- package/dist/cjs/src/auth/clients/AuthFetch.js +4 -1
- package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/cjs/src/identity/ContactsManager.js +44 -6
- package/dist/cjs/src/identity/ContactsManager.js.map +1 -1
- package/dist/cjs/src/identity/IdentityClient.js +106 -37
- package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
- package/dist/cjs/src/overlay-tools/LookupResolver.js +180 -82
- package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/cjs/src/primitives/Hash.js +173 -50
- package/dist/cjs/src/primitives/Hash.js.map +1 -1
- package/dist/cjs/src/primitives/SymmetricKey.js +123 -1
- package/dist/cjs/src/primitives/SymmetricKey.js.map +1 -1
- package/dist/cjs/src/transaction/MerklePath.js +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/Peer.js +28 -18
- package/dist/esm/src/auth/Peer.js.map +1 -1
- package/dist/esm/src/auth/SessionManager.js.map +1 -1
- package/dist/esm/src/auth/clients/AuthFetch.js +4 -1
- package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
- package/dist/esm/src/identity/ContactsManager.js +44 -6
- package/dist/esm/src/identity/ContactsManager.js.map +1 -1
- package/dist/esm/src/identity/IdentityClient.js +106 -37
- package/dist/esm/src/identity/IdentityClient.js.map +1 -1
- package/dist/esm/src/overlay-tools/LookupResolver.js +180 -82
- package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/esm/src/primitives/Hash.js +177 -50
- package/dist/esm/src/primitives/Hash.js.map +1 -1
- package/dist/esm/src/primitives/SymmetricKey.js +123 -1
- package/dist/esm/src/primitives/SymmetricKey.js.map +1 -1
- package/dist/esm/src/transaction/MerklePath.js +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/Peer.d.ts +3 -3
- package/dist/types/src/auth/Peer.d.ts.map +1 -1
- package/dist/types/src/auth/SessionManager.d.ts +21 -0
- package/dist/types/src/auth/SessionManager.d.ts.map +1 -1
- package/dist/types/src/auth/clients/AuthFetch.d.ts +2 -2
- package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
- package/dist/types/src/identity/ContactsManager.d.ts +13 -2
- package/dist/types/src/identity/ContactsManager.d.ts.map +1 -1
- package/dist/types/src/identity/IdentityClient.d.ts +50 -24
- package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
- package/dist/types/src/overlay-tools/LookupResolver.d.ts +15 -1
- package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
- package/dist/types/src/primitives/Hash.d.ts +21 -16
- package/dist/types/src/primitives/Hash.d.ts.map +1 -1
- package/dist/types/src/primitives/SymmetricKey.d.ts.map +1 -1
- package/dist/types/src/wallet/Wallet.interfaces.d.ts +16 -1
- package/dist/types/src/wallet/Wallet.interfaces.d.ts.map +1 -1
- package/dist/types/src/wallet/WalletClient.d.ts +1 -1
- package/dist/types/src/wallet/WalletClient.d.ts.map +1 -1
- package/dist/types/src/wallet/substrates/window.CWI.d.ts +1 -1
- package/dist/types/src/wallet/substrates/window.CWI.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +4 -4
- package/package.json +1 -1
- package/src/auth/Peer.ts +30 -20
- package/src/auth/SessionManager.ts +22 -0
- package/src/auth/__tests/Peer.test.ts +47 -1
- package/src/auth/clients/AuthFetch.ts +6 -3
- package/src/identity/ContactsManager.ts +47 -6
- package/src/identity/IdentityClient.ts +137 -53
- package/src/identity/__tests/IdentityClient.additional.test.ts +150 -1
- package/src/identity/__tests/IdentityClient.test.ts +4 -4
- package/src/overlay-tools/LookupResolver.ts +191 -77
- package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +90 -0
- package/src/primitives/Hash.ts +232 -96
- package/src/primitives/SymmetricKey.ts +145 -1
- package/src/primitives/__tests/Hash.additional.test.ts +65 -0
- package/src/primitives/__tests/Hash.test.ts +6 -1
- package/src/script/__tests/Spend.test.ts +45 -4
- package/src/transaction/MerklePath.ts +1 -1
- package/src/transaction/__tests/Transaction.test.ts +17 -0
- package/src/wallet/Wallet.interfaces.ts +16 -1
- package/src/wallet/WalletClient.ts +1 -1
- package/src/wallet/substrates/window.CWI.ts +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Transaction } from '../transaction/index.js'
|
|
2
|
+
import { Beef } from '../transaction/Beef.js'
|
|
2
3
|
import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'
|
|
3
4
|
import * as Utils from '../primitives/utils.js'
|
|
4
5
|
import { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js'
|
|
@@ -35,6 +36,8 @@ export type LookupAnswer =
|
|
|
35
36
|
beef: number[]
|
|
36
37
|
outputIndex: number
|
|
37
38
|
context?: number[]
|
|
39
|
+
/** Optional txid hint. When present, consumers can skip re-parsing beef to derive the txid. */
|
|
40
|
+
txid?: string
|
|
38
41
|
}>
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -65,7 +68,7 @@ export interface LookupQueryOptions {
|
|
|
65
68
|
*/
|
|
66
69
|
export interface LookupAnswerProgress {
|
|
67
70
|
type: 'output-list'
|
|
68
|
-
outputs: Array<{ beef: number[], outputIndex: number, context?: number[] }>
|
|
71
|
+
outputs: Array<{ beef: number[], outputIndex: number, context?: number[], txid?: string }>
|
|
69
72
|
/** Parallel array of resolved tx ids for each output (same index as `outputs`). */
|
|
70
73
|
txIds: string[]
|
|
71
74
|
/** True only for the final emission, after every in-flight host has settled. */
|
|
@@ -102,6 +105,73 @@ export const DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [
|
|
|
102
105
|
|
|
103
106
|
const MAX_TRACKER_WAIT_TIME = 5000
|
|
104
107
|
|
|
108
|
+
/** A wall-clock deadline that rejects after `timeoutMs`, optionally aborting a controller. */
|
|
109
|
+
interface Deadline {
|
|
110
|
+
/** Rejects with `Error('Request timed out')` once the timer fires. */
|
|
111
|
+
promise: Promise<never>
|
|
112
|
+
/** Clears the underlying timer. Safe to call after the timer has already fired. */
|
|
113
|
+
cancel: () => void
|
|
114
|
+
/** Returns true once the timer has fired. */
|
|
115
|
+
didTimeOut: () => boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createDeadline (timeoutMs: number, controller?: AbortController): Deadline {
|
|
119
|
+
let expired = false
|
|
120
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
121
|
+
const promise = new Promise<never>((_, reject) => {
|
|
122
|
+
timer = setTimeout(() => {
|
|
123
|
+
expired = true
|
|
124
|
+
try { controller?.abort() } catch { /* noop */ }
|
|
125
|
+
reject(new Error('Request timed out'))
|
|
126
|
+
}, timeoutMs)
|
|
127
|
+
})
|
|
128
|
+
return {
|
|
129
|
+
promise,
|
|
130
|
+
cancel: () => {
|
|
131
|
+
if (timer !== null) clearTimeout(timer)
|
|
132
|
+
},
|
|
133
|
+
didTimeOut: () => expired
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeLookupError (err: unknown, timedOut: boolean): Error {
|
|
138
|
+
if (timedOut) return new Error('Request timed out')
|
|
139
|
+
if ((err as { name?: string })?.name === 'AbortError') return new Error('Request timed out')
|
|
140
|
+
if (err instanceof Error) return err
|
|
141
|
+
return new Error(stringifyErrorValue(err))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Coerce a non-Error thrown value to a human-readable string without falling
|
|
146
|
+
* back to the default `'[object Object]'` for plain objects.
|
|
147
|
+
*/
|
|
148
|
+
function stringifyErrorValue (value: unknown): string {
|
|
149
|
+
if (value === null) return 'null'
|
|
150
|
+
if (value === undefined) return 'undefined'
|
|
151
|
+
if (typeof value === 'string') return value
|
|
152
|
+
if (typeof value === 'number') return value.toString()
|
|
153
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
154
|
+
if (typeof value === 'bigint') return value.toString()
|
|
155
|
+
const message = (value as { message?: unknown }).message
|
|
156
|
+
if (typeof message === 'string' && message.length > 0) return message
|
|
157
|
+
try {
|
|
158
|
+
return JSON.stringify(value) ?? 'Unknown error'
|
|
159
|
+
} catch {
|
|
160
|
+
return 'Unknown error'
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns true when the given Content-Type header value represents
|
|
166
|
+
* `application/octet-stream`, ignoring case and any media-type parameters
|
|
167
|
+
* (e.g. `; charset=utf-8`).
|
|
168
|
+
*/
|
|
169
|
+
function isOctetStream (contentType: string | null): boolean {
|
|
170
|
+
if (typeof contentType !== 'string') return false
|
|
171
|
+
const baseType = contentType.split(';', 1)[0].trim().toLowerCase()
|
|
172
|
+
return baseType === 'application/octet-stream'
|
|
173
|
+
}
|
|
174
|
+
|
|
105
175
|
/** Internal cache options. Kept optional to preserve drop-in compatibility. */
|
|
106
176
|
interface CacheOptions {
|
|
107
177
|
/** How long (ms) a hosts entry is considered fresh. Default 5 minutes. */
|
|
@@ -178,62 +248,89 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
|
|
|
178
248
|
}
|
|
179
249
|
|
|
180
250
|
const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
251
|
+
const deadline = createDeadline(timeout, controller)
|
|
252
|
+
|
|
253
|
+
// Hard wall-clock deadline: in some environments (e.g. browser/Electron CORS
|
|
254
|
+
// failures) the underlying fetch can stall without ever settling, and the
|
|
255
|
+
// AbortController signal alone is insufficient to make the returned promise
|
|
256
|
+
// resolve or reject. Race the fetch against a setTimeout-backed reject so
|
|
257
|
+
// the consumer-facing promise always settles within `timeout` ms.
|
|
258
|
+
const fetchPromise = this.performLookupRequest(url, question, controller?.signal)
|
|
259
|
+
// Swallow background rejection if the deadline wins first.
|
|
260
|
+
fetchPromise.catch(() => { /* noop */ })
|
|
184
261
|
|
|
185
262
|
try {
|
|
186
|
-
|
|
187
|
-
method: 'POST',
|
|
188
|
-
headers: {
|
|
189
|
-
'Content-Type': 'application/json',
|
|
190
|
-
'X-Aggregation': 'yes'
|
|
191
|
-
},
|
|
192
|
-
body: JSON.stringify({ service: question.service, query: question.query }),
|
|
193
|
-
signal: controller?.signal
|
|
194
|
-
}
|
|
195
|
-
const response: Response = await this.fetchClient(`${url}/lookup`, fco)
|
|
196
|
-
|
|
197
|
-
if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
|
|
198
|
-
if (response.headers.get('content-type') === 'application/octet-stream') {
|
|
199
|
-
const payload = await response.arrayBuffer()
|
|
200
|
-
const r = new Utils.Reader([...new Uint8Array(payload)])
|
|
201
|
-
const nOutpoints = r.readVarIntNum()
|
|
202
|
-
const outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }> = []
|
|
203
|
-
for (let i = 0; i < nOutpoints; i++) {
|
|
204
|
-
const txid = Utils.toHex(r.read(32))
|
|
205
|
-
const outputIndex = r.readVarIntNum()
|
|
206
|
-
const contextLength = r.readVarIntNum()
|
|
207
|
-
let context
|
|
208
|
-
if (contextLength > 0) {
|
|
209
|
-
context = r.read(contextLength)
|
|
210
|
-
}
|
|
211
|
-
outpoints.push({
|
|
212
|
-
txid,
|
|
213
|
-
outputIndex,
|
|
214
|
-
context
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
const beef = r.read()
|
|
218
|
-
return {
|
|
219
|
-
type: 'output-list',
|
|
220
|
-
outputs: outpoints.map(x => ({
|
|
221
|
-
outputIndex: x.outputIndex,
|
|
222
|
-
context: x.context,
|
|
223
|
-
beef: Transaction.fromBEEF(beef, x.txid).toBEEF()
|
|
224
|
-
}))
|
|
225
|
-
}
|
|
226
|
-
} else {
|
|
227
|
-
return await response.json()
|
|
228
|
-
}
|
|
263
|
+
return await Promise.race([fetchPromise, deadline.promise])
|
|
229
264
|
} catch (e) {
|
|
230
|
-
|
|
231
|
-
if ((e as { name?: string })?.name === 'AbortError') throw new Error('Request timed out')
|
|
232
|
-
throw e
|
|
265
|
+
throw normalizeLookupError(e, deadline.didTimeOut())
|
|
233
266
|
} finally {
|
|
234
|
-
|
|
267
|
+
deadline.cancel()
|
|
235
268
|
}
|
|
236
269
|
}
|
|
270
|
+
|
|
271
|
+
private async performLookupRequest (
|
|
272
|
+
url: string,
|
|
273
|
+
question: LookupQuestion,
|
|
274
|
+
signal: AbortSignal | undefined
|
|
275
|
+
): Promise<LookupAnswer> {
|
|
276
|
+
const fco: RequestInit = {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
headers: {
|
|
279
|
+
'Content-Type': 'application/json',
|
|
280
|
+
'X-Aggregation': 'yes'
|
|
281
|
+
},
|
|
282
|
+
body: JSON.stringify({ service: question.service, query: question.query }),
|
|
283
|
+
signal
|
|
284
|
+
}
|
|
285
|
+
const response: Response = await this.fetchClient(`${url}/lookup`, fco)
|
|
286
|
+
if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
|
|
287
|
+
if (isOctetStream(response.headers.get('content-type'))) {
|
|
288
|
+
return await this.parseOctetStreamLookup(response)
|
|
289
|
+
}
|
|
290
|
+
return await response.json()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Parse the aggregated octet-stream lookup response into an output-list LookupAnswer. */
|
|
294
|
+
private async parseOctetStreamLookup (response: Response): Promise<LookupAnswer> {
|
|
295
|
+
const payload = await response.arrayBuffer()
|
|
296
|
+
const r = new Utils.Reader([...new Uint8Array(payload)])
|
|
297
|
+
const nOutpoints = r.readVarIntNum()
|
|
298
|
+
const outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }> = []
|
|
299
|
+
for (let i = 0; i < nOutpoints; i++) {
|
|
300
|
+
const txid = Utils.toHex(r.read(32))
|
|
301
|
+
const outputIndex = r.readVarIntNum()
|
|
302
|
+
const contextLength = r.readVarIntNum()
|
|
303
|
+
const context = contextLength > 0 ? r.read(contextLength) : undefined
|
|
304
|
+
outpoints.push({ txid, outputIndex, context })
|
|
305
|
+
}
|
|
306
|
+
const beef = r.read()
|
|
307
|
+
const beefObj = Beef.fromBinary(beef)
|
|
308
|
+
const outputs = await this.extractAtomicOutputs(outpoints, beefObj)
|
|
309
|
+
return { type: 'output-list', outputs }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Memoize per-txid atomic BEEF extraction, yielding to the event loop between outputs. */
|
|
313
|
+
private async extractAtomicOutputs (
|
|
314
|
+
outpoints: Array<{ txid: string, outputIndex: number, context?: number[] }>,
|
|
315
|
+
beefObj: Beef
|
|
316
|
+
): Promise<Array<{ outputIndex: number, context?: number[], beef: number[], txid: string }>> {
|
|
317
|
+
const beefByTxid = new Map<string, number[]>()
|
|
318
|
+
const outputs: Array<{ outputIndex: number, context?: number[], beef: number[], txid: string }> = new Array(outpoints.length)
|
|
319
|
+
for (let idx = 0; idx < outpoints.length; idx++) {
|
|
320
|
+
const x = outpoints[idx]
|
|
321
|
+
let beefBytes = beefByTxid.get(x.txid)
|
|
322
|
+
if (beefBytes === undefined) {
|
|
323
|
+
beefBytes = beefObj.toBinaryAtomic(x.txid)
|
|
324
|
+
beefByTxid.set(x.txid, beefBytes)
|
|
325
|
+
}
|
|
326
|
+
outputs[idx] = { outputIndex: x.outputIndex, context: x.context, beef: beefBytes, txid: x.txid }
|
|
327
|
+
// Yield to event loop so UI animations and other JS don't starve.
|
|
328
|
+
if (idx > 0 && idx < outpoints.length - 1) {
|
|
329
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return outputs
|
|
333
|
+
}
|
|
237
334
|
}
|
|
238
335
|
|
|
239
336
|
/**
|
|
@@ -296,13 +393,18 @@ export default class LookupResolver {
|
|
|
296
393
|
timeout?: number,
|
|
297
394
|
options?: LookupQueryOptions
|
|
298
395
|
): Promise<LookupAnswer> {
|
|
299
|
-
let last: LookupAnswerProgress | null = null
|
|
300
396
|
// Existing fast-but-narrow contract: return at the first cumulative emission
|
|
301
397
|
// (the post-grace aggregate, or the final emission when every host settles
|
|
302
398
|
// before the grace window). Callers wanting progressive enrichment use query$().
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
399
|
+
// Take only the first emission, then explicitly close the iterator so the
|
|
400
|
+
// generator's `finally` block runs and clears any outstanding timers.
|
|
401
|
+
const iter = this.query$(question, timeout, options)[Symbol.asyncIterator]()
|
|
402
|
+
let last: LookupAnswerProgress | null = null
|
|
403
|
+
try {
|
|
404
|
+
const { value, done } = await iter.next()
|
|
405
|
+
if (done !== true && value != null) last = value
|
|
406
|
+
} finally {
|
|
407
|
+
await iter.return?.(undefined)
|
|
306
408
|
}
|
|
307
409
|
return {
|
|
308
410
|
type: 'output-list',
|
|
@@ -401,31 +503,16 @@ export default class LookupResolver {
|
|
|
401
503
|
let graceFired = false
|
|
402
504
|
let emittedOnce = false
|
|
403
505
|
|
|
404
|
-
const beefKey = (beef: number[] | undefined): string => {
|
|
405
|
-
if (typeof beef !== 'object' || beef == null) return ''
|
|
406
|
-
return beef.join(',')
|
|
407
|
-
}
|
|
408
|
-
|
|
409
506
|
const mergeAnswer = (answer: LookupAnswer): boolean => {
|
|
410
507
|
let added = false
|
|
508
|
+
const now = Date.now()
|
|
411
509
|
for (const output of answer.outputs) {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
if (typeof memo !== 'object' || memo === null || memo.expiresAt <= now) {
|
|
416
|
-
try {
|
|
417
|
-
const txId = Transaction.fromBEEF(output.beef).id('hex')
|
|
418
|
-
memo = { txId, expiresAt: now + this.txMemoTtlMs }
|
|
419
|
-
if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
|
|
420
|
-
this.txMemo.set(keyForBeef, memo)
|
|
421
|
-
} catch {
|
|
422
|
-
continue
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
const uniqKey = `${memo.txId}.${output.outputIndex}`
|
|
510
|
+
const txId = this.resolveTxIdForOutput(output, now)
|
|
511
|
+
if (txId === null) continue
|
|
512
|
+
const uniqKey = `${txId}.${output.outputIndex}`
|
|
426
513
|
if (!outputsMap.has(uniqKey)) {
|
|
427
514
|
outputsMap.set(uniqKey, output)
|
|
428
|
-
txIds.push(
|
|
515
|
+
txIds.push(txId)
|
|
429
516
|
added = true
|
|
430
517
|
}
|
|
431
518
|
}
|
|
@@ -603,7 +690,7 @@ export default class LookupResolver {
|
|
|
603
690
|
resolve([...allHosts])
|
|
604
691
|
}
|
|
605
692
|
})
|
|
606
|
-
.catch(() => { /* tracker
|
|
693
|
+
.catch(() => { /* tracker failure tracked in reputation */ })
|
|
607
694
|
.finally(() => {
|
|
608
695
|
pending--
|
|
609
696
|
if (pending === 0 && !resolved) {
|
|
@@ -615,7 +702,34 @@ export default class LookupResolver {
|
|
|
615
702
|
})
|
|
616
703
|
}
|
|
617
704
|
|
|
618
|
-
/**
|
|
705
|
+
/**
|
|
706
|
+
* Resolve a txid for an aggregated lookup output. Uses the threaded-through `output.txid`
|
|
707
|
+
* fast path when present; otherwise memoizes Transaction.fromBEEF(beef).id('hex') keyed by
|
|
708
|
+
* the BEEF byte sequence. Returns null when the BEEF is unparseable.
|
|
709
|
+
*/
|
|
710
|
+
private resolveTxIdForOutput (
|
|
711
|
+
output: { txid?: string, beef: number[], outputIndex: number, context?: number[] },
|
|
712
|
+
now: number
|
|
713
|
+
): string | null {
|
|
714
|
+
if (typeof output.txid === 'string' && output.txid.length > 0) {
|
|
715
|
+
return output.txid
|
|
716
|
+
}
|
|
717
|
+
const keyForBeef = Array.isArray(output.beef) ? output.beef.join(',') : ''
|
|
718
|
+
const memo = this.txMemo.get(keyForBeef)
|
|
719
|
+
if (typeof memo === 'object' && memo !== null && memo.expiresAt > now) {
|
|
720
|
+
return memo.txId
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
const txId = Transaction.fromBEEF(output.beef).id('hex')
|
|
724
|
+
if (this.txMemo.size > 4096) this.evictOldest(this.txMemo)
|
|
725
|
+
this.txMemo.set(keyForBeef, { txId, expiresAt: now + this.txMemoTtlMs })
|
|
726
|
+
return txId
|
|
727
|
+
} catch {
|
|
728
|
+
return null
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Evict an arbitrary "oldest" entry from a Map (iteration order). */
|
|
619
733
|
private evictOldest<T>(m: Map<string, T>): void {
|
|
620
734
|
const firstKey = m.keys().next().value
|
|
621
735
|
if (firstKey !== undefined) m.delete(firstKey)
|
|
@@ -472,6 +472,22 @@ describe('LookupResolver – additional coverage', () => {
|
|
|
472
472
|
).rejects.toThrow('Request timed out')
|
|
473
473
|
})
|
|
474
474
|
|
|
475
|
+
it('rejects within the timeout even when fetch never settles', async () => {
|
|
476
|
+
// Simulate the CORS-blocked / hung-preflight case where the fetch promise
|
|
477
|
+
// does not honor the AbortController signal and never settles.
|
|
478
|
+
const neverFetch = jest.fn().mockImplementation(
|
|
479
|
+
() => new Promise(() => { /* never resolves */ })
|
|
480
|
+
)
|
|
481
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(neverFetch, true)
|
|
482
|
+
const start = Date.now()
|
|
483
|
+
await expect(
|
|
484
|
+
facilitator.lookup('http://host', { service: 'ls_test', query: {} }, 50)
|
|
485
|
+
).rejects.toThrow('Request timed out')
|
|
486
|
+
const elapsed = Date.now() - start
|
|
487
|
+
// Allow generous slack but must complete well before any global jest timeout.
|
|
488
|
+
expect(elapsed).toBeLessThan(2000)
|
|
489
|
+
})
|
|
490
|
+
|
|
475
491
|
it('parses octet-stream responses', async () => {
|
|
476
492
|
// Build a minimal octet-stream payload: 1 outpoint, then BEEF bytes
|
|
477
493
|
const tx = new Transaction(
|
|
@@ -506,6 +522,36 @@ describe('LookupResolver – additional coverage', () => {
|
|
|
506
522
|
expect(result.outputs[0].outputIndex).toBe(0)
|
|
507
523
|
})
|
|
508
524
|
|
|
525
|
+
it('parses octet-stream responses when header carries parameters or differing case', async () => {
|
|
526
|
+
const tx = new Transaction(
|
|
527
|
+
1,
|
|
528
|
+
[],
|
|
529
|
+
[{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
|
|
530
|
+
0
|
|
531
|
+
)
|
|
532
|
+
const beef = tx.toBEEF()
|
|
533
|
+
const txid = Buffer.from(tx.id('hex'), 'hex')
|
|
534
|
+
const payload = Buffer.concat([
|
|
535
|
+
Buffer.from([0x01]), txid, Buffer.from([0x00]), Buffer.from([0x00]), Buffer.from(beef)
|
|
536
|
+
])
|
|
537
|
+
|
|
538
|
+
for (const header of [
|
|
539
|
+
'application/octet-stream; charset=utf-8',
|
|
540
|
+
'Application/Octet-Stream',
|
|
541
|
+
' application/octet-stream '
|
|
542
|
+
]) {
|
|
543
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
544
|
+
ok: true,
|
|
545
|
+
headers: { get: () => header },
|
|
546
|
+
arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
|
|
547
|
+
})
|
|
548
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
549
|
+
const result = await facilitator.lookup('https://host', { service: 'ls_test', query: {} })
|
|
550
|
+
expect(result.type).toBe('output-list')
|
|
551
|
+
expect(result.outputs).toHaveLength(1)
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
|
|
509
555
|
it('parses octet-stream responses with context bytes', async () => {
|
|
510
556
|
const tx = new Transaction(
|
|
511
557
|
1,
|
|
@@ -547,6 +593,50 @@ describe('LookupResolver – additional coverage', () => {
|
|
|
547
593
|
).rejects.toThrow('DNS failure')
|
|
548
594
|
})
|
|
549
595
|
|
|
596
|
+
it('normalises string thrown values from fetch', async () => {
|
|
597
|
+
const mockFetch = jest.fn().mockRejectedValue('boom')
|
|
598
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
599
|
+
await expect(
|
|
600
|
+
facilitator.lookup('https://host', { service: 'ls_test', query: {} })
|
|
601
|
+
).rejects.toThrow('boom')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('normalises object-with-message thrown values from fetch', async () => {
|
|
605
|
+
const mockFetch = jest.fn().mockRejectedValue({ message: 'object boom' })
|
|
606
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
607
|
+
await expect(
|
|
608
|
+
facilitator.lookup('https://host', { service: 'ls_test', query: {} })
|
|
609
|
+
).rejects.toThrow('object boom')
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('normalises plain-object thrown values via JSON', async () => {
|
|
613
|
+
const mockFetch = jest.fn().mockRejectedValue({ code: 42 })
|
|
614
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
615
|
+
await expect(
|
|
616
|
+
facilitator.lookup('https://host', { service: 'ls_test', query: {} })
|
|
617
|
+
).rejects.toThrow('{"code":42}')
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('normalises number/boolean/null thrown values from fetch', async () => {
|
|
621
|
+
for (const value of [123, true, null]) {
|
|
622
|
+
const mockFetch = jest.fn().mockRejectedValue(value)
|
|
623
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
624
|
+
await expect(
|
|
625
|
+
facilitator.lookup('https://host', { service: 'ls_test', query: {} })
|
|
626
|
+
).rejects.toThrow(String(value))
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('normalises circular thrown values without crashing', async () => {
|
|
631
|
+
const circular: { self?: unknown } = {}
|
|
632
|
+
circular.self = circular
|
|
633
|
+
const mockFetch = jest.fn().mockRejectedValue(circular)
|
|
634
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
635
|
+
await expect(
|
|
636
|
+
facilitator.lookup('https://host', { service: 'ls_test', query: {} })
|
|
637
|
+
).rejects.toThrow('Unknown error')
|
|
638
|
+
})
|
|
639
|
+
|
|
550
640
|
it('sends correct request body to /lookup endpoint', async () => {
|
|
551
641
|
const mockFetch = jest.fn().mockResolvedValue({
|
|
552
642
|
ok: true,
|