@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.
- package/README.md +2 -0
- package/dist/cjs/package.json +14 -14
- package/dist/cjs/src/identity/IdentityClient.js +3 -3
- package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
- package/dist/cjs/src/identity/types/index.js +1 -1
- package/dist/cjs/src/identity/types/index.js.map +1 -1
- package/dist/cjs/src/primitives/Hash.js +1 -1
- package/dist/cjs/src/primitives/Hash.js.map +1 -1
- package/dist/cjs/src/primitives/TransactionSignature.js +10 -3
- package/dist/cjs/src/primitives/TransactionSignature.js.map +1 -1
- package/dist/cjs/src/script/Script.js +60 -13
- package/dist/cjs/src/script/Script.js.map +1 -1
- package/dist/cjs/src/script/Spend.js +434 -59
- package/dist/cjs/src/script/Spend.js.map +1 -1
- package/dist/cjs/src/storage/StorageUploader.js +315 -96
- package/dist/cjs/src/storage/StorageUploader.js.map +1 -1
- package/dist/cjs/src/storage/index.js +3 -1
- package/dist/cjs/src/storage/index.js.map +1 -1
- package/dist/cjs/src/transaction/http/BinaryFetchClient.js +6 -2
- package/dist/cjs/src/transaction/http/BinaryFetchClient.js.map +1 -1
- package/dist/cjs/src/transaction/http/DefaultHttpClient.js +8 -4
- package/dist/cjs/src/transaction/http/DefaultHttpClient.js.map +1 -1
- package/dist/cjs/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/identity/IdentityClient.js +3 -3
- package/dist/esm/src/identity/IdentityClient.js.map +1 -1
- package/dist/esm/src/identity/types/index.js +1 -1
- package/dist/esm/src/identity/types/index.js.map +1 -1
- package/dist/esm/src/primitives/Hash.js +1 -1
- package/dist/esm/src/primitives/Hash.js.map +1 -1
- package/dist/esm/src/primitives/TransactionSignature.js +10 -3
- package/dist/esm/src/primitives/TransactionSignature.js.map +1 -1
- package/dist/esm/src/script/Script.js +60 -13
- package/dist/esm/src/script/Script.js.map +1 -1
- package/dist/esm/src/script/Spend.js +438 -59
- package/dist/esm/src/script/Spend.js.map +1 -1
- package/dist/esm/src/storage/StorageUploader.js +319 -95
- package/dist/esm/src/storage/StorageUploader.js.map +1 -1
- package/dist/esm/src/storage/index.js +1 -1
- package/dist/esm/src/storage/index.js.map +1 -1
- package/dist/esm/src/transaction/http/BinaryFetchClient.js +6 -2
- package/dist/esm/src/transaction/http/BinaryFetchClient.js.map +1 -1
- package/dist/esm/src/transaction/http/DefaultHttpClient.js +8 -4
- package/dist/esm/src/transaction/http/DefaultHttpClient.js.map +1 -1
- package/dist/esm/src/wallet/WERR_REVIEW_ACTIONS.js +2 -2
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/primitives/TransactionSignature.d.ts +1 -0
- package/dist/types/src/primitives/TransactionSignature.d.ts.map +1 -1
- package/dist/types/src/script/Script.d.ts +1 -0
- package/dist/types/src/script/Script.d.ts.map +1 -1
- package/dist/types/src/script/ScriptChunk.d.ts +1 -0
- package/dist/types/src/script/ScriptChunk.d.ts.map +1 -1
- package/dist/types/src/script/Spend.d.ts +29 -0
- package/dist/types/src/script/Spend.d.ts.map +1 -1
- package/dist/types/src/storage/StorageUploader.d.ts +94 -55
- package/dist/types/src/storage/StorageUploader.d.ts.map +1 -1
- package/dist/types/src/storage/index.d.ts +1 -1
- package/dist/types/src/storage/index.d.ts.map +1 -1
- package/dist/types/src/transaction/http/BinaryFetchClient.d.ts.map +1 -1
- package/dist/types/src/transaction/http/DefaultHttpClient.d.ts +2 -2
- package/dist/types/src/transaction/http/DefaultHttpClient.d.ts.map +1 -1
- package/dist/types/src/wallet/WERR_REVIEW_ACTIONS.d.ts +2 -2
- package/dist/types/src/wallet/Wallet.interfaces.d.ts +2 -2
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +4 -5
- package/docs/index.md +3 -1
- package/docs/reference/identity.md +1 -1
- package/docs/reference/primitives.md +1 -0
- package/docs/reference/script.md +7 -0
- package/docs/reference/storage.md +214 -85
- package/docs/reference/transaction.md +4 -4
- package/docs/reference/wallet.md +4 -4
- package/package.json +14 -14
- package/src/identity/IdentityClient.ts +3 -3
- package/src/identity/__tests/IdentityClient.additional.test.ts +3 -3
- package/src/identity/types/index.ts +1 -1
- package/src/primitives/Hash.ts +1 -1
- package/src/primitives/TransactionSignature.ts +11 -3
- package/src/script/Script.ts +59 -13
- package/src/script/ScriptChunk.ts +1 -0
- package/src/script/Spend.ts +483 -61
- package/src/script/__tests/NormativeVectors.test.ts +465 -0
- package/src/script/__tests/fixtures/SOURCES.md +25 -0
- package/src/script/__tests/fixtures/bitcoin-sv/script_tests.json +2591 -0
- package/src/script/__tests/fixtures/bitcoin-sv/sighash.json +1003 -0
- package/src/script/__tests/fixtures/bitcoin-sv/tx_invalid.json +285 -0
- package/src/script/__tests/fixtures/bitcoin-sv/tx_valid.json +367 -0
- package/src/script/__tests/fixtures/teranode/script_tests.json +2432 -0
- package/src/script/__tests/fixtures/teranode/sighash.json +1003 -0
- package/src/script/__tests/fixtures/teranode/tx_invalid.json +285 -0
- package/src/script/__tests/fixtures/teranode/tx_valid.json +367 -0
- package/src/storage/StorageUploader.ts +427 -105
- package/src/storage/__tests/StorageUploader.test.ts +881 -64
- package/src/storage/index.ts +1 -1
- package/src/transaction/broadcasters/__tests/ARC.test.ts +26 -4
- package/src/transaction/broadcasters/__tests/WhatsOnChainBroadcaster.test.ts +26 -4
- package/src/transaction/chaintrackers/__tests/WhatsOnChainChainTracker.test.ts +32 -10
- package/src/transaction/http/BinaryFetchClient.ts +5 -2
- package/src/transaction/http/DefaultHttpClient.ts +7 -4
- package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +19 -1
- package/src/wallet/WERR_REVIEW_ACTIONS.ts +2 -2
- package/src/wallet/Wallet.interfaces.ts +2 -2
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +0 -827
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +0 -1
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js +0 -266
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.test.js.map +0 -1
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +0 -654
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +0 -1
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +0 -144
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +0 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +0 -825
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +0 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js +0 -264
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.test.js.map +0 -1
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +0 -619
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +0 -1
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js +0 -109
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.test.js.map +0 -1
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +0 -21
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +0 -1
- package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts +0 -2
- package/dist/types/src/auth/clients/__tests__/AuthFetch.test.d.ts.map +0 -1
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +0 -2
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +0 -1
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts +0 -2
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.test.d.ts.map +0 -1
- 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
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
195
|
+
data: Uint8Array,
|
|
196
|
+
contentType: string,
|
|
109
197
|
requiredHeaders: Record<string, string>
|
|
110
|
-
): Promise<
|
|
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:
|
|
201
|
+
body: data as BodyInit,
|
|
116
202
|
headers: {
|
|
117
|
-
'Content-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
|
-
|
|
128
|
-
|
|
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
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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(
|
|
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
|
}
|