@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,561 @@
1
+ import { HostReputationTracker } from '../HostReputationTracker'
2
+
3
+ // ---- helpers ----------------------------------------------------------------
4
+
5
+ function makeStore (initial: Record<string, string> = {}): {
6
+ store: Map<string, string>
7
+ get: (key: string) => string | null
8
+ set: (key: string, value: string) => void
9
+ } {
10
+ const store = new Map<string, string>(Object.entries(initial))
11
+ return {
12
+ store,
13
+ get: (key: string) => store.get(key) ?? null,
14
+ set: (key: string, value: string) => { store.set(key, value) }
15
+ }
16
+ }
17
+
18
+ // ---- suite ------------------------------------------------------------------
19
+
20
+ describe('HostReputationTracker – additional coverage', () => {
21
+ // -----------------------------------------------------------------------
22
+ // snapshot
23
+ // -----------------------------------------------------------------------
24
+
25
+ describe('snapshot', () => {
26
+ it('returns undefined for an unknown host', () => {
27
+ const t = new HostReputationTracker()
28
+ expect(t.snapshot('https://unknown.host')).toBeUndefined()
29
+ })
30
+
31
+ it('returns a copy of the entry for a known host', () => {
32
+ const t = new HostReputationTracker()
33
+ t.recordSuccess('https://known.host', 100)
34
+ const snap = t.snapshot('https://known.host')
35
+ expect(snap).toBeDefined()
36
+ expect(snap!.host).toBe('https://known.host')
37
+ expect(snap!.totalSuccesses).toBe(1)
38
+
39
+ // Verify it is a copy — mutating the snapshot does not affect the tracker
40
+ snap!.totalSuccesses = 999
41
+ const snap2 = t.snapshot('https://known.host')
42
+ expect(snap2!.totalSuccesses).toBe(1)
43
+ })
44
+ })
45
+
46
+ // -----------------------------------------------------------------------
47
+ // reset
48
+ // -----------------------------------------------------------------------
49
+
50
+ describe('reset', () => {
51
+ it('clears all stats', () => {
52
+ const t = new HostReputationTracker()
53
+ t.recordSuccess('https://a.com', 50)
54
+ t.recordSuccess('https://b.com', 80)
55
+ t.reset()
56
+ expect(t.snapshot('https://a.com')).toBeUndefined()
57
+ expect(t.snapshot('https://b.com')).toBeUndefined()
58
+ })
59
+ })
60
+
61
+ // -----------------------------------------------------------------------
62
+ // recordSuccess – latency EWMA
63
+ // -----------------------------------------------------------------------
64
+
65
+ describe('recordSuccess', () => {
66
+ it('sets avgLatencyMs to safeLatency on first success', () => {
67
+ const t = new HostReputationTracker()
68
+ t.recordSuccess('https://host.com', 200)
69
+ const snap = t.snapshot('https://host.com')!
70
+ expect(snap.avgLatencyMs).toBe(200)
71
+ expect(snap.lastLatencyMs).toBe(200)
72
+ expect(snap.totalSuccesses).toBe(1)
73
+ expect(snap.consecutiveFailures).toBe(0)
74
+ expect(snap.backoffUntil).toBe(0)
75
+ })
76
+
77
+ it('applies EWMA on subsequent successes', () => {
78
+ const t = new HostReputationTracker()
79
+ t.recordSuccess('https://host.com', 100)
80
+ t.recordSuccess('https://host.com', 500)
81
+ const snap = t.snapshot('https://host.com')!
82
+ // avg = (1 - 0.25) * 100 + 0.25 * 500 = 75 + 125 = 200
83
+ expect(snap.avgLatencyMs).toBeCloseTo(200)
84
+ expect(snap.lastLatencyMs).toBe(500)
85
+ })
86
+
87
+ it('treats negative latency as DEFAULT_LATENCY_MS (1500)', () => {
88
+ const t = new HostReputationTracker()
89
+ t.recordSuccess('https://host.com', -1)
90
+ const snap = t.snapshot('https://host.com')!
91
+ expect(snap.avgLatencyMs).toBe(1500)
92
+ })
93
+
94
+ it('treats NaN latency as DEFAULT_LATENCY_MS (1500)', () => {
95
+ const t = new HostReputationTracker()
96
+ t.recordSuccess('https://host.com', NaN)
97
+ const snap = t.snapshot('https://host.com')!
98
+ expect(snap.avgLatencyMs).toBe(1500)
99
+ })
100
+
101
+ it('treats Infinity latency as DEFAULT_LATENCY_MS (1500)', () => {
102
+ const t = new HostReputationTracker()
103
+ t.recordSuccess('https://host.com', Infinity)
104
+ const snap = t.snapshot('https://host.com')!
105
+ expect(snap.avgLatencyMs).toBe(1500)
106
+ })
107
+
108
+ it('clears lastError on success', () => {
109
+ const t = new HostReputationTracker()
110
+ t.recordFailure('https://host.com', 'some error')
111
+ t.recordSuccess('https://host.com', 100)
112
+ const snap = t.snapshot('https://host.com')!
113
+ expect(snap.lastError).toBeUndefined()
114
+ })
115
+ })
116
+
117
+ // -----------------------------------------------------------------------
118
+ // recordFailure – backoff logic
119
+ // -----------------------------------------------------------------------
120
+
121
+ describe('recordFailure', () => {
122
+ it('does not backoff on first two failures (grace period)', () => {
123
+ const t = new HostReputationTracker()
124
+ // FAILURE_BACKOFF_GRACE = 2, so first 2 failures have penaltyLevel 0
125
+ t.recordFailure('https://host.com', 'transient')
126
+ t.recordFailure('https://host.com', 'transient')
127
+ const snap = t.snapshot('https://host.com')!
128
+ expect(snap.backoffUntil).toBe(0)
129
+ })
130
+
131
+ it('starts backing off after grace period failures', () => {
132
+ const t = new HostReputationTracker()
133
+ const before = Date.now()
134
+ t.recordFailure('https://host.com', 'e')
135
+ t.recordFailure('https://host.com', 'e')
136
+ t.recordFailure('https://host.com', 'e') // penaltyLevel = 1 => backoff = 1000 ms
137
+ const snap = t.snapshot('https://host.com')!
138
+ expect(snap.backoffUntil).toBeGreaterThan(before)
139
+ expect(snap.backoffUntil).toBeLessThanOrEqual(Date.now() + 1001)
140
+ })
141
+
142
+ it('caps backoff at MAX_BACKOFF_MS (60000)', () => {
143
+ const t = new HostReputationTracker()
144
+ // After many failures the backoff should cap at 60000 ms
145
+ for (let i = 0; i < 20; i++) {
146
+ t.recordFailure('https://host.com', 'offline')
147
+ }
148
+ const snap = t.snapshot('https://host.com')!
149
+ const maxBackoff = 60_000
150
+ expect(snap.backoffUntil).toBeLessThanOrEqual(Date.now() + maxBackoff + 10)
151
+ })
152
+
153
+ it('sets lastError to string reason', () => {
154
+ const t = new HostReputationTracker()
155
+ t.recordFailure('https://host.com', 'timeout')
156
+ const snap = t.snapshot('https://host.com')!
157
+ expect(snap.lastError).toBe('timeout')
158
+ })
159
+
160
+ it('sets lastError to Error message', () => {
161
+ const t = new HostReputationTracker()
162
+ t.recordFailure('https://host.com', new Error('connection refused'))
163
+ const snap = t.snapshot('https://host.com')!
164
+ expect(snap.lastError).toBe('connection refused')
165
+ })
166
+
167
+ it('sets lastError to undefined for non-string, non-Error reason', () => {
168
+ const t = new HostReputationTracker()
169
+ t.recordFailure('https://host.com', { some: 'object' })
170
+ const snap = t.snapshot('https://host.com')!
171
+ expect(snap.lastError).toBeUndefined()
172
+ })
173
+
174
+ it('sets lastError to undefined when reason is undefined', () => {
175
+ const t = new HostReputationTracker()
176
+ t.recordFailure('https://host.com')
177
+ const snap = t.snapshot('https://host.com')!
178
+ expect(snap.lastError).toBeUndefined()
179
+ })
180
+
181
+ // Immediate backoff triggers (ERR_NAME_NOT_RESOLVED, ENOTFOUND, etc.)
182
+ it('immediately escalates consecutiveFailures for ERR_NAME_NOT_RESOLVED', () => {
183
+ const t = new HostReputationTracker()
184
+ // First failure with DNS error should skip grace period
185
+ t.recordFailure('https://host.com', 'ERR_NAME_NOT_RESOLVED: dns error')
186
+ const snap = t.snapshot('https://host.com')!
187
+ // consecutiveFailures should be >= FAILURE_BACKOFF_GRACE + 1 = 3
188
+ expect(snap.consecutiveFailures).toBeGreaterThanOrEqual(3)
189
+ expect(snap.backoffUntil).toBeGreaterThan(0)
190
+ })
191
+
192
+ it('immediately escalates consecutiveFailures for ENOTFOUND', () => {
193
+ const t = new HostReputationTracker()
194
+ t.recordFailure('https://host.com', 'ENOTFOUND host.invalid')
195
+ const snap = t.snapshot('https://host.com')!
196
+ expect(snap.consecutiveFailures).toBeGreaterThanOrEqual(3)
197
+ expect(snap.backoffUntil).toBeGreaterThan(0)
198
+ })
199
+
200
+ it('immediately escalates consecutiveFailures for getaddrinfo errors', () => {
201
+ const t = new HostReputationTracker()
202
+ t.recordFailure('https://host.com', 'getaddrinfo ENOTFOUND')
203
+ const snap = t.snapshot('https://host.com')!
204
+ expect(snap.consecutiveFailures).toBeGreaterThanOrEqual(3)
205
+ expect(snap.backoffUntil).toBeGreaterThan(0)
206
+ })
207
+
208
+ it('immediately escalates consecutiveFailures for "Failed to fetch"', () => {
209
+ const t = new HostReputationTracker()
210
+ t.recordFailure('https://host.com', 'Failed to fetch')
211
+ const snap = t.snapshot('https://host.com')!
212
+ expect(snap.consecutiveFailures).toBeGreaterThanOrEqual(3)
213
+ expect(snap.backoffUntil).toBeGreaterThan(0)
214
+ })
215
+
216
+ it('immediately escalates for Error with ENOTFOUND message', () => {
217
+ const t = new HostReputationTracker()
218
+ t.recordFailure('https://host.com', new Error('ENOTFOUND myhost'))
219
+ const snap = t.snapshot('https://host.com')!
220
+ expect(snap.consecutiveFailures).toBeGreaterThanOrEqual(3)
221
+ })
222
+
223
+ it('does NOT immediately escalate when consecutiveFailures already exceeds grace+1', () => {
224
+ const t = new HostReputationTracker()
225
+ // Get into deep failure first
226
+ for (let i = 0; i < 5; i++) t.recordFailure('https://host.com', 'normal')
227
+ const snapBefore = t.snapshot('https://host.com')!
228
+ const cfBefore = snapBefore.consecutiveFailures
229
+
230
+ // Now a DNS-type error shouldn't change consecutiveFailures (it's already above threshold)
231
+ t.recordFailure('https://host.com', 'ENOTFOUND deephost')
232
+ const snapAfter = t.snapshot('https://host.com')!
233
+ // consecutive failures should still increment by 1 from the regular path
234
+ expect(snapAfter.consecutiveFailures).toBe(cfBefore + 1)
235
+ })
236
+
237
+ it('resets consecutive failures to zero on success after failures', () => {
238
+ const t = new HostReputationTracker()
239
+ t.recordFailure('https://host.com', 'err')
240
+ t.recordFailure('https://host.com', 'err')
241
+ t.recordFailure('https://host.com', 'err')
242
+ t.recordSuccess('https://host.com', 50)
243
+ const snap = t.snapshot('https://host.com')!
244
+ expect(snap.consecutiveFailures).toBe(0)
245
+ expect(snap.backoffUntil).toBe(0)
246
+ })
247
+ })
248
+
249
+ // -----------------------------------------------------------------------
250
+ // rankHosts
251
+ // -----------------------------------------------------------------------
252
+
253
+ describe('rankHosts', () => {
254
+ it('returns empty array for empty input', () => {
255
+ const t = new HostReputationTracker()
256
+ expect(t.rankHosts([])).toEqual([])
257
+ })
258
+
259
+ it('deduplicates hosts keeping first occurrence order', () => {
260
+ const t = new HostReputationTracker()
261
+ const ranked = t.rankHosts([
262
+ 'https://a.com',
263
+ 'https://b.com',
264
+ 'https://a.com', // duplicate
265
+ 'https://c.com'
266
+ ])
267
+ const hosts = ranked.map(r => r.host)
268
+ expect(hosts.filter(h => h === 'https://a.com')).toHaveLength(1)
269
+ expect(hosts).toHaveLength(3)
270
+ })
271
+
272
+ it('ignores empty string and non-string entries', () => {
273
+ const t = new HostReputationTracker()
274
+ const ranked = t.rankHosts([
275
+ 'https://valid.com',
276
+ '', // empty string – skipped
277
+ 'https://also.valid.com'
278
+ ])
279
+ const hosts = ranked.map(r => r.host)
280
+ expect(hosts).not.toContain('')
281
+ expect(hosts).toHaveLength(2)
282
+ })
283
+
284
+ it('sorts hosts in backoff to the end', () => {
285
+ const t = new HostReputationTracker()
286
+ // Put https://bad.com in backoff
287
+ for (let i = 0; i < 5; i++) t.recordFailure('https://bad.com', 'err')
288
+ t.recordSuccess('https://good.com', 50)
289
+
290
+ const ranked = t.rankHosts(['https://bad.com', 'https://good.com'])
291
+ expect(ranked[0].host).toBe('https://good.com')
292
+ expect(ranked[1].host).toBe('https://bad.com')
293
+ })
294
+
295
+ it('sorts by score (lower is better) when no backoff', () => {
296
+ const t = new HostReputationTracker()
297
+ // high latency host
298
+ for (let i = 0; i < 3; i++) t.recordSuccess('https://slow.com', 3000)
299
+ // low latency host
300
+ for (let i = 0; i < 3; i++) t.recordSuccess('https://fast.com', 50)
301
+
302
+ const ranked = t.rankHosts(['https://slow.com', 'https://fast.com'])
303
+ expect(ranked[0].host).toBe('https://fast.com')
304
+ })
305
+
306
+ it('prefers host with more successes when score is equal', () => {
307
+ const t = new HostReputationTracker()
308
+ // Same latency
309
+ t.recordSuccess('https://few.com', 100)
310
+ for (let i = 0; i < 5; i++) t.recordSuccess('https://many.com', 100)
311
+
312
+ const ranked = t.rankHosts(['https://few.com', 'https://many.com'])
313
+ expect(ranked[0].host).toBe('https://many.com')
314
+ })
315
+
316
+ it('preserves original insertion order for hosts with identical scores and successes', () => {
317
+ const t = new HostReputationTracker()
318
+ // Brand new hosts with no history – equal scores
319
+ const ranked = t.rankHosts(['https://first.com', 'https://second.com', 'https://third.com'])
320
+ const hosts = ranked.map(r => r.host)
321
+ expect(hosts).toEqual(['https://first.com', 'https://second.com', 'https://third.com'])
322
+ })
323
+
324
+ it('includes score field in returned entries', () => {
325
+ const t = new HostReputationTracker()
326
+ t.recordSuccess('https://host.com', 200)
327
+ const ranked = t.rankHosts(['https://host.com'])
328
+ expect(typeof ranked[0].score).toBe('number')
329
+ })
330
+ })
331
+
332
+ // -----------------------------------------------------------------------
333
+ // computeScore
334
+ // -----------------------------------------------------------------------
335
+
336
+ describe('computeScore (via rankHosts)', () => {
337
+ it('caps successBonus at latency/2', () => {
338
+ const t = new HostReputationTracker()
339
+ // Many successes with very low latency
340
+ for (let i = 0; i < 1000; i++) t.recordSuccess('https://host.com', 10)
341
+ const ranked = t.rankHosts(['https://host.com'])
342
+ // score should be >= 0 (bonus capped at latency/2)
343
+ expect(ranked[0].score).toBeGreaterThanOrEqual(0)
344
+ })
345
+
346
+ it('adds backoff penalty when host is in backoff', () => {
347
+ const t = new HostReputationTracker()
348
+ const before = Date.now()
349
+
350
+ t.recordSuccess('https://host.com', 100) // baseline
351
+ const normalScore = t.rankHosts(['https://host.com'])[0].score
352
+
353
+ // Force backoff
354
+ for (let i = 0; i < 5; i++) t.recordFailure('https://host.com', 'err')
355
+ const backedOffScore = t.rankHosts(['https://host.com'])[0].score
356
+
357
+ expect(backedOffScore).toBeGreaterThan(normalScore)
358
+ })
359
+ })
360
+
361
+ // -----------------------------------------------------------------------
362
+ // Storage – loadFromStorage / saveToStorage
363
+ // -----------------------------------------------------------------------
364
+
365
+ describe('storage', () => {
366
+ it('persists and restores data via a custom store', () => {
367
+ const kv = makeStore()
368
+ const t1 = new HostReputationTracker(kv)
369
+ t1.recordSuccess('https://persist.com', 200)
370
+ t1.recordFailure('https://persist.com', 'oops')
371
+
372
+ // A new tracker with the same store should load the persisted data
373
+ const t2 = new HostReputationTracker(kv)
374
+ const snap = t2.snapshot('https://persist.com')
375
+ expect(snap).toBeDefined()
376
+ expect(snap!.totalSuccesses).toBe(1)
377
+ expect(snap!.totalFailures).toBe(1)
378
+ })
379
+
380
+ it('handles corrupt JSON in storage gracefully', () => {
381
+ const kv = makeStore({ bsvsdk_overlay_host_reputation_v1: 'not-json{' })
382
+ expect(() => new HostReputationTracker(kv)).not.toThrow()
383
+ })
384
+
385
+ it('handles non-object JSON in storage gracefully', () => {
386
+ const kv = makeStore({ bsvsdk_overlay_host_reputation_v1: '"just a string"' })
387
+ expect(() => new HostReputationTracker(kv)).not.toThrow()
388
+ })
389
+
390
+ it('handles null JSON value in storage gracefully', () => {
391
+ const kv = makeStore({ bsvsdk_overlay_host_reputation_v1: 'null' })
392
+ expect(() => new HostReputationTracker(kv)).not.toThrow()
393
+ })
394
+
395
+ it('handles empty string storage gracefully (no-op)', () => {
396
+ const kv = makeStore({ bsvsdk_overlay_host_reputation_v1: '' })
397
+ expect(() => new HostReputationTracker(kv)).not.toThrow()
398
+ })
399
+
400
+ it('handles storage entries with missing fields by defaulting them', () => {
401
+ const partial = JSON.stringify({
402
+ 'https://partial.com': {
403
+ host: 'https://partial.com'
404
+ // missing all numeric fields
405
+ }
406
+ })
407
+ const kv = makeStore({ bsvsdk_overlay_host_reputation_v1: partial })
408
+ const t = new HostReputationTracker(kv)
409
+ const snap = t.snapshot('https://partial.com')
410
+ expect(snap).toBeDefined()
411
+ expect(snap!.totalSuccesses).toBe(0)
412
+ expect(snap!.totalFailures).toBe(0)
413
+ expect(snap!.avgLatencyMs).toBeNull()
414
+ })
415
+
416
+ it('handles avgLatencyMs: null in stored data', () => {
417
+ const data = JSON.stringify({
418
+ 'https://host.com': {
419
+ host: 'https://host.com',
420
+ totalSuccesses: 0,
421
+ totalFailures: 0,
422
+ consecutiveFailures: 0,
423
+ avgLatencyMs: null,
424
+ lastLatencyMs: null,
425
+ backoffUntil: 0,
426
+ lastUpdatedAt: 0
427
+ }
428
+ })
429
+ const kv = makeStore({ bsvsdk_overlay_host_reputation_v1: data })
430
+ const t = new HostReputationTracker(kv)
431
+ const snap = t.snapshot('https://host.com')
432
+ expect(snap!.avgLatencyMs).toBeNull()
433
+ expect(snap!.lastLatencyMs).toBeNull()
434
+ })
435
+
436
+ it('handles storage.get throwing by returning undefined', () => {
437
+ const kv = {
438
+ get: (_key: string): string | null => { throw new Error('get error') },
439
+ set: (_key: string, _value: string): void => {}
440
+ }
441
+ // Should not throw during construction
442
+ expect(() => new HostReputationTracker(kv)).not.toThrow()
443
+ })
444
+
445
+ it('handles storage.set throwing gracefully', () => {
446
+ const kv = {
447
+ get: (_key: string): null => null,
448
+ set: (_key: string, _value: string): void => { throw new Error('set error') }
449
+ }
450
+ const t = new HostReputationTracker(kv)
451
+ // Should not throw when saving
452
+ expect(() => t.recordSuccess('https://host.com', 100)).not.toThrow()
453
+ })
454
+
455
+ it('constructs without store when no store is provided and no localStorage', () => {
456
+ // In Jest/Node environment, globalThis.localStorage is not defined
457
+ const t = new HostReputationTracker(undefined)
458
+ expect(t).toBeInstanceOf(HostReputationTracker)
459
+ // Should work without persistence
460
+ t.recordSuccess('https://no-storage.com', 100)
461
+ expect(t.snapshot('https://no-storage.com')).toBeDefined()
462
+ })
463
+ })
464
+
465
+ // -----------------------------------------------------------------------
466
+ // localStorage adapter (via mock globalThis)
467
+ // -----------------------------------------------------------------------
468
+
469
+ describe('localStorage adapter', () => {
470
+ let originalLocalStorage: any
471
+
472
+ beforeEach(() => {
473
+ originalLocalStorage = (globalThis as any).localStorage
474
+ })
475
+
476
+ afterEach(() => {
477
+ if (originalLocalStorage === undefined) {
478
+ delete (globalThis as any).localStorage
479
+ } else {
480
+ (globalThis as any).localStorage = originalLocalStorage
481
+ }
482
+ })
483
+
484
+ it('uses globalThis.localStorage when available', () => {
485
+ const mockStore = new Map<string, string>()
486
+ ;(globalThis as any).localStorage = {
487
+ getItem: (key: string): string | null => mockStore.get(key) ?? null,
488
+ setItem: (key: string, value: string): void => { mockStore.set(key, value) }
489
+ }
490
+
491
+ const t = new HostReputationTracker()
492
+ t.recordSuccess('https://ls-test.com', 300)
493
+
494
+ // Data should have been persisted to the mock localStorage
495
+ expect(mockStore.has('bsvsdk_overlay_host_reputation_v1')).toBe(true)
496
+ const stored = JSON.parse(mockStore.get('bsvsdk_overlay_host_reputation_v1')!)
497
+ expect(stored['https://ls-test.com']).toBeDefined()
498
+ })
499
+
500
+ it('handles localStorage.getItem throwing', () => {
501
+ ;(globalThis as any).localStorage = {
502
+ getItem: (_key: string): never => { throw new Error('security error') },
503
+ setItem: (_key: string, _value: string): void => {}
504
+ }
505
+
506
+ // Construction should not throw even if getItem throws
507
+ expect(() => new HostReputationTracker()).not.toThrow()
508
+ })
509
+
510
+ it('handles localStorage.setItem throwing', () => {
511
+ ;(globalThis as any).localStorage = {
512
+ getItem: (_key: string): null => null,
513
+ setItem: (_key: string, _value: string): never => { throw new Error('quota exceeded') }
514
+ }
515
+
516
+ const t = new HostReputationTracker()
517
+ expect(() => t.recordSuccess('https://ls-quota.com', 100)).not.toThrow()
518
+ })
519
+ })
520
+
521
+ // -----------------------------------------------------------------------
522
+ // Integration: multiple calls
523
+ // -----------------------------------------------------------------------
524
+
525
+ describe('integration', () => {
526
+ it('tracks multiple hosts independently', () => {
527
+ const t = new HostReputationTracker()
528
+ t.recordSuccess('https://alpha.com', 100)
529
+ t.recordSuccess('https://alpha.com', 150)
530
+ t.recordFailure('https://beta.com', 'timeout')
531
+ t.recordFailure('https://beta.com', 'timeout')
532
+ t.recordFailure('https://beta.com', 'timeout')
533
+
534
+ const alpha = t.snapshot('https://alpha.com')!
535
+ const beta = t.snapshot('https://beta.com')!
536
+
537
+ expect(alpha.totalSuccesses).toBe(2)
538
+ expect(alpha.totalFailures).toBe(0)
539
+ expect(beta.totalSuccesses).toBe(0)
540
+ expect(beta.totalFailures).toBe(3)
541
+ expect(beta.backoffUntil).toBeGreaterThan(0)
542
+ })
543
+
544
+ it('returns correct ranking with mixed host states', () => {
545
+ const t = new HostReputationTracker()
546
+ t.recordSuccess('https://mid.com', 500)
547
+ t.recordSuccess('https://fast.com', 50)
548
+ for (let i = 0; i < 4; i++) t.recordFailure('https://down.com', 'err')
549
+
550
+ const ranked = t.rankHosts([
551
+ 'https://down.com',
552
+ 'https://mid.com',
553
+ 'https://fast.com'
554
+ ])
555
+
556
+ // fast should come first, down should be last (in backoff)
557
+ expect(ranked[0].host).toBe('https://fast.com')
558
+ expect(ranked[ranked.length - 1].host).toBe('https://down.com')
559
+ })
560
+ })
561
+ })