@bsv/sdk 1.8.12 → 1.9.0
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/Peer.js +35 -16
- package/dist/cjs/src/auth/Peer.js.map +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/primitives/BigNumber.js +43 -31
- package/dist/cjs/src/primitives/BigNumber.js.map +1 -1
- package/dist/cjs/src/primitives/Hash.js +11 -5
- package/dist/cjs/src/primitives/Hash.js.map +1 -1
- package/dist/cjs/src/primitives/SymmetricKey.js +15 -6
- package/dist/cjs/src/primitives/SymmetricKey.js.map +1 -1
- package/dist/cjs/src/primitives/TransactionSignature.js +60 -18
- package/dist/cjs/src/primitives/TransactionSignature.js.map +1 -1
- package/dist/cjs/src/primitives/utils.js +74 -28
- package/dist/cjs/src/primitives/utils.js.map +1 -1
- package/dist/cjs/src/script/Script.js +217 -108
- package/dist/cjs/src/script/Script.js.map +1 -1
- package/dist/cjs/src/script/Spend.js +5 -2
- package/dist/cjs/src/script/Spend.js.map +1 -1
- package/dist/cjs/src/transaction/Beef.js +62 -7
- package/dist/cjs/src/transaction/Beef.js.map +1 -1
- package/dist/cjs/src/transaction/BeefTx.js +1 -1
- package/dist/cjs/src/transaction/BeefTx.js.map +1 -1
- package/dist/cjs/src/transaction/Transaction.js +67 -35
- package/dist/cjs/src/transaction/Transaction.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/Peer.js +36 -16
- package/dist/esm/src/auth/Peer.js.map +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/primitives/BigNumber.js +43 -31
- package/dist/esm/src/primitives/BigNumber.js.map +1 -1
- package/dist/esm/src/primitives/Hash.js +11 -5
- package/dist/esm/src/primitives/Hash.js.map +1 -1
- package/dist/esm/src/primitives/SymmetricKey.js +15 -6
- package/dist/esm/src/primitives/SymmetricKey.js.map +1 -1
- package/dist/esm/src/primitives/TransactionSignature.js +60 -18
- package/dist/esm/src/primitives/TransactionSignature.js.map +1 -1
- package/dist/esm/src/primitives/utils.js +74 -28
- package/dist/esm/src/primitives/utils.js.map +1 -1
- package/dist/esm/src/script/Script.js +222 -110
- package/dist/esm/src/script/Script.js.map +1 -1
- package/dist/esm/src/script/Spend.js +6 -2
- package/dist/esm/src/script/Spend.js.map +1 -1
- package/dist/esm/src/transaction/Beef.js +64 -7
- package/dist/esm/src/transaction/Beef.js.map +1 -1
- package/dist/esm/src/transaction/BeefTx.js +1 -1
- package/dist/esm/src/transaction/BeefTx.js.map +1 -1
- package/dist/esm/src/transaction/Transaction.js +69 -35
- package/dist/esm/src/transaction/Transaction.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/Peer.d.ts +4 -0
- package/dist/types/src/auth/Peer.d.ts.map +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/src/primitives/BigNumber.d.ts.map +1 -1
- package/dist/types/src/primitives/Hash.d.ts +10 -10
- package/dist/types/src/primitives/Hash.d.ts.map +1 -1
- package/dist/types/src/primitives/SymmetricKey.d.ts.map +1 -1
- package/dist/types/src/primitives/TransactionSignature.d.ts +34 -13
- package/dist/types/src/primitives/TransactionSignature.d.ts.map +1 -1
- package/dist/types/src/primitives/utils.d.ts +6 -8
- package/dist/types/src/primitives/utils.d.ts.map +1 -1
- package/dist/types/src/script/Script.d.ts +18 -9
- package/dist/types/src/script/Script.d.ts.map +1 -1
- package/dist/types/src/script/Spend.d.ts +1 -0
- package/dist/types/src/script/Spend.d.ts.map +1 -1
- package/dist/types/src/transaction/Beef.d.ts +9 -0
- package/dist/types/src/transaction/Beef.d.ts.map +1 -1
- package/dist/types/src/transaction/Transaction.d.ts +7 -0
- package/dist/types/src/transaction/Transaction.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 +120 -37
- package/docs/reference/script.md +11 -7
- package/docs/reference/transaction.md +2 -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/auth/Peer.ts +44 -18
- 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/primitives/BigNumber.ts +44 -23
- package/src/primitives/Hash.ts +41 -17
- package/src/primitives/SymmetricKey.ts +15 -6
- package/src/primitives/TransactionSignature.ts +77 -31
- package/src/primitives/utils.ts +80 -30
- package/src/script/Script.ts +238 -104
- package/src/script/Spend.ts +7 -3
- package/src/transaction/Beef.ts +74 -7
- package/src/transaction/BeefTx.ts +1 -1
- package/src/transaction/Transaction.ts +77 -34
|
@@ -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()
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import ReductionContext from './ReductionContext.js'
|
|
3
3
|
|
|
4
|
+
const BufferCtor =
|
|
5
|
+
typeof globalThis !== 'undefined' ? (globalThis as any).Buffer : undefined
|
|
6
|
+
const CAN_USE_BUFFER =
|
|
7
|
+
BufferCtor != null && typeof BufferCtor.from === 'function'
|
|
8
|
+
const HEX_CHAR_TO_VALUE = new Int8Array(256).fill(-1)
|
|
9
|
+
for (let i = 0; i < 10; i++) {
|
|
10
|
+
HEX_CHAR_TO_VALUE[48 + i] = i // '0'-'9'
|
|
11
|
+
}
|
|
12
|
+
for (let i = 0; i < 6; i++) {
|
|
13
|
+
HEX_CHAR_TO_VALUE[65 + i] = 10 + i // 'A'-'F'
|
|
14
|
+
HEX_CHAR_TO_VALUE[97 + i] = 10 + i // 'a'-'f'
|
|
15
|
+
}
|
|
16
|
+
|
|
4
17
|
/**
|
|
5
18
|
* JavaScript numbers are only precise up to 53 bits. Since Bitcoin relies on
|
|
6
19
|
* 256-bit cryptography, this BigNumber class enables operations on larger
|
|
@@ -1059,31 +1072,30 @@ export default class BigNumber {
|
|
|
1059
1072
|
static fromSm (bytes: number[], endian: 'big' | 'little' = 'big'): BigNumber {
|
|
1060
1073
|
if (bytes.length === 0) return new BigNumber(0n)
|
|
1061
1074
|
|
|
1075
|
+
const beBytes = bytes.slice()
|
|
1076
|
+
if (endian === 'little') {
|
|
1077
|
+
beBytes.reverse()
|
|
1078
|
+
}
|
|
1062
1079
|
let sign: 0 | 1 = 0
|
|
1063
|
-
|
|
1080
|
+
if (beBytes.length > 0 && (beBytes[0] & 0x80) !== 0) {
|
|
1081
|
+
sign = 1
|
|
1082
|
+
beBytes[0] &= 0x7f
|
|
1083
|
+
}
|
|
1064
1084
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
hex += (firstByte < 16 ? '0' : '') + firstByte.toString(16)
|
|
1070
|
-
for (let i = last - 1; i >= 0; i--) {
|
|
1071
|
-
const b = bytes[i]
|
|
1072
|
-
hex += (b < 16 ? '0' : '') + b.toString(16)
|
|
1073
|
-
}
|
|
1085
|
+
let magnitude = 0n
|
|
1086
|
+
if (CAN_USE_BUFFER) {
|
|
1087
|
+
const hex = BufferCtor.from(beBytes).toString('hex') as string
|
|
1088
|
+
magnitude = hex.length === 0 ? 0n : BigInt('0x' + hex)
|
|
1074
1089
|
} else {
|
|
1075
|
-
let
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
for (let i = 1; i < bytes.length; i++) {
|
|
1079
|
-
const b = bytes[i]
|
|
1080
|
-
hex += (b < 16 ? '0' : '') + b.toString(16)
|
|
1090
|
+
let hex = ''
|
|
1091
|
+
for (const byte of beBytes) {
|
|
1092
|
+
hex += byte < 16 ? '0' + byte.toString(16) : byte.toString(16)
|
|
1081
1093
|
}
|
|
1094
|
+
magnitude = hex.length === 0 ? 0n : BigInt('0x' + hex)
|
|
1082
1095
|
}
|
|
1083
1096
|
|
|
1084
|
-
const mag = hex === '' ? 0n : BigInt('0x' + hex)
|
|
1085
1097
|
const r = new BigNumber(0n)
|
|
1086
|
-
r._initializeState(
|
|
1098
|
+
r._initializeState(magnitude, sign)
|
|
1087
1099
|
return r
|
|
1088
1100
|
}
|
|
1089
1101
|
|
|
@@ -1105,17 +1117,26 @@ export default class BigNumber {
|
|
|
1105
1117
|
const byteLen = hex.length / 2
|
|
1106
1118
|
const bytes = new Array(byteLen)
|
|
1107
1119
|
for (let i = 0, j = 0; i < hex.length; i += 2) {
|
|
1108
|
-
|
|
1120
|
+
const high = HEX_CHAR_TO_VALUE[hex.charCodeAt(i)]
|
|
1121
|
+
const low = HEX_CHAR_TO_VALUE[hex.charCodeAt(i + 1)]
|
|
1122
|
+
bytes[j++] = ((high & 0xf) << 4) | (low & 0xf)
|
|
1109
1123
|
}
|
|
1110
1124
|
|
|
1125
|
+
let result: number[]
|
|
1111
1126
|
if (this._sign === 1) {
|
|
1112
|
-
if ((bytes[0] & 0x80) !== 0)
|
|
1113
|
-
|
|
1127
|
+
if ((bytes[0] & 0x80) !== 0) {
|
|
1128
|
+
result = [0x80, ...bytes]
|
|
1129
|
+
} else {
|
|
1130
|
+
result = bytes.slice()
|
|
1131
|
+
result[0] |= 0x80
|
|
1132
|
+
}
|
|
1114
1133
|
} else if ((bytes[0] & 0x80) !== 0) {
|
|
1115
|
-
bytes
|
|
1134
|
+
result = [0x00, ...bytes]
|
|
1135
|
+
} else {
|
|
1136
|
+
result = bytes.slice()
|
|
1116
1137
|
}
|
|
1117
1138
|
|
|
1118
|
-
return endian === 'little' ?
|
|
1139
|
+
return endian === 'little' ? result.reverse() : result
|
|
1119
1140
|
}
|
|
1120
1141
|
|
|
1121
1142
|
/**
|