@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.
Files changed (102) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/Peer.js +35 -16
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/overlay-tools/HostReputationTracker.js +216 -0
  5. package/dist/cjs/src/overlay-tools/HostReputationTracker.js.map +1 -0
  6. package/dist/cjs/src/overlay-tools/LookupResolver.js +55 -3
  7. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  8. package/dist/cjs/src/primitives/BigNumber.js +43 -31
  9. package/dist/cjs/src/primitives/BigNumber.js.map +1 -1
  10. package/dist/cjs/src/primitives/Hash.js +11 -5
  11. package/dist/cjs/src/primitives/Hash.js.map +1 -1
  12. package/dist/cjs/src/primitives/SymmetricKey.js +15 -6
  13. package/dist/cjs/src/primitives/SymmetricKey.js.map +1 -1
  14. package/dist/cjs/src/primitives/TransactionSignature.js +60 -18
  15. package/dist/cjs/src/primitives/TransactionSignature.js.map +1 -1
  16. package/dist/cjs/src/primitives/utils.js +74 -28
  17. package/dist/cjs/src/primitives/utils.js.map +1 -1
  18. package/dist/cjs/src/script/Script.js +217 -108
  19. package/dist/cjs/src/script/Script.js.map +1 -1
  20. package/dist/cjs/src/script/Spend.js +5 -2
  21. package/dist/cjs/src/script/Spend.js.map +1 -1
  22. package/dist/cjs/src/transaction/Beef.js +62 -7
  23. package/dist/cjs/src/transaction/Beef.js.map +1 -1
  24. package/dist/cjs/src/transaction/BeefTx.js +1 -1
  25. package/dist/cjs/src/transaction/BeefTx.js.map +1 -1
  26. package/dist/cjs/src/transaction/Transaction.js +67 -35
  27. package/dist/cjs/src/transaction/Transaction.js.map +1 -1
  28. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  29. package/dist/esm/src/auth/Peer.js +36 -16
  30. package/dist/esm/src/auth/Peer.js.map +1 -1
  31. package/dist/esm/src/overlay-tools/HostReputationTracker.js +213 -0
  32. package/dist/esm/src/overlay-tools/HostReputationTracker.js.map +1 -0
  33. package/dist/esm/src/overlay-tools/LookupResolver.js +56 -3
  34. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  35. package/dist/esm/src/primitives/BigNumber.js +43 -31
  36. package/dist/esm/src/primitives/BigNumber.js.map +1 -1
  37. package/dist/esm/src/primitives/Hash.js +11 -5
  38. package/dist/esm/src/primitives/Hash.js.map +1 -1
  39. package/dist/esm/src/primitives/SymmetricKey.js +15 -6
  40. package/dist/esm/src/primitives/SymmetricKey.js.map +1 -1
  41. package/dist/esm/src/primitives/TransactionSignature.js +60 -18
  42. package/dist/esm/src/primitives/TransactionSignature.js.map +1 -1
  43. package/dist/esm/src/primitives/utils.js +74 -28
  44. package/dist/esm/src/primitives/utils.js.map +1 -1
  45. package/dist/esm/src/script/Script.js +222 -110
  46. package/dist/esm/src/script/Script.js.map +1 -1
  47. package/dist/esm/src/script/Spend.js +6 -2
  48. package/dist/esm/src/script/Spend.js.map +1 -1
  49. package/dist/esm/src/transaction/Beef.js +64 -7
  50. package/dist/esm/src/transaction/Beef.js.map +1 -1
  51. package/dist/esm/src/transaction/BeefTx.js +1 -1
  52. package/dist/esm/src/transaction/BeefTx.js.map +1 -1
  53. package/dist/esm/src/transaction/Transaction.js +69 -35
  54. package/dist/esm/src/transaction/Transaction.js.map +1 -1
  55. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  56. package/dist/types/src/auth/Peer.d.ts +4 -0
  57. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  58. package/dist/types/src/overlay-tools/HostReputationTracker.d.ts +37 -0
  59. package/dist/types/src/overlay-tools/HostReputationTracker.d.ts.map +1 -0
  60. package/dist/types/src/overlay-tools/LookupResolver.d.ts +8 -0
  61. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  62. package/dist/types/src/primitives/BigNumber.d.ts.map +1 -1
  63. package/dist/types/src/primitives/Hash.d.ts +10 -10
  64. package/dist/types/src/primitives/Hash.d.ts.map +1 -1
  65. package/dist/types/src/primitives/SymmetricKey.d.ts.map +1 -1
  66. package/dist/types/src/primitives/TransactionSignature.d.ts +34 -13
  67. package/dist/types/src/primitives/TransactionSignature.d.ts.map +1 -1
  68. package/dist/types/src/primitives/utils.d.ts +6 -8
  69. package/dist/types/src/primitives/utils.d.ts.map +1 -1
  70. package/dist/types/src/script/Script.d.ts +18 -9
  71. package/dist/types/src/script/Script.d.ts.map +1 -1
  72. package/dist/types/src/script/Spend.d.ts +1 -0
  73. package/dist/types/src/script/Spend.d.ts.map +1 -1
  74. package/dist/types/src/transaction/Beef.d.ts +9 -0
  75. package/dist/types/src/transaction/Beef.d.ts.map +1 -1
  76. package/dist/types/src/transaction/Transaction.d.ts +7 -0
  77. package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
  78. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  79. package/dist/umd/bundle.js +3 -3
  80. package/dist/umd/bundle.js.map +1 -1
  81. package/docs/reference/overlay-tools.md +58 -0
  82. package/docs/reference/primitives.md +120 -37
  83. package/docs/reference/script.md +11 -7
  84. package/docs/reference/transaction.md +2 -0
  85. package/docs/tutorials/advanced-transaction.md +2 -2
  86. package/docs/tutorials/first-transaction.md +1 -1
  87. package/docs/tutorials/transaction-types.md +1 -1
  88. package/package.json +1 -1
  89. package/src/auth/Peer.ts +44 -18
  90. package/src/overlay-tools/HostReputationTracker.ts +232 -0
  91. package/src/overlay-tools/LookupResolver.ts +73 -4
  92. package/src/overlay-tools/__tests/LookupResolver.test.ts +120 -0
  93. package/src/primitives/BigNumber.ts +44 -23
  94. package/src/primitives/Hash.ts +41 -17
  95. package/src/primitives/SymmetricKey.ts +15 -6
  96. package/src/primitives/TransactionSignature.ts +77 -31
  97. package/src/primitives/utils.ts +80 -30
  98. package/src/script/Script.ts +238 -104
  99. package/src/script/Spend.ts +7 -3
  100. package/src/transaction/Beef.ts +74 -7
  101. package/src/transaction/BeefTx.ts +1 -1
  102. 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
- 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()
@@ -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
- let hex = ''
1080
+ if (beBytes.length > 0 && (beBytes[0] & 0x80) !== 0) {
1081
+ sign = 1
1082
+ beBytes[0] &= 0x7f
1083
+ }
1064
1084
 
1065
- if (endian === 'little') {
1066
- const last = bytes.length - 1
1067
- let firstByte = bytes[last]
1068
- if ((firstByte & 0x80) !== 0) { sign = 1; firstByte &= 0x7f }
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 firstByte = bytes[0]
1076
- if ((firstByte & 0x80) !== 0) { sign = 1; firstByte &= 0x7f }
1077
- hex += (firstByte < 16 ? '0' : '') + firstByte.toString(16)
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(mag, sign)
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
- bytes[j++] = parseInt(hex.slice(i, i + 2), 16)
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) bytes.unshift(0x80)
1113
- else bytes[0] |= 0x80
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.unshift(0x00)
1134
+ result = [0x00, ...bytes]
1135
+ } else {
1136
+ result = bytes.slice()
1116
1137
  }
1117
1138
 
1118
- return endian === 'little' ? bytes.reverse() : bytes
1139
+ return endian === 'little' ? result.reverse() : result
1119
1140
  }
1120
1141
 
1121
1142
  /**