@ardrive/turbo-sdk 1.43.0 → 1.44.0-alpha.2
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/lib/cjs/common/chunked.js +10 -1
- package/lib/cjs/common/http.js +24 -0
- package/lib/cjs/common/payment.js +8 -1
- package/lib/cjs/common/turbo.js +28 -0
- package/lib/cjs/common/upload.js +295 -23
- package/lib/esm/common/chunked.js +10 -1
- package/lib/esm/common/http.js +24 -0
- package/lib/esm/common/payment.js +8 -1
- package/lib/esm/common/turbo.js +28 -0
- package/lib/esm/common/upload.js +295 -23
- package/lib/types/common/chunked.d.ts +25 -8
- package/lib/types/common/chunked.d.ts.map +1 -1
- package/lib/types/common/http.d.ts +8 -0
- package/lib/types/common/http.d.ts.map +1 -1
- package/lib/types/common/payment.d.ts +2 -9
- package/lib/types/common/payment.d.ts.map +1 -1
- package/lib/types/common/turbo.d.ts +22 -9
- package/lib/types/common/turbo.d.ts.map +1 -1
- package/lib/types/common/upload.d.ts +55 -1
- package/lib/types/common/upload.d.ts.map +1 -1
- package/lib/types/types.d.ts +143 -8
- package/lib/types/types.d.ts.map +1 -1
- package/package.json +2 -1
|
@@ -41,13 +41,14 @@ const chunkingHeader = { 'x-chunking-version': '2' };
|
|
|
41
41
|
* uploading them in parallel, and emitting progress/error events.
|
|
42
42
|
*/
|
|
43
43
|
class ChunkedUploader {
|
|
44
|
-
constructor({ http, token, maxChunkConcurrency = exports.defaultMaxChunkConcurrency, maxFinalizeMs, chunkByteCount = exports.defaultChunkByteCount, logger = logger_js_1.Logger.default, chunkingMode = 'auto', dataItemByteCount, x402, x402RefundIdentity, }) {
|
|
44
|
+
constructor({ http, token, maxChunkConcurrency = exports.defaultMaxChunkConcurrency, maxFinalizeMs, chunkByteCount = exports.defaultChunkByteCount, logger = logger_js_1.Logger.default, chunkingMode = 'auto', dataItemByteCount, x402, x402RefundIdentity, paidUploadId, }) {
|
|
45
45
|
this.assertChunkParams({
|
|
46
46
|
chunkByteCount,
|
|
47
47
|
chunkingMode,
|
|
48
48
|
maxChunkConcurrency,
|
|
49
49
|
maxFinalizeMs,
|
|
50
50
|
});
|
|
51
|
+
this.paidUploadId = paidUploadId;
|
|
51
52
|
this.chunkByteCount = chunkByteCount;
|
|
52
53
|
this.maxChunkConcurrency = maxChunkConcurrency;
|
|
53
54
|
this.maxFinalizeMs = maxFinalizeMs;
|
|
@@ -145,6 +146,14 @@ class ChunkedUploader {
|
|
|
145
146
|
this.paidUploadId = res.id;
|
|
146
147
|
return res.id;
|
|
147
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* The paid upload id, once one has been bought. `uploadFile` reads this after
|
|
151
|
+
* a failed attempt so the next attempt resumes the upload already paid for
|
|
152
|
+
* rather than buying a second one.
|
|
153
|
+
*/
|
|
154
|
+
get currentPaidUploadId() {
|
|
155
|
+
return this.paidUploadId;
|
|
156
|
+
}
|
|
148
157
|
async initUpload() {
|
|
149
158
|
if (this.x402 !== undefined) {
|
|
150
159
|
return this.initPaidUpload(this.x402);
|
package/lib/cjs/common/http.js
CHANGED
|
@@ -52,8 +52,21 @@ class TurboHTTPService {
|
|
|
52
52
|
this.baseURL = url;
|
|
53
53
|
this.retryConfig = retryConfig;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Refuse to pay over cleartext.
|
|
57
|
+
*
|
|
58
|
+
* An x402 payment authorization is a bearer credential: anyone who observes
|
|
59
|
+
* it can submit it. Sending one over `http:` hands it to the network, so a
|
|
60
|
+
* misconfigured service URL must fail loudly rather than quietly leak.
|
|
61
|
+
*/
|
|
62
|
+
assertSecureForPayment() {
|
|
63
|
+
if (!this.baseURL.startsWith('https:') && !isLoopback(this.baseURL)) {
|
|
64
|
+
throw new Error(`Refusing to send an x402 payment over a non-HTTPS URL: ${this.baseURL}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
55
67
|
async get({ endpoint, signal, allowedStatuses = [200, 202], headers, x402Options, }) {
|
|
56
68
|
if (x402Options !== undefined) {
|
|
69
|
+
this.assertSecureForPayment();
|
|
57
70
|
const maxMUSDCAmount = x402Options.maxMUSDCAmount !== undefined
|
|
58
71
|
? BigInt(x402Options.maxMUSDCAmount.toString())
|
|
59
72
|
: undefined;
|
|
@@ -162,6 +175,7 @@ class TurboHTTPService {
|
|
|
162
175
|
endpoint,
|
|
163
176
|
x402Options,
|
|
164
177
|
});
|
|
178
|
+
this.assertSecureForPayment();
|
|
165
179
|
const { body, duplex } = await toFetchBody(data);
|
|
166
180
|
return this.tryRequest(async () => {
|
|
167
181
|
const maxMUSDCAmount = x402Options.maxMUSDCAmount !== undefined
|
|
@@ -213,3 +227,13 @@ function isFirefoxOrSafari() {
|
|
|
213
227
|
!ua.includes('Chrome') &&
|
|
214
228
|
!ua.includes('Chromium')));
|
|
215
229
|
}
|
|
230
|
+
/** Loopback is exempt: local development never leaves the machine. */
|
|
231
|
+
function isLoopback(url) {
|
|
232
|
+
try {
|
|
233
|
+
const { hostname } = new URL(url);
|
|
234
|
+
return (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1');
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -657,13 +657,20 @@ class TurboAuthenticatedPaymentService extends TurboUnauthenticatedPaymentServic
|
|
|
657
657
|
* The owner needs a Solana key to sign with, NOT a funded one — Turbo pays
|
|
658
658
|
* every lamport of fee and rent.
|
|
659
659
|
*/
|
|
660
|
-
async buyArNSName({ name, owner, type = 'lease', years, paidBy, onNonce, }) {
|
|
660
|
+
async buyArNSName({ name, owner, type = 'lease', years, paidBy, onNonce, antState, }) {
|
|
661
661
|
return this.completeArNSAction('buy-name', {
|
|
662
662
|
name,
|
|
663
663
|
ownerAddress: await owner.getAddress(),
|
|
664
664
|
type,
|
|
665
665
|
...(years !== undefined ? { years } : {}),
|
|
666
666
|
...(paidBy !== undefined ? { paidBy } : {}),
|
|
667
|
+
/*
|
|
668
|
+
Spread conditionally, like `paidBy` — an `antState: undefined` key
|
|
669
|
+
would serialize into the body and is not the same request as omitting
|
|
670
|
+
it. Sent whole in the BODY rather than the query string: it is an
|
|
671
|
+
object, and `createArNSAction` already posts every param as JSON.
|
|
672
|
+
*/
|
|
673
|
+
...(antState !== undefined ? { antState } : {}),
|
|
667
674
|
}, owner, { onNonce });
|
|
668
675
|
}
|
|
669
676
|
/** Extend a lease. Permissionless on chain — no owner signature needed. */
|
package/lib/cjs/common/turbo.js
CHANGED
|
@@ -193,6 +193,20 @@ class TurboUnauthenticatedClient {
|
|
|
193
193
|
maxMUSDCAmount,
|
|
194
194
|
});
|
|
195
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Price a signed data item for an x402 upload without sending it. See
|
|
198
|
+
* `TurboUnauthenticatedUploadService.getX402PriceForDataItem`.
|
|
199
|
+
*/
|
|
200
|
+
getX402PriceForDataItem(p) {
|
|
201
|
+
return this.uploadService.getX402PriceForDataItem(p);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Price raw data for an x402 upload, including the data-item wrapping
|
|
205
|
+
* overhead. See `TurboUnauthenticatedUploadService.getX402PriceForRawData`.
|
|
206
|
+
*/
|
|
207
|
+
getX402PriceForRawData(p) {
|
|
208
|
+
return this.uploadService.getX402PriceForRawData(p);
|
|
209
|
+
}
|
|
196
210
|
}
|
|
197
211
|
exports.TurboUnauthenticatedClient = TurboUnauthenticatedClient;
|
|
198
212
|
class TurboAuthenticatedClient extends TurboUnauthenticatedClient {
|
|
@@ -381,5 +395,19 @@ class TurboAuthenticatedClient extends TurboUnauthenticatedClient {
|
|
|
381
395
|
maxMUSDCAmount,
|
|
382
396
|
});
|
|
383
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Price a signed data item for an x402 upload without sending it. See
|
|
400
|
+
* `TurboUnauthenticatedUploadService.getX402PriceForDataItem`.
|
|
401
|
+
*/
|
|
402
|
+
getX402PriceForDataItem(p) {
|
|
403
|
+
return this.uploadService.getX402PriceForDataItem(p);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Price raw data for an x402 upload, including the data-item wrapping
|
|
407
|
+
* overhead. See `TurboUnauthenticatedUploadService.getX402PriceForRawData`.
|
|
408
|
+
*/
|
|
409
|
+
getX402PriceForRawData(p) {
|
|
410
|
+
return this.uploadService.getX402PriceForRawData(p);
|
|
411
|
+
}
|
|
384
412
|
}
|
|
385
413
|
exports.TurboAuthenticatedClient = TurboAuthenticatedClient;
|
package/lib/cjs/common/upload.js
CHANGED
|
@@ -17,6 +17,7 @@ exports.TurboAuthenticatedBaseUploadService = exports.TurboUnauthenticatedUpload
|
|
|
17
17
|
* limitations under the License.
|
|
18
18
|
*/
|
|
19
19
|
const bignumber_js_1 = require("bignumber.js");
|
|
20
|
+
const node_stream_1 = require("node:stream");
|
|
20
21
|
const plimit_lit_1 = require("plimit-lit");
|
|
21
22
|
const types_js_1 = require("../types.js");
|
|
22
23
|
const common_js_1 = require("../utils/common.js");
|
|
@@ -80,6 +81,29 @@ class TurboUnauthenticatedUploadService {
|
|
|
80
81
|
headers['x-paid-by'] = paidBy;
|
|
81
82
|
}
|
|
82
83
|
}
|
|
84
|
+
if (x402Options !== undefined) {
|
|
85
|
+
/*
|
|
86
|
+
x402 needs a body fetch can measure, and one it can send twice.
|
|
87
|
+
|
|
88
|
+
`content-length` is a forbidden header, so setting it above does
|
|
89
|
+
nothing: a streamed body goes out chunked with no length. The service
|
|
90
|
+
prices from that header, so without it there is no 402 challenge — and
|
|
91
|
+
the protocol's paid retry has a spent stream to replay, which fails
|
|
92
|
+
outright. Buffering fixes both: the length is declared honestly and the
|
|
93
|
+
body can be re-sent.
|
|
94
|
+
|
|
95
|
+
Bounded by the chunking decision above — anything over two chunks took
|
|
96
|
+
the chunked path, which pays at create and never reaches here.
|
|
97
|
+
*/
|
|
98
|
+
const body = await streamToBuffer(streamWithUploadEvents, dataItemSize, resume, signal);
|
|
99
|
+
return this.httpService.post({
|
|
100
|
+
endpoint: `/tx/${this.token}`,
|
|
101
|
+
signal,
|
|
102
|
+
data: body,
|
|
103
|
+
headers,
|
|
104
|
+
x402Options,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
83
107
|
// setup the post request using the stream with upload events
|
|
84
108
|
const postPromise = this.httpService.post({
|
|
85
109
|
endpoint: `/tx/${this.token}`,
|
|
@@ -92,6 +116,46 @@ class TurboUnauthenticatedUploadService {
|
|
|
92
116
|
resume();
|
|
93
117
|
return postPromise;
|
|
94
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Price a SIGNED data item for an x402 upload, without sending it.
|
|
121
|
+
*
|
|
122
|
+
* The only other way to learn an x402 price is to POST the payload and read
|
|
123
|
+
* the 402 challenge, which means transmitting it to find out what it costs.
|
|
124
|
+
*
|
|
125
|
+
* `byteCount` is the size of the signed data item — what
|
|
126
|
+
* `dataItemSizeFactory()` returns — not the payload inside it. Use
|
|
127
|
+
* `getX402PriceForRawData` when the service does the wrapping.
|
|
128
|
+
*
|
|
129
|
+
* `network` names the x402 network, not the SDK token: the route builds its
|
|
130
|
+
* token as `usdc-{network}`. `base-usdc` is accepted on mainnet only because
|
|
131
|
+
* the network there is literally `base`, so a testnet caller must pass
|
|
132
|
+
* `base-sepolia` explicitly.
|
|
133
|
+
*/
|
|
134
|
+
async getX402PriceForDataItem({ byteCount, network = 'base', }) {
|
|
135
|
+
return this.httpService.get({
|
|
136
|
+
endpoint: `/price/x402/data-item/usdc-${network}/${byteCount}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Price RAW data for an x402 upload, where the service wraps it into a data
|
|
141
|
+
* item itself.
|
|
142
|
+
*
|
|
143
|
+
* Also reports the wrapping overhead — a data item is larger than its
|
|
144
|
+
* payload by its header, signature and tags — which the caller has no way to
|
|
145
|
+
* compute. `tagCount` and `contentType` feed that estimate, so pass what the
|
|
146
|
+
* upload will actually carry or the quote will read low.
|
|
147
|
+
*/
|
|
148
|
+
async getX402PriceForRawData({ byteCount, network = 'base', tagCount, contentType, }) {
|
|
149
|
+
const query = new URLSearchParams();
|
|
150
|
+
if (tagCount !== undefined)
|
|
151
|
+
query.set('tags', `${tagCount}`);
|
|
152
|
+
if (contentType !== undefined)
|
|
153
|
+
query.set('contentType', contentType);
|
|
154
|
+
const qs = query.toString();
|
|
155
|
+
return this.httpService.get({
|
|
156
|
+
endpoint: `/price/x402/data/usdc-${network}/${byteCount}${qs ? `?${qs}` : ''}`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
95
159
|
async uploadRawX402Data({ data, tags, signal, maxMUSDCAmount, signer, }) {
|
|
96
160
|
if (!this.x402EnabledTokens.includes(this.token)) {
|
|
97
161
|
throw new Error('x402 uploads are not supported for token: ' + this.token);
|
|
@@ -226,8 +290,30 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
226
290
|
!this.x402EnabledTokens.includes(this.token)) {
|
|
227
291
|
throw new Error('x402 uploads are not supported for token: ' + this.token);
|
|
228
292
|
}
|
|
293
|
+
/*
|
|
294
|
+
Buffering the single request is only safe because anything larger chunks.
|
|
295
|
+
`chunkingMode: 'disabled'` removes that guarantee, so an arbitrarily large
|
|
296
|
+
item would be pulled into memory — and the service caps single-request
|
|
297
|
+
items anyway, so it would be refused after the fact. Check before signing:
|
|
298
|
+
signing a large item only to reject it wastes the expensive part.
|
|
299
|
+
*/
|
|
300
|
+
if (fundingMode instanceof types_js_1.X402Funding &&
|
|
301
|
+
params.chunkingMode === 'disabled' &&
|
|
302
|
+
fileSizeFactory() + x402SignedItemOverheadByteCount >
|
|
303
|
+
maxX402SingleRequestByteCount) {
|
|
304
|
+
throw new Error(`An x402 upload of ${fileSizeFactory()} bytes must be chunked: the ` +
|
|
305
|
+
`single-request path buffers the SIGNED item in memory and is ` +
|
|
306
|
+
`limited to ${maxX402SingleRequestByteCount} bytes, of which ` +
|
|
307
|
+
`signing claims about ${x402SignedItemOverheadByteCount}. Remove ` +
|
|
308
|
+
`chunkingMode: 'disabled' to upload this item.`);
|
|
309
|
+
}
|
|
229
310
|
this.logger.debug('Starting file upload', { params });
|
|
230
311
|
let retries = 0;
|
|
312
|
+
// Carried ACROSS retry attempts. A paid chunked upload is bought before any
|
|
313
|
+
// chunk is accepted, so an attempt that fails after paying must hand its
|
|
314
|
+
// upload id to the next attempt. Rebuilding the uploader with no id is how
|
|
315
|
+
// a single upload gets billed once per retry.
|
|
316
|
+
let paidUploadId;
|
|
231
317
|
const maxRetries = this.retryConfig.retries ?? 3;
|
|
232
318
|
const retryDelay = this.retryConfig.retryDelay ??
|
|
233
319
|
((retryNumber) => retryNumber * 1000);
|
|
@@ -250,9 +336,8 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
250
336
|
});
|
|
251
337
|
if (fundingMode instanceof types_js_1.OnDemandFunding &&
|
|
252
338
|
cryptoFundResult === undefined) {
|
|
253
|
-
const totalByteCount = dataItemSizeFactory();
|
|
254
339
|
cryptoFundResult = await this.onDemand({
|
|
255
|
-
|
|
340
|
+
itemByteCounts: [dataItemSizeFactory()],
|
|
256
341
|
onDemandFunding: fundingMode,
|
|
257
342
|
});
|
|
258
343
|
}
|
|
@@ -283,6 +368,7 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
283
368
|
dataItemByteCount: dataItemSizeFactory(),
|
|
284
369
|
chunkingMode: params.chunkingMode,
|
|
285
370
|
maxFinalizeMs: params.maxFinalizeMs,
|
|
371
|
+
paidUploadId,
|
|
286
372
|
...(x402Options ? { x402: x402Options } : {}),
|
|
287
373
|
...(x402Options
|
|
288
374
|
? {
|
|
@@ -294,14 +380,21 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
294
380
|
: {}),
|
|
295
381
|
});
|
|
296
382
|
if (chunkedUploader.shouldUseChunkUploader) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
383
|
+
try {
|
|
384
|
+
const response = await chunkedUploader.upload({
|
|
385
|
+
dataItemStreamFactory,
|
|
386
|
+
dataItemSizeFactory,
|
|
387
|
+
dataItemOpts,
|
|
388
|
+
signal,
|
|
389
|
+
events,
|
|
390
|
+
});
|
|
391
|
+
return { ...response, cryptoFundResult };
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
// Read on the way out, success or failure. On failure this is the
|
|
395
|
+
// whole point: the next attempt resumes what this one paid for.
|
|
396
|
+
paidUploadId = chunkedUploader.currentPaidUploadId ?? paidUploadId;
|
|
397
|
+
}
|
|
305
398
|
}
|
|
306
399
|
const response = await this.uploadSignedDataItem({
|
|
307
400
|
dataItemStreamFactory,
|
|
@@ -346,6 +439,28 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
346
439
|
}
|
|
347
440
|
throw new errors_js_2.FailedRequestError(msg, lastStatusCode);
|
|
348
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* Returns an upper bound on the bytes in a folder's manifest, if every file
|
|
444
|
+
* lands. Data item ids are always 43 characters, so placeholder ids give the
|
|
445
|
+
* real length, except for the index path: without an index file, the
|
|
446
|
+
* manifest indexes whichever file finished first, so the estimate allows for
|
|
447
|
+
* the longest path there.
|
|
448
|
+
*/
|
|
449
|
+
async plannedManifestByteCount({ relativePaths, indexFile, fallbackFile, }) {
|
|
450
|
+
const placeholder = { id: 'x'.repeat(43) };
|
|
451
|
+
const paths = {};
|
|
452
|
+
let longestPathByteCount = 0;
|
|
453
|
+
for (const path of relativePaths) {
|
|
454
|
+
paths[path] = placeholder;
|
|
455
|
+
longestPathByteCount = Math.max(longestPathByteCount, Buffer.byteLength(JSON.stringify(path)));
|
|
456
|
+
}
|
|
457
|
+
const manifest = await this.generateManifest({
|
|
458
|
+
paths,
|
|
459
|
+
indexFile,
|
|
460
|
+
fallbackFile,
|
|
461
|
+
});
|
|
462
|
+
return Buffer.byteLength(JSON.stringify(manifest)) + longestPathByteCount;
|
|
463
|
+
}
|
|
349
464
|
async generateManifest({ paths, indexFile, fallbackFile, }) {
|
|
350
465
|
const indexPath =
|
|
351
466
|
// Use the user provided index file if it exists,
|
|
@@ -750,11 +865,20 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
750
865
|
};
|
|
751
866
|
let cryptoFundResult;
|
|
752
867
|
if (fundingMode instanceof types_js_1.OnDemandFunding) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
868
|
+
// allow extra per item for ANS-104 headers
|
|
869
|
+
const headerByteCount = 1200;
|
|
870
|
+
const itemByteCounts = filesToUpload.map((file) => this.getFileSize(file) + headerByteCount);
|
|
871
|
+
// The manifest spends the same balance. Left out of the estimate, it
|
|
872
|
+
// needed a second top-up of its own.
|
|
873
|
+
if (!disableManifest && files.length > 0) {
|
|
874
|
+
itemByteCounts.push((await this.plannedManifestByteCount({
|
|
875
|
+
relativePaths: files.map((file) => this.getRelativePath(file, params)),
|
|
876
|
+
indexFile,
|
|
877
|
+
fallbackFile,
|
|
878
|
+
})) + headerByteCount);
|
|
879
|
+
}
|
|
756
880
|
cryptoFundResult = await this.onDemand({
|
|
757
|
-
|
|
881
|
+
itemByteCounts,
|
|
758
882
|
onDemandFunding: fundingMode,
|
|
759
883
|
});
|
|
760
884
|
}
|
|
@@ -886,6 +1010,54 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
886
1010
|
}
|
|
887
1011
|
return revokedApprovals;
|
|
888
1012
|
}
|
|
1013
|
+
/**
|
|
1014
|
+
* The winc the service will charge for the given items. Never less.
|
|
1015
|
+
*
|
|
1016
|
+
* The service prices an item as a per-byte rate plus a fixed per-item
|
|
1017
|
+
* charge, rounded up per item: a zero-byte item costs about 8M winc. Scaling
|
|
1018
|
+
* the 1 GiB price down to the item size dropped that fixed part, so a small
|
|
1019
|
+
* upload was under funded by up to ~38%, a folder by roughly the fixed charge
|
|
1020
|
+
* per file, and `topUpBufferMultiplier: 1` could never succeed.
|
|
1021
|
+
*
|
|
1022
|
+
* Items from 1 byte to 1 GiB are priced from two quotes, at 1 byte and 1 GiB.
|
|
1023
|
+
* The line through those two (already rounded up) prices never falls below
|
|
1024
|
+
* the service's unrounded price anywhere between them, so rounding each item
|
|
1025
|
+
* up keeps every estimate, and their sum, at or above what the service
|
|
1026
|
+
* charges. Rounding only the total could not promise that. Anything the line
|
|
1027
|
+
* would have to extrapolate to, and a lone item, is quoted exactly.
|
|
1028
|
+
*
|
|
1029
|
+
* The two quotes are made once per estimate. Each file of a folder still
|
|
1030
|
+
* checks its own price when it uploads.
|
|
1031
|
+
*/
|
|
1032
|
+
async estimateUploadWinc(itemByteCounts) {
|
|
1033
|
+
const oneGiB = 2 ** 30;
|
|
1034
|
+
const inModelRange = (bytes) => bytes >= 1 && bytes <= oneGiB;
|
|
1035
|
+
const useModel = itemByteCounts.filter(inModelRange).length > 1;
|
|
1036
|
+
const modelled = useModel ? itemByteCounts.filter(inModelRange) : [];
|
|
1037
|
+
const quoted = useModel
|
|
1038
|
+
? itemByteCounts.filter((bytes) => !inModelRange(bytes))
|
|
1039
|
+
: itemByteCounts;
|
|
1040
|
+
const bytes = useModel ? [1, oneGiB, ...quoted] : quoted;
|
|
1041
|
+
if (bytes.length === 0) {
|
|
1042
|
+
return '0';
|
|
1043
|
+
}
|
|
1044
|
+
const quotes = await this.paymentService.getUploadCosts({ bytes });
|
|
1045
|
+
let total = (useModel ? quotes.slice(2) : quotes).reduce((sum, { winc }) => sum.plus(winc), new bignumber_js_1.BigNumber(0));
|
|
1046
|
+
if (useModel) {
|
|
1047
|
+
const [oneByte, gibibyte] = quotes;
|
|
1048
|
+
const perByte = new bignumber_js_1.BigNumber(gibibyte.winc)
|
|
1049
|
+
.minus(oneByte.winc)
|
|
1050
|
+
.dividedBy(oneGiB - 1);
|
|
1051
|
+
const perItem = new bignumber_js_1.BigNumber(oneByte.winc).minus(perByte);
|
|
1052
|
+
for (const itemBytes of modelled) {
|
|
1053
|
+
total = total.plus(perByte
|
|
1054
|
+
.multipliedBy(itemBytes)
|
|
1055
|
+
.plus(perItem)
|
|
1056
|
+
.integerValue(bignumber_js_1.BigNumber.ROUND_UP));
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return total.toFixed(0);
|
|
1060
|
+
}
|
|
889
1061
|
/**
|
|
890
1062
|
* Triggers an upload that will top-up the wallet with Credits for the amount before uploading.
|
|
891
1063
|
* First, it calculates the expected cost of the upload. Next, it checks the wallet for existing
|
|
@@ -893,16 +1065,10 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
893
1065
|
* and await for the balance to be credited.
|
|
894
1066
|
* Note: Only `ario`, `solana`, and `base-eth` tokens are currently supported for on-demand uploads.
|
|
895
1067
|
*/
|
|
896
|
-
async onDemand({
|
|
1068
|
+
async onDemand({ itemByteCounts, onDemandFunding, }) {
|
|
897
1069
|
const { maxTokenAmount, topUpBufferMultiplier } = onDemandFunding;
|
|
898
1070
|
const currentBalance = await this.paymentService.getBalance();
|
|
899
|
-
const
|
|
900
|
-
bytes: [2 ** 30],
|
|
901
|
-
}))[0].winc;
|
|
902
|
-
const expectedWincPrice = new bignumber_js_1.BigNumber(wincPriceForOneGiB)
|
|
903
|
-
.multipliedBy(totalByteCount)
|
|
904
|
-
.dividedBy(2 ** 30)
|
|
905
|
-
.toFixed(0, bignumber_js_1.BigNumber.ROUND_UP);
|
|
1071
|
+
const expectedWincPrice = await this.estimateUploadWinc(itemByteCounts);
|
|
906
1072
|
if ((0, bignumber_js_1.BigNumber)(currentBalance.effectiveBalance).isGreaterThanOrEqualTo(expectedWincPrice)) {
|
|
907
1073
|
this.logger.debug('Sufficient balance for on demand upload', {
|
|
908
1074
|
currentBalance,
|
|
@@ -930,7 +1096,15 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
930
1096
|
.toFixed(0, bignumber_js_1.BigNumber.ROUND_UP);
|
|
931
1097
|
if (maxTokenAmount !== undefined) {
|
|
932
1098
|
if (new bignumber_js_1.BigNumber(topUpTokenAmount).isGreaterThan(maxTokenAmount)) {
|
|
933
|
-
|
|
1099
|
+
// Both amounts are in base units. Report them in whole tokens: the
|
|
1100
|
+
// exponent is the number of decimals, so divide by 10 to that power.
|
|
1101
|
+
const baseUnitsPerToken = new bignumber_js_1.BigNumber(10).pow(index_js_1.exponentMap[this.token]);
|
|
1102
|
+
throw new Error(`Top up token amount ${new bignumber_js_1.BigNumber(topUpTokenAmount)
|
|
1103
|
+
.div(baseUnitsPerToken)
|
|
1104
|
+
.toFixed()} ${this.token} is greater than the maximum allowed ` +
|
|
1105
|
+
`amount of ${new bignumber_js_1.BigNumber(maxTokenAmount)
|
|
1106
|
+
.div(baseUnitsPerToken)
|
|
1107
|
+
.toFixed()} ${this.token}`);
|
|
934
1108
|
}
|
|
935
1109
|
}
|
|
936
1110
|
this.logger.debug(`Topping up wallet with ${topUpTokenAmount} ${this.token} for ${topUpWincAmount} winc`);
|
|
@@ -983,3 +1157,101 @@ class TurboAuthenticatedBaseUploadService extends TurboUnauthenticatedUploadServ
|
|
|
983
1157
|
}
|
|
984
1158
|
}
|
|
985
1159
|
exports.TurboAuthenticatedBaseUploadService = TurboAuthenticatedBaseUploadService;
|
|
1160
|
+
/**
|
|
1161
|
+
* The largest data item the x402 single-request path will buffer.
|
|
1162
|
+
*
|
|
1163
|
+
* Normally unreachable: anything over two chunks takes the chunked path, which
|
|
1164
|
+
* pays at create and streams. It bites only when chunking is explicitly
|
|
1165
|
+
* disabled, and it exists so that case fails with an explanation rather than
|
|
1166
|
+
* by exhausting memory.
|
|
1167
|
+
*/
|
|
1168
|
+
const maxX402SingleRequestByteCount = 100 * 1024 * 1024;
|
|
1169
|
+
/**
|
|
1170
|
+
* Headroom for what signing ADDS, so the guard measures the thing that is
|
|
1171
|
+
* actually buffered.
|
|
1172
|
+
*
|
|
1173
|
+
* The limit applies to the signed data item, not the file: ANS-104 headers,
|
|
1174
|
+
* the signature and the caller's tags all ride along. Checking the raw size
|
|
1175
|
+
* let a file in the top of the range through, to be signed, buffered, and then
|
|
1176
|
+
* refused by the service — wasting precisely the expensive step the guard
|
|
1177
|
+
* exists to skip.
|
|
1178
|
+
*
|
|
1179
|
+
* Same allowance folder uploads already use for the same overhead.
|
|
1180
|
+
*/
|
|
1181
|
+
const x402SignedItemOverheadByteCount = 1200;
|
|
1182
|
+
/**
|
|
1183
|
+
* Drain a data-item stream into a Buffer, resuming it once the reader is
|
|
1184
|
+
* attached so upload-progress events still fire.
|
|
1185
|
+
*
|
|
1186
|
+
* Checks the running total as it fills rather than at the end: a stream that
|
|
1187
|
+
* overruns its declared size would otherwise be held in memory in full before
|
|
1188
|
+
* anything objected.
|
|
1189
|
+
*/
|
|
1190
|
+
async function streamToBuffer(stream, byteCount, resume, signal) {
|
|
1191
|
+
// A closure, so TypeScript does not narrow `aborted` away for the whole
|
|
1192
|
+
// function — it genuinely can flip while the stream is draining.
|
|
1193
|
+
const isAborted = () => signal?.aborted === true;
|
|
1194
|
+
if (isAborted()) {
|
|
1195
|
+
throw new errors_js_1.AbortError();
|
|
1196
|
+
}
|
|
1197
|
+
const chunks = [];
|
|
1198
|
+
let received = 0;
|
|
1199
|
+
const take = (chunk) => {
|
|
1200
|
+
received += chunk.byteLength;
|
|
1201
|
+
if (received > byteCount) {
|
|
1202
|
+
throw new Error(`Data item stream exceeded its declared size of ${byteCount} bytes`);
|
|
1203
|
+
}
|
|
1204
|
+
chunks.push(chunk);
|
|
1205
|
+
};
|
|
1206
|
+
if (stream instanceof node_stream_1.Readable) {
|
|
1207
|
+
const done = new Promise((resolve, reject) => {
|
|
1208
|
+
const onAbort = () => {
|
|
1209
|
+
stream.destroy();
|
|
1210
|
+
reject(new errors_js_1.AbortError());
|
|
1211
|
+
};
|
|
1212
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1213
|
+
stream.on('data', (c) => {
|
|
1214
|
+
try {
|
|
1215
|
+
take(Buffer.from(c));
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
stream.destroy();
|
|
1219
|
+
reject(error);
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
stream.on('end', () => {
|
|
1223
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1224
|
+
resolve();
|
|
1225
|
+
});
|
|
1226
|
+
stream.on('error', reject);
|
|
1227
|
+
});
|
|
1228
|
+
resume();
|
|
1229
|
+
await done;
|
|
1230
|
+
}
|
|
1231
|
+
else {
|
|
1232
|
+
const reader = stream.getReader();
|
|
1233
|
+
resume();
|
|
1234
|
+
try {
|
|
1235
|
+
for (;;) {
|
|
1236
|
+
if (isAborted()) {
|
|
1237
|
+
await reader.cancel();
|
|
1238
|
+
throw new errors_js_1.AbortError();
|
|
1239
|
+
}
|
|
1240
|
+
const { done, value } = await reader.read();
|
|
1241
|
+
if (done)
|
|
1242
|
+
break;
|
|
1243
|
+
if (value !== undefined)
|
|
1244
|
+
take(Buffer.from(value));
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
catch (error) {
|
|
1248
|
+
await reader.cancel().catch(() => undefined);
|
|
1249
|
+
throw error;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const buf = Buffer.concat(chunks);
|
|
1253
|
+
if (buf.byteLength !== byteCount) {
|
|
1254
|
+
throw new Error(`Data item stream produced ${buf.byteLength} bytes, expected ${byteCount}`);
|
|
1255
|
+
}
|
|
1256
|
+
return buf;
|
|
1257
|
+
}
|
|
@@ -35,13 +35,14 @@ const chunkingHeader = { 'x-chunking-version': '2' };
|
|
|
35
35
|
* uploading them in parallel, and emitting progress/error events.
|
|
36
36
|
*/
|
|
37
37
|
export class ChunkedUploader {
|
|
38
|
-
constructor({ http, token, maxChunkConcurrency = defaultMaxChunkConcurrency, maxFinalizeMs, chunkByteCount = defaultChunkByteCount, logger = Logger.default, chunkingMode = 'auto', dataItemByteCount, x402, x402RefundIdentity, }) {
|
|
38
|
+
constructor({ http, token, maxChunkConcurrency = defaultMaxChunkConcurrency, maxFinalizeMs, chunkByteCount = defaultChunkByteCount, logger = Logger.default, chunkingMode = 'auto', dataItemByteCount, x402, x402RefundIdentity, paidUploadId, }) {
|
|
39
39
|
this.assertChunkParams({
|
|
40
40
|
chunkByteCount,
|
|
41
41
|
chunkingMode,
|
|
42
42
|
maxChunkConcurrency,
|
|
43
43
|
maxFinalizeMs,
|
|
44
44
|
});
|
|
45
|
+
this.paidUploadId = paidUploadId;
|
|
45
46
|
this.chunkByteCount = chunkByteCount;
|
|
46
47
|
this.maxChunkConcurrency = maxChunkConcurrency;
|
|
47
48
|
this.maxFinalizeMs = maxFinalizeMs;
|
|
@@ -139,6 +140,14 @@ export class ChunkedUploader {
|
|
|
139
140
|
this.paidUploadId = res.id;
|
|
140
141
|
return res.id;
|
|
141
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The paid upload id, once one has been bought. `uploadFile` reads this after
|
|
145
|
+
* a failed attempt so the next attempt resumes the upload already paid for
|
|
146
|
+
* rather than buying a second one.
|
|
147
|
+
*/
|
|
148
|
+
get currentPaidUploadId() {
|
|
149
|
+
return this.paidUploadId;
|
|
150
|
+
}
|
|
142
151
|
async initUpload() {
|
|
143
152
|
if (this.x402 !== undefined) {
|
|
144
153
|
return this.initPaidUpload(this.x402);
|
package/lib/esm/common/http.js
CHANGED
|
@@ -48,8 +48,21 @@ export class TurboHTTPService {
|
|
|
48
48
|
this.baseURL = url;
|
|
49
49
|
this.retryConfig = retryConfig;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Refuse to pay over cleartext.
|
|
53
|
+
*
|
|
54
|
+
* An x402 payment authorization is a bearer credential: anyone who observes
|
|
55
|
+
* it can submit it. Sending one over `http:` hands it to the network, so a
|
|
56
|
+
* misconfigured service URL must fail loudly rather than quietly leak.
|
|
57
|
+
*/
|
|
58
|
+
assertSecureForPayment() {
|
|
59
|
+
if (!this.baseURL.startsWith('https:') && !isLoopback(this.baseURL)) {
|
|
60
|
+
throw new Error(`Refusing to send an x402 payment over a non-HTTPS URL: ${this.baseURL}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
51
63
|
async get({ endpoint, signal, allowedStatuses = [200, 202], headers, x402Options, }) {
|
|
52
64
|
if (x402Options !== undefined) {
|
|
65
|
+
this.assertSecureForPayment();
|
|
53
66
|
const maxMUSDCAmount = x402Options.maxMUSDCAmount !== undefined
|
|
54
67
|
? BigInt(x402Options.maxMUSDCAmount.toString())
|
|
55
68
|
: undefined;
|
|
@@ -158,6 +171,7 @@ export class TurboHTTPService {
|
|
|
158
171
|
endpoint,
|
|
159
172
|
x402Options,
|
|
160
173
|
});
|
|
174
|
+
this.assertSecureForPayment();
|
|
161
175
|
const { body, duplex } = await toFetchBody(data);
|
|
162
176
|
return this.tryRequest(async () => {
|
|
163
177
|
const maxMUSDCAmount = x402Options.maxMUSDCAmount !== undefined
|
|
@@ -208,3 +222,13 @@ function isFirefoxOrSafari() {
|
|
|
208
222
|
!ua.includes('Chrome') &&
|
|
209
223
|
!ua.includes('Chromium')));
|
|
210
224
|
}
|
|
225
|
+
/** Loopback is exempt: local development never leaves the machine. */
|
|
226
|
+
function isLoopback(url) {
|
|
227
|
+
try {
|
|
228
|
+
const { hostname } = new URL(url);
|
|
229
|
+
return (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1');
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -653,13 +653,20 @@ export class TurboAuthenticatedPaymentService extends TurboUnauthenticatedPaymen
|
|
|
653
653
|
* The owner needs a Solana key to sign with, NOT a funded one — Turbo pays
|
|
654
654
|
* every lamport of fee and rent.
|
|
655
655
|
*/
|
|
656
|
-
async buyArNSName({ name, owner, type = 'lease', years, paidBy, onNonce, }) {
|
|
656
|
+
async buyArNSName({ name, owner, type = 'lease', years, paidBy, onNonce, antState, }) {
|
|
657
657
|
return this.completeArNSAction('buy-name', {
|
|
658
658
|
name,
|
|
659
659
|
ownerAddress: await owner.getAddress(),
|
|
660
660
|
type,
|
|
661
661
|
...(years !== undefined ? { years } : {}),
|
|
662
662
|
...(paidBy !== undefined ? { paidBy } : {}),
|
|
663
|
+
/*
|
|
664
|
+
Spread conditionally, like `paidBy` — an `antState: undefined` key
|
|
665
|
+
would serialize into the body and is not the same request as omitting
|
|
666
|
+
it. Sent whole in the BODY rather than the query string: it is an
|
|
667
|
+
object, and `createArNSAction` already posts every param as JSON.
|
|
668
|
+
*/
|
|
669
|
+
...(antState !== undefined ? { antState } : {}),
|
|
663
670
|
}, owner, { onNonce });
|
|
664
671
|
}
|
|
665
672
|
/** Extend a lease. Permissionless on chain — no owner signature needed. */
|