@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/overlay-tools/HostReputationTracker.js +216 -0
- package/dist/cjs/src/overlay-tools/HostReputationTracker.js.map +1 -0
- package/dist/cjs/src/overlay-tools/LookupResolver.js +55 -3
- package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/cjs/src/wallet/WalletError.js +2 -2
- package/dist/cjs/src/wallet/WalletError.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/overlay-tools/HostReputationTracker.js +213 -0
- package/dist/esm/src/overlay-tools/HostReputationTracker.js.map +1 -0
- package/dist/esm/src/overlay-tools/LookupResolver.js +56 -3
- package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
- package/dist/esm/src/wallet/WalletError.js +2 -2
- package/dist/esm/src/wallet/WalletError.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/overlay-tools/HostReputationTracker.d.ts +37 -0
- package/dist/types/src/overlay-tools/HostReputationTracker.d.ts.map +1 -0
- package/dist/types/src/overlay-tools/LookupResolver.d.ts +8 -0
- package/dist/types/src/overlay-tools/LookupResolver.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/overlay-tools.md +58 -0
- package/docs/reference/primitives.md +33 -0
- package/docs/tutorials/advanced-transaction.md +2 -2
- package/docs/tutorials/first-transaction.md +1 -1
- package/docs/tutorials/transaction-types.md +1 -1
- package/package.json +1 -1
- package/src/overlay-tools/HostReputationTracker.ts +232 -0
- package/src/overlay-tools/LookupResolver.ts +73 -4
- package/src/overlay-tools/__tests/LookupResolver.test.ts +120 -0
- 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://
|
|
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://
|
|
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://
|
|
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://
|
|
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
|
@@ -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
|
-
|
|
268
|
-
return await this.
|
|
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
|
-
|
|
387
|
-
await this.
|
|
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()
|