@bsv/sdk 2.0.11 → 2.0.13

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.
Files changed (106) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
  3. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  4. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
  5. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  6. package/dist/cjs/src/overlay-tools/HostReputationTracker.js +21 -13
  7. package/dist/cjs/src/overlay-tools/HostReputationTracker.js.map +1 -1
  8. package/dist/cjs/src/primitives/PrivateKey.js +3 -3
  9. package/dist/cjs/src/primitives/PrivateKey.js.map +1 -1
  10. package/dist/cjs/src/script/Spend.js +17 -9
  11. package/dist/cjs/src/script/Spend.js.map +1 -1
  12. package/dist/cjs/src/storage/StorageDownloader.js +6 -6
  13. package/dist/cjs/src/storage/StorageDownloader.js.map +1 -1
  14. package/dist/cjs/src/storage/StorageUtils.js +1 -1
  15. package/dist/cjs/src/storage/StorageUtils.js.map +1 -1
  16. package/dist/cjs/src/transaction/MerklePath.js +168 -27
  17. package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
  18. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  19. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
  20. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  21. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
  22. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  23. package/dist/esm/src/overlay-tools/HostReputationTracker.js +21 -13
  24. package/dist/esm/src/overlay-tools/HostReputationTracker.js.map +1 -1
  25. package/dist/esm/src/primitives/PrivateKey.js +3 -3
  26. package/dist/esm/src/primitives/PrivateKey.js.map +1 -1
  27. package/dist/esm/src/script/Spend.js +17 -9
  28. package/dist/esm/src/script/Spend.js.map +1 -1
  29. package/dist/esm/src/storage/StorageDownloader.js +6 -6
  30. package/dist/esm/src/storage/StorageDownloader.js.map +1 -1
  31. package/dist/esm/src/storage/StorageUtils.js +1 -1
  32. package/dist/esm/src/storage/StorageUtils.js.map +1 -1
  33. package/dist/esm/src/transaction/MerklePath.js +168 -27
  34. package/dist/esm/src/transaction/MerklePath.js.map +1 -1
  35. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  36. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
  37. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
  38. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
  39. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
  40. package/dist/types/src/overlay-tools/HostReputationTracker.d.ts.map +1 -1
  41. package/dist/types/src/script/Spend.d.ts.map +1 -1
  42. package/dist/types/src/transaction/MerklePath.d.ts +27 -0
  43. package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
  44. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  45. package/dist/umd/bundle.js +3 -3
  46. package/dist/umd/bundle.js.map +1 -1
  47. package/docs/reference/storage.md +1 -1
  48. package/docs/reference/transaction.md +40 -0
  49. package/package.json +1 -1
  50. package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
  51. package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
  52. package/src/auth/utils/__tests/validateCertificates.test.ts +12 -9
  53. package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
  54. package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
  55. package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
  56. package/src/kvstore/__tests/LocalKVStore.test.ts +4 -6
  57. package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
  58. package/src/overlay-tools/HostReputationTracker.ts +17 -14
  59. package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
  60. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
  61. package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
  62. package/src/primitives/PrivateKey.ts +3 -3
  63. package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
  64. package/src/primitives/__tests/Curve.additional.test.ts +208 -0
  65. package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
  66. package/src/primitives/__tests/Hash.additional.test.ts +59 -0
  67. package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
  68. package/src/primitives/__tests/Point.additional.test.ts +503 -0
  69. package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
  70. package/src/primitives/__tests/Random.additional.test.ts +262 -0
  71. package/src/primitives/__tests/Signature.test.ts +333 -0
  72. package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
  73. package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
  74. package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
  75. package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
  76. package/src/script/Spend.ts +19 -11
  77. package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
  78. package/src/script/__tests/Script.additional.test.ts +100 -0
  79. package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
  80. package/src/script/__tests/Spend.additional.test.ts +837 -0
  81. package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
  82. package/src/storage/StorageDownloader.ts +6 -6
  83. package/src/storage/StorageUtils.ts +1 -1
  84. package/src/transaction/MerklePath.ts +196 -36
  85. package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
  86. package/src/transaction/__tests/Broadcaster.test.ts +159 -0
  87. package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
  88. package/src/transaction/__tests/MerklePath.test.ts +232 -21
  89. package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
  90. package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
  91. package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
  92. package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
  93. package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
  94. package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
  95. package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
  96. package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
  97. package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
  98. package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
  99. package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
  100. package/src/wallet/__tests/WERR.test.ts +212 -0
  101. package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
  102. package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
  103. package/src/wallet/__tests/WalletError.test.ts +290 -0
  104. package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
  105. package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
  106. package/src/wallet/substrates/__tests/HTTPWalletWire.test.ts +273 -0
@@ -0,0 +1,612 @@
1
+ import LookupResolver, {
2
+ HTTPSOverlayLookupFacilitator,
3
+ DEFAULT_SLAP_TRACKERS,
4
+ DEFAULT_TESTNET_SLAP_TRACKERS,
5
+ LookupQuestion
6
+ } from '../LookupResolver'
7
+ import { getOverlayHostReputationTracker, HostReputationTracker } from '../HostReputationTracker'
8
+ import OverlayAdminTokenTemplate from '../../overlay-tools/OverlayAdminTokenTemplate'
9
+ import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet'
10
+ import { PrivateKey } from '../../primitives/index'
11
+ import { Transaction } from '../../transaction/index'
12
+ import { LockingScript } from '../../script/index'
13
+
14
+ const mockFacilitator = {
15
+ lookup: jest.fn()
16
+ }
17
+
18
+ // --------------------------------------------------------------------------
19
+ // Sample BEEFs for use in tests
20
+ // --------------------------------------------------------------------------
21
+
22
+ const sampleBeef1 = new Transaction(
23
+ 1,
24
+ [],
25
+ [{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
26
+ 0
27
+ ).toBEEF()
28
+
29
+ const sampleBeef2 = new Transaction(
30
+ 1,
31
+ [],
32
+ [{ lockingScript: LockingScript.fromHex('88'), satoshis: 2 }],
33
+ 0
34
+ ).toBEEF()
35
+
36
+ // --------------------------------------------------------------------------
37
+ // Helper: build a SLAP token transaction pointing at a given host/service
38
+ // --------------------------------------------------------------------------
39
+
40
+ async function makeSlapTx (
41
+ keyScalar: number,
42
+ domain: string,
43
+ service: string
44
+ ): Promise<Transaction> {
45
+ const key = new PrivateKey(keyScalar)
46
+ const wallet = new CompletedProtoWallet(key)
47
+ const lib = new OverlayAdminTokenTemplate(wallet)
48
+ const script = await lib.lock('SLAP', domain, service)
49
+ return new Transaction(1, [], [{ lockingScript: script, satoshis: 1 }], 0)
50
+ }
51
+
52
+ // --------------------------------------------------------------------------
53
+ // Suite
54
+ // --------------------------------------------------------------------------
55
+
56
+ describe('LookupResolver – additional coverage', () => {
57
+ const globalTracker = getOverlayHostReputationTracker()
58
+
59
+ beforeEach(() => {
60
+ mockFacilitator.lookup.mockReset()
61
+ globalTracker.reset()
62
+ })
63
+
64
+ // -----------------------------------------------------------------------
65
+ // networkPreset branches
66
+ // -----------------------------------------------------------------------
67
+
68
+ describe('networkPreset', () => {
69
+ it('uses DEFAULT_SLAP_TRACKERS for mainnet preset (default)', () => {
70
+ const r = new LookupResolver({ facilitator: mockFacilitator })
71
+ // Access private via cast
72
+ expect((r as any).slapTrackers).toEqual(DEFAULT_SLAP_TRACKERS)
73
+ expect((r as any).networkPreset).toBe('mainnet')
74
+ })
75
+
76
+ it('uses DEFAULT_TESTNET_SLAP_TRACKERS for testnet preset', () => {
77
+ const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'testnet' })
78
+ expect((r as any).slapTrackers).toEqual(DEFAULT_TESTNET_SLAP_TRACKERS)
79
+ expect((r as any).networkPreset).toBe('testnet')
80
+ })
81
+
82
+ it('uses localhost for local preset in ls_slap query', async () => {
83
+ mockFacilitator.lookup.mockResolvedValueOnce({
84
+ type: 'output-list',
85
+ outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
86
+ })
87
+
88
+ const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'local' })
89
+ await r.query({ service: 'ls_slap', query: {} })
90
+
91
+ expect(mockFacilitator.lookup.mock.calls[0][0]).toBe('http://localhost:8080')
92
+ })
93
+
94
+ it('uses localhost for local preset on non-slap service query', async () => {
95
+ mockFacilitator.lookup.mockResolvedValueOnce({
96
+ type: 'output-list',
97
+ outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
98
+ })
99
+
100
+ const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'local' })
101
+ await r.query({ service: 'ls_bar', query: {} })
102
+
103
+ expect(mockFacilitator.lookup.mock.calls[0][0]).toBe('http://localhost:8080')
104
+ })
105
+
106
+ it('includes "testnet" in error message for testnet preset', async () => {
107
+ mockFacilitator.lookup.mockResolvedValueOnce({
108
+ type: 'output-list',
109
+ outputs: []
110
+ })
111
+
112
+ const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'testnet' })
113
+ await expect(r.query({ service: 'ls_missing', query: {} })).rejects.toThrow(
114
+ 'No competent testnet hosts found'
115
+ )
116
+ })
117
+
118
+ it('uses custom slapTrackers even when preset is testnet', () => {
119
+ const r = new LookupResolver({
120
+ facilitator: mockFacilitator,
121
+ networkPreset: 'testnet',
122
+ slapTrackers: ['https://custom.tracker']
123
+ })
124
+ expect((r as any).slapTrackers).toEqual(['https://custom.tracker'])
125
+ })
126
+ })
127
+
128
+ // -----------------------------------------------------------------------
129
+ // hostOverrides validation
130
+ // -----------------------------------------------------------------------
131
+
132
+ describe('hostOverrides validation', () => {
133
+ it('throws when hostOverride service name does not start with ls_', () => {
134
+ expect(() => new LookupResolver({
135
+ facilitator: mockFacilitator,
136
+ hostOverrides: { badServiceName: ['https://host.com'] }
137
+ })).toThrow('Host override service names must start with "ls_": badServiceName')
138
+ })
139
+
140
+ it('does not throw for valid ls_ prefixed hostOverride keys', () => {
141
+ expect(() => new LookupResolver({
142
+ facilitator: mockFacilitator,
143
+ hostOverrides: { ls_valid: ['https://host.com'] }
144
+ })).not.toThrow()
145
+ })
146
+ })
147
+
148
+ // -----------------------------------------------------------------------
149
+ // reputationStorage options
150
+ // -----------------------------------------------------------------------
151
+
152
+ describe('reputationStorage', () => {
153
+ it('accepts reputationStorage: "localStorage" option', () => {
154
+ // Simply verify construction does not throw
155
+ expect(() => new LookupResolver({
156
+ facilitator: mockFacilitator,
157
+ reputationStorage: 'localStorage'
158
+ })).not.toThrow()
159
+ })
160
+
161
+ it('accepts reputationStorage as a custom key-value store object', async () => {
162
+ const store = new Map<string, string>()
163
+ const kvStore = {
164
+ get: (key: string): string | null => store.get(key) ?? null,
165
+ set: (key: string, value: string): void => { store.set(key, value) }
166
+ }
167
+
168
+ mockFacilitator.lookup.mockResolvedValueOnce({
169
+ type: 'output-list',
170
+ outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
171
+ })
172
+
173
+ const r = new LookupResolver({
174
+ facilitator: mockFacilitator,
175
+ reputationStorage: kvStore,
176
+ hostOverrides: { ls_test: ['https://host.com'] }
177
+ })
178
+
179
+ await r.query({ service: 'ls_test', query: {} })
180
+ // Reputation data should have been written to the store
181
+ expect(store.size).toBeGreaterThan(0)
182
+ })
183
+ })
184
+
185
+ // -----------------------------------------------------------------------
186
+ // Cache tuning options
187
+ // -----------------------------------------------------------------------
188
+
189
+ describe('cache configuration', () => {
190
+ it('respects custom hostsTtlMs', () => {
191
+ const r = new LookupResolver({
192
+ facilitator: mockFacilitator,
193
+ cache: { hostsTtlMs: 999 }
194
+ })
195
+ expect((r as any).hostsTtlMs).toBe(999)
196
+ })
197
+
198
+ it('respects custom hostsMaxEntries', () => {
199
+ const r = new LookupResolver({
200
+ facilitator: mockFacilitator,
201
+ cache: { hostsMaxEntries: 5 }
202
+ })
203
+ expect((r as any).hostsMaxEntries).toBe(5)
204
+ })
205
+
206
+ it('respects custom txMemoTtlMs', () => {
207
+ const r = new LookupResolver({
208
+ facilitator: mockFacilitator,
209
+ cache: { txMemoTtlMs: 123 }
210
+ })
211
+ expect((r as any).txMemoTtlMs).toBe(123)
212
+ })
213
+
214
+ it('uses stale hosts from cache while refreshing in the background', async () => {
215
+ const slapTx = await makeSlapTx(42, 'https://cached.host', 'ls_cached')
216
+
217
+ // First call: populates the cache
218
+ mockFacilitator.lookup
219
+ .mockResolvedValueOnce({
220
+ type: 'output-list',
221
+ outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
222
+ })
223
+ .mockResolvedValueOnce({
224
+ type: 'output-list',
225
+ outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
226
+ })
227
+
228
+ const r = new LookupResolver({
229
+ facilitator: mockFacilitator,
230
+ slapTrackers: ['https://mock.slap'],
231
+ cache: { hostsTtlMs: 0 } // immediate expiry to force stale path
232
+ })
233
+
234
+ await r.query({ service: 'ls_cached', query: {} })
235
+
236
+ // Second call: cache entry is now stale (ttl=0), should use stale hosts
237
+ // while kicking off a background refresh
238
+ mockFacilitator.lookup.mockResolvedValue({
239
+ type: 'output-list',
240
+ outputs: [{ beef: sampleBeef2, outputIndex: 1 }]
241
+ })
242
+
243
+ const res2 = await r.query({ service: 'ls_cached', query: {} })
244
+
245
+ expect(res2.type).toBe('output-list')
246
+ })
247
+
248
+ it('evicts oldest cache entry when hostsMaxEntries is reached', async () => {
249
+ const r = new LookupResolver({
250
+ facilitator: mockFacilitator,
251
+ slapTrackers: ['https://mock.slap'],
252
+ cache: { hostsMaxEntries: 2 }
253
+ })
254
+
255
+ const hostsCache: Map<string, any> = (r as any).hostsCache
256
+
257
+ // Manually populate the cache to its limit
258
+ hostsCache.set('ls_service1', { hosts: ['https://h1.com'], expiresAt: Date.now() + 60000 })
259
+ hostsCache.set('ls_service2', { hosts: ['https://h2.com'], expiresAt: Date.now() + 60000 })
260
+
261
+ expect(hostsCache.size).toBe(2)
262
+
263
+ // Force a refresh for a third service which should evict ls_service1
264
+ mockFacilitator.lookup.mockResolvedValueOnce({
265
+ type: 'output-list',
266
+ outputs: []
267
+ })
268
+
269
+ // Trigger cache refresh via refreshHosts indirectly
270
+ const slapTx = await makeSlapTx(42, 'https://h3.com', 'ls_service3')
271
+ mockFacilitator.lookup.mockResolvedValue({
272
+ type: 'output-list',
273
+ outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
274
+ })
275
+
276
+ try {
277
+ await r.query({ service: 'ls_service3', query: {} })
278
+ } catch {
279
+ // might fail if no competent hosts for the actual lookup
280
+ }
281
+
282
+ // Cache size should not exceed hostsMaxEntries + 1 (the new entry)
283
+ expect(hostsCache.size).toBeLessThanOrEqual(3)
284
+ })
285
+
286
+ it('coalesces concurrent in-flight host resolution requests for the same service', async () => {
287
+ const slapTx = await makeSlapTx(42, 'https://coalesce.host', 'ls_coalesce')
288
+
289
+ let resolveSlap: (v: any) => void
290
+ const slapPromise = new Promise<any>((res) => { resolveSlap = res })
291
+
292
+ mockFacilitator.lookup
293
+ .mockReturnValueOnce(slapPromise) // slap tracker – delayed
294
+ .mockResolvedValue({
295
+ type: 'output-list',
296
+ outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
297
+ })
298
+
299
+ const r = new LookupResolver({
300
+ facilitator: mockFacilitator,
301
+ slapTrackers: ['https://mock.slap']
302
+ })
303
+
304
+ // Fire two concurrent queries before slap resolves
305
+ const p1 = r.query({ service: 'ls_coalesce', query: {} })
306
+ const p2 = r.query({ service: 'ls_coalesce', query: {} })
307
+
308
+ // Resolve the SLAP tracker
309
+ resolveSlap!({
310
+ type: 'output-list',
311
+ outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
312
+ })
313
+
314
+ const [res1, res2] = await Promise.all([p1, p2])
315
+ expect(res1.type).toBe('output-list')
316
+ expect(res2.type).toBe('output-list')
317
+ })
318
+ })
319
+
320
+ // -----------------------------------------------------------------------
321
+ // txMemo eviction at 4096 entries
322
+ // -----------------------------------------------------------------------
323
+
324
+ describe('txMemo eviction', () => {
325
+ it('evicts the oldest txMemo entry when size exceeds 4096', async () => {
326
+ mockFacilitator.lookup.mockResolvedValue({
327
+ type: 'output-list',
328
+ outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
329
+ })
330
+
331
+ const r = new LookupResolver({
332
+ facilitator: mockFacilitator,
333
+ hostOverrides: { ls_memo: ['https://memo.host'] }
334
+ })
335
+
336
+ const txMemo: Map<string, any> = (r as any).txMemo
337
+
338
+ // Pre-fill to just over 4096 entries
339
+ for (let i = 0; i < 4097; i++) {
340
+ txMemo.set(`key${i}`, { txId: `tx${i}`, expiresAt: Date.now() + 60000 })
341
+ }
342
+
343
+ expect(txMemo.size).toBe(4097)
344
+
345
+ // Query to trigger the eviction path
346
+ await r.query({ service: 'ls_memo', query: {} })
347
+
348
+ // After query the eviction should have fired, size should be <= 4097 + 1 - 1 = 4097
349
+ // (evict oldest then set new)
350
+ expect(txMemo.size).toBeLessThanOrEqual(4098)
351
+ })
352
+ })
353
+
354
+ // -----------------------------------------------------------------------
355
+ // prepareHostsForQuery – all-backoff error
356
+ // -----------------------------------------------------------------------
357
+
358
+ describe('prepareHostsForQuery – backoff error', () => {
359
+ it('throws when all competent hosts are in backoff', async () => {
360
+ const slapTx = await makeSlapTx(42, 'https://backing.off', 'ls_backoff_test')
361
+
362
+ mockFacilitator.lookup.mockResolvedValueOnce({
363
+ type: 'output-list',
364
+ outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
365
+ })
366
+
367
+ const r = new LookupResolver({
368
+ facilitator: mockFacilitator,
369
+ slapTrackers: ['https://mock.slap']
370
+ })
371
+
372
+ // Poison the reputation of the host so it enters backoff
373
+ const tracker: HostReputationTracker = (r as any).hostReputation
374
+ for (let i = 0; i < 5; i++) {
375
+ tracker.recordFailure('https://backing.off', 'connection refused')
376
+ }
377
+
378
+ // Now the host is deeply in backoff
379
+ // prepareHostsForQuery throws with context = 'lookup service ls_backoff_test'
380
+ await expect(r.query({ service: 'ls_backoff_test', query: {} })).rejects.toThrow(
381
+ 'All lookup service ls_backoff_test hosts are backing off'
382
+ )
383
+ })
384
+
385
+ it('throws when all SLAP trackers are in backoff', async () => {
386
+ const r = new LookupResolver({
387
+ facilitator: mockFacilitator,
388
+ slapTrackers: ['https://backed.off.slap']
389
+ })
390
+
391
+ // Put the SLAP tracker into deep backoff
392
+ const tracker: HostReputationTracker = (r as any).hostReputation
393
+ for (let i = 0; i < 5; i++) {
394
+ tracker.recordFailure('https://backed.off.slap', 'connection refused')
395
+ }
396
+
397
+ await expect(r.query({ service: 'ls_any', query: {} })).rejects.toThrow(
398
+ 'All SLAP trackers hosts are backing off'
399
+ )
400
+ })
401
+ })
402
+
403
+ // -----------------------------------------------------------------------
404
+ // additionalHosts – deduplication
405
+ // -----------------------------------------------------------------------
406
+
407
+ describe('additionalHosts', () => {
408
+ it('does not duplicate a host that already appears in competentHosts', async () => {
409
+ // Override + additional pointing at same host
410
+ mockFacilitator.lookup.mockResolvedValueOnce({
411
+ type: 'output-list',
412
+ outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
413
+ })
414
+
415
+ const r = new LookupResolver({
416
+ facilitator: mockFacilitator,
417
+ hostOverrides: { ls_dup: ['https://same.host'] },
418
+ additionalHosts: { ls_dup: ['https://same.host', 'https://extra.host'] }
419
+ })
420
+
421
+ await r.query({ service: 'ls_dup', query: {} })
422
+
423
+ // same.host should appear exactly once in the calls
424
+ const calledHosts = mockFacilitator.lookup.mock.calls.map((c: any[]) => c[0])
425
+ const sameHostCalls = calledHosts.filter((h: string) => h === 'https://same.host')
426
+ expect(sameHostCalls).toHaveLength(1)
427
+ })
428
+ })
429
+
430
+ // -----------------------------------------------------------------------
431
+ // HTTPSOverlayLookupFacilitator
432
+ // -----------------------------------------------------------------------
433
+
434
+ describe('HTTPSOverlayLookupFacilitator', () => {
435
+ it('throws when no fetch implementation is available', () => {
436
+ expect(() => new HTTPSOverlayLookupFacilitator(null as any)).toThrow(
437
+ 'HTTPSOverlayLookupFacilitator requires a fetch implementation'
438
+ )
439
+ })
440
+
441
+ it('allows HTTP URLs when allowHTTP is true', async () => {
442
+ const mockFetch = jest.fn().mockResolvedValue({
443
+ ok: true,
444
+ headers: { get: () => 'application/json' },
445
+ json: async () => ({ type: 'output-list', outputs: [] })
446
+ })
447
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
448
+ const result = await facilitator.lookup('http://localhost:8080', { service: 'ls_test', query: {} })
449
+ expect(result).toEqual({ type: 'output-list', outputs: [] })
450
+ })
451
+
452
+ it('handles HTTP error responses by throwing', async () => {
453
+ const mockFetch = jest.fn().mockResolvedValue({
454
+ ok: false,
455
+ status: 503,
456
+ headers: { get: () => 'application/json' },
457
+ json: async () => ({})
458
+ })
459
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
460
+ await expect(
461
+ facilitator.lookup('http://host', { service: 'ls_test', query: {} })
462
+ ).rejects.toThrow('Failed to facilitate lookup (HTTP 503)')
463
+ })
464
+
465
+ it('normalises AbortError to "Request timed out"', async () => {
466
+ const abortError = new Error('aborted')
467
+ abortError.name = 'AbortError'
468
+ const mockFetch = jest.fn().mockRejectedValue(abortError)
469
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
470
+ await expect(
471
+ facilitator.lookup('http://host', { service: 'ls_test', query: {} }, 1)
472
+ ).rejects.toThrow('Request timed out')
473
+ })
474
+
475
+ it('parses octet-stream responses', async () => {
476
+ // Build a minimal octet-stream payload: 1 outpoint, then BEEF bytes
477
+ const tx = new Transaction(
478
+ 1,
479
+ [],
480
+ [{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
481
+ 0
482
+ )
483
+ const beef = tx.toBEEF()
484
+
485
+ // Build the payload: varint(1) + txid(32) + varint(outputIndex) + varint(contextLen=0) + beef
486
+ // The source reads 32 bytes and calls Utils.toHex(), so the bytes must be in big-endian
487
+ // (same order as tx.id('hex')) so the resulting hex matches what Transaction.fromBEEF expects.
488
+ const txid = Buffer.from(tx.id('hex'), 'hex')
489
+ const nOutpoints = Buffer.from([0x01]) // varint 1
490
+ const outputIndex = Buffer.from([0x00]) // varint 0
491
+ const contextLen = Buffer.from([0x00]) // varint 0
492
+ const beefBuf = Buffer.from(beef)
493
+ const payload = Buffer.concat([nOutpoints, txid, outputIndex, contextLen, beefBuf])
494
+
495
+ const mockFetch = jest.fn().mockResolvedValue({
496
+ ok: true,
497
+ headers: { get: () => 'application/octet-stream' },
498
+ arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
499
+ })
500
+
501
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
502
+ const result = await facilitator.lookup('http://host', { service: 'ls_test', query: {} })
503
+
504
+ expect(result.type).toBe('output-list')
505
+ expect(result.outputs).toHaveLength(1)
506
+ expect(result.outputs[0].outputIndex).toBe(0)
507
+ })
508
+
509
+ it('parses octet-stream responses with context bytes', async () => {
510
+ const tx = new Transaction(
511
+ 1,
512
+ [],
513
+ [{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
514
+ 0
515
+ )
516
+ const beef = tx.toBEEF()
517
+ // Use big-endian byte order so Utils.toHex(r.read(32)) produces the same hex as tx.id('hex')
518
+ const txid = Buffer.from(tx.id('hex'), 'hex')
519
+
520
+ // payload: 1 outpoint, with context of 2 bytes [0xde, 0xad]
521
+ const nOutpoints = Buffer.from([0x01])
522
+ const outputIndex = Buffer.from([0x00])
523
+ const contextLen = Buffer.from([0x02])
524
+ const contextBytes = Buffer.from([0xde, 0xad])
525
+ const beefBuf = Buffer.from(beef)
526
+ const payload = Buffer.concat([nOutpoints, txid, outputIndex, contextLen, contextBytes, beefBuf])
527
+
528
+ const mockFetch = jest.fn().mockResolvedValue({
529
+ ok: true,
530
+ headers: { get: () => 'application/octet-stream' },
531
+ arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
532
+ })
533
+
534
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
535
+ const result = await facilitator.lookup('http://host', { service: 'ls_test', query: {} })
536
+
537
+ expect(result.type).toBe('output-list')
538
+ expect(result.outputs[0].context).toBeDefined()
539
+ expect(Array.from(result.outputs[0].context!)).toEqual([0xde, 0xad])
540
+ })
541
+
542
+ it('re-throws non-AbortError errors from fetch', async () => {
543
+ const mockFetch = jest.fn().mockRejectedValue(new Error('DNS failure'))
544
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
545
+ await expect(
546
+ facilitator.lookup('http://host', { service: 'ls_test', query: {} })
547
+ ).rejects.toThrow('DNS failure')
548
+ })
549
+
550
+ it('sends correct request body to /lookup endpoint', async () => {
551
+ const mockFetch = jest.fn().mockResolvedValue({
552
+ ok: true,
553
+ headers: { get: () => 'application/json' },
554
+ json: async () => ({ type: 'output-list', outputs: [] })
555
+ })
556
+ const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
557
+ const question = { service: 'ls_test', query: { filter: 'abc' } }
558
+ await facilitator.lookup('http://host', question)
559
+
560
+ const calledUrl: string = mockFetch.mock.calls[0][0]
561
+ expect(calledUrl).toBe('http://host/lookup')
562
+
563
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body)
564
+ expect(body).toEqual({ service: 'ls_test', query: { filter: 'abc' } })
565
+ })
566
+ })
567
+
568
+ // -----------------------------------------------------------------------
569
+ // lookupHostWithTracking – invalid response tracking
570
+ // -----------------------------------------------------------------------
571
+
572
+ describe('lookupHostWithTracking – invalid response', () => {
573
+ it('records failure when host returns a non-output-list response', async () => {
574
+ mockFacilitator.lookup.mockResolvedValueOnce({
575
+ type: 'freeform',
576
+ data: 'some free data'
577
+ })
578
+
579
+ const r = new LookupResolver({
580
+ facilitator: mockFacilitator,
581
+ hostOverrides: { ls_invalid: ['https://weird.host'] }
582
+ })
583
+
584
+ // The query returns empty outputs since the response is ignored
585
+ const res = await r.query({ service: 'ls_invalid', query: {} })
586
+ expect(res.outputs).toHaveLength(0)
587
+
588
+ // The host should have been penalised in the tracker
589
+ const tracker: HostReputationTracker = (r as any).hostReputation
590
+ const snap = tracker.snapshot('https://weird.host')
591
+ expect(snap?.totalFailures).toBeGreaterThan(0)
592
+ })
593
+ })
594
+
595
+ // -----------------------------------------------------------------------
596
+ // Empty hosts edge case
597
+ // -----------------------------------------------------------------------
598
+
599
+ describe('empty trackerHosts', () => {
600
+ it('returns empty array from findCompetentHosts when all SLAP trackers are empty list', async () => {
601
+ // Provide an empty slapTrackers list so trackerHosts.length === 0
602
+ const r = new LookupResolver({
603
+ facilitator: mockFacilitator,
604
+ slapTrackers: []
605
+ })
606
+
607
+ await expect(r.query({ service: 'ls_foo', query: {} })).rejects.toThrow(
608
+ 'No competent mainnet hosts found'
609
+ )
610
+ })
611
+ })
612
+ })