@bsv/sdk 1.8.11 → 1.8.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 (32) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/overlay-tools/HostReputationTracker.js +216 -0
  3. package/dist/cjs/src/overlay-tools/HostReputationTracker.js.map +1 -0
  4. package/dist/cjs/src/overlay-tools/LookupResolver.js +55 -3
  5. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  6. package/dist/cjs/src/wallet/WalletError.js +2 -2
  7. package/dist/cjs/src/wallet/WalletError.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/overlay-tools/HostReputationTracker.js +213 -0
  10. package/dist/esm/src/overlay-tools/HostReputationTracker.js.map +1 -0
  11. package/dist/esm/src/overlay-tools/LookupResolver.js +56 -3
  12. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  13. package/dist/esm/src/wallet/WalletError.js +2 -2
  14. package/dist/esm/src/wallet/WalletError.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/overlay-tools/HostReputationTracker.d.ts +37 -0
  17. package/dist/types/src/overlay-tools/HostReputationTracker.d.ts.map +1 -0
  18. package/dist/types/src/overlay-tools/LookupResolver.d.ts +8 -0
  19. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  20. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  21. package/dist/umd/bundle.js +3 -3
  22. package/dist/umd/bundle.js.map +1 -1
  23. package/docs/reference/overlay-tools.md +58 -0
  24. package/docs/reference/primitives.md +33 -0
  25. package/docs/tutorials/advanced-transaction.md +2 -2
  26. package/docs/tutorials/first-transaction.md +1 -1
  27. package/docs/tutorials/transaction-types.md +1 -1
  28. package/package.json +1 -1
  29. package/src/overlay-tools/HostReputationTracker.ts +232 -0
  30. package/src/overlay-tools/LookupResolver.ts +73 -4
  31. package/src/overlay-tools/__tests/LookupResolver.test.ts +120 -0
  32. package/src/wallet/WalletError.ts +2 -2
@@ -11,6 +11,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
11
11
  | [LookupResolverConfig](#interface-lookupresolverconfig) |
12
12
  | [OverlayBroadcastFacilitator](#interface-overlaybroadcastfacilitator) |
13
13
  | [OverlayLookupFacilitator](#interface-overlaylookupfacilitator) |
14
+ | [RankedHost](#interface-rankedhost) |
14
15
  | [SHIPBroadcasterConfig](#interface-shipbroadcasterconfig) |
15
16
  | [TaggedBEEF](#interface-taggedbeef) |
16
17
 
@@ -101,6 +102,10 @@ export interface LookupResolverConfig {
101
102
  hostOverrides?: Record<string, string[]>;
102
103
  additionalHosts?: Record<string, string[]>;
103
104
  cache?: CacheOptions;
105
+ reputationStorage?: "localStorage" | {
106
+ get: (key: string) => string | null | undefined;
107
+ set: (key: string, value: string) => void;
108
+ };
104
109
  }
105
110
  ```
106
111
 
@@ -150,6 +155,17 @@ The network preset to use, unless other options override it.
150
155
  networkPreset?: "mainnet" | "testnet" | "local"
151
156
  ```
152
157
 
158
+ #### Property reputationStorage
159
+
160
+ Optional storage for host reputation data.
161
+
162
+ ```ts
163
+ reputationStorage?: "localStorage" | {
164
+ get: (key: string) => string | null | undefined;
165
+ set: (key: string, value: string) => void;
166
+ }
167
+ ```
168
+
153
169
  #### Property slapTrackers
154
170
 
155
171
  The list of SLAP trackers queried to resolve Overlay Services hosts for a given lookup service.
@@ -199,6 +215,17 @@ See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion]
199
215
 
200
216
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
201
217
 
218
+ ---
219
+ ### Interface: RankedHost
220
+
221
+ ```ts
222
+ export interface RankedHost extends HostReputationEntry {
223
+ score: number;
224
+ }
225
+ ```
226
+
227
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
228
+
202
229
  ---
203
230
  ### Interface: SHIPBroadcasterConfig
204
231
 
@@ -294,6 +321,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
294
321
  | --- |
295
322
  | [HTTPSOverlayBroadcastFacilitator](#class-httpsoverlaybroadcastfacilitator) |
296
323
  | [HTTPSOverlayLookupFacilitator](#class-httpsoverlaylookupfacilitator) |
324
+ | [HostReputationTracker](#class-hostreputationtracker) |
297
325
  | [LookupResolver](#class-lookupresolver) |
298
326
  | [OverlayAdminTokenTemplate](#class-overlayadmintokentemplate) |
299
327
  | [TopicBroadcaster](#class-topicbroadcaster) |
@@ -333,6 +361,24 @@ See also: [LookupAnswer](./overlay-tools.md#type-lookupanswer), [LookupQuestion]
333
361
 
334
362
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
335
363
 
364
+ ---
365
+ ### Class: HostReputationTracker
366
+
367
+ ```ts
368
+ export class HostReputationTracker {
369
+ constructor(store?: KeyValueStore)
370
+ reset(): void
371
+ recordSuccess(host: string, latencyMs: number): void
372
+ recordFailure(host: string, reason?: unknown): void
373
+ rankHosts(hosts: string[], now: number = Date.now()): RankedHost[]
374
+ snapshot(host: string): HostReputationEntry | undefined
375
+ }
376
+ ```
377
+
378
+ See also: [RankedHost](./overlay-tools.md#interface-rankedhost)
379
+
380
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
381
+
336
382
  ---
337
383
  ### Class: LookupResolver
338
384
 
@@ -569,6 +615,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
569
615
  | --- |
570
616
  | [DEFAULT_SLAP_TRACKERS](#variable-default_slap_trackers) |
571
617
  | [DEFAULT_TESTNET_SLAP_TRACKERS](#variable-default_testnet_slap_trackers) |
618
+ | [getOverlayHostReputationTracker](#variable-getoverlayhostreputationtracker) |
572
619
 
573
620
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
574
621
 
@@ -599,3 +646,14 @@ DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [
599
646
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
600
647
 
601
648
  ---
649
+ ### Variable: getOverlayHostReputationTracker
650
+
651
+ ```ts
652
+ getOverlayHostReputationTracker = (): HostReputationTracker => globalTracker
653
+ ```
654
+
655
+ See also: [HostReputationTracker](./overlay-tools.md#class-hostreputationtracker)
656
+
657
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
658
+
659
+ ---
@@ -4764,6 +4764,7 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
4764
4764
  | [red](#function-red) |
4765
4765
  | [toArray](#function-toarray) |
4766
4766
  | [toBase64](#function-tobase64) |
4767
+ | [verifyNotNull](#function-verifynotnull) |
4767
4768
 
4768
4769
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
4769
4770
 
@@ -4889,6 +4890,38 @@ Argument Details
4889
4890
 
4890
4891
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
4891
4892
 
4893
+ ---
4894
+ ### Function: verifyNotNull
4895
+
4896
+ Verifies that a value is not null or undefined, throwing an error if it is.
4897
+
4898
+ Example
4899
+
4900
+ ```ts
4901
+ const myValue = verifyNotNull(someValue, 'someValue must be defined')
4902
+ ```
4903
+
4904
+ ```ts
4905
+ export function verifyNotNull<T>(value: T | undefined | null, errorMessage: string = "Expected a valid value, but got undefined or null."): T
4906
+ ```
4907
+
4908
+ Returns
4909
+
4910
+ - The verified value
4911
+
4912
+ Argument Details
4913
+
4914
+ + **value**
4915
+ + The value to verify
4916
+ + **errorMessage**
4917
+ + The error message to throw if the value is null or undefined
4918
+
4919
+ Throws
4920
+
4921
+ - If the value is null or undefined
4922
+
4923
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
4924
+
4892
4925
  ---
4893
4926
  ## Types
4894
4927
 
@@ -17,7 +17,7 @@ This tutorial builds on your knowledge of basic `WalletClient` usage to explore
17
17
  ## Prerequisites
18
18
 
19
19
  - Complete the [Transaction Types and Data](./transaction-types.md) tutorial
20
- - Have a BRC-100 compliant wallet (such as the [MetaNet Desktop Wallet](https://metanet.bsvb.tech/)) installed and configured
20
+ - Have a BRC-100 compliant wallet (such as the [MetaNet Desktop Wallet](https://desktop.bsvb.tech/)) installed and configured
21
21
  - Some BSV in your wallet
22
22
  - Understanding of Bitcoin transaction fundamentals
23
23
 
@@ -569,4 +569,4 @@ These techniques enable you to build production-ready applications that efficien
569
569
 
570
570
  - [Wallet Reference](../reference/wallet.md)
571
571
  - [BSV Blockchain Documentation](https://docs.bsvblockchain.org/)
572
- - [MetaNet Desktop Wallet](https://metanet.bsvb.tech/)
572
+ - [MetaNet Desktop Wallet](https://desktop.bsvb.tech/)
@@ -18,7 +18,7 @@ In this tutorial, you'll learn how to create your first Bitcoin SV transactions
18
18
 
19
19
  ## Precondition
20
20
 
21
- Install a BRC-100 compliant wallet such as the [MetaNet Desktop Wallet](https://metanet.bsvb.tech/). When you install it, you'll receive a small amount of funds to play with.
21
+ Install a BRC-100 compliant wallet such as the [MetaNet Desktop Wallet](https://desktop.bsvb.tech/). When you install it, you'll receive a small amount of funds to play with.
22
22
 
23
23
  ## Step 1: Setting Up Your Environment
24
24
 
@@ -17,7 +17,7 @@ In the previous tutorial, you created a simple transaction that sent BSV to a si
17
17
  ## Prerequisites
18
18
 
19
19
  - Complete the [Your First BSV Transaction](./first-transaction.md) tutorial
20
- - Have a BRC-100 compliant wallet (such as [MetaNet Desktop Wallet](https://metanet.bsvb.tech/)) installed and configured
20
+ - Have a BRC-100 compliant wallet (such as [MetaNet Desktop Wallet](https://desktop.bsvb.tech/)) installed and configured
21
21
  - Some BSV in your wallet
22
22
 
23
23
  ## Transaction with Multiple Outputs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.8.11",
3
+ "version": "1.8.13",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -0,0 +1,232 @@
1
+ interface HostReputationEntry {
2
+ host: string
3
+ totalSuccesses: number
4
+ totalFailures: number
5
+ consecutiveFailures: number
6
+ avgLatencyMs: number | null
7
+ lastLatencyMs: number | null
8
+ backoffUntil: number
9
+ lastUpdatedAt: number
10
+ lastError?: string
11
+ }
12
+
13
+ export interface RankedHost extends HostReputationEntry {
14
+ score: number
15
+ }
16
+
17
+ const DEFAULT_LATENCY_MS = 1500
18
+ const LATENCY_SMOOTHING_FACTOR = 0.25
19
+ const BASE_BACKOFF_MS = 1000
20
+ const MAX_BACKOFF_MS = 60_000
21
+ const FAILURE_PENALTY_MS = 400
22
+ const SUCCESS_BONUS_MS = 30
23
+ const FAILURE_BACKOFF_GRACE = 2
24
+ const STORAGE_KEY = 'bsvsdk_overlay_host_reputation_v1'
25
+
26
+ interface KeyValueStore {
27
+ get: (key: string) => string | null | undefined
28
+ set: (key: string, value: string) => void
29
+ }
30
+
31
+ export class HostReputationTracker {
32
+ private readonly stats: Map<string, HostReputationEntry>
33
+ private readonly store: KeyValueStore | undefined
34
+
35
+ constructor (store?: KeyValueStore) {
36
+ this.stats = new Map()
37
+ this.store = store ?? this.getLocalStorageAdapter()
38
+ this.loadFromStorage()
39
+ }
40
+
41
+ reset (): void {
42
+ this.stats.clear()
43
+ }
44
+
45
+ recordSuccess (host: string, latencyMs: number): void {
46
+ const entry = this.getOrCreate(host)
47
+ const now = Date.now()
48
+ const safeLatency = Number.isFinite(latencyMs) && latencyMs >= 0 ? latencyMs : DEFAULT_LATENCY_MS
49
+ if (entry.avgLatencyMs === null) {
50
+ entry.avgLatencyMs = safeLatency
51
+ } else {
52
+ entry.avgLatencyMs =
53
+ (1 - LATENCY_SMOOTHING_FACTOR) * entry.avgLatencyMs +
54
+ LATENCY_SMOOTHING_FACTOR * safeLatency
55
+ }
56
+ entry.lastLatencyMs = safeLatency
57
+ entry.totalSuccesses += 1
58
+ entry.consecutiveFailures = 0
59
+ entry.backoffUntil = 0
60
+ entry.lastUpdatedAt = now
61
+ entry.lastError = undefined
62
+ this.saveToStorage()
63
+ }
64
+
65
+ recordFailure (host: string, reason?: unknown): void {
66
+ const entry = this.getOrCreate(host)
67
+ const now = Date.now()
68
+ entry.totalFailures += 1
69
+ entry.consecutiveFailures += 1
70
+ const msg =
71
+ typeof reason === 'string'
72
+ ? reason
73
+ : reason instanceof Error
74
+ ? reason.message
75
+ : undefined
76
+ const immediate =
77
+ typeof msg === 'string' &&
78
+ (msg.includes('ERR_NAME_NOT_RESOLVED') ||
79
+ msg.includes('ENOTFOUND') ||
80
+ msg.includes('getaddrinfo') ||
81
+ msg.includes('Failed to fetch'))
82
+ if (immediate && entry.consecutiveFailures < FAILURE_BACKOFF_GRACE + 1) {
83
+ entry.consecutiveFailures = FAILURE_BACKOFF_GRACE + 1
84
+ }
85
+ const penaltyLevel = Math.max(entry.consecutiveFailures - FAILURE_BACKOFF_GRACE, 0)
86
+ if (penaltyLevel === 0) {
87
+ entry.backoffUntil = 0
88
+ } else {
89
+ const backoffDuration = Math.min(
90
+ MAX_BACKOFF_MS,
91
+ BASE_BACKOFF_MS * Math.pow(2, penaltyLevel - 1)
92
+ )
93
+ entry.backoffUntil = now + backoffDuration
94
+ }
95
+ entry.lastUpdatedAt = now
96
+ entry.lastError =
97
+ typeof reason === 'string'
98
+ ? reason
99
+ : reason instanceof Error
100
+ ? reason.message
101
+ : undefined
102
+ this.saveToStorage()
103
+ }
104
+
105
+ rankHosts (hosts: string[], now: number = Date.now()): RankedHost[] {
106
+ const seen = new Map<string, number>()
107
+ hosts.forEach((host, idx) => {
108
+ if (typeof host !== 'string' || host.length === 0) return
109
+ if (!seen.has(host)) seen.set(host, idx)
110
+ })
111
+
112
+ const orderedHosts = Array.from(seen.keys())
113
+ const ranked = orderedHosts.map((host) => {
114
+ const entry = this.getOrCreate(host)
115
+ return {
116
+ ...entry,
117
+ score: this.computeScore(entry, now),
118
+ originalOrder: seen.get(host) ?? 0
119
+ }
120
+ })
121
+
122
+ ranked.sort((a, b) => {
123
+ const aInBackoff = a.backoffUntil > now
124
+ const bInBackoff = b.backoffUntil > now
125
+ if (aInBackoff !== bInBackoff) return aInBackoff ? 1 : -1
126
+ if (a.score !== b.score) return a.score - b.score
127
+ if (a.totalSuccesses !== b.totalSuccesses) return b.totalSuccesses - a.totalSuccesses
128
+ return (a as any).originalOrder - (b as any).originalOrder
129
+ })
130
+
131
+ return ranked.map(({ originalOrder, ...rest }) => rest)
132
+ }
133
+
134
+ snapshot (host: string): HostReputationEntry | undefined {
135
+ const entry = this.stats.get(host)
136
+ return entry != null ? { ...entry } : undefined
137
+ }
138
+
139
+ private getStorage (): any {
140
+ try {
141
+ const g: any = typeof globalThis === 'object' ? globalThis : undefined
142
+ if (g == null || g.localStorage == null) return undefined
143
+ return g.localStorage
144
+ } catch {
145
+ return undefined
146
+ }
147
+ }
148
+
149
+ private getLocalStorageAdapter (): KeyValueStore | undefined {
150
+ const s = this.getStorage()
151
+ if (s == null) return undefined
152
+ return {
153
+ get: (key: string) => {
154
+ try { return s.getItem(key) } catch { return null }
155
+ },
156
+ set: (key: string, value: string) => {
157
+ try { s.setItem(key, value) } catch { }
158
+ }
159
+ }
160
+ }
161
+
162
+ private loadFromStorage (): void {
163
+ const s = this.store
164
+ if (s == null) return
165
+ try {
166
+ const raw = s.get(STORAGE_KEY)
167
+ if (typeof raw !== 'string' || raw.length === 0) return
168
+ const data = JSON.parse(raw)
169
+ if (typeof data !== 'object' || data === null) return
170
+ this.stats.clear()
171
+ for (const k of Object.keys(data)) {
172
+ const v: any = (data)[k]
173
+ if (v != null && typeof v === 'object') {
174
+ const entry: HostReputationEntry = {
175
+ host: String(v.host ?? k),
176
+ totalSuccesses: Number(v.totalSuccesses ?? 0),
177
+ totalFailures: Number(v.totalFailures ?? 0),
178
+ consecutiveFailures: Number(v.consecutiveFailures ?? 0),
179
+ avgLatencyMs: v.avgLatencyMs == null ? null : Number(v.avgLatencyMs),
180
+ lastLatencyMs: v.lastLatencyMs == null ? null : Number(v.lastLatencyMs),
181
+ backoffUntil: Number(v.backoffUntil ?? 0),
182
+ lastUpdatedAt: Number(v.lastUpdatedAt ?? 0),
183
+ lastError: typeof v.lastError === 'string' ? v.lastError : undefined
184
+ }
185
+ this.stats.set(entry.host, entry)
186
+ }
187
+ }
188
+ } catch {}
189
+ }
190
+
191
+ private saveToStorage (): void {
192
+ const s = this.store
193
+ if (s == null) return
194
+ try {
195
+ const obj: Record<string, any> = {}
196
+ for (const [host, entry] of this.stats.entries()) {
197
+ obj[host] = entry
198
+ }
199
+ s.set(STORAGE_KEY, JSON.stringify(obj))
200
+ } catch {}
201
+ }
202
+
203
+ private computeScore (entry: HostReputationEntry, now: number): number {
204
+ const latency = entry.avgLatencyMs ?? DEFAULT_LATENCY_MS
205
+ const failurePenalty = entry.consecutiveFailures * FAILURE_PENALTY_MS
206
+ const successBonus = Math.min(entry.totalSuccesses * SUCCESS_BONUS_MS, latency / 2)
207
+ const backoffPenalty = entry.backoffUntil > now ? entry.backoffUntil - now : 0
208
+ return latency + failurePenalty + backoffPenalty - successBonus
209
+ }
210
+
211
+ private getOrCreate (host: string): HostReputationEntry {
212
+ let entry = this.stats.get(host)
213
+ if (entry == null) {
214
+ entry = {
215
+ host,
216
+ totalSuccesses: 0,
217
+ totalFailures: 0,
218
+ consecutiveFailures: 0,
219
+ avgLatencyMs: null,
220
+ lastLatencyMs: null,
221
+ backoffUntil: 0,
222
+ lastUpdatedAt: 0
223
+ }
224
+ this.stats.set(host, entry)
225
+ }
226
+ return entry
227
+ }
228
+ }
229
+
230
+ const globalTracker = new HostReputationTracker()
231
+
232
+ export const getOverlayHostReputationTracker = (): HostReputationTracker => globalTracker
@@ -1,6 +1,7 @@
1
1
  import { Transaction } from '../transaction/index.js'
2
2
  import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js'
3
3
  import * as Utils from '../primitives/utils.js'
4
+ import { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js'
4
5
 
5
6
  const defaultFetch: typeof fetch =
6
7
  typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function'
@@ -92,6 +93,8 @@ export interface LookupResolverConfig {
92
93
  additionalHosts?: Record<string, string[]>
93
94
  /** Optional cache tuning. */
94
95
  cache?: CacheOptions
96
+ /** Optional storage for host reputation data. */
97
+ reputationStorage?: 'localStorage' | { get: (key: string) => string | null | undefined, set: (key: string, value: string) => void }
95
98
  }
96
99
 
97
100
  /** Facilitates lookups to URLs that return answers. */
@@ -204,6 +207,7 @@ export default class LookupResolver {
204
207
  private readonly hostOverrides: Record<string, string[]>
205
208
  private readonly additionalHosts: Record<string, string[]>
206
209
  private readonly networkPreset: 'mainnet' | 'testnet' | 'local'
210
+ private readonly hostReputation: HostReputationTracker
207
211
 
208
212
  // ---- Caches / memoization ----
209
213
  private readonly hostsCache: Map<string, { hosts: string[], expiresAt: number }>
@@ -223,6 +227,15 @@ export default class LookupResolver {
223
227
  this.hostOverrides = hostOverrides
224
228
  this.additionalHosts = config.additionalHosts ?? {}
225
229
 
230
+ const rs = config.reputationStorage
231
+ if (rs === 'localStorage') {
232
+ this.hostReputation = new HostReputationTracker()
233
+ } else if (typeof rs === 'object' && rs !== null && typeof rs.get === 'function' && typeof rs.set === 'function') {
234
+ this.hostReputation = new HostReputationTracker(rs)
235
+ } else {
236
+ this.hostReputation = getOverlayHostReputationTracker()
237
+ }
238
+
226
239
  // cache tuning
227
240
  this.hostsTtlMs = config.cache?.hostsTtlMs ?? 5 * 60 * 1000 // 5 min
228
241
  this.hostsMaxEntries = config.cache?.hostsMaxEntries ?? 128
@@ -262,10 +275,18 @@ export default class LookupResolver {
262
275
  )
263
276
  }
264
277
 
278
+ const rankedHosts = this.prepareHostsForQuery(
279
+ competentHosts,
280
+ `lookup service ${question.service}`
281
+ )
282
+ if (rankedHosts.length < 1) {
283
+ throw new Error(`All competent hosts for ${question.service} are temporarily unavailable due to backoff.`)
284
+ }
285
+
265
286
  // Fire all hosts with per-host timeout, harvest successful output-list responses
266
287
  const hostResponses = await Promise.allSettled(
267
- competentHosts.map(async (host) => {
268
- return await this.facilitator.lookup(host, question, timeout)
288
+ rankedHosts.map(async (host) => {
289
+ return await this.lookupHostWithTracking(host, question, timeout)
269
290
  })
270
291
  )
271
292
 
@@ -382,9 +403,15 @@ export default class LookupResolver {
382
403
  }
383
404
 
384
405
  // Query all SLAP trackers; tolerate failures.
406
+ const trackerHosts = this.prepareHostsForQuery(
407
+ this.slapTrackers,
408
+ 'SLAP trackers'
409
+ )
410
+ if (trackerHosts.length === 0) return []
411
+
385
412
  const trackerResponses = await Promise.allSettled(
386
- this.slapTrackers.map(async (tracker) =>
387
- await this.facilitator.lookup(tracker, query, MAX_TRACKER_WAIT_TIME)
413
+ trackerHosts.map(async (tracker) =>
414
+ await this.lookupHostWithTracking(tracker, query, MAX_TRACKER_WAIT_TIME)
388
415
  )
389
416
  )
390
417
 
@@ -427,4 +454,46 @@ export default class LookupResolver {
427
454
  }
428
455
  }
429
456
  }
457
+
458
+ private prepareHostsForQuery (hosts: string[], context: string): string[] {
459
+ if (hosts.length === 0) return []
460
+ const now = Date.now()
461
+ const ranked = this.hostReputation.rankHosts(hosts, now)
462
+ const available = ranked.filter((h) => h.backoffUntil <= now).map((h) => h.host)
463
+ if (available.length > 0) return available
464
+
465
+ const soonest = Math.min(...ranked.map((h) => h.backoffUntil))
466
+ const waitMs = Math.max(soonest - now, 0)
467
+ throw new Error(
468
+ `All ${context} hosts are backing off for approximately ${waitMs}ms due to repeated failures.`
469
+ )
470
+ }
471
+
472
+ private async lookupHostWithTracking (
473
+ host: string,
474
+ question: LookupQuestion,
475
+ timeout?: number
476
+ ): Promise<LookupAnswer> {
477
+ const startedAt = Date.now()
478
+ try {
479
+ const answer = await this.facilitator.lookup(host, question, timeout)
480
+ const latency = Date.now() - startedAt
481
+ const isValid =
482
+ typeof answer === 'object' &&
483
+ answer !== null &&
484
+ answer.type === 'output-list' &&
485
+ Array.isArray((answer).outputs)
486
+
487
+ if (isValid) {
488
+ this.hostReputation.recordSuccess(host, latency)
489
+ } else {
490
+ this.hostReputation.recordFailure(host, 'Invalid lookup response')
491
+ }
492
+
493
+ return answer
494
+ } catch (err) {
495
+ this.hostReputation.recordFailure(host, err)
496
+ throw err
497
+ }
498
+ }
430
499
  }
@@ -1,6 +1,7 @@
1
1
  import LookupResolver, {
2
2
  HTTPSOverlayLookupFacilitator
3
3
  } from '../LookupResolver'
4
+ import { getOverlayHostReputationTracker } from '../HostReputationTracker'
4
5
  import OverlayAdminTokenTemplate from '../../overlay-tools/OverlayAdminTokenTemplate'
5
6
  import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet'
6
7
  import { PrivateKey } from '../../primitives/index'
@@ -37,8 +38,10 @@ const sampleBeef4 = new Transaction(
37
38
  ).toBEEF()
38
39
 
39
40
  describe('LookupResolver', () => {
41
+ const hostTracker = getOverlayHostReputationTracker()
40
42
  beforeEach(() => {
41
43
  mockFacilitator.lookup.mockReset()
44
+ hostTracker.reset()
42
45
  })
43
46
 
44
47
  it('should query the host and return the response when a single host is found via SLAP', async () => {
@@ -967,6 +970,123 @@ describe('LookupResolver', () => {
967
970
  'HTTPS facilitator can only use URLs that start with "https:"'
968
971
  )
969
972
  })
973
+
974
+ describe('Host reputation tracking', () => {
975
+ it('shares performance learnings across resolver instances and prefers low latency hosts', async () => {
976
+ const fastHost = 'https://fast.host'
977
+ const slowHost = 'https://slow.host'
978
+ const hosts = [slowHost, fastHost]
979
+ let fakeNow = 0
980
+ const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => fakeNow)
981
+
982
+ try {
983
+ mockFacilitator.lookup.mockImplementation(async (url: string) => {
984
+ if (url === slowHost) {
985
+ fakeNow += 80
986
+ } else if (url === fastHost) {
987
+ fakeNow += 5
988
+ }
989
+ return {
990
+ type: 'output-list',
991
+ outputs: [
992
+ {
993
+ beef: url === fastHost ? sampleBeef2 : sampleBeef1,
994
+ outputIndex: url === fastHost ? 2 : 1
995
+ }
996
+ ]
997
+ }
998
+ })
999
+
1000
+ const resolverA = new LookupResolver({
1001
+ facilitator: mockFacilitator,
1002
+ hostOverrides: {
1003
+ ls_latency: hosts
1004
+ }
1005
+ })
1006
+ await resolverA.query({
1007
+ service: 'ls_latency',
1008
+ query: { attempt: 1 }
1009
+ })
1010
+
1011
+ mockFacilitator.lookup.mockClear()
1012
+
1013
+ const resolverB = new LookupResolver({
1014
+ facilitator: mockFacilitator,
1015
+ hostOverrides: {
1016
+ ls_latency: hosts
1017
+ }
1018
+ })
1019
+ await resolverB.query({
1020
+ service: 'ls_latency',
1021
+ query: { attempt: 2 }
1022
+ })
1023
+
1024
+ const orderedHosts = mockFacilitator.lookup.mock.calls.map((call) => call[0])
1025
+ expect(orderedHosts).toEqual([fastHost, slowHost])
1026
+ } finally {
1027
+ nowSpy.mockRestore()
1028
+ }
1029
+ })
1030
+
1031
+ it('exponentially backs off consistently failing hosts to avoid repeated work', async () => {
1032
+ const failingHost = 'https://offline.host'
1033
+ const healthyHost = 'https://healthy.host'
1034
+ let fakeNow = 0
1035
+ const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => fakeNow)
1036
+ const callLog: string[] = []
1037
+ let failingCalls = 0
1038
+
1039
+ try {
1040
+ mockFacilitator.lookup.mockImplementation(async (url: string) => {
1041
+ callLog.push(url)
1042
+ fakeNow += 5
1043
+ if (url === failingHost) {
1044
+ failingCalls += 1
1045
+ throw new Error('offline')
1046
+ }
1047
+ return {
1048
+ type: 'output-list',
1049
+ outputs: [
1050
+ {
1051
+ beef: sampleBeef3,
1052
+ outputIndex: 0
1053
+ }
1054
+ ]
1055
+ }
1056
+ })
1057
+
1058
+ const resolver = new LookupResolver({
1059
+ facilitator: mockFacilitator,
1060
+ hostOverrides: {
1061
+ ls_backoff: [failingHost, healthyHost]
1062
+ }
1063
+ })
1064
+
1065
+ // First three attempts should contact both hosts (grace period)
1066
+ await resolver.query({ service: 'ls_backoff', query: { attempt: 1 } })
1067
+ fakeNow += 20
1068
+ await resolver.query({ service: 'ls_backoff', query: { attempt: 2 } })
1069
+ fakeNow += 20
1070
+ await resolver.query({ service: 'ls_backoff', query: { attempt: 3 } })
1071
+
1072
+ expect(failingCalls).toBe(3)
1073
+
1074
+ // Immediately try again; failing host should now be in backoff and skipped
1075
+ fakeNow += 20
1076
+ await resolver.query({ service: 'ls_backoff', query: { attempt: 4 } })
1077
+ expect(failingCalls).toBe(3)
1078
+ const lastCall = callLog[callLog.length - 1]
1079
+ expect(lastCall).toBe(healthyHost)
1080
+
1081
+ // Advance beyond the backoff window so the failing host is retried
1082
+ fakeNow = 2000
1083
+ await resolver.query({ service: 'ls_backoff', query: { attempt: 5 } })
1084
+ expect(failingCalls).toBe(4)
1085
+ } finally {
1086
+ nowSpy.mockRestore()
1087
+ }
1088
+ })
1089
+ })
970
1090
  describe('LookupResolver Resiliency', () => {
971
1091
  beforeEach(() => {
972
1092
  mockFacilitator.lookup.mockReset()