@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
- package/dist/cjs/src/overlay-tools/HostReputationTracker.js +21 -13
- package/dist/cjs/src/overlay-tools/HostReputationTracker.js.map +1 -1
- package/dist/cjs/src/primitives/PrivateKey.js +3 -3
- package/dist/cjs/src/primitives/PrivateKey.js.map +1 -1
- package/dist/cjs/src/script/Spend.js +17 -9
- package/dist/cjs/src/script/Spend.js.map +1 -1
- package/dist/cjs/src/storage/StorageDownloader.js +6 -6
- package/dist/cjs/src/storage/StorageDownloader.js.map +1 -1
- package/dist/cjs/src/storage/StorageUtils.js +1 -1
- package/dist/cjs/src/storage/StorageUtils.js.map +1 -1
- package/dist/cjs/src/transaction/MerklePath.js +168 -27
- package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
- package/dist/esm/src/overlay-tools/HostReputationTracker.js +21 -13
- package/dist/esm/src/overlay-tools/HostReputationTracker.js.map +1 -1
- package/dist/esm/src/primitives/PrivateKey.js +3 -3
- package/dist/esm/src/primitives/PrivateKey.js.map +1 -1
- package/dist/esm/src/script/Spend.js +17 -9
- package/dist/esm/src/script/Spend.js.map +1 -1
- package/dist/esm/src/storage/StorageDownloader.js +6 -6
- package/dist/esm/src/storage/StorageDownloader.js.map +1 -1
- package/dist/esm/src/storage/StorageUtils.js +1 -1
- package/dist/esm/src/storage/StorageUtils.js.map +1 -1
- package/dist/esm/src/transaction/MerklePath.js +168 -27
- package/dist/esm/src/transaction/MerklePath.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
- package/dist/types/src/overlay-tools/HostReputationTracker.d.ts.map +1 -1
- package/dist/types/src/script/Spend.d.ts.map +1 -1
- package/dist/types/src/transaction/MerklePath.d.ts +27 -0
- package/dist/types/src/transaction/MerklePath.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/storage.md +1 -1
- package/docs/reference/transaction.md +40 -0
- package/package.json +1 -1
- package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
- package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
- package/src/auth/utils/__tests/validateCertificates.test.ts +12 -9
- package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
- package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
- package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
- package/src/kvstore/__tests/LocalKVStore.test.ts +4 -6
- package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
- package/src/overlay-tools/HostReputationTracker.ts +17 -14
- package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
- package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
- package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
- package/src/primitives/PrivateKey.ts +3 -3
- package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
- package/src/primitives/__tests/Curve.additional.test.ts +208 -0
- package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
- package/src/primitives/__tests/Hash.additional.test.ts +59 -0
- package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
- package/src/primitives/__tests/Point.additional.test.ts +503 -0
- package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
- package/src/primitives/__tests/Random.additional.test.ts +262 -0
- package/src/primitives/__tests/Signature.test.ts +333 -0
- package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
- package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
- package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
- package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
- package/src/script/Spend.ts +19 -11
- package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
- package/src/script/__tests/Script.additional.test.ts +100 -0
- package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
- package/src/script/__tests/Spend.additional.test.ts +837 -0
- package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
- package/src/storage/StorageDownloader.ts +6 -6
- package/src/storage/StorageUtils.ts +1 -1
- package/src/transaction/MerklePath.ts +196 -36
- package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
- package/src/transaction/__tests/Broadcaster.test.ts +159 -0
- package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
- package/src/transaction/__tests/MerklePath.test.ts +232 -21
- package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
- package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
- package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
- package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
- package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
- package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
- package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
- package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
- package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
- package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
- package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
- package/src/wallet/__tests/WERR.test.ts +212 -0
- package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
- package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
- package/src/wallet/__tests/WalletError.test.ts +290 -0
- package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
- package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
- 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
|
+
})
|