@bsv/sdk 2.0.13 → 2.0.15

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 (127) hide show
  1. package/README.md +2 -0
  2. package/dist/cjs/package.json +14 -14
  3. package/dist/cjs/src/identity/IdentityClient.js +3 -3
  4. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  5. package/dist/cjs/src/identity/types/index.js +1 -1
  6. package/dist/cjs/src/identity/types/index.js.map +1 -1
  7. package/dist/cjs/src/primitives/Hash.js +1 -1
  8. package/dist/cjs/src/primitives/Hash.js.map +1 -1
  9. package/dist/cjs/src/primitives/TransactionSignature.js +10 -3
  10. package/dist/cjs/src/primitives/TransactionSignature.js.map +1 -1
  11. package/dist/cjs/src/script/Script.js +60 -13
  12. package/dist/cjs/src/script/Script.js.map +1 -1
  13. package/dist/cjs/src/script/Spend.js +434 -59
  14. package/dist/cjs/src/script/Spend.js.map +1 -1
  15. package/dist/cjs/src/storage/StorageUploader.js +315 -96
  16. package/dist/cjs/src/storage/StorageUploader.js.map +1 -1
  17. package/dist/cjs/src/storage/index.js +3 -1
  18. package/dist/cjs/src/storage/index.js.map +1 -1
  19. package/dist/cjs/src/transaction/http/BinaryFetchClient.js +6 -2
  20. package/dist/cjs/src/transaction/http/BinaryFetchClient.js.map +1 -1
  21. package/dist/cjs/src/transaction/http/DefaultHttpClient.js +8 -4
  22. package/dist/cjs/src/transaction/http/DefaultHttpClient.js.map +1 -1
  23. package/dist/cjs/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
  24. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  25. package/dist/esm/src/identity/IdentityClient.js +3 -3
  26. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  27. package/dist/esm/src/identity/types/index.js +1 -1
  28. package/dist/esm/src/identity/types/index.js.map +1 -1
  29. package/dist/esm/src/primitives/Hash.js +1 -1
  30. package/dist/esm/src/primitives/Hash.js.map +1 -1
  31. package/dist/esm/src/primitives/TransactionSignature.js +10 -3
  32. package/dist/esm/src/primitives/TransactionSignature.js.map +1 -1
  33. package/dist/esm/src/script/Script.js +60 -13
  34. package/dist/esm/src/script/Script.js.map +1 -1
  35. package/dist/esm/src/script/Spend.js +438 -59
  36. package/dist/esm/src/script/Spend.js.map +1 -1
  37. package/dist/esm/src/storage/StorageUploader.js +319 -95
  38. package/dist/esm/src/storage/StorageUploader.js.map +1 -1
  39. package/dist/esm/src/storage/index.js +1 -1
  40. package/dist/esm/src/storage/index.js.map +1 -1
  41. package/dist/esm/src/transaction/http/BinaryFetchClient.js +6 -2
  42. package/dist/esm/src/transaction/http/BinaryFetchClient.js.map +1 -1
  43. package/dist/esm/src/transaction/http/DefaultHttpClient.js +8 -4
  44. package/dist/esm/src/transaction/http/DefaultHttpClient.js.map +1 -1
  45. package/dist/esm/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
  46. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  47. package/dist/types/src/primitives/TransactionSignature.d.ts +1 -0
  48. package/dist/types/src/primitives/TransactionSignature.d.ts.map +1 -1
  49. package/dist/types/src/script/Script.d.ts +1 -0
  50. package/dist/types/src/script/Script.d.ts.map +1 -1
  51. package/dist/types/src/script/ScriptChunk.d.ts +1 -0
  52. package/dist/types/src/script/ScriptChunk.d.ts.map +1 -1
  53. package/dist/types/src/script/Spend.d.ts +29 -0
  54. package/dist/types/src/script/Spend.d.ts.map +1 -1
  55. package/dist/types/src/storage/StorageUploader.d.ts +94 -55
  56. package/dist/types/src/storage/StorageUploader.d.ts.map +1 -1
  57. package/dist/types/src/storage/index.d.ts +1 -1
  58. package/dist/types/src/storage/index.d.ts.map +1 -1
  59. package/dist/types/src/transaction/http/BinaryFetchClient.d.ts.map +1 -1
  60. package/dist/types/src/transaction/http/DefaultHttpClient.d.ts +2 -2
  61. package/dist/types/src/transaction/http/DefaultHttpClient.d.ts.map +1 -1
  62. package/dist/types/src/wallet/WERR_REVIEW_ACTIONS.d.ts +2 -2
  63. package/dist/types/src/wallet/Wallet.interfaces.d.ts +2 -2
  64. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  65. package/dist/umd/bundle.js +4 -5
  66. package/docs/index.md +3 -1
  67. package/docs/reference/identity.md +1 -1
  68. package/docs/reference/primitives.md +1 -0
  69. package/docs/reference/script.md +7 -0
  70. package/docs/reference/storage.md +214 -85
  71. package/docs/reference/transaction.md +4 -4
  72. package/docs/reference/wallet.md +4 -4
  73. package/package.json +14 -14
  74. package/src/identity/IdentityClient.ts +3 -3
  75. package/src/identity/__tests/IdentityClient.additional.test.ts +3 -3
  76. package/src/identity/types/index.ts +1 -1
  77. package/src/primitives/Hash.ts +1 -1
  78. package/src/primitives/TransactionSignature.ts +11 -3
  79. package/src/script/Script.ts +59 -13
  80. package/src/script/ScriptChunk.ts +1 -0
  81. package/src/script/Spend.ts +483 -61
  82. package/src/script/__tests/NormativeVectors.test.ts +465 -0
  83. package/src/script/__tests/fixtures/SOURCES.md +25 -0
  84. package/src/script/__tests/fixtures/bitcoin-sv/script_tests.json +2591 -0
  85. package/src/script/__tests/fixtures/bitcoin-sv/sighash.json +1003 -0
  86. package/src/script/__tests/fixtures/bitcoin-sv/tx_invalid.json +285 -0
  87. package/src/script/__tests/fixtures/bitcoin-sv/tx_valid.json +367 -0
  88. package/src/script/__tests/fixtures/teranode/script_tests.json +2432 -0
  89. package/src/script/__tests/fixtures/teranode/sighash.json +1003 -0
  90. package/src/script/__tests/fixtures/teranode/tx_invalid.json +285 -0
  91. package/src/script/__tests/fixtures/teranode/tx_valid.json +367 -0
  92. package/src/storage/StorageUploader.ts +427 -105
  93. package/src/storage/__tests/StorageUploader.test.ts +881 -64
  94. package/src/storage/index.ts +1 -1
  95. package/src/transaction/broadcasters/__tests/ARC.test.ts +26 -4
  96. package/src/transaction/broadcasters/__tests/WhatsOnChainBroadcaster.test.ts +26 -4
  97. package/src/transaction/chaintrackers/__tests/WhatsOnChainChainTracker.test.ts +32 -10
  98. package/src/transaction/http/BinaryFetchClient.ts +5 -2
  99. package/src/transaction/http/DefaultHttpClient.ts +7 -4
  100. package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +19 -1
  101. package/src/wallet/WERR_REVIEW_ACTIONS.ts +2 -2
  102. package/src/wallet/Wallet.interfaces.ts +2 -2
  103. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +0 -827
  104. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +0 -1
  105. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +0 -266
  106. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +0 -1
  107. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +0 -654
  108. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +0 -1
  109. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +0 -144
  110. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +0 -1
  111. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +0 -825
  112. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +0 -1
  113. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +0 -264
  114. package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +0 -1
  115. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +0 -619
  116. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +0 -1
  117. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +0 -109
  118. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +0 -1
  119. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +0 -21
  120. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +0 -1
  121. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts +0 -2
  122. package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts.map +0 -1
  123. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +0 -2
  124. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +0 -1
  125. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts +0 -2
  126. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts.map +0 -1
  127. package/dist/umd/bundle.js.map +0 -1
@@ -2,8 +2,19 @@ import { AuthFetch } from '../auth/clients/AuthFetch.js'
2
2
  import { WalletInterface } from '../wallet/Wallet.interfaces.js'
3
3
  import * as StorageUtils from './StorageUtils.js'
4
4
 
5
+ /** Default UHRP storage providers used when the caller passes no host list. */
6
+ export const DEFAULT_UHRP_SERVERS: string[] = [
7
+ 'https://nanostore.babbage.systems',
8
+ 'https://bsv-storage-cloudflare.dev-a3e.workers.dev'
9
+ ]
10
+
5
11
  export interface UploaderConfig {
6
- storageURL: string
12
+ /** Legacy single-host URL. Mutually exclusive with `storageURLs`. */
13
+ storageURL?: string
14
+ /** Explicit provider list. Takes precedence over `storageURL`. */
15
+ storageURLs?: string[]
16
+ /** Minimum replicas to store the file on. Defaults to 1. */
17
+ resilienceLevel?: number
7
18
  wallet: WalletInterface
8
19
  }
9
20
 
@@ -15,6 +26,8 @@ export interface UploadableFile {
15
26
  export interface UploadFileResult {
16
27
  published: boolean
17
28
  uhrpURL: string
29
+ /** Providers that successfully hosted the file. */
30
+ hostedBy: string[]
18
31
  }
19
32
 
20
33
  export interface FindFileData {
@@ -22,44 +35,130 @@ export interface FindFileData {
22
35
  size: string
23
36
  mimeType: string
24
37
  expiryTime: number
38
+ /** Providers that reported this UHRP URL. Omitted in single-host mode. */
39
+ hostedBy?: string[]
40
+ }
41
+
42
+ export interface RenewPerHostResult {
43
+ host: string
44
+ status: 'success' | 'error'
45
+ prevExpiryTime?: number
46
+ newExpiryTime?: number
47
+ amount?: number
48
+ error?: string
25
49
  }
26
50
 
27
51
  export interface RenewFileResult {
28
52
  status: string
29
53
  prevExpiryTime?: number
30
54
  newExpiryTime?: number
55
+ /** Total satoshis paid across every host that renewed. */
31
56
  amount?: number
57
+ /** Per-host outcomes. Omitted in single-host mode. */
58
+ results?: RenewPerHostResult[]
59
+ }
60
+
61
+ export interface HostScopeOptions {
62
+ /** Restrict the operation to this subset of configured providers. */
63
+ hostedBy?: string[]
64
+ }
65
+
66
+ export interface EstimateCostResult {
67
+ /** Cheapest-first quotes from configured providers. */
68
+ quotes: Array<{ host: string, amount: number }>
69
+ resilienceLevel: number
70
+ /** Sum of the cheapest `resilienceLevel` amounts (or all collected, if below threshold). */
71
+ totalForResilience: number
72
+ /** False when `publishFile` would throw without uploading. */
73
+ meetsResilienceThreshold: boolean
74
+ }
75
+
76
+ /**
77
+ * Thrown by `renewFile` when successful renewals fall below the resilience
78
+ * threshold. Per-host outcomes are attached so callers can reconcile which
79
+ * providers were billed.
80
+ */
81
+ export class RenewResiliencyError extends Error {
82
+ readonly results: RenewPerHostResult[]
83
+ readonly requiredSuccesses: number
84
+ readonly successCount: number
85
+
86
+ constructor (message: string, results: RenewPerHostResult[], requiredSuccesses: number, successCount: number) {
87
+ super(message)
88
+ this.name = 'RenewResiliencyError'
89
+ this.results = results
90
+ this.requiredSuccesses = requiredSuccesses
91
+ this.successCount = successCount
92
+ }
93
+ }
94
+
95
+ interface ProviderQuote {
96
+ host: string
97
+ amount: number
32
98
  }
33
99
 
34
100
  /**
35
- * The StorageUploader class provides client-side methods for:
36
- * - Uploading files with a specified retention period
37
- * - Finding file metadata by UHRP URL
38
- * - Listing all user uploads
39
- * - Renewing an existing advertisement's expiry time
101
+ * Client for publishing, finding, listing, and renewing UHRP-hosted files
102
+ * across one or more storage providers.
40
103
  */
41
104
  export class StorageUploader {
42
105
  private readonly authFetch: AuthFetch
106
+ private readonly hosts: string[]
107
+ private readonly resilienceLevel: number
108
+ /** Primary host used for non-upload operations. */
43
109
  private readonly baseURL: string
44
110
 
45
- /**
46
- * Creates a new StorageUploader instance.
47
- * @param {UploaderConfig} config - An object containing the storage server's URL and a wallet interface
48
- */
49
111
  constructor (config: UploaderConfig) {
50
- this.baseURL = config.storageURL
112
+ const legacySingleHost = config.storageURLs === undefined && typeof config.storageURL === 'string'
113
+
114
+ let hosts: string[]
115
+ if (config.storageURLs !== undefined) {
116
+ if (config.storageURLs.length === 0) {
117
+ throw new Error('StorageUploader requires at least one storage provider.')
118
+ }
119
+ hosts = [...config.storageURLs]
120
+ } else if (typeof config.storageURL === 'string') {
121
+ hosts = [config.storageURL]
122
+ } else {
123
+ hosts = [...DEFAULT_UHRP_SERVERS]
124
+ }
125
+
126
+ const requestedLevel = config.resilienceLevel ?? 1
127
+ if (!Number.isInteger(requestedLevel) || requestedLevel < 1) {
128
+ throw new Error('resilienceLevel must be a positive integer.')
129
+ }
130
+
131
+ // Legacy `storageURL` callers must not start demanding extra replicas.
132
+ this.resilienceLevel = legacySingleHost ? 1 : requestedLevel
133
+ this.hosts = hosts
134
+ this.baseURL = hosts[0]
51
135
  this.authFetch = new AuthFetch(config.wallet)
52
136
  }
53
137
 
54
- /**
55
- * Requests information from the server to upload a file (including presigned URL and headers).
56
- * @private
57
- * @param {number} fileSize - The size of the file, in bytes
58
- * @param {number} retentionPeriod - The desired hosting time, in minutes
59
- * @returns {Promise<{ uploadURL: string; requiredHeaders: Record<string, string>; amount?: number }>}
60
- * @throws {Error} If the server returns a non-OK response or an error status
61
- */
62
- private async getUploadInfo (
138
+ /** Returns `null` when the provider is unreachable or errors out. */
139
+ private async getQuote (
140
+ host: string,
141
+ fileSize: number,
142
+ retentionPeriod: number
143
+ ): Promise<ProviderQuote | null> {
144
+ try {
145
+ const response = await fetch(`${host}/quote`, {
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({ fileSize, retentionPeriod })
149
+ })
150
+ if (!response.ok) return null
151
+ const data = await response.json() as { quote?: number, status?: string }
152
+ if (data.status === 'error' || typeof data.quote !== 'number') return null
153
+ return { host, amount: data.quote }
154
+ } catch {
155
+ return null
156
+ }
157
+ }
158
+
159
+ /** Drives the authenticated `/upload` route; `AuthFetch` handles the 402 payment flow. */
160
+ private async getUploadURL (
161
+ host: string,
63
162
  fileSize: number,
64
163
  retentionPeriod: number
65
164
  ): Promise<{
@@ -67,13 +166,10 @@ export class StorageUploader {
67
166
  requiredHeaders: Record<string, string>
68
167
  amount?: number
69
168
  }> {
70
- const url = `${this.baseURL}/upload`
71
- const body = { fileSize, retentionPeriod }
72
-
73
- const response = await this.authFetch.fetch(url, {
169
+ const response = await this.authFetch.fetch(`${host}/upload`, {
74
170
  method: 'POST',
75
171
  headers: { 'Content-Type': 'application/json' },
76
- body: JSON.stringify(body)
172
+ body: JSON.stringify({ fileSize, retentionPeriod })
77
173
  })
78
174
  if (!response.ok) {
79
175
  throw new Error(`Upload info request failed: HTTP ${response.status}`)
@@ -94,54 +190,82 @@ export class StorageUploader {
94
190
  }
95
191
  }
96
192
 
97
- /**
98
- * Performs the actual file upload (HTTP PUT) to the presigned URL returned by the server.
99
- * @private
100
- * @param {string} uploadURL - The presigned URL where the file is to be uploaded
101
- * @param {UploadableFile} file - The file to upload, including its raw data and MIME type
102
- * @param {Record<string, string>} requiredHeaders - Additional headers required by the server (e.g. content-length)
103
- * @returns {Promise<UploadFileResult>} An object indicating whether publishing was successful and the resulting UHRP URL
104
- * @throws {Error} If the server returns a non-OK response
105
- */
106
- private async uploadFile (
193
+ private async putFile (
107
194
  uploadURL: string,
108
- file: UploadableFile,
195
+ data: Uint8Array,
196
+ contentType: string,
109
197
  requiredHeaders: Record<string, string>
110
- ): Promise<UploadFileResult> {
111
- const body = file.data instanceof Uint8Array ? file.data : Uint8Array.from(file.data)
112
-
198
+ ): Promise<void> {
113
199
  const response = await fetch(uploadURL, {
114
200
  method: 'PUT',
115
- body: body as BodyInit,
201
+ body: data as BodyInit,
116
202
  headers: {
117
- 'Content-Type': file.type,
203
+ 'Content-Type': contentType,
118
204
  ...requiredHeaders
119
205
  }
120
206
  })
121
207
  if (!response.ok) {
122
208
  throw new Error(`File upload failed: HTTP ${response.status}`)
123
209
  }
210
+ }
211
+
212
+ /**
213
+ * Collects quotes in parallel batches, shrinking each batch to only the
214
+ * remaining quotes still needed so we never over-query once the quote
215
+ * budget is satisfied.
216
+ */
217
+ private async collectQuotes (
218
+ fileSize: number,
219
+ retentionPeriod: number,
220
+ maxNeeded: number
221
+ ): Promise<ProviderQuote[]> {
222
+ const quotes: ProviderQuote[] = []
223
+ let index = 0
224
+ while (index < this.hosts.length && quotes.length < maxNeeded) {
225
+ const remaining = maxNeeded - quotes.length
226
+ const batch = this.hosts.slice(index, index + remaining)
227
+ index += batch.length
228
+ const results = await Promise.all(
229
+ batch.map(async host => await this.getQuote(host, fileSize, retentionPeriod))
230
+ )
231
+ for (const quote of results) {
232
+ if (quote !== null && quotes.length < maxNeeded) {
233
+ quotes.push(quote)
234
+ }
235
+ }
236
+ }
237
+ return quotes
238
+ }
239
+
240
+ /**
241
+ * Queries the unauthenticated `/quote` endpoint on up to `2 * resilienceLevel`
242
+ * providers and returns the cheapest-first quote list plus the aggregate
243
+ * cost `publishFile` would pay. No provider is billed.
244
+ */
245
+ public async estimateCost (params: {
246
+ fileSize: number
247
+ retentionPeriod: number
248
+ }): Promise<EstimateCostResult> {
249
+ const { fileSize, retentionPeriod } = params
250
+ const quotes = await this.collectQuotes(fileSize, retentionPeriod, this.resilienceLevel * 2)
251
+ quotes.sort((a, b) => a.amount - b.amount)
252
+
253
+ const meetsResilienceThreshold = quotes.length >= this.resilienceLevel
254
+ const budget = meetsResilienceThreshold ? quotes.slice(0, this.resilienceLevel) : quotes
255
+ const totalForResilience = budget.reduce((sum, q) => sum + q.amount, 0)
124
256
 
125
- const uhrpURL = StorageUtils.getURLForFile(body)
126
257
  return {
127
- published: true,
128
- uhrpURL
258
+ quotes: quotes.map(q => ({ host: q.host, amount: q.amount })),
259
+ resilienceLevel: this.resilienceLevel,
260
+ totalForResilience,
261
+ meetsResilienceThreshold
129
262
  }
130
263
  }
131
264
 
132
265
  /**
133
- * Publishes a file to the storage server with the specified retention period.
134
- *
135
- * This will:
136
- * 1. Request an upload URL from the server.
137
- * 2. Perform an HTTP PUT to upload the file’s raw bytes.
138
- * 3. Return a UHRP URL referencing the file once published.
139
- *
140
- * @param {Object} params
141
- * @param {UploadableFile} params.file - The file data + type
142
- * @param {number} params.retentionPeriod - Number of minutes to host the file
143
- * @returns {Promise<UploadFileResult>} An object with the file's UHRP URL
144
- * @throws {Error} If the server or upload step returns a non-OK response
266
+ * Publishes a file across the cheapest configured providers, falling
267
+ * through to the next-cheapest quote if a paid upload fails. Throws when
268
+ * the resilience threshold cannot be met.
145
269
  */
146
270
  public async publishFile (params: {
147
271
  file: UploadableFile
@@ -151,18 +275,51 @@ export class StorageUploader {
151
275
  const data = file.data instanceof Uint8Array ? file.data : Uint8Array.from(file.data)
152
276
  const fileSize = data.byteLength
153
277
 
154
- const { uploadURL, requiredHeaders } = await this.getUploadInfo(fileSize, retentionPeriod)
155
- return await this.uploadFile(uploadURL, { data, type: file.type }, requiredHeaders)
278
+ const estimate = await this.estimateCost({ fileSize, retentionPeriod })
279
+
280
+ if (!estimate.meetsResilienceThreshold) {
281
+ throw new Error(
282
+ `Resiliency threshold of ${this.resilienceLevel} could not be met: ` +
283
+ `only ${estimate.quotes.length} of ${this.hosts.length} provider(s) responded with quotes.`
284
+ )
285
+ }
286
+
287
+ const uhrpURL = StorageUtils.getURLForFile(data)
288
+ const hostedBy: string[] = []
289
+ const failures: Array<{ host: string, error: string }> = []
290
+
291
+ for (const quote of estimate.quotes) {
292
+ if (hostedBy.length >= this.resilienceLevel) break
293
+ try {
294
+ const { uploadURL, requiredHeaders } = await this.getUploadURL(
295
+ quote.host,
296
+ fileSize,
297
+ retentionPeriod
298
+ )
299
+ await this.putFile(uploadURL, data, file.type, requiredHeaders)
300
+ hostedBy.push(quote.host)
301
+ } catch (e) {
302
+ failures.push({ host: quote.host, error: (e as Error).message })
303
+ }
304
+ }
305
+
306
+ if (hostedBy.length < this.resilienceLevel) {
307
+ const detail = failures.map(f => `${f.host}: ${f.error}`).join('; ')
308
+ throw new Error(
309
+ `Resiliency threshold of ${this.resilienceLevel} could not be met: ` +
310
+ `only ${hostedBy.length} upload(s) succeeded. Failures — ${detail}`
311
+ )
312
+ }
313
+
314
+ return {
315
+ published: true,
316
+ uhrpURL,
317
+ hostedBy
318
+ }
156
319
  }
157
320
 
158
- /**
159
- * Retrieves metadata for a file matching the given UHRP URL from the `/find` route.
160
- * @param {string} uhrpUrl - The UHRP URL, e.g. "uhrp://abcd..."
161
- * @returns {Promise<FindFileData>} An object with file name, size, MIME type, and expiry time
162
- * @throws {Error} If the server or the route returns an error
163
- */
164
- public async findFile (uhrpUrl: string): Promise<FindFileData> {
165
- const url = new URL(`${this.baseURL}/find`)
321
+ private async findFileAtHost (host: string, uhrpUrl: string): Promise<FindFileData> {
322
+ const url = new URL(`${host}/find`)
166
323
  url.searchParams.set('uhrpUrl', uhrpUrl)
167
324
 
168
325
  const response = await this.authFetch.fetch(url.toString(), {
@@ -187,47 +344,15 @@ export class StorageUploader {
187
344
  return data.data
188
345
  }
189
346
 
190
- /**
191
- * Lists all advertisements belonging to the user from the `/list` route.
192
- * @returns {Promise<any>} The array of uploads returned by the server
193
- * @throws {Error} If the server or the route returns an error
194
- */
195
- public async listUploads (): Promise<any> {
196
- const url = `${this.baseURL}/list`
197
- const response = await this.authFetch.fetch(url, {
198
- method: 'GET'
199
- })
200
- if (!response.ok) {
201
- throw new Error(`listUploads request failed: HTTP ${response.status}`)
202
- }
203
-
204
- const data = await response.json()
205
- if (data.status === 'error') {
206
- const errCode = data.code as string ?? 'unknown-code'
207
- const errDesc = data.description as string ?? 'no-description'
208
- throw new Error(`listUploads returned an error: ${errCode} - ${errDesc}`)
209
- }
210
- return data.uploads
211
- }
212
-
213
- /**
214
- * Renews the hosting time for an existing file advertisement identified by uhrpUrl.
215
- * Calls the `/renew` route to add `additionalMinutes` to the GCS customTime
216
- * and re-mint the advertisement token on-chain.
217
- *
218
- * @param {string} uhrpUrl - The UHRP URL of the file (e.g., "uhrp://abcd1234...")
219
- * @param {number} additionalMinutes - The number of minutes to extend
220
- * @returns {Promise<RenewFileResult>} An object with the new and previous expiry times, plus any cost
221
- * @throws {Error} If the request fails or the server returns an error
222
- */
223
- public async renewFile (uhrpUrl: string, additionalMinutes: number): Promise<RenewFileResult> {
224
- const url = `${this.baseURL}/renew`
225
- const body = { uhrpUrl, additionalMinutes }
226
-
227
- const response = await this.authFetch.fetch(url, {
347
+ private async renewFileAtHost (
348
+ host: string,
349
+ uhrpUrl: string,
350
+ additionalMinutes: number
351
+ ): Promise<{ status: string, prevExpiryTime?: number, newExpiryTime?: number, amount?: number }> {
352
+ const response = await this.authFetch.fetch(`${host}/renew`, {
228
353
  method: 'POST',
229
354
  headers: { 'Content-Type': 'application/json' },
230
- body: JSON.stringify(body)
355
+ body: JSON.stringify({ uhrpUrl, additionalMinutes })
231
356
  })
232
357
  if (!response.ok) {
233
358
  throw new Error(`renewFile request failed: HTTP ${response.status}`)
@@ -255,4 +380,201 @@ export class StorageUploader {
255
380
  amount: data.amount
256
381
  }
257
382
  }
383
+
384
+ /** Intersects `hostedBy` with the configured host set; throws when empty. */
385
+ private resolveTargets (hostedBy?: string[]): string[] {
386
+ if (hostedBy === undefined) return this.hosts
387
+ const configured = new Set(this.hosts)
388
+ const intersection = hostedBy.filter(h => configured.has(h))
389
+ if (intersection.length === 0) {
390
+ throw new Error(
391
+ 'hostedBy did not intersect any configured provider. ' +
392
+ 'Provide hosts that were also passed to the StorageUploader constructor.'
393
+ )
394
+ }
395
+ return intersection
396
+ }
397
+
398
+ /**
399
+ * Fans `/find` out across configured hosts (UHRP storage is host-local,
400
+ * so any one host may not know the file) and returns the record with the
401
+ * longest remaining expiry. Single-host configurations preserve the
402
+ * legacy error-message contract verbatim.
403
+ */
404
+ public async findFile (uhrpUrl: string, options: HostScopeOptions = {}): Promise<FindFileData> {
405
+ const targets = this.resolveTargets(options.hostedBy)
406
+
407
+ const outcomes = await Promise.all(
408
+ targets.map(async host => {
409
+ try {
410
+ return { ok: true, host, data: await this.findFileAtHost(host, uhrpUrl) } as const
411
+ } catch (e) {
412
+ return { ok: false, host, error: e as Error } as const
413
+ }
414
+ })
415
+ )
416
+
417
+ const successes = outcomes.flatMap(o => o.ok ? [o] : [])
418
+ if (successes.length === 0) {
419
+ const failures = outcomes.flatMap(o => o.ok ? [] : [o])
420
+ if (targets.length === 1) throw failures[0].error
421
+ const detail = failures.map(f => `${f.host}: ${f.error.message}`).join('; ')
422
+ throw new Error(`findFile: no configured host reported this UHRP URL — ${detail}`)
423
+ }
424
+
425
+ successes.sort((a, b) => b.data.expiryTime - a.data.expiryTime)
426
+ const best = successes[0]
427
+
428
+ if (targets.length === 1) {
429
+ return best.data
430
+ }
431
+ return {
432
+ ...best.data,
433
+ hostedBy: successes.map(s => s.host)
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Unions `/list` output across configured hosts, merging duplicate UHRP
439
+ * URLs by the longest expiry observed. One failing host does not hide
440
+ * the rest. Single-host configurations preserve the legacy error contract.
441
+ */
442
+ public async listUploads (options: HostScopeOptions = {}): Promise<any> {
443
+ const targets = this.resolveTargets(options.hostedBy)
444
+
445
+ const outcomes = await Promise.all(
446
+ targets.map(async host => {
447
+ try {
448
+ return { ok: true, host, data: await this.listUploadsAtHost(host) } as const
449
+ } catch (e) {
450
+ return { ok: false, host, error: e as Error } as const
451
+ }
452
+ })
453
+ )
454
+
455
+ const successes = outcomes.flatMap(o => o.ok ? [o] : [])
456
+ if (successes.length === 0) {
457
+ const failures = outcomes.flatMap(o => o.ok ? [] : [o])
458
+ if (targets.length === 1) throw failures[0].error
459
+ const detail = failures.map(f => `${f.host}: ${f.error.message}`).join('; ')
460
+ throw new Error(`listUploads: no configured host returned a listing — ${detail}`)
461
+ }
462
+
463
+ if (targets.length === 1) {
464
+ return successes[0].data
465
+ }
466
+
467
+ const merged = new Map<string, { uhrpUrl: string, expiryTime: number, hostedBy: string[] }>()
468
+ for (const { host, data } of successes) {
469
+ if (!Array.isArray(data)) continue
470
+ for (const entry of data) {
471
+ const key = entry?.uhrpUrl
472
+ if (typeof key !== 'string') continue
473
+ const rawExpiry = Number(entry.expiryTime)
474
+ const expiry = Number.isFinite(rawExpiry) ? rawExpiry : 0
475
+ const existing = merged.get(key)
476
+ if (existing === undefined) {
477
+ merged.set(key, { uhrpUrl: key, expiryTime: expiry, hostedBy: [host] })
478
+ } else {
479
+ existing.expiryTime = Math.max(existing.expiryTime, expiry)
480
+ if (!existing.hostedBy.includes(host)) existing.hostedBy.push(host)
481
+ }
482
+ }
483
+ }
484
+ return Array.from(merged.values())
485
+ }
486
+
487
+ private async listUploadsAtHost (host: string): Promise<any> {
488
+ const response = await this.authFetch.fetch(`${host}/list`, { method: 'GET' })
489
+ if (!response.ok) {
490
+ throw new Error(`listUploads request failed: HTTP ${response.status}`)
491
+ }
492
+
493
+ const data = await response.json()
494
+ if (data.status === 'error') {
495
+ const errCode = data.code as string ?? 'unknown-code'
496
+ const errDesc = data.description as string ?? 'no-description'
497
+ throw new Error(`listUploads returned an error: ${errCode} - ${errDesc}`)
498
+ }
499
+ return data.uploads
500
+ }
501
+
502
+ /**
503
+ * Fans `/renew` out across every configured host (each provider owns its
504
+ * own advertisement, so a single-host renewal would degrade resilience
505
+ * over time). Hosts that do not carry the file are not billed. Throws
506
+ * {@link RenewResiliencyError} when successful renewals fall below the
507
+ * resilience threshold.
508
+ */
509
+ public async renewFile (
510
+ uhrpUrl: string,
511
+ additionalMinutes: number,
512
+ options: HostScopeOptions = {}
513
+ ): Promise<RenewFileResult> {
514
+ const targets = this.resolveTargets(options.hostedBy)
515
+
516
+ // Single-host: pass the server's error through unchanged for legacy callers.
517
+ if (targets.length === 1) {
518
+ const data = await this.renewFileAtHost(targets[0], uhrpUrl, additionalMinutes)
519
+ return {
520
+ status: data.status,
521
+ prevExpiryTime: data.prevExpiryTime,
522
+ newExpiryTime: data.newExpiryTime,
523
+ amount: data.amount
524
+ }
525
+ }
526
+
527
+ const perHost: Array<{ result: RenewPerHostResult, raw?: Error }> = await Promise.all(
528
+ targets.map(async host => {
529
+ try {
530
+ const data = await this.renewFileAtHost(host, uhrpUrl, additionalMinutes)
531
+ return {
532
+ result: {
533
+ host,
534
+ status: 'success' as const,
535
+ prevExpiryTime: data.prevExpiryTime,
536
+ newExpiryTime: data.newExpiryTime,
537
+ amount: data.amount
538
+ }
539
+ }
540
+ } catch (e) {
541
+ const err = e as Error
542
+ return {
543
+ result: { host, status: 'error' as const, error: err.message },
544
+ raw: err
545
+ }
546
+ }
547
+ })
548
+ )
549
+
550
+ const outcomes = perHost.map(p => p.result)
551
+ const successes = outcomes.filter(o => o.status === 'success')
552
+
553
+ // Clamp to targets.length so an explicit `hostedBy` smaller than the
554
+ // configured resilience level doesn't trigger an impossible threshold.
555
+ const requiredSuccesses = Math.min(targets.length, this.resilienceLevel)
556
+ if (successes.length < requiredSuccesses) {
557
+ const detail = outcomes
558
+ .map(o => `${o.host}: ${o.status === 'success' ? 'renewed' : (o.error ?? 'unknown')}`)
559
+ .join('; ')
560
+ throw new RenewResiliencyError(
561
+ `renewFile: only ${successes.length} of ${requiredSuccesses} required hosts renewed — ${detail}`,
562
+ outcomes,
563
+ requiredSuccesses,
564
+ successes.length
565
+ )
566
+ }
567
+
568
+ successes.sort((a, b) => (b.newExpiryTime ?? 0) - (a.newExpiryTime ?? 0))
569
+ const primary = successes[0]
570
+ const totalAmount = successes.reduce((sum, s) => sum + (s.amount ?? 0), 0)
571
+
572
+ return {
573
+ status: 'success',
574
+ prevExpiryTime: primary.prevExpiryTime,
575
+ newExpiryTime: primary.newExpiryTime,
576
+ amount: totalAmount,
577
+ results: outcomes
578
+ }
579
+ }
258
580
  }